baa-conductor

git clone 

commit
a29ea3c
parent
81fa64c
author
codex@macbookpro
date
2026-03-26 15:41:08 +0800 CST
feat: persist browser bridge metadata
27 files changed,  +3362, -796
M README.md
+8, -5
 1@@ -92,7 +92,9 @@ docs/
 2 - `status-api` 当前决定是继续保留为本地只读观察兼容层,不立即删除
 3 - `mini` on-node 静态+运行态检查统一入口是 `./scripts/runtime/verify-mini.sh`(仓库根可用 `pnpm verify:mini`)
 4 - 运行中的浏览器插件代码以 [`plugins/baa-firefox`](./plugins/baa-firefox) 为准
 5-- 当前正式浏览器 HTTP 面是 `/v1/browser/*`,只支持 Claude,且通过本地 `/ws/firefox` 转发到 Firefox 插件页面内 HTTP 代理
 6+- 浏览器桥接正式模型已经固定为“登录态元数据持久化 + 单平台单空壳页 + 浏览器本地代发”;页面对话 UI 不是正式主能力
 7+- `GET /v1/browser` 是当前正式浏览器桥接读面:返回活跃 bridge 和持久化登录态记录,只暴露 `account`、凭证指纹、端点元数据与 `fresh/stale/lost`
 8+- `POST` / `GET /v1/browser/claude/*` 是当前唯一正式浏览器代发面:请求经本地 `/ws/firefox` 转发到 Firefox 插件在浏览器本地代发,`conductor` 不直接持有原始凭证
 9 - `codexd` 目前还是半成品,不是已上线组件
10 - `codexd` 必须作为独立常驻进程存在,不接受长期内嵌到 `conductor-daemon`
11 - `codexd` 后续默认以 `app-server` 为主,不以 TUI 或 `exec` 作为主双工接口
12@@ -101,7 +103,7 @@ docs/
13 
14 | 面 | 地址 | 定位 | 说明 |
15 | --- | --- | --- | --- |
16-| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/status`、`/v1/status/ui`、`/v1/browser/*`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 `/v1/browser/*` 当前只支持 Claude,host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
17+| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/status`、`/v1/status/ui`、`/v1/browser/*`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 `GET /v1/browser` 返回浏览器登录态元数据与持久化状态,Claude 专用 `/v1/browser/claude/*` 则走本地插件代发,host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
18 | public host | `https://conductor.makefile.so` | 唯一公网域名 | 由 VPS Nginx 回源到 `100.71.210.78:4317`;`/v1/exec` 和 `/v1/files/*` 不再允许匿名调用 |
19 | local status view | `http://100.71.210.78:4318` | 本地只读观察兼容层 | 显式 opt-in 保留,继续提供 `/describe`、`/v1/status`、`/v1/status/ui` 和 `/ui` 等 legacy 合同,不是主控制面 |
20 
21@@ -122,14 +124,15 @@ legacy 兼容说明:
22 
23 - 保持 `mini` launchd、自启动和本地探针稳定
24 - 保持 `conductor.makefile.so -> 100.71.210.78:4317` 的链路稳定
25-- 继续清理仍依赖 `4318` wrapper 的旧脚本、书签和文档
26-- 把 `conductor-daemon` 对 `status-api` 构建产物的复用提成共享模块
27+- 保持 `/v1/browser`、浏览器登录态持久化与 Claude 本地代发合同稳定
28+- 保持 `pnpm smoke` 和 browser-control e2e smoke 对浏览器链路持续可回归
29 
30 ## 当前已知 gap
31 
32 - `verify-mini.sh` 只收口静态检查和运行态探针;会话级链路回归仍要单独跑 `pnpm smoke` 或 `./scripts/runtime/codexd-e2e-smoke.sh`
33 - `status-api` 已降为显式 opt-in 的本地只读兼容包装层;旧调用方仍可能继续依赖 `4318`
34-- `status-api` 和 `conductor /v1/status` 现在共享同一套状态拼装/渲染语义;后续如果要删 `status-api`,还需要先清 `4318` 调用方,并拆掉当前构建时复用
35+- `4318/status-api` 删旧与共享模块提取目前只是低优先级 backlog,不再是当前主线
36+- `status-api` 和 `conductor /v1/status` 现在共享同一套状态拼装/渲染语义;如果未来要删 `status-api`,还需要先清 `4318` 调用方,并拆掉当前构建时复用
37 
38 ## 本机能力层
39 
M apps/conductor-daemon/src/browser-types.ts
+19, -0
 1@@ -1,12 +1,31 @@
 2+export type BrowserBridgeLoginStatus = "fresh" | "stale" | "lost";
 3+
 4+export interface BrowserBridgeEndpointMetadataSnapshot {
 5+  first_seen_at: number | null;
 6+  last_seen_at: number | null;
 7+  method: string | null;
 8+  path: string;
 9+}
10+
11 export interface BrowserBridgeCredentialSnapshot {
12+  account: string | null;
13+  account_captured_at: number | null;
14+  account_last_seen_at: number | null;
15   captured_at: number;
16+  credential_fingerprint: string | null;
17+  freshness: BrowserBridgeLoginStatus | null;
18   header_count: number;
19+  last_seen_at: number | null;
20   platform: string;
21 }
22 
23 export interface BrowserBridgeRequestHookSnapshot {
24+  account: string | null;
25+  credential_fingerprint: string | null;
26   endpoint_count: number;
27+  endpoint_metadata: BrowserBridgeEndpointMetadataSnapshot[];
28   endpoints: string[];
29+  last_verified_at: number | null;
30   platform: string;
31   updated_at: number;
32 }
M apps/conductor-daemon/src/firefox-ws.ts
+376, -15
  1@@ -10,6 +10,8 @@ import {
  2 } from "./firefox-bridge.js";
  3 import type {
  4   BrowserBridgeClientSnapshot,
  5+  BrowserBridgeEndpointMetadataSnapshot,
  6+  BrowserBridgeLoginStatus,
  7   BrowserBridgeStateSnapshot
  8 } from "./browser-types.js";
  9 import { buildSystemStateData, setAutomationMode } from "./local-api.js";
 10@@ -19,6 +21,8 @@ const FIREFOX_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
 11 const FIREFOX_WS_PROTOCOL = "baa.firefox.local";
 12 const FIREFOX_WS_PROTOCOL_VERSION = 1;
 13 const FIREFOX_WS_STATE_POLL_INTERVAL_MS = 2_000;
 14+const FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS = 45_000;
 15+const FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS = 120_000;
 16 const MAX_FRAME_PAYLOAD_BYTES = 1024 * 1024;
 17 const CLIENT_REPLACED_CLOSE_CODE = 4001;
 18 const INVALID_MESSAGE_CLOSE_CODE = 4002;
 19@@ -38,13 +42,30 @@ interface FirefoxWebSocketServerOptions {
 20   snapshotLoader: () => ConductorRuntimeSnapshot;
 21 }
 22 
 23+interface FirefoxBrowserEndpointMetadataSummary {
 24+  firstSeenAt: number | null;
 25+  lastSeenAt: number | null;
 26+  method: string | null;
 27+  path: string;
 28+}
 29+
 30 interface FirefoxBrowserCredentialSummary {
 31+  account: string | null;
 32+  accountCapturedAt: number | null;
 33+  accountLastSeenAt: number | null;
 34   capturedAt: number;
 35+  credentialFingerprint: string | null;
 36+  freshness: BrowserBridgeLoginStatus | null;
 37   headerCount: number;
 38+  lastSeenAt: number | null;
 39 }
 40 
 41 interface FirefoxBrowserHookSummary {
 42+  account: string | null;
 43+  credentialFingerprint: string | null;
 44+  endpointMetadata: FirefoxBrowserEndpointMetadataSummary[];
 45   endpoints: string[];
 46+  lastVerifiedAt: number | null;
 47   updatedAt: number;
 48 }
 49 
 50@@ -60,6 +81,23 @@ interface FirefoxBrowserSession {
 51   requestHooks: Map<string, FirefoxBrowserHookSummary>;
 52 }
 53 
 54+function normalizeBrowserLoginStatus(value: unknown): BrowserBridgeLoginStatus | null {
 55+  if (typeof value !== "string") {
 56+    return null;
 57+  }
 58+
 59+  switch (value.trim().toLowerCase()) {
 60+    case "fresh":
 61+      return "fresh";
 62+    case "stale":
 63+      return "stale";
 64+    case "lost":
 65+      return "lost";
 66+    default:
 67+      return null;
 68+  }
 69+}
 70+
 71 function asRecord(value: unknown): Record<string, unknown> | null {
 72   if (value === null || typeof value !== "object" || Array.isArray(value)) {
 73     return null;
 74@@ -115,6 +153,48 @@ function readStringArray(
 75   return [...values].sort((left, right) => left.localeCompare(right));
 76 }
 77 
 78+function readEndpointMetadataArray(
 79+  input: Record<string, unknown>,
 80+  key: string
 81+): FirefoxBrowserEndpointMetadataSummary[] {
 82+  const rawValue = input[key];
 83+
 84+  if (!Array.isArray(rawValue)) {
 85+    return [];
 86+  }
 87+
 88+  const metadata: FirefoxBrowserEndpointMetadataSummary[] = [];
 89+
 90+  for (const entry of rawValue) {
 91+    const record = asRecord(entry);
 92+
 93+    if (record == null) {
 94+      continue;
 95+    }
 96+
 97+    const path = readFirstString(record, ["path"]);
 98+
 99+    if (path == null) {
100+      continue;
101+    }
102+
103+    metadata.push({
104+      firstSeenAt: readOptionalTimestampMilliseconds(record, ["first_seen_at", "firstSeenAt"]),
105+      lastSeenAt: readOptionalTimestampMilliseconds(record, ["last_seen_at", "lastSeenAt"]),
106+      method: readFirstString(record, ["method"]),
107+      path
108+    });
109+  }
110+
111+  metadata.sort((left, right) => {
112+    const leftKey = `${left.method ?? ""} ${left.path}`;
113+    const rightKey = `${right.method ?? ""} ${right.path}`;
114+    return leftKey.localeCompare(rightKey);
115+  });
116+
117+  return metadata;
118+}
119+
120 function countObjectKeys(value: unknown): number {
121   const record = asRecord(value);
122 
123@@ -125,14 +205,19 @@ function countObjectKeys(value: unknown): number {
124   return Object.keys(record).length;
125 }
126 
127-function readTimestampMilliseconds(input: Record<string, unknown>, key: string, fallback: number): number {
128-  const value = input[key];
129+function readOptionalTimestampMilliseconds(
130+  input: Record<string, unknown>,
131+  keys: readonly string[]
132+): number | null {
133+  for (const key of keys) {
134+    const value = input[key];
135 
136-  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
137-    return fallback;
138+    if (typeof value === "number" && Number.isFinite(value) && value > 0) {
139+      return Math.round(value);
140+    }
141   }
142 
143-  return Math.round(value);
144+  return null;
145 }
146 
147 function toUnixMilliseconds(value: number | null | undefined): number | null {
148@@ -143,6 +228,71 @@ function toUnixMilliseconds(value: number | null | undefined): number | null {
149   return value * 1000;
150 }
151 
152+function toUnixSecondsMilliseconds(value: number | null | undefined): number | null {
153+  if (value == null) {
154+    return null;
155+  }
156+
157+  return Math.floor(value / 1000);
158+}
159+
160+function countHeaderNames(input: Record<string, unknown>): number {
161+  const headerCount = countObjectKeys(input.headers);
162+
163+  if (headerCount > 0) {
164+    return headerCount;
165+  }
166+
167+  return readStringArray(input, "header_names").length;
168+}
169+
170+function getLatestEndpointTimestamp(
171+  updatedAt: number | null,
172+  endpointMetadata: FirefoxBrowserEndpointMetadataSummary[],
173+  fallback: number
174+): number {
175+  const metadataTimestamp = endpointMetadata.reduce<number>(
176+    (current, entry) => Math.max(current, entry.lastSeenAt ?? entry.firstSeenAt ?? 0),
177+    0
178+  );
179+
180+  return Math.max(updatedAt ?? 0, metadataTimestamp, fallback);
181+}
182+
183+function getPersistedLoginStateStatus(
184+  messageStatus: BrowserBridgeLoginStatus | null | undefined
185+): BrowserBridgeLoginStatus {
186+  return messageStatus ?? "fresh";
187+}
188+
189+function getAgedLoginStateStatus(
190+  lastMessageAt: number,
191+  nowMs: number
192+): BrowserBridgeLoginStatus | null {
193+  const ageMs = Math.max(0, nowMs - lastMessageAt);
194+
195+  if (ageMs >= FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS) {
196+    return "lost";
197+  }
198+
199+  if (ageMs >= FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS) {
200+    return "stale";
201+  }
202+
203+  return null;
204+}
205+
206+function getBrowserLoginStateRank(status: BrowserBridgeLoginStatus): number {
207+  switch (status) {
208+    case "fresh":
209+      return 0;
210+    case "stale":
211+      return 1;
212+    case "lost":
213+      return 2;
214+  }
215+}
216+
217 function normalizePathname(value: string): string {
218   const normalized = value.replace(/\/+$/u, "");
219   return normalized === "" ? "/" : normalized;
220@@ -211,7 +361,7 @@ class FirefoxWebSocketConnection {
221     private readonly socket: Socket,
222     private readonly server: ConductorFirefoxWebSocketServer
223   ) {
224-    const now = Date.now();
225+    const now = this.server.getNextTimestampMilliseconds();
226 
227     this.session = {
228       clientId: null,
229@@ -277,28 +427,51 @@ class FirefoxWebSocketConnection {
230     this.session.credentials.set(platform, summary);
231   }
232 
233+  getCredential(platform: string): FirefoxBrowserCredentialSummary | null {
234+    return this.session.credentials.get(platform) ?? null;
235+  }
236+
237   updateRequestHook(platform: string, summary: FirefoxBrowserHookSummary): void {
238     this.session.requestHooks.set(platform, summary);
239   }
240 
241+  getRequestHook(platform: string): FirefoxBrowserHookSummary | null {
242+    return this.session.requestHooks.get(platform) ?? null;
243+  }
244+
245   touch(): void {
246-    this.session.lastMessageAt = Date.now();
247+    this.session.lastMessageAt = this.server.getNextTimestampMilliseconds();
248   }
249 
250   describe(): BrowserBridgeClientSnapshot {
251     const credentials = [...this.session.credentials.entries()]
252       .sort(([left], [right]) => left.localeCompare(right))
253       .map(([platform, summary]) => ({
254+        account: summary.account,
255+        account_captured_at: summary.accountCapturedAt,
256+        account_last_seen_at: summary.accountLastSeenAt,
257         platform,
258         captured_at: summary.capturedAt,
259-        header_count: summary.headerCount
260+        credential_fingerprint: summary.credentialFingerprint,
261+        freshness: summary.freshness,
262+        header_count: summary.headerCount,
263+        last_seen_at: summary.lastSeenAt
264       }));
265     const requestHooks = [...this.session.requestHooks.entries()]
266       .sort(([left], [right]) => left.localeCompare(right))
267       .map(([platform, summary]) => ({
268+        account: summary.account,
269+        credential_fingerprint: summary.credentialFingerprint,
270         platform,
271         endpoint_count: summary.endpoints.length,
272+        endpoint_metadata: summary.endpointMetadata.map((entry): BrowserBridgeEndpointMetadataSnapshot => ({
273+          first_seen_at: entry.firstSeenAt,
274+          last_seen_at: entry.lastSeenAt,
275+          method: entry.method,
276+          path: entry.path
277+        })),
278         endpoints: [...summary.endpoints],
279+        last_verified_at: summary.lastVerifiedAt,
280         updated_at: summary.updatedAt
281       }));
282 
283@@ -493,6 +666,7 @@ export class ConductorFirefoxWebSocketServer {
284   private readonly connectionsByClientId = new Map<string, FirefoxWebSocketConnection>();
285   private broadcastQueue: Promise<void> = Promise.resolve();
286   private lastSnapshotSignature: string | null = null;
287+  private lastTimestampMs = 0;
288   private pollTimer: IntervalHandle | null = null;
289 
290   constructor(options: FirefoxWebSocketServerOptions) {
291@@ -516,6 +690,16 @@ export class ConductorFirefoxWebSocketServer {
292     return this.bridgeService;
293   }
294 
295+  getNowMilliseconds(): number {
296+    return this.now() * 1000;
297+  }
298+
299+  getNextTimestampMilliseconds(): number {
300+    const nowMs = this.getNowMilliseconds();
301+    this.lastTimestampMs = Math.max(this.lastTimestampMs + 1, nowMs);
302+    return this.lastTimestampMs;
303+  }
304+
305   getStateSnapshot(): BrowserBridgeStateSnapshot {
306     return this.buildBrowserStateSnapshot();
307   }
308@@ -526,6 +710,7 @@ export class ConductorFirefoxWebSocketServer {
309     }
310 
311     this.pollTimer = globalThis.setInterval(() => {
312+      void this.refreshBrowserLoginStateStatuses();
313       void this.broadcastStateSnapshot("poll");
314     }, FIREFOX_WS_STATE_POLL_INTERVAL_MS);
315   }
316@@ -608,6 +793,7 @@ export class ConductorFirefoxWebSocketServer {
317       connectionId: connection.getConnectionId(),
318       reason: closeInfo.reason
319     });
320+    void this.markClientLoginStatesStale(clientId);
321 
322     void this.broadcastStateSnapshot("disconnect", {
323       force: true
324@@ -803,37 +989,144 @@ export class ConductorFirefoxWebSocketServer {
325     message: Record<string, unknown>
326   ): Promise<void> {
327     const platform = readFirstString(message, ["platform"]);
328+    const nowMs = this.getNowMilliseconds();
329 
330     if (platform == null) {
331       this.sendError(connection, "invalid_message", "credentials requires a platform field.");
332       return;
333     }
334 
335-    connection.updateCredential(platform, {
336-      capturedAt: readTimestampMilliseconds(message, "timestamp", Date.now()),
337-      headerCount: countObjectKeys(message.headers)
338-    });
339+    const capturedAt =
340+      readOptionalTimestampMilliseconds(message, ["captured_at", "capturedAt"])
341+      ?? readOptionalTimestampMilliseconds(message, ["timestamp"])
342+      ?? nowMs;
343+    const lastSeenAt =
344+      readOptionalTimestampMilliseconds(message, ["last_seen_at", "lastSeenAt"])
345+      ?? capturedAt;
346+    const credentialSummary: FirefoxBrowserCredentialSummary = {
347+      account: readFirstString(message, ["account"]),
348+      accountCapturedAt: readOptionalTimestampMilliseconds(message, ["account_captured_at", "accountCapturedAt"]),
349+      accountLastSeenAt: readOptionalTimestampMilliseconds(message, ["account_last_seen_at", "accountLastSeenAt"]),
350+      capturedAt,
351+      credentialFingerprint: readFirstString(message, ["credential_fingerprint", "credentialFingerprint"]),
352+      freshness: normalizeBrowserLoginStatus(message.freshness) ?? "fresh",
353+      headerCount: countHeaderNames(message),
354+      lastSeenAt
355+    };
356+
357+    connection.updateCredential(platform, credentialSummary);
358+
359+    const requestHook = connection.getRequestHook(platform);
360+
361+    if (requestHook != null) {
362+      connection.updateRequestHook(platform, {
363+        ...requestHook,
364+        account: requestHook.account ?? credentialSummary.account,
365+        credentialFingerprint: requestHook.credentialFingerprint ?? credentialSummary.credentialFingerprint
366+      });
367+    }
368+
369+    await this.persistCredentialSnapshot(connection, platform, credentialSummary);
370+    await this.refreshBrowserLoginStateStatuses();
371     await this.broadcastStateSnapshot("credentials");
372   }
373 
374+  private async persistCredentialSnapshot(
375+    connection: FirefoxWebSocketConnection,
376+    platform: string,
377+    summary: FirefoxBrowserCredentialSummary
378+  ): Promise<void> {
379+    const clientId = connection.getClientId();
380+    const fingerprint = summary.credentialFingerprint;
381+
382+    if (clientId == null || summary.account == null || fingerprint == null) {
383+      return;
384+    }
385+
386+    const capturedAt = toUnixSecondsMilliseconds(summary.capturedAt);
387+    const lastSeenAt = toUnixSecondsMilliseconds(summary.lastSeenAt ?? summary.capturedAt);
388+
389+    if (capturedAt == null || lastSeenAt == null) {
390+      return;
391+    }
392+
393+    const runtime = this.snapshotLoader();
394+    await this.repository.upsertBrowserLoginState({
395+      account: summary.account,
396+      browser: connection.session.nodePlatform ?? "firefox",
397+      capturedAt,
398+      clientId,
399+      credentialFingerprint: fingerprint,
400+      host: runtime.daemon.host,
401+      lastSeenAt,
402+      platform,
403+      status: getPersistedLoginStateStatus(summary.freshness)
404+    });
405+  }
406+
407   private async handleApiEndpoints(
408     connection: FirefoxWebSocketConnection,
409     message: Record<string, unknown>
410   ): Promise<void> {
411     const platform = readFirstString(message, ["platform"]);
412+    const nowMs = this.getNowMilliseconds();
413 
414     if (platform == null) {
415       this.sendError(connection, "invalid_message", "api_endpoints requires a platform field.");
416       return;
417     }
418 
419-    connection.updateRequestHook(platform, {
420+    const endpointMetadata = readEndpointMetadataArray(message, "endpoint_metadata");
421+    const credentialSummary = connection.getCredential(platform);
422+    const requestHookSummary: FirefoxBrowserHookSummary = {
423+      account:
424+        readFirstString(message, ["account"])
425+        ?? credentialSummary?.account
426+        ?? null,
427+      credentialFingerprint:
428+        readFirstString(message, ["credential_fingerprint", "credentialFingerprint"])
429+        ?? credentialSummary?.credentialFingerprint
430+        ?? null,
431+      endpointMetadata,
432       endpoints: readStringArray(message, "endpoints"),
433-      updatedAt: Date.now()
434-    });
435+      lastVerifiedAt: getLatestEndpointTimestamp(
436+        readOptionalTimestampMilliseconds(message, ["updated_at", "updatedAt"]),
437+        endpointMetadata,
438+        nowMs
439+      ),
440+      updatedAt: getLatestEndpointTimestamp(
441+        readOptionalTimestampMilliseconds(message, ["updated_at", "updatedAt"]),
442+        endpointMetadata,
443+        nowMs
444+      )
445+    };
446+
447+    connection.updateRequestHook(platform, requestHookSummary);
448+    await this.persistEndpointSnapshot(connection, platform, requestHookSummary);
449     await this.broadcastStateSnapshot("api_endpoints");
450   }
451 
452+  private async persistEndpointSnapshot(
453+    connection: FirefoxWebSocketConnection,
454+    platform: string,
455+    summary: FirefoxBrowserHookSummary
456+  ): Promise<void> {
457+    const clientId = connection.getClientId();
458+
459+    if (clientId == null || summary.account == null) {
460+      return;
461+    }
462+
463+    await this.repository.upsertBrowserEndpointMetadata({
464+      account: summary.account,
465+      clientId,
466+      endpoints: summary.endpoints,
467+      lastVerifiedAt: toUnixSecondsMilliseconds(summary.lastVerifiedAt),
468+      platform,
469+      updatedAt: toUnixSecondsMilliseconds(summary.updatedAt) ?? this.now()
470+    });
471+  }
472+
473   private handleApiResponse(
474     connection: FirefoxWebSocketConnection,
475     message: Record<string, unknown>
476@@ -860,6 +1153,74 @@ export class ConductorFirefoxWebSocketServer {
477     }
478   }
479 
480+  private async refreshBrowserLoginStateStatuses(): Promise<void> {
481+    await this.repository.markBrowserLoginStatesLost(
482+      this.now() - Math.ceil(FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS / 1000)
483+    );
484+    await this.repository.markBrowserLoginStatesStale(
485+      this.now() - Math.ceil(FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS / 1000)
486+    );
487+
488+    const nowMs = this.getNowMilliseconds();
489+
490+    for (const connection of this.connectionsByClientId.values()) {
491+      const agedStatus = getAgedLoginStateStatus(connection.session.lastMessageAt, nowMs);
492+
493+      if (agedStatus == null) {
494+        continue;
495+      }
496+
497+      await this.updateClientLoginStateStatus(connection.getClientId(), agedStatus);
498+    }
499+  }
500+
501+  private async markClientLoginStatesStale(clientId: string | null): Promise<void> {
502+    await this.updateClientLoginStateStatus(clientId, "stale");
503+  }
504+
505+  private async updateClientLoginStateStatus(
506+    clientId: string | null,
507+    nextStatus: BrowserBridgeLoginStatus
508+  ): Promise<void> {
509+    if (clientId == null) {
510+      return;
511+    }
512+
513+    const records = await this.repository.listBrowserLoginStates({
514+      clientId
515+    });
516+
517+    if (records.length === 0) {
518+      return;
519+    }
520+
521+    const connection = this.connectionsByClientId.get(clientId);
522+
523+    if (connection != null) {
524+      for (const [platform, summary] of connection.session.credentials.entries()) {
525+        const currentStatus = summary.freshness ?? "fresh";
526+
527+        if (getBrowserLoginStateRank(nextStatus) > getBrowserLoginStateRank(currentStatus)) {
528+          connection.updateCredential(platform, {
529+            ...summary,
530+            freshness: nextStatus
531+          });
532+        }
533+      }
534+    }
535+
536+    for (const record of records) {
537+      if (getBrowserLoginStateRank(nextStatus) <= getBrowserLoginStateRank(record.status)) {
538+        continue;
539+      }
540+
541+      await this.repository.upsertBrowserLoginState({
542+        ...record,
543+        status: nextStatus
544+      });
545+    }
546+  }
547+
548   private async buildStateSnapshot(): Promise<Record<string, unknown>> {
549     const runtime = this.snapshotLoader();
550 
M apps/conductor-daemon/src/index.test.js
+326, -4
  1@@ -480,9 +480,15 @@ function createBrowserBridgeStub() {
  2         connection_id: "conn-firefox-claude",
  3         credentials: [
  4           {
  5+            account: "ops@example.com",
  6+            account_captured_at: 1710000000500,
  7+            account_last_seen_at: 1710000000900,
  8             platform: "claude",
  9             captured_at: 1710000001000,
 10-            header_count: 3
 11+            credential_fingerprint: "fp-claude-stub",
 12+            freshness: "fresh",
 13+            header_count: 3,
 14+            last_seen_at: 1710000001100
 15           }
 16         ],
 17         last_message_at: 1710000002000,
 18@@ -491,13 +497,24 @@ function createBrowserBridgeStub() {
 19         node_type: "browser",
 20         request_hooks: [
 21           {
 22+            account: "ops@example.com",
 23+            credential_fingerprint: "fp-claude-stub",
 24             platform: "claude",
 25             endpoint_count: 3,
 26+            endpoint_metadata: [
 27+              {
 28+                method: "GET",
 29+                path: "/api/organizations",
 30+                first_seen_at: 1710000001500,
 31+                last_seen_at: 1710000002500
 32+              }
 33+            ],
 34             endpoints: [
 35               "GET /api/organizations",
 36               "GET /api/organizations/{id}/chat_conversations/{id}",
 37               "POST /api/organizations/{id}/chat_conversations/{id}/completion"
 38             ],
 39+            last_verified_at: 1710000003500,
 40             updated_at: 1710000003000
 41           }
 42         ]
 43@@ -794,6 +811,33 @@ async function waitForWebSocketClose(socket) {
 44   });
 45 }
 46 
 47+async function waitForCondition(assertion, timeoutMs = 2_000, intervalMs = 50) {
 48+  const deadline = Date.now() + timeoutMs;
 49+  let lastError = null;
 50+
 51+  while (Date.now() < deadline) {
 52+    try {
 53+      return await assertion();
 54+    } catch (error) {
 55+      lastError = error;
 56+      await new Promise((resolve) => setTimeout(resolve, intervalMs));
 57+    }
 58+  }
 59+
 60+  throw lastError ?? new Error("timed out waiting for condition");
 61+}
 62+
 63+async function fetchJson(url, init) {
 64+  const response = await fetch(url, init);
 65+  const text = await response.text();
 66+
 67+  return {
 68+    payload: text === "" ? null : JSON.parse(text),
 69+    response,
 70+    text
 71+  };
 72+}
 73+
 74 async function connectFirefoxBridgeClient(wsUrl, clientId) {
 75   const socket = new WebSocket(wsUrl);
 76   const queue = createWebSocketMessageQueue(socket);
 77@@ -1413,6 +1457,8 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 78     assert.equal(browserStatusPayload.data.bridge.client_count, 1);
 79     assert.equal(browserStatusPayload.data.current_client.client_id, "firefox-claude");
 80     assert.equal(browserStatusPayload.data.claude.ready, true);
 81+    assert.equal(browserStatusPayload.data.records[0].live.credentials.account, "ops@example.com");
 82+    assert.equal(browserStatusPayload.data.records[0].status, "fresh");
 83 
 84     const browserOpenResponse = await handleConductorHttpRequest(
 85       {
 86@@ -2348,13 +2394,35 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
 87         JSON.stringify({
 88           type: "api_endpoints",
 89           platform: "chatgpt",
 90-          endpoints: ["/backend-api/conversation", "/backend-api/models"]
 91+          account: "agent@example.com",
 92+          credential_fingerprint: "fp-chatgpt-test",
 93+          updated_at: 1_760_000_000_500,
 94+          endpoints: ["/backend-api/conversation", "/backend-api/models"],
 95+          endpoint_metadata: [
 96+            {
 97+              method: "POST",
 98+              path: "/backend-api/conversation",
 99+              first_seen_at: 1_760_000_000_100,
100+              last_seen_at: 1_760_000_000_400
101+            },
102+            {
103+              method: "GET",
104+              path: "/backend-api/models",
105+              first_seen_at: 1_760_000_000_200,
106+              last_seen_at: 1_760_000_000_500
107+            }
108+          ]
109         })
110       );
111       socket.send(
112         JSON.stringify({
113           type: "credentials",
114           platform: "chatgpt",
115+          account: "agent@example.com",
116+          credential_fingerprint: "fp-chatgpt-test",
117+          freshness: "fresh",
118+          captured_at: 1_760_000_000_000,
119+          last_seen_at: 1_760_000_000_500,
120           headers: {
121             authorization: "Bearer test-token",
122             cookie: "session=test"
123@@ -2368,8 +2436,17 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
124           message.type === "state_snapshot"
125           && message.snapshot.browser.clients.some((client) =>
126             client.client_id === "firefox-test"
127-            && client.credentials.some((entry) => entry.platform === "chatgpt" && entry.header_count === 2)
128-            && client.request_hooks.some((entry) => entry.platform === "chatgpt" && entry.endpoint_count === 2)
129+            && client.credentials.some((entry) =>
130+              entry.platform === "chatgpt"
131+              && entry.account === "agent@example.com"
132+              && entry.credential_fingerprint === "fp-chatgpt-test"
133+              && entry.header_count === 2
134+            )
135+            && client.request_hooks.some((entry) =>
136+              entry.platform === "chatgpt"
137+              && entry.account === "agent@example.com"
138+              && entry.endpoint_count === 2
139+            )
140           )
141       );
142       assert.equal(browserSnapshot.snapshot.browser.client_count, 1);
143@@ -2569,6 +2646,11 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
144       JSON.stringify({
145         type: "credentials",
146         platform: "claude",
147+        account: "ops@example.com",
148+        credential_fingerprint: "fp-claude-http",
149+        freshness: "fresh",
150+        captured_at: 1710000001000,
151+        last_seen_at: 1710000001500,
152         headers: {
153           cookie: "session=1",
154           "x-csrf-token": "token-1"
155@@ -2584,10 +2666,21 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
156       JSON.stringify({
157         type: "api_endpoints",
158         platform: "claude",
159+        account: "ops@example.com",
160+        credential_fingerprint: "fp-claude-http",
161+        updated_at: 1710000002000,
162         endpoints: [
163           "GET /api/organizations",
164           "GET /api/organizations/{id}/chat_conversations/{id}",
165           "POST /api/organizations/{id}/chat_conversations/{id}/completion"
166+        ],
167+        endpoint_metadata: [
168+          {
169+            method: "GET",
170+            path: "/api/organizations",
171+            first_seen_at: 1710000001200,
172+            last_seen_at: 1710000002000
173+          }
174         ]
175       })
176     );
177@@ -2601,6 +2694,8 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
178     assert.equal(browserStatusPayload.data.bridge.client_count, 1);
179     assert.equal(browserStatusPayload.data.claude.ready, true);
180     assert.equal(browserStatusPayload.data.current_client.client_id, "firefox-claude-http");
181+    assert.equal(browserStatusPayload.data.records[0].status, "fresh");
182+    assert.equal(browserStatusPayload.data.records[0].persisted.credential_fingerprint, "fp-claude-http");
183 
184     const openResponse = await fetch(`${baseUrl}/v1/browser/claude/open`, {
185       method: "POST",
186@@ -2804,6 +2899,233 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
187   }
188 });
189 
190+test("ConductorRuntime persists browser metadata across disconnect and restart without leaking raw credentials", async () => {
191+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-persistence-"));
192+  const createRuntime = () =>
193+    new ConductorRuntime(
194+      {
195+        nodeId: "mini-main",
196+        host: "mini",
197+        role: "primary",
198+        controlApiBase: "https://control.example.test",
199+        localApiBase: "http://127.0.0.1:0",
200+        sharedToken: "replace-me",
201+        paths: {
202+          runsDir: "/tmp/runs",
203+          stateDir
204+        }
205+      },
206+      {
207+        autoStartLoops: false,
208+        now: () => 100
209+      }
210+    );
211+
212+  let runtime = createRuntime();
213+  let client = null;
214+
215+  try {
216+    const snapshot = await runtime.start();
217+    const baseUrl = snapshot.controlApi.localApiBase;
218+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-persist");
219+
220+    client.socket.send(
221+      JSON.stringify({
222+        type: "credentials",
223+        platform: "claude",
224+        account: "persist@example.com",
225+        credential_fingerprint: "fp-claude-persist",
226+        freshness: "fresh",
227+        captured_at: 1710000001000,
228+        last_seen_at: 1710000001500,
229+        headers: {
230+          cookie: "session=persist-secret",
231+          "x-csrf-token": "csrf-persist-secret"
232+        }
233+      })
234+    );
235+    await client.queue.next(
236+      (message) => message.type === "state_snapshot" && message.reason === "credentials"
237+    );
238+
239+    client.socket.send(
240+      JSON.stringify({
241+        type: "api_endpoints",
242+        platform: "claude",
243+        account: "persist@example.com",
244+        credential_fingerprint: "fp-claude-persist",
245+        updated_at: 1710000002000,
246+        endpoints: [
247+          "GET /api/organizations",
248+          "POST /api/organizations/{id}/chat_conversations/{id}/completion"
249+        ],
250+        endpoint_metadata: [
251+          {
252+            method: "GET",
253+            path: "/api/organizations",
254+            first_seen_at: 1710000001200,
255+            last_seen_at: 1710000002000
256+          }
257+        ]
258+      })
259+    );
260+    await client.queue.next(
261+      (message) => message.type === "state_snapshot" && message.reason === "api_endpoints"
262+    );
263+
264+    const connectedStatus = await fetchJson(
265+      `${baseUrl}/v1/browser?platform=claude&account=persist%40example.com`
266+    );
267+    assert.equal(connectedStatus.response.status, 200);
268+    assert.equal(connectedStatus.payload.data.records.length, 1);
269+    assert.equal(connectedStatus.payload.data.records[0].view, "active_and_persisted");
270+    assert.equal(connectedStatus.payload.data.records[0].status, "fresh");
271+    assert.equal(connectedStatus.payload.data.records[0].live.credentials.account, "persist@example.com");
272+    assert.equal(
273+      connectedStatus.payload.data.records[0].persisted.credential_fingerprint,
274+      "fp-claude-persist"
275+    );
276+    assert.deepEqual(
277+      connectedStatus.payload.data.records[0].persisted.endpoints,
278+      [
279+        "GET /api/organizations",
280+        "POST /api/organizations/{id}/chat_conversations/{id}/completion"
281+      ]
282+    );
283+    assert.doesNotMatch(connectedStatus.text, /persist-secret/u);
284+
285+    const closePromise = waitForWebSocketClose(client.socket);
286+    client.socket.close(1000, "disconnect-persist");
287+    await closePromise;
288+    client.queue.stop();
289+    client = null;
290+
291+    const disconnectedStatus = await waitForCondition(async () => {
292+      const result = await fetchJson(
293+        `${baseUrl}/v1/browser?platform=claude&account=persist%40example.com`
294+      );
295+      assert.equal(result.payload.data.bridge.client_count, 0);
296+      assert.equal(result.payload.data.records.length, 1);
297+      assert.equal(result.payload.data.records[0].view, "persisted_only");
298+      assert.equal(result.payload.data.records[0].status, "stale");
299+      return result;
300+    });
301+    assert.equal(disconnectedStatus.payload.data.records[0].persisted.status, "stale");
302+
303+    await runtime.stop();
304+    runtime = null;
305+
306+    runtime = createRuntime();
307+    const restartedSnapshot = await runtime.start();
308+    const restartedStatus = await fetchJson(
309+      `${restartedSnapshot.controlApi.localApiBase}/v1/browser?platform=claude&account=persist%40example.com`
310+    );
311+    assert.equal(restartedStatus.response.status, 200);
312+    assert.equal(restartedStatus.payload.data.bridge.client_count, 0);
313+    assert.equal(restartedStatus.payload.data.records.length, 1);
314+    assert.equal(restartedStatus.payload.data.records[0].view, "persisted_only");
315+    assert.equal(restartedStatus.payload.data.records[0].persisted.credential_fingerprint, "fp-claude-persist");
316+  } finally {
317+    client?.queue.stop();
318+    client?.socket.close(1000, "done");
319+
320+    if (runtime != null) {
321+      await runtime.stop();
322+    }
323+
324+    rmSync(stateDir, {
325+      force: true,
326+      recursive: true
327+    });
328+  }
329+});
330+
331+test("ConductorRuntime ages browser login state from fresh to stale to lost when Firefox WS traffic goes quiet", async () => {
332+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-aging-"));
333+  let nowSeconds = 100;
334+  const runtime = new ConductorRuntime(
335+    {
336+      nodeId: "mini-main",
337+      host: "mini",
338+      role: "primary",
339+      controlApiBase: "https://control.example.test",
340+      localApiBase: "http://127.0.0.1:0",
341+      sharedToken: "replace-me",
342+      paths: {
343+        runsDir: "/tmp/runs",
344+        stateDir
345+      }
346+    },
347+    {
348+      autoStartLoops: false,
349+      now: () => nowSeconds
350+    }
351+  );
352+
353+  let client = null;
354+
355+  try {
356+    const snapshot = await runtime.start();
357+    const baseUrl = snapshot.controlApi.localApiBase;
358+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-aging");
359+
360+    client.socket.send(
361+      JSON.stringify({
362+        type: "credentials",
363+        platform: "claude",
364+        account: "aging@example.com",
365+        credential_fingerprint: "fp-aging",
366+        freshness: "fresh",
367+        captured_at: 100_000,
368+        last_seen_at: 100_000,
369+        headers: {
370+          cookie: "session=aging"
371+        }
372+      })
373+    );
374+    await client.queue.next(
375+      (message) => message.type === "state_snapshot" && message.reason === "credentials"
376+    );
377+
378+    const freshStatus = await fetchJson(
379+      `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
380+    );
381+    assert.equal(freshStatus.payload.data.records[0].status, "fresh");
382+
383+    nowSeconds = 160;
384+    await new Promise((resolve) => setTimeout(resolve, 2_200));
385+
386+    const staleStatus = await waitForCondition(async () => {
387+      const result = await fetchJson(
388+        `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
389+      );
390+      assert.equal(result.payload.data.records[0].status, "stale");
391+      return result;
392+    }, 3_000, 100);
393+    assert.equal(staleStatus.payload.data.records[0].view, "active_and_persisted");
394+
395+    nowSeconds = 260;
396+    await new Promise((resolve) => setTimeout(resolve, 2_200));
397+
398+    const lostStatus = await waitForCondition(async () => {
399+      const result = await fetchJson(
400+        `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
401+      );
402+      assert.equal(result.payload.data.records[0].status, "lost");
403+      return result;
404+    }, 3_000, 100);
405+    assert.equal(lostStatus.payload.data.records[0].persisted.status, "lost");
406+  } finally {
407+    client?.queue.stop();
408+    client?.socket.close(1000, "done");
409+    await runtime.stop();
410+    rmSync(stateDir, {
411+      force: true,
412+      recursive: true
413+    });
414+  }
415+});
416+
417 test("Firefox bridge api requests reject on timeout, disconnect, and replacement", async () => {
418   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-firefox-bridge-errors-"));
419   const runtime = new ConductorRuntime(
M apps/conductor-daemon/src/local-api.ts
+388, -6
  1@@ -4,6 +4,9 @@ import {
  2   TASK_STATUS_VALUES,
  3   parseJsonText,
  4   type AutomationMode,
  5+  type BrowserEndpointMetadataRecord,
  6+  type BrowserLoginStateRecord,
  7+  type BrowserLoginStateStatus,
  8   type ControlPlaneRepository,
  9   type ControllerRecord,
 10   type JsonObject,
 11@@ -51,6 +54,7 @@ const MAX_LIST_LIMIT = 100;
 12 const MAX_LOG_LIMIT = 500;
 13 const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
 14 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
 15+const BROWSER_LOGIN_STATUS_SET = new Set<BrowserLoginStateStatus>(["fresh", "stale", "lost"]);
 16 const CODEXD_LOCAL_API_ENV = "BAA_CODEXD_LOCAL_API_BASE";
 17 const CODEX_ROUTE_IDS = new Set([
 18   "codex.status",
 19@@ -76,6 +80,7 @@ const BROWSER_CLAUDE_COMPLETION_PATH = "/api/organizations/{id}/chat_conversatio
 20 type LocalApiRouteMethod = "GET" | "POST";
 21 type LocalApiRouteKind = "probe" | "read" | "write";
 22 type LocalApiDescribeSurface = "business" | "control";
 23+type BrowserRecordView = "active_and_persisted" | "active_only" | "persisted_only";
 24 type SharedTokenAuthFailureReason =
 25   | "empty_bearer_token"
 26   | "invalid_authorization_scheme"
 27@@ -96,6 +101,30 @@ interface LocalApiRouteMatch {
 28   route: LocalApiRouteDefinition;
 29 }
 30 
 31+interface BrowserStatusFilters {
 32+  account?: string;
 33+  browser?: string;
 34+  clientId?: string;
 35+  host?: string;
 36+  limit: number;
 37+  platform?: string;
 38+  status?: BrowserLoginStateStatus;
 39+}
 40+
 41+interface BrowserMergedRecord {
 42+  account: string | null;
 43+  activeConnection: BrowserBridgeClientSnapshot | null;
 44+  browser: string | null;
 45+  clientId: string;
 46+  credential: BrowserBridgeCredentialSnapshot | null;
 47+  endpointMetadata: BrowserEndpointMetadataRecord | null;
 48+  host: string | null;
 49+  persistedLoginState: BrowserLoginStateRecord | null;
 50+  platform: string;
 51+  requestHook: BrowserBridgeRequestHookSnapshot | null;
 52+  view: BrowserRecordView;
 53+}
 54+
 55 type UpstreamSuccessEnvelope = JsonObject & {
 56   data: JsonValue;
 57   ok: true;
 58@@ -771,6 +800,40 @@ function readTaskStatusFilter(url: URL): TaskStatus | undefined {
 59   return rawValue as TaskStatus;
 60 }
 61 
 62+function readBrowserLoginStatusFilter(url: URL): BrowserLoginStateStatus | undefined {
 63+  const rawValue = url.searchParams.get("status");
 64+
 65+  if (rawValue == null || rawValue.trim() === "") {
 66+    return undefined;
 67+  }
 68+
 69+  if (!BROWSER_LOGIN_STATUS_SET.has(rawValue as BrowserLoginStateStatus)) {
 70+    throw new LocalApiHttpError(
 71+      400,
 72+      "invalid_request",
 73+      'Query parameter "status" must be one of fresh, stale, lost.',
 74+      {
 75+        field: "status",
 76+        allowed_values: ["fresh", "stale", "lost"]
 77+      }
 78+    );
 79+  }
 80+
 81+  return rawValue as BrowserLoginStateStatus;
 82+}
 83+
 84+function readBrowserStatusFilters(url: URL): BrowserStatusFilters {
 85+  return {
 86+    account: readOptionalQueryString(url, "account"),
 87+    browser: readOptionalQueryString(url, "browser"),
 88+    clientId: readOptionalQueryString(url, "client_id", "clientId"),
 89+    host: readOptionalQueryString(url, "host", "machine"),
 90+    limit: readPositiveIntegerQuery(url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT),
 91+    platform: readOptionalQueryString(url, "platform"),
 92+    status: readBrowserLoginStatusFilter(url)
 93+  };
 94+}
 95+
 96 function requireRepository(repository: ControlPlaneRepository | null): ControlPlaneRepository {
 97   if (repository == null) {
 98     throw new LocalApiHttpError(
 99@@ -1257,6 +1320,283 @@ function selectClaudeBrowserClient(
100   };
101 }
102 
103+function buildBrowserRecordKey(platform: string, clientId: string, account: string | null): string {
104+  return `${platform}\u0000${clientId}\u0000${account ?? ""}`;
105+}
106+
107+function findMatchingRequestHook(
108+  client: BrowserBridgeClientSnapshot,
109+  platform: string,
110+  account: string | null
111+): BrowserBridgeRequestHookSnapshot | null {
112+  const directMatch =
113+    client.request_hooks.find((entry) =>
114+      entry.platform === platform && (account == null || entry.account == null || entry.account === account)
115+    ) ?? null;
116+
117+  if (directMatch != null) {
118+    return directMatch;
119+  }
120+
121+  return client.request_hooks.find((entry) => entry.platform === platform) ?? null;
122+}
123+
124+function upsertBrowserMergedRecord(
125+  records: Map<string, BrowserMergedRecord>,
126+  record: BrowserMergedRecord
127+): void {
128+  const key = buildBrowserRecordKey(record.platform, record.clientId, record.account);
129+  const current = records.get(key);
130+
131+  if (current == null) {
132+    records.set(key, record);
133+    return;
134+  }
135+
136+  records.set(key, {
137+    account: record.account ?? current.account,
138+    activeConnection: record.activeConnection ?? current.activeConnection,
139+    browser: record.browser ?? current.browser,
140+    clientId: record.clientId,
141+    credential: record.credential ?? current.credential,
142+    endpointMetadata: record.endpointMetadata ?? current.endpointMetadata,
143+    host: record.host ?? current.host,
144+    persistedLoginState: record.persistedLoginState ?? current.persistedLoginState,
145+    platform: record.platform,
146+    requestHook: record.requestHook ?? current.requestHook,
147+    view:
148+      (record.activeConnection ?? current.activeConnection) != null
149+      && (record.persistedLoginState ?? current.persistedLoginState) != null
150+        ? "active_and_persisted"
151+        : (record.activeConnection ?? current.activeConnection) != null
152+          ? "active_only"
153+          : "persisted_only"
154+  });
155+}
156+
157+function buildActiveBrowserRecords(
158+  state: BrowserBridgeStateSnapshot,
159+  host: string | null
160+): Map<string, BrowserMergedRecord> {
161+  const records = new Map<string, BrowserMergedRecord>();
162+
163+  for (const client of state.clients) {
164+    for (const credential of client.credentials) {
165+      upsertBrowserMergedRecord(records, {
166+        account: credential.account,
167+        activeConnection: client,
168+        browser: client.node_platform,
169+        clientId: client.client_id,
170+        credential,
171+        endpointMetadata: null,
172+        host,
173+        persistedLoginState: null,
174+        platform: credential.platform,
175+        requestHook: findMatchingRequestHook(client, credential.platform, credential.account),
176+        view: "active_only"
177+      });
178+    }
179+
180+    for (const requestHook of client.request_hooks) {
181+      upsertBrowserMergedRecord(records, {
182+        account: requestHook.account,
183+        activeConnection: client,
184+        browser: client.node_platform,
185+        clientId: client.client_id,
186+        credential:
187+          client.credentials.find((entry) =>
188+            entry.platform === requestHook.platform
189+            && (requestHook.account == null || entry.account == null || entry.account === requestHook.account)
190+          ) ?? null,
191+        endpointMetadata: null,
192+        host,
193+        persistedLoginState: null,
194+        platform: requestHook.platform,
195+        requestHook,
196+        view: "active_only"
197+      });
198+    }
199+  }
200+
201+  return records;
202+}
203+
204+function resolveBrowserRecordStatus(record: BrowserMergedRecord): BrowserLoginStateStatus | null {
205+  return record.persistedLoginState?.status ?? record.credential?.freshness ?? null;
206+}
207+
208+function matchesBrowserStatusFilters(record: BrowserMergedRecord, filters: BrowserStatusFilters): boolean {
209+  if (filters.platform != null && record.platform !== filters.platform) {
210+    return false;
211+  }
212+
213+  if (filters.browser != null && record.browser !== filters.browser) {
214+    return false;
215+  }
216+
217+  if (filters.host != null && record.host !== filters.host) {
218+    return false;
219+  }
220+
221+  if (filters.account != null && record.account !== filters.account) {
222+    return false;
223+  }
224+
225+  if (filters.clientId != null && record.clientId !== filters.clientId) {
226+    return false;
227+  }
228+
229+  if (filters.status != null && resolveBrowserRecordStatus(record) !== filters.status) {
230+    return false;
231+  }
232+
233+  return true;
234+}
235+
236+function getBrowserRecordSortTimestamp(record: BrowserMergedRecord): number {
237+  return (
238+    record.activeConnection?.last_message_at
239+    ?? record.credential?.last_seen_at
240+    ?? toUnixMilliseconds(record.persistedLoginState?.lastSeenAt)
241+    ?? record.requestHook?.updated_at
242+    ?? toUnixMilliseconds(record.endpointMetadata?.updatedAt)
243+    ?? 0
244+  );
245+}
246+
247+function summarizeBrowserFilters(filters: BrowserStatusFilters): JsonObject {
248+  return compactJsonObject({
249+    account: filters.account,
250+    browser: filters.browser,
251+    client_id: filters.clientId,
252+    host: filters.host,
253+    limit: filters.limit,
254+    platform: filters.platform,
255+    status: filters.status
256+  });
257+}
258+
259+async function listBrowserMergedRecords(
260+  context: LocalApiRequestContext,
261+  state: BrowserBridgeStateSnapshot,
262+  filters: BrowserStatusFilters
263+): Promise<BrowserMergedRecord[]> {
264+  const snapshot = context.snapshotLoader();
265+  const records = buildActiveBrowserRecords(state, snapshot.daemon.host);
266+
267+  if (context.repository != null) {
268+    const persistedLoginStates = await context.repository.listBrowserLoginStates({
269+      account: filters.account,
270+      browser: filters.browser,
271+      clientId: filters.clientId,
272+      host: filters.host,
273+      limit: filters.limit,
274+      platform: filters.platform,
275+      status: filters.status
276+    });
277+    const endpointMetadata = await context.repository.listBrowserEndpointMetadata({
278+      account: filters.account,
279+      clientId: filters.clientId,
280+      limit: filters.limit,
281+      platform: filters.platform
282+    });
283+    const endpointsByKey = new Map<string, BrowserEndpointMetadataRecord>();
284+
285+    for (const entry of endpointMetadata) {
286+      endpointsByKey.set(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account), entry);
287+    }
288+
289+    for (const entry of persistedLoginStates) {
290+      upsertBrowserMergedRecord(records, {
291+        account: entry.account,
292+        activeConnection: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.activeConnection ?? null,
293+        browser: entry.browser,
294+        clientId: entry.clientId,
295+        credential: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.credential ?? null,
296+        endpointMetadata: endpointsByKey.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account)) ?? null,
297+        host: entry.host,
298+        persistedLoginState: entry,
299+        platform: entry.platform,
300+        requestHook: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.requestHook ?? null,
301+        view: "persisted_only"
302+      });
303+    }
304+
305+    for (const entry of endpointMetadata) {
306+      upsertBrowserMergedRecord(records, {
307+        account: entry.account,
308+        activeConnection: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.activeConnection ?? null,
309+        browser: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.browser ?? null,
310+        clientId: entry.clientId,
311+        credential: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.credential ?? null,
312+        endpointMetadata: entry,
313+        host: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.host ?? null,
314+        persistedLoginState: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.persistedLoginState ?? null,
315+        platform: entry.platform,
316+        requestHook: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.requestHook ?? null,
317+        view: "persisted_only"
318+      });
319+    }
320+  }
321+
322+  return [...records.values()]
323+    .filter((record) => matchesBrowserStatusFilters(record, filters))
324+    .sort((left, right) => getBrowserRecordSortTimestamp(right) - getBrowserRecordSortTimestamp(left))
325+    .slice(0, filters.limit);
326+}
327+
328+function serializeBrowserMergedRecord(
329+  record: BrowserMergedRecord,
330+  activeClientId: string | null
331+): JsonObject {
332+  return {
333+    account: record.account,
334+    active_connection:
335+      record.activeConnection == null
336+        ? null
337+        : compactJsonObject({
338+            active_client: record.activeConnection.client_id === activeClientId,
339+            connected_at: record.activeConnection.connected_at,
340+            connection_id: record.activeConnection.connection_id,
341+            last_message_at: record.activeConnection.last_message_at,
342+            node_category: record.activeConnection.node_category,
343+            node_platform: record.activeConnection.node_platform,
344+            node_type: record.activeConnection.node_type
345+          }),
346+    browser: record.browser,
347+    client_id: record.clientId,
348+    host: record.host,
349+    live:
350+      record.credential == null && record.requestHook == null
351+        ? null
352+        : compactJsonObject({
353+            credentials:
354+              record.credential == null
355+                ? undefined
356+                : serializeBrowserCredentialSnapshot(record.credential),
357+            request_hooks:
358+              record.requestHook == null
359+                ? undefined
360+                : serializeBrowserRequestHookSnapshot(record.requestHook)
361+          }),
362+    persisted:
363+      record.persistedLoginState == null && record.endpointMetadata == null
364+        ? null
365+        : compactJsonObject({
366+            captured_at: toUnixMilliseconds(record.persistedLoginState?.capturedAt),
367+            credential_fingerprint: record.persistedLoginState?.credentialFingerprint,
368+            endpoints: record.endpointMetadata?.endpoints,
369+            last_seen_at: toUnixMilliseconds(record.persistedLoginState?.lastSeenAt),
370+            last_verified_at: toUnixMilliseconds(record.endpointMetadata?.lastVerifiedAt),
371+            status: record.persistedLoginState?.status,
372+            updated_at: toUnixMilliseconds(record.endpointMetadata?.updatedAt)
373+          }),
374+    platform: record.platform,
375+    status: resolveBrowserRecordStatus(record),
376+    view: record.view
377+  };
378+}
379+
380 function readBridgeErrorCode(error: unknown): string | null {
381   return readUnknownString(asUnknownRecord(error), ["code"]);
382 }
383@@ -1367,20 +1707,37 @@ function ensureClaudeBridgeReady(
384 }
385 
386 function serializeBrowserCredentialSnapshot(snapshot: BrowserBridgeCredentialSnapshot): JsonObject {
387-  return {
388+  return compactJsonObject({
389+    account: snapshot.account ?? undefined,
390+    account_captured_at: snapshot.account_captured_at ?? undefined,
391+    account_last_seen_at: snapshot.account_last_seen_at ?? undefined,
392     captured_at: snapshot.captured_at,
393+    credential_fingerprint: snapshot.credential_fingerprint ?? undefined,
394+    freshness: snapshot.freshness ?? undefined,
395     header_count: snapshot.header_count,
396+    last_seen_at: snapshot.last_seen_at ?? undefined,
397     platform: snapshot.platform
398-  };
399+  });
400 }
401 
402 function serializeBrowserRequestHookSnapshot(snapshot: BrowserBridgeRequestHookSnapshot): JsonObject {
403-  return {
404+  return compactJsonObject({
405+    account: snapshot.account ?? undefined,
406+    credential_fingerprint: snapshot.credential_fingerprint ?? undefined,
407     endpoint_count: snapshot.endpoint_count,
408+    endpoint_metadata: snapshot.endpoint_metadata.map((entry) =>
409+      compactJsonObject({
410+        first_seen_at: entry.first_seen_at ?? undefined,
411+        last_seen_at: entry.last_seen_at ?? undefined,
412+        method: entry.method ?? undefined,
413+        path: entry.path
414+      })
415+    ),
416     endpoints: [...snapshot.endpoints],
417+    last_verified_at: snapshot.last_verified_at ?? undefined,
418     platform: snapshot.platform,
419     updated_at: snapshot.updated_at
420-  };
421+  });
422 }
423 
424 function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot): JsonObject {
425@@ -1900,13 +2257,28 @@ async function readClaudeConversationCurrentData(
426   };
427 }
428 
429-function buildBrowserStatusData(context: LocalApiRequestContext): JsonObject {
430+async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<JsonObject> {
431   const browserState = loadBrowserState(context);
432+  const filters = readBrowserStatusFilters(context.url);
433+  const records = await listBrowserMergedRecords(context, browserState, filters);
434   const currentClient =
435     browserState.clients.find((client) => client.client_id === browserState.active_client_id)
436     ?? [...browserState.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
437     ?? null;
438   const claudeSelection = selectClaudeBrowserClient(browserState);
439+  const statusCounts = {
440+    fresh: 0,
441+    stale: 0,
442+    lost: 0
443+  };
444+
445+  for (const record of records) {
446+    const status = resolveBrowserRecordStatus(record);
447+
448+    if (status != null) {
449+      statusCounts[status] += 1;
450+    }
451+  }
452 
453   return {
454     bridge: {
455@@ -1934,6 +2306,16 @@ function buildBrowserStatusData(context: LocalApiRequestContext): JsonObject {
456           ? null
457           : serializeBrowserRequestHookSnapshot(claudeSelection.requestHook),
458       supported: true
459+    },
460+    filters: summarizeBrowserFilters(filters),
461+    records: records.map((record) =>
462+      serializeBrowserMergedRecord(record, browserState.active_client_id)
463+    ),
464+    summary: {
465+      active_records: records.filter((record) => record.activeConnection != null).length,
466+      matched_records: records.length,
467+      persisted_only_records: records.filter((record) => record.view === "persisted_only").length,
468+      status_counts: statusCounts
469     }
470   };
471 }
472@@ -2932,7 +3314,7 @@ async function handleStatusViewUiRead(context: LocalApiRequestContext): Promise<
473 }
474 
475 async function handleBrowserStatusRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
476-  return buildSuccessEnvelope(context.requestId, 200, buildBrowserStatusData(context));
477+  return buildSuccessEnvelope(context.requestId, 200, await buildBrowserStatusData(context));
478 }
479 
480 async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
M docs/api/README.md
+30, -13
 1@@ -124,25 +124,33 @@
 2 
 3 ### Browser HTTP 接口
 4 
 5-当前正式浏览器面只支持 Claude,并且只通过 `conductor` HTTP 暴露:
 6+当前正式浏览器桥接分成两层:
 7+
 8+- `GET /v1/browser`:浏览器登录态元数据与持久化状态读面
 9+- `POST` / `GET /v1/browser/claude/*`:Claude 专用的浏览器本地代发面
10 
11 | 方法 | 路径 | 说明 |
12 | --- | --- | --- |
13-| `GET` | `/v1/browser` | 读取 Firefox bridge 摘要、当前 client 和 Claude 就绪状态 |
14-| `POST` | `/v1/browser/claude/open` | 打开或聚焦 Claude 标签页 |
15+| `GET` | `/v1/browser` | 读取活跃 Firefox bridge、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
16+| `POST` | `/v1/browser/claude/open` | 打开或聚焦 Claude shell 标签页 |
17 | `POST` | `/v1/browser/claude/send` | 发起一轮 Claude prompt;由 daemon 转发到本地 `/ws/firefox`,再由插件走页面内 HTTP 代理 |
18-| `GET` | `/v1/browser/claude/current` | 读取当前 Claude 对话内容和页面代理状态 |
19+| `GET` | `/v1/browser/claude/current` | 读取当前 Claude 代理回读结果;这是 Claude relay 辅助读接口,不是持久化主模型 |
20 | `POST` | `/v1/browser/claude/reload` | 请求当前 Claude bridge 页面重载 |
21 
22 Browser 面约定:
23 
24 - Claude 浏览器动作属于业务面,AI / CLI 应先读 `GET /describe/business`
25-- 当前只支持 `claude`
26+- 当前正式模型是“单平台单空壳页 + 登录态元数据持久化 + 浏览器本地代发”;`GET /v1/browser/claude/current` 只是 Claude relay 辅助读接口
27+- `GET /v1/browser` 是当前正式浏览器桥接 truth read;支持 `platform`、`account`、`browser`、`client_id`、`host`、`status` 过滤
28+- `records[]` 会合并“活跃 WS 连接视图”和“持久化记录视图”,`view` 可能是 `active_and_persisted`、`active_only` 或 `persisted_only`
29+- `conductor` 只保存并回显 `account`、`credential_fingerprint`、`endpoints`、`endpoint_metadata`、时间戳和 `fresh/stale/lost`
30+- 原始 `cookie`、`token`、header 值不会入库,也不会出现在 `/v1/browser` 读接口里
31+- 连接断开或流量老化后,持久化记录仍可读,但状态会从 `fresh` 变成 `stale` / `lost`
32+- 当前浏览器本地代发面只支持 `claude`;ChatGPT / Gemini 目前只有壳页和元数据上报,不在正式 HTTP relay 合同里
33 - `open` 通过本地 WS 下发 `open_tab`
34-- `send` / `current` 优先通过本地 WS 下发 `api_request`,由插件转成本页 HTTP 请求
35+- `send` / `current` 优先通过本地 WS 下发 `api_request`,由插件在浏览器本地代发,不是 DOM 自动化
36 - `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
37 - `send` / `current` 只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
38-- ChatGPT / Gemini 当前不在正式 `/v1/browser/*` 合同里
39 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
40 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
41 
42@@ -155,11 +163,18 @@ LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
43 curl "${LOCAL_API_BASE}/describe/business"
44 ```
45 
46-读取 bridge 状态:
47+读取登录态与持久化状态:
48+
49+```bash
50+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
51+curl "${LOCAL_API_BASE}/v1/browser?platform=claude&status=fresh"
52+```
53+
54+按账号读取持久化记录:
55 
56 ```bash
57 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
58-curl "${LOCAL_API_BASE}/v1/browser"
59+curl "${LOCAL_API_BASE}/v1/browser?platform=claude&account=smoke%40example.com"
60 ```
61 
62 打开或聚焦 Claude 标签页:
63@@ -180,7 +195,7 @@ curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/send" \
64   -d '{"prompt":"Summarize the current bridge state."}'
65 ```
66 
67-读取当前对话:
68+读取当前 Claude 代理回读:
69 
70 ```bash
71 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
72@@ -274,10 +289,12 @@ host-ops 约定:
73 
74 - 只服务本地 / loopback / 明确允许的 Tailscale `100.x` 地址
75 - 不是公网通道,不单独暴露到 `conductor.makefile.so`
76-- Firefox 插件默认同时使用本地 WS 和 `https://conductor.makefile.so` 的 HTTP 控制接口
77+- Firefox 插件默认同时使用本地 WS 和同一个本地 HTTP listener
78 - `state_snapshot.system` 直接复用 `/v1/system/state` 的字段结构
79 - `action_request` 支持 `pause` / `resume` / `drain`
80-- 浏览器发来的 `credentials` / `api_endpoints` 只在服务端保存最小元数据并进入 snapshot,不回显原始 header 值
81+- 浏览器发来的 `credentials` / `api_endpoints` 会被转换成 `account`、凭证指纹、端点元数据和 `fresh/stale/lost` 持久化记录
82+- `headers` 只保留名称与数量;原始 `cookie` / `token` / header 值既不会入库,也不会在 snapshot 或 `/v1/browser` 中回显
83+- `GET /v1/browser` 会合并当前活跃连接和持久化记录;即使 client 断开或 daemon 重启,最近一次记录仍可读取
84 - `/v1/browser/claude/send` 和 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` 做 Claude 页面内 HTTP 代理
85 
86 详细消息模型和 smoke 示例见:
87@@ -347,7 +364,7 @@ curl "${LOCAL_API_BASE}/v1/codex"
88 
89 ```bash
90 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
91-curl "${LOCAL_API_BASE}/v1/browser"
92+curl "${LOCAL_API_BASE}/v1/browser?platform=claude&status=fresh"
93 ```
94 
95 ```bash
M docs/api/business-interfaces.md
+11, -7
 1@@ -53,24 +53,27 @@
 2 
 3 | 方法 | 路径 | 作用 |
 4 | --- | --- | --- |
 5-| `GET` | `/v1/browser` | 查看本地 Firefox bridge 摘要、当前 client 和 Claude 就绪状态 |
 6-| `GET` | `/v1/browser/claude/current` | 查看当前 Claude 对话内容和页面代理状态 |
 7+| `GET` | `/v1/browser` | 查看浏览器登录态元数据、持久化记录和 `fresh` / `stale` / `lost` 状态 |
 8+| `GET` | `/v1/browser/claude/current` | 查看当前 Claude 代理回读结果;这是 Claude relay 辅助读接口,不是浏览器桥接主模型 |
 9 | `GET` | `/v1/controllers?limit=20` | 查看当前 controller 摘要 |
10 | `GET` | `/v1/tasks?status=queued&limit=20` | 查看任务列表,可按 `status` 过滤 |
11 | `GET` | `/v1/tasks/:task_id` | 查看单个任务详情 |
12 | `GET` | `/v1/tasks/:task_id/logs?limit=200` | 查看单个任务日志,可按 `run_id` 过滤 |
13 
14-### 浏览器 Claude 写接口
15+### 浏览器 Claude 代发接口
16 
17 | 方法 | 路径 | 作用 |
18 | --- | --- | --- |
19-| `POST` | `/v1/browser/claude/open` | 打开或聚焦 Claude 标签页 |
20+| `POST` | `/v1/browser/claude/open` | 打开或聚焦 Claude shell 标签页 |
21 | `POST` | `/v1/browser/claude/send` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起一轮 Claude prompt |
22 | `POST` | `/v1/browser/claude/reload` | 请求当前 Claude bridge 页面重载 |
23 
24 说明:
25 
26-- 当前浏览器面只支持 `claude`
27+- `GET /v1/browser` 是当前正式浏览器桥接读面;支持按 `platform`、`account`、`client_id`、`host`、`status` 过滤
28+- 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
29+- `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
30+- 当前浏览器代发面只支持 `claude`
31 - `send` / `current` 不是 DOM 自动化,而是通过插件已有的页面内 HTTP 代理完成
32 - 如果没有活跃 Firefox bridge client,会返回 `503`
33 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
34@@ -133,7 +136,7 @@ curl "${BASE_URL}/v1/codex"
35 
36 ```bash
37 BASE_URL="http://100.71.210.78:4317"
38-curl "${BASE_URL}/v1/browser"
39+curl "${BASE_URL}/v1/browser?platform=claude&status=fresh"
40 ```
41 
42 ```bash
43@@ -200,7 +203,8 @@ curl "${BASE_URL}/v1/tasks/${TASK_ID}/logs?limit=50"
44 ## 当前边界
45 
46 - 业务类接口当前以“只读查询”为主
47-- 浏览器业务面当前只正式支持 Claude,且依赖本地 Firefox bridge 已连接
48+- 浏览器业务面当前以“登录态元数据读面 + Claude 浏览器本地代发”收口,不把页面对话 UI 当成正式能力
49+- 浏览器 relay 当前只正式支持 Claude,且依赖本地 Firefox bridge 已连接
50 - `/v1/codex/*` 是少数已经正式开放的业务写接口,但后端固定代理到独立 `codexd`
51 - 控制动作例如 `pause` / `resume` / `drain` 不在本文件讨论范围内
52 - 本机能力接口 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 也不在本文件讨论范围内
M docs/api/firefox-local-ws.md
+51, -12
  1@@ -22,7 +22,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
  2 - path 固定是 `/ws/firefox`
  3 - 只应监听 loopback 或显式允许的 Tailscale `100.x` 地址
  4 - 不是公网入口
  5-- 当前正式 `/v1/browser/*` HTTP 面只支持 `claude`;WS transport 里的 `platform` 字段仍保留扩展空间
  6+- 当前正式浏览器代发 HTTP 面只支持 `claude`;但 `GET /v1/browser` 的元数据读面可以返回所有已上报 `platform`
  7 
  8 ## 自动重连语义
  9 
 10@@ -40,8 +40,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 11 | `hello` | 注册当前 Firefox client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
 12 | `state_request` | 主动请求最新 snapshot |
 13 | `action_request` | 请求执行 `pause` / `resume` / `drain` |
 14-| `credentials` | 上送浏览器凭证快照;server 只保留平台、header 数量、时间戳等最小元数据 |
 15-| `api_endpoints` | 上送当前 request hook 观察到的 endpoint 列表 |
 16+| `credentials` | 上送账号、凭证指纹、新鲜度和脱敏 header 名称摘要;server 只持久化最小元数据 |
 17+| `api_endpoints` | 上送当前可代发的 endpoint 列表及其 `endpoint_metadata` |
 18 | `api_response` | 对服务端下发的 `api_request` 回包,按 `id` 做 request-response 关联 |
 19 | `client_log` | 可选日志消息;当前 server 只接收,不做业务处理 |
 20 
 21@@ -134,8 +134,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 22 说明:
 23 
 24 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
 25-- `snapshot.browser.clients[].credentials` 只回传 `platform`、`header_count`、`captured_at`
 26-- `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表和更新时间
 27+- `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
 28+- `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表、`endpoint_metadata` 和更新时间
 29 
 30 ### `action_request`
 31 
 32@@ -176,10 +176,15 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 33 {
 34   "type": "credentials",
 35   "platform": "claude",
 36+  "account": "user@example.com",
 37+  "credential_fingerprint": "fp-claude-demo",
 38+  "freshness": "fresh",
 39+  "captured_at": 1760000000000,
 40+  "last_seen_at": 1760000005000,
 41   "headers": {
 42-    "cookie": "session=...",
 43-    "x-csrf-token": "...",
 44-    "anthropic-client-version": "web"
 45+    "cookie": "<redacted>",
 46+    "x-csrf-token": "<redacted>",
 47+    "anthropic-client-version": "<redacted>"
 48   },
 49   "timestamp": 1760000000000
 50 }
 51@@ -187,9 +192,39 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 52 
 53 server 行为:
 54 
 55-- 接收原始 header 作为浏览器凭证快照输入
 56-- 不在 `state_snapshot` 中回显 header 原文
 57-- 只把 `platform`、`header_count`、`captured_at` 汇总到 browser snapshot
 58+- 把 `account`、`credential_fingerprint`、`freshness`、`captured_at`、`last_seen_at` 写入持久化登录态记录
 59+- `headers` 只用于保留名称和数量;这些值应当已经是脱敏占位符
 60+- 不在 `state_snapshot` 或 `GET /v1/browser` 中回显原始 `cookie` / `token` / header 值
 61+
 62+### `api_endpoints`
 63+
 64+```json
 65+{
 66+  "type": "api_endpoints",
 67+  "platform": "claude",
 68+  "account": "user@example.com",
 69+  "credential_fingerprint": "fp-claude-demo",
 70+  "updated_at": 1760000008000,
 71+  "endpoints": [
 72+    "GET /api/organizations",
 73+    "POST /api/organizations/{id}/chat_conversations/{id}/completion"
 74+  ],
 75+  "endpoint_metadata": [
 76+    {
 77+      "method": "GET",
 78+      "path": "/api/organizations",
 79+      "first_seen_at": 1760000001000,
 80+      "last_seen_at": 1760000008000
 81+    }
 82+  ]
 83+}
 84+```
 85+
 86+server 行为:
 87+
 88+- 把 endpoint 列表和 `endpoint_metadata` 写入持久化端点记录
 89+- `GET /v1/browser` 会把这些持久化记录和当前活跃 WS 连接视图合并
 90+- client 断开或 daemon 重启后,最近一次元数据仍可通过 `/v1/browser` 读取
 91 
 92 ### `open_tab`
 93 
 94@@ -274,6 +309,7 @@ server 行为:
 95 当前非目标:
 96 
 97 - 不把 `/ws/firefox` 直接暴露成公网产品接口
 98+- 不把页面对话 UI、聊天 DOM 自动化或多标签会话编排写成正式 bridge 能力
 99 - Claude 正式 HTTP 面统一收口到 `GET /v1/browser`、`POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current`
100 - 本文仍只讨论 WS transport、client registry 和 request-response 基础能力
101 
102@@ -318,7 +354,10 @@ EOF
103 
104 这条 smoke 会覆盖:
105 
106-- `GET /v1/browser`
107+- `GET /v1/browser` 上的元数据上报与合并读面
108+- `GET /v1/browser` 持久化记录在断连和重启后的可读性
109+- `GET /v1/browser` 上 `fresh` / `stale` / `lost` 的状态变化
110+- `GET /v1/browser` 不回显原始 `cookie` / `token` / header 值
111 - `POST /v1/browser/claude/open`
112 - `POST /v1/browser/claude/send`
113 - `GET /v1/browser/claude/current`
M docs/firefox/README.md
+151, -202
  1@@ -4,19 +4,17 @@
  2 
  3 - [`../../plugins/baa-firefox/`](../../plugins/baa-firefox/)
  4 
  5-这份文档只描述当前 `baa-conductor` 里实际支持的 Firefox 插件能力。
  6+这份文档只描述当前 `baa-conductor` 里正式支持的 Firefox 插件能力。
  7 
  8-## 当前范围
  9+## 正式模型
 10 
 11-- 当前对外支持的页面 HTTP 代理能力只支持 `Claude`
 12-- 主线是:
 13-  - 捕获真实 Claude 页面请求里的凭证
 14-  - 发现真实 Claude API endpoint
 15-  - 在页面上下文里用真实 cookie / csrf / endpoint 直接代发 HTTP
 16-- 不支持 ChatGPT / Gemini 的页面自动化协议
 17-- 不把“找输入框、填 prompt、点击发送”作为主方案
 18+当前主线已经收口到三件事:
 19 
 20-当前仓库里仍保留多平台的被动观测代码,但对后续服务端承诺的 runtime message 只针对 `Claude`。
 21+1. 每个平台维持一个空壳 shell tab,作为登录环境和同源请求来源环境
 22+2. 插件通过本地 `/ws/firefox` 上报登录态元数据、凭证指纹和端点元数据
 23+3. 真正的上游请求仍由浏览器本地代发,`conductor` 只负责调度、持久化元数据和暴露读面
 24+
 25+页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。当前仍保留的 `GET /v1/browser/claude/current` 只是 Claude relay 的辅助读接口。
 26 
 27 ## 固定入口
 28 
 29@@ -30,201 +28,140 @@
 30 
 31 插件管理页不再允许手工编辑地址。
 32 
 33-## 给 AI / CLI 的最短入口
 34-
 35-如果目标是 Claude 浏览器动作,推荐固定按这个顺序:
 36-
 37-1. 先读 [`../api/business-interfaces.md`](../api/business-interfaces.md)
 38-2. 再调 `GET /describe/business`
 39-3. 再调 `GET /v1/browser`,确认 bridge 已连接且 `claude.ready=true`
 40-4. 然后才调 `POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current`
 41-
 42-不要把 Claude 浏览器面当成通用公网浏览器自动化服务;正式链路依赖 `mini` 本地 Firefox 插件和本地 `/ws/firefox`。
 43-
 44-## Claude HTTP-First 设计
 45-
 46-Claude 页面能力建立在三层信息上:
 47-
 48-1. 凭证抓取
 49-   - `controller.js` 通过 `webRequest.onBeforeSendHeaders` 抓真实请求头
 50-   - 当前会保存 Claude 的 `cookie`、`x-csrf-token`、`anthropic-client-*`
 51-   - Claude 的 `org-id` 优先从请求 URL 提取,也会保存在凭证快照里
 52-
 53-2. Endpoint 发现
 54-   - `page-interceptor.js` 和 `webRequest` 会把真实访问过的 Claude API 路径收集到 `endpoints`
 55-   - 当前主线依赖的核心路径是:
 56-     - `POST /api/organizations/{orgId}/chat_conversations`
 57-     - `GET /api/organizations/{orgId}/chat_conversations/{convId}`
 58-     - `POST /api/organizations/{orgId}/chat_conversations/{convId}/completion`
 59-
 60-3. 页面内 HTTP 代理
 61-   - `controller.js` 发 `baa_page_proxy_request`
 62-   - `content-script.js` 把请求转发到页面上下文
 63-   - `page-interceptor.js` 在 Claude 页面里执行 `fetch(..., { credentials: "include" })`
 64-   - Claude API 响应再通过 `baa_page_proxy_response` 回到插件
 65-
 66-这条链路的目标是让后续 `conductor` WS bridge 调的是“真实 Claude 页面里的 HTTP 能力”,不是 DOM 自动化。
 67-
 68-## Conductor HTTP 最小闭环
 69-
 70-当前正式链路固定是:
 71-
 72-1. `GET /v1/browser`
 73-2. `POST /v1/browser/claude/open`
 74-3. `POST /v1/browser/claude/send`
 75-4. `GET /v1/browser/claude/current`
 76-
 77-对应最小 curl 示例见:
 78+## 空壳页模型
 79 
 80-- [`../api/README.md`](../api/README.md)
 81-- [`../api/business-interfaces.md`](../api/business-interfaces.md)
 82+每个平台最多保留一个专用 shell tab:
 83 
 84-对应自动 smoke:
 85+- Claude:`https://claude.ai/#baa-shell`
 86+- ChatGPT:`https://chatgpt.com/#baa-shell`
 87+- Gemini:`https://gemini.google.com/#baa-shell`
 88 
 89-- `./scripts/runtime/browser-control-e2e-smoke.sh`
 90+这些标签页只承载:
 91 
 92-## 当前 runtime message
 93+- 登录态
 94+- 同源请求上下文
 95+- endpoint 发现
 96 
 97-### `claude_send`
 98+它们不再承载持久化会话语义,也不是服务端侧的正式业务主键。
 99 
100-用途:
101+## `conductor` 侧会保存什么
102 
103-- 基于已捕获的凭证和 endpoint 发起一轮 Claude HTTP 对话
104+通过插件上报并由 `conductor` 持久化的字段:
105 
106-示例:
107+- `platform`
108+- `browser`
109+- `client_id`
110+- `host`
111+- `account`
112+- `credential_fingerprint`
113+- `captured_at`
114+- `last_seen_at`
115+- `freshness`
116+- `endpoints`
117+- `endpoint_metadata`
118 
119-```json
120-{
121-  "type": "claude_send",
122-  "prompt": "帮我总结当前仓库的 Firefox 插件结构",
123-  "conversationId": "optional",
124-  "organizationId": "optional",
125-  "createNew": false,
126-  "title": "optional"
127-}
128-```
129+明确不会持久化的内容:
130 
131-行为:
132+- 原始 `cookie`
133+- 原始 `token`
134+- 原始 header 值
135+- 页面会话内容
136+- 页面 UI 状态
137 
138-- 如果没有传 `conversationId`,优先使用当前页面/缓存里的对话 ID
139-- 如果仍没有对话 ID,则先创建新对话
140-- 然后对 `/completion` 发 HTTP 请求
141-- 读取 SSE 文本,之后再用 `GET chat_conversations/{convId}` 拉一次权威会话内容
142+`GET /v1/browser` 会把活跃 WS 连接和持久化记录合并成统一读面,并暴露 `fresh` / `stale` / `lost`。
143 
144-返回重点字段:
145+## 给 AI / CLI 的最短入口
146 
147-- `organizationId`
148-- `conversationId`
149-- `response.role`
150-- `response.content`
151-- `response.thinking`
152-- `response.timestamp`
153-- `response.messageUuid`
154-- `state.title`
155-- `state.currentUrl`
156-- `state.busy`
157-- `state.recentMessages`
158+如果目标是浏览器桥接能力,推荐固定按这个顺序:
159 
160-### `claude_read_conversation`
161+1. 先读 [`../api/business-interfaces.md`](../api/business-interfaces.md)
162+2. 再调 `GET /describe/business`
163+3. 再调 `GET /v1/browser`,确认需要的平台记录、`status` 和 `view`
164+4. 如果目标是 Claude 浏览器代发,再调 `POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`
165+5. 只有在需要 Claude 辅助回读时,才调 `GET /v1/browser/claude/current`
166 
167-用途:
168+不要把这条链路当成通用公网浏览器自动化服务;正式链路依赖 `mini` 本地 Firefox 插件和本地 `/ws/firefox`。
169 
170-- 读取当前 Claude 对话的最近消息
171+## WS 上报模型
172 
173-示例:
174+插件启动后会先发送:
175 
176 ```json
177 {
178-  "type": "claude_read_conversation",
179-  "conversationId": "optional",
180-  "organizationId": "optional"
181+  "type": "hello",
182+  "clientId": "firefox-ab12cd",
183+  "nodeType": "browser",
184+  "nodeCategory": "proxy",
185+  "nodePlatform": "firefox"
186 }
187 ```
188 
189-行为:
190-
191-- 先确定 `org-id`
192-- 再确定 `conversationId`
193-- 调 `GET /api/organizations/{orgId}/chat_conversations/{convId}`
194-- 返回最近消息、标题、URL、busy 状态
195-
196-### `claude_read_state`
197-
198-用途:
199-
200-- 读取最小 Claude 页面状态,给后续服务端消费
201-
202-示例:
203+登录态元数据上报:
204 
205 ```json
206 {
207-  "type": "claude_read_state",
208-  "refresh": true
209+  "type": "credentials",
210+  "platform": "claude",
211+  "account": "user@example.com",
212+  "credential_fingerprint": "fp-claude-demo",
213+  "freshness": "fresh",
214+  "captured_at": 1760000000000,
215+  "last_seen_at": 1760000005000,
216+  "headers": {
217+    "cookie": "<redacted>",
218+    "x-csrf-token": "<redacted>"
219+  }
220 }
221 ```
222 
223-返回重点字段:
224-
225-- `title`
226-- `currentUrl`
227-- `busy`
228-- `recentMessages`
229-- `sources`
230-
231-其中 `recentMessages` 里的消息元素当前统一为:
232+端点元数据上报:
233 
234 ```json
235 {
236-  "id": "optional",
237-  "role": "user|assistant",
238-  "content": "text",
239-  "thinking": "optional",
240-  "timestamp": 0,
241-  "seq": 0
242+  "type": "api_endpoints",
243+  "platform": "claude",
244+  "account": "user@example.com",
245+  "credential_fingerprint": "fp-claude-demo",
246+  "updated_at": 1760000008000,
247+  "endpoints": [
248+    "GET /api/organizations",
249+    "POST /api/organizations/{id}/chat_conversations/{id}/completion"
250+  ],
251+  "endpoint_metadata": [
252+    {
253+      "method": "GET",
254+      "path": "/api/organizations",
255+      "first_seen_at": 1760000001000,
256+      "last_seen_at": 1760000008000
257+    }
258+  ]
259 }
260 ```
261 
262-## 数据来源说明
263-
264-### 来自 API
265-
266-- 对话标题
267-- 最近消息列表
268-- `role / content / timestamp`
269-- model
270-- 当前对话 ID
271-
272-这些数据来自:
273+说明:
274 
275-- `GET /api/organizations/{orgId}/chat_conversations/{convId}`
276+- `headers` 只保留脱敏占位符,用于让服务端知道 header 名称和数量
277+- 原始凭证值仍只停留在浏览器本地,用于后续同源代发
278+- client 断开或流量长时间老化后,持久化记录仍可读,但会从 `fresh` 变成 `stale` / `lost`
279 
280-### 来自 SSE
281+## Claude 浏览器本地代发
282 
283-- 本轮 `/completion` 的增量文本
284-- Claude 返回的 message uuid
285-- stop reason
286+当前正式对外 HTTP relay 只支持 Claude:
287 
288-这些数据来自:
289-
290-- `POST /api/organizations/{orgId}/chat_conversations/{convId}/completion`
291-
292-### 来自浏览器 tab 元数据
293-
294-- 当前 URL
295-- 当 API 还没拿到标题时的临时标题兜底
296-
297-当前实现没有依赖页面 DOM 解析输入框、消息列表或发送按钮。
298-
299-### 来自插件内部状态
300-
301-- `busy`
302+1. `GET /v1/browser`
303+2. `POST /v1/browser/claude/open`
304+3. `POST /v1/browser/claude/send`
305+4. `GET /v1/browser/claude/current`
306 
307-`busy` 由两部分合并得到:
308+这条链路的关键边界:
309 
310-- 插件自己发起的 Claude proxy 请求
311-- 页面里观察到的 Claude completion / SSE 活跃状态
312+- `send` / `current` 通过插件已有的页面内 HTTP 代理完成,不是 DOM 自动化
313+- `conductor` 不直接持有原始 Claude 凭证
314+- 如果没有活跃 Firefox bridge client,会返回 `503`
315+- 如果 client 还没有 Claude 凭证和 endpoint,会返回 `409`
316+- ChatGPT / Gemini 当前不在正式 HTTP relay 合同里
317 
318-## 启动行为
319+## 启动和管理页
320 
321 - Firefox 启动时,`background.js` 会确保 `controller.html` 存在
322 - `controller.html` 启动后立刻连接 `ws://100.71.210.78:4317/ws/firefox`
323@@ -233,30 +170,26 @@ Claude 页面能力建立在三层信息上:
324 - HTTP 成功后按 `15` 秒周期继续同步
325 - HTTP 失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试
326 
327-## 管理页 UI
328-
329-管理页当前仍保留:
330+管理页当前只保留:
331 
332 - 本地 WS 状态卡片
333 - 本地 HTTP 状态卡片
334 - `暂停` / `恢复` / `排空` 按钮
335-- 凭证 / endpoint / 平台状态面板
336+- 空壳页 / 账号 / 指纹 / endpoint 元数据面板
337 
338-Claude 的 runtime message 能力不依赖管理页按钮触发。
339+## 验证
340 
341-## WS 协议
342+最小自动 smoke:
343 
344-插件启动后会先发送:
345+- `./scripts/runtime/browser-control-e2e-smoke.sh`
346 
347-```json
348-{
349-  "type": "hello",
350-  "clientId": "firefox-ab12cd",
351-  "nodeType": "browser",
352-  "nodeCategory": "proxy",
353-  "nodePlatform": "firefox"
354-}
355-```
356+这条 smoke 现在覆盖:
357+
358+- `GET /v1/browser` 上的元数据上报
359+- 持久化记录在断连和重启后的可读性
360+- `fresh` / `stale` / `lost` 状态变化
361+- 读接口不泄露原始凭证值
362+- Claude `open` / `send` / `current` 的最小浏览器本地代发闭环
363 
364 随后插件会继续上送:
365 
366@@ -293,9 +226,15 @@ Claude 的 runtime message 能力不依赖管理页按钮触发。
367 ./scripts/runtime/browser-control-e2e-smoke.sh
368 ```
369 
370-它会在临时 runtime 上覆盖 `status -> open -> send -> current` 这条 HTTP + WS 链路。
371+它会在临时 runtime 上覆盖:
372 
373-### 1. 凭证与 endpoint
374+- `GET /v1/browser` 的元数据上报与合并读面
375+- 持久化记录在断连和重启后的可读性
376+- `fresh` / `stale` / `lost` 状态变化
377+- 读接口不泄露原始凭证值
378+- Claude `open` / `send` / `current` 的最小浏览器本地代发闭环
379+
380+### 1. 元数据与持久化
381 
382 1. 安装插件并打开 `https://claude.ai/`
383 2. 手工登录 Claude
384@@ -303,22 +242,33 @@ Claude 的 runtime message 能力不依赖管理页按钮触发。
385 4. 在管理页确认:
386    - Claude 有有效凭证
387    - 已发现 `/chat_conversations` 和 `/completion` endpoint
388-
389-### 2. runtime message
390-
391-1. 发送 `claude_read_state`
392-2. 确认返回:
393-   - `currentUrl`
394-   - `busy`
395-   - `recentMessages`
396-3. 发送 `claude_read_conversation`
397-4. 确认标题和最近消息来自 Claude API
398-5. 发送 `claude_send`
399-6. 确认:
400-   - Claude completion 返回 SSE 文本
401-   - 之后能重新读到最新会话内容
402-
403-### 3. 控制面
404+5. 请求 `GET /v1/browser?platform=claude`
405+6. 确认返回:
406+   - `records[0].view` 为 `active_and_persisted`
407+   - 返回里只有 `account`、凭证指纹、端点元数据和状态
408+   - 没有原始 `cookie`、`token` 或 header 值
409+
410+### 2. 断连与状态变化
411+
412+1. 断开插件 WS,或停止管理页连接
413+2. 轮询 `GET /v1/browser?platform=claude`
414+3. 确认:
415+   - 记录仍然可读
416+   - `view` 变成 `persisted_only` 或等价离线视图
417+   - `status` 会从 `fresh` 变成 `stale`
418+4. 继续等待老化窗口
419+5. 确认同一条记录进一步进入 `lost`
420+
421+### 3. Claude relay
422+
423+1. 调 `POST /v1/browser/claude/open`
424+2. 确认 Claude shell tab 被打开或聚焦
425+3. 调 `POST /v1/browser/claude/send`
426+4. 确认请求经 `/ws/firefox` 转给浏览器本地代理,并收到 Claude API 回包
427+5. 如需辅助回读,再调 `GET /v1/browser/claude/current`
428+6. 把这个接口理解为 Claude relay 的辅助读面,不是浏览器桥接持久化主模型
429+
430+### 4. 控制面
431 
432 1. 点击 `暂停`,确认 HTTP 状态里的 `mode` 变成 `paused`
433 2. 点击 `恢复`,确认 `mode` 变成 `running`
434@@ -326,12 +276,11 @@ Claude 的 runtime message 能力不依赖管理页按钮触发。
435 
436 ## 已知限制
437 
438-- 当前正式支持的页面 HTTP 代理只有 Claude
439-- ChatGPT / Gemini 当前不在正式 `/v1/browser/*` 合同里
440+- 当前正式浏览器本地代发 HTTP 面只有 Claude
441+- ChatGPT / Gemini 当前只保留空壳页和元数据上报,不在正式 `/v1/browser/*` relay 合同里
442 - 必须先在真实 Claude 页面里产生过请求,插件才能学到可用凭证和 `org-id`
443-- 如果当前页面 URL 里没有对话 ID,且最近没有观察到 Claude 会话请求,`claude_read_conversation` 需要显式传 `conversationId`
444-- `claude_send` 走 HTTP 代理,不会驱动 Claude 页面 DOM,也不会让页面自动导航到新建对话 URL
445-- `recentMessages` 当前只保留最近一段窗口,不返回完整长历史
446+- `claude_send` / `claude_current` 走浏览器本地 HTTP 代理,不会驱动 Claude 页面 DOM,也不会把页面会话历史持久化到 `conductor`
447+- 当前 `/v1/browser/claude/current` 只是辅助回读最近一段 Claude 状态,不提供长期历史合同
448 
449 ## 相关文件
450 
M docs/ops/repo-verification.md
+1, -1
1@@ -63,7 +63,7 @@
2 - runtime 脚本的 conductor public API compatibility 行为
3 - legacy `control-api-worker` / Cloudflare / importer absence 约束
4 - codexd 本地 app-server 会话链路 e2e smoke
5-- browser-control 本地 Firefox bridge e2e smoke
6+- browser-control 本地 Firefox bridge e2e smoke:覆盖元数据上报、持久化读取、`fresh/stale/lost`、读接口不泄露原始凭证值,以及 Claude 最小 relay 闭环
7 
8 说明:
9 
M docs/runtime/README.md
+1, -1
1@@ -89,7 +89,7 @@
2   - 会起临时 `codexd` + `conductor`,覆盖 `codexd status`、`GET /v1/codex`、session create/read、turn create/read,以及 `logs/codexd/**`、`state/codexd/**` 落盘
3 - 浏览器控制链路 smoke:
4   - `./scripts/runtime/browser-control-e2e-smoke.sh`
5-  - 会起临时 `conductor` + fake Firefox bridge client,覆盖 `GET /v1/browser`、`POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current`,以及 `/ws/firefox` 上的 `open_tab` 与 `api_request/api_response`
6+  - 会起临时 `conductor` + fake Firefox bridge client,覆盖 `GET /v1/browser` 的元数据上报、持久化读取、`fresh/stale/lost`、敏感值不泄露,以及 `POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current` 和 `/ws/firefox` 上的 `open_tab` 与 `api_request/api_response`
7 
8 职责边界:
9 
A ops/sql/migrations/0002_browser_auth_records.sql
+39, -0
 1@@ -0,0 +1,39 @@
 2+-- Browser bridge login-state metadata persistence.
 3+-- Stores account, credential fingerprints, and endpoint metadata only.
 4+
 5+BEGIN TRANSACTION;
 6+
 7+CREATE TABLE IF NOT EXISTS browser_login_states (
 8+  platform TEXT NOT NULL,
 9+  host TEXT NOT NULL,
10+  browser TEXT NOT NULL,
11+  client_id TEXT NOT NULL,
12+  account TEXT NOT NULL,
13+  credential_fingerprint TEXT NOT NULL,
14+  captured_at INTEGER NOT NULL,
15+  last_seen_at INTEGER NOT NULL,
16+  status TEXT NOT NULL CHECK (status IN ('fresh', 'stale', 'lost')),
17+  PRIMARY KEY (platform, client_id, account),
18+  CHECK (captured_at <= last_seen_at)
19+);
20+
21+CREATE INDEX IF NOT EXISTS idx_browser_login_states_status_seen
22+ON browser_login_states(status, last_seen_at);
23+
24+CREATE INDEX IF NOT EXISTS idx_browser_login_states_lookup
25+ON browser_login_states(platform, host, browser, account, last_seen_at);
26+
27+CREATE TABLE IF NOT EXISTS browser_endpoint_metadata (
28+  platform TEXT NOT NULL,
29+  client_id TEXT NOT NULL,
30+  account TEXT NOT NULL,
31+  endpoints_json TEXT NOT NULL,
32+  updated_at INTEGER NOT NULL,
33+  last_verified_at INTEGER,
34+  PRIMARY KEY (platform, client_id, account)
35+);
36+
37+CREATE INDEX IF NOT EXISTS idx_browser_endpoint_metadata_updated
38+ON browser_endpoint_metadata(platform, account, updated_at);
39+
40+COMMIT;
M ops/sql/schema.sql
+33, -0
 1@@ -239,4 +239,37 @@ ON task_artifacts(task_id, created_at);
 2 CREATE INDEX IF NOT EXISTS idx_task_artifacts_run_type
 3 ON task_artifacts(run_id, artifact_type);
 4 
 5+CREATE TABLE IF NOT EXISTS browser_login_states (
 6+  platform TEXT NOT NULL,
 7+  host TEXT NOT NULL,
 8+  browser TEXT NOT NULL,
 9+  client_id TEXT NOT NULL,
10+  account TEXT NOT NULL,
11+  credential_fingerprint TEXT NOT NULL,
12+  captured_at INTEGER NOT NULL,
13+  last_seen_at INTEGER NOT NULL,
14+  status TEXT NOT NULL CHECK (status IN ('fresh', 'stale', 'lost')),
15+  PRIMARY KEY (platform, client_id, account),
16+  CHECK (captured_at <= last_seen_at)
17+);
18+
19+CREATE INDEX IF NOT EXISTS idx_browser_login_states_status_seen
20+ON browser_login_states(status, last_seen_at);
21+
22+CREATE INDEX IF NOT EXISTS idx_browser_login_states_lookup
23+ON browser_login_states(platform, host, browser, account, last_seen_at);
24+
25+CREATE TABLE IF NOT EXISTS browser_endpoint_metadata (
26+  platform TEXT NOT NULL,
27+  client_id TEXT NOT NULL,
28+  account TEXT NOT NULL,
29+  endpoints_json TEXT NOT NULL,
30+  updated_at INTEGER NOT NULL,
31+  last_verified_at INTEGER,
32+  PRIMARY KEY (platform, client_id, account)
33+);
34+
35+CREATE INDEX IF NOT EXISTS idx_browser_endpoint_metadata_updated
36+ON browser_endpoint_metadata(platform, account, updated_at);
37+
38 COMMIT;
M packages/db/src/index.test.js
+184, -0
  1@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
  2 import test from "node:test";
  3 
  4 import {
  5+  BROWSER_LOGIN_STATE_STATUS_VALUES,
  6   D1ControlPlaneRepository,
  7   GLOBAL_LEASE_NAME,
  8   SqliteD1Database,
  9@@ -248,3 +249,186 @@ test("SqliteD1Database supports task log queries through D1ControlPlaneRepositor
 10     db.close();
 11   }
 12 });
 13+
 14+test("D1ControlPlaneRepository upserts and lists browser login state metadata", async () => {
 15+  const db = new SqliteD1Database(":memory:", {
 16+    schemaSql: CONTROL_PLANE_SCHEMA_SQL
 17+  });
 18+  const repository = new D1ControlPlaneRepository(db);
 19+
 20+  try {
 21+    await repository.upsertBrowserLoginState({
 22+      platform: "claude",
 23+      host: "mini",
 24+      browser: "firefox",
 25+      clientId: "firefox-claude",
 26+      account: "ops@example.com",
 27+      credentialFingerprint: "fp-1",
 28+      capturedAt: 100,
 29+      lastSeenAt: 120,
 30+      status: "fresh"
 31+    });
 32+    await repository.upsertBrowserEndpointMetadata({
 33+      platform: "claude",
 34+      clientId: "firefox-claude",
 35+      account: "ops@example.com",
 36+      endpoints: ["/api/organizations", "/api/organizations", " /api/bootstrap "],
 37+      updatedAt: 125,
 38+      lastVerifiedAt: 126
 39+    });
 40+
 41+    const state = await repository.getBrowserLoginState({
 42+      platform: "claude",
 43+      clientId: "firefox-claude",
 44+      account: "ops@example.com"
 45+    });
 46+    const endpoints = await repository.getBrowserEndpointMetadata({
 47+      platform: "claude",
 48+      clientId: "firefox-claude",
 49+      account: "ops@example.com"
 50+    });
 51+    const listedStates = await repository.listBrowserLoginStates({
 52+      platform: "claude",
 53+      host: "mini",
 54+      browser: "firefox",
 55+      account: "ops@example.com",
 56+      status: "fresh"
 57+    });
 58+    const listedEndpoints = await repository.listBrowserEndpointMetadata({
 59+      platform: "claude",
 60+      account: "ops@example.com"
 61+    });
 62+
 63+    assert.deepEqual(state, {
 64+      platform: "claude",
 65+      host: "mini",
 66+      browser: "firefox",
 67+      clientId: "firefox-claude",
 68+      account: "ops@example.com",
 69+      credentialFingerprint: "fp-1",
 70+      capturedAt: 100,
 71+      lastSeenAt: 120,
 72+      status: "fresh"
 73+    });
 74+    assert.deepEqual(endpoints, {
 75+      platform: "claude",
 76+      clientId: "firefox-claude",
 77+      account: "ops@example.com",
 78+      endpoints: ["/api/bootstrap", "/api/organizations"],
 79+      updatedAt: 125,
 80+      lastVerifiedAt: 126
 81+    });
 82+    assert.equal(listedStates.length, 1);
 83+    assert.deepEqual(listedStates[0], state);
 84+    assert.equal(listedEndpoints.length, 1);
 85+    assert.deepEqual(listedEndpoints[0], endpoints);
 86+  } finally {
 87+    db.close();
 88+  }
 89+});
 90+
 91+test("browser login state upsert preserves capturedAt for unchanged fingerprints", async () => {
 92+  const db = new SqliteD1Database(":memory:", {
 93+    schemaSql: CONTROL_PLANE_SCHEMA_SQL
 94+  });
 95+  const repository = new D1ControlPlaneRepository(db);
 96+  const key = {
 97+    platform: "chatgpt",
 98+    clientId: "firefox-chatgpt",
 99+    account: "agent@example.com"
100+  };
101+
102+  try {
103+    await repository.upsertBrowserLoginState({
104+      ...key,
105+      host: "mini",
106+      browser: "firefox",
107+      credentialFingerprint: "fp-1",
108+      capturedAt: 100,
109+      lastSeenAt: 100,
110+      status: "fresh"
111+    });
112+    await repository.upsertBrowserLoginState({
113+      ...key,
114+      host: "mini",
115+      browser: "firefox",
116+      credentialFingerprint: "fp-1",
117+      capturedAt: 140,
118+      lastSeenAt: 160,
119+      status: "fresh"
120+    });
121+
122+    const unchangedFingerprint = await repository.getBrowserLoginState(key);
123+
124+    assert.deepEqual(unchangedFingerprint, {
125+      ...key,
126+      host: "mini",
127+      browser: "firefox",
128+      credentialFingerprint: "fp-1",
129+      capturedAt: 100,
130+      lastSeenAt: 160,
131+      status: "fresh"
132+    });
133+
134+    await repository.upsertBrowserLoginState({
135+      ...key,
136+      host: "mini",
137+      browser: "firefox",
138+      credentialFingerprint: "fp-2",
139+      capturedAt: 170,
140+      lastSeenAt: 180,
141+      status: "fresh"
142+    });
143+
144+    const rotatedFingerprint = await repository.getBrowserLoginState(key);
145+
146+    assert.deepEqual(rotatedFingerprint, {
147+      ...key,
148+      host: "mini",
149+      browser: "firefox",
150+      credentialFingerprint: "fp-2",
151+      capturedAt: 170,
152+      lastSeenAt: 180,
153+      status: "fresh"
154+    });
155+  } finally {
156+    db.close();
157+  }
158+});
159+
160+test("browser login state status transitions from fresh to stale to lost", async () => {
161+  const db = new SqliteD1Database(":memory:", {
162+    schemaSql: CONTROL_PLANE_SCHEMA_SQL
163+  });
164+  const repository = new D1ControlPlaneRepository(db);
165+  const key = {
166+    platform: "gemini",
167+    clientId: "firefox-gemini",
168+    account: "ops@example.com"
169+  };
170+
171+  try {
172+    await repository.upsertBrowserLoginState({
173+      ...key,
174+      host: "macbook",
175+      browser: "firefox",
176+      credentialFingerprint: "fp-gemini",
177+      capturedAt: 200,
178+      lastSeenAt: 210,
179+      status: "fresh"
180+    });
181+
182+    const staleCount = await repository.markBrowserLoginStatesStale(210);
183+    const staleRecord = await repository.getBrowserLoginState(key);
184+    const lostCount = await repository.markBrowserLoginStatesLost(220);
185+    const lostRecord = await repository.getBrowserLoginState(key);
186+
187+    assert.equal(staleCount, 1);
188+    assert.equal(staleRecord?.status, "stale");
189+    assert.equal(lostCount, 1);
190+    assert.equal(lostRecord?.status, "lost");
191+    assert.deepEqual(BROWSER_LOGIN_STATE_STATUS_VALUES, ["fresh", "stale", "lost"]);
192+  } finally {
193+    db.close();
194+  }
195+});
M packages/db/src/index.ts
+391, -1
  1@@ -12,7 +12,9 @@ export const D1_TABLES = [
  2   "task_checkpoints",
  3   "task_logs",
  4   "system_state",
  5-  "task_artifacts"
  6+  "task_artifacts",
  7+  "browser_login_states",
  8+  "browser_endpoint_metadata"
  9 ] as const;
 10 
 11 export type D1TableName = (typeof D1_TABLES)[number];
 12@@ -36,11 +38,13 @@ export const TASK_STATUS_VALUES = [
 13 ] as const;
 14 export const STEP_STATUS_VALUES = ["pending", "running", "done", "failed", "timeout"] as const;
 15 export const STEP_KIND_VALUES = ["planner", "codex", "shell", "git", "review", "finalize"] as const;
 16+export const BROWSER_LOGIN_STATE_STATUS_VALUES = ["fresh", "stale", "lost"] as const;
 17 
 18 export type AutomationMode = (typeof AUTOMATION_MODE_VALUES)[number];
 19 export type TaskStatus = (typeof TASK_STATUS_VALUES)[number];
 20 export type StepStatus = (typeof STEP_STATUS_VALUES)[number];
 21 export type StepKind = (typeof STEP_KIND_VALUES)[number];
 22+export type BrowserLoginStateStatus = (typeof BROWSER_LOGIN_STATE_STATUS_VALUES)[number];
 23 
 24 export type JsonPrimitive = boolean | number | null | string;
 25 export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
 26@@ -297,6 +301,27 @@ export interface TaskArtifactRecord {
 27   createdAt: number;
 28 }
 29 
 30+export interface BrowserSessionKey {
 31+  platform: string;
 32+  clientId: string;
 33+  account: string;
 34+}
 35+
 36+export interface BrowserLoginStateRecord extends BrowserSessionKey {
 37+  host: string;
 38+  browser: string;
 39+  credentialFingerprint: string;
 40+  capturedAt: number;
 41+  lastSeenAt: number;
 42+  status: BrowserLoginStateStatus;
 43+}
 44+
 45+export interface BrowserEndpointMetadataRecord extends BrowserSessionKey {
 46+  endpoints: string[];
 47+  updatedAt: number;
 48+  lastVerifiedAt: number | null;
 49+}
 50+
 51 export interface ListControllersOptions {
 52   limit?: number;
 53 }
 54@@ -315,6 +340,23 @@ export interface ListTaskLogsOptions {
 55   runId?: string;
 56 }
 57 
 58+export interface ListBrowserLoginStatesOptions {
 59+  account?: string;
 60+  browser?: string;
 61+  clientId?: string;
 62+  host?: string;
 63+  limit?: number;
 64+  platform?: string;
 65+  status?: BrowserLoginStateStatus;
 66+}
 67+
 68+export interface ListBrowserEndpointMetadataOptions {
 69+  account?: string;
 70+  clientId?: string;
 71+  limit?: number;
 72+  platform?: string;
 73+}
 74+
 75 export interface ControlPlaneRepository {
 76   appendTaskLog(record: NewTaskLogRecord): Promise<number | null>;
 77   countActiveRuns(): Promise<number>;
 78@@ -323,6 +365,8 @@ export interface ControlPlaneRepository {
 79   ensureAutomationState(mode?: AutomationMode): Promise<void>;
 80   acquireLeaderLease(input: LeaderLeaseAcquireInput): Promise<LeaderLeaseAcquireResult>;
 81   getAutomationState(): Promise<AutomationStateRecord | null>;
 82+  getBrowserEndpointMetadata(key: BrowserSessionKey): Promise<BrowserEndpointMetadataRecord | null>;
 83+  getBrowserLoginState(key: BrowserSessionKey): Promise<BrowserLoginStateRecord | null>;
 84   getController(controllerId: string): Promise<ControllerRecord | null>;
 85   getCurrentLease(): Promise<LeaderLeaseRecord | null>;
 86   getRun(runId: string): Promise<TaskRunRecord | null>;
 87@@ -334,14 +378,22 @@ export interface ControlPlaneRepository {
 88   insertTaskRun(record: TaskRunRecord): Promise<void>;
 89   insertTaskStep(record: TaskStepRecord): Promise<void>;
 90   insertTaskSteps(records: TaskStepRecord[]): Promise<void>;
 91+  listBrowserEndpointMetadata(
 92+    options?: ListBrowserEndpointMetadataOptions
 93+  ): Promise<BrowserEndpointMetadataRecord[]>;
 94+  listBrowserLoginStates(options?: ListBrowserLoginStatesOptions): Promise<BrowserLoginStateRecord[]>;
 95   listControllers(options?: ListControllersOptions): Promise<ControllerRecord[]>;
 96   listTaskLogs(taskId: string, options?: ListTaskLogsOptions): Promise<TaskLogRecord[]>;
 97   listRuns(options?: ListRunsOptions): Promise<TaskRunRecord[]>;
 98   listTasks(options?: ListTasksOptions): Promise<TaskRecord[]>;
 99   listTaskSteps(taskId: string): Promise<TaskStepRecord[]>;
100+  markBrowserLoginStatesLost(lastSeenBeforeOrAt: number): Promise<number>;
101+  markBrowserLoginStatesStale(lastSeenBeforeOrAt: number): Promise<number>;
102   putLeaderLease(record: LeaderLeaseRecord): Promise<void>;
103   putSystemState(record: SystemStateRecord): Promise<void>;
104   setAutomationMode(mode: AutomationMode, updatedAt?: number): Promise<void>;
105+  upsertBrowserEndpointMetadata(record: BrowserEndpointMetadataRecord): Promise<void>;
106+  upsertBrowserLoginState(record: BrowserLoginStateRecord): Promise<void>;
107   upsertController(record: ControllerRecord): Promise<void>;
108   upsertWorker(record: WorkerRecord): Promise<void>;
109 }
110@@ -350,6 +402,7 @@ const AUTOMATION_MODE_SET = new Set<string>(AUTOMATION_MODE_VALUES);
111 const TASK_STATUS_SET = new Set<string>(TASK_STATUS_VALUES);
112 const STEP_STATUS_SET = new Set<string>(STEP_STATUS_VALUES);
113 const STEP_KIND_SET = new Set<string>(STEP_KIND_VALUES);
114+const BROWSER_LOGIN_STATE_STATUS_SET = new Set<string>(BROWSER_LOGIN_STATE_STATUS_VALUES);
115 
116 export function nowUnixSeconds(date: Date = new Date()): number {
117   return Math.floor(date.getTime() / 1000);
118@@ -387,6 +440,10 @@ export function isStepKind(value: unknown): value is StepKind {
119   return typeof value === "string" && STEP_KIND_SET.has(value);
120 }
121 
122+export function isBrowserLoginStateStatus(value: unknown): value is BrowserLoginStateStatus {
123+  return typeof value === "string" && BROWSER_LOGIN_STATE_STATUS_SET.has(value);
124+}
125+
126 export function buildAutomationStateValue(mode: AutomationMode): string {
127   return JSON.stringify({ mode });
128 }
129@@ -563,6 +620,54 @@ function readStepKind(row: DatabaseRow, column: string): StepKind {
130   return value;
131 }
132 
133+function readBrowserLoginStateStatus(row: DatabaseRow, column: string): BrowserLoginStateStatus {
134+  const value = readRequiredString(row, column);
135+
136+  if (!isBrowserLoginStateStatus(value)) {
137+    throw new TypeError(`Unexpected browser login state status "${value}".`);
138+  }
139+
140+  return value;
141+}
142+
143+function normalizeStringArray(values: readonly string[]): string[] {
144+  const uniqueValues = new Set<string>();
145+
146+  for (const value of values) {
147+    const normalized = value.trim();
148+
149+    if (normalized !== "") {
150+      uniqueValues.add(normalized);
151+    }
152+  }
153+
154+  return [...uniqueValues].sort((left, right) => left.localeCompare(right));
155+}
156+
157+function parseStringArrayJson(jsonText: string | null | undefined, column: string): string[] {
158+  if (jsonText == null || jsonText === "") {
159+    return [];
160+  }
161+
162+  const parsed = JSON.parse(jsonText) as unknown;
163+
164+  if (!Array.isArray(parsed)) {
165+    throw new TypeError(`Expected column "${column}" to contain a JSON string array.`);
166+  }
167+
168+  const values: string[] = [];
169+
170+  for (const entry of parsed) {
171+    if (typeof entry !== "string") {
172+      throw new TypeError(`Expected column "${column}" to contain only string values.`);
173+    }
174+
175+    values.push(entry);
176+  }
177+
178+  return normalizeStringArray(values);
179+}
180+
181 export function mapLeaderLeaseRow(row: DatabaseRow): LeaderLeaseRecord {
182   return {
183     leaseName: readRequiredString(row, "lease_name"),
184@@ -760,6 +865,31 @@ export function mapTaskArtifactRow(row: DatabaseRow): TaskArtifactRecord {
185   };
186 }
187 
188+export function mapBrowserLoginStateRow(row: DatabaseRow): BrowserLoginStateRecord {
189+  return {
190+    platform: readRequiredString(row, "platform"),
191+    host: readRequiredString(row, "host"),
192+    browser: readRequiredString(row, "browser"),
193+    clientId: readRequiredString(row, "client_id"),
194+    account: readRequiredString(row, "account"),
195+    credentialFingerprint: readRequiredString(row, "credential_fingerprint"),
196+    capturedAt: readRequiredNumber(row, "captured_at"),
197+    lastSeenAt: readRequiredNumber(row, "last_seen_at"),
198+    status: readBrowserLoginStateStatus(row, "status")
199+  };
200+}
201+
202+export function mapBrowserEndpointMetadataRow(row: DatabaseRow): BrowserEndpointMetadataRecord {
203+  return {
204+    platform: readRequiredString(row, "platform"),
205+    clientId: readRequiredString(row, "client_id"),
206+    account: readRequiredString(row, "account"),
207+    endpoints: parseStringArrayJson(readRequiredString(row, "endpoints_json"), "endpoints_json"),
208+    updatedAt: readRequiredNumber(row, "updated_at"),
209+    lastVerifiedAt: readOptionalNumber(row, "last_verified_at")
210+  };
211+}
212+
213 export const SELECT_CURRENT_LEASE_SQL = `
214   SELECT
215     lease_name,
216@@ -1335,6 +1465,118 @@ export const INSERT_TASK_ARTIFACT_SQL = `
217   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
218 `;
219 
220+export const UPSERT_BROWSER_LOGIN_STATE_SQL = `
221+  INSERT INTO browser_login_states (
222+    platform,
223+    host,
224+    browser,
225+    client_id,
226+    account,
227+    credential_fingerprint,
228+    captured_at,
229+    last_seen_at,
230+    status
231+  )
232+  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
233+  ON CONFLICT(platform, client_id, account) DO UPDATE SET
234+    host = excluded.host,
235+    browser = excluded.browser,
236+    credential_fingerprint = excluded.credential_fingerprint,
237+    captured_at = CASE
238+      WHEN browser_login_states.credential_fingerprint = excluded.credential_fingerprint
239+        THEN browser_login_states.captured_at
240+      ELSE excluded.captured_at
241+    END,
242+    last_seen_at = excluded.last_seen_at,
243+    status = excluded.status
244+`;
245+
246+export const SELECT_BROWSER_LOGIN_STATE_SQL = `
247+  SELECT
248+    platform,
249+    host,
250+    browser,
251+    client_id,
252+    account,
253+    credential_fingerprint,
254+    captured_at,
255+    last_seen_at,
256+    status
257+  FROM browser_login_states
258+  WHERE platform = ?
259+    AND client_id = ?
260+    AND account = ?
261+`;
262+
263+export const SELECT_BROWSER_LOGIN_STATES_PREFIX_SQL = `
264+  SELECT
265+    platform,
266+    host,
267+    browser,
268+    client_id,
269+    account,
270+    credential_fingerprint,
271+    captured_at,
272+    last_seen_at,
273+    status
274+  FROM browser_login_states
275+`;
276+
277+export const UPDATE_BROWSER_LOGIN_STATES_STALE_SQL = `
278+  UPDATE browser_login_states
279+  SET status = 'stale'
280+  WHERE status = 'fresh'
281+    AND last_seen_at <= ?
282+`;
283+
284+export const UPDATE_BROWSER_LOGIN_STATES_LOST_SQL = `
285+  UPDATE browser_login_states
286+  SET status = 'lost'
287+  WHERE status != 'lost'
288+    AND last_seen_at <= ?
289+`;
290+
291+export const UPSERT_BROWSER_ENDPOINT_METADATA_SQL = `
292+  INSERT INTO browser_endpoint_metadata (
293+    platform,
294+    client_id,
295+    account,
296+    endpoints_json,
297+    updated_at,
298+    last_verified_at
299+  )
300+  VALUES (?, ?, ?, ?, ?, ?)
301+  ON CONFLICT(platform, client_id, account) DO UPDATE SET
302+    endpoints_json = excluded.endpoints_json,
303+    updated_at = excluded.updated_at,
304+    last_verified_at = excluded.last_verified_at
305+`;
306+
307+export const SELECT_BROWSER_ENDPOINT_METADATA_SQL = `
308+  SELECT
309+    platform,
310+    client_id,
311+    account,
312+    endpoints_json,
313+    updated_at,
314+    last_verified_at
315+  FROM browser_endpoint_metadata
316+  WHERE platform = ?
317+    AND client_id = ?
318+    AND account = ?
319+`;
320+
321+export const SELECT_BROWSER_ENDPOINT_METADATA_PREFIX_SQL = `
322+  SELECT
323+    platform,
324+    client_id,
325+    account,
326+    endpoints_json,
327+    updated_at,
328+    last_verified_at
329+  FROM browser_endpoint_metadata
330+`;
331+
332 function leaderLeaseParams(record: LeaderLeaseRecord): D1Bindable[] {
333   return [
334     record.leaseName,
335@@ -1510,6 +1752,110 @@ function taskArtifactParams(record: TaskArtifactRecord): D1Bindable[] {
336   ];
337 }
338 
339+function browserSessionKeyParams(key: BrowserSessionKey): D1Bindable[] {
340+  return [key.platform, key.clientId, key.account];
341+}
342+
343+function browserLoginStateParams(record: BrowserLoginStateRecord): D1Bindable[] {
344+  return [
345+    record.platform,
346+    record.host,
347+    record.browser,
348+    record.clientId,
349+    record.account,
350+    record.credentialFingerprint,
351+    record.capturedAt,
352+    record.lastSeenAt,
353+    record.status
354+  ];
355+}
356+
357+function browserEndpointMetadataParams(record: BrowserEndpointMetadataRecord): D1Bindable[] {
358+  return [
359+    record.platform,
360+    record.clientId,
361+    record.account,
362+    JSON.stringify(normalizeStringArray(record.endpoints)),
363+    record.updatedAt,
364+    record.lastVerifiedAt
365+  ];
366+}
367+
368+function buildBrowserLoginStatesListQuery(
369+  options: ListBrowserLoginStatesOptions
370+): { query: string; params: D1Bindable[] } {
371+  const clauses: string[] = [];
372+  const params: D1Bindable[] = [];
373+
374+  if (options.platform != null) {
375+    clauses.push("platform = ?");
376+    params.push(options.platform);
377+  }
378+
379+  if (options.host != null) {
380+    clauses.push("host = ?");
381+    params.push(options.host);
382+  }
383+
384+  if (options.browser != null) {
385+    clauses.push("browser = ?");
386+    params.push(options.browser);
387+  }
388+
389+  if (options.clientId != null) {
390+    clauses.push("client_id = ?");
391+    params.push(options.clientId);
392+  }
393+
394+  if (options.account != null) {
395+    clauses.push("account = ?");
396+    params.push(options.account);
397+  }
398+
399+  if (options.status != null) {
400+    clauses.push("status = ?");
401+    params.push(options.status);
402+  }
403+
404+  const whereClause = clauses.length === 0 ? "" : `\nWHERE ${clauses.join("\n  AND ")}`;
405+  const query = `${SELECT_BROWSER_LOGIN_STATES_PREFIX_SQL}${whereClause}
406+  ORDER BY last_seen_at DESC, platform ASC, client_id ASC, account ASC
407+  LIMIT ?`;
408+
409+  params.push(options.limit ?? 50);
410+  return { query, params };
411+}
412+
413+function buildBrowserEndpointMetadataListQuery(
414+  options: ListBrowserEndpointMetadataOptions
415+): { query: string; params: D1Bindable[] } {
416+  const clauses: string[] = [];
417+  const params: D1Bindable[] = [];
418+
419+  if (options.platform != null) {
420+    clauses.push("platform = ?");
421+    params.push(options.platform);
422+  }
423+
424+  if (options.clientId != null) {
425+    clauses.push("client_id = ?");
426+    params.push(options.clientId);
427+  }
428+
429+  if (options.account != null) {
430+    clauses.push("account = ?");
431+    params.push(options.account);
432+  }
433+
434+  const whereClause = clauses.length === 0 ? "" : `\nWHERE ${clauses.join("\n  AND ")}`;
435+  const query = `${SELECT_BROWSER_ENDPOINT_METADATA_PREFIX_SQL}${whereClause}
436+  ORDER BY updated_at DESC, platform ASC, client_id ASC, account ASC
437+  LIMIT ?`;
438+
439+  params.push(options.limit ?? 50);
440+  return { query, params };
441+}
442+
443 function sqliteQueryMeta(extra: D1ResultMeta = {}): D1ResultMeta {
444   return {
445     changes: 0,
446@@ -1700,6 +2046,16 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
447     return row == null ? null : mapAutomationStateRow(row);
448   }
449 
450+  async getBrowserEndpointMetadata(key: BrowserSessionKey): Promise<BrowserEndpointMetadataRecord | null> {
451+    const row = await this.fetchFirst(SELECT_BROWSER_ENDPOINT_METADATA_SQL, browserSessionKeyParams(key));
452+    return row == null ? null : mapBrowserEndpointMetadataRow(row);
453+  }
454+
455+  async getBrowserLoginState(key: BrowserSessionKey): Promise<BrowserLoginStateRecord | null> {
456+    const row = await this.fetchFirst(SELECT_BROWSER_LOGIN_STATE_SQL, browserSessionKeyParams(key));
457+    return row == null ? null : mapBrowserLoginStateRow(row);
458+  }
459+
460   async getController(controllerId: string): Promise<ControllerRecord | null> {
461     const row = await this.fetchFirst(SELECT_CONTROLLER_SQL, [controllerId]);
462     return row == null ? null : mapControllerRow(row);
463@@ -1725,6 +2081,14 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
464     return row == null ? null : mapTaskRow(row);
465   }
466 
467+  async upsertBrowserEndpointMetadata(record: BrowserEndpointMetadataRecord): Promise<void> {
468+    await this.run(UPSERT_BROWSER_ENDPOINT_METADATA_SQL, browserEndpointMetadataParams(record));
469+  }
470+
471+  async upsertBrowserLoginState(record: BrowserLoginStateRecord): Promise<void> {
472+    await this.run(UPSERT_BROWSER_LOGIN_STATE_SQL, browserLoginStateParams(record));
473+  }
474+
475   async insertTask(record: TaskRecord): Promise<void> {
476     await this.run(INSERT_TASK_SQL, taskParams(record));
477   }
478@@ -1758,6 +2122,22 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
479     return rows.map(mapControllerRow);
480   }
481 
482+  async listBrowserEndpointMetadata(
483+    options: ListBrowserEndpointMetadataOptions = {}
484+  ): Promise<BrowserEndpointMetadataRecord[]> {
485+    const { query, params } = buildBrowserEndpointMetadataListQuery(options);
486+    const rows = await this.fetchAll(query, params);
487+    return rows.map(mapBrowserEndpointMetadataRow);
488+  }
489+
490+  async listBrowserLoginStates(
491+    options: ListBrowserLoginStatesOptions = {}
492+  ): Promise<BrowserLoginStateRecord[]> {
493+    const { query, params } = buildBrowserLoginStatesListQuery(options);
494+    const rows = await this.fetchAll(query, params);
495+    return rows.map(mapBrowserLoginStateRow);
496+  }
497+
498   async listTaskLogs(taskId: string, options: ListTaskLogsOptions = {}): Promise<TaskLogRecord[]> {
499     const limit = options.limit ?? 200;
500     const query = options.runId == null ? SELECT_TASK_LOGS_SQL : SELECT_TASK_LOGS_BY_RUN_SQL;
501@@ -1784,6 +2164,16 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
502     return rows.map(mapTaskStepRow);
503   }
504 
505+  async markBrowserLoginStatesLost(lastSeenBeforeOrAt: number): Promise<number> {
506+    const result = await this.run(UPDATE_BROWSER_LOGIN_STATES_LOST_SQL, [lastSeenBeforeOrAt]);
507+    return result.meta.changes ?? 0;
508+  }
509+
510+  async markBrowserLoginStatesStale(lastSeenBeforeOrAt: number): Promise<number> {
511+    const result = await this.run(UPDATE_BROWSER_LOGIN_STATES_STALE_SQL, [lastSeenBeforeOrAt]);
512+    return result.meta.changes ?? 0;
513+  }
514+
515   async putLeaderLease(record: LeaderLeaseRecord): Promise<void> {
516     if (record.leaseName !== GLOBAL_LEASE_NAME) {
517       throw new RangeError(`leader_lease only supports lease_name="${GLOBAL_LEASE_NAME}".`);
M packages/schemas/src/index.ts
+21, -0
 1@@ -2,6 +2,7 @@ export type AutomationMode = "running" | "draining" | "paused";
 2 export type TaskStatus = "queued" | "planning" | "running" | "paused" | "done" | "failed" | "canceled";
 3 export type StepStatus = "pending" | "running" | "done" | "failed" | "timeout";
 4 export type StepKind = "planner" | "codex" | "shell" | "git" | "review" | "finalize";
 5+export type BrowserLoginStateStatus = "fresh" | "stale" | "lost";
 6 
 7 export interface TaskRecord {
 8   taskId: string;
 9@@ -24,3 +25,23 @@ export interface StepRecord {
10   timeoutSec: number;
11 }
12 
13+export interface BrowserSessionKey {
14+  platform: string;
15+  clientId: string;
16+  account: string;
17+}
18+
19+export interface BrowserLoginStateRecord extends BrowserSessionKey {
20+  host: string;
21+  browser: string;
22+  credentialFingerprint: string;
23+  capturedAt: number;
24+  lastSeenAt: number;
25+  status: BrowserLoginStateStatus;
26+}
27+
28+export interface BrowserEndpointMetadataRecord extends BrowserSessionKey {
29+  endpoints: string[];
30+  updatedAt: number;
31+  lastVerifiedAt: number | null;
32+}
M plans/STATUS_SUMMARY.md
+13, -11
 1@@ -9,12 +9,12 @@
 2 - 主线基线:`main@4796db4`
 3 - 任务文档已统一收口到 `tasks/`
 4 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
 5-- `T-S001` 到 `T-S016` 已经合入主线
 6+- `T-S001` 到 `T-S020` 已经完成
 7 
 8 ## 当前状态分类
 9 
10-- `已完成`:`T-S001` 到 `T-S016`
11-- `当前 TODO`:浏览器桥接登录态与 API 端点元数据持久化
12+- `已完成`:`T-S001` 到 `T-S020`
13+- `当前 TODO`:`无`
14 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
15 
16 当前新的主需求文档:
17@@ -33,7 +33,7 @@
18 - `tests/control-api/` 当前只剩一份 legacy absence smoke 测试文件
19 - `status-api` 当前默认读 `BAA_CONDUCTOR_LOCAL_API` / `4317`,`BAA_CONTROL_API_BASE` 只保留为兼容覆盖入口
20 - `status-api` 当前结论是继续保留为显式 opt-in 的本地兼容包装层,不立即删除
21-- 浏览器桥接当前正在转向“登录态元数据持久化 + 浏览器本地代发请求”模型
22+- 浏览器桥接当前正式模型已经收口为“登录态元数据持久化 + 空壳标签页 + 浏览器本地代发请求”
23 
24 ## 当前在线面
25 
26@@ -54,15 +54,12 @@
27 - 单节点的 Nginx / DNS 计划脚本
28 - 迁移期兼容件:`apps/status-api`、`ops/cloudflare/**`、`tests/control-api/**`、`BAA_CONTROL_API_BASE`
29 
30-## 当前 TODO
31+## 当前主 TODO
32 
33-当前主 TODO:
34+当前主 TODO 已清空;当前主线只剩低优先级 backlog:
35 
36-1. 按 [`./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md) 定义并实现浏览器桥接持久化模型:
37-   - `conductor` 持久化 `account`
38-   - 只保存凭证指纹,不保存原始值
39-   - 持久化已发现 API 端点
40-   - 浏览器侧只保留空壳标签页,不把页面对话作为正式能力
41+1. 清理仍依赖 `4318` / `status-api` wrapper 的旧脚本、书签和运维说明
42+2. 把 `conductor-daemon` 对 `status-api` 构建产物的复用提成共享模块,再评估是否删除 `apps/status-api`
43 
44 ## 低优先级 TODO
45 
46@@ -90,6 +87,10 @@
47 - `T-S014`:runtime 默认服务集合已收口到 `conductor` + `codexd`,`status-api` 改为显式 opt-in
48 - `T-S015`:`mini` 节点 on-node 静态+运行态检查已收口到 `scripts/runtime/verify-mini.sh`,仓库根入口是 `pnpm verify:mini`
49 - `T-S016`:`conductor-daemon` 已承接 `/v1/status` 和 `/v1/status/ui`;`status-api` 降为 `4318` 上的兼容包装层
50+- `T-S017`:浏览器登录态与端点元数据仓储模型已落地
51+- `T-S018`:Firefox 插件已收口到空壳标签页,并开始上报 `account`、凭证指纹和端点元数据
52+- `T-S019`:`conductor-daemon` 已接入浏览器登录态持久化、读接口合并视图和 `fresh` / `stale` / `lost` 状态收口
53+- `T-S020`:浏览器桥接文档、browser smoke 和状态视图已经回写到“登录态元数据持久化 + 空壳标签页 + 浏览器本地代发”正式模型
54 - 根级 `pnpm smoke` 已进主线,覆盖 runtime public-api compatibility、legacy absence、codexd e2e 和 browser-control e2e smoke
55 
56 ## 4318 依赖盘点与结论
57@@ -116,5 +117,6 @@
58 - 如果未来新增 runtime 测试绕开 `withRuntimeFixture(...)`,同类 listener 泄漏仍可能重新出现
59 - 这次没有改 `ConductorRuntime.stop()` 内部逻辑;如果未来关闭路径本身阻塞,还需要单独补运行时层测试
60 - 根 `pnpm test` 现在已经覆盖 `worker-runner`;runtime / e2e 检查则由 `pnpm smoke` 收口
61+- runtime smoke 当前仍假定仓库根已经存在 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮浏览器桥接功能改动本身
62 - `pnpm verify:mini` 只收口 on-node 静态检查和运行态探针,不替代会话级 smoke
63 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
M plugins/baa-firefox/README.md
+78, -31
  1@@ -1,45 +1,90 @@
  2 # BAA Firefox
  3 
  4-这个目录现在作为 `baa-conductor` 内部插件子目录维护。
  5+Firefox 插件的正式能力已经收口到三件事:
  6+
  7+- 为 Claude / ChatGPT / Gemini 维持单个平台单空壳页
  8+- 向本地 `conductor` 上报登录态元数据
  9+- 在浏览器本地持有原始凭证并代发 API 请求
 10+
 11+页面对话 UI、会话回读、消息态观察不再是正式能力。
 12 
 13 ## 当前默认连接
 14 
 15 - 本地 WS bridge:`ws://100.71.210.78:4317/ws/firefox`
 16 - 本地 HTTP 入口:`http://100.71.210.78:4317`
 17 
 18-管理页已经收口为两块状态和三颗控制按钮:
 19+管理页只保留:
 20 
 21 - 本地 WS 状态
 22 - 本地 HTTP 状态
 23+- 空壳页 / 账号 / 登录态指纹 / 端点元数据概览
 24 - `Pause` / `Resume` / `Drain`
 25 
 26-不再允许用户手工编辑地址。
 27+## 空壳页模式
 28+
 29+每个平台最多保留一个专用 shell tab:
 30+
 31+- Claude:`https://claude.ai/#baa-shell`
 32+- ChatGPT:`https://chatgpt.com/#baa-shell`
 33+- Gemini:`https://gemini.google.com/#baa-shell`
 34+
 35+这些标签页只是登录环境和同源请求壳,不再承载业务会话语义。
 36+
 37+插件行为:
 38+
 39+- 只把带 `#baa-shell` 的平台页认作正式 bridge 壳页
 40+- 如果壳页被导航到其他业务 URL,插件会把它从正式能力里摘掉
 41+- 需要代理请求时,插件会优先复用或重新收口到对应平台的壳页
 42+
 43+## 上报的元数据
 44+
 45+插件会通过本地 WS 发送:
 46 
 47-## What This Extension Does
 48+- `platform`
 49+- `account`
 50+- `credential_fingerprint`
 51+- `freshness`
 52+- `captured_at`
 53+- `last_seen_at`
 54+- `endpoints`
 55+- `endpoint_metadata`
 56 
 57-- keeps an always-open `controller.html`
 58-- auto-connects the local Firefox bridge WS on startup
 59-- keeps polling the local conductor HTTP surface with retry/backoff
 60-- sends `hello` / `credentials` / `api_endpoints` metadata to the local WS bridge
 61-- lets the operator trigger `pause` / `resume` / `drain`
 62+其中:
 63 
 64-底层的 tab 跟踪、endpoint 发现和凭证捕获逻辑仍然保留,用于本地 WS bridge 同步,但不再在管理页里展开显示。
 65+- `account` 是尽力识别出的账号标识,优先 email,其次平台侧账号 hint
 66+- `credential_fingerprint` 是浏览器本地从真实凭证计算的不可逆摘要
 67+- `endpoint_metadata` 包含方法、归一化路径、首次观测时间、最近观测时间
 68 
 69-## Files
 70+为兼容现有 bridge 汇总,`credentials` 消息仍会带一个 `headers` 对象,但值全部是脱敏占位符,只用于保留 header 名称和数量,不包含原始值。
 71 
 72-- `manifest.json` - Firefox MV3 manifest
 73-- `background.js` - keeps the controller tab alive and updates the toolbar badge
 74-- `controller.html` - compact management page
 75-- `controller.js` - local WS client, public HTTP client, control actions, browser bridge logic
 76-- `content-script.js` - bridge from page events into the extension runtime
 77-- `page-interceptor.js` - MAIN world `fetch` interceptor
 78+## 明确不上报的敏感值
 79+
 80+以下内容仍只允许停留在浏览器本地,不通过 bridge 回传给 `conductor`:
 81+
 82+- 原始 `cookie`
 83+- 原始 `authorization` / `token`
 84+- 原始 header 值
 85+- 页面会话内容
 86+- 页面 UI 状态
 87+
 88+`network_log` / `sse_event` 这类诊断路径不再向 WS 回传请求头值。
 89+
 90+## 浏览器本地代发边界
 91+
 92+插件仍然会在本地保存最近一次可用的原始请求头快照,用于:
 93+
 94+- Claude 同源 API 代发
 95+- ChatGPT 同源 API 代发
 96+- Gemini 基于已发现模板的本地代发
 97+
 98+这些原始值只在浏览器内部使用,不进入 `conductor` 的 WS 元数据上报。
 99 
100 ## How To Load
101 
102 1. Open Firefox.
103 2. Visit `about:debugging#/runtime/this-firefox`.
104 3. Click `Load Temporary Add-on...`.
105-4. Choose [manifest.json](/Users/george/code/worktrees/baa-conductor-firefox-local-ws-client/plugins/baa-firefox/manifest.json).
106+4. Choose [manifest.json](/Users/george/code/baa-conductor/plugins/baa-firefox/manifest.json).
107 5. Firefox should open `controller.html` automatically.
108 
109 ## How To Run
110@@ -49,20 +94,22 @@
111 3. Open `controller.html` and confirm:
112    - `本地 WS` becomes `已连接`
113    - `本地 HTTP` reaches `已连接` or enters visible auto-retry
114-4. Click `暂停` / `恢复` / `排空` as needed.
115-5. Open Claude, ChatGPT, or Gemini normally if you want the browser bridge to report credentials and endpoints.
116+4. Let the plugin open or refocus the shell tab for the target platform.
117+5. Sign in on that shell tab if needed.
118+6. Wait for the management page to show account / fingerprint / endpoint metadata.
119 
120 ## Verification
121 
122 - WS connect: controller page shows `本地 WS = 已连接`
123-- WS reconnect: stop local daemon and restart it; controller page should move to retrying and then recover automatically
124-- HTTP sync: `本地 HTTP` should keep refreshing every `15` seconds
125-- Control buttons: after clicking `暂停` / `恢复` / `排空`, the HTTP state should reflect the new mode
126-
127-## Limitations
128-
129-- only one tab per platform is tracked
130-- only `fetch` is patched in the page
131-- there is no full remote request execution path yet
132-- if Firefox unloads the extension, the session must be re-established
133-- Gemini interception is still heuristic because its web app mixes private RPC paths under `/_/`
134+- HTTP sync: controller page shows `本地 HTTP = 已连接` or visible retry
135+- Shell tabs: platform cards show the dedicated shell tabs instead of arbitrary business tabs
136+- Metadata: account / fingerprint / endpoint panels populate without exposing raw header values
137+- `GET /v1/browser`: merged record shows `view` / `status` and does not expose raw credential values
138+- Disconnect / aging: persisted record remains readable after disconnect and later changes `fresh -> stale -> lost`
139+- Proxying: server-initiated `api_request` still completes through the shell tab
140+
141+## Conductor 对接现状
142+
143+- `GET /v1/browser` 已经会合并活跃 bridge 和持久化记录,并返回 `view`、`status`、`account`、凭证指纹和端点元数据
144+- client 断连或长时间老化后,持久化记录仍可读取,但状态会转成 `stale` / `lost`
145+- 当前正式浏览器本地代发 HTTP 面只有 Claude:`/v1/browser/claude/*`
M plugins/baa-firefox/content-script.js
+0, -316
  1@@ -1,15 +1,3 @@
  2-const CONTROL_STATE_KEY = "baaFirefox.controlState";
  3-const CLAUDE_ENTRY_HOSTS = ["claude.ai"];
  4-
  5-const claudeEntryState = {
  6-  host: null,
  7-  shadow: null,
  8-  snapshot: null,
  9-  expanded: false,
 10-  busyAction: null,
 11-  error: ""
 12-};
 13-
 14 function sendBridgeMessage(type, data) {
 15   browser.runtime.sendMessage({
 16     type,
 17@@ -17,294 +5,6 @@ function sendBridgeMessage(type, data) {
 18   }).catch(() => {});
 19 }
 20 
 21-function isRecord(value) {
 22-  return !!value && typeof value === "object" && !Array.isArray(value);
 23-}
 24-
 25-function isClaudePage() {
 26-  return CLAUDE_ENTRY_HOSTS.some((host) => location.hostname === host || location.hostname.endsWith(`.${host}`));
 27-}
 28-
 29-function normalizeMode(value) {
 30-  const lower = String(value || "").trim().toLowerCase();
 31-  if (lower === "running" || lower === "paused" || lower === "draining") {
 32-    return lower;
 33-  }
 34-  return "unknown";
 35-}
 36-
 37-function formatModeLabel(mode) {
 38-  switch (normalizeMode(mode)) {
 39-    case "running":
 40-      return "运行中";
 41-    case "paused":
 42-      return "已暂停";
 43-    case "draining":
 44-      return "排空中";
 45-    default:
 46-      return "未知";
 47-  }
 48-}
 49-
 50-function normalizeEntrySnapshot(value) {
 51-  if (!isRecord(value)) {
 52-    return {
 53-      mode: "unknown",
 54-      leader: null,
 55-      queueDepth: null,
 56-      activeRuns: null,
 57-      error: "未同步"
 58-    };
 59-  }
 60-
 61-  return {
 62-    mode: normalizeMode(value.mode),
 63-    leader: typeof value.leader === "string" && value.leader.trim() ? value.leader.trim() : null,
 64-    queueDepth: Number.isFinite(value.queueDepth) ? Number(value.queueDepth) : null,
 65-    activeRuns: Number.isFinite(value.activeRuns) ? Number(value.activeRuns) : null,
 66-    error: typeof value.error === "string" && value.error ? value.error : ""
 67-  };
 68-}
 69-
 70-function ensureClaudeEntry() {
 71-  if (!isClaudePage() || claudeEntryState.host || !document.documentElement) return;
 72-
 73-  const host = document.createElement("div");
 74-  host.style.position = "fixed";
 75-  host.style.right = "16px";
 76-  host.style.bottom = "16px";
 77-  host.style.zIndex = "2147483647";
 78-
 79-  const shadow = host.attachShadow({ mode: "open" });
 80-  shadow.innerHTML = `
 81-    <style>
 82-      :host {
 83-        all: initial;
 84-      }
 85-
 86-      .wrap {
 87-        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
 88-        color: #f5efe2;
 89-      }
 90-
 91-      button {
 92-        font: inherit;
 93-        border: 0;
 94-        cursor: pointer;
 95-      }
 96-
 97-      .toggle {
 98-        display: inline-flex;
 99-        align-items: center;
100-        gap: 8px;
101-        padding: 9px 12px;
102-        border-radius: 999px;
103-        background: rgba(19, 26, 26, 0.92);
104-        color: inherit;
105-        box-shadow: 0 10px 28px rgba(0, 0, 0, 0.22);
106-      }
107-
108-      .toggle strong {
109-        font-size: 12px;
110-        letter-spacing: 0.08em;
111-        text-transform: uppercase;
112-      }
113-
114-      .badge {
115-        padding: 4px 8px;
116-        border-radius: 999px;
117-        font-size: 11px;
118-        line-height: 1;
119-      }
120-
121-      .badge.running {
122-        background: rgba(31, 111, 95, 0.28);
123-        color: #91dbc7;
124-      }
125-
126-      .badge.paused,
127-      .badge.draining {
128-        background: rgba(166, 81, 47, 0.28);
129-        color: #ffd3bf;
130-      }
131-
132-      .badge.unknown {
133-        background: rgba(127, 122, 112, 0.28);
134-        color: #e5dccd;
135-      }
136-
137-      .panel {
138-        margin-top: 10px;
139-        width: 250px;
140-        padding: 12px;
141-        border-radius: 18px;
142-        background: rgba(19, 26, 26, 0.94);
143-        box-shadow: 0 16px 40px rgba(0, 0, 0, 0.24);
144-      }
145-
146-      .hidden {
147-        display: none;
148-      }
149-
150-      .topline,
151-      .meta,
152-      .actions {
153-        display: flex;
154-        align-items: center;
155-        gap: 8px;
156-      }
157-
158-      .topline,
159-      .meta {
160-        justify-content: space-between;
161-      }
162-
163-      .meta {
164-        margin-top: 10px;
165-        color: rgba(245, 239, 226, 0.78);
166-        font-size: 12px;
167-      }
168-
169-      .actions {
170-        margin-top: 12px;
171-        flex-wrap: wrap;
172-      }
173-
174-      .panel-btn {
175-        padding: 8px 10px;
176-        border-radius: 999px;
177-        background: rgba(255, 255, 255, 0.08);
178-        color: inherit;
179-      }
180-
181-      .panel-btn:hover {
182-        background: rgba(255, 255, 255, 0.14);
183-      }
184-
185-      .panel-btn[disabled] {
186-        opacity: 0.6;
187-        cursor: default;
188-      }
189-
190-      .error {
191-        margin-top: 10px;
192-        color: #ffd3bf;
193-        font-size: 12px;
194-        line-height: 1.45;
195-      }
196-    </style>
197-    <div class="wrap">
198-      <button id="baa-toggle" class="toggle" type="button">
199-        <strong>BAA</strong>
200-        <span id="baa-mode-badge" class="badge unknown">未知</span>
201-      </button>
202-      <div id="baa-panel" class="panel hidden">
203-        <div class="topline">
204-          <span id="baa-leader">主控 -</span>
205-          <button id="baa-open-panel" class="panel-btn" type="button">管理页</button>
206-        </div>
207-        <div class="meta">
208-          <span id="baa-queue">队列 -</span>
209-          <span id="baa-runs">运行 -</span>
210-        </div>
211-        <div class="actions">
212-          <button data-action="pause" class="panel-btn" type="button">暂停</button>
213-          <button data-action="resume" class="panel-btn" type="button">恢复</button>
214-          <button data-action="drain" class="panel-btn" type="button">排空</button>
215-        </div>
216-        <div id="baa-error" class="error hidden"></div>
217-      </div>
218-    </div>
219-  `;
220-
221-  claudeEntryState.host = host;
222-  claudeEntryState.shadow = shadow;
223-
224-  shadow.getElementById("baa-toggle").addEventListener("click", () => {
225-    claudeEntryState.expanded = !claudeEntryState.expanded;
226-    renderClaudeEntry();
227-  });
228-
229-  shadow.getElementById("baa-open-panel").addEventListener("click", () => {
230-    browser.runtime.sendMessage({ type: "focus_controller" }).catch(() => {});
231-    claudeEntryState.expanded = false;
232-    renderClaudeEntry();
233-  });
234-
235-  for (const actionButton of shadow.querySelectorAll("[data-action]")) {
236-    actionButton.addEventListener("click", () => {
237-      runClaudeControlAction(actionButton.dataset.action || "").catch(() => {});
238-    });
239-  }
240-
241-  document.documentElement.append(host);
242-  renderClaudeEntry();
243-}
244-
245-function renderClaudeEntry() {
246-  if (!claudeEntryState.shadow) return;
247-
248-  const snapshot = normalizeEntrySnapshot(claudeEntryState.snapshot);
249-  const badge = claudeEntryState.shadow.getElementById("baa-mode-badge");
250-  const panel = claudeEntryState.shadow.getElementById("baa-panel");
251-  const leader = claudeEntryState.shadow.getElementById("baa-leader");
252-  const queue = claudeEntryState.shadow.getElementById("baa-queue");
253-  const runs = claudeEntryState.shadow.getElementById("baa-runs");
254-  const error = claudeEntryState.shadow.getElementById("baa-error");
255-
256-  badge.textContent = formatModeLabel(snapshot.mode);
257-  badge.className = `badge ${snapshot.mode}`;
258-  leader.textContent = `主控 ${snapshot.leader || "-"}`;
259-  queue.textContent = `队列 ${snapshot.queueDepth == null ? "-" : snapshot.queueDepth}`;
260-  runs.textContent = `运行 ${snapshot.activeRuns == null ? "-" : snapshot.activeRuns}`;
261-  panel.classList.toggle("hidden", !claudeEntryState.expanded);
262-
263-  const busy = !!claudeEntryState.busyAction;
264-  for (const button of claudeEntryState.shadow.querySelectorAll(".panel-btn")) {
265-    if (button.id === "baa-open-panel") continue;
266-    button.disabled = busy;
267-  }
268-
269-  const errorText = claudeEntryState.error || snapshot.error;
270-  error.textContent = errorText || "";
271-  error.classList.toggle("hidden", !errorText);
272-}
273-
274-async function loadClaudeEntrySnapshot() {
275-  const data = await browser.storage.local.get(CONTROL_STATE_KEY);
276-  claudeEntryState.snapshot = normalizeEntrySnapshot(data[CONTROL_STATE_KEY]);
277-  renderClaudeEntry();
278-}
279-
280-async function runClaudeControlAction(action) {
281-  if (!action) return;
282-
283-  claudeEntryState.busyAction = action;
284-  claudeEntryState.error = "";
285-  renderClaudeEntry();
286-
287-  try {
288-    const response = await browser.runtime.sendMessage({
289-      type: "control_plane_command",
290-      action,
291-      source: "claude_entry"
292-    });
293-
294-    if (response?.snapshot) {
295-      claudeEntryState.snapshot = normalizeEntrySnapshot(response.snapshot);
296-    }
297-
298-    if (!response?.ok) {
299-      throw new Error(response?.error || `${action} 执行失败`);
300-    }
301-  } catch (error) {
302-    claudeEntryState.error = error.message;
303-  } finally {
304-    claudeEntryState.busyAction = null;
305-    renderClaudeEntry();
306-  }
307-}
308-
309 sendBridgeMessage("baa_page_bridge_ready", {
310   url: location.href,
311   source: "content-script"
312@@ -340,19 +40,3 @@ browser.runtime.onMessage.addListener((message) => {
313 
314   return undefined;
315 });
316-
317-browser.storage.onChanged.addListener((changes, areaName) => {
318-  if (areaName !== "local" || !changes[CONTROL_STATE_KEY]) return;
319-  claudeEntryState.snapshot = normalizeEntrySnapshot(changes[CONTROL_STATE_KEY].newValue);
320-  renderClaudeEntry();
321-});
322-
323-if (document.readyState === "loading") {
324-  document.addEventListener("DOMContentLoaded", () => {
325-    ensureClaudeEntry();
326-    loadClaudeEntrySnapshot().catch(() => {});
327-  }, { once: true });
328-} else {
329-  ensureClaudeEntry();
330-  loadClaudeEntrySnapshot().catch(() => {});
331-}
M plugins/baa-firefox/controller.html
+21, -8
 1@@ -11,8 +11,8 @@
 2     <section class="topbar">
 3       <div>
 4         <p class="eyebrow">BAA Firefox 控制台</p>
 5-        <h1>本地 WS / 本地 HTTP</h1>
 6-        <p class="meta">本地 bridge 自动连接 mini,本地控制面固定走 <code>http://100.71.210.78:4317</code>。</p>
 7+        <h1>空壳页 / 元数据 / 本地代发</h1>
 8+        <p class="meta">正式能力已经收口到平台空壳页、登录态元数据上报和浏览器本地 API 代发;控制面固定走 <code>http://100.71.210.78:4317</code>。</p>
 9       </div>
10     </section>
11 
12@@ -36,19 +36,25 @@
13       </article>
14 
15       <article class="card">
16-        <p class="label">追踪标签页</p>
17+        <p class="label">空壳页</p>
18         <p id="tracked-count" class="value off">0</p>
19-        <p id="tracked-meta" class="meta">等待平台扫描</p>
20+        <p id="tracked-meta" class="meta">等待壳页扫描</p>
21       </article>
22 
23       <article class="card">
24-        <p class="label">有效凭证</p>
25+        <p class="label">账号</p>
26+        <p id="account-count" class="value off">0</p>
27+        <p id="account-meta" class="meta">等待账号识别</p>
28+      </article>
29+
30+      <article class="card">
31+        <p class="label">登录态指纹</p>
32         <p id="credential-count" class="value off">0</p>
33-        <p id="credential-meta" class="meta">等待凭证快照</p>
34+        <p id="credential-meta" class="meta">等待登录态快照</p>
35       </article>
36 
37       <article class="card">
38-        <p class="label">已发现端点</p>
39+        <p class="label">代理端点</p>
40         <p id="endpoint-count" class="value off">0</p>
41         <p id="endpoint-meta" class="meta">等待端点发现</p>
42       </article>
43@@ -63,7 +69,14 @@
44 
45     <section class="panel">
46       <div class="panel-head">
47-        <h2>凭证快照</h2>
48+        <h2>账号元数据</h2>
49+      </div>
50+      <pre id="account-view" class="code"></pre>
51+    </section>
52+
53+    <section class="panel">
54+      <div class="panel-head">
55+        <h2>登录态指纹</h2>
56       </div>
57       <pre id="credential-view" class="code"></pre>
58     </section>
M plugins/baa-firefox/controller.js
+782, -148
   1@@ -14,9 +14,12 @@ const CONTROLLER_STORAGE_KEYS = {
   2   trackedTabs: "baaFirefox.trackedTabs",
   3   endpointsByPlatform: "baaFirefox.endpointsByPlatform",
   4   lastHeadersByPlatform: "baaFirefox.lastHeadersByPlatform",
   5+  credentialCapturedAtByPlatform: "baaFirefox.credentialCapturedAtByPlatform",
   6   lastCredentialAtByPlatform: "baaFirefox.lastCredentialAtByPlatform",
   7   lastCredentialUrlByPlatform: "baaFirefox.lastCredentialUrlByPlatform",
   8   lastCredentialTabIdByPlatform: "baaFirefox.lastCredentialTabIdByPlatform",
   9+  credentialFingerprintByPlatform: "baaFirefox.credentialFingerprintByPlatform",
  10+  accountByPlatform: "baaFirefox.accountByPlatform",
  11   geminiSendTemplate: "baaFirefox.geminiSendTemplate",
  12   claudeState: "baaFirefox.claudeState"
  13 };
  14@@ -24,7 +27,7 @@ const CONTROLLER_STORAGE_KEYS = {
  15 const DEFAULT_LOCAL_API_BASE = "http://100.71.210.78:4317";
  16 const DEFAULT_WS_URL = "ws://100.71.210.78:4317/ws/firefox";
  17 const DEFAULT_CONTROL_BASE_URL = "http://100.71.210.78:4317";
  18-const STATUS_SCHEMA_VERSION = 3;
  19+const STATUS_SCHEMA_VERSION = 5;
  20 const CREDENTIAL_SEND_INTERVAL = 30_000;
  21 const CREDENTIAL_TTL = 15 * 60_000;
  22 const NETWORK_BODY_LIMIT = 5000;
  23@@ -42,6 +45,9 @@ const PROXY_REQUEST_TIMEOUT = 180_000;
  24 const CLAUDE_MESSAGE_LIMIT = 20;
  25 const CLAUDE_TOOL_PLACEHOLDER_RE = /```\n?This block is not supported on your current device yet\.?\n?```/g;
  26 const CLAUDE_THINKING_START_RE = /^(The user|Let me|I need to|I should|I'll|George|User |Looking at|This is a|OK[,.]|Alright|Hmm|Now |Here|So |Wait|Actually|My |Their |His |Her |We |用户|让我|我需要|我来|我想|好的|那|先|接下来)/;
  27+const SHELL_TAB_HASH = "#baa-shell";
  28+const REDACTED_CREDENTIAL_VALUE = "[redacted]";
  29+const ACCOUNT_EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
  30 const CHATGPT_SESSION_COOKIE_PATTERNS = [
  31   /__secure-next-auth\.session-token=/i,
  32   /__secure-authjs\.session-token=/i,
  33@@ -77,6 +83,7 @@ const PLATFORMS = {
  34   claude: {
  35     label: "Claude",
  36     rootUrl: "https://claude.ai/",
  37+    shellUrl: `https://claude.ai/${SHELL_TAB_HASH}`,
  38     urlPatterns: ["*://claude.ai/*"],
  39     hosts: ["claude.ai"],
  40     requestUrlPatterns: ["*://claude.ai/*"],
  41@@ -94,6 +101,7 @@ const PLATFORMS = {
  42   chatgpt: {
  43     label: "ChatGPT",
  44     rootUrl: "https://chatgpt.com/",
  45+    shellUrl: `https://chatgpt.com/${SHELL_TAB_HASH}`,
  46     urlPatterns: ["*://chatgpt.com/*", "*://*.chatgpt.com/*", "*://chat.openai.com/*", "*://*.chat.openai.com/*"],
  47     hosts: ["chatgpt.com", "chat.openai.com"],
  48     requestUrlPatterns: [
  49@@ -127,6 +135,7 @@ const PLATFORMS = {
  50   gemini: {
  51     label: "Gemini",
  52     rootUrl: "https://gemini.google.com/",
  53+    shellUrl: `https://gemini.google.com/${SHELL_TAB_HASH}`,
  54     urlPatterns: ["*://gemini.google.com/*"],
  55     hosts: ["gemini.google.com"],
  56     requestUrlPatterns: ["*://gemini.google.com/*"],
  57@@ -173,9 +182,12 @@ const state = {
  58   trackedTabs: createPlatformMap(() => null),
  59   endpoints: createPlatformMap(() => ({})),
  60   lastHeaders: createPlatformMap(() => ({})),
  61+  credentialCapturedAt: createPlatformMap(() => 0),
  62   lastCredentialAt: createPlatformMap(() => 0),
  63   lastCredentialUrl: createPlatformMap(() => ""),
  64   lastCredentialTabId: createPlatformMap(() => null),
  65+  credentialFingerprint: createPlatformMap(() => ""),
  66+  account: createPlatformMap(() => createDefaultAccountState()),
  67   lastCredentialHash: createPlatformMap(() => ""),
  68   lastCredentialSentAt: createPlatformMap(() => 0),
  69   geminiSendTemplate: null,
  70@@ -197,6 +209,22 @@ function trimTrailingSlash(value) {
  71   return String(value || "").trim().replace(/\/+$/u, "");
  72 }
  73 
  74+function getPlatformShellUrl(platform) {
  75+  return PLATFORMS[platform]?.shellUrl || PLATFORMS[platform]?.rootUrl || "";
  76+}
  77+
  78+function isPlatformShellUrl(platform, url) {
  79+  if (!platform || !PLATFORMS[platform] || !url) return false;
  80+
  81+  try {
  82+    const parsed = new URL(url, PLATFORMS[platform].rootUrl);
  83+    const expected = new URL(getPlatformShellUrl(platform));
  84+    return parsed.origin === expected.origin && parsed.pathname === expected.pathname && parsed.hash === expected.hash;
  85+  } catch (_) {
  86+    return false;
  87+  }
  88+}
  89+
  90 function normalizeSavedControlBaseUrl(value) {
  91   void value;
  92   return DEFAULT_CONTROL_BASE_URL;
  93@@ -400,6 +428,33 @@ function loadClaudeState(raw) {
  94   return next;
  95 }
  96 
  97+function createDefaultAccountState(overrides = {}) {
  98+  return {
  99+    value: null,
 100+    kind: null,
 101+    source: null,
 102+    capturedAt: 0,
 103+    lastSeenAt: 0,
 104+    priority: 0,
 105+    ...overrides
 106+  };
 107+}
 108+
 109+function cloneAccountState(value) {
 110+  if (!isRecord(value)) {
 111+    return createDefaultAccountState();
 112+  }
 113+
 114+  return createDefaultAccountState({
 115+    value: trimToNull(value.value),
 116+    kind: trimToNull(value.kind),
 117+    source: trimToNull(value.source),
 118+    capturedAt: Number(value.capturedAt) || 0,
 119+    lastSeenAt: Number(value.lastSeenAt) || 0,
 120+    priority: Number(value.priority) || 0
 121+  });
 122+}
 123+
 124 function trimToNull(value) {
 125   return typeof value === "string" && value.trim() ? value.trim() : null;
 126 }
 127@@ -523,12 +578,12 @@ function updateClaudeState(patch = {}, options = {}) {
 128 async function refreshClaudeTabState(createIfMissing = false) {
 129   const tab = createIfMissing
 130     ? await ensurePlatformTab("claude", { focus: false })
 131-    : (await resolveTrackedTab("claude")) || await findPlatformTab("claude");
 132+    : (await resolveTrackedTab("claude", { requireShell: true })) || await findPlatformShellTab("claude");
 133 
 134   if (!tab) {
 135     updateClaudeState({
 136       tabId: null,
 137-      currentUrl: PLATFORMS.claude.rootUrl,
 138+      currentUrl: getPlatformShellUrl("claude"),
 139       tabTitle: null
 140     }, {
 141       persist: true,
 142@@ -539,9 +594,11 @@ async function refreshClaudeTabState(createIfMissing = false) {
 143 
 144   updateClaudeState({
 145     tabId: Number.isInteger(tab.id) ? tab.id : null,
 146-    currentUrl: tab.url || state.claudeState.currentUrl || PLATFORMS.claude.rootUrl,
 147+    currentUrl: tab.url || state.claudeState.currentUrl || getPlatformShellUrl("claude"),
 148     tabTitle: trimToNull(tab.title),
 149-    conversationId: extractClaudeConversationIdFromPageUrl(tab.url || "") || state.claudeState.conversationId,
 150+    conversationId: isPlatformShellUrl("claude", tab.url || "")
 151+      ? null
 152+      : (extractClaudeConversationIdFromPageUrl(tab.url || "") || state.claudeState.conversationId),
 153     title: state.claudeState.title || trimToNull(tab.title),
 154     titleSource: state.claudeState.title ? state.claudeState.titleSource : (trimToNull(tab.title) ? "tab" : null)
 155   }, {
 156@@ -682,6 +739,52 @@ function loadObjectMap(raw, legacyValue = null) {
 157   return next;
 158 }
 159 
 160+function normalizeEndpointEntry(key, value) {
 161+  const fallbackKey = typeof key === "string" ? key : "";
 162+  const keyParts = fallbackKey.split(" ");
 163+  const fallbackMethod = keyParts.length > 1 ? keyParts[0] : "GET";
 164+  const fallbackPath = keyParts.length > 1 ? keyParts.slice(1).join(" ") : fallbackKey;
 165+
 166+  if (!isRecord(value)) {
 167+    return {
 168+      key: fallbackKey,
 169+      method: fallbackMethod,
 170+      path: fallbackPath,
 171+      firstObservedAt: 0,
 172+      lastObservedAt: 0,
 173+      sampleUrl: null
 174+    };
 175+  }
 176+
 177+  return {
 178+    key: fallbackKey,
 179+    method: trimToNull(value.method) || fallbackMethod,
 180+    path: trimToNull(value.path) || fallbackPath,
 181+    firstObservedAt: Number(value.firstObservedAt) || 0,
 182+    lastObservedAt: Number(value.lastObservedAt) || 0,
 183+    sampleUrl: trimToNull(value.sampleUrl)
 184+  };
 185+}
 186+
 187+function loadEndpointEntries(raw, legacyValue = null) {
 188+  const next = createPlatformMap(() => ({}));
 189+  const source = hasPlatformShape(raw)
 190+    ? raw
 191+    : (isRecord(raw) ? { claude: raw } : (isRecord(legacyValue) ? { claude: legacyValue } : {}));
 192+
 193+  for (const platform of PLATFORM_ORDER) {
 194+    const entries = isRecord(source[platform]) ? source[platform] : {};
 195+    const normalized = {};
 196+    for (const [key, value] of Object.entries(entries)) {
 197+      const entry = normalizeEndpointEntry(key, value);
 198+      normalized[entry.key] = entry;
 199+    }
 200+    next[platform] = normalized;
 201+  }
 202+
 203+  return next;
 204+}
 205+
 206 function loadNumberMap(raw, legacyValue = 0) {
 207   const next = createPlatformMap(() => 0);
 208   if (hasPlatformShape(raw)) {
 209@@ -702,6 +805,17 @@ function loadNumberMap(raw, legacyValue = 0) {
 210   return next;
 211 }
 212 
 213+function loadAccountMap(raw) {
 214+  const next = createPlatformMap(() => createDefaultAccountState());
 215+  if (!hasPlatformShape(raw)) return next;
 216+
 217+  for (const platform of PLATFORM_ORDER) {
 218+    next[platform] = cloneAccountState(raw[platform]);
 219+  }
 220+
 221+  return next;
 222+}
 223+
 224 function loadStringMap(raw, legacyValue = "") {
 225   const next = createPlatformMap(() => "");
 226   if (hasPlatformShape(raw)) {
 227@@ -1216,6 +1330,319 @@ function cookieHeaderMatches(headers, patterns) {
 228   return !!cookie && patterns.some((pattern) => pattern.test(cookie));
 229 }
 230 
 231+function redactCredentialHeaders(headers = {}) {
 232+  const out = {};
 233+
 234+  for (const [name, value] of Object.entries(headers || {})) {
 235+    const normalizedName = String(name || "").toLowerCase();
 236+    if (!normalizedName || value == null || value === "") continue;
 237+    out[normalizedName] = REDACTED_CREDENTIAL_VALUE;
 238+  }
 239+
 240+  return out;
 241+}
 242+
 243+function buildCredentialFingerprintInput(platform, headers = {}) {
 244+  const keys = [];
 245+  const pushIfPresent = (name) => {
 246+    if (hasHeaderValue(headers, name)) {
 247+      keys.push([name, getHeaderValue(headers, name)]);
 248+    }
 249+  };
 250+
 251+  switch (platform) {
 252+    case "claude":
 253+      pushIfPresent("authorization");
 254+      pushIfPresent("cookie");
 255+      pushIfPresent("x-csrf-token");
 256+      pushIfPresent("x-org-id");
 257+      break;
 258+    case "chatgpt":
 259+      pushIfPresent("authorization");
 260+      pushIfPresent("cookie");
 261+      pushIfPresent("openai-sentinel-chat-requirements-token");
 262+      pushIfPresent("openai-sentinel-proof-token");
 263+      pushIfPresent("x-openai-assistant-app-id");
 264+      break;
 265+    case "gemini":
 266+      pushIfPresent("authorization");
 267+      pushIfPresent("cookie");
 268+      pushIfPresent("x-goog-authuser");
 269+      pushIfPresent("x-same-domain");
 270+      break;
 271+    default:
 272+      for (const name of Object.keys(headers || {}).sort()) {
 273+        pushIfPresent(name);
 274+      }
 275+      break;
 276+  }
 277+
 278+  return keys
 279+    .sort(([left], [right]) => left.localeCompare(right))
 280+    .map(([name, value]) => `${name}=${value}`)
 281+    .join("\n");
 282+}
 283+
 284+async function computeCredentialFingerprint(platform, headers = {}) {
 285+  const input = buildCredentialFingerprintInput(platform, headers);
 286+  if (!input) return "";
 287+
 288+  const encoded = new TextEncoder().encode(`${platform}\n${input}`);
 289+  const digest = await crypto.subtle.digest("SHA-256", encoded);
 290+  return Array.from(new Uint8Array(digest))
 291+    .map((value) => value.toString(16).padStart(2, "0"))
 292+    .join("");
 293+}
 294+
 295+function normalizeAccountValue(value) {
 296+  if (typeof value !== "string") return null;
 297+  const trimmed = value.trim();
 298+  if (!trimmed) return null;
 299+  return ACCOUNT_EMAIL_RE.test(trimmed) ? trimmed.toLowerCase() : trimmed;
 300+}
 301+
 302+function createAccountCandidate(value, meta = {}) {
 303+  const normalizedValue = normalizeAccountValue(value);
 304+  if (!normalizedValue) return null;
 305+
 306+  return {
 307+    value: normalizedValue,
 308+    kind: trimToNull(meta.kind),
 309+    source: trimToNull(meta.source),
 310+    priority: Number(meta.priority) || 0,
 311+    observedAt: Number(meta.observedAt) || Date.now()
 312+  };
 313+}
 314+
 315+function selectAccountCandidate(current, next) {
 316+  if (!next) return current;
 317+  if (!current || !current.value) return next;
 318+  if (current.value === next.value) {
 319+    return {
 320+      ...current,
 321+      kind: next.kind || current.kind,
 322+      source: next.source || current.source,
 323+      priority: Math.max(current.priority || 0, next.priority || 0),
 324+      observedAt: Math.max(current.observedAt || 0, next.observedAt || 0)
 325+    };
 326+  }
 327+  if ((next.priority || 0) > (current.priority || 0)) {
 328+    return next;
 329+  }
 330+  return current;
 331+}
 332+
 333+function pathLooksLikeAccountMetadata(url) {
 334+  const path = getRequestPath(url).toLowerCase();
 335+  if (!path) return false;
 336+  if (path.includes("/conversation") || path.includes("/completion") || path.includes("streamgenerate")) {
 337+    return false;
 338+  }
 339+  return path.includes("/account")
 340+    || path.includes("/accounts")
 341+    || path.includes("/profile")
 342+    || path.includes("/session")
 343+    || path.includes("/settings")
 344+    || path.includes("/user")
 345+    || path.includes("/users")
 346+    || path.includes("/me")
 347+    || path.includes("/auth")
 348+    || path.includes("/organizations");
 349+}
 350+
 351+function extractAccountCandidateFromHeaders(platform, headers = {}, requestUrl = "") {
 352+  const observedAt = Date.now();
 353+  let candidate = null;
 354+
 355+  const authUser = getHeaderValue(headers, "x-goog-authuser");
 356+  if (platform === "gemini" && authUser) {
 357+    candidate = selectAccountCandidate(candidate, createAccountCandidate(`google-authuser:${authUser}`, {
 358+      kind: "google_authuser",
 359+      source: "request_header",
 360+      priority: 40,
 361+      observedAt
 362+    }));
 363+  }
 364+
 365+  const orgId = getHeaderValue(headers, "x-org-id") || trimToNull(extractClaudeOrgId(headers, requestUrl));
 366+  if (platform === "claude" && orgId) {
 367+    candidate = selectAccountCandidate(candidate, createAccountCandidate(`claude-org:${orgId}`, {
 368+      kind: "organization_id",
 369+      source: "request_header",
 370+      priority: 20,
 371+      observedAt
 372+    }));
 373+  }
 374+
 375+  return candidate;
 376+}
 377+
 378+function tryParseJson(text) {
 379+  if (typeof text !== "string" || !text.trim()) return null;
 380+  try {
 381+    return JSON.parse(text);
 382+  } catch (_) {
 383+    return null;
 384+  }
 385+}
 386+
 387+function collectAccountCandidates(source, candidates, meta = {}) {
 388+  const queue = [{ value: source, path: "", depth: 0 }];
 389+  let scanned = 0;
 390+
 391+  while (queue.length > 0 && scanned < 120) {
 392+    const current = queue.shift();
 393+    scanned += 1;
 394+    if (!current) continue;
 395+
 396+    const { value, path, depth } = current;
 397+    const lowerPath = path.toLowerCase();
 398+
 399+    if (typeof value === "string") {
 400+      const normalized = value.trim();
 401+      if (!normalized) continue;
 402+
 403+      if (ACCOUNT_EMAIL_RE.test(normalized)) {
 404+        candidates.push(createAccountCandidate(normalized.match(ACCOUNT_EMAIL_RE)?.[0] || normalized, {
 405+          kind: "email",
 406+          source: meta.source,
 407+          priority: 100,
 408+          observedAt: meta.observedAt
 409+        }));
 410+        continue;
 411+      }
 412+
 413+      if (lowerPath.includes("email")) {
 414+        candidates.push(createAccountCandidate(normalized, {
 415+          kind: "email_like",
 416+          source: meta.source,
 417+          priority: 90,
 418+          observedAt: meta.observedAt
 419+        }));
 420+        continue;
 421+      }
 422+
 423+      if (/(account|user|profile|login|username|handle|sub|identifier|id)$/.test(lowerPath)) {
 424+        candidates.push(createAccountCandidate(normalized, {
 425+          kind: "account_hint",
 426+          source: meta.source,
 427+          priority: /name$/.test(lowerPath) ? 35 : 60,
 428+          observedAt: meta.observedAt
 429+        }));
 430+      }
 431+      continue;
 432+    }
 433+
 434+    if (depth >= 4) continue;
 435+
 436+    if (Array.isArray(value)) {
 437+      for (let index = 0; index < value.length && index < 12; index += 1) {
 438+        queue.push({
 439+          value: value[index],
 440+          path: `${path}[${index}]`,
 441+          depth: depth + 1
 442+        });
 443+      }
 444+      continue;
 445+    }
 446+
 447+    if (!isRecord(value)) continue;
 448+
 449+    for (const [key, child] of Object.entries(value).slice(0, 24)) {
 450+      queue.push({
 451+        value: child,
 452+        path: path ? `${path}.${key}` : key,
 453+        depth: depth + 1
 454+      });
 455+    }
 456+  }
 457+}
 458+
 459+function extractAccountCandidateFromBody(body, requestUrl = "") {
 460+  if (!pathLooksLikeAccountMetadata(requestUrl)) return null;
 461+
 462+  const observedAt = Date.now();
 463+  const candidates = [];
 464+  const parsed = tryParseJson(body);
 465+
 466+  if (parsed != null) {
 467+    collectAccountCandidates(parsed, candidates, {
 468+      source: "response_body",
 469+      observedAt
 470+    });
 471+  } else if (typeof body === "string") {
 472+    const match = body.match(ACCOUNT_EMAIL_RE);
 473+    if (match?.[0]) {
 474+      candidates.push(createAccountCandidate(match[0], {
 475+        kind: "email",
 476+        source: "response_body",
 477+        priority: 100,
 478+        observedAt
 479+      }));
 480+    }
 481+  }
 482+
 483+  let best = null;
 484+  for (const candidate of candidates) {
 485+    best = selectAccountCandidate(best, candidate);
 486+  }
 487+  return best;
 488+}
 489+
 490+function updateAccountState(platform, candidate) {
 491+  if (!platform || !candidate) return false;
 492+
 493+  const current = cloneAccountState(state.account[platform]);
 494+  const selected = selectAccountCandidate({
 495+    value: current.value,
 496+    kind: current.kind,
 497+    source: current.source,
 498+    priority: current.priority,
 499+    observedAt: current.lastSeenAt || current.capturedAt || 0
 500+  }, candidate);
 501+
 502+  if (!selected || !selected.value) return false;
 503+
 504+  const sameValue = current.value === selected.value;
 505+  const next = createDefaultAccountState({
 506+    value: selected.value,
 507+    kind: selected.kind || current.kind,
 508+    source: selected.source || current.source,
 509+    priority: Math.max(current.priority || 0, selected.priority || 0),
 510+    capturedAt: sameValue && current.capturedAt > 0 ? current.capturedAt : selected.observedAt,
 511+    lastSeenAt: selected.observedAt
 512+  });
 513+
 514+  const changed = JSON.stringify(current) !== JSON.stringify(next);
 515+  if (!changed) return false;
 516+
 517+  state.account[platform] = next;
 518+  render();
 519+  persistState().catch(() => {});
 520+  return true;
 521+}
 522+
 523+function observeAccountMetadata(platform, requestUrl, body = null, headers = null) {
 524+  let changed = false;
 525+  const headerCandidate = extractAccountCandidateFromHeaders(platform, headers || {}, requestUrl);
 526+  if (headerCandidate) {
 527+    changed = updateAccountState(platform, headerCandidate) || changed;
 528+  }
 529+
 530+  const bodyCandidate = extractAccountCandidateFromBody(body, requestUrl);
 531+  if (bodyCandidate) {
 532+    changed = updateAccountState(platform, bodyCandidate) || changed;
 533+  }
 534+
 535+  return changed;
 536+}
 537+
 538+function getCredentialFreshness(reason) {
 539+  if (reason === "ok") return "fresh";
 540+  if (reason === "stale") return "stale";
 541+  return "lost";
 542+}
 543+
 544 function validateCredentialSnapshot(platform, headers, requestUrl = "") {
 545   const path = getRequestPath(requestUrl).toLowerCase();
 546 
 547@@ -1297,9 +1724,9 @@ function describeCredentialReason(reason) {
 548     case "missing-auth":
 549       return "无登录凭证";
 550     case "missing-tab":
 551-      return "无标签页";
 552+      return "无空壳页";
 553     case "tab-mismatch":
 554-      return "标签页已切换";
 555+      return "空壳页已切换";
 556     case "stale":
 557       return "已过期";
 558     case "missing-meta":
 559@@ -1312,13 +1739,21 @@ function describeCredentialReason(reason) {
 560 function getCredentialState(platform, now = Date.now()) {
 561   const headers = cloneHeaderMap(state.lastHeaders[platform]);
 562   const headerCount = Object.keys(headers).length;
 563+  const fingerprint = trimToNull(state.credentialFingerprint[platform]) || null;
 564+  const capturedAt = Number(state.credentialCapturedAt[platform]) || 0;
 565+  const lastSeenAt = Number(state.lastCredentialAt[platform]) || 0;
 566+  const account = cloneAccountState(state.account[platform]);
 567 
 568   if (headerCount === 0) {
 569     return {
 570       valid: false,
 571       reason: "missing-headers",
 572       headerCount,
 573-      headers
 574+      headers,
 575+      fingerprint,
 576+      capturedAt,
 577+      lastSeenAt,
 578+      account
 579     };
 580   }
 581 
 582@@ -1328,7 +1763,11 @@ function getCredentialState(platform, now = Date.now()) {
 583       valid: false,
 584       reason: "missing-tab",
 585       headerCount,
 586-      headers
 587+      headers,
 588+      fingerprint,
 589+      capturedAt,
 590+      lastSeenAt,
 591+      account
 592     };
 593   }
 594 
 595@@ -1339,7 +1778,11 @@ function getCredentialState(platform, now = Date.now()) {
 596       reason: "missing-meta",
 597       headerCount,
 598       headers,
 599-      tabId: trackedTabId
 600+      tabId: trackedTabId,
 601+      fingerprint,
 602+      capturedAt,
 603+      lastSeenAt,
 604+      account
 605     };
 606   }
 607 
 608@@ -1350,29 +1793,39 @@ function getCredentialState(platform, now = Date.now()) {
 609       headerCount,
 610       headers,
 611       tabId: trackedTabId,
 612-      credentialTabId
 613+      credentialTabId,
 614+      fingerprint,
 615+      capturedAt,
 616+      lastSeenAt,
 617+      account
 618     };
 619   }
 620 
 621-  const capturedAt = Number(state.lastCredentialAt[platform]) || 0;
 622-  if (capturedAt <= 0) {
 623+  if (capturedAt <= 0 || lastSeenAt <= 0) {
 624     return {
 625       valid: false,
 626       reason: "missing-meta",
 627       headerCount,
 628       headers,
 629-      tabId: trackedTabId
 630+      tabId: trackedTabId,
 631+      fingerprint,
 632+      capturedAt,
 633+      lastSeenAt,
 634+      account
 635     };
 636   }
 637 
 638-  if (now - capturedAt > CREDENTIAL_TTL) {
 639+  if (now - lastSeenAt > CREDENTIAL_TTL) {
 640     return {
 641       valid: false,
 642       reason: "stale",
 643       headerCount,
 644       headers,
 645       tabId: trackedTabId,
 646-      capturedAt
 647+      capturedAt,
 648+      lastSeenAt,
 649+      fingerprint,
 650+      account
 651     };
 652   }
 653 
 654@@ -1386,7 +1839,10 @@ function getCredentialState(platform, now = Date.now()) {
 655       headers,
 656       tabId: trackedTabId,
 657       capturedAt,
 658-      url: requestUrl
 659+      lastSeenAt,
 660+      url: requestUrl,
 661+      fingerprint,
 662+      account
 663     };
 664   }
 665 
 666@@ -1397,7 +1853,10 @@ function getCredentialState(platform, now = Date.now()) {
 667     headers,
 668     tabId: trackedTabId,
 669     capturedAt,
 670-    url: requestUrl
 671+    lastSeenAt,
 672+    url: requestUrl,
 673+    fingerprint,
 674+    account
 675   };
 676 }
 677 
 678@@ -1407,6 +1866,29 @@ function requireCredentialState(platform) {
 679   throw new Error(`${platformLabel(platform)} 没有有效凭证:${describeCredentialReason(credential.reason)}`);
 680 }
 681 
 682+function buildCredentialTransportSnapshot(platform) {
 683+  const credential = getCredentialState(platform);
 684+  const account = cloneAccountState(state.account[platform]);
 685+  const fingerprint = trimToNull(state.credentialFingerprint[platform]) || null;
 686+
 687+  return {
 688+    platform,
 689+    account: account.value,
 690+    account_kind: account.kind,
 691+    account_source: account.source,
 692+    account_captured_at: account.capturedAt > 0 ? account.capturedAt : null,
 693+    account_last_seen_at: account.lastSeenAt > 0 ? account.lastSeenAt : null,
 694+    credential_fingerprint: fingerprint,
 695+    freshness: getCredentialFreshness(credential.reason),
 696+    captured_at: credential.capturedAt > 0 ? credential.capturedAt : null,
 697+    last_seen_at: credential.lastSeenAt > 0 ? credential.lastSeenAt : null,
 698+    timestamp: credential.capturedAt || credential.lastSeenAt || account.lastSeenAt || account.capturedAt || 0,
 699+    endpoint_count: getEndpointCount(platform),
 700+    headers: redactCredentialHeaders(credential.headers || {}),
 701+    header_names: Object.keys(credential.headers || {}).sort()
 702+  };
 703+}
 704+
 705 function clearPlatformCredential(platform) {
 706   let changed = false;
 707 
 708@@ -1414,10 +1896,6 @@ function clearPlatformCredential(platform) {
 709     state.lastHeaders[platform] = {};
 710     changed = true;
 711   }
 712-  if (state.lastCredentialAt[platform] !== 0) {
 713-    state.lastCredentialAt[platform] = 0;
 714-    changed = true;
 715-  }
 716   if (state.lastCredentialUrl[platform]) {
 717     state.lastCredentialUrl[platform] = "";
 718     changed = true;
 719@@ -1441,7 +1919,11 @@ function pruneInvalidCredentialState(now = Date.now()) {
 720 
 721     const credential = getCredentialState(platform, now);
 722     if (!credential.valid) {
 723-      changed = clearPlatformCredential(platform) || changed;
 724+      const cleared = clearPlatformCredential(platform);
 725+      if (cleared) {
 726+        sendCredentialSnapshot(platform, true);
 727+      }
 728+      changed = cleared || changed;
 729     }
 730   }
 731 
 732@@ -1570,9 +2052,12 @@ async function persistState() {
 733     [CONTROLLER_STORAGE_KEYS.trackedTabs]: state.trackedTabs,
 734     [CONTROLLER_STORAGE_KEYS.endpointsByPlatform]: state.endpoints,
 735     [CONTROLLER_STORAGE_KEYS.lastHeadersByPlatform]: state.lastHeaders,
 736+    [CONTROLLER_STORAGE_KEYS.credentialCapturedAtByPlatform]: state.credentialCapturedAt,
 737     [CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform]: state.lastCredentialAt,
 738     [CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]: state.lastCredentialUrl,
 739     [CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]: state.lastCredentialTabId,
 740+    [CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]: state.credentialFingerprint,
 741+    [CONTROLLER_STORAGE_KEYS.accountByPlatform]: state.account,
 742     [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate,
 743     [CONTROLLER_STORAGE_KEYS.claudeState]: {
 744       ...cloneClaudeState(state.claudeState),
 745@@ -1587,8 +2072,15 @@ function getTrackedCount() {
 746   return PLATFORM_ORDER.filter((platform) => Number.isInteger(state.trackedTabs[platform])).length;
 747 }
 748 
 749+function getAccountCount() {
 750+  return PLATFORM_ORDER.filter((platform) => !!trimToNull(state.account[platform]?.value)).length;
 751+}
 752+
 753 function getCredentialCount() {
 754-  return PLATFORM_ORDER.filter((platform) => getCredentialState(platform).valid).length;
 755+  return PLATFORM_ORDER.filter((platform) => {
 756+    const credential = getCredentialState(platform);
 757+    return credential.valid || !!trimToNull(state.credentialFingerprint[platform]);
 758+  }).length;
 759 }
 760 
 761 function getEndpointCount(platform) {
 762@@ -1603,18 +2095,29 @@ function formatTrackedMeta() {
 763   const labels = PLATFORM_ORDER
 764     .filter((platform) => Number.isInteger(state.trackedTabs[platform]))
 765     .map((platform) => `${platformLabel(platform)}#${state.trackedTabs[platform]}`);
 766-  return labels.length > 0 ? labels.join(" · ") : "当前没有正在追踪的平台标签页";
 767+  return labels.length > 0 ? labels.join(" · ") : "当前没有已建立的空壳页";
 768+}
 769+
 770+function formatAccountMeta() {
 771+  const labels = PLATFORM_ORDER
 772+    .map((platform) => {
 773+      const value = trimToNull(state.account[platform]?.value);
 774+      return value ? `${platformLabel(platform)}(${value})` : null;
 775+    })
 776+    .filter(Boolean);
 777+  return labels.length > 0 ? labels.join(" · ") : "当前还没有识别到账号元数据";
 778 }
 779 
 780 function formatCredentialMeta() {
 781   const labels = PLATFORM_ORDER
 782     .map((platform) => {
 783       const credential = getCredentialState(platform);
 784-      if (!credential.valid) return null;
 785-      return `${platformLabel(platform)}(${credential.headerCount})`;
 786+      const fingerprint = trimToNull(state.credentialFingerprint[platform]);
 787+      if (!credential.valid && !fingerprint) return null;
 788+      return `${platformLabel(platform)}(${getCredentialFreshness(credential.reason)})`;
 789     })
 790     .filter(Boolean);
 791-  return labels.length > 0 ? labels.join(" · ") : "当前没有有效凭证";
 792+  return labels.length > 0 ? labels.join(" · ") : "当前没有可汇报的登录态元数据";
 793 }
 794 
 795 function formatEndpointMeta() {
 796@@ -1633,12 +2136,12 @@ function renderPlatformStatus() {
 797   for (const platform of PLATFORM_ORDER) {
 798     const tabId = Number.isInteger(state.trackedTabs[platform]) ? state.trackedTabs[platform] : "-";
 799     const credential = getCredentialState(platform);
 800-    const credentialLabel = credential.valid
 801-      ? `有效(${credential.headerCount})`
 802-      : describeCredentialReason(credential.reason);
 803+    const account = trimToNull(state.account[platform]?.value) || "-";
 804+    const fingerprint = trimToNull(state.credentialFingerprint[platform]);
 805+    const credentialLabel = `${getCredentialFreshness(credential.reason)} / ${describeCredentialReason(credential.reason)}`;
 806     const endpointCount = getEndpointCount(platform);
 807     lines.push(
 808-      `${platformLabel(platform).padEnd(8)} 标签页=${String(tabId).padEnd(4)} 凭证=${credentialLabel.padEnd(8)} 端点=${endpointCount}`
 809+      `${platformLabel(platform).padEnd(8)} 空壳页=${String(tabId).padEnd(4)} 账号=${account.padEnd(18)} 登录态=${credentialLabel.padEnd(18)} 指纹=${(fingerprint || "-").slice(0, 12).padEnd(12)} 端点=${endpointCount}`
 810     );
 811   }
 812   return lines.join("\n");
 813@@ -1647,28 +2150,51 @@ function renderPlatformStatus() {
 814 function renderHeaderSnapshot() {
 815   const snapshot = {};
 816   for (const platform of PLATFORM_ORDER) {
 817+    const transport = buildCredentialTransportSnapshot(platform);
 818     const credential = getCredentialState(platform);
 819-    if (credential.valid) {
 820-      snapshot[platform] = {
 821-        capturedAt: new Date(credential.capturedAt).toISOString(),
 822-        tabId: credential.tabId,
 823-        url: credential.url || null,
 824-        headers: credential.headers
 825-      };
 826-    }
 827+    if (!transport.credential_fingerprint && !transport.account && !credential.valid) continue;
 828+    snapshot[platform] = {
 829+      ...transport,
 830+      tabId: credential.tabId || null,
 831+      url: credential.url || null,
 832+      browser_local_credentials_held: credential.valid
 833+    };
 834   }
 835   return Object.keys(snapshot).length > 0
 836     ? JSON.stringify(snapshot, null, 2)
 837-    : "还没有有效凭证快照。";
 838+    : "还没有登录态元数据快照。";
 839+}
 840+
 841+function renderAccountSnapshot() {
 842+  const snapshot = {};
 843+  for (const platform of PLATFORM_ORDER) {
 844+    const account = cloneAccountState(state.account[platform]);
 845+    if (!account.value) continue;
 846+    snapshot[platform] = {
 847+      value: account.value,
 848+      kind: account.kind,
 849+      source: account.source,
 850+      capturedAt: account.capturedAt > 0 ? new Date(account.capturedAt).toISOString() : null,
 851+      lastSeenAt: account.lastSeenAt > 0 ? new Date(account.lastSeenAt).toISOString() : null
 852+    };
 853+  }
 854+
 855+  return Object.keys(snapshot).length > 0
 856+    ? JSON.stringify(snapshot, null, 2)
 857+    : "还没有账号元数据。";
 858 }
 859 
 860 function renderEndpointSnapshot() {
 861   const lines = [];
 862   for (const platform of PLATFORM_ORDER) {
 863-    const endpoints = Object.keys(state.endpoints[platform]).sort();
 864+    const endpoints = Object.values(state.endpoints[platform] || {})
 865+      .map((entry) => normalizeEndpointEntry(entry?.key || "", entry))
 866+      .sort((left, right) => left.key.localeCompare(right.key));
 867     if (endpoints.length === 0) continue;
 868     lines.push(`[${platformLabel(platform)}]`);
 869-    lines.push(...endpoints);
 870+    for (const entry of endpoints) {
 871+      lines.push(`${entry.method} ${entry.path}  first=${formatSyncTime(entry.firstObservedAt)}  last=${formatSyncTime(entry.lastObservedAt)}`);
 872+    }
 873     lines.push("");
 874   }
 875   if (lines.length === 0) return "还没有发现端点。";
 876@@ -1705,6 +2231,7 @@ function render() {
 877   const wsSnapshot = cloneWsState(state.wsState);
 878   const controlSnapshot = cloneControlState(state.controlState);
 879   const trackedCount = getTrackedCount();
 880+  const accountCount = getAccountCount();
 881   const credentialCount = getCredentialCount();
 882   const endpointCount = getTotalEndpointCount();
 883 
 884@@ -1729,6 +2256,13 @@ function render() {
 885   if (ui.trackedMeta) {
 886     ui.trackedMeta.textContent = formatTrackedMeta();
 887   }
 888+  if (ui.accountCount) {
 889+    ui.accountCount.textContent = String(accountCount);
 890+    ui.accountCount.className = `value ${accountCount > 0 ? "on" : "off"}`;
 891+  }
 892+  if (ui.accountMeta) {
 893+    ui.accountMeta.textContent = formatAccountMeta();
 894+  }
 895   if (ui.credentialCount) {
 896     ui.credentialCount.textContent = String(credentialCount);
 897     ui.credentialCount.className = `value ${credentialCount > 0 ? "on" : "off"}`;
 898@@ -1746,6 +2280,9 @@ function render() {
 899   if (ui.platformView) {
 900     ui.platformView.textContent = renderPlatformStatus();
 901   }
 902+  if (ui.accountView) {
 903+    ui.accountView.textContent = renderAccountSnapshot();
 904+  }
 905   if (ui.credentialView) {
 906     ui.credentialView.textContent = renderHeaderSnapshot();
 907   }
 908@@ -2057,7 +2594,13 @@ function sendHello() {
 909     clientId: state.clientId,
 910     nodeType: "browser",
 911     nodeCategory: "proxy",
 912-    nodePlatform: "firefox"
 913+    nodePlatform: "firefox",
 914+    capabilities: {
 915+      formal_mode: "shell_tab_metadata_proxy",
 916+      shell_tabs: true,
 917+      page_conversation_runtime: false,
 918+      credential_metadata: true
 919+    }
 920   });
 921 }
 922 
 923@@ -2245,23 +2788,44 @@ async function executeProxyRequest(payload, meta = {}) {
 924 
 925 function sendEndpointSnapshot(platform = null) {
 926   for (const target of getTargetPlatforms(platform)) {
 927-    const endpoints = Object.keys(state.endpoints[target]).sort();
 928-    if (endpoints.length === 0) continue;
 929+    const endpointEntries = Object.values(state.endpoints[target] || {})
 930+      .map((entry) => normalizeEndpointEntry(entry?.key || "", entry))
 931+      .sort((left, right) => left.key.localeCompare(right.key));
 932+    if (endpointEntries.length === 0) continue;
 933+
 934+    const account = cloneAccountState(state.account[target]);
 935+    const fingerprint = trimToNull(state.credentialFingerprint[target]) || null;
 936     wsSend({
 937       type: "api_endpoints",
 938       platform: target,
 939-      endpoints
 940+      account: account.value,
 941+      credential_fingerprint: fingerprint,
 942+      updated_at: Math.max(...endpointEntries.map((entry) => entry.lastObservedAt || 0), 0) || null,
 943+      endpoints: endpointEntries.map((entry) => entry.key),
 944+      endpoint_metadata: endpointEntries.map((entry) => ({
 945+        method: entry.method,
 946+        path: entry.path,
 947+        first_seen_at: entry.firstObservedAt || null,
 948+        last_seen_at: entry.lastObservedAt || null
 949+      }))
 950     });
 951   }
 952 }
 953 
 954 function sendCredentialSnapshot(platform = null, force = false) {
 955   for (const target of getTargetPlatforms(platform)) {
 956-    const credential = getCredentialState(target);
 957-    if (!credential.valid) continue;
 958+    const payload = buildCredentialTransportSnapshot(target);
 959+    if (
 960+      !payload.account
 961+      && !payload.credential_fingerprint
 962+      && payload.header_names.length === 0
 963+      && !Number.isInteger(state.trackedTabs[target])
 964+    ) {
 965+      continue;
 966+    }
 967 
 968     const now = Date.now();
 969-    const serialized = JSON.stringify(credential.headers);
 970+    const serialized = JSON.stringify(payload);
 971     if (!force && serialized === state.lastCredentialHash[target] && now - state.lastCredentialSentAt[target] < CREDENTIAL_SEND_INTERVAL) {
 972       continue;
 973     }
 974@@ -2271,9 +2835,7 @@ function sendCredentialSnapshot(platform = null, force = false) {
 975 
 976     wsSend({
 977       type: "credentials",
 978-      platform: target,
 979-      headers: credential.headers,
 980-      timestamp: credential.capturedAt
 981+      ...payload
 982     });
 983   }
 984 }
 985@@ -2518,12 +3080,14 @@ function scheduleReconnect(reason = null) {
 986   }, WS_RECONNECT_DELAY);
 987 }
 988 
 989-async function resolveTrackedTab(platform) {
 990+async function resolveTrackedTab(platform, options = {}) {
 991+  const { requireShell = false } = options;
 992   const tabId = state.trackedTabs[platform];
 993   if (!Number.isInteger(tabId)) return null;
 994   try {
 995     const tab = await browser.tabs.get(tabId);
 996     if (!tab || !tab.url || !isPlatformUrl(platform, tab.url)) return null;
 997+    if (requireShell && !isPlatformShellUrl(platform, tab.url)) return null;
 998     return tab;
 999   } catch (_) {
1000     return null;
1001@@ -2542,15 +3106,42 @@ async function findPlatformTab(platform) {
1002   return tabs[0];
1003 }
1004 
1005+async function findPlatformShellTab(platform, preferredTabId = null) {
1006+  const tabs = await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns });
1007+  const shellTabs = tabs.filter((tab) => isPlatformShellUrl(platform, tab.url || ""));
1008+  if (shellTabs.length === 0) return null;
1009+
1010+  shellTabs.sort((left, right) => {
1011+    const leftAccess = Number(left.lastAccessed) || 0;
1012+    const rightAccess = Number(right.lastAccessed) || 0;
1013+    return rightAccess - leftAccess;
1014+  });
1015+
1016+  const canonical = Number.isInteger(preferredTabId)
1017+    ? (shellTabs.find((tab) => tab.id === preferredTabId) || shellTabs[0])
1018+    : shellTabs[0];
1019+  const duplicateIds = shellTabs
1020+    .map((tab) => tab.id)
1021+    .filter((tabId) => Number.isInteger(tabId) && tabId !== canonical.id);
1022+
1023+  if (duplicateIds.length > 0) {
1024+    browser.tabs.remove(duplicateIds).catch(() => {});
1025+  }
1026+
1027+  return canonical;
1028+}
1029+
1030 async function setTrackedTab(platform, tab) {
1031   state.trackedTabs[platform] = tab ? tab.id : null;
1032   pruneInvalidCredentialState();
1033   if (platform === "claude") {
1034     updateClaudeState({
1035       tabId: tab?.id ?? null,
1036-      currentUrl: tab?.url || state.claudeState.currentUrl,
1037+      currentUrl: tab?.url || getPlatformShellUrl(platform),
1038       tabTitle: trimToNull(tab?.title),
1039-      conversationId: extractClaudeConversationIdFromPageUrl(tab?.url || "") || state.claudeState.conversationId
1040+      conversationId: isPlatformShellUrl(platform, tab?.url || "")
1041+        ? null
1042+        : (extractClaudeConversationIdFromPageUrl(tab?.url || "") || state.claudeState.conversationId)
1043     }, {
1044       render: true
1045     });
1046@@ -2561,34 +3152,46 @@ async function setTrackedTab(platform, tab) {
1047 
1048 async function ensurePlatformTab(platform, options = {}) {
1049   const { focus = false, reloadIfExisting = false } = options;
1050-  let existed = true;
1051   let tab = await resolveTrackedTab(platform);
1052   if (!tab) {
1053-    tab = await findPlatformTab(platform);
1054+    tab = await findPlatformShellTab(platform);
1055   }
1056 
1057+  const shellUrl = getPlatformShellUrl(platform);
1058+  let created = false;
1059+  let updatedToShell = false;
1060   if (!tab) {
1061-    existed = false;
1062+    created = true;
1063     tab = await browser.tabs.create({
1064-      url: PLATFORMS[platform].rootUrl,
1065+      url: shellUrl,
1066+      active: focus
1067+    });
1068+    addLog("info", `已打开 ${platformLabel(platform)} 空壳页 ${tab.id}`);
1069+  } else if (!isPlatformShellUrl(platform, tab.url || "")) {
1070+    tab = await browser.tabs.update(tab.id, {
1071+      url: shellUrl,
1072       active: focus
1073     });
1074-    addLog("info", `已打开 ${platformLabel(platform)} 标签页 ${tab.id}`);
1075+    updatedToShell = true;
1076+    addLog("info", `已将 ${platformLabel(platform)} 标签页 ${tab.id} 收口为空壳页`);
1077   } else if (focus) {
1078+    tab = await findPlatformShellTab(platform, tab.id) || tab;
1079     await browser.tabs.update(tab.id, { active: true });
1080     if (tab.windowId != null) {
1081       await browser.windows.update(tab.windowId, { focused: true });
1082     }
1083+  } else {
1084+    tab = await findPlatformShellTab(platform, tab.id) || tab;
1085   }
1086 
1087   await setTrackedTab(platform, tab);
1088 
1089-  if (existed && reloadIfExisting) {
1090+  if ((updatedToShell || reloadIfExisting) && !created) {
1091     try {
1092       await browser.tabs.reload(tab.id);
1093-      addLog("info", `已重新加载现有 ${platformLabel(platform)} 标签页 ${tab.id}`);
1094+      addLog("info", `已重新加载 ${platformLabel(platform)} 空壳页 ${tab.id}`);
1095     } catch (error) {
1096-      addLog("error", `重新加载 ${platformLabel(platform)} 失败:${error.message}`);
1097+      addLog("error", `重新加载 ${platformLabel(platform)} 空壳页失败:${error.message}`);
1098     }
1099   }
1100 
1101@@ -2615,7 +3218,10 @@ async function refreshTrackedTabsFromBrowser(reason = "sync") {
1102 
1103       const next = createPlatformMap(() => null);
1104       for (const platform of PLATFORM_ORDER) {
1105-        const tab = await findPlatformTab(platform);
1106+        let tab = await resolveTrackedTab(platform, { requireShell: true });
1107+        if (!tab) {
1108+          tab = await findPlatformShellTab(platform);
1109+        }
1110         next[platform] = tab ? tab.id : null;
1111       }
1112 
1113@@ -2644,7 +3250,7 @@ function scheduleTrackedTabRefresh(reason = "tabs") {
1114   clearTimeout(state.trackedTabRefreshTimer);
1115   state.trackedTabRefreshTimer = setTimeout(() => {
1116     refreshTrackedTabsFromBrowser(reason).catch((error) => {
1117-      addLog("error", `刷新平台标签页失败:${error.message}`);
1118+      addLog("error", `刷新平台空壳页失败:${error.message}`);
1119     });
1120   }, TRACKED_TAB_REFRESH_DELAY);
1121 }
1122@@ -2653,12 +3259,25 @@ function collectEndpoint(platform, method, url) {
1123   if (!shouldTrackRequest(platform, url)) return;
1124 
1125   const key = `${(method || "GET").toUpperCase()} ${normalizePath(url)}`;
1126-  if (state.endpoints[platform][key]) return;
1127+  const now = Date.now();
1128+  const current = state.endpoints[platform][key];
1129+  const entry = normalizeEndpointEntry(key, current);
1130+  const isNew = !current;
1131+
1132+  state.endpoints[platform][key] = {
1133+    ...entry,
1134+    method: (method || "GET").toUpperCase(),
1135+    path: normalizePath(url),
1136+    firstObservedAt: entry.firstObservedAt || now,
1137+    lastObservedAt: now,
1138+    sampleUrl: trimToNull(url)
1139+  };
1140 
1141-  state.endpoints[platform][key] = true;
1142   persistState().catch(() => {});
1143   render();
1144-  addLog("info", `已发现 ${platformLabel(platform)} 端点 ${key}`);
1145+  if (isNew) {
1146+    addLog("info", `已发现 ${platformLabel(platform)} 端点 ${key}`);
1147+  }
1148   sendEndpointSnapshot(platform);
1149 }
1150 
1151@@ -2669,7 +3288,7 @@ function buildNetworkEntry(platform, data, tabId) {
1152     tabId,
1153     url: data.url,
1154     method: data.method,
1155-    reqHeaders: mergeKnownHeaders(platform, data.reqHeaders || {}),
1156+    reqHeaders: redactCredentialHeaders(mergeKnownHeaders(platform, data.reqHeaders || {})),
1157     reqBody: trimBody(data.reqBody),
1158     status: data.status,
1159     resHeaders: data.resHeaders || null,
1160@@ -2680,11 +3299,14 @@ function buildNetworkEntry(platform, data, tabId) {
1161   };
1162 }
1163 
1164-function ensureTrackedTabId(platform, tabId, source) {
1165+function ensureTrackedTabId(platform, tabId, source, senderUrl = "") {
1166   if (!Number.isInteger(tabId) || tabId < 0) return false;
1167 
1168   const current = state.trackedTabs[platform];
1169-  if (current === tabId) return true;
1170+  if (current === tabId) {
1171+    return senderUrl ? isPlatformShellUrl(platform, senderUrl) : true;
1172+  }
1173+  if (!isPlatformShellUrl(platform, senderUrl)) return false;
1174 
1175   state.trackedTabs[platform] = tabId;
1176   pruneInvalidCredentialState();
1177@@ -2692,26 +3314,25 @@ function ensureTrackedTabId(platform, tabId, source) {
1178   render();
1179 
1180   if (!Number.isInteger(current)) {
1181-    addLog("info", `已接管 ${platformLabel(platform)} 标签页 ${tabId},来源 ${source}`);
1182+    addLog("info", `已识别 ${platformLabel(platform)} 空壳页 ${tabId},来源 ${source}`);
1183   } else {
1184-    addLog("warn", `已切换 ${platformLabel(platform)} 标签页 ${current} -> ${tabId},来源 ${source}`);
1185+    addLog("warn", `已切换 ${platformLabel(platform)} 空壳页 ${current} -> ${tabId},来源 ${source}`);
1186   }
1187   return true;
1188 }
1189 
1190 function getSenderContext(sender, fallbackPlatform = null) {
1191   const tabId = sender?.tab?.id;
1192-  const senderPlatform = detectPlatformFromUrl(sender?.tab?.url || "");
1193+  const senderUrl = sender?.tab?.url || "";
1194+  const senderPlatform = detectPlatformFromUrl(senderUrl);
1195   const platform = senderPlatform || fallbackPlatform;
1196   if (!platform) return null;
1197-  if (!ensureTrackedTabId(platform, tabId, "message")) return null;
1198+  if (!ensureTrackedTabId(platform, tabId, "message", senderUrl)) return null;
1199   return { platform, tabId };
1200 }
1201 
1202 function resolvePlatformFromRequest(details) {
1203-  const platform = findTrackedPlatformByTabId(details.tabId) || detectPlatformFromRequestUrl(details.url);
1204-  if (!platform) return null;
1205-  return ensureTrackedTabId(platform, details.tabId, "request") ? platform : null;
1206+  return findTrackedPlatformByTabId(details.tabId) || null;
1207 }
1208 
1209 function setClaudeBusy(isBusy, reason = null) {
1210@@ -2789,26 +3410,20 @@ function applyObservedClaudeSse(data, tabId) {
1211 function handlePageNetwork(data, sender) {
1212   const context = getSenderContext(sender, data?.platform || null);
1213   if (!context || !data || !data.url || !data.method) return;
1214+  const observedHeaders = Object.keys(data.reqHeaders || {}).length > 0
1215+    ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
1216+    : cloneHeaderMap(state.lastHeaders[context.platform]);
1217 
1218   if (context.platform === "claude") {
1219     applyObservedClaudeResponse(data, context.tabId);
1220   }
1221 
1222   if (context.platform === "gemini" && typeof data.reqBody === "string" && data.reqBody) {
1223-    const templateHeaders = Object.keys(data.reqHeaders || {}).length > 0
1224-      ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
1225-      : cloneHeaderMap(state.lastHeaders.gemini);
1226-    rememberGeminiSendTemplate(data.url, data.reqBody, templateHeaders);
1227+    rememberGeminiSendTemplate(data.url, data.reqBody, observedHeaders);
1228   }
1229 
1230   collectEndpoint(context.platform, data.method, data.url);
1231-  const entry = buildNetworkEntry(context.platform, data, context.tabId);
1232-  wsSend({
1233-    type: "network_log",
1234-    clientId: state.clientId,
1235-    platform: context.platform,
1236-    entry
1237-  });
1238+  observeAccountMetadata(context.platform, data.url, data.resBody, observedHeaders);
1239 
1240   if (data.status >= 400 || data.error) {
1241     addLog("error", `${platformLabel(context.platform)} ${data.method} ${normalizePath(data.url)} -> ${data.status || data.error}`);
1242@@ -2822,19 +3437,6 @@ function handlePageSse(data, sender) {
1243   if (context.platform === "claude") {
1244     applyObservedClaudeSse(data, context.tabId);
1245   }
1246-
1247-  wsSend({
1248-    type: "sse_event",
1249-    clientId: state.clientId,
1250-    platform: context.platform,
1251-    url: data.url,
1252-    method: data.method,
1253-    reqBody: trimBody(data.reqBody),
1254-    chunk: data.chunk || null,
1255-    done: !!data.done,
1256-    error: data.error || null,
1257-    ts: data.ts || Date.now()
1258-  });
1259 }
1260 
1261 function handlePageProxyResponse(data, sender) {
1262@@ -2842,6 +3444,12 @@ function handlePageProxyResponse(data, sender) {
1263   if (!context || !data || !data.id) return;
1264   const pending = pendingProxyRequests.get(data.id);
1265   if (!pending) return;
1266+  observeAccountMetadata(
1267+    context.platform,
1268+    data.url || pending.path || "",
1269+    typeof data.body === "string" ? data.body : null,
1270+    cloneHeaderMap(state.lastHeaders[context.platform])
1271+  );
1272 
1273   if (context.platform === "claude") {
1274     const parsed = parseClaudeApiContext(data.url || pending.path || "");
1275@@ -2913,56 +3521,76 @@ function handlePageBridgeReady(data, sender) {
1276   const senderUrl = sender?.tab?.url || data?.url || "";
1277   const platform = detectPlatformFromUrl(senderUrl) || data?.platform || null;
1278   if (!platform || !Number.isInteger(tabId)) return;
1279+  if (!ensureTrackedTabId(platform, tabId, "bridge", senderUrl)) return;
1280 
1281-  ensureTrackedTabId(platform, tabId, "bridge");
1282   if (platform === "claude") {
1283     updateClaudeState({
1284       tabId,
1285-      currentUrl: senderUrl || state.claudeState.currentUrl,
1286+      currentUrl: senderUrl || getPlatformShellUrl(platform),
1287       tabTitle: trimToNull(sender?.tab?.title),
1288-      conversationId: extractClaudeConversationIdFromPageUrl(senderUrl) || state.claudeState.conversationId
1289+      conversationId: isPlatformShellUrl(platform, senderUrl)
1290+        ? null
1291+        : (extractClaudeConversationIdFromPageUrl(senderUrl) || state.claudeState.conversationId)
1292     }, {
1293       persist: true,
1294       render: true
1295     });
1296   }
1297-  addLog("info", `${platformLabel(platform)} 钩子已就绪,标签页 ${tabId},来源 ${data?.source || "未知"}`);
1298+  addLog("info", `${platformLabel(platform)} 空壳页已就绪,标签页 ${tabId},来源 ${data?.source || "未知"}`);
1299 }
1300 
1301-function handleBeforeSendHeaders(details) {
1302-  const platform = resolvePlatformFromRequest(details);
1303-  if (!platform) return;
1304-
1305-  const headers = headerArrayToObject(details.requestHeaders);
1306-  if (platform === "claude") {
1307-    const orgId = extractClaudeOrgId(headers, details.url || "");
1308-    if (orgId) {
1309-      headers["x-org-id"] = orgId;
1310-    }
1311-  }
1312-  if (Object.keys(headers).length === 0) return;
1313-
1314-  collectEndpoint(platform, details.method || "GET", details.url);
1315-  const validation = validateCredentialSnapshot(platform, headers, details.url);
1316+async function observeCredentialSnapshot(platform, headers, details = {}) {
1317+  const validation = validateCredentialSnapshot(platform, headers, details.url || "");
1318 
1319   if (!validation.valid) {
1320     if (validation.invalidate && clearPlatformCredential(platform)) {
1321       addLog("info", `${platformLabel(platform)} 凭证已清理:${describeCredentialReason(validation.reason)}`);
1322       render();
1323+      sendCredentialSnapshot(platform, true);
1324       persistState().catch(() => {});
1325     }
1326     return;
1327   }
1328 
1329+  const now = Date.now();
1330+  const fingerprint = await computeCredentialFingerprint(platform, headers).catch(() => "");
1331+  const previousFingerprint = trimToNull(state.credentialFingerprint[platform]) || "";
1332+  const isSameFingerprint = !!fingerprint && fingerprint === previousFingerprint;
1333+
1334   state.lastHeaders[platform] = headers;
1335-  state.lastCredentialAt[platform] = Date.now();
1336+  state.lastCredentialAt[platform] = now;
1337   state.lastCredentialUrl[platform] = details.url || "";
1338   state.lastCredentialTabId[platform] = Number.isInteger(details.tabId) ? details.tabId : null;
1339+  state.credentialFingerprint[platform] = fingerprint || previousFingerprint;
1340+  state.credentialCapturedAt[platform] = isSameFingerprint && state.credentialCapturedAt[platform] > 0
1341+    ? state.credentialCapturedAt[platform]
1342+    : now;
1343+
1344+  observeAccountMetadata(platform, details.url || "", null, headers);
1345   render();
1346-  sendCredentialSnapshot(platform);
1347+  sendCredentialSnapshot(platform, !isSameFingerprint);
1348   persistState().catch(() => {});
1349 }
1350 
1351+function handleBeforeSendHeaders(details) {
1352+  const platform = resolvePlatformFromRequest(details);
1353+  if (!platform) return;
1354+
1355+  const headers = headerArrayToObject(details.requestHeaders);
1356+  if (platform === "claude") {
1357+    const orgId = extractClaudeOrgId(headers, details.url || "");
1358+    if (orgId) {
1359+      headers["x-org-id"] = orgId;
1360+    }
1361+  }
1362+  if (Object.keys(headers).length === 0) return;
1363+
1364+  collectEndpoint(platform, details.method || "GET", details.url);
1365+  observeCredentialSnapshot(platform, headers, details).catch((error) => {
1366+    addLog("error", `${platformLabel(platform)} 凭证指纹计算失败:${error.message}`);
1367+  });
1368+}
1369+
1370 function handleCompleted(details) {
1371   const platform = resolvePlatformFromRequest(details);
1372   if (!platform || !shouldTrackRequest(platform, details.url)) return;
1373@@ -3357,29 +3985,13 @@ function registerRuntimeListeners() {
1374           snapshot: cloneControlState(state.controlState)
1375         });
1376       case "claude_send":
1377-        return sendClaudePrompt(message).catch((error) => ({
1378-          ok: false,
1379-          error: error.message,
1380-          state: buildClaudeStateSnapshot()
1381-        }));
1382       case "claude_read_conversation":
1383-        return readClaudeConversation(message).then((stateSnapshot) => ({
1384-          ok: true,
1385-          state: stateSnapshot
1386-        })).catch((error) => ({
1387-          ok: false,
1388-          error: error.message,
1389-          state: buildClaudeStateSnapshot()
1390-        }));
1391       case "claude_read_state":
1392-        return readClaudeState(message).then((stateSnapshot) => ({
1393-          ok: true,
1394-          state: stateSnapshot
1395-        })).catch((error) => ({
1396+        return Promise.resolve({
1397           ok: false,
1398-          error: error.message,
1399-          state: buildClaudeStateSnapshot()
1400-        }));
1401+          error: "page_conversation_runtime_deprecated",
1402+          message: "Firefox 插件正式能力已收口到空壳页、登录态元数据上报和本地 API 代发;页面对话运行时不再作为正式能力。"
1403+        });
1404       default:
1405         break;
1406     }
1407@@ -3406,8 +4018,15 @@ function registerTabListeners() {
1408     const urlPlatform = detectPlatformFromUrl(changeInfo.url || tab?.url || "");
1409     if (!platform && !urlPlatform && changeInfo.status !== "complete") return;
1410 
1411-    if (platform && changeInfo.status === "complete" && tab?.url && isPlatformUrl(platform, tab.url)) {
1412-      addLog("info", `${platformLabel(platform)} tab ready ${tabId}`);
1413+    if (platform && changeInfo.url && !isPlatformShellUrl(platform, changeInfo.url)) {
1414+      state.trackedTabs[platform] = null;
1415+      persistState().catch(() => {});
1416+      render();
1417+      addLog("warn", `${platformLabel(platform)} 空壳页 ${tabId} 已偏离壳页 URL,等待重新收口`);
1418+    }
1419+
1420+    if (platform && changeInfo.status === "complete" && tab?.url && isPlatformShellUrl(platform, tab.url)) {
1421+      addLog("info", `${platformLabel(platform)} shell ready ${tabId}`);
1422     }
1423 
1424     if (platform || urlPlatform || changeInfo.status === "complete") {
1425@@ -3425,11 +4044,14 @@ function bindUi() {
1426   ui.controlView = qs("control-view");
1427   ui.trackedCount = qs("tracked-count");
1428   ui.trackedMeta = qs("tracked-meta");
1429+  ui.accountCount = qs("account-count");
1430+  ui.accountMeta = qs("account-meta");
1431   ui.credentialCount = qs("credential-count");
1432   ui.credentialMeta = qs("credential-meta");
1433   ui.endpointCount = qs("endpoint-count");
1434   ui.endpointMeta = qs("endpoint-meta");
1435   ui.platformView = qs("platform-view");
1436+  ui.accountView = qs("account-view");
1437   ui.credentialView = qs("credential-view");
1438   ui.endpointView = qs("endpoint-view");
1439 
1440@@ -3461,7 +4083,7 @@ async function init() {
1441     saved[CONTROLLER_STORAGE_KEYS.trackedTabs],
1442     saved[LEGACY_STORAGE_KEYS.claudeTabId]
1443   );
1444-  state.endpoints = loadObjectMap(
1445+  state.endpoints = loadEndpointEntries(
1446     saved[CONTROLLER_STORAGE_KEYS.endpointsByPlatform],
1447     saved[LEGACY_STORAGE_KEYS.endpoints]
1448   );
1449@@ -3469,6 +4091,9 @@ async function init() {
1450     saved[CONTROLLER_STORAGE_KEYS.lastHeadersByPlatform],
1451     saved[LEGACY_STORAGE_KEYS.lastHeaders]
1452   );
1453+  state.credentialCapturedAt = loadNumberMap(
1454+    saved[CONTROLLER_STORAGE_KEYS.credentialCapturedAtByPlatform]
1455+  );
1456   state.lastCredentialAt = loadNumberMap(
1457     saved[CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform],
1458     saved[LEGACY_STORAGE_KEYS.lastCredentialAt]
1459@@ -3479,15 +4104,24 @@ async function init() {
1460   state.lastCredentialTabId = loadTabIdMap(
1461     saved[CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]
1462   );
1463+  state.credentialFingerprint = loadStringMap(
1464+    saved[CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]
1465+  );
1466+  state.account = loadAccountMap(
1467+    saved[CONTROLLER_STORAGE_KEYS.accountByPlatform]
1468+  );
1469   state.geminiSendTemplate = saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate] || null;
1470   state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
1471   if (needsStatusReset) {
1472     state.lastHeaders = createPlatformMap(() => ({}));
1473+    state.credentialCapturedAt = createPlatformMap(() => 0);
1474     state.lastCredentialAt = createPlatformMap(() => 0);
1475     state.lastCredentialUrl = createPlatformMap(() => "");
1476     state.lastCredentialTabId = createPlatformMap(() => null);
1477+    state.credentialFingerprint = createPlatformMap(() => "");
1478+    state.account = createPlatformMap(() => createDefaultAccountState());
1479   }
1480-  state.lastCredentialHash = createPlatformMap((platform) => JSON.stringify(state.lastHeaders[platform]));
1481+  state.lastCredentialHash = createPlatformMap((platform) => JSON.stringify(buildCredentialTransportSnapshot(platform)));
1482   state.wsState = createDefaultWsState({
1483     connection: "connecting",
1484     wsUrl: state.wsUrl,
M plugins/baa-firefox/manifest.json
+1, -1
1@@ -2,7 +2,7 @@
2   "manifest_version": 3,
3   "name": "BAA Firefox Controller",
4   "version": "0.1.0",
5-  "description": "Firefox MVP for BAA: persistent controller page plus real AI tabs.",
6+  "description": "Firefox shell-tab bridge for BAA: metadata reporting plus local API proxying.",
7   "permissions": [
8     "tabs",
9     "storage",
M pnpm-lock.yaml
+16, -1
 1@@ -12,7 +12,16 @@ importers:
 2         specifier: ^5.8.2
 3         version: 5.9.3
 4 
 5-  apps/conductor-daemon: {}
 6+  apps/codexd: {}
 7+
 8+  apps/conductor-daemon:
 9+    dependencies:
10+      '@baa-conductor/db':
11+        specifier: workspace:*
12+        version: link:../../packages/db
13+      '@baa-conductor/host-ops':
14+        specifier: workspace:*
15+        version: link:../../packages/host-ops
16 
17   apps/status-api: {}
18 
19@@ -22,10 +31,16 @@ importers:
20 
21   packages/checkpointing: {}
22 
23+  packages/codex-app-server: {}
24+
25+  packages/codex-exec: {}
26+
27   packages/db: {}
28 
29   packages/git-tools: {}
30 
31+  packages/host-ops: {}
32+
33   packages/logging: {}
34 
35   packages/planner: {}
M tasks/T-S019.md
+48, -1
  1@@ -19,9 +19,42 @@
  2 
  3 - 仓库:`/Users/george/code/baa-conductor`
  4 - 分支:`main`
  5-- 提交:`12310df`
  6+- 文档基线提交:`81fa64c`
  7 - 开工要求:不要从其他任务分支切出;如需新分支,从当前 `main` 新切
  8 
  9+## 当前状态
 10+
 11+- `已完成`
 12+- 当前作用:保留为实现与验收参考,不再作为当前待开发任务卡
 13+
 14+## 最新接力说明
 15+
 16+`T-S017`、`T-S018` 的并行改动已经在本地工作树里完成,当前这张任务卡只负责把它们接进 `conductor-daemon`。
 17+
 18+已经存在的前置产物:
 19+
 20+- `packages/db/` 已提供浏览器登录态与端点元数据仓储接口
 21+- `ops/sql/` 已补浏览器登录态与端点元数据表
 22+- Firefox 插件已经开始上报:
 23+  - `account`
 24+  - `credential_fingerprint`
 25+  - `endpoints`
 26+  - `endpoint_metadata`
 27+  - `freshness` / `captured_at` / `last_seen_at`
 28+
 29+本任务已完成的核心收口项:
 30+
 31+1. 把 `credentials` / `api_endpoints` WS 消息接到新的仓储写接口
 32+2. 在 `conductor` 读接口里合并“活跃 WS 连接视图”和“持久化记录视图”
 33+3. 在 WS 断连或心跳老化时更新 `fresh` / `stale` / `lost`
 34+4. 把 `conductor-daemon` 测试和 browser smoke 更新到新的上报格式与读接口断言
 35+
 36+工作树注意事项:
 37+
 38+- `/Users/george/code/baa-conductor/plugins/baa-firefox/controller.js` 当前存在与本任务无关的本地改动
 39+- 本任务不要顺手清理、覆盖或回退该文件
 40+- 后续收尾任务如果需要读这里的实现,仍然要避免覆盖该文件
 41+
 42 ## 建议分支名
 43 
 44 - `feat/conductor-browser-auth-persistence`
 45@@ -66,11 +99,13 @@
 46 
 47 - 让 WS bridge 能接收 `account`、凭证指纹和端点元数据
 48 - 把持久化记录和当前活跃连接状态合并成可读视图
 49+- 在 WS 断连和老化场景下落库 `stale` / `lost`
 50 - 优先复用现有 `/v1/browser` 路由;只有在必要时再加新的只读路由
 51 
 52 ## 允许修改的目录
 53 
 54 - `/Users/george/code/baa-conductor/apps/conductor-daemon/`
 55+- `/Users/george/code/baa-conductor/tests/browser/`
 56 
 57 ## 尽量不要修改
 58 
 59@@ -85,17 +120,21 @@
 60 - `conductor` 能接收 `account`
 61 - `conductor` 能接收并保存凭证指纹
 62 - `conductor` 能接收并保存端点元数据
 63+- `conductor` 能接收插件侧新的 `freshness` / 时间戳字段
 64+- 对旧格式消息最多保留兼容读法,但不能阻断新格式接入
 65 
 66 ### 2. 提供可读查询面
 67 
 68 - 能按平台、浏览器、机器、`account` 观察当前桥接能力
 69 - 能区分“当前活跃连接”和“仅持久化记录”
 70 - 能正确表达 `fresh` / `stale` / `lost` 或等价状态
 71+- 读接口里不能出现原始 `cookie` / `token` / `header` 值
 72 
 73 ### 3. 补测试
 74 
 75 - 至少覆盖活跃连接上报后持久化成功
 76 - 至少覆盖断开后仍可读到持久化记录
 77+- 至少覆盖 WS 心跳老化后状态从 `fresh` 变成 `stale` 或 `lost`
 78 - 至少覆盖敏感值不出现在对外读接口
 79 
 80 ## 需要特别注意
 81@@ -103,18 +142,25 @@
 82 - 不要把原始凭证值暴露到 `/v1/browser` 或任何新读接口
 83 - 不要把“标签页”继续当正式持久化主键
 84 - 本任务应在 `T-S017`、`T-S018` 结果上做集成,不要反向改它们的写范围
 85+- 如果必须读 Firefox 插件代码,只作为协议对接参考,不要在本任务里回写插件实现
 86+- 如果发现 `plugins/baa-firefox/controller.js` 的本地改动与集成点冲突,先保留它,再最小化调整 `conductor-daemon` 适配
 87 
 88 ## 验收标准
 89 
 90 - `conductor` 重启后仍能保留浏览器登录态元数据
 91 - 对外读接口只暴露 `account`、指纹和端点元数据,不暴露原始凭证值
 92+- WS 断开后记录会变成 `stale` 或等价离线状态
 93+- 心跳老化后记录会进一步进入 `lost` 或等价状态
 94 - 测试覆盖新增持久化和查询路径
 95+- `@baa-conductor/conductor-daemon` 能在当前 workspace 做一次真实 build
 96 - `git diff --check` 通过
 97 
 98 ## 推荐验证命令
 99 
100 - `npx --yes pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon test`
101+- `npx --yes pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
102 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
103+- `git -C /Users/george/code/baa-conductor status --short`
104 - `git -C /Users/george/code/baa-conductor diff --check`
105 
106 ## 交付要求
107@@ -124,5 +170,6 @@
108 - 修改了哪些文件
109 - `conductor` 现在持久化了哪些浏览器元数据
110 - 哪些读接口返回了这些数据
111+- WS 断连和老化分别如何更新状态
112 - 如何保证原始凭证值不会泄露
113 - 跑了哪些测试
M tasks/T-S020.md
+30, -0
 1@@ -23,6 +23,11 @@
 2 - 提交:`12310df`
 3 - 开工要求:不要从其他任务分支切出;如需新分支,从当前 `main` 新切
 4 
 5+## 当前状态
 6+
 7+- `已完成`
 8+- 当前作用:保留为文档与 smoke 收尾参考
 9+
10 ## 建议分支名
11 
12 - `docs/browser-bridge-persistence-cutover`
13@@ -40,6 +45,16 @@
14 
15 如果文档和 smoke 不跟上,后续调用方仍会按旧模型理解插件和 `conductor`。
16 
17+## 最新接力说明
18+
19+当前收尾前提已经更新:
20+
21+- `T-S017`、`T-S018` 已并行完成
22+- `T-S019` 已完成,包括 `conductor-daemon` 集成与 build / 验证收口
23+- 本任务现在就是当前主线最后的收尾任务
24+
25+本任务应以 `T-S019` 的实际实现结果为准回写文档和 smoke,不再沿用旧的桥接口径。
26+
27 ## 涉及仓库
28 
29 - `/Users/george/code/baa-conductor`
30@@ -63,6 +78,7 @@
31 
32 - 改写正式需求口径
33 - 让 smoke 覆盖登录态元数据与持久化读取
34+- 让 smoke 覆盖 `fresh` / `stale` / `lost` 的可观察行为
35 - 清掉“页面对话是正式能力”的旧文案
36 
37 ## 允许修改的目录
38@@ -94,6 +110,7 @@
39 - 至少能验证:
40   - 元数据上报
41   - 持久化存在
42+  - 断连或老化后的状态变化
43   - 读接口不泄露原始凭证值
44 
45 ### 3. 同步任务状态
46@@ -106,6 +123,7 @@
47 - 不要继续把页面对话、聊天 UI 或多标签会话写成插件主能力
48 - 不要在文档里模糊“浏览器本地代发”和“conductor 直接持凭证调用”的边界
49 - 这是收尾任务,优先讲清边界和验收口径
50+- 如果文档口径和 `T-S019` 的实际实现存在偏差,以实际实现为准,再补 smoke 和说明
51 
52 ## 验收标准
53 
54@@ -129,3 +147,15 @@
55 - 哪些旧文案被删除或降级
56 - 跑了哪些验证
57 - 还有哪些残余风险
58+
59+## 当前残余风险
60+
61+- `pnpm smoke` / runtime smoke 目前依赖仓库根存在本地运行目录:
62+  - `state/`
63+  - `runs/`
64+  - `worktrees/`
65+  - `logs/launchd/`
66+  - `logs/codexd/`
67+  - `tmp/`
68+- 这些目录前提属于当前脚本和本机运行环境假设,不是本次浏览器桥接功能改动本身
69+- 本机已补齐这些目录并完成验证,但如果换到一台新的机器或全新 checkout,仍需要先满足这些目录前提
M tasks/TASK_OVERVIEW.md
+22, -11
 1@@ -13,8 +13,8 @@
 2 
 3 ## 状态分类
 4 
 5-- `已完成`:`T-S001` 到 `T-S016`
 6-- `当前 TODO`:浏览器桥接登录态与端点元数据持久化
 7+- `已完成`:`T-S001` 到 `T-S020`
 8+- `当前 TODO`:`无`
 9 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
10 
11 当前新的主需求文档:
12@@ -41,6 +41,10 @@
13 14. [`T-S014.md`](./T-S014.md):把 `status-api` 从默认 runtime 服务集合里降为显式 opt-in
14 15. [`T-S015.md`](./T-S015.md):给 `mini` 单节点补统一 on-node verify wrapper
15 16. [`T-S016.md`](./T-S016.md):收口 `status-api` 终局并给 `conductor` 提供兼容状态视图
16+17. [`T-S017.md`](./T-S017.md):定义浏览器登录态持久化模型与仓储
17+18. [`T-S018.md`](./T-S018.md):把 Firefox 插件收口到空壳标签页并上报账号/指纹/端点
18+19. [`T-S019.md`](./T-S019.md):让 conductor 持久化浏览器登录态并提供查询面
19+20. [`T-S020.md`](./T-S020.md):回写浏览器桥接文档、补持久化 smoke 并同步状态视图
20 
21 当前主线已经额外收口:
22 
23@@ -53,22 +57,29 @@
24 
25 ## 当前活动任务
26 
27-当前没有进行中的任务卡。
28+- 当前无进行中的主线任务
29+- 最近完成任务:[`T-S020.md`](./T-S020.md)
30 
31 ## 当前 TODO
32 
33-当前最高优先级 TODO 已拆成 4 张任务卡:
34+当前浏览器桥接主线已进入集成阶段:
35 
36-1. [`T-S017.md`](./T-S017.md):定义浏览器登录态持久化模型与仓储
37-2. [`T-S018.md`](./T-S018.md):把 Firefox 插件收口到空壳标签页并上报账号/指纹/端点
38-3. [`T-S019.md`](./T-S019.md):让 conductor 持久化浏览器登录态并提供查询面
39-4. [`T-S020.md`](./T-S020.md):回写浏览器桥接文档并补持久化 smoke
40+1. [`T-S017.md`](./T-S017.md):已完成,提供浏览器登录态持久化模型与仓储
41+2. [`T-S018.md`](./T-S018.md):已完成,Firefox 插件已收口到空壳标签页并开始上报账号/指纹/端点
42+3. [`T-S019.md`](./T-S019.md):已完成,`conductor` 已接上仓储、读接口和状态老化逻辑
43+4. [`T-S020.md`](./T-S020.md):已完成,文档、browser smoke 和任务状态视图已同步到正式模型
44 
45 建议并行关系:
46 
47-- `T-S017` 与 `T-S018` 可并行
48-- `T-S019` 作为集成任务,默认接在 `T-S017`、`T-S018` 之后
49-- `T-S020` 作为文档和 smoke 收尾任务,默认接在 `T-S019` 之后
50+- `T-S017` 与 `T-S018` 已并行完成
51+- `T-S019` 已完成集成和验收收口
52+- `T-S020` 已在 `T-S019` 之后完成收尾
53+
54+当前已知主线遗留:
55+
56+- 当前主线开发任务已经清空
57+- runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归
58+- 本地工作树里仍存在与本轮并行任务无关的 `plugins/baa-firefox/controller.js` 改动;后续开发继续避免覆盖它
59 
60 ## 低优先级 TODO
61 
M tests/browser/browser-control-e2e-smoke.test.mjs
+321, -1
  1@@ -109,6 +109,35 @@ async function waitForWebSocketOpen(socket) {
  2   });
  3 }
  4 
  5+async function waitForWebSocketClose(socket) {
  6+  if (socket.readyState === WebSocket.CLOSED) {
  7+    return;
  8+  }
  9+
 10+  await new Promise((resolve) => {
 11+    socket.addEventListener("close", () => resolve(), {
 12+      once: true
 13+    });
 14+  });
 15+}
 16+
 17+async function waitForCondition(assertion, timeoutMs = 2_000, intervalMs = 50) {
 18+  const startedAt = Date.now();
 19+  let lastError = null;
 20+
 21+  while (Date.now() - startedAt < timeoutMs) {
 22+    try {
 23+      return await assertion();
 24+    } catch (error) {
 25+      lastError = error;
 26+    }
 27+
 28+    await new Promise((resolve) => setTimeout(resolve, intervalMs));
 29+  }
 30+
 31+  throw lastError ?? new Error("timed out waiting for condition");
 32+}
 33+
 34 async function connectFirefoxBridgeClient(wsUrl, clientId) {
 35   const socket = new WebSocket(wsUrl);
 36   const queue = createWebSocketMessageQueue(socket);
 37@@ -154,7 +183,13 @@ async function fetchJson(url, init) {
 38   };
 39 }
 40 
 41-test("browser control e2e smoke covers bridge status, Claude open, send, and current read", async () => {
 42+function assertNoSecretLeak(text, secrets) {
 43+  for (const secret of secrets) {
 44+    assert.doesNotMatch(text, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u"));
 45+  }
 46+}
 47+
 48+test("browser control e2e smoke covers metadata read surface plus Claude relay", async () => {
 49   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
 50   const runtime = new ConductorRuntime(
 51     {
 52@@ -193,6 +228,11 @@ test("browser control e2e smoke covers bridge status, Claude open, send, and cur
 53       JSON.stringify({
 54         type: "credentials",
 55         platform: "claude",
 56+        account: "smoke@example.com",
 57+        credential_fingerprint: "fp-smoke-claude",
 58+        freshness: "fresh",
 59+        captured_at: 1710000001000,
 60+        last_seen_at: 1710000001500,
 61         headers: {
 62           "anthropic-client-version": "smoke-client",
 63           cookie: "session=1",
 64@@ -209,10 +249,21 @@ test("browser control e2e smoke covers bridge status, Claude open, send, and cur
 65       JSON.stringify({
 66         type: "api_endpoints",
 67         platform: "claude",
 68+        account: "smoke@example.com",
 69+        credential_fingerprint: "fp-smoke-claude",
 70+        updated_at: 1710000002000,
 71         endpoints: [
 72           "GET /api/organizations",
 73           "GET /api/organizations/{id}/chat_conversations/{id}",
 74           "POST /api/organizations/{id}/chat_conversations/{id}/completion"
 75+        ],
 76+        endpoint_metadata: [
 77+          {
 78+            method: "GET",
 79+            path: "/api/organizations",
 80+            first_seen_at: 1710000001200,
 81+            last_seen_at: 1710000002000
 82+          }
 83         ]
 84       })
 85     );
 86@@ -225,6 +276,16 @@ test("browser control e2e smoke covers bridge status, Claude open, send, and cur
 87     assert.equal(browserStatus.payload.data.bridge.client_count, 1);
 88     assert.equal(browserStatus.payload.data.current_client.client_id, "firefox-browser-control-smoke");
 89     assert.equal(browserStatus.payload.data.claude.ready, true);
 90+    assert.equal(browserStatus.payload.data.records[0].view, "active_and_persisted");
 91+    assert.equal(browserStatus.payload.data.records[0].live.credentials.header_count, 3);
 92+    assert.equal(browserStatus.payload.data.records[0].persisted.credential_fingerprint, "fp-smoke-claude");
 93+    assert.deepEqual(browserStatus.payload.data.records[0].persisted.endpoints, [
 94+      "GET /api/organizations",
 95+      "GET /api/organizations/{id}/chat_conversations/{id}",
 96+      "POST /api/organizations/{id}/chat_conversations/{id}/completion"
 97+    ]);
 98+    assert.equal(browserStatus.payload.data.summary.status_counts.fresh, 1);
 99+    assertNoSecretLeak(browserStatus.text, ["csrf-smoke", "session=1"]);
100 
101     const openResult = await fetchJson(`${baseUrl}/v1/browser/claude/open`, {
102       method: "POST",
103@@ -428,3 +489,262 @@ test("browser control e2e smoke covers bridge status, Claude open, send, and cur
104     });
105   }
106 });
107+
108+test("browser control e2e smoke keeps persisted browser metadata readable across disconnect and restart", async () => {
109+  const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-persistence-smoke-"));
110+  const createRuntime = () =>
111+    new ConductorRuntime(
112+      {
113+        nodeId: "mini-main",
114+        host: "mini",
115+        role: "primary",
116+        controlApiBase: "https://conductor.example.test",
117+        localApiBase: "http://127.0.0.1:0",
118+        sharedToken: "replace-me",
119+        paths: {
120+          runsDir: "/tmp/runs",
121+          stateDir
122+        }
123+      },
124+      {
125+        autoStartLoops: false,
126+        now: () => 100
127+      }
128+    );
129+
130+  let runtime = createRuntime();
131+  let client = null;
132+
133+  try {
134+    const snapshot = await runtime.start();
135+    const baseUrl = snapshot.controlApi.localApiBase;
136+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-persist-smoke");
137+
138+    client.socket.send(
139+      JSON.stringify({
140+        type: "credentials",
141+        platform: "claude",
142+        account: "persist@example.com",
143+        credential_fingerprint: "fp-claude-persist",
144+        freshness: "fresh",
145+        captured_at: 1710000001000,
146+        last_seen_at: 1710000001500,
147+        headers: {
148+          cookie: "session=persist-secret",
149+          "x-csrf-token": "csrf-persist-secret"
150+        }
151+      })
152+    );
153+    await client.queue.next(
154+      (message) => message.type === "state_snapshot" && message.reason === "credentials"
155+    );
156+
157+    client.socket.send(
158+      JSON.stringify({
159+        type: "api_endpoints",
160+        platform: "claude",
161+        account: "persist@example.com",
162+        credential_fingerprint: "fp-claude-persist",
163+        updated_at: 1710000002000,
164+        endpoints: [
165+          "GET /api/organizations",
166+          "POST /api/organizations/{id}/chat_conversations/{id}/completion"
167+        ],
168+        endpoint_metadata: [
169+          {
170+            method: "GET",
171+            path: "/api/organizations",
172+            first_seen_at: 1710000001200,
173+            last_seen_at: 1710000002000
174+          }
175+        ]
176+      })
177+    );
178+    await client.queue.next(
179+      (message) => message.type === "state_snapshot" && message.reason === "api_endpoints"
180+    );
181+
182+    const connectedStatus = await fetchJson(
183+      `${baseUrl}/v1/browser?platform=claude&account=persist%40example.com`
184+    );
185+    assert.equal(connectedStatus.response.status, 200);
186+    assert.equal(connectedStatus.payload.data.records.length, 1);
187+    assert.equal(connectedStatus.payload.data.records[0].view, "active_and_persisted");
188+    assert.equal(connectedStatus.payload.data.records[0].status, "fresh");
189+    assert.equal(connectedStatus.payload.data.summary.active_records, 1);
190+    assert.equal(connectedStatus.payload.data.records[0].live.credentials.account, "persist@example.com");
191+    assert.equal(
192+      connectedStatus.payload.data.records[0].persisted.credential_fingerprint,
193+      "fp-claude-persist"
194+    );
195+    assert.deepEqual(
196+      connectedStatus.payload.data.records[0].persisted.endpoints,
197+      [
198+        "GET /api/organizations",
199+        "POST /api/organizations/{id}/chat_conversations/{id}/completion"
200+      ]
201+    );
202+    assertNoSecretLeak(connectedStatus.text, ["persist-secret", "csrf-persist-secret"]);
203+
204+    const closePromise = waitForWebSocketClose(client.socket);
205+    client.socket.close(1000, "disconnect-persist");
206+    await closePromise;
207+    client.queue.stop();
208+    client = null;
209+
210+    const disconnectedStatus = await waitForCondition(async () => {
211+      const result = await fetchJson(
212+        `${baseUrl}/v1/browser?platform=claude&account=persist%40example.com`
213+      );
214+      assert.equal(result.response.status, 200);
215+      assert.equal(result.payload.data.bridge.client_count, 0);
216+      assert.equal(result.payload.data.records.length, 1);
217+      assert.equal(result.payload.data.records[0].view, "persisted_only");
218+      assert.equal(result.payload.data.records[0].status, "stale");
219+      assert.equal(result.payload.data.summary.persisted_only_records, 1);
220+      return result;
221+    });
222+    assert.equal(disconnectedStatus.payload.data.records[0].persisted.status, "stale");
223+    assertNoSecretLeak(disconnectedStatus.text, ["persist-secret", "csrf-persist-secret"]);
224+
225+    await runtime.stop();
226+    runtime = null;
227+
228+    runtime = createRuntime();
229+    const restartedSnapshot = await runtime.start();
230+    const restartedStatus = await fetchJson(
231+      `${restartedSnapshot.controlApi.localApiBase}/v1/browser?platform=claude&account=persist%40example.com`
232+    );
233+    assert.equal(restartedStatus.response.status, 200);
234+    assert.equal(restartedStatus.payload.data.bridge.client_count, 0);
235+    assert.equal(restartedStatus.payload.data.records.length, 1);
236+    assert.equal(restartedStatus.payload.data.records[0].view, "persisted_only");
237+    assert.equal(restartedStatus.payload.data.records[0].status, "stale");
238+    assert.equal(restartedStatus.payload.data.records[0].live, null);
239+    assert.equal(restartedStatus.payload.data.records[0].persisted.status, "stale");
240+    assert.equal(restartedStatus.payload.data.summary.persisted_only_records, 1);
241+    assert.equal(
242+      restartedStatus.payload.data.records[0].persisted.credential_fingerprint,
243+      "fp-claude-persist"
244+    );
245+    assert.deepEqual(
246+      restartedStatus.payload.data.records[0].persisted.endpoints,
247+      [
248+        "GET /api/organizations",
249+        "POST /api/organizations/{id}/chat_conversations/{id}/completion"
250+      ]
251+    );
252+    assertNoSecretLeak(restartedStatus.text, ["persist-secret", "csrf-persist-secret"]);
253+  } finally {
254+    client?.queue.stop();
255+
256+    if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
257+      client.socket.close(1000, "done");
258+    }
259+
260+    if (runtime != null) {
261+      await runtime.stop();
262+    }
263+
264+    rmSync(stateDir, {
265+      force: true,
266+      recursive: true
267+    });
268+  }
269+});
270+
271+test("browser control e2e smoke shows browser login state aging from fresh to stale to lost", async () => {
272+  const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-aging-smoke-"));
273+  let nowSeconds = 100;
274+  const runtime = new ConductorRuntime(
275+    {
276+      nodeId: "mini-main",
277+      host: "mini",
278+      role: "primary",
279+      controlApiBase: "https://conductor.example.test",
280+      localApiBase: "http://127.0.0.1:0",
281+      sharedToken: "replace-me",
282+      paths: {
283+        runsDir: "/tmp/runs",
284+        stateDir
285+      }
286+    },
287+    {
288+      autoStartLoops: false,
289+      now: () => nowSeconds
290+    }
291+  );
292+
293+  let client = null;
294+
295+  try {
296+    const snapshot = await runtime.start();
297+    const baseUrl = snapshot.controlApi.localApiBase;
298+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-aging-smoke");
299+
300+    client.socket.send(
301+      JSON.stringify({
302+        type: "credentials",
303+        platform: "claude",
304+        account: "aging@example.com",
305+        credential_fingerprint: "fp-aging",
306+        freshness: "fresh",
307+        captured_at: 100_000,
308+        last_seen_at: 100_000,
309+        headers: {
310+          cookie: "session=aging-secret"
311+        }
312+      })
313+    );
314+    await client.queue.next(
315+      (message) => message.type === "state_snapshot" && message.reason === "credentials"
316+    );
317+
318+    const freshStatus = await fetchJson(
319+      `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
320+    );
321+    assert.equal(freshStatus.response.status, 200);
322+    assert.equal(freshStatus.payload.data.records[0].status, "fresh");
323+    assert.equal(freshStatus.payload.data.summary.status_counts.fresh, 1);
324+    assertNoSecretLeak(freshStatus.text, ["aging-secret"]);
325+
326+    nowSeconds = 160;
327+    await new Promise((resolve) => setTimeout(resolve, 2_200));
328+
329+    const staleStatus = await waitForCondition(async () => {
330+      const result = await fetchJson(
331+        `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
332+      );
333+      assert.equal(result.payload.data.records[0].status, "stale");
334+      assert.equal(result.payload.data.summary.status_counts.stale, 1);
335+      return result;
336+    }, 3_000, 100);
337+    assert.equal(staleStatus.payload.data.records[0].view, "active_and_persisted");
338+
339+    nowSeconds = 260;
340+    await new Promise((resolve) => setTimeout(resolve, 2_200));
341+
342+    const lostStatus = await waitForCondition(async () => {
343+      const result = await fetchJson(
344+        `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
345+      );
346+      assert.equal(result.payload.data.records[0].status, "lost");
347+      assert.equal(result.payload.data.summary.status_counts.lost, 1);
348+      return result;
349+    }, 3_000, 100);
350+    assert.equal(lostStatus.payload.data.records[0].persisted.status, "lost");
351+    assertNoSecretLeak(lostStatus.text, ["aging-secret"]);
352+  } finally {
353+    client?.queue.stop();
354+
355+    if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
356+      client.socket.close(1000, "done");
357+    }
358+
359+    await runtime.stop();
360+    rmSync(stateDir, {
361+      force: true,
362+      recursive: true
363+    });
364+  }
365+});