baa-conductor

git clone 

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
M docs/firefox/README.md
+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 
M plugins/baa-firefox/README.md
+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
M plugins/baa-firefox/controller.js
+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();