- commit
- eed061c
- parent
- 8a33fa2
- author
- im_wower
- date
- 2026-03-30 05:45:17 +0800 CST
Merge remote-tracking branch 'origin/feat/browser-chatgpt-gemini-targets'
5 files changed,
+888,
-60
+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",
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 {
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,
+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":
+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 可能需要同步调整。