- commit
- 22a3232
- parent
- fa6290a
- author
- im_wower
- date
- 2026-03-28 13:30:48 +0800 CST
fix: restore Claude final-message capture in live tabs
4 files changed,
+816,
-191
+47,
-14
1@@ -1,3 +1,10 @@
2+const CONTENT_SCRIPT_RUNTIME_KEY = "__baaFirefoxContentScriptRuntime__";
3+
4+const previousContentScriptRuntime = window[CONTENT_SCRIPT_RUNTIME_KEY];
5+if (previousContentScriptRuntime && typeof previousContentScriptRuntime.dispose === "function") {
6+ previousContentScriptRuntime.dispose();
7+}
8+
9 function sendBridgeMessage(type, data) {
10 browser.runtime.sendMessage({
11 type,
12@@ -35,32 +42,27 @@ async function handleDeliveryCommand(data = {}) {
13 return await deliveryRuntime.handleCommand(data);
14 }
15
16-sendBridgeMessage("baa_page_bridge_ready", {
17- url: location.href,
18- source: "content-script"
19-});
20-
21-window.addEventListener("__baa_ready__", (event) => {
22+function handlePageReady(event) {
23 sendBridgeMessage("baa_page_bridge_ready", {
24 ...(event.detail || {}),
25 url: event.detail?.url || location.href,
26 source: event.detail?.source || "page-interceptor"
27 });
28-});
29+}
30
31-window.addEventListener("__baa_net__", (event) => {
32+function handlePageNetwork(event) {
33 sendBridgeMessage("baa_page_network", event.detail);
34-});
35+}
36
37-window.addEventListener("__baa_sse__", (event) => {
38+function handlePageSse(event) {
39 sendBridgeMessage("baa_page_sse", event.detail);
40-});
41+}
42
43-window.addEventListener("__baa_proxy_response__", (event) => {
44+function handleProxyResponse(event) {
45 sendBridgeMessage("baa_page_proxy_response", event.detail);
46-});
47+}
48
49-browser.runtime.onMessage.addListener((message) => {
50+function handleRuntimeMessage(message) {
51 if (!message || typeof message !== "object") return undefined;
52
53 if (message.type === "baa_page_proxy_request") {
54@@ -83,4 +85,35 @@ browser.runtime.onMessage.addListener((message) => {
55 }
56
57 return undefined;
58+}
59+
60+window.addEventListener("__baa_ready__", handlePageReady);
61+window.addEventListener("__baa_net__", handlePageNetwork);
62+window.addEventListener("__baa_sse__", handlePageSse);
63+window.addEventListener("__baa_proxy_response__", handleProxyResponse);
64+browser.runtime.onMessage.addListener(handleRuntimeMessage);
65+
66+sendBridgeMessage("baa_page_bridge_ready", {
67+ url: location.href,
68+ source: "content-script"
69 });
70+
71+const contentScriptRuntime = {
72+ dispose() {
73+ window.removeEventListener("__baa_ready__", handlePageReady);
74+ window.removeEventListener("__baa_net__", handlePageNetwork);
75+ window.removeEventListener("__baa_sse__", handlePageSse);
76+ window.removeEventListener("__baa_proxy_response__", handleProxyResponse);
77+ browser.runtime.onMessage.removeListener(handleRuntimeMessage);
78+
79+ if (window[CONTENT_SCRIPT_RUNTIME_KEY] === contentScriptRuntime) {
80+ try {
81+ delete window[CONTENT_SCRIPT_RUNTIME_KEY];
82+ } catch (_) {
83+ window[CONTENT_SCRIPT_RUNTIME_KEY] = null;
84+ }
85+ }
86+ }
87+};
88+
89+window[CONTENT_SCRIPT_RUNTIME_KEY] = contentScriptRuntime;
+217,
-28
1@@ -43,6 +43,8 @@ const CONTROL_RETRY_LOG_INTERVAL = 60_000;
2 const TRACKED_TAB_REFRESH_DELAY = 150;
3 const SHELL_RUNTIME_HEALTHCHECK_INTERVAL = 30_000;
4 const CONTROL_STATUS_BODY_LIMIT = 12_000;
5+const CONTENT_SCRIPT_INJECTION_FILES = ["delivery-adapters.js", "content-script.js"];
6+const PAGE_INTERCEPTOR_INJECTION_FILES = ["page-interceptor.js"];
7 const WS_RECONNECT_DELAY = 3_000;
8 const MANUAL_WS_RECONNECT_DEFAULT_DISCONNECT_MS = 80;
9 const MANUAL_WS_RECONNECT_MAX_DISCONNECT_MS = 60_000;
10@@ -3672,11 +3674,18 @@ async function runPluginManagementAction(action, options = {}) {
11 reason,
12 action: methodName
13 });
14+ const observerRefresh = methodName === "tab_reload"
15+ ? summarizeObserverRefresh(await reinjectPlatformTabs(target, {
16+ excludeTabIds: Number.isInteger(tab?.id) ? [tab.id] : [],
17+ source: methodName
18+ }))
19+ : null;
20 results.push({
21 platform: target,
22 ok: true,
23 tabId: tab?.id ?? null,
24- restored: methodName === "tab_open" ? !previousActual.exists : undefined
25+ restored: methodName === "tab_open" ? !previousActual.exists : undefined,
26+ observer_refresh: observerRefresh
27 });
28 }
29 break;
30@@ -4377,30 +4386,47 @@ async function resolveTrackedTab(platform, options = {}) {
31 }
32 }
33
34-async function findPlatformTab(platform) {
35- const tabs = await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns });
36- if (tabs.length === 0) return null;
37- tabs.sort((left, right) => {
38+function sortTabsByRecency(tabs = []) {
39+ return [...tabs].sort((left, right) => {
40 if (!!left.active !== !!right.active) return left.active ? -1 : 1;
41 const leftAccess = Number(left.lastAccessed) || 0;
42 const rightAccess = Number(right.lastAccessed) || 0;
43 return rightAccess - leftAccess;
44 });
45+}
46+
47+function dedupeTabsById(tabs = []) {
48+ const seen = new Set();
49+ const unique = [];
50+
51+ for (const tab of tabs) {
52+ if (!Number.isInteger(tab?.id) || seen.has(tab.id)) {
53+ continue;
54+ }
55+
56+ seen.add(tab.id);
57+ unique.push(tab);
58+ }
59+
60+ return unique;
61+}
62+
63+async function queryPlatformTabs(platform) {
64+ return sortTabsByRecency(await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns }));
65+}
66+
67+async function findPlatformTab(platform) {
68+ const tabs = await queryPlatformTabs(platform);
69+ if (tabs.length === 0) return null;
70 return tabs[0];
71 }
72
73 async function findPlatformShellTab(platform, preferredTabId = null, options = {}) {
74 const allowFallbackShell = options.allowFallbackShell === true;
75- const tabs = await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns });
76+ const tabs = await queryPlatformTabs(platform);
77 const shellTabs = tabs.filter((tab) => isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }));
78 if (shellTabs.length === 0) return null;
79
80- shellTabs.sort((left, right) => {
81- const leftAccess = Number(left.lastAccessed) || 0;
82- const rightAccess = Number(right.lastAccessed) || 0;
83- return rightAccess - leftAccess;
84- });
85-
86 const canonical = Number.isInteger(preferredTabId)
87 ? (shellTabs.find((tab) => tab.id === preferredTabId) || shellTabs[0])
88 : shellTabs[0];
89@@ -4415,6 +4441,123 @@ async function findPlatformShellTab(platform, preferredTabId = null, options = {
90 return canonical;
91 }
92
93+async function injectObserverScriptsIntoTab(tabId) {
94+ const result = {
95+ tabId,
96+ ok: false,
97+ contentScriptInjected: false,
98+ interceptorInjected: false,
99+ error: null
100+ };
101+
102+ if (!browser.scripting?.executeScript) {
103+ result.error = "scripting_execute_script_unavailable";
104+ return result;
105+ }
106+
107+ const errors = [];
108+
109+ try {
110+ await browser.scripting.executeScript({
111+ target: { tabId },
112+ files: CONTENT_SCRIPT_INJECTION_FILES
113+ });
114+ result.contentScriptInjected = true;
115+ } catch (error) {
116+ errors.push(`content=${error instanceof Error ? error.message : String(error)}`);
117+ }
118+
119+ try {
120+ await browser.scripting.executeScript({
121+ target: { tabId },
122+ files: PAGE_INTERCEPTOR_INJECTION_FILES,
123+ world: "MAIN"
124+ });
125+ result.interceptorInjected = true;
126+ } catch (error) {
127+ errors.push(`interceptor=${error instanceof Error ? error.message : String(error)}`);
128+ }
129+
130+ result.ok = result.contentScriptInjected && result.interceptorInjected;
131+ result.error = errors.length > 0 ? errors.join("; ") : null;
132+ return result;
133+}
134+
135+function summarizeObserverRefresh(results = []) {
136+ const source = Array.isArray(results) ? results : [];
137+ const refreshed = source.filter((entry) => entry.ok);
138+ const failed = source.filter((entry) => !entry.ok);
139+
140+ return {
141+ attempted_count: source.length,
142+ refreshed_count: refreshed.length,
143+ failed_count: failed.length,
144+ tab_ids: source.map((entry) => entry.tabId),
145+ refreshed_tab_ids: refreshed.map((entry) => entry.tabId),
146+ failed_tab_ids: failed.map((entry) => entry.tabId)
147+ };
148+}
149+
150+async function reinjectPlatformTabs(platform, options = {}) {
151+ if (!PLATFORMS[platform]) return [];
152+
153+ const excludeTabIds = new Set(
154+ Array.isArray(options.excludeTabIds)
155+ ? options.excludeTabIds.filter((tabId) => Number.isInteger(tabId))
156+ : []
157+ );
158+ const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
159+ const tabs = dedupeTabsById(
160+ Array.isArray(options.tabs)
161+ ? sortTabsByRecency(options.tabs)
162+ : await queryPlatformTabs(platform)
163+ );
164+ const results = [];
165+
166+ for (const tab of tabs) {
167+ if (!Number.isInteger(tab?.id) || excludeTabIds.has(tab.id)) {
168+ continue;
169+ }
170+
171+ const injection = await injectObserverScriptsIntoTab(tab.id);
172+ results.push({
173+ ...injection,
174+ isShellPage: isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }),
175+ platform,
176+ url: trimToNull(tab.url)
177+ });
178+ }
179+
180+ if (results.length > 0) {
181+ const summary = summarizeObserverRefresh(results);
182+ addLog(
183+ "info",
184+ `已刷新 ${platformLabel(platform)} 页面观察脚本 ${summary.refreshed_count}/${summary.attempted_count} 个标签页,来源 ${trimToNull(options.source) || "runtime"}`,
185+ false
186+ );
187+
188+ if (summary.failed_count > 0) {
189+ const failedTabs = results
190+ .filter((entry) => !entry.ok)
191+ .map((entry) => `${entry.tabId}:${entry.error || "unknown"}`)
192+ .join(", ");
193+ addLog("warn", `${platformLabel(platform)} 页面观察脚本刷新失败:${failedTabs}`, false);
194+ }
195+ }
196+
197+ return results;
198+}
199+
200+async function reinjectAllOpenPlatformTabs(options = {}) {
201+ const results = [];
202+
203+ for (const platform of PLATFORM_ORDER) {
204+ results.push(...await reinjectPlatformTabs(platform, options));
205+ }
206+
207+ return results;
208+}
209+
210 async function setTrackedTab(platform, tab) {
211 state.trackedTabs[platform] = tab ? tab.id : null;
212 state.actualTabs[platform] = buildActualTabSnapshot(
213@@ -4666,14 +4809,33 @@ function ensureTrackedTabId(platform, tabId, source, senderUrl = "") {
214 return true;
215 }
216
217+function isSenderShellContext(platform, senderUrl = "") {
218+ const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
219+ const adoptableShell = shouldAdoptPlatformTabAsDesired(platform, senderUrl);
220+ const allowFallbackShell = desired.exists || adoptableShell;
221+ return isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell });
222+}
223+
224 function getSenderContext(sender, fallbackPlatform = null) {
225 const tabId = sender?.tab?.id;
226 const senderUrl = sender?.tab?.url || "";
227 const senderPlatform = detectPlatformFromUrl(senderUrl);
228 const platform = senderPlatform || fallbackPlatform;
229- if (!platform) return null;
230- if (!ensureTrackedTabId(platform, tabId, "message", senderUrl)) return null;
231- return { platform, tabId };
232+ if (!platform || !Number.isInteger(tabId) || tabId < 0) return null;
233+
234+ const isShellPage = isSenderShellContext(platform, senderUrl);
235+ if (isShellPage) {
236+ if (!ensureTrackedTabId(platform, tabId, "message", senderUrl)) return null;
237+ } else if (senderUrl && !isPlatformUrl(platform, senderUrl)) {
238+ return null;
239+ }
240+
241+ return {
242+ platform,
243+ tabId,
244+ senderUrl,
245+ isShellPage
246+ };
247 }
248
249 function resolvePlatformFromRequest(details) {
250@@ -5015,18 +5177,16 @@ function handlePageProxyResponse(data, sender) {
251 }
252
253 function handlePageBridgeReady(data, sender) {
254- const tabId = sender?.tab?.id;
255 const senderUrl = sender?.tab?.url || data?.url || "";
256- const platform = detectPlatformFromUrl(senderUrl) || data?.platform || null;
257- if (!platform || !Number.isInteger(tabId)) return;
258- if (!ensureTrackedTabId(platform, tabId, "bridge", senderUrl)) return;
259+ const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || data?.platform || null);
260+ if (!context) return;
261
262- if (platform === "claude") {
263+ if (context.platform === "claude") {
264 updateClaudeState({
265- tabId,
266- currentUrl: senderUrl || getPlatformShellUrl(platform),
267+ tabId: context.tabId,
268+ currentUrl: senderUrl || getPlatformShellUrl(context.platform),
269 tabTitle: trimToNull(sender?.tab?.title),
270- conversationId: isPlatformShellUrl(platform, senderUrl)
271+ conversationId: context.isShellPage
272 ? null
273 : (extractClaudeConversationIdFromPageUrl(senderUrl) || state.claudeState.conversationId)
274 }, {
275@@ -5034,7 +5194,10 @@ function handlePageBridgeReady(data, sender) {
276 render: true
277 });
278 }
279- addLog("info", `${platformLabel(platform)} 空壳页已就绪,标签页 ${tabId},来源 ${data?.source || "未知"}`);
280+ addLog(
281+ "info",
282+ `${platformLabel(context.platform)} ${context.isShellPage ? "空壳页" : "页面观察"}已就绪,标签页 ${context.tabId},来源 ${data?.source || "未知"}`
283+ );
284 }
285
286 async function observeCredentialSnapshot(platform, headers, details = {}) {
287@@ -5809,6 +5972,9 @@ async function init() {
288 addLog("warn", "控制页未拿到当前 tabId,跳过 controller_ready 握手", false);
289 }
290
291+ await reinjectAllOpenPlatformTabs({
292+ source: "startup"
293+ });
294 await refreshTrackedTabsFromBrowser("startup");
295 await collapseRecoveredDesiredTabs();
296 await restoreDesiredTabsOnStartup();
297@@ -5833,7 +5999,30 @@ window.addEventListener("beforeunload", () => {
298 closeWsConnection();
299 });
300
301-init().catch((error) => {
302- console.error(error);
303- addLog("error", error.message, false);
304-});
305+function exposeControllerTestApi() {
306+ const target = globalThis.__BAA_CONTROLLER_TEST_API__;
307+ if (!target || typeof target !== "object") {
308+ return;
309+ }
310+
311+ Object.assign(target, {
312+ getSenderContext,
313+ handlePageBridgeReady,
314+ handlePageNetwork,
315+ handlePageSse,
316+ reinjectAllOpenPlatformTabs,
317+ reinjectPlatformTabs,
318+ runPluginManagementAction,
319+ setDesiredTabState,
320+ state
321+ });
322+}
323+
324+exposeControllerTestApi();
325+
326+if (globalThis.__BAA_SKIP_CONTROLLER_INIT__ !== true) {
327+ init().catch((error) => {
328+ console.error(error);
329+ addLog("error", error.message, false);
330+ });
331+}
+211,
-148
1@@ -1,13 +1,31 @@
2 (function () {
3- if (window.__baaFirefoxIntercepted__) return;
4- window.__baaFirefoxIntercepted__ = true;
5+ const previousRuntime = window.__baaFirefoxIntercepted__;
6+ const hasManagedRuntime = !!previousRuntime
7+ && typeof previousRuntime === "object"
8+ && typeof previousRuntime.teardown === "function";
9+ const legacyIntercepted = previousRuntime === true
10+ || (hasManagedRuntime && previousRuntime.legacyIntercepted === true);
11+
12+ if (hasManagedRuntime) {
13+ try {
14+ previousRuntime.teardown();
15+ } catch (_) {}
16+ }
17
18 const BODY_LIMIT = 5000;
19- const originalFetch = window.fetch;
20- const originalXhrOpen = XMLHttpRequest.prototype.open;
21- const originalXhrSend = XMLHttpRequest.prototype.send;
22- const originalXhrSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
23+ const originalFetch = hasManagedRuntime ? previousRuntime.originalFetch : window.fetch;
24+ const originalXhrOpen = hasManagedRuntime ? previousRuntime.originalXhrOpen : XMLHttpRequest.prototype.open;
25+ const originalXhrSend = hasManagedRuntime ? previousRuntime.originalXhrSend : XMLHttpRequest.prototype.send;
26+ const originalXhrSetRequestHeader = hasManagedRuntime ? previousRuntime.originalXhrSetRequestHeader : XMLHttpRequest.prototype.setRequestHeader;
27 const activeProxyControllers = new Map();
28+ const cleanupHandlers = [];
29+
30+ function addWindowListener(type, listener) {
31+ window.addEventListener(type, listener);
32+ cleanupHandlers.push(() => {
33+ window.removeEventListener(type, listener);
34+ });
35+ }
36
37 function hostnameMatches(hostname, hosts) {
38 return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
39@@ -419,62 +437,90 @@
40 }
41 }
42
43- window.addEventListener("__baa_proxy_request__", async (event) => {
44- let detail = event.detail || {};
45- if (typeof detail === "string") {
46- try {
47- detail = JSON.parse(detail);
48- } catch (_) {
49- detail = {};
50+ if (!legacyIntercepted) {
51+ addWindowListener("__baa_proxy_request__", async (event) => {
52+ let detail = event.detail || {};
53+ if (typeof detail === "string") {
54+ try {
55+ detail = JSON.parse(detail);
56+ } catch (_) {
57+ detail = {};
58+ }
59 }
60- }
61
62- const id = detail.id;
63- const method = String(detail.method || "GET").toUpperCase();
64- const rawPath = detail.path || detail.url || location.href;
65- const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
66+ const id = detail.id;
67+ const method = String(detail.method || "GET").toUpperCase();
68+ const rawPath = detail.path || detail.url || location.href;
69+ const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
70
71- if (!id) return;
72+ if (!id) return;
73
74- const proxyAbortController = new AbortController();
75- activeProxyControllers.set(id, proxyAbortController);
76+ const proxyAbortController = new AbortController();
77+ activeProxyControllers.set(id, proxyAbortController);
78
79- try {
80- const url = new URL(rawPath, location.origin).href;
81- const context = getRequestContext(url);
82- const headers = new Headers();
83- const startedAt = Date.now();
84-
85- for (const [name, value] of Object.entries(detail.headers || {})) {
86- if (!name || value == null || value === "") continue;
87- if (isForbiddenProxyHeader(name)) continue;
88- headers.set(String(name).toLowerCase(), String(value));
89- }
90+ try {
91+ const url = new URL(rawPath, location.origin).href;
92+ const context = getRequestContext(url);
93+ const headers = new Headers();
94+ const startedAt = Date.now();
95+
96+ for (const [name, value] of Object.entries(detail.headers || {})) {
97+ if (!name || value == null || value === "") continue;
98+ if (isForbiddenProxyHeader(name)) continue;
99+ headers.set(String(name).toLowerCase(), String(value));
100+ }
101
102- let body = null;
103- if (method !== "GET" && method !== "HEAD" && Object.prototype.hasOwnProperty.call(detail, "body")) {
104- if (typeof detail.body === "string") {
105- body = detail.body;
106- } else if (detail.body != null) {
107- if (!headers.has("content-type")) headers.set("content-type", "application/json");
108- body = JSON.stringify(detail.body);
109+ let body = null;
110+ if (method !== "GET" && method !== "HEAD" && Object.prototype.hasOwnProperty.call(detail, "body")) {
111+ if (typeof detail.body === "string") {
112+ body = detail.body;
113+ } else if (detail.body != null) {
114+ if (!headers.has("content-type")) headers.set("content-type", "application/json");
115+ body = JSON.stringify(detail.body);
116+ }
117 }
118- }
119
120- const response = await originalFetch.call(window, url, {
121- method,
122- headers,
123- body,
124- credentials: "include",
125- signal: proxyAbortController.signal
126- });
127- const resHeaders = readHeaders(response.headers);
128- const contentType = response.headers.get("content-type") || "";
129- const isSse = context ? context.rule.isSse(context.parsed.pathname, contentType) : false;
130- const reqHeaders = readHeaders(headers);
131- const reqBody = typeof body === "string" ? trim(body) : null;
132+ const response = await originalFetch.call(window, url, {
133+ method,
134+ headers,
135+ body,
136+ credentials: "include",
137+ signal: proxyAbortController.signal
138+ });
139+ const resHeaders = readHeaders(response.headers);
140+ const contentType = response.headers.get("content-type") || "";
141+ const isSse = context ? context.rule.isSse(context.parsed.pathname, contentType) : false;
142+ const reqHeaders = readHeaders(headers);
143+ const reqBody = typeof body === "string" ? trim(body) : null;
144+
145+ if (responseMode === "sse") {
146+ emitNet({
147+ url,
148+ method,
149+ reqHeaders,
150+ reqBody,
151+ status: response.status,
152+ resHeaders,
153+ resBody: null,
154+ duration: Date.now() - startedAt,
155+ sse: true,
156+ source: "proxy"
157+ }, pageRule);
158+
159+ const replayDetail = {
160+ ...detail,
161+ id,
162+ method,
163+ stream_id: detail.stream_id || detail.streamId || id,
164+ url
165+ };
166+ await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
167+ return;
168+ }
169+
170+ const responseBody = await response.text();
171+ const trimmedResponseBody = trim(responseBody);
172
173- if (responseMode === "sse") {
174 emitNet({
175 url,
176 method,
177@@ -482,123 +528,97 @@
178 reqBody,
179 status: response.status,
180 resHeaders,
181- resBody: null,
182+ resBody: isSse && pageRule.platform !== "gemini" ? null : trimmedResponseBody,
183 duration: Date.now() - startedAt,
184- sse: true,
185+ sse: isSse,
186 source: "proxy"
187 }, pageRule);
188
189- const replayDetail = {
190- ...detail,
191- id,
192- method,
193- stream_id: detail.stream_id || detail.streamId || id,
194- url
195- };
196- await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
197- return;
198- }
199-
200- const responseBody = await response.text();
201- const trimmedResponseBody = trim(responseBody);
202-
203- emitNet({
204- url,
205- method,
206- reqHeaders,
207- reqBody,
208- status: response.status,
209- resHeaders,
210- resBody: isSse && pageRule.platform !== "gemini" ? null : trimmedResponseBody,
211- duration: Date.now() - startedAt,
212- sse: isSse,
213- source: "proxy"
214- }, pageRule);
215+ if (isSse && trimmedResponseBody) {
216+ emitSse({
217+ url,
218+ method,
219+ reqBody,
220+ chunk: trimmedResponseBody,
221+ ts: Date.now()
222+ }, pageRule);
223+ emitSse({
224+ url,
225+ method,
226+ reqBody,
227+ done: true,
228+ ts: Date.now(),
229+ duration: Date.now() - startedAt
230+ }, pageRule);
231+ }
232
233- if (isSse && trimmedResponseBody) {
234- emitSse({
235+ emit("__baa_proxy_response__", {
236+ id,
237+ platform: pageRule.platform,
238 url,
239 method,
240- reqBody,
241- chunk: trimmedResponseBody,
242- ts: Date.now()
243+ ok: response.ok,
244+ status: response.status,
245+ body: responseBody
246 }, pageRule);
247- emitSse({
248- url,
249+ } catch (error) {
250+ emitNet({
251+ url: rawPath,
252 method,
253- reqBody,
254- done: true,
255- ts: Date.now(),
256- duration: Date.now() - startedAt
257+ reqHeaders: readHeaders(detail.headers || {}),
258+ reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
259+ error: error.message,
260+ source: "proxy"
261 }, pageRule);
262- }
263
264- emit("__baa_proxy_response__", {
265- id,
266- platform: pageRule.platform,
267- url,
268- method,
269- ok: response.ok,
270- status: response.status,
271- body: responseBody
272- }, pageRule);
273- } catch (error) {
274- emitNet({
275- url: rawPath,
276- method,
277- reqHeaders: readHeaders(detail.headers || {}),
278- reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
279- error: error.message,
280- source: "proxy"
281- }, pageRule);
282+ if (responseMode === "sse") {
283+ emitSse({
284+ error: error.message,
285+ id,
286+ method,
287+ reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
288+ status: null,
289+ stream_id: detail.stream_id || detail.streamId || id,
290+ ts: Date.now(),
291+ url: rawPath
292+ }, pageRule);
293+ return;
294+ }
295
296- if (responseMode === "sse") {
297- emitSse({
298- error: error.message,
299+ emit("__baa_proxy_response__", {
300 id,
301+ platform: pageRule.platform,
302+ url: rawPath,
303 method,
304- reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
305- status: null,
306- stream_id: detail.stream_id || detail.streamId || id,
307- ts: Date.now(),
308- url: rawPath
309+ ok: false,
310+ error: error.message
311 }, pageRule);
312- return;
313+ } finally {
314+ activeProxyControllers.delete(id);
315 }
316+ });
317
318- emit("__baa_proxy_response__", {
319- id,
320- platform: pageRule.platform,
321- url: rawPath,
322- method,
323- ok: false,
324- error: error.message
325- }, pageRule);
326- } finally {
327- activeProxyControllers.delete(id);
328- }
329- });
330+ addWindowListener("__baa_proxy_cancel__", (event) => {
331+ let detail = event.detail || {};
332
333- window.addEventListener("__baa_proxy_cancel__", (event) => {
334- let detail = event.detail || {};
335-
336- if (typeof detail === "string") {
337- try {
338- detail = JSON.parse(detail);
339- } catch (_) {
340- detail = {};
341+ if (typeof detail === "string") {
342+ try {
343+ detail = JSON.parse(detail);
344+ } catch (_) {
345+ detail = {};
346+ }
347 }
348- }
349
350- const id = detail?.id || detail?.requestId;
351- if (!id) return;
352+ const id = detail?.id || detail?.requestId;
353+ if (!id) return;
354
355- const controller = activeProxyControllers.get(id);
356- if (!controller) return;
357+ const controller = activeProxyControllers.get(id);
358+ if (!controller) return;
359
360- activeProxyControllers.delete(id);
361- controller.abort(detail?.reason || "browser_request_cancelled");
362- });
363+ activeProxyControllers.delete(id);
364+ controller.abort(detail?.reason || "browser_request_cancelled");
365+ });
366+ }
367
368 window.fetch = async function patchedFetch(input, init) {
369 const url = input instanceof Request ? input.url : String(input);
370@@ -755,4 +775,47 @@
371
372 return originalXhrSend.apply(this, arguments);
373 };
374+
375+ const runtime = {
376+ legacyIntercepted,
377+ originalFetch,
378+ originalXhrOpen,
379+ originalXhrSend,
380+ originalXhrSetRequestHeader,
381+ teardown() {
382+ for (const controller of activeProxyControllers.values()) {
383+ try {
384+ controller.abort("observer_reinject");
385+ } catch (_) {}
386+ }
387+ activeProxyControllers.clear();
388+
389+ window.fetch = originalFetch;
390+ XMLHttpRequest.prototype.open = originalXhrOpen;
391+ XMLHttpRequest.prototype.send = originalXhrSend;
392+ XMLHttpRequest.prototype.setRequestHeader = originalXhrSetRequestHeader;
393+
394+ while (cleanupHandlers.length > 0) {
395+ const cleanup = cleanupHandlers.pop();
396+ try {
397+ cleanup();
398+ } catch (_) {}
399+ }
400+
401+ if (window.__baaFirefoxIntercepted__ === runtime) {
402+ if (legacyIntercepted) {
403+ window.__baaFirefoxIntercepted__ = true;
404+ return;
405+ }
406+
407+ try {
408+ delete window.__baaFirefoxIntercepted__;
409+ } catch (_) {
410+ window.__baaFirefoxIntercepted__ = null;
411+ }
412+ }
413+ }
414+ };
415+
416+ window.__baaFirefoxIntercepted__ = runtime;
417 })();
1@@ -1,9 +1,10 @@
2 import assert from "node:assert/strict";
3-import { mkdtempSync, rmSync } from "node:fs";
4+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5 import { createRequire } from "node:module";
6 import { tmpdir } from "node:os";
7 import { join } from "node:path";
8 import test from "node:test";
9+import vm from "node:vm";
10
11 import { ConductorRuntime } from "../../apps/conductor-daemon/dist/index.js";
12
13@@ -18,6 +19,10 @@ const {
14 observeSse,
15 rememberRelay
16 } = require("../../plugins/baa-firefox/final-message.js");
17+const controllerSource = readFileSync(
18+ new URL("../../plugins/baa-firefox/controller.js", import.meta.url),
19+ "utf8"
20+);
21
22 function createWebSocketMessageQueue(socket) {
23 const messages = [];
24@@ -213,6 +218,263 @@ async function fetchText(url, init) {
25 };
26 }
27
28+function createControllerUiElement() {
29+ return {
30+ addEventListener() {},
31+ className: "",
32+ disabled: false,
33+ textContent: ""
34+ };
35+}
36+
37+function wildcardPatternToRegExp(pattern) {
38+ const escaped = String(pattern || "")
39+ .replace(/[.+?^${}()|[\]\\]/gu, "\\$&")
40+ .replace(/\*/gu, ".*");
41+ return new RegExp(`^${escaped}$`, "u");
42+}
43+
44+function matchesUrlPatterns(url, patterns = []) {
45+ return patterns.some((pattern) => wildcardPatternToRegExp(pattern).test(url));
46+}
47+
48+function createControllerHarness(options = {}) {
49+ const executeScriptCalls = [];
50+ const reloadedTabIds = [];
51+ const tabs = new Map();
52+
53+ for (const tab of options.tabs || []) {
54+ tabs.set(tab.id, {
55+ active: false,
56+ discarded: false,
57+ hidden: false,
58+ lastAccessed: 0,
59+ status: "complete",
60+ title: "",
61+ windowId: 1,
62+ ...tab
63+ });
64+ }
65+
66+ let nextTabId = Math.max(0, ...tabs.keys()) + 1;
67+ const storage = {};
68+ const sentMessages = [];
69+ const ws = options.ws || {
70+ readyState: 1,
71+ send(payload) {
72+ sentMessages.push(JSON.parse(payload));
73+ }
74+ };
75+ const browser = {
76+ action: {
77+ onClicked: {
78+ addListener() {}
79+ },
80+ async setBadgeBackgroundColor() {},
81+ async setBadgeText() {},
82+ async setTitle() {}
83+ },
84+ runtime: {
85+ async sendMessage() {
86+ return { ok: true };
87+ },
88+ onMessage: {
89+ addListener() {}
90+ }
91+ },
92+ scripting: {
93+ async executeScript(details) {
94+ executeScriptCalls.push(JSON.parse(JSON.stringify(details)));
95+ return [];
96+ }
97+ },
98+ storage: {
99+ local: {
100+ async get(keys) {
101+ if (Array.isArray(keys)) {
102+ return Object.fromEntries(keys.map((key) => [key, storage[key]]));
103+ }
104+
105+ if (typeof keys === "string") {
106+ return {
107+ [keys]: storage[keys]
108+ };
109+ }
110+
111+ return { ...storage };
112+ },
113+ async set(values) {
114+ Object.assign(storage, values || {});
115+ }
116+ },
117+ onChanged: {
118+ addListener() {}
119+ }
120+ },
121+ tabs: {
122+ async create(info = {}) {
123+ const tab = {
124+ active: !!info.active,
125+ discarded: false,
126+ hidden: false,
127+ id: nextTabId,
128+ lastAccessed: Date.now(),
129+ status: "complete",
130+ title: "",
131+ url: info.url || "",
132+ windowId: 1
133+ };
134+ nextTabId += 1;
135+ tabs.set(tab.id, tab);
136+ return { ...tab };
137+ },
138+ async get(tabId) {
139+ const tab = tabs.get(tabId);
140+ if (!tab) {
141+ throw new Error(`missing tab ${tabId}`);
142+ }
143+
144+ return { ...tab };
145+ },
146+ async query(queryInfo = {}) {
147+ const patterns = Array.isArray(queryInfo.url) ? queryInfo.url : [queryInfo.url].filter(Boolean);
148+ const source = [...tabs.values()];
149+ if (patterns.length === 0) {
150+ return source.map((tab) => ({ ...tab }));
151+ }
152+
153+ return source
154+ .filter((tab) => matchesUrlPatterns(tab.url || "", patterns))
155+ .map((tab) => ({ ...tab }));
156+ },
157+ async reload(tabId) {
158+ reloadedTabIds.push(tabId);
159+ const tab = tabs.get(tabId);
160+ if (tab) {
161+ tabs.set(tabId, {
162+ ...tab,
163+ status: "loading"
164+ });
165+ }
166+ },
167+ async remove(tabIds) {
168+ for (const tabId of Array.isArray(tabIds) ? tabIds : [tabIds]) {
169+ tabs.delete(tabId);
170+ }
171+ },
172+ async update(tabId, patch = {}) {
173+ const current = tabs.get(tabId);
174+ if (!current) {
175+ throw new Error(`missing tab ${tabId}`);
176+ }
177+
178+ const next = {
179+ ...current,
180+ ...patch
181+ };
182+ tabs.set(tabId, next);
183+ return { ...next };
184+ },
185+ onActivated: {
186+ addListener() {}
187+ },
188+ onCreated: {
189+ addListener() {}
190+ },
191+ onRemoved: {
192+ addListener() {}
193+ },
194+ onUpdated: {
195+ addListener() {}
196+ }
197+ },
198+ webRequest: {
199+ onBeforeSendHeaders: {
200+ addListener() {}
201+ },
202+ onCompleted: {
203+ addListener() {}
204+ },
205+ onErrorOccurred: {
206+ addListener() {}
207+ }
208+ },
209+ windows: {
210+ async update() {}
211+ }
212+ };
213+ const context = {
214+ AbortController,
215+ Blob,
216+ BAAFinalMessage: options.finalMessageHelpers || null,
217+ Headers,
218+ FormData,
219+ Request,
220+ Response,
221+ TextDecoder,
222+ TextEncoder,
223+ URL,
224+ URLSearchParams,
225+ WebSocket: {
226+ OPEN: 1
227+ },
228+ browser,
229+ clearInterval,
230+ clearTimeout,
231+ console,
232+ crypto: globalThis.crypto,
233+ __BAA_TEST_WS__: ws,
234+ document: {
235+ getElementById() {
236+ return createControllerUiElement();
237+ }
238+ },
239+ fetch: async () => new Response("{}", {
240+ status: 200,
241+ headers: {
242+ "content-type": "application/json"
243+ }
244+ }),
245+ globalThis: null,
246+ location: {
247+ href: "moz-extension://baa/controller.html",
248+ reload() {}
249+ },
250+ performance: {
251+ now() {
252+ return 0;
253+ }
254+ },
255+ setInterval,
256+ setTimeout,
257+ window: null,
258+ __BAA_CONTROLLER_TEST_API__: {},
259+ __BAA_SKIP_CONTROLLER_INIT__: true
260+ };
261+
262+ context.window = context;
263+ context.globalThis = context;
264+ context.addEventListener = () => {};
265+ context.removeEventListener = () => {};
266+ vm.runInNewContext(controllerSource, context, {
267+ filename: "controller.js"
268+ });
269+ vm.runInNewContext(`
270+ __BAA_CONTROLLER_TEST_API__.state.ws = __BAA_TEST_WS__;
271+ __BAA_CONTROLLER_TEST_API__.state.wsConnected = true;
272+ `, context);
273+
274+ const hooks = context.__BAA_CONTROLLER_TEST_API__;
275+
276+ return {
277+ executeScriptCalls,
278+ hooks,
279+ reloadedTabIds,
280+ sentMessages,
281+ tabs
282+ };
283+}
284+
285 function parseSseFrames(text) {
286 return String(text || "")
287 .split(/\n\n+/u)
288@@ -746,6 +1008,84 @@ test("final message relay network observer extracts Claude buffered completion t
289 assert.equal(relay.payload.raw_text, "Buffered Claude reply");
290 });
291
292+test("controller accepts Claude non-shell page SSE without adopting the chat tab as shell", () => {
293+ const conversationId = "22222222-2222-4222-8222-222222222222";
294+ const harness = createControllerHarness();
295+
296+ harness.hooks.handlePageSse(
297+ {
298+ chunk: [
299+ "event: completion",
300+ 'data: {"type":"completion","completion":"Claude final answer from non-shell page","id":"msg-claude-non-shell"}',
301+ ""
302+ ].join("\n"),
303+ done: true,
304+ platform: "claude",
305+ url: `https://claude.ai/api/organizations/11111111-1111-4111-8111-111111111111/chat_conversations/${conversationId}/completion`
306+ },
307+ {
308+ tab: {
309+ id: 42,
310+ title: "Smoke Claude Chat",
311+ url: `https://claude.ai/chat/${conversationId}`
312+ }
313+ }
314+ );
315+
316+ assert.equal(harness.hooks.state.trackedTabs.claude, null);
317+ assert.equal(harness.hooks.state.claudeState.tabId, 42);
318+ assert.equal(harness.hooks.state.claudeState.conversationId, conversationId);
319+ assert.ok(harness.hooks.state.claudeState.lastActivityAt > 0);
320+});
321+
322+test("controller tab_reload refreshes observer scripts on existing Claude chat tabs", async () => {
323+ const conversationId = "33333333-3333-4333-8333-333333333333";
324+ const harness = createControllerHarness({
325+ tabs: [
326+ {
327+ active: false,
328+ id: 11,
329+ lastAccessed: 100,
330+ status: "complete",
331+ title: "Claude Shell",
332+ url: "https://claude.ai/#baa-shell"
333+ },
334+ {
335+ active: true,
336+ id: 12,
337+ lastAccessed: 200,
338+ status: "complete",
339+ title: "Claude Chat",
340+ url: `https://claude.ai/chat/${conversationId}`
341+ }
342+ ]
343+ });
344+
345+ const result = await harness.hooks.runPluginManagementAction("tab_reload", {
346+ platform: "claude",
347+ source: "smoke_test"
348+ });
349+
350+ assert.deepEqual(harness.reloadedTabIds, [11]);
351+ assert.equal(result.action, "tab_reload");
352+ assert.deepEqual(Array.from(result.results[0].observer_refresh.refreshed_tab_ids), [12]);
353+ assert.ok(
354+ harness.executeScriptCalls.some((call) =>
355+ call.target?.tabId === 12
356+ && Array.isArray(call.files)
357+ && call.files.join(",") === "delivery-adapters.js,content-script.js"
358+ )
359+ );
360+ assert.ok(
361+ harness.executeScriptCalls.some((call) =>
362+ call.target?.tabId === 12
363+ && Array.isArray(call.files)
364+ && call.files.join(",") === "page-interceptor.js"
365+ && call.world === "MAIN"
366+ )
367+ );
368+});
369+
370 test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
371 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
372 const runtime = new ConductorRuntime(