baa-conductor

git clone 

commit
57958a9
parent
c42fb56
author
im_wower
date
2026-03-30 03:20:41 +0800 CST
feat: open browser chatgpt and gemini baa targets
5 files changed,  +888, -60
M apps/conductor-daemon/src/index.test.js
+405, -37
  1@@ -744,33 +744,35 @@ test("BAA instruction normalization keeps auditable fields and stable dedupe key
  2   });
  3 });
  4 
  5-test("routeBaaInstruction maps browser.claude send/current to the existing local browser routes", () => {
  6-  const sendRoute = routeBaaInstruction(createInstructionEnvelope({
  7-    params: "hello claude from baa",
  8-    target: "browser.claude",
  9-    tool: "send"
 10-  }));
 11-  assert.deepEqual(sendRoute, {
 12-    body: {
 13-      prompt: "hello claude from baa"
 14-    },
 15-    key: "local.browser.claude.send",
 16-    method: "POST",
 17-    path: "/v1/browser/claude/send",
 18-    requiresSharedToken: false
 19-  });
 20+test("routeBaaInstruction maps browser send/current targets to the existing local browser routes", () => {
 21+  for (const platform of ["claude", "chatgpt", "gemini"]) {
 22+    const sendRoute = routeBaaInstruction(createInstructionEnvelope({
 23+      params: `hello ${platform} from baa`,
 24+      target: `browser.${platform}`,
 25+      tool: "send"
 26+    }));
 27+    assert.deepEqual(sendRoute, {
 28+      body: {
 29+        prompt: `hello ${platform} from baa`
 30+      },
 31+      key: `local.browser.${platform}.send`,
 32+      method: "POST",
 33+      path: `/v1/browser/${platform}/send`,
 34+      requiresSharedToken: false
 35+    });
 36 
 37-  const currentRoute = routeBaaInstruction(createInstructionEnvelope({
 38-    target: "browser.claude",
 39-    tool: "current"
 40-  }));
 41-  assert.deepEqual(currentRoute, {
 42-    body: null,
 43-    key: "local.browser.claude.current",
 44-    method: "GET",
 45-    path: "/v1/browser/claude/current",
 46-    requiresSharedToken: false
 47-  });
 48+    const currentRoute = routeBaaInstruction(createInstructionEnvelope({
 49+      target: `browser.${platform}`,
 50+      tool: "current"
 51+    }));
 52+    assert.deepEqual(currentRoute, {
 53+      body: null,
 54+      key: `local.browser.${platform}.current`,
 55+      method: "GET",
 56+      path: `/v1/browser/${platform}/current`,
 57+      requiresSharedToken: false
 58+    });
 59+  }
 60 });
 61 
 62 test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed messages", async () => {
 63@@ -944,6 +946,178 @@ test("BaaInstructionCenter executes browser.claude send/current through the Phas
 64   }
 65 });
 66 
 67+test("BaaInstructionCenter executes browser.chatgpt and browser.gemini send/current through the Phase 1 route layer", async () => {
 68+  const { controlPlane, repository, snapshot } = await createLocalApiFixture();
 69+  const browser = createBrowserBridgeStub();
 70+  const browserState = browser.context.browserStateLoader();
 71+
 72+  browserState.clients[0].request_hooks.push(
 73+    {
 74+      account: "ops@example.com",
 75+      credential_fingerprint: "fp-chatgpt-stub",
 76+      platform: "chatgpt",
 77+      endpoint_count: 1,
 78+      endpoint_metadata: [
 79+        {
 80+          method: "POST",
 81+          path: "/backend-api/conversation",
 82+          first_seen_at: 1710000002600,
 83+          last_seen_at: 1710000003600
 84+        }
 85+      ],
 86+      endpoints: [
 87+        "POST /backend-api/conversation"
 88+      ],
 89+      last_verified_at: 1710000003650,
 90+      updated_at: 1710000003550
 91+    },
 92+    {
 93+      account: "ops@example.com",
 94+      credential_fingerprint: "fp-gemini-stub",
 95+      platform: "gemini",
 96+      endpoint_count: 1,
 97+      endpoint_metadata: [
 98+        {
 99+          method: "POST",
100+          path: "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate",
101+          first_seen_at: 1710000002700,
102+          last_seen_at: 1710000003700
103+        }
104+      ],
105+      endpoints: [
106+        "POST /_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
107+      ],
108+      last_verified_at: 1710000003750,
109+      updated_at: 1710000003650
110+    }
111+  );
112+  browserState.clients[0].shell_runtime.push(
113+    buildShellRuntime("chatgpt", {
114+      actual: {
115+        ...buildShellRuntime("chatgpt").actual,
116+        url: "https://chatgpt.com/c/conv-chatgpt-current"
117+      }
118+    }),
119+    buildShellRuntime("gemini", {
120+      actual: {
121+        ...buildShellRuntime("gemini").actual,
122+        url: "https://gemini.google.com/app/conv-gemini-current"
123+      }
124+    })
125+  );
126+  browserState.clients[0].final_messages.push(
127+    {
128+      conversation_id: "conv-chatgpt-current",
129+      observed_at: 1710000003800,
130+      page_title: "ChatGPT Current",
131+      page_url: "https://chatgpt.com/c/conv-chatgpt-current",
132+      platform: "chatgpt",
133+      raw_text: "hello from chatgpt current"
134+    },
135+    {
136+      conversation_id: "conv-gemini-current",
137+      observed_at: 1710000003900,
138+      page_title: "Gemini Current",
139+      page_url: "https://gemini.google.com/app/conv-gemini-current",
140+      platform: "gemini",
141+      raw_text: "hello from gemini current"
142+    }
143+  );
144+
145+  const center = new BaaInstructionCenter({
146+    localApiContext: {
147+      ...browser.context,
148+      browserStateLoader: () => browserState,
149+      fetchImpl: globalThis.fetch,
150+      repository,
151+      snapshotLoader: () => snapshot
152+    }
153+  });
154+  const message = [
155+    "```baa",
156+    "@browser.chatgpt::send::hello chatgpt",
157+    "```",
158+    "",
159+    "```baa",
160+    "@browser.chatgpt::current",
161+    "```",
162+    "",
163+    "```baa",
164+    "@browser.gemini::send::hello gemini",
165+    "```",
166+    "",
167+    "```baa",
168+    "@browser.gemini::current",
169+    "```"
170+  ].join("\n");
171+
172+  try {
173+    const result = await center.processAssistantMessage({
174+      assistantMessageId: "msg-browser-compat-1",
175+      conversationId: "conv-browser-compat-1",
176+      platform: "claude",
177+      text: message
178+    });
179+
180+    assert.equal(result.status, "executed");
181+    assert.equal(result.denied.length, 0);
182+    assert.equal(result.executions.length, 4);
183+
184+    const chatgptSend = result.executions.find((execution) => execution.target === "browser.chatgpt" && execution.tool === "send");
185+    assert.ok(chatgptSend);
186+    assert.equal(chatgptSend.ok, true);
187+    assert.equal(chatgptSend.route.path, "/v1/browser/chatgpt/send");
188+    assert.equal(chatgptSend.data.proxy.path, "/backend-api/conversation");
189+
190+    const chatgptCurrent = result.executions.find((execution) => execution.target === "browser.chatgpt" && execution.tool === "current");
191+    assert.ok(chatgptCurrent);
192+    assert.equal(chatgptCurrent.ok, true);
193+    assert.equal(chatgptCurrent.route.path, "/v1/browser/chatgpt/current");
194+    assert.equal(chatgptCurrent.data.conversation.conversation_id, "conv-chatgpt-current");
195+    assert.equal(chatgptCurrent.data.messages[0].text, "hello from chatgpt current");
196+    assert.equal(chatgptCurrent.data.proxy.path, "/backend-api/conversation/conv-chatgpt-current");
197+
198+    const geminiSend = result.executions.find((execution) => execution.target === "browser.gemini" && execution.tool === "send");
199+    assert.ok(geminiSend);
200+    assert.equal(geminiSend.ok, true);
201+    assert.equal(geminiSend.route.path, "/v1/browser/gemini/send");
202+    assert.equal(
203+      geminiSend.data.proxy.path,
204+      "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
205+    );
206+
207+    const geminiCurrent = result.executions.find((execution) => execution.target === "browser.gemini" && execution.tool === "current");
208+    assert.ok(geminiCurrent);
209+    assert.equal(geminiCurrent.ok, true);
210+    assert.equal(geminiCurrent.route.path, "/v1/browser/gemini/current");
211+    assert.equal(geminiCurrent.data.conversation.conversation_id, "conv-gemini-current");
212+    assert.equal(geminiCurrent.data.messages[0].text, "hello from gemini current");
213+    assert.equal(geminiCurrent.data.proxy, null);
214+
215+    assert.ok(
216+      browser.calls.find(
217+        (call) => call.kind === "apiRequest" && call.path === "/backend-api/conversation"
218+      )
219+    );
220+    assert.ok(
221+      browser.calls.find(
222+        (call) =>
223+          call.kind === "apiRequest"
224+          && call.path === "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
225+      )
226+    );
227+    assert.ok(
228+      browser.calls.find(
229+        (call) =>
230+          call.kind === "apiRequest"
231+          && call.path === "/backend-api/conversation/conv-chatgpt-current"
232+      )
233+    );
234+  } finally {
235+    controlPlane.close();
236+  }
237+});
238+
239 test("BaaInstructionCenter keeps supported instructions running when one instruction is denied", async () => {
240   const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
241   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-partial-deny-"));
242@@ -962,7 +1136,7 @@ test("BaaInstructionCenter keeps supported instructions running when one instruc
243     "```",
244     "",
245     "```baa",
246-    "@browser.chatgpt::send::draw a cat",
247+    "@browser.gemini::reload",
248     "```",
249     "",
250     "```baa",
251@@ -996,9 +1170,9 @@ test("BaaInstructionCenter keeps supported instructions running when one instruc
252 
253     assert.equal(result.denied[0].blockIndex, 1);
254     assert.equal(result.denied[0].stage, "policy");
255-    assert.equal(result.denied[0].code, "unsupported_target");
256-    assert.equal(result.denied[0].instruction.target, "browser.chatgpt");
257-    assert.equal(result.denied[0].instruction.tool, "send");
258+    assert.equal(result.denied[0].code, "unsupported_tool");
259+    assert.equal(result.denied[0].instruction.target, "browser.gemini");
260+    assert.equal(result.denied[0].instruction.tool, "reload");
261     assert.match(result.denied[0].reason, /not supported in Phase 1/i);
262   } finally {
263     controlPlane.close();
264@@ -1021,11 +1195,11 @@ test("BaaInstructionCenter returns denied_only when every pending instruction is
265   });
266   const message = [
267     "```baa",
268-    "@browser.chatgpt::send::draw a cat",
269+    "@browser.chatgpt::reload",
270     "```",
271     "",
272     "```baa",
273-    "@browser.claude::reload",
274+    "@browser.gemini::reload",
275     "```"
276   ].join("\n");
277 
278@@ -1048,13 +1222,13 @@ test("BaaInstructionCenter returns denied_only when every pending instruction is
279       })),
280       [
281         {
282-          code: "unsupported_target",
283+          code: "unsupported_tool",
284           target: "browser.chatgpt",
285-          tool: "send"
286+          tool: "reload"
287         },
288         {
289           code: "unsupported_tool",
290-          target: "browser.claude",
291+          target: "browser.gemini",
292           tool: "reload"
293         }
294       ]
295@@ -1170,7 +1344,7 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
296       source: "browser.final_message",
297       text: [
298         "```baa",
299-        "@browser.chatgpt::send::still denied",
300+        "@browser.chatgpt::reload",
301         "```"
302       ].join("\n")
303     });
304@@ -1183,7 +1357,7 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
305     assert.equal(deniedOnly.processResult.executions.length, 0);
306     assert.equal(deniedOnly.processResult.denied.length, 1);
307     assert.equal(deniedOnly.processResult.denied[0].stage, "policy");
308-    assert.equal(deniedOnly.processResult.denied[0].code, "unsupported_target");
309+    assert.equal(deniedOnly.processResult.denied[0].code, "unsupported_tool");
310 
311     assert.equal(ingest.getSnapshot().last_ingest?.assistant_message_id, "msg-live-denied-only");
312     assert.equal(ingest.getSnapshot().last_execute?.status, "denied_only");
313@@ -1490,6 +1664,7 @@ function createBrowserBridgeStub() {
314             last_seen_at: 1710000001100
315           }
316         ],
317+        final_messages: [],
318         last_action_result: {
319           accepted: true,
320           action: "plugin_status",
321@@ -1701,6 +1876,30 @@ function createBrowserBridgeStub() {
322             });
323           }
324 
325+          if (input.path === "/backend-api/conversation") {
326+            return buildApiResponse({
327+              id: input.id,
328+              body: {
329+                conversation_id: "conv-chatgpt-send",
330+                message: {
331+                  id: "msg-chatgpt-send"
332+                },
333+                accepted: true
334+              },
335+              status: 202
336+            });
337+          }
338+
339+          if (input.path === "/backend-api/conversation/conv-chatgpt-current") {
340+            return buildApiResponse({
341+              id: input.id,
342+              body: {
343+                conversation_id: "conv-chatgpt-current",
344+                title: "Current ChatGPT Chat"
345+              }
346+            });
347+          }
348+
349           if (input.path === "/backend-api/models") {
350             return buildApiResponse({
351               id: input.id,
352@@ -1714,6 +1913,20 @@ function createBrowserBridgeStub() {
353             });
354           }
355 
356+          if (
357+            input.path
358+            === "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
359+          ) {
360+            return buildApiResponse({
361+              id: input.id,
362+              body: {
363+                conversation_id: "conv-gemini-current",
364+                accepted: true
365+              },
366+              status: 202
367+            });
368+          }
369+
370           throw new Error(`unexpected browser proxy path: ${input.path}`);
371         },
372         cancelApiRequest(input = {}) {
373@@ -2702,8 +2915,82 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
374     authorization: `Bearer ${sharedToken}`
375   };
376   snapshot.codexd.localApiBase = codexd.baseUrl;
377+  const browserState = browser.context.browserStateLoader();
378+  browserState.clients[0].request_hooks.push(
379+    {
380+      account: "ops@example.com",
381+      credential_fingerprint: "fp-chatgpt-stub",
382+      platform: "chatgpt",
383+      endpoint_count: 1,
384+      endpoint_metadata: [
385+        {
386+          method: "POST",
387+          path: "/backend-api/conversation",
388+          first_seen_at: 1710000002600,
389+          last_seen_at: 1710000003600
390+        }
391+      ],
392+      endpoints: [
393+        "POST /backend-api/conversation"
394+      ],
395+      last_verified_at: 1710000003650,
396+      updated_at: 1710000003550
397+    },
398+    {
399+      account: "ops@example.com",
400+      credential_fingerprint: "fp-gemini-stub",
401+      platform: "gemini",
402+      endpoint_count: 1,
403+      endpoint_metadata: [
404+        {
405+          method: "POST",
406+          path: "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate",
407+          first_seen_at: 1710000002700,
408+          last_seen_at: 1710000003700
409+        }
410+      ],
411+      endpoints: [
412+        "POST /_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
413+      ],
414+      last_verified_at: 1710000003750,
415+      updated_at: 1710000003650
416+    }
417+  );
418+  browserState.clients[0].shell_runtime.push(
419+    buildShellRuntime("chatgpt", {
420+      actual: {
421+        ...buildShellRuntime("chatgpt").actual,
422+        url: "https://chatgpt.com/c/conv-chatgpt-current"
423+      }
424+    }),
425+    buildShellRuntime("gemini", {
426+      actual: {
427+        ...buildShellRuntime("gemini").actual,
428+        url: "https://gemini.google.com/app/conv-gemini-current"
429+      }
430+    })
431+  );
432+  browserState.clients[0].final_messages.push(
433+    {
434+      conversation_id: "conv-chatgpt-current",
435+      observed_at: 1710000003800,
436+      page_title: "ChatGPT Current",
437+      page_url: "https://chatgpt.com/c/conv-chatgpt-current",
438+      platform: "chatgpt",
439+      raw_text: "hello from chatgpt current"
440+    },
441+    {
442+      conversation_id: "conv-gemini-current",
443+      observed_at: 1710000003900,
444+      page_title: "Gemini Current",
445+      page_url: "https://gemini.google.com/app/conv-gemini-current",
446+      platform: "gemini",
447+      raw_text: "hello from gemini current"
448+    }
449+  );
450   const localApiContext = {
451     ...browser.context,
452+    browserStateLoader: () => browserState,
453     codexdLocalApiBase: codexd.baseUrl,
454     fetchImpl: globalThis.fetch,
455     repository,
456@@ -2764,6 +3051,20 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
457     );
458     assert.equal(legacyClaudeOpen.lifecycle, "legacy");
459     assert.equal(legacyClaudeOpen.legacy_replacement_path, "/v1/browser/actions");
460+    assert.deepEqual(
461+      describePayload.data.browser.legacy_helper_platforms,
462+      ["claude", "chatgpt", "gemini"]
463+    );
464+    assert.ok(
465+      describePayload.data.browser.legacy_routes.find(
466+        (route) => route.path === "/v1/browser/chatgpt/send"
467+      )
468+    );
469+    assert.ok(
470+      describePayload.data.browser.legacy_routes.find(
471+        (route) => route.path === "/v1/browser/gemini/current"
472+      )
473+    );
474     assert.doesNotMatch(JSON.stringify(describePayload.data.codex.routes), /\/v1\/codex\/runs/u);
475     assert.doesNotMatch(JSON.stringify(describePayload.data.capabilities.read_endpoints), /\/v1\/runs/u);
476 
477@@ -2782,6 +3083,10 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
478     assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/request/u);
479     assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/request\/cancel/u);
480     assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/claude\/current/u);
481+    assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/chatgpt\/send/u);
482+    assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/chatgpt\/current/u);
483+    assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/gemini\/send/u);
484+    assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/gemini\/current/u);
485     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/actions/u);
486     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/claude\/open/u);
487     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
488@@ -3023,6 +3328,66 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
489     assert.equal(chatgptBufferedPayload.data.response.models[0].slug, "gpt-5.4");
490     assert.equal(chatgptBufferedPayload.data.policy.platform, "chatgpt");
491 
492+    const chatgptLegacySendResponse = await handleConductorHttpRequest(
493+      {
494+        body: JSON.stringify({
495+          prompt: "legacy helper chatgpt"
496+        }),
497+        method: "POST",
498+        path: "/v1/browser/chatgpt/send"
499+      },
500+      localApiContext
501+    );
502+    assert.equal(chatgptLegacySendResponse.status, 200);
503+    const chatgptLegacySendPayload = parseJsonBody(chatgptLegacySendResponse);
504+    assert.equal(chatgptLegacySendPayload.data.platform, "chatgpt");
505+    assert.equal(chatgptLegacySendPayload.data.proxy.path, "/backend-api/conversation");
506+
507+    const chatgptLegacyCurrentResponse = await handleConductorHttpRequest(
508+      {
509+        method: "GET",
510+        path: "/v1/browser/chatgpt/current"
511+      },
512+      localApiContext
513+    );
514+    assert.equal(chatgptLegacyCurrentResponse.status, 200);
515+    const chatgptLegacyCurrentPayload = parseJsonBody(chatgptLegacyCurrentResponse);
516+    assert.equal(chatgptLegacyCurrentPayload.data.conversation.conversation_id, "conv-chatgpt-current");
517+    assert.equal(
518+      chatgptLegacyCurrentPayload.data.proxy.path,
519+      "/backend-api/conversation/conv-chatgpt-current"
520+    );
521+
522+    const geminiLegacySendResponse = await handleConductorHttpRequest(
523+      {
524+        body: JSON.stringify({
525+          prompt: "legacy helper gemini"
526+        }),
527+        method: "POST",
528+        path: "/v1/browser/gemini/send"
529+      },
530+      localApiContext
531+    );
532+    assert.equal(geminiLegacySendResponse.status, 200);
533+    const geminiLegacySendPayload = parseJsonBody(geminiLegacySendResponse);
534+    assert.equal(geminiLegacySendPayload.data.platform, "gemini");
535+    assert.equal(
536+      geminiLegacySendPayload.data.proxy.path,
537+      "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
538+    );
539+
540+    const geminiLegacyCurrentResponse = await handleConductorHttpRequest(
541+      {
542+        method: "GET",
543+        path: "/v1/browser/gemini/current"
544+      },
545+      localApiContext
546+    );
547+    assert.equal(geminiLegacyCurrentResponse.status, 200);
548+    const geminiLegacyCurrentPayload = parseJsonBody(geminiLegacyCurrentResponse);
549+    assert.equal(geminiLegacyCurrentPayload.data.conversation.conversation_id, "conv-gemini-current");
550+    assert.equal(geminiLegacyCurrentPayload.data.proxy, null);
551+
552     const browserStreamResponse = await handleConductorHttpRequest(
553       {
554         body: JSON.stringify({
555@@ -3465,6 +3830,9 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
556       "apiRequest:GET:/api/stream-buffered-smoke",
557       "apiRequest:GET:/backend-api/conversation-buffered-smoke",
558       "apiRequest:GET:/backend-api/models",
559+      "apiRequest:POST:/backend-api/conversation",
560+      "apiRequest:GET:/backend-api/conversation/conv-chatgpt-current",
561+      "apiRequest:POST:/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate",
562       "apiRequest:GET:/api/organizations",
563       "apiRequest:GET:/api/organizations/org-1/chat_conversations",
564       "streamRequest:claude",
M apps/conductor-daemon/src/instructions/policy.ts
+4, -2
 1@@ -9,14 +9,16 @@ const CONDUCTOR_TOOLS = new Set([
 2   "files/write",
 3   "status"
 4 ]);
 5-const BROWSER_CLAUDE_TOOLS = new Set([
 6+const BROWSER_LEGACY_TARGET_TOOLS = new Set([
 7   "current",
 8   "send"
 9 ]);
10 const SUPPORTED_TARGET_TOOLS = new Map([
11   ["conductor", CONDUCTOR_TOOLS],
12   ["system", CONDUCTOR_TOOLS],
13-  ["browser.claude", BROWSER_CLAUDE_TOOLS]
14+  ["browser.claude", BROWSER_LEGACY_TARGET_TOOLS],
15+  ["browser.chatgpt", BROWSER_LEGACY_TARGET_TOOLS],
16+  ["browser.gemini", BROWSER_LEGACY_TARGET_TOOLS]
17 ]);
18 
19 export interface BaaInstructionPolicyDecision {
M apps/conductor-daemon/src/instructions/router.ts
+15, -8
 1@@ -128,7 +128,7 @@ function normalizeFileWriteBody(instruction: BaaInstructionEnvelope): BaaJsonObj
 2   return params;
 3 }
 4 
 5-function normalizeBrowserClaudeSendBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
 6+function normalizeBrowserSendBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
 7   if (typeof instruction.params === "string") {
 8     return {
 9       prompt: requireNonEmptyStringParam(instruction, "prompt", true)
10@@ -228,23 +228,26 @@ function routeLocalInstruction(instruction: BaaInstructionEnvelope): BaaInstruct
11   }
12 }
13 
14-function routeBrowserClaudeInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
15+function routeBrowserInstruction(
16+  instruction: BaaInstructionEnvelope,
17+  platform: "claude" | "chatgpt" | "gemini"
18+): BaaInstructionRoute {
19   switch (instruction.tool) {
20     case "send":
21       return {
22-        body: normalizeBrowserClaudeSendBody(instruction),
23-        key: "local.browser.claude.send",
24+        body: normalizeBrowserSendBody(instruction),
25+        key: `local.browser.${platform}.send`,
26         method: "POST",
27-        path: "/v1/browser/claude/send",
28+        path: `/v1/browser/${platform}/send`,
29         requiresSharedToken: false
30       };
31     case "current":
32       requireNoParams(instruction);
33       return {
34         body: null,
35-        key: "local.browser.claude.current",
36+        key: `local.browser.${platform}.current`,
37         method: "GET",
38-        path: "/v1/browser/claude/current",
39+        path: `/v1/browser/${platform}/current`,
40         requiresSharedToken: false
41       };
42     default:
43@@ -261,7 +264,11 @@ export function routeBaaInstruction(instruction: BaaInstructionEnvelope): BaaIns
44     case "system":
45       return routeLocalInstruction(instruction);
46     case "browser.claude":
47-      return routeBrowserClaudeInstruction(instruction);
48+      return routeBrowserInstruction(instruction, "claude");
49+    case "browser.chatgpt":
50+      return routeBrowserInstruction(instruction, "chatgpt");
51+    case "browser.gemini":
52+      return routeBrowserInstruction(instruction, "gemini");
53     default:
54       throw new BaaInstructionRouteError(
55         instruction.blockIndex,
M apps/conductor-daemon/src/local-api.ts
+452, -6
  1@@ -57,6 +57,7 @@ import type {
  2   BrowserBridgeClientSnapshot,
  3   BrowserBridgeController,
  4   BrowserBridgeCredentialSnapshot,
  5+  BrowserBridgeFinalMessageSnapshot,
  6   BrowserBridgeRequestHookSnapshot,
  7   BrowserBridgeShellRuntimeSnapshot,
  8   BrowserBridgeStreamEvent,
  9@@ -167,6 +168,13 @@ const BROWSER_CLAUDE_ORGANIZATIONS_PATH = "/api/organizations";
 10 const BROWSER_CLAUDE_CONVERSATIONS_PATH = "/api/organizations/{id}/chat_conversations";
 11 const BROWSER_CLAUDE_CONVERSATION_PATH = "/api/organizations/{id}/chat_conversations/{id}";
 12 const BROWSER_CLAUDE_COMPLETION_PATH = "/api/organizations/{id}/chat_conversations/{id}/completion";
 13+const BROWSER_CHATGPT_PLATFORM = "chatgpt";
 14+const BROWSER_CHATGPT_ROOT_URL = "https://chatgpt.com/";
 15+const BROWSER_CHATGPT_CONVERSATION_PATH = "/backend-api/conversation";
 16+const BROWSER_GEMINI_PLATFORM = "gemini";
 17+const BROWSER_GEMINI_ROOT_URL = "https://gemini.google.com/";
 18+const BROWSER_GEMINI_STREAM_GENERATE_PATH =
 19+  "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
 20 const SUPPORTED_BROWSER_ACTIONS = [
 21   "controller_reload",
 22   "plugin_status",
 23@@ -552,6 +560,40 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 24     pathPattern: "/v1/browser/claude/current",
 25     summary: "legacy 辅助读:读取当前 Claude 对话内容与页面代理状态"
 26   },
 27+  {
 28+    id: "browser.chatgpt.send",
 29+    kind: "write",
 30+    legacyReplacementPath: "/v1/browser/request",
 31+    lifecycle: "legacy",
 32+    method: "POST",
 33+    pathPattern: "/v1/browser/chatgpt/send",
 34+    summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 ChatGPT 对话"
 35+  },
 36+  {
 37+    id: "browser.chatgpt.current",
 38+    kind: "read",
 39+    lifecycle: "legacy",
 40+    method: "GET",
 41+    pathPattern: "/v1/browser/chatgpt/current",
 42+    summary: "legacy 辅助读:读取当前 ChatGPT 对话状态与页面代理信息"
 43+  },
 44+  {
 45+    id: "browser.gemini.send",
 46+    kind: "write",
 47+    legacyReplacementPath: "/v1/browser/request",
 48+    lifecycle: "legacy",
 49+    method: "POST",
 50+    pathPattern: "/v1/browser/gemini/send",
 51+    summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 Gemini 对话"
 52+  },
 53+  {
 54+    id: "browser.gemini.current",
 55+    kind: "read",
 56+    lifecycle: "legacy",
 57+    method: "GET",
 58+    pathPattern: "/v1/browser/gemini/current",
 59+    summary: "legacy 辅助读:读取当前 Gemini 对话状态与页面代理信息"
 60+  },
 61   {
 62     id: "browser.claude.reload",
 63     kind: "write",
 64@@ -2532,6 +2574,154 @@ function serializeBrowserShellRuntimeSnapshot(snapshot: BrowserBridgeShellRuntim
 65   });
 66 }
 67 
 68+function serializeBrowserFinalMessageSnapshot(snapshot: BrowserBridgeFinalMessageSnapshot): JsonObject {
 69+  return compactJsonObject({
 70+    conversation_id: snapshot.conversation_id ?? undefined,
 71+    observed_at: snapshot.observed_at,
 72+    organization_id: snapshot.organization_id ?? undefined,
 73+    page_title: snapshot.page_title ?? undefined,
 74+    page_url: snapshot.page_url ?? undefined,
 75+    platform: snapshot.platform,
 76+    raw_text: snapshot.raw_text,
 77+    shell_page: snapshot.shell_page ?? undefined,
 78+    tab_id: snapshot.tab_id ?? undefined
 79+  });
 80+}
 81+
 82+function extractChatgptConversationIdFromPageUrl(url: string | null | undefined): string | null {
 83+  const normalizedUrl = normalizeOptionalString(url);
 84+
 85+  if (normalizedUrl == null) {
 86+    return null;
 87+  }
 88+
 89+  try {
 90+    const parsed = new URL(normalizedUrl, BROWSER_CHATGPT_ROOT_URL);
 91+    const pathname = parsed.pathname || "/";
 92+    const match = pathname.match(/\/c\/([^/?#]+)/u);
 93+
 94+    if (match?.[1]) {
 95+      return match[1];
 96+    }
 97+
 98+    return normalizeOptionalString(parsed.searchParams.get("conversation_id"));
 99+  } catch {
100+    return null;
101+  }
102+}
103+
104+function extractGeminiConversationIdFromPageUrl(url: string | null | undefined): string | null {
105+  const normalizedUrl = normalizeOptionalString(url);
106+
107+  if (normalizedUrl == null) {
108+    return null;
109+  }
110+
111+  try {
112+    const parsed = new URL(normalizedUrl, BROWSER_GEMINI_ROOT_URL);
113+    const pathname = parsed.pathname || "/";
114+    const match = pathname.match(/\/app\/([^/?#]+)/u);
115+
116+    if (match?.[1]) {
117+      return match[1];
118+    }
119+
120+    return normalizeOptionalString(parsed.searchParams.get("conversation_id"));
121+  } catch {
122+    return null;
123+  }
124+}
125+
126+function buildChatgptConversationPath(conversationId: string): string {
127+  return `${BROWSER_CHATGPT_CONVERSATION_PATH}/${encodeURIComponent(conversationId)}`;
128+}
129+
130+function findBrowserCredentialForPlatform(
131+  client: BrowserBridgeClientSnapshot,
132+  platform: string
133+): BrowserBridgeCredentialSnapshot | null {
134+  return client.credentials.find((entry) => entry.platform === platform) ?? null;
135+}
136+
137+function findLatestBrowserFinalMessage(
138+  client: BrowserBridgeClientSnapshot,
139+  platform: string,
140+  conversationId?: string | null
141+): BrowserBridgeFinalMessageSnapshot | null {
142+  const normalizedConversationId = normalizeOptionalString(conversationId);
143+  const finalMessages = Array.isArray(client.final_messages) ? client.final_messages : [];
144+
145+  if (normalizedConversationId != null) {
146+    const directMatch =
147+      finalMessages
148+        .filter((entry) => entry.platform === platform && entry.conversation_id === normalizedConversationId)
149+        .sort((left, right) => right.observed_at - left.observed_at)[0]
150+      ?? null;
151+
152+    if (directMatch != null) {
153+      return directMatch;
154+    }
155+  }
156+
157+  return (
158+    finalMessages
159+      .filter((entry) => entry.platform === platform)
160+      .sort((left, right) => right.observed_at - left.observed_at)[0]
161+    ?? null
162+  );
163+}
164+
165+function resolveBrowserLegacyConversationId(
166+  platform: string,
167+  selection: {
168+    latestFinalMessage: BrowserBridgeFinalMessageSnapshot | null;
169+    shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
170+  },
171+  requestedConversationId?: string | null
172+): string | null {
173+  const normalizedRequestedConversationId = normalizeOptionalString(requestedConversationId);
174+
175+  if (normalizedRequestedConversationId != null) {
176+    return normalizedRequestedConversationId;
177+  }
178+
179+  const finalMessageConversationId = normalizeOptionalString(selection.latestFinalMessage?.conversation_id ?? null);
180+
181+  if (finalMessageConversationId != null) {
182+    return finalMessageConversationId;
183+  }
184+
185+  const actualUrl = normalizeOptionalString(selection.shellRuntime?.actual.url ?? null);
186+  const pageUrl = normalizeOptionalString(selection.latestFinalMessage?.page_url ?? null);
187+
188+  switch (platform) {
189+    case BROWSER_CHATGPT_PLATFORM:
190+      return extractChatgptConversationIdFromPageUrl(actualUrl)
191+        ?? extractChatgptConversationIdFromPageUrl(pageUrl);
192+    case BROWSER_GEMINI_PLATFORM:
193+      return extractGeminiConversationIdFromPageUrl(actualUrl)
194+        ?? extractGeminiConversationIdFromPageUrl(pageUrl);
195+    default:
196+      return null;
197+  }
198+}
199+
200+function buildBrowserLegacyCurrentMessages(
201+  finalMessage: BrowserBridgeFinalMessageSnapshot | null
202+): JsonObject[] {
203+  if (finalMessage == null) {
204+    return [];
205+  }
206+
207+  return [
208+    compactJsonObject({
209+      observed_at: finalMessage.observed_at,
210+      role: "assistant",
211+      text: finalMessage.raw_text
212+    })
213+  ];
214+}
215+
216 function serializeBrowserActionResultItemSnapshot(
217   snapshot: BrowserBridgeActionResultItemSnapshot
218 ): JsonObject {
219@@ -3755,6 +3945,10 @@ function buildBrowserLegacyRouteData(): JsonObject[] {
220     describeRoute(requireRouteDefinition("browser.claude.open")),
221     describeRoute(requireRouteDefinition("browser.claude.send")),
222     describeRoute(requireRouteDefinition("browser.claude.current")),
223+    describeRoute(requireRouteDefinition("browser.chatgpt.send")),
224+    describeRoute(requireRouteDefinition("browser.chatgpt.current")),
225+    describeRoute(requireRouteDefinition("browser.gemini.send")),
226+    describeRoute(requireRouteDefinition("browser.gemini.current")),
227     describeRoute(requireRouteDefinition("browser.claude.reload"))
228   ];
229 }
230@@ -3763,6 +3957,11 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
231   return {
232     enabled: snapshot.controlApi.firefoxWsUrl != null,
233     legacy_helper_platform: BROWSER_CLAUDE_PLATFORM,
234+    legacy_helper_platforms: [
235+      BROWSER_CLAUDE_PLATFORM,
236+      BROWSER_CHATGPT_PLATFORM,
237+      BROWSER_GEMINI_PLATFORM
238+    ],
239     platform: BROWSER_CLAUDE_PLATFORM,
240     supported_action_platforms: [...FORMAL_BROWSER_SHELL_PLATFORMS],
241     supported_request_platforms: [...FORMAL_BROWSER_REQUEST_PLATFORMS],
242@@ -3786,9 +3985,10 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
243       "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
244       "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT raw relay, and expects a local Firefox bridge client.",
245       "Claude keeps the prompt shortcut when path is omitted; ChatGPT currently requires an explicit path and a real browser login context captured on the selected client.",
246+      "The legacy helper surface now also exposes /v1/browser/chatgpt/* and /v1/browser/gemini/* wrappers for BAA target compatibility.",
247       "POST /v1/browser/actions now waits for the plugin to return a structured action_result instead of returning only a dispatch ack.",
248       "POST /v1/browser/request now supports buffered JSON and formal SSE event envelopes; POST /v1/browser/request/cancel cancels an in-flight browser request by requestId.",
249-      "The /v1/browser/claude/* routes remain available as legacy wrappers during the migration window."
250+      "The /v1/browser/{claude,chatgpt,gemini}/* routes remain available as legacy wrappers during the migration window."
251     ]
252   };
253 }
254@@ -4038,6 +4238,10 @@ function routeBelongsToSurface(
255       "browser.request.cancel",
256       "browser.claude.send",
257       "browser.claude.current",
258+      "browser.chatgpt.send",
259+      "browser.chatgpt.current",
260+      "browser.gemini.send",
261+      "browser.gemini.current",
262       "codex.status",
263       "codex.sessions.list",
264       "codex.sessions.read",
265@@ -4102,9 +4306,9 @@ function buildCapabilitiesData(
266       "GET /v1/system/state",
267       "POST /v1/browser/request for browser-mediated business requests",
268       "POST /v1/browser/actions for browser/plugin management actions",
269-      "GET /v1/browser/claude/current or /v1/tasks or /v1/codex when a legacy Claude helper read is needed",
270+      "GET /v1/browser/{claude,chatgpt,gemini}/current or /v1/tasks or /v1/codex when a legacy helper read is needed",
271       "Use /v1/codex/* for interactive Codex session and turn work",
272-      "Use /v1/browser/claude/* only as legacy compatibility wrappers",
273+      "Use /v1/browser/{claude,chatgpt,gemini}/* only as legacy compatibility wrappers",
274       "GET /describe/control if local shell/file access is needed",
275       "Use POST system routes or host operations only when a write/exec is intended"
276     ],
277@@ -4278,7 +4482,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
278       "AI callers should prefer /describe/business for business queries and /describe/control for control actions.",
279       "GET /v1/status and GET /v1/status/ui expose the narrow read-only compatibility status view; /v1/system/state remains the fuller control-oriented truth surface.",
280       "The formal /v1/browser/* surface is now split into generic GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions contracts.",
281-      "The /v1/browser/claude/* routes remain available as legacy compatibility wrappers during migration.",
282+      "The /v1/browser/{claude,chatgpt,gemini}/* routes remain available as legacy compatibility wrappers during migration.",
283       "All /v1/codex routes proxy the independent codexd daemon; this process does not host Codex sessions itself.",
284       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
285       "These routes read and mutate the mini node's local truth source directly.",
286@@ -4380,7 +4584,7 @@ async function handleScopedDescribeRead(
287         "This surface is intended to be enough for business-query discovery without reading external docs.",
288         "Use GET /v1/status for the narrow read-only compatibility snapshot and GET /v1/status/ui for the matching HTML panel.",
289         "Business-facing browser work now lands on POST /v1/browser/request; POST /v1/browser/request/cancel cancels an in-flight request by requestId.",
290-        "GET /v1/browser/claude/current and POST /v1/browser/claude/send remain available as legacy Claude helpers during migration.",
291+        "GET /v1/browser/{claude,chatgpt,gemini}/current and POST /v1/browser/{claude,chatgpt,gemini}/send remain available as legacy helper wrappers during migration.",
292         "All /v1/codex routes proxy the independent codexd daemon instead of an in-process bridge.",
293         "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
294         "Browser/plugin management actions such as tab open/reload live under /describe/control via POST /v1/browser/actions."
295@@ -4534,7 +4738,7 @@ async function handleCapabilitiesRead(
296     notes: [
297       "Read routes are safe for discovery and inspection.",
298       "The browser HTTP contract is now split into GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions.",
299-      "The generic browser request surface now formally supports Claude and ChatGPT; /v1/browser/claude/* remains available as legacy compatibility wrappers.",
300+      "The generic browser request surface now formally supports Claude and ChatGPT; /v1/browser/{claude,chatgpt,gemini}/* remains available as legacy compatibility wrappers.",
301       "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
302       "POST /v1/system/* writes the local automation mode immediately.",
303       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
304@@ -5479,6 +5683,213 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
305   });
306 }
307 
308+function buildBrowserSendSuccessPayload(execution: BrowserRequestExecutionResult): JsonObject {
309+  return compactJsonObject({
310+    client_id: execution.client_id,
311+    conversation: serializeClaudeConversationSummary(execution.conversation),
312+    organization: serializeClaudeOrganizationSummary(execution.organization),
313+    platform: execution.platform,
314+    policy: serializeBrowserRequestPolicyAdmission(execution.policy),
315+    proxy: {
316+      method: execution.request_method,
317+      path: execution.request_path,
318+      request_body: execution.request_body,
319+      request_id: execution.request_id,
320+      response_mode: execution.response_mode,
321+      status: execution.status
322+    },
323+    request_mode: execution.request_mode,
324+    response: execution.response
325+  });
326+}
327+
328+async function handleBrowserLegacySend(
329+  context: LocalApiRequestContext,
330+  input: {
331+    defaultPath: string;
332+    platform: string;
333+  }
334+): Promise<ConductorHttpResponse> {
335+  const body = readBodyObject(context.request, true);
336+  const responseMode = readBrowserRequestResponseMode(body);
337+
338+  try {
339+    const execution = await executeBrowserRequest(context, {
340+      clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
341+      conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
342+      headers:
343+        readOptionalStringMap(body, "headers")
344+        ?? readOptionalStringMap(body, "request_headers")
345+        ?? undefined,
346+      method: readOptionalStringBodyField(body, "method") ?? "POST",
347+      organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
348+      path: readOptionalStringBodyField(body, "path") ?? input.defaultPath,
349+      platform: input.platform,
350+      prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
351+      requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
352+      requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
353+      responseMode,
354+      timeoutMs: readOptionalTimeoutMs(body, context.url)
355+    });
356+
357+    if (responseMode === "sse") {
358+      return buildBrowserSseSuccessResponse(execution, context.request.signal);
359+    }
360+
361+    return buildSuccessEnvelope(context.requestId, 200, buildBrowserSendSuccessPayload(execution));
362+  } catch (error) {
363+    if (responseMode !== "sse" || !(error instanceof LocalApiHttpError)) {
364+      throw error;
365+    }
366+
367+    return buildBrowserSseErrorResponse({
368+      error: error.error,
369+      message: error.message,
370+      platform: input.platform,
371+      requestId:
372+        readUnknownString(asUnknownRecord(error.details), ["bridge_request_id"])
373+        ?? readOptionalStringBodyField(body, "requestId", "request_id", "id")
374+        ?? context.requestId,
375+      status: error.status
376+    });
377+  }
378+}
379+
380+function selectBrowserLegacyCurrentSelection(
381+  context: LocalApiRequestContext,
382+  platform: string,
383+  requestedClientId?: string | null,
384+  requestedConversationId?: string | null
385+): {
386+  client: BrowserBridgeClientSnapshot;
387+  credential: BrowserBridgeCredentialSnapshot | null;
388+  latestFinalMessage: BrowserBridgeFinalMessageSnapshot | null;
389+  requestHook: BrowserBridgeRequestHookSnapshot | null;
390+  shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
391+} {
392+  const client = ensureBrowserClientReady(
393+    selectBrowserClient(loadBrowserState(context), requestedClientId),
394+    platform,
395+    requestedClientId
396+  );
397+  const credential = findBrowserCredentialForPlatform(client, platform);
398+
399+  return {
400+    client,
401+    credential,
402+    latestFinalMessage: findLatestBrowserFinalMessage(client, platform, requestedConversationId),
403+    requestHook: findMatchingRequestHook(client, platform, credential?.account ?? null),
404+    shellRuntime: findMatchingShellRuntime(client, platform)
405+  };
406+}
407+
408+function buildBrowserLegacyCurrentPage(
409+  selection: {
410+    client: BrowserBridgeClientSnapshot;
411+    credential: BrowserBridgeCredentialSnapshot | null;
412+    latestFinalMessage: BrowserBridgeFinalMessageSnapshot | null;
413+    requestHook: BrowserBridgeRequestHookSnapshot | null;
414+    shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
415+  },
416+  conversationId: string | null
417+): JsonObject {
418+  return compactJsonObject({
419+    client_id: selection.client.client_id,
420+    conversation_id: conversationId ?? undefined,
421+    credentials:
422+      selection.credential == null
423+        ? undefined
424+        : serializeBrowserCredentialSnapshot(selection.credential),
425+    final_message:
426+      selection.latestFinalMessage == null
427+        ? undefined
428+        : serializeBrowserFinalMessageSnapshot(selection.latestFinalMessage),
429+    request_hooks:
430+      selection.requestHook == null
431+        ? undefined
432+        : serializeBrowserRequestHookSnapshot(selection.requestHook),
433+    shell_runtime:
434+      selection.shellRuntime == null
435+        ? undefined
436+        : serializeBrowserShellRuntimeSnapshot(selection.shellRuntime)
437+  });
438+}
439+
440+async function handleBrowserLegacyCurrent(
441+  context: LocalApiRequestContext,
442+  input: {
443+    platform: string;
444+    resolveProxyPath?: ((conversationId: string) => string) | undefined;
445+  }
446+): Promise<ConductorHttpResponse> {
447+  const timeoutMs = readOptionalTimeoutMs({}, context.url);
448+  const clientId = readOptionalQueryString(context.url, "clientId", "client_id");
449+  const requestedConversationId = readOptionalQueryString(
450+    context.url,
451+    "conversationId",
452+    "conversation_id"
453+  );
454+  const selection = selectBrowserLegacyCurrentSelection(
455+    context,
456+    input.platform,
457+    clientId,
458+    requestedConversationId
459+  );
460+  const conversationId = resolveBrowserLegacyConversationId(
461+    input.platform,
462+    selection,
463+    requestedConversationId
464+  );
465+  const page = buildBrowserLegacyCurrentPage(selection, conversationId);
466+  const finalMessage = selection.latestFinalMessage;
467+  const messages = buildBrowserLegacyCurrentMessages(finalMessage);
468+
469+  if (conversationId != null && input.resolveProxyPath != null) {
470+    const path = input.resolveProxyPath(conversationId);
471+    const detail = await requestBrowserProxy(context, {
472+      action: `${input.platform} conversation read`,
473+      clientId: selection.client.client_id,
474+      method: "GET",
475+      path,
476+      platform: input.platform,
477+      timeoutMs
478+    });
479+
480+    return buildSuccessEnvelope(context.requestId, 200, {
481+      conversation: {
482+        conversation_id: conversationId
483+      },
484+      final_message:
485+        finalMessage == null ? null : serializeBrowserFinalMessageSnapshot(finalMessage),
486+      messages,
487+      page,
488+      platform: input.platform,
489+      proxy: {
490+        path,
491+        request_id: detail.apiResponse.id,
492+        status: detail.apiResponse.status
493+      },
494+      raw: detail.body
495+    });
496+  }
497+
498+  return buildSuccessEnvelope(context.requestId, 200, {
499+    conversation:
500+      conversationId == null
501+        ? null
502+        : {
503+            conversation_id: conversationId
504+          },
505+    final_message:
506+      finalMessage == null ? null : serializeBrowserFinalMessageSnapshot(finalMessage),
507+    messages,
508+    page,
509+    platform: input.platform,
510+    proxy: null,
511+    raw: null
512+  });
513+}
514+
515 async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
516   const body = readBodyObject(context.request, true);
517   const dispatch = await dispatchBrowserAction(context, {
518@@ -5591,6 +6002,33 @@ async function handleBrowserClaudeCurrent(context: LocalApiRequestContext): Prom
519   });
520 }
521 
522+async function handleBrowserChatgptSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
523+  return handleBrowserLegacySend(context, {
524+    defaultPath: BROWSER_CHATGPT_CONVERSATION_PATH,
525+    platform: BROWSER_CHATGPT_PLATFORM
526+  });
527+}
528+
529+async function handleBrowserChatgptCurrent(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
530+  return handleBrowserLegacyCurrent(context, {
531+    platform: BROWSER_CHATGPT_PLATFORM,
532+    resolveProxyPath: buildChatgptConversationPath
533+  });
534+}
535+
536+async function handleBrowserGeminiSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
537+  return handleBrowserLegacySend(context, {
538+    defaultPath: BROWSER_GEMINI_STREAM_GENERATE_PATH,
539+    platform: BROWSER_GEMINI_PLATFORM
540+  });
541+}
542+
543+async function handleBrowserGeminiCurrent(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
544+  return handleBrowserLegacyCurrent(context, {
545+    platform: BROWSER_GEMINI_PLATFORM
546+  });
547+}
548+
549 async function handleBrowserClaudeReload(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
550   const body = readBodyObject(context.request, true);
551   const reason = readOptionalStringBodyField(body, "reason") ?? "browser_http_reload";
552@@ -6599,6 +7037,14 @@ async function dispatchBusinessRoute(
553       return handleBrowserClaudeSend(context);
554     case "browser.claude.current":
555       return handleBrowserClaudeCurrent(context);
556+    case "browser.chatgpt.send":
557+      return handleBrowserChatgptSend(context);
558+    case "browser.chatgpt.current":
559+      return handleBrowserChatgptCurrent(context);
560+    case "browser.gemini.send":
561+      return handleBrowserGeminiSend(context);
562+    case "browser.gemini.current":
563+      return handleBrowserGeminiCurrent(context);
564     case "browser.claude.reload":
565       return handleBrowserClaudeReload(context);
566     case "codex.status":
M tasks/T-S049.md
+12, -7
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- 当前状态:`待开始`
 6+- 当前状态:`已完成`
 7 - 规模预估:`S`
 8 - 依赖任务:`T-S048`(Gemini 适配器)
 9 - 建议执行者:`Codex` 或 `Claude`(加白名单 + 路由,边界清晰)
10@@ -77,18 +77,23 @@ SUPPORTED_TARGET_TOOLS["browser.gemini"] = ["current", "send"];
11 
12 ### 开始执行
13 
14-- 执行者:
15-- 开始时间:
16+- 执行者:Codex
17+- 开始时间:2026-03-30 02:49 CST
18 - 状态变更:`待开始` → `进行中`
19 
20 ### 完成摘要
21 
22-- 完成时间:
23+- 完成时间:2026-03-30 03:19 CST
24 - 状态变更:`进行中` → `已完成`
25-- 修改了哪些文件:
26-- 核心实现思路:
27-- 跑了哪些测试:
28+- 修改了哪些文件:`apps/conductor-daemon/src/instructions/policy.ts`、`apps/conductor-daemon/src/instructions/router.ts`、`apps/conductor-daemon/src/local-api.ts`、`apps/conductor-daemon/src/index.test.js`、`tasks/T-S049.md`
29+- 核心实现思路:把 `browser.chatgpt`、`browser.gemini` 加入 BAA Phase 1 target 白名单;router 复用 `browser.claude` 的 `send/current` 映射模式,落到 `/v1/browser/chatgpt/*`、`/v1/browser/gemini/*`;local-api 为 ChatGPT/Gemini 增加 legacy helper wrapper,其中 `send` 复用现有 browser proxy,请求默认分别落到 ChatGPT conversation 路径和 Gemini StreamGenerate 路径,`current` 为 ChatGPT 增加当前对话代理读取,为 Gemini 返回当前页面/runtime 与最近 final-message 快照;同时补齐 describe/capabilities 中的 legacy route 暴露,并把原先“应被 policy deny”的测试改成新语义下的正向覆盖。
30+- 跑了哪些测试:`pnpm -C apps/conductor-daemon test`
31 
32 ### 执行过程中遇到的问题
33 
34+- 新建 worktree 默认没有 `node_modules`,导致 `pnpm exec tsc` 失败;复用主工作区依赖后重新跑测试,源码无需额外调整。
35+
36 ### 剩余风险
37+
38+- `POST /v1/browser/chatgpt/send` 仍依赖浏览器侧真实登录态和已捕获的请求上下文;若 ChatGPT 页面协议再次变化,需要和插件侧模板/headers 逻辑一起联调。
39+- `POST /v1/browser/gemini/send` 依赖浏览器插件已先捕获一条真实 Gemini `StreamGenerate` 模板请求;若模板过期或页面协议变化,helper route 可能需要同步调整。