- commit
- 6391ada
- parent
- f8e2593
- author
- im_wower
- date
- 2026-03-29 03:43:04 +0800 CST
feat: forward plugin diagnostic logs to conductor
7 files changed,
+351,
-10
+70,
-0
1@@ -53,6 +53,7 @@ interface FirefoxWebSocketServerOptions {
2 ingestLogDir?: string | null;
3 instructionIngest?: BaaLiveInstructionIngest | null;
4 now?: () => number;
5+ pluginDiagnosticLogDir?: string | null;
6 repository: ControlPlaneRepository;
7 snapshotLoader: () => ConductorRuntimeSnapshot;
8 }
9@@ -116,6 +117,25 @@ function normalizeBrowserLoginStatus(value: unknown): BrowserBridgeLoginStatus |
10 }
11 }
12
13+function normalizeDiagnosticLogLevel(value: unknown): "debug" | "error" | "info" | "warn" | null {
14+ if (typeof value !== "string") {
15+ return null;
16+ }
17+
18+ switch (value.trim().toLowerCase()) {
19+ case "debug":
20+ return "debug";
21+ case "error":
22+ return "error";
23+ case "info":
24+ return "info";
25+ case "warn":
26+ return "warn";
27+ default:
28+ return null;
29+ }
30+}
31+
32 function asRecord(value: unknown): Record<string, unknown> | null {
33 if (value === null || typeof value !== "object" || Array.isArray(value)) {
34 return null;
35@@ -990,6 +1010,7 @@ export class ConductorFirefoxWebSocketServer {
36 private readonly ingestLogDir: string | null;
37 private readonly instructionIngest: BaaLiveInstructionIngest | null;
38 private readonly now: () => number;
39+ private readonly pluginDiagnosticLogDir: string | null;
40 private readonly repository: ControlPlaneRepository;
41 private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
42 private readonly connections = new Set<FirefoxWebSocketConnection>();
43@@ -1004,6 +1025,7 @@ export class ConductorFirefoxWebSocketServer {
44 this.ingestLogDir = options.ingestLogDir ?? null;
45 this.instructionIngest = options.instructionIngest ?? null;
46 this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
47+ this.pluginDiagnosticLogDir = options.pluginDiagnosticLogDir ?? null;
48 this.repository = options.repository;
49 this.snapshotLoader = options.snapshotLoader;
50 const commandBroker = new FirefoxCommandBroker({
51@@ -1199,6 +1221,9 @@ export class ConductorFirefoxWebSocketServer {
52 return;
53 case "client_log":
54 return;
55+ case "plugin_diagnostic_log":
56+ this.handlePluginDiagnosticLog(connection, message);
57+ return;
58 case "browser.final_message":
59 await this.handleBrowserFinalMessage(connection, message);
60 return;
61@@ -1265,6 +1290,7 @@ export class ConductorFirefoxWebSocketServer {
62 "credentials",
63 "api_endpoints",
64 "client_log",
65+ "plugin_diagnostic_log",
66 "browser.final_message",
67 "api_response",
68 "stream_open",
69@@ -1702,6 +1728,50 @@ export class ConductorFirefoxWebSocketServer {
70 }
71 }
72
73+ private handlePluginDiagnosticLog(
74+ connection: FirefoxWebSocketConnection,
75+ message: Record<string, unknown>
76+ ): void {
77+ const level = normalizeDiagnosticLogLevel(message.level);
78+ const text = readFirstString(message, ["text"]);
79+
80+ if (level == null || text == null) {
81+ return;
82+ }
83+
84+ const rawTimestamp = readFirstString(message, ["ts", "timestamp"]);
85+ const parsedTimestamp = rawTimestamp == null ? Number.NaN : Date.parse(rawTimestamp);
86+ const timestamp = Number.isNaN(parsedTimestamp)
87+ ? new Date(this.getNextTimestampMilliseconds()).toISOString()
88+ : new Date(parsedTimestamp).toISOString();
89+
90+ this.writePluginDiagnosticLog({
91+ client_id: readFirstString(message, ["client_id", "clientId"]) ?? connection.getClientId(),
92+ level,
93+ text,
94+ ts: timestamp,
95+ type: "plugin_diagnostic_log"
96+ });
97+ }
98+
99+ private writePluginDiagnosticLog(entry: Record<string, unknown>): void {
100+ if (this.pluginDiagnosticLogDir == null) {
101+ return;
102+ }
103+
104+ try {
105+ const timestamp = readFirstString(entry, ["ts"]);
106+ const parsedTimestamp = timestamp == null ? Number.NaN : Date.parse(timestamp);
107+ const date = Number.isNaN(parsedTimestamp)
108+ ? new Date().toISOString().slice(0, 10)
109+ : new Date(parsedTimestamp).toISOString().slice(0, 10);
110+ const filePath = join(this.pluginDiagnosticLogDir, `${date}.jsonl`);
111+ appendFileSync(filePath, JSON.stringify(entry) + "\n");
112+ } catch (error) {
113+ console.error(`[baa-plugin-log] write failed: ${String(error)}`);
114+ }
115+ }
116+
117 private handleApiResponse(
118 connection: FirefoxWebSocketConnection,
119 message: Record<string, unknown>
+67,
-0
1@@ -5683,6 +5683,73 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
2 }
3 });
4
5+test("ConductorRuntime writes plugin diagnostic logs received over the Firefox bridge", async () => {
6+ const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-plugin-log-state-"));
7+ const logsDir = mkdtempSync(join(tmpdir(), "baa-conductor-plugin-log-output-"));
8+ const runtime = new ConductorRuntime(
9+ {
10+ nodeId: "mini-main",
11+ host: "mini",
12+ role: "primary",
13+ controlApiBase: "https://control.example.test",
14+ localApiBase: "http://127.0.0.1:0",
15+ sharedToken: "replace-me",
16+ paths: {
17+ logsDir,
18+ runsDir: "/tmp/runs",
19+ stateDir
20+ }
21+ },
22+ {
23+ autoStartLoops: false,
24+ now: () => 100
25+ }
26+ );
27+
28+ let client = null;
29+
30+ try {
31+ const snapshot = await runtime.start();
32+ client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-plugin-log");
33+
34+ client.socket.send(
35+ JSON.stringify({
36+ type: "plugin_diagnostic_log",
37+ ts: "2026-03-29T03:30:00.000Z",
38+ level: "info",
39+ text: "[PAGE] interceptor_active platform=chatgpt tab=17 / source=page-interceptor",
40+ client_id: "firefox-plugin-log"
41+ })
42+ );
43+
44+ const logPath = join(logsDir, "baa-plugin", "2026-03-29.jsonl");
45+ const entry = await waitForCondition(async () => {
46+ assert.equal(existsSync(logPath), true);
47+ const lines = readFileSync(logPath, "utf8").trim().split("\n");
48+ assert.equal(lines.length, 1);
49+ return JSON.parse(lines[0]);
50+ });
51+
52+ assert.equal(entry.type, "plugin_diagnostic_log");
53+ assert.equal(entry.ts, "2026-03-29T03:30:00.000Z");
54+ assert.equal(entry.level, "info");
55+ assert.equal(entry.text, "[PAGE] interceptor_active platform=chatgpt tab=17 / source=page-interceptor");
56+ assert.equal(entry.client_id, "firefox-plugin-log");
57+ } finally {
58+ client?.queue.stop();
59+ client?.socket.close(1000, "done");
60+ await runtime.stop();
61+ rmSync(logsDir, {
62+ force: true,
63+ recursive: true
64+ });
65+ rmSync(stateDir, {
66+ force: true,
67+ recursive: true
68+ });
69+ }
70+});
71+
72 test("ConductorRuntime persists browser metadata across disconnect and restart without leaking raw credentials", async () => {
73 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-persistence-"));
74 const createRuntime = () =>
+20,
-4
1@@ -749,7 +749,8 @@ class ConductorLocalHttpServer {
2 artifactInlineThreshold: number,
3 artifactSummaryLength: number,
4 browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {},
5- ingestLogDir: string | null = null
6+ ingestLogDir: string | null = null,
7+ pluginDiagnosticLogDir: string | null = null
8 ) {
9 this.artifactStore = artifactStore;
10 this.browserRequestPolicy = new BrowserRequestPolicyController(browserRequestPolicyOptions);
11@@ -797,6 +798,7 @@ class ConductorLocalHttpServer {
12 ingestLogDir,
13 instructionIngest,
14 now: this.now,
15+ pluginDiagnosticLogDir,
16 repository: this.repository,
17 snapshotLoader: this.snapshotLoader
18 });
19@@ -1606,13 +1608,25 @@ function resolvePathConfig(paths?: Partial<ConductorRuntimePaths>): ConductorRun
20 }
21
22 function resolveIngestLogDir(logsDir: string | null): string | null {
23+ return resolveLogSubdir(logsDir, "baa-ingest", "baa-ingest-log");
24+}
25+
26+function resolvePluginDiagnosticLogDir(logsDir: string | null): string | null {
27+ return resolveLogSubdir(logsDir, "baa-plugin", "baa-plugin-log");
28+}
29+
30+function resolveLogSubdir(
31+ logsDir: string | null,
32+ subdir: string,
33+ label: string
34+): string | null {
35 const base = logsDir ?? "logs";
36- const dir = join(base, "baa-ingest");
37+ const dir = join(base, subdir);
38
39 try {
40 mkdirSync(dir, { recursive: true });
41 } catch (error) {
42- console.error(`[baa-ingest-log] failed to create log directory ${dir}: ${String(error)}`);
43+ console.error(`[${label}] failed to create log directory ${dir}: ${String(error)}`);
44 return null;
45 }
46
47@@ -2171,6 +2185,7 @@ export class ConductorRuntime {
48 now: this.now
49 });
50 const ingestLogDir = resolveIngestLogDir(this.config.paths.logsDir);
51+ const pluginDiagnosticLogDir = resolvePluginDiagnosticLogDir(this.config.paths.logsDir);
52 this.localApiServer =
53 this.config.localApiBase == null
54 ? null
55@@ -2188,7 +2203,8 @@ export class ConductorRuntime {
56 this.config.artifactInlineThreshold,
57 this.config.artifactSummaryLength,
58 options.browserRequestPolicyOptions,
59- ingestLogDir
60+ ingestLogDir,
61+ pluginDiagnosticLogDir
62 );
63
64 // D1 sync worker — silently skipped when env vars are not set.
+31,
-0
1@@ -28,6 +28,20 @@ function trimToNull(value) {
2 return normalized === "" ? null : normalized;
3 }
4
5+function sendDiagnosticLog(eventName, detail = {}) {
6+ const normalizedEventName = trimToNull(eventName);
7+ if (!normalizedEventName) {
8+ return;
9+ }
10+
11+ sendBridgeMessage("baa_diagnostic_log", {
12+ ...(detail || {}),
13+ event: normalizedEventName,
14+ source: trimToNull(detail?.source) || "content-script",
15+ url: trimToNull(detail?.url) || location.href
16+ });
17+}
18+
19 function normalizeMode(value) {
20 const normalized = String(value || "").trim().toLowerCase();
21
22@@ -820,6 +834,17 @@ function handleProxyResponse(event) {
23 sendBridgeMessage("baa_page_proxy_response", event.detail);
24 }
25
26+function handleDiagnosticEvent(event) {
27+ const detail = event?.detail && typeof event.detail === "object" ? event.detail : {};
28+ const eventName = trimToNull(detail.event);
29+ if (!eventName) {
30+ return;
31+ }
32+
33+ try { console.log("[BAA-CS]", "diagnostic", eventName, detail.platform, detail.method, detail.url?.slice(0, 120)); } catch (_) {}
34+ sendDiagnosticLog(eventName, detail);
35+}
36+
37 function handleRuntimeMessage(message) {
38 if (!message || typeof message !== "object") return undefined;
39
40@@ -849,6 +874,7 @@ function handleRuntimeMessage(message) {
41 window.addEventListener("__baa_ready__", handlePageReady);
42 window.addEventListener("__baa_net__", handlePageNetwork);
43 window.addEventListener("__baa_sse__", handlePageSse);
44+window.addEventListener("__baa_diagnostic__", handleDiagnosticEvent);
45 window.addEventListener("__baa_proxy_response__", handleProxyResponse);
46 browser.runtime.onMessage.addListener(handleRuntimeMessage);
47
48@@ -859,12 +885,17 @@ sendBridgeMessage("baa_page_bridge_ready", {
49 url: location.href,
50 source: "content-script"
51 });
52+sendDiagnosticLog("page_bridge_ready", {
53+ source: "content-script",
54+ url: location.href
55+});
56
57 const contentScriptRuntime = {
58 dispose() {
59 window.removeEventListener("__baa_ready__", handlePageReady);
60 window.removeEventListener("__baa_net__", handlePageNetwork);
61 window.removeEventListener("__baa_sse__", handlePageSse);
62+ window.removeEventListener("__baa_diagnostic__", handleDiagnosticEvent);
63 window.removeEventListener("__baa_proxy_response__", handleProxyResponse);
64 browser.runtime.onMessage.removeListener(handleRuntimeMessage);
65 pageControlOverlayRuntime?.dispose?.();
+111,
-0
1@@ -79,6 +79,8 @@ const FORBIDDEN_PROXY_HEADER_NAMES = new Set([
2 "referer",
3 "user-agent"
4 ]);
5+const DIAGNOSTIC_LOG_DEBUG_PREFIXES = ["[FM-", "[SSE]"];
6+const DIAGNOSTIC_LOG_DEBUG_EVENT_RE = /\b(page_bridge_ready|interceptor_active|fetch_intercepted|sse_stream_start|sse_stream_done)\b/u;
7
8 function hostnameMatches(hostname, hosts) {
9 return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
10@@ -2205,6 +2207,10 @@ function addLog(level, text, sendRemote = true) {
11 if (state.logs.length > LOG_LIMIT) state.logs.shift();
12 render();
13
14+ try {
15+ sendPluginDiagnosticLog(level, text);
16+ } catch (_) {}
17+
18 if (sendRemote) {
19 wsSend({
20 type: "client_log",
21@@ -2215,6 +2221,41 @@ function addLog(level, text, sendRemote = true) {
22 }
23 }
24
25+function shouldForwardDiagnosticLog(level, text) {
26+ const normalizedLevel = trimToNull(level);
27+ const normalizedText = trimToNull(text);
28+
29+ if (!normalizedLevel || !normalizedText) {
30+ return false;
31+ }
32+
33+ switch (normalizedLevel.toLowerCase()) {
34+ case "error":
35+ case "warn":
36+ case "info":
37+ return true;
38+ case "debug":
39+ return DIAGNOSTIC_LOG_DEBUG_PREFIXES.some((prefix) => normalizedText.startsWith(prefix))
40+ || DIAGNOSTIC_LOG_DEBUG_EVENT_RE.test(normalizedText);
41+ default:
42+ return false;
43+ }
44+}
45+
46+function sendPluginDiagnosticLog(level, text) {
47+ if (!shouldForwardDiagnosticLog(level, text)) {
48+ return false;
49+ }
50+
51+ return wsSend({
52+ type: "plugin_diagnostic_log",
53+ ts: new Date().toISOString(),
54+ level,
55+ text,
56+ client_id: trimToNull(state.clientId)
57+ });
58+}
59+
60 function normalizePath(url) {
61 try {
62 const parsed = new URL(url);
63@@ -5600,6 +5641,73 @@ function getObservedPagePlatform(sender, fallbackPlatform = null) {
64 return detectPlatformFromUrl(senderUrl) || fallbackPlatform || null;
65 }
66
67+function buildPageDiagnosticLogText(data, sender, context = null) {
68+ const eventName = trimToNull(data?.event);
69+
70+ if (!eventName) {
71+ return null;
72+ }
73+
74+ const platform = context?.platform
75+ || getObservedPagePlatform(sender, trimToNull(data?.platform) || null)
76+ || "unknown";
77+ const tabId = Number.isInteger(context?.tabId)
78+ ? context.tabId
79+ : (Number.isInteger(sender?.tab?.id) ? sender.tab.id : null);
80+ const method = trimToNull(data?.method);
81+ const source = trimToNull(data?.source) || "page";
82+ const url = trimToNull(data?.url) || context?.senderUrl || sender?.tab?.url || "";
83+ const parts = [`[PAGE] ${eventName}`, `platform=${platform}`];
84+
85+ if (tabId != null) {
86+ parts.push(`tab=${tabId}`);
87+ }
88+
89+ if (method) {
90+ parts.push(method);
91+ }
92+
93+ if (url) {
94+ parts.push(normalizePath(url));
95+ }
96+
97+ parts.push(`source=${source}`);
98+
99+ if (eventName === "sse_stream_done") {
100+ const duration = Number.isFinite(data?.duration) ? Math.max(0, Math.round(data.duration)) : null;
101+ parts.push(`duration=${duration == null ? "-" : duration}ms`);
102+ }
103+
104+ switch (eventName) {
105+ case "page_bridge_ready":
106+ case "interceptor_active":
107+ case "fetch_intercepted":
108+ case "sse_stream_start":
109+ case "sse_stream_done":
110+ return parts.join(" ");
111+ default:
112+ return null;
113+ }
114+}
115+
116+function handlePageDiagnosticLog(data, sender) {
117+ const senderUrl = sender?.tab?.url || data?.url || "";
118+ const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || trimToNull(data?.platform) || null);
119+
120+ if (context) {
121+ syncPageControlFromContext(context, {
122+ conversationId: extractObservedConversationId(context.platform, data, context)
123+ });
124+ }
125+
126+ const text = buildPageDiagnosticLogText(data, sender, context);
127+ if (!text) {
128+ return;
129+ }
130+
131+ addLog("debug", text, false);
132+}
133+
134 function getObservedPageConversationId(context, pageControl) {
135 return trimToNull(context?.conversationId) || trimToNull(pageControl?.conversationId) || null;
136 }
137@@ -6885,6 +6993,9 @@ function registerRuntimeListeners() {
138 case "baa_page_sse":
139 handlePageSse(message.data, sender);
140 break;
141+ case "baa_diagnostic_log":
142+ handlePageDiagnosticLog(message.data, sender);
143+ break;
144 case "baa_page_proxy_response":
145 handlePageProxyResponse(message.data, sender);
146 break;
+30,
-2
1@@ -191,6 +191,13 @@
2 emit("__baa_sse__", detail, rule);
3 }
4
5+ function emitDiagnostic(event, detail = {}, rule = pageRule) {
6+ emit("__baa_diagnostic__", {
7+ event,
8+ ...detail
9+ }, rule);
10+ }
11+
12 function isForbiddenProxyHeader(name) {
13 const lower = String(name || "").toLowerCase();
14 return lower === "accept-encoding"
15@@ -211,6 +218,10 @@
16 }, pageRule);
17
18 try { console.log("[BAA]", "interceptor_active", pageRule.platform, location.href.slice(0, 120)); } catch (_) {}
19+ emitDiagnostic("interceptor_active", {
20+ source: "page-interceptor",
21+ url: location.href
22+ }, pageRule);
23
24 function trimBodyValue(body) {
25 try {
26@@ -255,6 +266,11 @@
27
28 async function streamSse(url, method, requestBody, response, startedAt, rule) {
29 try { console.log("[BAA]", "sse_stream_start", method, url.slice(0, 120)); } catch (_) {}
30+ emitDiagnostic("sse_stream_start", {
31+ method,
32+ source: "page-interceptor",
33+ url
34+ }, rule);
35 try {
36 const clone = response.clone();
37 if (!clone.body) return;
38@@ -293,14 +309,21 @@
39 }, rule);
40 }
41
42- try { console.log("[BAA]", "sse_stream_done", method, url.slice(0, 120), "duration=" + (Date.now() - startedAt) + "ms"); } catch (_) {}
43+ const duration = Date.now() - startedAt;
44+ try { console.log("[BAA]", "sse_stream_done", method, url.slice(0, 120), "duration=" + duration + "ms"); } catch (_) {}
45+ emitDiagnostic("sse_stream_done", {
46+ duration,
47+ method,
48+ source: "page-interceptor",
49+ url
50+ }, rule);
51 emitSse({
52 url,
53 method,
54 reqBody: requestBody,
55 done: true,
56 ts: Date.now(),
57- duration: Date.now() - startedAt
58+ duration
59 }, rule);
60 } catch (error) {
61 emitSse({
62@@ -656,6 +679,11 @@
63 const startedAt = Date.now();
64 const reqHeaders = readHeaders(init && init.headers ? init.headers : (input instanceof Request ? input.headers : null));
65 const reqBody = await readRequestBody(input, init);
66+ emitDiagnostic("fetch_intercepted", {
67+ method,
68+ source: "page-interceptor",
69+ url
70+ }, context.rule);
71
72 try {
73 const response = await originalFetch.apply(this, arguments);
+22,
-4
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`S`
8 - 依赖任务:`T-S053`(已完成)
9 - 建议执行者:`Claude`(需要理解插件 WS 协议和 conductor 日志写入)
10@@ -141,21 +141,39 @@ content-script.js 中的 `[BAA-CS]` 日志和 page-interceptor.js 中的 `[BAA]`
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:Codex
17+- 开始时间:2026-03-29
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:2026-03-29
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - `plugins/baa-firefox/controller.js` — `addLog` 增加 `plugin_diagnostic_log` 转发,筛选 debug 级别关键事件,新增 `baa_diagnostic_log` 处理与页面诊断文本格式化
27+ - `plugins/baa-firefox/content-script.js` — 新增 `baa_diagnostic_log` runtime 消息发送,桥接 `__baa_diagnostic__`,并在 content-script 就绪时上报 `page_bridge_ready`
28+ - `plugins/baa-firefox/page-interceptor.js` — 新增 `__baa_diagnostic__` CustomEvent,转发 `interceptor_active`、`fetch_intercepted`、`sse_stream_start`、`sse_stream_done`
29+ - `apps/conductor-daemon/src/firefox-ws.ts` — 接收 `plugin_diagnostic_log`,按消息时间写入 `logs/baa-plugin/YYYY-MM-DD.jsonl`
30+ - `apps/conductor-daemon/src/index.ts` — conductor 启动时创建 `logs/baa-plugin` 目录并注入 Firefox WS server
31+ - `apps/conductor-daemon/src/index.test.js` — 增加 WS 诊断日志落盘测试
32 - 核心实现思路:
33+ - 插件端采用 `page-interceptor -> content-script -> controller -> WS` 的桥接链路,把 MAIN world 里的关键诊断事件带回后台页
34+ - controller 保持现有内存日志,同时在 `addLog` 内按级别筛选并发送 `plugin_diagnostic_log`,debug 只放行 final-message、SSE 生命周期和页面桥接关键事件
35+ - conductor 端把 `plugin_diagnostic_log` 规范化后按天追加写入 JSONL;写失败只打 stderr,不影响主流程
36 - 跑了哪些测试:
37+ - `pnpm install`
38+ - `pnpm build`
39+ - `pnpm -C apps/conductor-daemon test`
40+ - `node --check plugins/baa-firefox/controller.js`
41+ - `node --check plugins/baa-firefox/content-script.js`
42+ - `node --check plugins/baa-firefox/page-interceptor.js`
43
44 ### 执行过程中遇到的问题
45
46 > 记录执行过程中遇到的阻塞、环境问题、临时绕过方案等。合并时由合并者判断是否需要修复或建新任务。
47
48+- worktree 初始未安装依赖,`pnpm build` 时 `pnpm exec tsc` 报 `tsc not found`;执行 `pnpm install` 后恢复正常。
49+
50 ### 剩余风险
51
52+- 自动化验证覆盖了 WS 落盘与 daemon 行为,但插件重载后在真实 Firefox + ChatGPT 页面上的手动验证仍需按任务文档走一遍,确认 `interceptor_active` / `sse_stream_*` / final-message 相关事件都能稳定落盘。