- commit
- b0a42d0
- parent
- 9802336
- author
- im_wower
- date
- 2026-03-22 15:30:18 +0800 CST
fix(firefox): derive live tab and credential counts from current state
3 files changed,
+450,
-51
+2,
-0
1@@ -56,6 +56,8 @@
2 - Firefox 插件只调用 HTTP control API,不直接操作 conductor 进程。
3 - 插件负责显示状态和发起人工控制动作,不负责创建、分配、恢复 task。
4 - `pause`、`resume`、`drain` 都是全局动作,不是单标签页动作。
5+- 插件里的“跟踪标签页”只统计当前真实打开且仍匹配平台 host 的页面,不把历史 storage 残留当成当前状态。
6+- 插件里的“凭证”只统计绑定到当前 tracked tab、最近重新捕获且仍通过平台登录规则校验的快照;启动时不自动补开全部平台页。
7
8 ## 2. 模式语义
9
+5,
-3
1@@ -11,12 +11,12 @@ Firefox MVP for the BAA browser-proxy path.
2
3 ## What This Repo Does
4
5-This extension keeps an always-open controller page and one real browser tab per platform.
6+This extension keeps an always-open controller page and tracks one live browser tab per platform.
7
8 It does four things:
9
10 - keeps a WebSocket connection to `baa-server`
11-- opens or reuses real tabs for `claude.ai`, `chatgpt.com`, and `gemini.google.com`
12+- tracks current real tabs for `claude.ai`, `chatgpt.com`, and `gemini.google.com`, and only opens them on demand
13 - discovers live API endpoints from real browser traffic
14 - captures auth-related request headers and forwards them to `baa-server`
15
16@@ -74,7 +74,7 @@ BAA_FIREFOX_PROFILE=baa-firefox-persistent ./scripts/run-persistent.sh
17 1. Start `baa-server` so `ws://localhost:9800` is available.
18 2. Load this extension temporarily.
19 3. In the controller page, keep the default WS URL or change it.
20-4. Let the extension open `https://claude.ai/`, `https://chatgpt.com/`, and `https://gemini.google.com/`.
21+4. Use the controller buttons to open only the platform pages you actually need.
22 5. Log in manually in the tabs you want to intercept.
23 6. Use Claude, ChatGPT, or Gemini normally.
24 7. Watch the controller page and `baa-server` logs for:
25@@ -99,6 +99,8 @@ These message shapes match the current `baa-server` browser-side WS handling.
26 ## Limitations
27
28 - only one tab per platform is tracked
29+- tracked tab count only reflects tabs that are currently open and still match the platform host
30+- credential count only reflects recent valid snapshots bound to the current tracked tab
31 - only `fetch` is patched in the page
32 - there is no remote request execution path yet
33 - if Firefox unloads the extension, the session must be re-established
+443,
-48
1@@ -10,22 +10,33 @@ const CONTROLLER_STORAGE_KEYS = {
2 wsUrl: "baaFirefox.wsUrl",
3 controlBaseUrl: "baaFirefox.controlBaseUrl",
4 controlState: "baaFirefox.controlState",
5+ statusSchemaVersion: "baaFirefox.statusSchemaVersion",
6 trackedTabs: "baaFirefox.trackedTabs",
7 endpointsByPlatform: "baaFirefox.endpointsByPlatform",
8 lastHeadersByPlatform: "baaFirefox.lastHeadersByPlatform",
9 lastCredentialAtByPlatform: "baaFirefox.lastCredentialAtByPlatform",
10+ lastCredentialUrlByPlatform: "baaFirefox.lastCredentialUrlByPlatform",
11+ lastCredentialTabIdByPlatform: "baaFirefox.lastCredentialTabIdByPlatform",
12 geminiSendTemplate: "baaFirefox.geminiSendTemplate"
13 };
14
15 const DEFAULT_WS_URL = "ws://127.0.0.1:9800";
16 const DEFAULT_CONTROL_BASE_URL = "https://control-api.makefile.so";
17+const STATUS_SCHEMA_VERSION = 2;
18 const CREDENTIAL_SEND_INTERVAL = 30_000;
19+const CREDENTIAL_TTL = 15 * 60_000;
20 const NETWORK_BODY_LIMIT = 5000;
21 const LOG_LIMIT = 160;
22 const PROXY_MESSAGE_RETRY = 10;
23 const PROXY_MESSAGE_RETRY_DELAY = 400;
24 const CONTROL_REFRESH_INTERVAL = 15_000;
25+const TRACKED_TAB_REFRESH_DELAY = 150;
26 const CONTROL_STATUS_BODY_LIMIT = 12_000;
27+const CHATGPT_SESSION_COOKIE_PATTERNS = [
28+ /__secure-next-auth\.session-token=/i,
29+ /__secure-authjs\.session-token=/i,
30+ /next-auth\.session-token=/i
31+];
32 const FORBIDDEN_PROXY_HEADER_NAMES = new Set([
33 "accept-encoding",
34 "connection",
35@@ -142,11 +153,17 @@ const state = {
36 wsConnected: false,
37 reconnectTimer: null,
38 controlRefreshTimer: null,
39+ trackedTabRefreshTimer: null,
40+ trackedTabRefreshRunning: false,
41+ trackedTabRefreshQueued: false,
42 trackedTabs: createPlatformMap(() => null),
43 endpoints: createPlatformMap(() => ({})),
44 lastHeaders: createPlatformMap(() => ({})),
45 lastCredentialAt: createPlatformMap(() => 0),
46+ lastCredentialUrl: createPlatformMap(() => ""),
47+ lastCredentialTabId: createPlatformMap(() => null),
48 lastCredentialHash: createPlatformMap(() => ""),
49+ lastCredentialSentAt: createPlatformMap(() => 0),
50 geminiSendTemplate: null,
51 logs: []
52 };
53@@ -288,6 +305,46 @@ function loadNumberMap(raw, legacyValue = 0) {
54 return next;
55 }
56
57+function loadStringMap(raw, legacyValue = "") {
58+ const next = createPlatformMap(() => "");
59+ if (hasPlatformShape(raw)) {
60+ for (const platform of PLATFORM_ORDER) {
61+ next[platform] = typeof raw[platform] === "string" ? raw[platform] : "";
62+ }
63+ return next;
64+ }
65+
66+ if (typeof raw === "string") {
67+ next.claude = raw;
68+ return next;
69+ }
70+
71+ if (typeof legacyValue === "string") {
72+ next.claude = legacyValue;
73+ }
74+ return next;
75+}
76+
77+function loadTabIdMap(raw, legacyValue = null) {
78+ const next = createPlatformMap(() => null);
79+ if (hasPlatformShape(raw)) {
80+ for (const platform of PLATFORM_ORDER) {
81+ next[platform] = Number.isInteger(raw[platform]) ? raw[platform] : null;
82+ }
83+ return next;
84+ }
85+
86+ if (Number.isInteger(raw)) {
87+ next.claude = raw;
88+ return next;
89+ }
90+
91+ if (Number.isInteger(legacyValue)) {
92+ next.claude = legacyValue;
93+ }
94+ return next;
95+}
96+
97 function loadControlState(raw) {
98 if (!isRecord(raw)) return createDefaultControlState();
99 return cloneControlState(raw);
100@@ -580,6 +637,261 @@ function normalizeStoredUrl(url, baseUrl = location.href) {
101 }
102 }
103
104+function getRequestPath(url, baseUrl = location.href) {
105+ try {
106+ return new URL(url, baseUrl).pathname || "/";
107+ } catch (_) {
108+ return "";
109+ }
110+}
111+
112+function getHeaderValue(headers, name) {
113+ if (!headers || typeof headers !== "object") return "";
114+ const value = headers[String(name || "").toLowerCase()];
115+ return typeof value === "string" ? value.trim() : "";
116+}
117+
118+function hasHeaderValue(headers, name) {
119+ return getHeaderValue(headers, name) !== "";
120+}
121+
122+function cookieHeaderMatches(headers, patterns) {
123+ const cookie = getHeaderValue(headers, "cookie");
124+ return !!cookie && patterns.some((pattern) => pattern.test(cookie));
125+}
126+
127+function validateCredentialSnapshot(platform, headers, requestUrl = "") {
128+ const path = getRequestPath(requestUrl).toLowerCase();
129+
130+ switch (platform) {
131+ case "chatgpt": {
132+ if (path.includes("/backend-anon/")) {
133+ return {
134+ valid: false,
135+ invalidate: true,
136+ reason: "chatgpt-anon"
137+ };
138+ }
139+
140+ const hasBearer = /^bearer\s+\S+/i.test(getHeaderValue(headers, "authorization"));
141+ const hasSessionCookie = cookieHeaderMatches(headers, CHATGPT_SESSION_COOKIE_PATTERNS);
142+ const hasSentinel = hasHeaderValue(headers, "openai-sentinel-chat-requirements-token")
143+ || hasHeaderValue(headers, "openai-sentinel-proof-token")
144+ || hasHeaderValue(headers, "x-openai-assistant-app-id");
145+
146+ if (hasBearer || hasSessionCookie || (hasSentinel && hasHeaderValue(headers, "cookie"))) {
147+ return {
148+ valid: true,
149+ invalidate: false,
150+ reason: "ok"
151+ };
152+ }
153+
154+ if (path.includes("/backend-api/") || path.includes("/public-api/")) {
155+ return {
156+ valid: false,
157+ invalidate: true,
158+ reason: "chatgpt-missing-auth"
159+ };
160+ }
161+
162+ return {
163+ valid: false,
164+ invalidate: false,
165+ reason: "chatgpt-missing-auth"
166+ };
167+ }
168+ case "claude": {
169+ const hasAuth = hasHeaderValue(headers, "authorization")
170+ || hasHeaderValue(headers, "cookie")
171+ || hasHeaderValue(headers, "x-csrf-token");
172+ return {
173+ valid: hasAuth,
174+ invalidate: false,
175+ reason: hasAuth ? "ok" : "missing-auth"
176+ };
177+ }
178+ case "gemini": {
179+ const hasAuth = hasHeaderValue(headers, "authorization")
180+ || hasHeaderValue(headers, "cookie")
181+ || hasHeaderValue(headers, "x-goog-authuser")
182+ || hasHeaderValue(headers, "x-same-domain");
183+ return {
184+ valid: hasAuth,
185+ invalidate: false,
186+ reason: hasAuth ? "ok" : "missing-auth"
187+ };
188+ }
189+ default:
190+ return {
191+ valid: false,
192+ invalidate: false,
193+ reason: "unknown-platform"
194+ };
195+ }
196+}
197+
198+function describeCredentialReason(reason) {
199+ switch (reason) {
200+ case "missing-headers":
201+ return "无快照";
202+ case "chatgpt-anon":
203+ return "未登录";
204+ case "chatgpt-missing-auth":
205+ case "missing-auth":
206+ return "无登录凭证";
207+ case "missing-tab":
208+ return "无标签页";
209+ case "tab-mismatch":
210+ return "标签页已切换";
211+ case "stale":
212+ return "已过期";
213+ case "missing-meta":
214+ return "等待新请求";
215+ default:
216+ return "不可用";
217+ }
218+}
219+
220+function getCredentialState(platform, now = Date.now()) {
221+ const headers = cloneHeaderMap(state.lastHeaders[platform]);
222+ const headerCount = Object.keys(headers).length;
223+
224+ if (headerCount === 0) {
225+ return {
226+ valid: false,
227+ reason: "missing-headers",
228+ headerCount,
229+ headers
230+ };
231+ }
232+
233+ const trackedTabId = Number.isInteger(state.trackedTabs[platform]) ? state.trackedTabs[platform] : null;
234+ if (!Number.isInteger(trackedTabId)) {
235+ return {
236+ valid: false,
237+ reason: "missing-tab",
238+ headerCount,
239+ headers
240+ };
241+ }
242+
243+ const credentialTabId = Number.isInteger(state.lastCredentialTabId[platform]) ? state.lastCredentialTabId[platform] : null;
244+ if (!Number.isInteger(credentialTabId)) {
245+ return {
246+ valid: false,
247+ reason: "missing-meta",
248+ headerCount,
249+ headers,
250+ tabId: trackedTabId
251+ };
252+ }
253+
254+ if (credentialTabId !== trackedTabId) {
255+ return {
256+ valid: false,
257+ reason: "tab-mismatch",
258+ headerCount,
259+ headers,
260+ tabId: trackedTabId,
261+ credentialTabId
262+ };
263+ }
264+
265+ const capturedAt = Number(state.lastCredentialAt[platform]) || 0;
266+ if (capturedAt <= 0) {
267+ return {
268+ valid: false,
269+ reason: "missing-meta",
270+ headerCount,
271+ headers,
272+ tabId: trackedTabId
273+ };
274+ }
275+
276+ if (now - capturedAt > CREDENTIAL_TTL) {
277+ return {
278+ valid: false,
279+ reason: "stale",
280+ headerCount,
281+ headers,
282+ tabId: trackedTabId,
283+ capturedAt
284+ };
285+ }
286+
287+ const requestUrl = state.lastCredentialUrl[platform] || "";
288+ const validation = validateCredentialSnapshot(platform, headers, requestUrl);
289+ if (!validation.valid) {
290+ return {
291+ valid: false,
292+ reason: validation.reason || "missing-auth",
293+ headerCount,
294+ headers,
295+ tabId: trackedTabId,
296+ capturedAt,
297+ url: requestUrl
298+ };
299+ }
300+
301+ return {
302+ valid: true,
303+ reason: "ok",
304+ headerCount,
305+ headers,
306+ tabId: trackedTabId,
307+ capturedAt,
308+ url: requestUrl
309+ };
310+}
311+
312+function requireCredentialState(platform) {
313+ const credential = getCredentialState(platform);
314+ if (credential.valid) return credential;
315+ throw new Error(`${platformLabel(platform)} 没有有效凭证:${describeCredentialReason(credential.reason)}`);
316+}
317+
318+function clearPlatformCredential(platform) {
319+ let changed = false;
320+
321+ if (Object.keys(state.lastHeaders[platform]).length > 0) {
322+ state.lastHeaders[platform] = {};
323+ changed = true;
324+ }
325+ if (state.lastCredentialAt[platform] !== 0) {
326+ state.lastCredentialAt[platform] = 0;
327+ changed = true;
328+ }
329+ if (state.lastCredentialUrl[platform]) {
330+ state.lastCredentialUrl[platform] = "";
331+ changed = true;
332+ }
333+ if (Number.isInteger(state.lastCredentialTabId[platform])) {
334+ state.lastCredentialTabId[platform] = null;
335+ changed = true;
336+ }
337+
338+ state.lastCredentialHash[platform] = "";
339+ state.lastCredentialSentAt[platform] = 0;
340+ return changed;
341+}
342+
343+function pruneInvalidCredentialState(now = Date.now()) {
344+ let changed = false;
345+
346+ for (const platform of PLATFORM_ORDER) {
347+ const hasSnapshot = Object.keys(state.lastHeaders[platform]).length > 0;
348+ if (!hasSnapshot) continue;
349+
350+ const credential = getCredentialState(platform, now);
351+ if (!credential.valid) {
352+ changed = clearPlatformCredential(platform) || changed;
353+ }
354+ }
355+
356+ return changed;
357+}
358+
359 function isGeminiStreamGenerateUrl(url) {
360 try {
361 const parsed = new URL(url, PLATFORMS.gemini.rootUrl);
362@@ -698,10 +1010,13 @@ async function persistState() {
363 [CONTROLLER_STORAGE_KEYS.wsUrl]: state.wsUrl,
364 [CONTROLLER_STORAGE_KEYS.controlBaseUrl]: state.controlBaseUrl,
365 [CONTROLLER_STORAGE_KEYS.controlState]: state.controlState,
366+ [CONTROLLER_STORAGE_KEYS.statusSchemaVersion]: STATUS_SCHEMA_VERSION,
367 [CONTROLLER_STORAGE_KEYS.trackedTabs]: state.trackedTabs,
368 [CONTROLLER_STORAGE_KEYS.endpointsByPlatform]: state.endpoints,
369 [CONTROLLER_STORAGE_KEYS.lastHeadersByPlatform]: state.lastHeaders,
370 [CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform]: state.lastCredentialAt,
371+ [CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]: state.lastCredentialUrl,
372+ [CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]: state.lastCredentialTabId,
373 [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate
374 });
375 }
376@@ -711,7 +1026,7 @@ function getTrackedCount() {
377 }
378
379 function getCredentialCount() {
380- return PLATFORM_ORDER.filter((platform) => Object.keys(state.lastHeaders[platform]).length > 0).length;
381+ return PLATFORM_ORDER.filter((platform) => getCredentialState(platform).valid).length;
382 }
383
384 function getEndpointCount(platform) {
385@@ -726,10 +1041,13 @@ function renderPlatformStatus() {
386 const lines = [];
387 for (const platform of PLATFORM_ORDER) {
388 const tabId = Number.isInteger(state.trackedTabs[platform]) ? state.trackedTabs[platform] : "-";
389- const headerCount = Object.keys(state.lastHeaders[platform]).length;
390+ const credential = getCredentialState(platform);
391+ const credentialLabel = credential.valid
392+ ? `有效(${credential.headerCount})`
393+ : describeCredentialReason(credential.reason);
394 const endpointCount = getEndpointCount(platform);
395 lines.push(
396- `${platformLabel(platform).padEnd(8)} 标签页=${String(tabId).padEnd(4)} 凭证=${String(headerCount).padEnd(3)} 端点=${endpointCount}`
397+ `${platformLabel(platform).padEnd(8)} 标签页=${String(tabId).padEnd(4)} 凭证=${credentialLabel.padEnd(8)} 端点=${endpointCount}`
398 );
399 }
400 return lines.join("\n");
401@@ -738,13 +1056,19 @@ function renderPlatformStatus() {
402 function renderHeaderSnapshot() {
403 const snapshot = {};
404 for (const platform of PLATFORM_ORDER) {
405- if (Object.keys(state.lastHeaders[platform]).length > 0) {
406- snapshot[platform] = state.lastHeaders[platform];
407+ const credential = getCredentialState(platform);
408+ if (credential.valid) {
409+ snapshot[platform] = {
410+ capturedAt: new Date(credential.capturedAt).toISOString(),
411+ tabId: credential.tabId,
412+ url: credential.url || null,
413+ headers: credential.headers
414+ };
415 }
416 }
417 return Object.keys(snapshot).length > 0
418 ? JSON.stringify(snapshot, null, 2)
419- : "还没有凭证快照。";
420+ : "还没有有效凭证快照。";
421 }
422
423 function renderEndpointSnapshot() {
424@@ -800,7 +1124,10 @@ function render() {
425
426 ui.credStatus.textContent = `${credentialCount} / ${PLATFORM_ORDER.length}`;
427 ui.credStatus.className = `value ${credentialCount === 0 ? "off" : credentialCount === PLATFORM_ORDER.length ? "on" : "warn"}`;
428- ui.credMeta.textContent = `请求头: ${PLATFORM_ORDER.reduce((sum, platform) => sum + Object.keys(state.lastHeaders[platform]).length, 0)}`;
429+ ui.credMeta.textContent = `有效请求头: ${PLATFORM_ORDER.reduce((sum, platform) => {
430+ const credential = getCredentialState(platform);
431+ return sum + (credential.valid ? credential.headerCount : 0);
432+ }, 0)}`;
433
434 ui.endpointCount.textContent = String(totalEndpointCount);
435 ui.endpointCount.className = `value ${totalEndpointCount > 0 ? "on" : "off"}`;
436@@ -943,6 +1270,7 @@ async function runControlPlaneAction(action, options = {}) {
437 function restartControlPlaneRefreshTimer() {
438 clearInterval(state.controlRefreshTimer);
439 state.controlRefreshTimer = setInterval(() => {
440+ refreshTrackedTabsFromBrowser("poll").catch(() => {});
441 refreshControlPlaneState({ source: "poll", silent: true }).catch(() => {});
442 }, CONTROL_REFRESH_INTERVAL);
443 }
444@@ -1004,7 +1332,8 @@ function copyProxyHeaders(sourceHeaders = {}) {
445
446 function buildProxyHeaders(platform, apiPath, sourceHeaders = null) {
447 const targetPath = getProxyHeaderPath(apiPath);
448- const out = copyProxyHeaders(sourceHeaders || state.lastHeaders[platform] || {});
449+ const credential = sourceHeaders ? null : requireCredentialState(platform);
450+ const out = copyProxyHeaders(sourceHeaders || credential.headers || {});
451
452 if (platform === "chatgpt") {
453 out["x-openai-target-path"] = targetPath;
454@@ -1023,6 +1352,7 @@ function buildGeminiAutoRequest(prompt) {
455 if (!template?.url || !template?.reqBody) {
456 throw new Error("missing Gemini send template; send one real Gemini message first");
457 }
458+ const credential = requireCredentialState("gemini");
459
460 try {
461 const url = new URL(template.url, PLATFORMS.gemini.rootUrl);
462@@ -1060,7 +1390,7 @@ function buildGeminiAutoRequest(prompt) {
463 const path = `${url.pathname || "/"}${url.search || ""}`;
464 const headerSource = hasGeminiTemplateHeaders(template.headers)
465 ? template.headers
466- : state.lastHeaders.gemini;
467+ : credential.headers;
468 const headers = buildProxyHeaders("gemini", path, headerSource);
469 if (!hasGeminiTemplateHeaders(headers)) {
470 throw new Error("缺少 Gemini 请求头;请先手动发送一条真实 Gemini 消息");
471@@ -1089,33 +1419,26 @@ function sendEndpointSnapshot(platform = null) {
472 }
473
474 function sendCredentialSnapshot(platform = null, force = false) {
475- let changed = false;
476-
477 for (const target of getTargetPlatforms(platform)) {
478- const headers = state.lastHeaders[target];
479- if (Object.keys(headers).length === 0) continue;
480+ const credential = getCredentialState(target);
481+ if (!credential.valid) continue;
482
483 const now = Date.now();
484- const serialized = JSON.stringify(headers);
485- if (!force && serialized === state.lastCredentialHash[target] && now - state.lastCredentialAt[target] < CREDENTIAL_SEND_INTERVAL) {
486+ const serialized = JSON.stringify(credential.headers);
487+ if (!force && serialized === state.lastCredentialHash[target] && now - state.lastCredentialSentAt[target] < CREDENTIAL_SEND_INTERVAL) {
488 continue;
489 }
490
491 state.lastCredentialHash[target] = serialized;
492- state.lastCredentialAt[target] = now;
493- changed = true;
494+ state.lastCredentialSentAt[target] = now;
495
496 wsSend({
497 type: "credentials",
498 platform: target,
499- headers,
500- timestamp: now
501+ headers: credential.headers,
502+ timestamp: credential.capturedAt
503 });
504 }
505-
506- if (changed) {
507- persistState().catch(() => {});
508- }
509 }
510
511 function connectWs() {
512@@ -1233,6 +1556,7 @@ async function findPlatformTab(platform) {
513
514 async function setTrackedTab(platform, tab) {
515 state.trackedTabs[platform] = tab ? tab.id : null;
516+ pruneInvalidCredentialState();
517 await persistState();
518 render();
519 }
520@@ -1279,6 +1603,54 @@ async function ensureAllPlatformTabs(options = {}) {
521 }
522 }
523
524+async function refreshTrackedTabsFromBrowser(reason = "sync") {
525+ if (state.trackedTabRefreshRunning) {
526+ state.trackedTabRefreshQueued = true;
527+ return;
528+ }
529+
530+ state.trackedTabRefreshRunning = true;
531+
532+ try {
533+ do {
534+ state.trackedTabRefreshQueued = false;
535+
536+ const next = createPlatformMap(() => null);
537+ for (const platform of PLATFORM_ORDER) {
538+ const tab = await findPlatformTab(platform);
539+ next[platform] = tab ? tab.id : null;
540+ }
541+
542+ let changed = false;
543+ for (const platform of PLATFORM_ORDER) {
544+ if (state.trackedTabs[platform] !== next[platform]) {
545+ state.trackedTabs[platform] = next[platform];
546+ changed = true;
547+ }
548+ }
549+
550+ const credentialChanged = pruneInvalidCredentialState();
551+ if (changed || credentialChanged) {
552+ await persistState();
553+ }
554+ if (changed || credentialChanged || reason === "poll") {
555+ render();
556+ }
557+ } while (state.trackedTabRefreshQueued);
558+ } finally {
559+ state.trackedTabRefreshRunning = false;
560+ }
561+}
562+
563+function scheduleTrackedTabRefresh(reason = "tabs") {
564+ clearTimeout(state.trackedTabRefreshTimer);
565+ state.trackedTabRefreshTimer = setTimeout(() => {
566+ refreshTrackedTabsFromBrowser(reason).catch((error) => {
567+ addLog("error", `刷新平台标签页失败:${error.message}`);
568+ });
569+ }, TRACKED_TAB_REFRESH_DELAY);
570+}
571+
572 function collectEndpoint(platform, method, url) {
573 if (!shouldTrackRequest(platform, url)) return;
574
575@@ -1317,6 +1689,7 @@ function ensureTrackedTabId(platform, tabId, source) {
576 if (current === tabId) return true;
577
578 state.trackedTabs[platform] = tabId;
579+ pruneInvalidCredentialState();
580 persistState().catch(() => {});
581 render();
582
583@@ -1460,8 +1833,22 @@ function handleBeforeSendHeaders(details) {
584 const headers = headerArrayToObject(details.requestHeaders);
585 if (Object.keys(headers).length === 0) return;
586
587- state.lastHeaders[platform] = headers;
588 collectEndpoint(platform, details.method || "GET", details.url);
589+ const validation = validateCredentialSnapshot(platform, headers, details.url);
590+
591+ if (!validation.valid) {
592+ if (validation.invalidate && clearPlatformCredential(platform)) {
593+ addLog("info", `${platformLabel(platform)} 凭证已清理:${describeCredentialReason(validation.reason)}`);
594+ render();
595+ persistState().catch(() => {});
596+ }
597+ return;
598+ }
599+
600+ state.lastHeaders[platform] = headers;
601+ state.lastCredentialAt[platform] = Date.now();
602+ state.lastCredentialUrl[platform] = details.url || "";
603+ state.lastCredentialTabId[platform] = Number.isInteger(details.tabId) ? details.tabId : null;
604 render();
605 sendCredentialSnapshot(platform);
606 persistState().catch(() => {});
607@@ -1592,40 +1979,30 @@ function registerRuntimeListeners() {
608 }
609
610 function registerTabListeners() {
611- browser.tabs.onActivated.addListener(async ({ tabId }) => {
612- try {
613- const tab = await browser.tabs.get(tabId);
614- const platform = detectPlatformFromUrl(tab?.url || "");
615- if (!platform) return;
616- ensureTrackedTabId(platform, tabId, "activation");
617- } catch (_) {}
618+ browser.tabs.onActivated.addListener(() => {
619+ scheduleTrackedTabRefresh("activation");
620 });
621
622- browser.tabs.onRemoved.addListener((tabId) => {
623- const platform = findTrackedPlatformByTabId(tabId);
624- if (!platform) return;
625+ browser.tabs.onCreated.addListener(() => {
626+ scheduleTrackedTabRefresh("create");
627+ });
628
629- state.trackedTabs[platform] = null;
630- persistState().catch(() => {});
631- render();
632- addLog("warn", `${platformLabel(platform)} tab closed, reopening`);
633- ensurePlatformTab(platform, { focus: false }).catch(() => {});
634+ browser.tabs.onRemoved.addListener(() => {
635+ scheduleTrackedTabRefresh("remove");
636 });
637
638 browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
639 const platform = findTrackedPlatformByTabId(tabId);
640- if (!platform || changeInfo.status !== "complete") return;
641+ const urlPlatform = detectPlatformFromUrl(changeInfo.url || tab?.url || "");
642+ if (!platform && !urlPlatform && changeInfo.status !== "complete") return;
643
644- if (tab?.url && isPlatformUrl(platform, tab.url)) {
645+ if (platform && changeInfo.status === "complete" && tab?.url && isPlatformUrl(platform, tab.url)) {
646 addLog("info", `${platformLabel(platform)} tab ready ${tabId}`);
647- return;
648 }
649
650- state.trackedTabs[platform] = null;
651- persistState().catch(() => {});
652- render();
653- addLog("warn", `${platformLabel(platform)} tab moved away, reacquiring`);
654- ensurePlatformTab(platform, { focus: false }).catch(() => {});
655+ if (platform || urlPlatform || changeInfo.status === "complete") {
656+ scheduleTrackedTabRefresh("update");
657+ }
658 });
659 }
660
661@@ -1710,6 +2087,8 @@ async function init() {
662 ...Object.values(CONTROLLER_STORAGE_KEYS),
663 ...Object.values(LEGACY_STORAGE_KEYS)
664 ]);
665+ const savedSchemaVersion = Number(saved[CONTROLLER_STORAGE_KEYS.statusSchemaVersion]) || 0;
666+ const needsStatusReset = savedSchemaVersion < STATUS_SCHEMA_VERSION;
667
668 state.clientId = saved[CONTROLLER_STORAGE_KEYS.clientId] || genClientId();
669 state.wsUrl = saved[CONTROLLER_STORAGE_KEYS.wsUrl] || DEFAULT_WS_URL;
670@@ -1735,7 +2114,19 @@ async function init() {
671 saved[CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform],
672 saved[LEGACY_STORAGE_KEYS.lastCredentialAt]
673 );
674+ state.lastCredentialUrl = loadStringMap(
675+ saved[CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]
676+ );
677+ state.lastCredentialTabId = loadTabIdMap(
678+ saved[CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]
679+ );
680 state.geminiSendTemplate = saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate] || null;
681+ if (needsStatusReset) {
682+ state.lastHeaders = createPlatformMap(() => ({}));
683+ state.lastCredentialAt = createPlatformMap(() => 0);
684+ state.lastCredentialUrl = createPlatformMap(() => "");
685+ state.lastCredentialTabId = createPlatformMap(() => null);
686+ }
687 state.lastCredentialHash = createPlatformMap((platform) => JSON.stringify(state.lastHeaders[platform]));
688
689 ui.wsUrl.value = state.wsUrl;
690@@ -1747,11 +2138,14 @@ async function init() {
691
692 const current = await browser.tabs.getCurrent();
693 await browser.runtime.sendMessage({ type: "controller_ready", tabId: current.id });
694+ await refreshTrackedTabsFromBrowser("startup");
695 await persistState();
696 render();
697 addLog("info", `controller ready ${state.clientId}`, false);
698+ if (needsStatusReset) {
699+ addLog("info", "已清理旧版平台状态缓存,等待新的真实请求重新建立凭证", false);
700+ }
701
702- await ensureAllPlatformTabs({ reloadIfExisting: true });
703 connectWs();
704 restartControlPlaneRefreshTimer();
705 refreshControlPlaneState({ source: "startup", silent: true }).catch(() => {});
706@@ -1760,6 +2154,7 @@ async function init() {
707 window.addEventListener("beforeunload", () => {
708 clearTimeout(state.reconnectTimer);
709 clearInterval(state.controlRefreshTimer);
710+ clearTimeout(state.trackedTabRefreshTimer);
711 if (state.ws) {
712 try {
713 state.ws.close();