- commit
- 88ae94b
- parent
- 0cdbd8a
- author
- im_wower
- date
- 2026-03-22 18:30:52 +0800 CST
feat(firefox): default local ws bridge
8 files changed,
+657,
-714
+91,
-0
1@@ -0,0 +1,91 @@
2+---
3+task_id: T-C002
4+title: Firefox 插件接本地 WS
5+status: review
6+branch: feat/firefox-local-ws-client
7+repo: /Users/george/code/baa-conductor
8+base_ref: main@0cdbd8a
9+depends_on:
10+ - T-C001
11+write_scope:
12+ - plugins/baa-firefox/**
13+ - docs/firefox/**
14+updated_at: 2026-03-22
15+---
16+
17+# Firefox 插件接本地 WS
18+
19+## 目标
20+
21+让 Firefox 插件默认自动连接 `mini` 本地 WS,并把管理页收口为 WS 状态、HTTP API 状态和控制按钮。
22+
23+## 本任务包含
24+
25+- 插件默认连接本地 `/ws/firefox`
26+- 保留远程 HTTP 控制状态同步
27+- 管理页只保留 WS 状态、HTTP 状态、暂停/恢复/排空按钮
28+- 去掉手工编辑 WS 地址和 API 地址
29+- 支持自动重连和服务端重启后恢复
30+- 更新 Firefox 使用文档和验证步骤
31+
32+## 本任务不包含
33+
34+- 不修改 `conductor-daemon`
35+- 不修改 Firefox 插件以外的业务逻辑
36+
37+## 建议起始文件
38+
39+- `plugins/baa-firefox/controller.js`
40+- `plugins/baa-firefox/background.js`
41+- `plugins/baa-firefox/controller.html`
42+- `plugins/baa-firefox/docs/conductor-control.md`
43+- `docs/firefox/README.md`
44+
45+## 交付物
46+
47+- 默认接入本地 WS 的 Firefox 插件
48+- 收口后的管理页 UI
49+- 已更新的 Firefox 文档和任务卡
50+
51+## 验收
52+
53+- `node --check` 覆盖插件脚本
54+- 说明如何验证 WS 连接、断线重连、按钮控制
55+- `git diff --check` 通过
56+
57+## files_changed
58+
59+- `coordination/tasks/T-C002.md`
60+- `plugins/baa-firefox/controller.js`
61+- `plugins/baa-firefox/controller.html`
62+- `plugins/baa-firefox/controller.css`
63+- `plugins/baa-firefox/manifest.json`
64+- `plugins/baa-firefox/README.md`
65+- `plugins/baa-firefox/docs/conductor-control.md`
66+- `docs/firefox/README.md`
67+
68+## commands_run
69+
70+- `node --check /Users/george/code/worktrees/baa-conductor-firefox-local-ws-client/plugins/baa-firefox/controller.js`
71+- `node --check /Users/george/code/worktrees/baa-conductor-firefox-local-ws-client/plugins/baa-firefox/background.js`
72+- `node --check /Users/george/code/worktrees/baa-conductor-firefox-local-ws-client/plugins/baa-firefox/content-script.js`
73+- `node --check /Users/george/code/worktrees/baa-conductor-firefox-local-ws-client/plugins/baa-firefox/page-interceptor.js`
74+- `git -C /Users/george/code/worktrees/baa-conductor-firefox-local-ws-client diff --check`
75+
76+## result
77+
78+- Firefox 插件默认固定连接本地 `ws://127.0.0.1:4317/ws/firefox`,不再允许手工修改地址
79+- 远程 HTTP control API 固定为 `https://conductor.makefile.so`,并继续按轮询加退避重试同步状态
80+- 管理页已收口为 WS 状态、HTTP 状态和 `暂停` / `恢复` / `排空` 按钮
81+- 本地 WS 已支持握手、快照展示、断线自动重连和本地服务重启后恢复
82+- Firefox 文档已更新为新的固定地址、简化 UI 和验证步骤
83+
84+## risks
85+
86+- 本次只做了静态校验;真实的 WS 握手、断线重连和 HTTP 控制按钮仍需在已运行的 `conductor-daemon` 与远程 `conductor.makefile.so` 上做人工联调
87+- 管理页不再展示标签页/凭证/端点细节;这些底层桥接能力仍然保留,但后续排障要看 WS 详情或浏览器扩展存储
88+
89+## next_handoff
90+
91+- 在装好插件的 Firefox 上手工验证三件事:本地 WS 首次连接、停服后自动重连、`暂停` / `恢复` / `排空` 的远程状态回写
92+- 如果需要更强可观测性,再考虑把当前被隐藏的桥接调试信息迁到单独调试页,而不是重新塞回主管理页
+97,
-270
1@@ -4,327 +4,154 @@
2
3 - [`../../plugins/baa-firefox/`](../../plugins/baa-firefox/)
4
5-当前推荐启动脚本:
6+这里保留 Firefox 插件与 `baa-conductor` 的最小接入约定。
7
8-- [`../../scripts/firefox/open-firefox-with-plugin.sh`](../../scripts/firefox/open-firefox-with-plugin.sh)
9+## 当前固定入口
10
11-这里保留协议和控制面约定文档;具体实现以 `plugins/baa-firefox/` 为准。
12+- Local WS bridge: `ws://127.0.0.1:4317/ws/firefox`
13+- Public HTTP control API: `https://conductor.makefile.so`
14
15-## 快速启动
16+当前插件默认同时使用这两条链路:
17
18-当前推荐模式已经改成:
19+- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端快照展示
20+- 远程 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain` 写入
21
22-- 使用系统 Firefox 的正常默认 profile
23-- `baa-firefox` 插件由你手动安装到这个 profile
24-- Firefox 常开,不再依赖 `web-ext run` 的临时加载模式
25+不再允许在插件管理页中手工编辑地址。
26
27-启动命令:
28+## 目标
29
30-```bash
31-./scripts/firefox/open-firefox-with-plugin.sh
32-```
33-
34-如果要顺手打开 Claude:
35-
36-```bash
37-./scripts/firefox/open-firefox-with-plugin.sh --url https://claude.ai
38-```
39-
40-说明:
41-
42-- 脚本现在只是打开系统 Firefox,不再创建单独 profile
43-- 脚本也不再临时注入扩展
44-- 插件需要你手动安装到 Firefox 的正常使用 profile
45-- 下次新对话直接运行这个脚本即可,前提是 Firefox 里已经装好插件
46-
47-本文档定义 `baa-firefox` 与 `baa-conductor` control API 之间的最小协议。
48-
49-目标:
50-
51-- 让 Firefox 插件能显示全局自动化状态
52-- 让 Firefox 插件能触发 `pause`、`resume`,可选 `drain`
53-- 明确可见 `control` 与隐藏 `dispatch` 的职责边界
54-
55-非目标:
56-
57-- 不定义完整 UI 视觉稿
58-- 不让浏览器成为调度真相来源
59-
60-## 1. 基本原则
61-
62-- 真相来源是 control API 背后的 D1 `system_state`,不是浏览器本地状态。
63-- Firefox 插件只调用 HTTP control API,不直接操作 conductor 进程。
64-- 插件负责显示状态和发起人工控制动作,不负责创建、分配、恢复 task。
65-- `pause`、`resume`、`drain` 都是全局动作,不是单标签页动作。
66-- Firefox 插件默认模式不依赖 websocket;如果保留兼容能力,也只能作为手动启用的可选通道。
67-- Firefox 启动时由 `background.js` 确保 controller 页存在;controller 启动后立即同步 Control API。
68-- Control API 临时失败时,插件必须持续自动重试;服务恢复后自动回到正常同步周期。
69-- 插件里的“跟踪标签页”只统计当前真实打开且仍匹配平台 host 的页面,不把历史 storage 残留当成当前状态。
70-- 插件里的“凭证”只统计绑定到当前 tracked tab、最近重新捕获且仍通过平台登录规则校验的快照;启动时不自动补开全部平台页。
71+- 让 Firefox 插件自动接入 `mini` 本地 `/ws/firefox`
72+- 保留远程 HTTP control-plane 状态同步
73+- 把管理页收口成 WS 状态、HTTP 状态、控制按钮
74+- 在本地服务重启或远程 HTTP 短暂失败后自动恢复
75
76-## 2. 模式语义
77+## 非目标
78
79-全局模式只有三种:
80+- 不让浏览器成为调度真相源
81+- 不重新引入旧的手工地址配置
82+- 不把 `/ws/firefox` 作为公网入口
83
84-- `running`: 正常调度。
85-- `draining`: 不再启动新的 step,已启动的 run 可以自然结束。
86-- `paused`: 不再启动新的 step,conductor 调度暂停;已运行任务是否继续或终止由后端策略决定。
87+## 启动行为
88
89-插件文案必须和这个语义保持一致,尤其不要把 `paused` 展示成“所有运行中的工作都已强制停止”。
90+- Firefox 启动时,`background.js` 会确保 `controller.html` 存在。
91+- `controller.html` 启动后立刻连接 `ws://127.0.0.1:4317/ws/firefox`。
92+- WS 断开后按固定间隔自动重连;本地服务恢复后连接会自动恢复。
93+- `controller.html` 启动后也会立刻请求 `GET /v1/system/state`。
94+- HTTP 成功后按 `15` 秒周期继续同步。
95+- HTTP 失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试。
96
97-## 3. 入口与当前简化模式
98+## 管理页 UI
99
100-推荐入口:
101+当前管理页只保留:
102
103-- Base URL: `https://control-api.makefile.so`
104-- 新对话或新接入 AI 客户端时,先读 `GET /describe`
105+- 本地 WS 状态卡片
106+- 远程 HTTP 状态卡片
107+- `暂停` / `恢复` / `排空` 按钮
108+- `WS 状态` 原始详情面板
109+- `HTTP 状态` 原始详情面板
110
111-当前单节点临时模式:
112+不再保留:
113
114-- `CONTROL_API_AUTH_REQUIRED=false`
115-- Firefox 插件默认直接请求 `https://control-api.makefile.so`
116-- 如果用户没有手动保存配置,插件会继续默认使用 `https://control-api.makefile.so`
117-- Firefox 插件默认不配置 websocket,也不会主动连接 `ws://127.0.0.1:9800`
118-- 当前不要求本地 `baa-server`
119-- 当前不要求在插件里填写 token
120+- WS 地址输入框
121+- HTTP 地址输入框
122+- 手工重连按钮
123+- 手工刷新按钮
124+- 标签页/凭证/端点/日志面板
125
126-如果后续重新启用鉴权,再恢复 `Authorization: Bearer <token>` 即可。
127-如果后续需要兼容旧的 browser-proxy WS,再在插件里手动填写 `ws://` / `wss://` 地址并显式启用。
128+## WS 协议
129
130-推荐 describe-first 顺序:
131-
132-1. `GET /describe`
133-2. `GET /v1/capabilities`
134-3. `GET /v1/system/state`
135-4. 如需更强只读发现能力,再查 `GET /v1/controllers`、`GET /v1/tasks`、`GET /v1/runs`
136-
137-## 4. 读取状态
138-
139-### `GET /v1/system/state`
140-
141-Firefox 插件至少要读取这些字段:
142-
143-| 字段 | 类型 | 说明 |
144-| --- | --- | --- |
145-| `automation.mode` | string | `running \| draining \| paused` |
146-| `automation.updated_at` | integer | 最近一次模式变更时间,毫秒时间戳 |
147-| `automation.requested_by` | string \| null | 最近一次变更来源,建议用于审计展示 |
148-| `automation.reason` | string \| null | 最近一次变更原因 |
149-| `leader.controller_id` | string \| null | 当前 leader controller id |
150-| `leader.host` | string \| null | 当前持有租约的 host;当前主线一般是 `mini` |
151-| `leader.role` | string \| null | 当前 leader 注册角色 |
152-| `leader.lease_expires_at` | integer \| null | 当前 lease 到期时间,毫秒时间戳 |
153-| `queue.active_runs` | integer | 当前运行中的 run 数 |
154-| `queue.queued_tasks` | integer | 当前排队中的 task 数 |
155-| `request_id` | string | 请求追踪 id |
156-
157-推荐响应:
158+插件启动后会先发送:
159
160 ```json
161 {
162- "ok": true,
163- "request_id": "req_123",
164- "data": {
165- "automation": {
166- "mode": "running",
167- "updated_at": 1760000000000,
168- "requested_by": "browser_admin",
169- "reason": "human_clicked_resume"
170- },
171- "leader": {
172- "controller_id": "mini-main",
173- "host": "mini",
174- "role": "primary",
175- "lease_expires_at": 1760000030000
176- },
177- "queue": {
178- "active_runs": 2,
179- "queued_tasks": 7
180- }
181- }
182+ "type": "hello",
183+ "clientId": "firefox-ab12cd",
184+ "nodeType": "browser",
185+ "nodeCategory": "proxy",
186+ "nodePlatform": "firefox"
187 }
188 ```
189
190-插件行为:
191+随后插件会继续上送:
192
193-- Firefox 启动时,background 会自动确保 `controller.html` 标签页存在。
194-- `controller.html` 启动后立即请求一次 `GET /v1/system/state`。
195-- 当前实现成功后按 `15` 秒周期继续同步。
196-- 当前实现失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再切到每 `30` 秒一次的慢速重试。
197-- 服务恢复后自动回到 `15` 秒正常同步周期,不需要手动刷新页面或重新打开 controller。
198-- `刷新控制面` 按钮仍然保留,但只是额外的手动触发入口。
199-- 每次成功执行 `pause`、`resume`、`drain` 后,优先使用写接口返回的新状态更新 UI;如果后端暂未回传完整状态,则立即补一次 `GET /v1/system/state`。
200+- `credentials`
201+- `api_endpoints`
202+- `client_log`
203
204-## 4.1 能力发现和辅助只读接口
205+插件会消费服务端返回的:
206
207-这些接口不是插件主循环必需,但适合 Claude、手机端网页或人工排障先读:
208+- `hello_ack`
209+- `state_snapshot`
210+- `request_credentials`
211+- `action_result`
212+- `error`
213
214-- `GET /describe`
215-- `GET /v1/capabilities`
216-- `GET /v1/controllers`
217-- `GET /v1/tasks?limit=5`
218-- `GET /v1/runs?limit=5`
219+其中 `state_snapshot` 用来驱动管理页里的 WS 状态展示。
220
221-用途:
222+## HTTP 协议
223
224-- `/describe`:读完整端点表、字段结构、示例和当前模式说明
225-- `/v1/capabilities`:快速区分 public/read/write surface
226-- `/v1/controllers`:确认当前注册的 controller 和 active controller
227-- `/v1/tasks`:确认系统最近有哪些任务
228-- `/v1/runs`:确认系统最近执行过哪些 run
229+读取:
230
231-## 5. 写接口
232+- `GET /v1/system/state`
233
234-### 5.1 共用请求体
235+写入:
236
237-`POST /v1/system/pause`、`POST /v1/system/resume`、`POST /v1/system/drain` 使用同一套 body。
238+- `POST /v1/system/pause`
239+- `POST /v1/system/resume`
240+- `POST /v1/system/drain`
241
242-请求体:
243-
244-| 字段 | 类型 | 必填 | 说明 |
245-| --- | --- | --- | --- |
246-| `requested_by` | string | 是 | 固定写 `browser_admin` |
247-| `source` | string | 是 | 固定写 `firefox_extension` |
248-| `reason` | string | 是 | 例如 `human_clicked_pause` |
249-| `request_id` | string | 否 | 前端生成的幂等追踪 id,推荐 UUID |
250-
251-示例:
252+写接口请求体固定包含:
253
254 ```json
255 {
256 "requested_by": "browser_admin",
257 "source": "firefox_extension",
258 "reason": "human_clicked_pause",
259- "request_id": "4e08d0d6-4e78-4e58-b71f-9cc0f9c3f245"
260-}
261-```
262-
263-### 5.2 状态迁移
264-
265-- `POST /v1/system/pause`: 把全局模式设为 `paused`
266-- `POST /v1/system/resume`: 把全局模式设为 `running`
267-- `POST /v1/system/drain`: 把全局模式设为 `draining`
268-
269-推荐迁移规则:
270-
271-- `running -> paused`
272-- `draining -> paused`
273-- `paused -> running`
274-- `draining -> running`
275-- `running -> draining`
276-
277-推荐幂等规则:
278-
279-- 当前已经是目标模式时,返回 `200` 和当前状态,不报错。
280-- `paused -> drain` 返回 `409 invalid_mode_transition`,要求用户先 `resume` 再 `drain`。
281-
282-### 5.3 成功响应
283-
284-写接口成功时,建议直接返回最新状态,避免插件立刻多打一轮查询:
285-
286-```json
287-{
288- "ok": true,
289- "request_id": "req_124",
290- "data": {
291- "automation": {
292- "mode": "paused",
293- "updated_at": 1760000005000,
294- "requested_by": "browser_admin",
295- "reason": "human_clicked_pause"
296- },
297- "leader": {
298- "controller_id": "mini-main",
299- "host": "mini",
300- "role": "primary",
301- "lease_expires_at": 1760000030000
302- },
303- "queue": {
304- "active_runs": 2,
305- "queued_tasks": 7
306- }
307- }
308+ "request_id": "uuid"
309 }
310 ```
311
312-### 5.4 失败响应
313-
314-失败体遵循 control API 的统一结构:
315-
316-```json
317-{
318- "ok": false,
319- "error": "invalid_mode_transition",
320- "message": "Drain is not allowed while automation is paused.",
321- "request_id": "req_125"
322-}
323-```
324-
325-插件至少要处理这些错误:
326-
327-- `401` / `403`: 如果后续重新启用鉴权,则表示 token 无效或角色不足
328-- `409`: 非法状态迁移或并发冲突
329-- `5xx`: control API 暂时不可用
330-
331-## 6. 按钮行为
332-
333-最少按钮:
334-
335-- `Pause`
336-- `Resume`
337-- 可选 `Drain`
338-
339-按钮启用规则:
340-
341-- `mode = running`: `Pause` 可点,`Drain` 可点,`Resume` 禁用
342-- `mode = draining`: `Pause` 可点,`Resume` 可点,`Drain` 禁用
343-- `mode = paused`: `Resume` 可点,`Pause` 禁用,`Drain` 禁用
344-
345-交互要求:
346-
347-- 任一写请求发出后,先把三个按钮全部置为 loading / disabled,直到请求结束。
348-- 成功后立即用返回状态刷新 badge、按钮和文案。
349-- 失败后保留旧状态,并展示后端错误消息。
350-- 插件 badge 至少区分 `running`、`draining`、`paused` 三种状态。
351-
352-推荐展示字段:
353+插件会尽量从 HTTP 返回中归一化这些字段:
354
355-- Control API 连接状态
356-- 当前 `mode`
357-- 当前 leader host
358+- `mode`
359+- `leader`
360+- `lease_holder`
361+- `queue_depth` / `queued_tasks`
362 - `active_runs`
363-- `queued_tasks`
364-- 最近一次成功同步时间
365-- 自动重试中的失败信息和下一次重试时间
366-- 可选 WS 状态;未配置时展示 `未启用`,不要展示成主连接故障
367+- `controlConnection`
368+- `retryCount`
369+- `lastSuccessAt`
370+- `lastFailureAt`
371+- `nextRetryAt`
372
373-## 7. `control` 与 `dispatch` 边界
374+## 验收与验证
375
376-Firefox 插件要明确区分两个通道:
377+### 1. 验证 WS 连接
378
379-- 可见 `control`: 给人类对话、查看状态、做高层决策
380-- 隐藏 `dispatch`: 给自动化 task dispatch 与 review 流程
381+1. 安装插件并启动 Firefox。
382+2. 确认 `controller.html` 自动打开。
383+3. 确认 `本地 WS` 最终显示 `已连接`。
384+4. 在 `WS 状态` 面板中确认:
385+ - `wsUrl` 是 `ws://127.0.0.1:4317/ws/firefox`
386+ - 有最近一次 `state_snapshot`
387
388-必须遵守:
389+### 2. 验证断线重连
390
391-- 插件的 `pause`、`resume`、`drain` 只走 control API,不走 Claude 对话注入。
392-- 不把自动化任务下发到可见 `control` 对话。
393-- 不把人类交互消息写进隐藏 `dispatch` 通道。
394-- 浏览器内任何状态都不能替代 D1 中的 `system_state`。
395+1. 在插件已连接的情况下停掉本地 `conductor-daemon`。
396+2. 确认 `本地 WS` 状态变成 `重连中` 或显示最近错误。
397+3. 重启本地服务。
398+4. 确认管理页无需刷新即可回到 `已连接`。
399
400-推荐实现:
401+### 3. 验证按钮控制
402
403-- 一个可见的 `control` 会话入口
404-- 一个隐藏的 `dispatch` 通道入口
405-- 一个独立的控制面板调用 control API
406+1. 点击 `暂停`,确认 HTTP 状态里的 `mode` 变成 `paused`。
407+2. 点击 `恢复`,确认 `mode` 变成 `running`。
408+3. 点击 `排空`,确认 `mode` 变成 `draining`。
409+4. 如果 HTTP 临时失败,确认管理页会展示明确错误并自动继续重试。
410
411-## 8. 给 `baa-firefox` 的落地清单
412+## 相关文件
413
414-- 读取 `GET /v1/system/state`
415-- 根据 `automation.mode` 渲染 badge 与按钮禁用状态
416-- 实现 `POST /v1/system/pause`
417-- 实现 `POST /v1/system/resume`
418-- 可选实现 `POST /v1/system/drain`
419-- 当前默认不配置 websocket;只有用户手动填写后才启用可选 WS
420-- 当前临时模式下,插件不携带 token;如果后续重新启用鉴权,再恢复 bearer token
421-- 所有状态展示都以 control API 返回值为准
422+- [`../../plugins/baa-firefox/controller.js`](../../plugins/baa-firefox/controller.js)
423+- [`../../plugins/baa-firefox/controller.html`](../../plugins/baa-firefox/controller.html)
424+- [`../../plugins/baa-firefox/background.js`](../../plugins/baa-firefox/background.js)
425+- [`../../plugins/baa-firefox/docs/conductor-control.md`](../../plugins/baa-firefox/docs/conductor-control.md)
+32,
-82
1@@ -2,117 +2,67 @@
2
3 这个目录现在作为 `baa-conductor` 内部插件子目录维护。
4
5-来源说明:
6+## 当前默认连接
7
8-- 初始代码来自原独立仓库 `baa-firefox`
9-- 已迁入当前仓库,后续以这里为主写入面
10+- 本地 WS bridge:`ws://127.0.0.1:4317/ws/firefox`
11+- 远程 HTTP control API:`https://conductor.makefile.so`
12
13-Firefox MVP for the BAA browser-proxy path.
14+管理页已经收口为两块状态和三颗控制按钮:
15
16-## What This Repo Does
17+- 本地 WS 状态
18+- 远程 HTTP 状态
19+- `Pause` / `Resume` / `Drain`
20
21-This extension keeps an always-open controller page and tracks one live browser tab per platform.
22+不再允许用户手工编辑地址。
23
24-It does four things:
25+## What This Extension Does
26
27-- reads and updates conductor state via `https://control-api.makefile.so`
28-- keeps an optional legacy WebSocket path for browser-proxy workflows when manually enabled
29-- tracks current real tabs for `claude.ai`, `chatgpt.com`, and `gemini.google.com`, and only opens them on demand
30-- discovers live API endpoints from real browser traffic
31-- captures auth-related request headers and forwards them to the optional WS channel when enabled
32+- keeps an always-open `controller.html`
33+- auto-connects the local Firefox bridge WS on startup
34+- keeps polling remote HTTP control state with retry/backoff
35+- sends `hello` / `credentials` / `api_endpoints` metadata to the local WS bridge
36+- lets the operator trigger `pause` / `resume` / `drain`
37
38-This MVP is intentionally narrow:
39-
40-- manual login only
41-- one tracked tab per platform
42-- no iframe
43-- no DOM automation
44-- no full remote execution path yet
45+底层的 tab 跟踪、endpoint 发现和凭证捕获逻辑仍然保留,用于本地 WS bridge 同步,但不再在管理页里展开显示。
46
47 ## Files
48
49 - `manifest.json` - Firefox MV3 manifest
50-- `background.js` - opens and keeps the controller tab around
51-- `controller.html` - always-open management page
52-- `controller.js` - control API client, optional WS owner, multi-platform tab manager, webRequest capture, message relay
53+- `background.js` - keeps the controller tab alive and updates the toolbar badge
54+- `controller.html` - compact management page
55+- `controller.js` - local WS client, remote HTTP client, control actions, browser bridge logic
56 - `content-script.js` - bridge from page events into the extension runtime
57 - `page-interceptor.js` - MAIN world `fetch` interceptor
58-- `scripts/run-persistent.sh` - starts Firefox with the persistent `baa-firefox-persistent` profile
59-- `PLAN.md` - MVP design notes
60-
61-## Persistent Startup
62-
63-If you already created and logged into the persistent Firefox profile once, start the extension with:
64-
65-```bash
66-./scripts/run-persistent.sh
67-```
68-
69-This launches Firefox with the `baa-firefox-persistent` profile, keeps profile changes, reuses the saved browser login state on this machine, and reloads already-open platform pages so the interceptor is injected immediately.
70-
71-Useful overrides:
72-
73-- `BAA_FIREFOX_PROFILE` - change the Firefox profile name
74-- `BAA_FIREFOX_BIN` - change the Firefox binary path
75-- `BAA_WEB_EXT_BIN` - change the `web-ext` executable
76-
77-Example:
78-
79-```bash
80-BAA_FIREFOX_PROFILE=baa-firefox-persistent ./scripts/run-persistent.sh
81-```
82
83 ## How To Load
84
85 1. Open Firefox.
86 2. Visit `about:debugging#/runtime/this-firefox`.
87 3. Click `Load Temporary Add-on...`.
88-4. Choose [manifest.json](/Users/george/code/baa-conductor-main-merge/plugins/baa-firefox/manifest.json).
89+4. Choose [manifest.json](/Users/george/code/worktrees/baa-conductor-firefox-local-ws-client/plugins/baa-firefox/manifest.json).
90 5. Firefox should open `controller.html` automatically.
91
92 ## How To Run
93
94 1. Load this extension temporarily.
95-2. Open `controller.html`; the default control API should already be `https://control-api.makefile.so`.
96-3. Leave the optional WS input empty unless you explicitly need the legacy browser-proxy channel.
97-4. Use the controller buttons to open only the platform pages you actually need.
98-5. Log in manually in the tabs you want to intercept.
99-6. Use Claude, ChatGPT, or Gemini normally.
100-7. Watch the controller page for:
101- - control-plane mode and leader sync
102- - endpoint discovery
103- - credential snapshots
104-
105-If you still need the old WS path, manually fill a `ws://` or `wss://` address and click the reconnect button to enable it.
106-
107-## Expected MVP Result
108-
109-Once the platform tabs start making real requests, the extension should forward:
110+2. Make sure local `conductor-daemon` is listening on `http://127.0.0.1:4317`.
111+3. Open `controller.html` and confirm:
112+ - `本地 WS` becomes `已连接`
113+ - `远程 HTTP API` 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
117-- `hello`
118-- `api_endpoints`
119-- `credentials`
120-- `network_log`
121-- `sse_event`
122-- `client_log`
123+## Verification
124
125-These message shapes still match the current `baa-server` browser-side WS handling when the optional WS channel is enabled.
126+- WS connect: controller page shows `本地 WS = 已连接`
127+- WS reconnect: stop local daemon and restart it; controller page should move to retrying and then recover automatically
128+- HTTP sync: `远程 HTTP API` should keep refreshing every `15` seconds
129+- Control buttons: after clicking `暂停` / `恢复` / `排空`, the HTTP state should reflect the new mode
130
131 ## Limitations
132
133 - only one tab per platform is tracked
134-- tracked tab count only reflects tabs that are currently open and still match the platform host
135-- credential count only reflects recent valid snapshots bound to the current tracked tab
136 - only `fetch` is patched in the page
137-- there is no remote request execution path yet
138+- there is no full remote request execution path yet
139 - if Firefox unloads the extension, the session must be re-established
140-- Gemini interception is heuristic because its web app mixes private RPC paths under `/_/`
141-
142-## Next Step
143-
144-After this works end to end, add:
145-
146-- request execution into the live platform tabs
147-- stronger tab recovery
148-- broader network interception
149-- more robust Gemini and SSE coverage
150+- Gemini interception is still heuristic because its web app mixes private RPC paths under `/_/`
+2,
-9
1@@ -32,7 +32,6 @@ body {
2
3 .topbar {
4 display: flex;
5- justify-content: space-between;
6 gap: 16px;
7 align-items: end;
8 margin-bottom: 18px;
9@@ -67,7 +66,7 @@ h2 {
10 flex-wrap: wrap;
11 }
12
13-.settings {
14+.actions {
15 margin-bottom: 18px;
16 padding: 14px;
17 border: 1px solid var(--line);
18@@ -110,7 +109,7 @@ button:hover {
19
20 .grid {
21 display: grid;
22- grid-template-columns: repeat(4, minmax(0, 1fr));
23+ grid-template-columns: repeat(2, minmax(0, 1fr));
24 gap: 14px;
25 margin-bottom: 18px;
26 }
27@@ -184,12 +183,6 @@ button:hover {
28 word-break: break-word;
29 }
30
31-@media (max-width: 900px) {
32- .grid {
33- grid-template-columns: repeat(2, minmax(0, 1fr));
34- }
35-}
36-
37 @media (max-width: 640px) {
38 .shell {
39 padding: 16px;
+13,
-83
1@@ -11,25 +11,12 @@
2 <section class="topbar">
3 <div>
4 <p class="eyebrow">BAA Firefox 控制台</p>
5- <h1>管理页面</h1>
6- </div>
7- <div class="actions">
8- <button id="focus-controller-btn" type="button">聚焦本页</button>
9- <button id="open-claude-btn" type="button">打开 Claude</button>
10- <button id="open-chatgpt-btn" type="button">打开 ChatGPT</button>
11- <button id="open-gemini-btn" type="button">打开 Gemini</button>
12- <button id="reconnect-btn" type="button">启用可选 WS</button>
13+ <h1>本地 WS / 远程 HTTP</h1>
14+ <p class="meta">本地 bridge 自动连接 mini,远程控制面固定走 <code>https://conductor.makefile.so</code>。</p>
15 </div>
16 </section>
17
18- <section class="settings">
19- <label for="ws-url">可选 WS</label>
20- <input id="ws-url" type="text" spellcheck="false" placeholder="留空表示未启用">
21- <button id="save-ws-btn" type="button">保存 WS 配置</button>
22- <label for="control-base-url">控制 API</label>
23- <input id="control-base-url" type="text" spellcheck="false" placeholder="https://control-api.makefile.so">
24- <button id="save-control-btn" type="button">保存控制面配置</button>
25- <button id="refresh-control-btn" type="button">刷新控制面</button>
26+ <section class="actions">
27 <button id="pause-btn" type="button">暂停</button>
28 <button id="resume-btn" type="button">恢复</button>
29 <button id="drain-btn" type="button">排空</button>
30@@ -37,87 +24,30 @@
31
32 <section class="grid">
33 <article class="card">
34- <p class="label">控制面连接</p>
35- <p id="control-mode" class="value off">未知</p>
36- <p id="control-meta" class="meta">未同步</p>
37- </article>
38-
39- <article class="card">
40- <p class="label">主控</p>
41- <p id="leader-value" class="value off">-</p>
42- <p id="leader-meta" class="meta">租约: -</p>
43- </article>
44-
45- <article class="card">
46- <p class="label">队列</p>
47- <p id="queue-value" class="value off">-</p>
48- <p id="queue-meta" class="meta">排队任务</p>
49- </article>
50-
51- <article class="card">
52- <p class="label">活动运行</p>
53- <p id="runs-value" class="value off">-</p>
54- <p id="runs-meta" class="meta">控制面</p>
55- </article>
56-
57- <article class="card">
58- <p class="label">可选 WS</p>
59- <p id="ws-status" class="value off">未启用</p>
60- <p id="client-id" class="meta">当前默认仅使用 Control API</p>
61- </article>
62-
63- <article class="card">
64- <p class="label">跟踪标签页</p>
65- <p id="tab-status" class="value off">0 / 3</p>
66- <p id="tab-meta" class="meta">标签页: -</p>
67- </article>
68-
69- <article class="card">
70- <p class="label">凭证</p>
71- <p id="cred-status" class="value off">0 / 3</p>
72- <p id="cred-meta" class="meta">请求头: 0</p>
73+ <p class="label">本地 WS</p>
74+ <p id="ws-status" class="value off">连接中</p>
75+ <p id="ws-meta" class="meta">等待握手</p>
76 </article>
77
78 <article class="card">
79- <p class="label">端点</p>
80- <p id="endpoint-count" class="value">0</p>
81- <p class="meta">自动探测</p>
82+ <p class="label">远程 HTTP API</p>
83+ <p id="control-mode" class="value off">连接中</p>
84+ <p id="control-meta" class="meta">等待同步</p>
85 </article>
86 </section>
87
88 <section class="panel">
89 <div class="panel-head">
90- <h2>控制面</h2>
91- </div>
92- <pre id="control-view" class="code"></pre>
93- </section>
94-
95- <section class="panel">
96- <div class="panel-head">
97- <h2>平台状态</h2>
98- </div>
99- <pre id="platforms-view" class="code"></pre>
100- </section>
101-
102- <section class="panel">
103- <div class="panel-head">
104- <h2>最新请求头</h2>
105+ <h2>WS 状态</h2>
106 </div>
107- <pre id="headers-view" class="code"></pre>
108+ <pre id="ws-view" class="code"></pre>
109 </section>
110
111 <section class="panel">
112 <div class="panel-head">
113- <h2>已发现端点</h2>
114+ <h2>HTTP 状态</h2>
115 </div>
116- <pre id="endpoints-view" class="code"></pre>
117- </section>
118-
119- <section class="panel">
120- <div class="panel-head">
121- <h2>日志</h2>
122- </div>
123- <pre id="log-view" class="code"></pre>
124+ <pre id="control-view" class="code"></pre>
125 </section>
126 </main>
127
+333,
-204
1@@ -20,13 +20,10 @@ const CONTROLLER_STORAGE_KEYS = {
2 geminiSendTemplate: "baaFirefox.geminiSendTemplate"
3 };
4
5-const DEFAULT_WS_URL = "";
6-const DEFAULT_CONTROL_BASE_URL = "https://control-api.makefile.so";
7-const LEGACY_DEFAULT_WS_URLS = new Set([
8- "ws://127.0.0.1:9800",
9- "ws://localhost:9800"
10-]);
11-const STATUS_SCHEMA_VERSION = 2;
12+const DEFAULT_LOCAL_API_BASE = "http://127.0.0.1:4317";
13+const DEFAULT_WS_URL = "ws://127.0.0.1:4317/ws/firefox";
14+const DEFAULT_CONTROL_BASE_URL = "https://conductor.makefile.so";
15+const STATUS_SCHEMA_VERSION = 3;
16 const CREDENTIAL_SEND_INTERVAL = 30_000;
17 const CREDENTIAL_TTL = 15 * 60_000;
18 const NETWORK_BODY_LIMIT = 5000;
19@@ -39,6 +36,7 @@ const CONTROL_RETRY_SLOW_INTERVAL = 30_000;
20 const CONTROL_RETRY_LOG_INTERVAL = 60_000;
21 const TRACKED_TAB_REFRESH_DELAY = 150;
22 const CONTROL_STATUS_BODY_LIMIT = 12_000;
23+const WS_RECONNECT_DELAY = 3_000;
24 const CHATGPT_SESSION_COOKIE_PATTERNS = [
25 /__secure-next-auth\.session-token=/i,
26 /__secure-authjs\.session-token=/i,
27@@ -156,6 +154,7 @@ const state = {
28 wsUrl: DEFAULT_WS_URL,
29 controlBaseUrl: DEFAULT_CONTROL_BASE_URL,
30 controlState: null,
31+ wsState: null,
32 ws: null,
33 wsConnected: false,
34 reconnectTimer: null,
35@@ -193,52 +192,18 @@ function trimTrailingSlash(value) {
36 }
37
38 function normalizeSavedControlBaseUrl(value) {
39- const normalized = trimTrailingSlash(value);
40-
41- if (
42- !normalized ||
43- normalized === "http://127.0.0.1:9800" ||
44- normalized === "http://localhost:9800"
45- ) {
46- return DEFAULT_CONTROL_BASE_URL;
47- }
48-
49- return normalized;
50+ void value;
51+ return DEFAULT_CONTROL_BASE_URL;
52 }
53
54 function normalizeSavedWsUrl(value) {
55- const normalized = trimTrailingSlash(value);
56-
57- if (!normalized || LEGACY_DEFAULT_WS_URLS.has(normalized)) {
58- return DEFAULT_WS_URL;
59- }
60-
61- try {
62- const parsed = new URL(normalized);
63- return parsed.protocol === "ws:" || parsed.protocol === "wss:"
64- ? normalized
65- : DEFAULT_WS_URL;
66- } catch (_) {
67- return DEFAULT_WS_URL;
68- }
69+ void value;
70+ return DEFAULT_WS_URL;
71 }
72
73 function deriveControlBaseUrl(wsUrl) {
74- const normalizedWsUrl = normalizeSavedWsUrl(wsUrl);
75- if (!normalizedWsUrl) {
76- return DEFAULT_CONTROL_BASE_URL;
77- }
78-
79- try {
80- const parsed = new URL(normalizedWsUrl);
81- parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:";
82- const derived = trimTrailingSlash(parsed.toString());
83- return derived === "http://127.0.0.1:9800" || derived === "http://localhost:9800"
84- ? DEFAULT_CONTROL_BASE_URL
85- : derived;
86- } catch (_) {
87- return DEFAULT_CONTROL_BASE_URL;
88- }
89+ void wsUrl;
90+ return DEFAULT_CONTROL_BASE_URL;
91 }
92
93 function createDefaultControlState(overrides = {}) {
94@@ -272,6 +237,41 @@ function cloneControlState(value) {
95 };
96 }
97
98+function createDefaultWsState(overrides = {}) {
99+ return {
100+ connection: "disconnected",
101+ wsUrl: DEFAULT_WS_URL,
102+ localApiBase: DEFAULT_LOCAL_API_BASE,
103+ clientId: null,
104+ protocol: null,
105+ version: null,
106+ serverIdentity: null,
107+ serverHost: null,
108+ serverRole: null,
109+ leaseState: null,
110+ clientCount: 0,
111+ retryCount: 0,
112+ nextRetryAt: 0,
113+ lastOpenAt: 0,
114+ lastMessageAt: 0,
115+ lastSnapshotAt: 0,
116+ lastCloseCode: null,
117+ lastCloseReason: null,
118+ lastError: null,
119+ lastSnapshotReason: null,
120+ raw: null,
121+ ...overrides
122+ };
123+}
124+
125+function cloneWsState(value) {
126+ if (!isRecord(value)) return createDefaultWsState();
127+ return {
128+ ...createDefaultWsState(),
129+ ...value
130+ };
131+}
132+
133 function createPlatformMap(factory) {
134 const out = {};
135 for (const platform of PLATFORM_ORDER) {
136@@ -484,7 +484,96 @@ function formatRetryDelay(targetTime) {
137 }
138
139 function isWsEnabled() {
140- return !!state.wsUrl;
141+ return true;
142+}
143+
144+function normalizeWsConnection(value) {
145+ switch (String(value || "").trim().toLowerCase()) {
146+ case "connecting":
147+ case "connected":
148+ case "retrying":
149+ case "disconnected":
150+ return String(value || "").trim().toLowerCase();
151+ default:
152+ return "disconnected";
153+ }
154+}
155+
156+function formatWsConnectionLabel(snapshot) {
157+ switch (normalizeWsConnection(snapshot?.connection)) {
158+ case "connecting":
159+ return "连接中";
160+ case "connected":
161+ return "已连接";
162+ case "retrying":
163+ return "重连中";
164+ default:
165+ return "已断开";
166+ }
167+}
168+
169+function wsConnectionClass(snapshot) {
170+ switch (normalizeWsConnection(snapshot?.connection)) {
171+ case "connected":
172+ return "on";
173+ case "connecting":
174+ case "retrying":
175+ return "warn";
176+ default:
177+ return "off";
178+ }
179+}
180+
181+function formatWsMeta(snapshot) {
182+ const parts = [];
183+
184+ if (snapshot.serverIdentity) {
185+ parts.push(`服务端: ${snapshot.serverIdentity}`);
186+ } else if (snapshot.connection === "connecting") {
187+ parts.push("等待本地 bridge 握手");
188+ }
189+
190+ if (snapshot.lastSnapshotAt > 0) {
191+ parts.push(`最近快照: ${formatSyncTime(snapshot.lastSnapshotAt)}`);
192+ }
193+
194+ if (snapshot.connection === "retrying" && snapshot.nextRetryAt > 0) {
195+ parts.push(`下次重连: ${formatRetryDelay(snapshot.nextRetryAt)}`);
196+ }
197+
198+ if (snapshot.lastError) {
199+ parts.push(`错误: ${snapshot.lastError}`);
200+ }
201+
202+ return parts.length > 0 ? parts.join(" · ") : "等待本地 bridge 握手";
203+}
204+
205+function renderWsSnapshot() {
206+ const snapshot = cloneWsState(state.wsState);
207+
208+ return JSON.stringify({
209+ connection: snapshot.connection,
210+ wsUrl: snapshot.wsUrl,
211+ localApiBase: snapshot.localApiBase,
212+ clientId: snapshot.clientId,
213+ protocol: snapshot.protocol,
214+ version: snapshot.version,
215+ serverIdentity: snapshot.serverIdentity,
216+ serverHost: snapshot.serverHost,
217+ serverRole: snapshot.serverRole,
218+ leaseState: snapshot.leaseState,
219+ clientCount: snapshot.clientCount,
220+ retryCount: snapshot.retryCount,
221+ nextRetryAt: snapshot.nextRetryAt ? new Date(snapshot.nextRetryAt).toISOString() : null,
222+ lastOpenAt: snapshot.lastOpenAt ? new Date(snapshot.lastOpenAt).toISOString() : null,
223+ lastMessageAt: snapshot.lastMessageAt ? new Date(snapshot.lastMessageAt).toISOString() : null,
224+ lastSnapshotAt: snapshot.lastSnapshotAt ? new Date(snapshot.lastSnapshotAt).toISOString() : null,
225+ lastSnapshotReason: snapshot.lastSnapshotReason,
226+ lastCloseCode: snapshot.lastCloseCode,
227+ lastCloseReason: snapshot.lastCloseReason,
228+ lastError: snapshot.lastError,
229+ raw: snapshot.raw
230+ }, null, 2);
231 }
232
233 function normalizeControlConnection(value) {
234@@ -1198,6 +1287,7 @@ function renderControlSnapshot() {
235 const snapshot = cloneControlState(state.controlState);
236
237 return JSON.stringify({
238+ baseUrl: state.controlBaseUrl,
239 ok: snapshot.ok,
240 controlConnection: snapshot.controlConnection,
241 retryCount: snapshot.retryCount,
242@@ -1219,57 +1309,28 @@ function renderControlSnapshot() {
243 }
244
245 function render() {
246- const trackedCount = getTrackedCount();
247- const credentialCount = getCredentialCount();
248- const totalEndpointCount = getTotalEndpointCount();
249+ const wsSnapshot = cloneWsState(state.wsState);
250 const controlSnapshot = cloneControlState(state.controlState);
251- const wsEnabled = isWsEnabled();
252-
253- ui.wsStatus.textContent = !wsEnabled ? "未启用" : state.wsConnected ? "已连接" : "未连接";
254- ui.wsStatus.className = `value ${!wsEnabled ? "off" : state.wsConnected ? "on" : "warn"}`;
255-
256- ui.tabStatus.textContent = `${trackedCount} / ${PLATFORM_ORDER.length}`;
257- ui.tabStatus.className = `value ${trackedCount === 0 ? "off" : trackedCount === PLATFORM_ORDER.length ? "on" : "warn"}`;
258- ui.tabMeta.textContent = trackedCount > 0
259- ? PLATFORM_ORDER
260- .filter((platform) => Number.isInteger(state.trackedTabs[platform]))
261- .map((platform) => `${platformLabel(platform)}:${state.trackedTabs[platform]}`)
262- .join(" | ")
263- : "标签页: -";
264-
265- ui.credStatus.textContent = `${credentialCount} / ${PLATFORM_ORDER.length}`;
266- ui.credStatus.className = `value ${credentialCount === 0 ? "off" : credentialCount === PLATFORM_ORDER.length ? "on" : "warn"}`;
267- ui.credMeta.textContent = `有效请求头: ${PLATFORM_ORDER.reduce((sum, platform) => {
268- const credential = getCredentialState(platform);
269- return sum + (credential.valid ? credential.headerCount : 0);
270- }, 0)}`;
271-
272- ui.endpointCount.textContent = String(totalEndpointCount);
273- ui.endpointCount.className = `value ${totalEndpointCount > 0 ? "on" : "off"}`;
274- ui.clientId.textContent = !wsEnabled
275- ? "可选能力;当前默认仅使用 Control API"
276- : `客户端: ${state.clientId || "-"}${state.wsConnected ? "" : " · 等待连接"}`;
277- ui.controlMode.textContent = formatControlConnectionLabel(controlSnapshot);
278- ui.controlMode.className = `value ${controlConnectionClass(controlSnapshot)}`;
279- ui.controlMeta.textContent = formatControlMeta(controlSnapshot);
280- ui.leaderValue.textContent = controlSnapshot.leader || "-";
281- ui.leaderValue.className = `value ${controlSnapshot.leader ? "on" : "off"}`;
282- ui.leaderMeta.textContent = `租约: ${controlSnapshot.leaseHolder || "-"}`;
283- ui.queueValue.textContent = controlSnapshot.queueDepth == null ? "-" : String(controlSnapshot.queueDepth);
284- ui.queueValue.className = `value ${controlSnapshot.queueDepth > 0 ? "warn" : controlSnapshot.queueDepth === 0 ? "on" : "off"}`;
285- ui.queueMeta.textContent = "排队任务";
286- ui.runsValue.textContent = controlSnapshot.activeRuns == null ? "-" : String(controlSnapshot.activeRuns);
287- ui.runsValue.className = `value ${controlSnapshot.activeRuns > 0 ? "warn" : controlSnapshot.activeRuns === 0 ? "on" : "off"}`;
288- ui.runsMeta.textContent = `自动化模式: ${formatModeLabel(controlSnapshot.mode)}`;
289- ui.platformsView.textContent = renderPlatformStatus();
290- ui.controlView.textContent = renderControlSnapshot();
291- ui.headersView.textContent = renderHeaderSnapshot();
292- ui.endpointsView.textContent = renderEndpointSnapshot();
293- ui.logView.textContent = state.logs.length > 0
294- ? state.logs.join("\n")
295- : "还没有日志。";
296- if (ui.reconnectBtn) {
297- ui.reconnectBtn.textContent = wsEnabled ? "重连可选 WS" : "启用可选 WS";
298+
299+ if (ui.wsStatus) {
300+ ui.wsStatus.textContent = formatWsConnectionLabel(wsSnapshot);
301+ ui.wsStatus.className = `value ${wsConnectionClass(wsSnapshot)}`;
302+ }
303+ if (ui.wsMeta) {
304+ ui.wsMeta.textContent = formatWsMeta(wsSnapshot);
305+ }
306+ if (ui.controlMode) {
307+ ui.controlMode.textContent = formatControlConnectionLabel(controlSnapshot);
308+ ui.controlMode.className = `value ${controlConnectionClass(controlSnapshot)}`;
309+ }
310+ if (ui.controlMeta) {
311+ ui.controlMeta.textContent = formatControlMeta(controlSnapshot);
312+ }
313+ if (ui.wsView) {
314+ ui.wsView.textContent = renderWsSnapshot();
315+ }
316+ if (ui.controlView) {
317+ ui.controlView.textContent = renderControlSnapshot();
318 }
319 }
320
321@@ -1477,15 +1538,34 @@ async function runControlPlaneAction(action, options = {}) {
322 throw new Error(`未知控制动作:${action || "-"}`);
323 }
324
325+ const requestId = typeof crypto?.randomUUID === "function"
326+ ? crypto.randomUUID()
327+ : `req-${Date.now()}`;
328 const response = await requestControlPlane(`/v1/system/${methodName}`, {
329- method: "POST"
330+ method: "POST",
331+ body: JSON.stringify({
332+ requested_by: "browser_admin",
333+ source: "firefox_extension",
334+ reason: `human_clicked_${methodName}`,
335+ request_id: requestId
336+ })
337 });
338
339- addLog("info", `控制动作 ${methodName} 已接受(${response.statusCode})`);
340+ const nextSnapshot = createControlSuccessState(response.payload, {
341+ ok: true,
342+ statusCode: response.statusCode,
343+ source: options.source || "http_action"
344+ }, state.controlState);
345+ await setControlState(nextSnapshot);
346+ resetControlFailureLog();
347+ restartControlPlaneRefreshTimer(CONTROL_REFRESH_INTERVAL, {
348+ reason: "poll"
349+ });
350+ addLog("info", `控制动作 ${methodName} 已执行(${response.statusCode})`, false);
351
352 try {
353 await refreshControlPlaneState({
354- source: options.source || "http",
355+ source: options.source || "http_action",
356 silent: true
357 });
358 } catch (_) {}
359@@ -1694,6 +1774,64 @@ function sendCredentialSnapshot(platform = null, force = false) {
360 }
361 }
362
363+function setWsState(next) {
364+ state.wsState = cloneWsState(next);
365+ render();
366+}
367+
368+function handleWsHelloAck(message) {
369+ const previous = cloneWsState(state.wsState);
370+ const version = Number.isFinite(Number(message.version)) ? Number(message.version) : null;
371+
372+ setWsState({
373+ ...previous,
374+ connection: "connected",
375+ wsUrl: typeof message.wsUrl === "string" && message.wsUrl.trim() ? message.wsUrl.trim() : state.wsUrl,
376+ localApiBase: typeof message.localApiBase === "string" && message.localApiBase.trim()
377+ ? message.localApiBase.trim()
378+ : previous.localApiBase,
379+ clientId: typeof message.clientId === "string" && message.clientId.trim()
380+ ? message.clientId.trim()
381+ : (state.clientId || previous.clientId),
382+ protocol: typeof message.protocol === "string" && message.protocol.trim() ? message.protocol.trim() : null,
383+ version,
384+ retryCount: 0,
385+ nextRetryAt: 0,
386+ lastMessageAt: Date.now(),
387+ lastError: null,
388+ raw: truncateControlRaw(message)
389+ });
390+}
391+
392+function handleWsStateSnapshot(message) {
393+ const previous = cloneWsState(state.wsState);
394+ const snapshot = isRecord(message.snapshot) ? message.snapshot : {};
395+ const server = isRecord(snapshot.server) ? snapshot.server : {};
396+ const browserSnapshot = isRecord(snapshot.browser) ? snapshot.browser : {};
397+ const clientCount = normalizeCount(getFirstDefinedValue(browserSnapshot, ["client_count", "clients"]));
398+
399+ setWsState({
400+ ...previous,
401+ connection: "connected",
402+ wsUrl: typeof server.ws_url === "string" && server.ws_url.trim() ? server.ws_url.trim() : state.wsUrl,
403+ localApiBase: typeof server.local_api_base === "string" && server.local_api_base.trim()
404+ ? server.local_api_base.trim()
405+ : previous.localApiBase,
406+ serverIdentity: typeof server.identity === "string" && server.identity.trim() ? server.identity.trim() : null,
407+ serverHost: typeof server.host === "string" && server.host.trim() ? server.host.trim() : null,
408+ serverRole: typeof server.role === "string" && server.role.trim() ? server.role.trim() : null,
409+ leaseState: typeof server.lease_state === "string" && server.lease_state.trim() ? server.lease_state.trim() : null,
410+ clientCount: clientCount ?? previous.clientCount,
411+ retryCount: 0,
412+ nextRetryAt: 0,
413+ lastMessageAt: Date.now(),
414+ lastSnapshotAt: Date.now(),
415+ lastSnapshotReason: typeof message.reason === "string" && message.reason.trim() ? message.reason.trim() : null,
416+ lastError: null,
417+ raw: truncateControlRaw(snapshot)
418+ });
419+}
420+
421 function closeWsConnection() {
422 clearTimeout(state.reconnectTimer);
423
424@@ -1713,32 +1851,56 @@ function closeWsConnection() {
425
426 function connectWs(options = {}) {
427 const { silentWhenDisabled = false } = options;
428-
429 closeWsConnection();
430- render();
431+ setWsState({
432+ ...cloneWsState(state.wsState),
433+ connection: "connecting",
434+ wsUrl: state.wsUrl,
435+ localApiBase: DEFAULT_LOCAL_API_BASE,
436+ clientId: state.clientId,
437+ nextRetryAt: 0,
438+ lastError: null
439+ });
440
441 if (!isWsEnabled()) {
442 if (!silentWhenDisabled) {
443- addLog("info", "可选 WS 未启用;当前默认仅使用 Control API", false);
444+ addLog("info", "本地 WS 已被禁用", false);
445 }
446 return;
447 }
448
449- addLog("info", `正在连接 WS:${state.wsUrl}`, false);
450+ addLog("info", `正在连接本地 WS:${state.wsUrl}`, false);
451
452 try {
453 state.ws = new WebSocket(state.wsUrl);
454 } catch (error) {
455- addLog("error", `WS 创建失败:${error.message}`, false);
456- scheduleReconnect();
457+ const message = error instanceof Error ? error.message : String(error);
458+ setWsState({
459+ ...cloneWsState(state.wsState),
460+ connection: "disconnected",
461+ lastError: message
462+ });
463+ addLog("error", `本地 WS 创建失败:${message}`, false);
464+ scheduleReconnect(message);
465 return;
466 }
467
468 state.ws.onopen = () => {
469 state.wsConnected = true;
470- render();
471+ setWsState({
472+ ...cloneWsState(state.wsState),
473+ connection: "connected",
474+ wsUrl: state.wsUrl,
475+ localApiBase: DEFAULT_LOCAL_API_BASE,
476+ clientId: state.clientId,
477+ retryCount: 0,
478+ nextRetryAt: 0,
479+ lastOpenAt: Date.now(),
480+ lastMessageAt: Date.now(),
481+ lastError: null
482+ });
483 sendHello();
484- addLog("info", "WS 已连接");
485+ addLog("info", "本地 WS 已连接", false);
486 sendCredentialSnapshot(null, true);
487 sendEndpointSnapshot();
488 };
489@@ -1753,7 +1915,26 @@ function connectWs(options = {}) {
490
491 if (!message || typeof message !== "object") return;
492
493+ setWsState({
494+ ...cloneWsState(state.wsState),
495+ lastMessageAt: Date.now(),
496+ lastError: null
497+ });
498+
499 switch (message.type) {
500+ case "hello_ack":
501+ handleWsHelloAck(message);
502+ break;
503+ case "state_snapshot":
504+ handleWsStateSnapshot(message);
505+ break;
506+ case "action_result":
507+ if (message.ok) {
508+ addLog("info", `本地 WS 动作确认:${message.action || "-"}`, false);
509+ } else {
510+ addLog("warn", `本地 WS 动作失败:${message.message || message.error || message.action || "-"}`, false);
511+ }
512+ break;
513 case "open_tab": {
514 const targets = getTargetPlatforms(message.platform);
515 for (const target of targets) {
516@@ -1770,6 +1951,15 @@ function connectWs(options = {}) {
517 case "request_credentials":
518 sendCredentialSnapshot(message.platform || null, true);
519 break;
520+ case "error":
521+ setWsState({
522+ ...cloneWsState(state.wsState),
523+ lastMessageAt: Date.now(),
524+ lastError: String(message.message || message.code || "ws_error"),
525+ raw: truncateControlRaw(message)
526+ });
527+ addLog("error", `本地 WS 返回错误:${message.message || message.code || "未知错误"}`, false);
528+ break;
529 case "reload":
530 addLog("warn", "收到重载命令");
531 window.location.reload();
532@@ -1781,24 +1971,42 @@ function connectWs(options = {}) {
533
534 state.ws.onclose = (event) => {
535 state.wsConnected = false;
536- render();
537- addLog("warn", `WS 已关闭 code=${event.code || 0} reason=${event.reason || "-"}`, false);
538- scheduleReconnect();
539+ setWsState({
540+ ...cloneWsState(state.wsState),
541+ connection: "disconnected",
542+ lastCloseCode: event.code || 0,
543+ lastCloseReason: event.reason || null,
544+ lastError: event.reason || `closed_${event.code || 0}`
545+ });
546+ addLog("warn", `本地 WS 已关闭 code=${event.code || 0} reason=${event.reason || "-"}`, false);
547+ scheduleReconnect(event.reason || `closed_${event.code || 0}`);
548 };
549
550 state.ws.onerror = () => {
551 state.wsConnected = false;
552- render();
553- addLog("error", `WS 错误:${state.wsUrl}`, false);
554+ setWsState({
555+ ...cloneWsState(state.wsState),
556+ lastError: "连接错误"
557+ });
558+ addLog("error", `本地 WS 错误:${state.wsUrl}`, false);
559 };
560 }
561
562-function scheduleReconnect() {
563+function scheduleReconnect(reason = null) {
564 clearTimeout(state.reconnectTimer);
565 if (!isWsEnabled()) return;
566+ const previous = cloneWsState(state.wsState);
567+ const nextRetryAt = Date.now() + WS_RECONNECT_DELAY;
568+ setWsState({
569+ ...previous,
570+ connection: "retrying",
571+ retryCount: (Number(previous.retryCount) || 0) + 1,
572+ nextRetryAt,
573+ lastError: reason || previous.lastError
574+ });
575 state.reconnectTimer = setTimeout(() => {
576 connectWs({ silentWhenDisabled: true });
577- }, 3000);
578+ }, WS_RECONNECT_DELAY);
579 }
580
581 async function resolveTrackedTab(platform) {
582@@ -2279,81 +2487,11 @@ function registerTabListeners() {
583
584 function bindUi() {
585 ui.wsStatus = qs("ws-status");
586- ui.tabStatus = qs("tab-status");
587- ui.credStatus = qs("cred-status");
588- ui.endpointCount = qs("endpoint-count");
589- ui.clientId = qs("client-id");
590+ ui.wsMeta = qs("ws-meta");
591+ ui.wsView = qs("ws-view");
592 ui.controlMode = qs("control-mode");
593 ui.controlMeta = qs("control-meta");
594- ui.leaderValue = qs("leader-value");
595- ui.leaderMeta = qs("leader-meta");
596- ui.queueValue = qs("queue-value");
597- ui.queueMeta = qs("queue-meta");
598- ui.runsValue = qs("runs-value");
599- ui.runsMeta = qs("runs-meta");
600- ui.tabMeta = qs("tab-meta");
601- ui.credMeta = qs("cred-meta");
602 ui.controlView = qs("control-view");
603- ui.platformsView = qs("platforms-view");
604- ui.headersView = qs("headers-view");
605- ui.endpointsView = qs("endpoints-view");
606- ui.logView = qs("log-view");
607- ui.wsUrl = qs("ws-url");
608- ui.controlBaseUrl = qs("control-base-url");
609- ui.reconnectBtn = qs("reconnect-btn");
610-
611- for (const platform of PLATFORM_ORDER) {
612- qs(`open-${platform}-btn`).addEventListener("click", () => {
613- ensurePlatformTab(platform, { focus: true }).catch((error) => {
614- addLog("error", `打开 ${platformLabel(platform)} 失败:${error.message}`);
615- });
616- });
617- }
618-
619- ui.reconnectBtn.addEventListener("click", () => {
620- state.wsUrl = normalizeSavedWsUrl(ui.wsUrl.value);
621- ui.wsUrl.value = state.wsUrl;
622- persistState().catch(() => {});
623- connectWs({ silentWhenDisabled: false });
624- });
625-
626- qs("save-ws-btn").addEventListener("click", () => {
627- const previousWsUrl = state.wsUrl;
628- const wasConnected = state.wsConnected;
629-
630- state.wsUrl = normalizeSavedWsUrl(ui.wsUrl.value);
631- ui.wsUrl.value = state.wsUrl;
632- persistState().catch(() => {});
633-
634- if (!isWsEnabled()) {
635- closeWsConnection();
636- addLog("info", "已禁用可选 WS;当前默认仅使用 Control API", false);
637- render();
638- return;
639- }
640-
641- if (previousWsUrl !== state.wsUrl) {
642- closeWsConnection();
643- addLog("info", `已保存可选 WS 地址:${state.wsUrl};点击“重连可选 WS”开始连接`, false);
644- render();
645- return;
646- }
647-
648- addLog("info", `已保存可选 WS 地址:${state.wsUrl}${wasConnected ? "" : ";点击“重连可选 WS”开始连接"}`, false);
649- render();
650- });
651-
652- qs("save-control-btn").addEventListener("click", () => {
653- state.controlBaseUrl = normalizeSavedControlBaseUrl(ui.controlBaseUrl.value);
654- persistState().catch(() => {});
655- addLog("info", `已保存控制 API:${state.controlBaseUrl}`, false);
656- render();
657- refreshControlPlaneState({ source: "manual", silent: false }).catch(() => {});
658- });
659-
660- qs("refresh-control-btn").addEventListener("click", () => {
661- refreshControlPlaneState({ source: "manual", silent: false }).catch(() => {});
662- });
663
664 for (const action of ["pause", "resume", "drain"]) {
665 qs(`${action}-btn`).addEventListener("click", () => {
666@@ -2362,14 +2500,6 @@ function bindUi() {
667 });
668 });
669 }
670-
671- qs("focus-controller-btn").addEventListener("click", async () => {
672- const tab = await browser.tabs.getCurrent();
673- await browser.tabs.update(tab.id, { active: true });
674- if (tab.windowId != null) {
675- await browser.windows.update(tab.windowId, { focused: true });
676- }
677- });
678 }
679
680 async function init() {
681@@ -2417,9 +2547,12 @@ async function init() {
682 state.lastCredentialTabId = createPlatformMap(() => null);
683 }
684 state.lastCredentialHash = createPlatformMap((platform) => JSON.stringify(state.lastHeaders[platform]));
685-
686- ui.wsUrl.value = state.wsUrl;
687- ui.controlBaseUrl.value = state.controlBaseUrl;
688+ state.wsState = createDefaultWsState({
689+ connection: "connecting",
690+ wsUrl: state.wsUrl,
691+ localApiBase: DEFAULT_LOCAL_API_BASE,
692+ clientId: state.clientId
693+ });
694
695 registerRuntimeListeners();
696 registerTabListeners();
697@@ -2435,11 +2568,7 @@ async function init() {
698 addLog("info", "已清理旧版平台状态缓存,等待新的真实请求重新建立凭证", false);
699 }
700
701- if (isWsEnabled()) {
702- connectWs({ silentWhenDisabled: true });
703- } else {
704- addLog("info", "当前默认模式只使用 Control API;可选 WS 保持未启用", false);
705- }
706+ connectWs({ silentWhenDisabled: true });
707 await prepareStartupControlState();
708 refreshControlPlaneState({ source: "startup", silent: true }).catch(() => {});
709 }
1@@ -1,84 +1,110 @@
2 # Firefox Conductor Control
3
4-`baa-firefox` 现在默认通过 Control API 自动连接 conductor control-plane,并在 Firefox 启动后自动恢复同步。
5+`baa-firefox` 现在默认同时接两条固定链路:
6+
7+- 本地 WS bridge:`ws://127.0.0.1:4317/ws/firefox`
8+- 远程 HTTP control API:`https://conductor.makefile.so`
9+
10+管理页已经收口,只保留:
11+
12+- 本地 WS 状态
13+- 远程 HTTP 状态
14+- `Pause` / `Resume` / `Drain` 按钮
15+
16+不再允许用户手工编辑 WS 地址或 HTTP 地址。
17
18 ## 范围
19
20 - `controller.html` / `controller.js`
21- - 配置 Control API base URL
22- - 启动后立即读取 `GET /v1/system/state`
23- - 失败时自动退避重试,成功后恢复常规轮询
24+ - Firefox 启动后自动连接本地 `/ws/firefox`
25+ - 启动后立即请求 `GET /v1/system/state`
26+ - HTTP 失败时自动退避重试,成功后恢复常规轮询
27 - 调用 `POST /v1/system/pause`
28 - 调用 `POST /v1/system/resume`
29 - 调用 `POST /v1/system/drain`
30 - `background.js`
31- - Firefox 启动时确保 `controller.html` 存在
32- - 根据最新 control snapshot 更新扩展 badge
33-- `content-script.js`
34- - 在 Claude 页面右下角提供最小浮层入口
35+ - Firefox 启动时确保 `controller.html` 标签页存在
36+ - 根据最新 HTTP control snapshot 更新扩展 badge
37
38-## 配置
39+## 固定地址
40
41-控制页现在有两项控制面相关配置:
42+- Local WS: `ws://127.0.0.1:4317/ws/firefox`
43+- Remote HTTP: `https://conductor.makefile.so`
44
45-- `Control API`
46- - 默认预填 `https://control-api.makefile.so`
47-- `可选 WS`
48- - 默认留空
49- - 留空表示未启用
50- - 只有手动填写 `ws://` / `wss://` 地址后才会尝试连接
51+当前实现不再从 UI 或 storage 读取用户自定义地址;旧配置会被固定地址覆盖。
52
53-当前临时单节点模式默认不开启 Control API 鉴权,因此插件不会再要求填写 bearer token。
54-当前默认模式也不再要求本地 `ws://127.0.0.1:9800` 或 `ws://localhost:9800`。
55-如果 storage 中没有显式配置,插件仍然会默认回落到 `https://control-api.makefile.so`。
56+## 默认连接行为
57
58-状态快照会持久化到 `browser.storage.local` 的 `baaFirefox.controlState`,供:
59+- Firefox 启动时,`background.js` 会确保 `controller.html` 标签页存在。
60+- `controller.html` 启动后立即尝试连接本地 WS。
61+- WS 断开后按固定间隔自动重连;本地服务重启后,连接会自动恢复。
62+- `controller.html` 启动后也会立刻请求一次 `GET /v1/system/state`。
63+- HTTP 成功后按 `15` 秒周期继续拉取状态。
64+- HTTP 失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再切到每 `30` 秒一次的慢速重试。
65+- 服务恢复后自动回到正常已连接状态,不需要手工刷新页面。
66
67-- controller 面板渲染
68-- toolbar badge 渲染
69-- Claude 页面浮层渲染
70+## WS 侧职责
71
72-## 状态字段
73+插件会向本地 WS 发送:
74
75-插件会尽量从 `/v1/system/state` 响应中归一化这些字段:
76+- `hello`
77+- `credentials`
78+- `api_endpoints`
79+- `client_log`
80
81-- `mode`
82-- `leader`
83-- `lease_holder`
84-- `queue_depth` / `queued_tasks`
85-- `active_runs`
86-- `controlConnection`
87-- `retryCount`
88-- `lastSuccessAt`
89-- `lastFailureAt`
90-- `nextRetryAt`
91+插件会消费服务端返回的:
92
93-如果服务端实际字段名略有不同,`controller.js` 已做多路径兼容解析。
94+- `hello_ack`
95+- `state_snapshot`
96+- `request_credentials`
97+- `error`
98
99-## 默认连接行为
100+管理页中的 WS 卡片和 WS 详情面板直接展示这条本地 bridge 的连接状态、最近快照和服务端元数据。
101
102-- Firefox 启动时,`background.js` 会确保 `controller.html` 标签页存在。
103-- `controller.html` 启动后会立刻请求 `GET /v1/system/state`,不需要用户先点“刷新控制面”。
104-- 正常同步成功后,插件按 `15` 秒周期继续拉取 Control API。
105-- 如果 Control API 临时失败、服务端重启或网络抖动,插件会按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试,不会停在一次性报错状态。
106-- 服务恢复后,插件会自动回到正常已连接状态,不需要手动刷新页面或重新打开 controller。
107-
108-## 可见入口
109-
110-- 扩展工具栏 badge
111- - 已连接时显示自动化模式
112- - 自动重试时显示重试状态
113-- `controller.html`
114- - 第一张卡片主状态表示 Control API 连接状态
115- - 会显示最近成功时间、最近失败和下一次重试
116- - 保留“刷新控制面”按钮作为手动触发入口
117- - 如果未配置 WS,则显示 `未启用`,不再把它当成主故障
118-- Claude 页面
119- - 右下角 `BAA` 浮层,可直接 `Pause` / `Resume` / `Drain` 或打开 controller
120-
121-## 当前限制
122-
123-- 本次没有改 `manifest.json`
124-- 因此 Control API 需要落在当前扩展 CSP 允许的地址范围内
125-- 当前默认目标就是 `https://control-api.makefile.so`
126-- 可选 WS 能力仍然保留,但默认不启用,也不是主连接状态来源
127+## HTTP 侧职责
128+
129+HTTP 仍然是管理页里控制状态的同步来源,也是控制按钮的写入通道。
130+
131+读取:
132+
133+- `GET /v1/system/state`
134+
135+写入:
136+
137+- `POST /v1/system/pause`
138+- `POST /v1/system/resume`
139+- `POST /v1/system/drain`
140+
141+写接口会带固定请求体字段:
142+
143+```json
144+{
145+ "requested_by": "browser_admin",
146+ "source": "firefox_extension",
147+ "reason": "human_clicked_pause",
148+ "request_id": "uuid"
149+}
150+```
151+
152+## 管理页可见信息
153+
154+- WS 卡片:连接状态、最近快照、最近错误
155+- HTTP 卡片:连接状态、当前 mode、最近成功/失败、下次重试
156+- WS 详情:本地 bridge 的原始状态摘要
157+- HTTP 详情:control API 的原始状态摘要
158+
159+不再展示:
160+
161+- 地址输入框
162+- 手工重连按钮
163+- 手工刷新按钮
164+- 标签页/凭证/端点/日志面板
165+
166+## 验证建议
167+
168+1. 安装插件并启动 Firefox,确认 `controller.html` 自动打开。
169+2. 打开管理页,确认:
170+ - `本地 WS` 最终变成 `已连接`
171+ - `远程 HTTP API` 能显示 `已连接` 或自动重试中的明确状态
172+3. 停掉本地 `conductor-daemon`,确认 WS 状态进入重连中;恢复服务后确认自动回到 `已连接`。
173+4. 点击 `暂停` / `恢复` / `排空`,确认 HTTP 状态会更新 mode,且服务端状态与按钮动作一致。
+1,
-4
1@@ -21,15 +21,12 @@
2 "https://oaiusercontent.com/*",
3 "https://*.oaiusercontent.com/*",
4 "https://gemini.google.com/*",
5- "https://control-api.makefile.so/*",
6 "https://conductor.makefile.so/*",
7- "http://localhost/*",
8 "http://127.0.0.1/*",
9- "ws://localhost/*",
10 "ws://127.0.0.1/*"
11 ],
12 "content_security_policy": {
13- "extension_pages": "default-src 'self'; connect-src https://control-api.makefile.so https://conductor.makefile.so ws://localhost:9800 ws://127.0.0.1:9800 http://localhost:9800 http://127.0.0.1:9800"
14+ "extension_pages": "default-src 'self'; connect-src https://conductor.makefile.so ws://127.0.0.1:4317 http://127.0.0.1:4317"
15 },
16 "background": {
17 "scripts": [