- 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
+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
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 }
+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
+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(
+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> {
+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
+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` 也不在本文件讨论范围内
+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`
+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
+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
+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
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;
+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;
+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+});
+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}".`);
+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+}
+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` 调用方并拆掉当前构建时复用
+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/*`
+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-}
+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>
+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,
+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",
+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: {}
+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 - 跑了哪些测试
+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,仍需要先满足这些目录前提
+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
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+});