ppxans-harness 2.4.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +218 -265
  3. package/bin/ppx-channels.js +2 -2
  4. package/bin/ppx-serve.js +5 -5
  5. package/bin/ppx-setup.js +124 -0
  6. package/bin/ppx-web.js +140 -0
  7. package/bin/ppx.js +2 -2
  8. package/config/identity.md +6 -6
  9. package/config/ishiki.md +16 -16
  10. package/config/ppx.json +15 -151
  11. package/config/ppx.json.example +143 -0
  12. package/package.json +17 -10
  13. package/skills/.usage.json +6 -0
  14. package/skills/agent-professional-training/SKILL.md +94 -0
  15. package/skills/brainstorm/SKILL.md +24 -0
  16. package/skills/cupid-lover-comms/SKILL.md +37 -0
  17. package/skills/debug/SKILL.md +26 -0
  18. package/skills/plan/SKILL.md +25 -0
  19. package/skills/ponytail/SKILL.md +25 -0
  20. package/skills/ppx-memory/SKILL.md +91 -0
  21. package/skills/ppx-memory/scripts/cli.js +192 -0
  22. package/skills/ppx-memory/scripts/experience.js +133 -0
  23. package/skills/ppx-memory/scripts/fact-store.js +842 -0
  24. package/skills/ppx-memory/scripts/l0.js +52 -0
  25. package/skills/ppx-memory/scripts/l2.js +146 -0
  26. package/skills/ppx-memory/scripts/l3.js +112 -0
  27. package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
  28. package/skills/ppx-memory/scripts/pii.js +42 -0
  29. package/skills/ppx-memory/scripts/schema.js +80 -0
  30. package/skills/ppx-memory/scripts/session.js +398 -0
  31. package/skills/ppx-memory/scripts/similarity.js +43 -0
  32. package/skills/ppx-memory/scripts/store.js +116 -0
  33. package/skills/ppx-memory/scripts/wal.js +38 -0
  34. package/skills/ppx-selfheal/SKILL.md +24 -0
  35. package/skills/ppx-selfheal/scripts/cli.js +80 -0
  36. package/skills/ppx-selfheal/scripts/healer.js +184 -0
  37. package/skills/ppx-selfheal/scripts/logger.js +17 -0
  38. package/skills/ppx-selfheal/scripts/store.js +116 -0
  39. package/skills/prompt-depth-kit/SKILL.md +28 -0
  40. package/skills/session-naming/SKILL.md +36 -0
  41. package/skills/verify/SKILL.md +25 -0
  42. package/src/agent/index.js +1340 -717
  43. package/src/agent/prompts.js +47 -3
  44. package/src/aml-server.js +197 -151
  45. package/src/ans/eviction.js +123 -143
  46. package/src/ans/guard.js +159 -120
  47. package/src/ans/lifecycle.js +96 -93
  48. package/src/ans/proactive.js +112 -129
  49. package/src/ans/reward.js +95 -111
  50. package/src/ans/values.js +15 -15
  51. package/src/audit/audit-chain.js +43 -7
  52. package/src/audit/verifier.js +157 -120
  53. package/src/bus/circuit-breaker.js +9 -1
  54. package/src/bus/runtime-bus.js +107 -93
  55. package/src/channels/base.js +57 -34
  56. package/src/channels/feishu.js +118 -126
  57. package/src/channels/http.js +1090 -592
  58. package/src/channels/index.js +111 -110
  59. package/src/channels/log.js +29 -29
  60. package/src/channels/wechat-crypto.js +73 -74
  61. package/src/channels/wechat.js +191 -197
  62. package/src/channels/workspace.js +94 -0
  63. package/src/channels-cli.js +126 -124
  64. package/src/cli.js +129 -120
  65. package/src/commands/index.js +142 -0
  66. package/src/config/channels.js +137 -170
  67. package/src/config/index.js +277 -224
  68. package/src/config/placeholder.js +31 -0
  69. package/src/config/providers.js +148 -188
  70. package/src/config/settings.js +156 -182
  71. package/src/core/policy.js +69 -18
  72. package/src/core/trace.js +8 -6
  73. package/src/edit/editblock.js +266 -0
  74. package/src/edit/snapshot.js +67 -0
  75. package/src/evidence/index.js +153 -0
  76. package/src/evolve/playbook.js +9 -10
  77. package/src/hooks/index.js +113 -0
  78. package/src/llm/client.js +188 -446
  79. package/src/llm/dsml.js +74 -74
  80. package/src/llm/embedder.js +41 -35
  81. package/src/llm/fence.js +52 -105
  82. package/src/llm/index.js +4 -4
  83. package/src/llm/local-embedder.js +94 -0
  84. package/src/llm/presets.js +113 -0
  85. package/src/llm/pricing.js +93 -0
  86. package/src/llm/retry.js +73 -73
  87. package/src/llm/router.js +92 -97
  88. package/src/mcp/admin.js +326 -0
  89. package/src/mcp/client.js +487 -375
  90. package/src/mcp/http.js +203 -0
  91. package/src/mcp/index.js +116 -116
  92. package/src/mcp/server.js +392 -0
  93. package/src/mcp/tasks.js +133 -0
  94. package/src/memory/asset-hub.js +6 -11
  95. package/src/memory/canvas.js +2 -5
  96. package/src/memory/compaction.js +28 -28
  97. package/src/memory/experience.js +133 -122
  98. package/src/memory/fact-store.js +914 -698
  99. package/src/memory/failure-episode.js +20 -11
  100. package/src/memory/fork.js +17 -8
  101. package/src/memory/index.js +8 -6
  102. package/src/memory/l0.js +53 -52
  103. package/src/memory/l2.js +145 -130
  104. package/src/memory/l3.js +111 -111
  105. package/src/memory/legion-board.js +71 -0
  106. package/src/memory/memory-ticker.js +239 -240
  107. package/src/memory/session.js +398 -397
  108. package/src/memory/sqlite-store.js +581 -0
  109. package/src/mode/blackboard.js +49 -49
  110. package/src/mode/graph.js +42 -41
  111. package/src/mode/index.js +64 -64
  112. package/src/mode/legion.js +54 -51
  113. package/src/mode/plan-exec.js +50 -50
  114. package/src/mode/router.js +28 -40
  115. package/src/orchestrator/agent-worker.js +69 -69
  116. package/src/orchestrator/dag.js +90 -83
  117. package/src/orchestrator/experts.js +76 -0
  118. package/src/orchestrator/index.js +1 -1
  119. package/src/orchestrator/legion.js +179 -187
  120. package/src/orchestrator/supervisor.js +6 -8
  121. package/src/permissions/index.js +378 -0
  122. package/src/persona/index.js +28 -29
  123. package/src/plugin/builtin.js +313 -212
  124. package/src/plugin/context.js +80 -79
  125. package/src/plugin/index.js +62 -62
  126. package/src/plugin/v3.js +73 -0
  127. package/src/protocol/index.js +148 -0
  128. package/src/repomap/index.js +309 -0
  129. package/src/review/index.js +393 -0
  130. package/src/seam/registry.js +3 -0
  131. package/src/seam/shell.js +55 -55
  132. package/src/security/injection.js +79 -0
  133. package/src/selfheal/evolve.js +67 -67
  134. package/src/selfheal/healer.js +184 -167
  135. package/src/selfheal/run.js +9 -9
  136. package/src/server.js +63 -60
  137. package/src/services/diagnose.js +180 -0
  138. package/src/services/learning-service.js +9 -0
  139. package/src/services/memory-health.js +34 -6
  140. package/src/services/memory-service.js +42 -9
  141. package/src/services/triage.js +138 -0
  142. package/src/session/parts.js +76 -0
  143. package/src/session/projection.js +73 -0
  144. package/src/session/rollout.js +54 -0
  145. package/src/session/turn.js +137 -0
  146. package/src/skills/lint.js +72 -0
  147. package/src/skills/loader.js +231 -150
  148. package/src/skills/search.js +58 -0
  149. package/src/skills/verify.js +95 -100
  150. package/src/tools/advanced.js +388 -352
  151. package/src/tools/builtin.js +384 -297
  152. package/src/tools/catalog.js +283 -159
  153. package/src/tools/command-guard.js +112 -112
  154. package/src/tools/custom.js +47 -47
  155. package/src/tools/delegate.js +383 -297
  156. package/src/tools/document.js +254 -253
  157. package/src/tools/git.js +151 -0
  158. package/src/tools/governance.js +47 -20
  159. package/src/tools/index.js +16 -11
  160. package/src/tools/methods.js +178 -178
  161. package/src/tools/ocr.js +59 -59
  162. package/src/tools/sandbox-worker.js +40 -0
  163. package/src/tools/sandbox.js +92 -0
  164. package/src/tools/seam.js +162 -125
  165. package/src/tools/selfmod.js +196 -176
  166. package/src/tools/v3.js +225 -0
  167. package/src/tools/vad.js +176 -0
  168. package/src/tools/voice.js +238 -0
  169. package/src/utils/async.js +14 -0
  170. package/src/utils/config-file.js +53 -0
  171. package/src/utils/crashguard.js +88 -0
  172. package/src/utils/http.js +53 -0
  173. package/src/utils/id.js +8 -0
  174. package/src/utils/json-state.js +33 -0
  175. package/src/utils/logger.js +17 -17
  176. package/src/utils/ndjson.js +25 -0
  177. package/src/utils/pii.js +42 -42
  178. package/src/utils/rate-limit.js +50 -0
  179. package/src/utils/schema.js +80 -0
  180. package/src/utils/similarity.js +43 -0
  181. package/src/utils/store.js +170 -108
  182. package/src/utils/text.js +15 -15
  183. package/src/utils/trace.js +153 -153
  184. package/src/utils/wal.js +39 -0
  185. package/src/utils/winutf8.js +16 -15
  186. package/src/wiki/index.js +170 -0
@@ -1,592 +1,1090 @@
1
- // src/channels/http.js - HTTP 通道 (零依赖)
2
- // 起一个本地 HTTP server, 接收 POST /message 消息, 调 agent 回复
3
- import http from "node:http";
4
- import fs from "node:fs";
5
- import path from "node:path";
6
- import crypto from "node:crypto";
7
- import { Channel } from "./base.js";
8
- import {
9
- listProviders, addProvider, updateProvider, removeProvider, reorderProviders,
10
- } from "../config/providers.js";
11
- import { getSettings, updateSettings } from "../config/settings.js";
12
- import { suggestProactive } from "../ans/proactive.js";
13
- import { ensureDir, atomicWrite, readText } from "../utils/store.js";
14
-
15
- const MAX_BODY = 1024 * 1024; // 请求体上限 1MB
16
- const RATE_PER_MIN = 60; // 每 IP 每分钟最大请求数 (令牌桶)
17
- const RATE_WINDOW_MS = 60_000;
18
- const MAX_INFLIGHT = 4; // 同时处理的最大对话请求数 (超出立即 429, 防单 agent 被并发压垮)
19
-
20
- // HTTP 鉴权 token 解析 / 生成 (v1.0.9 起持久化, 重启复用, 免去 Web 前端每次重贴 token)
21
- // 优先级: 1) 显式配置 (env/ppx.json 的 channels.http.auth_token) > 2) 数据目录持久化文件复用 > 3) 新生成并原子落盘
22
- // @param {object} opts
23
- // @param {string} opts.configured 显式配置的 token (PPX_AUTH_TOKEN 或 config 里的 auth_token; 空串视为未配置)
24
- // @param {string|null} opts.persistedFile 自动生成 token 的持久化文件路径 (无则仅返回生成, 不落盘)
25
- // @returns {{ token:string, source:"configured"|"persisted"|"generated", generated:boolean }}
26
- // 纯函数, 便于单测: 不依赖 agent/实例状态, 全部副作用 (读/写文件) 走参数传入的路径
27
- export function resolveAuthToken({ configured = "", persistedFile = null } = {}) {
28
- const explicit = String(configured || "").trim();
29
- // 优先级 1: 显式配置 (env/config) — 以身作则, 不读写持久化文件
30
- if (explicit) return { token: explicit, source: "configured", generated: false };
31
-
32
- // 优先级 2: 复用持久化文件里的旧 token (非空)
33
- if (persistedFile) {
34
- const old = String(readText(persistedFile, "") || "").trim();
35
- if (old) return { token: old, source: "persisted", generated: false };
36
- }
37
-
38
- // 优先级 3: 新生成随机 token; 若有持久化路径则原子落盘 (写 .tmp 再 rename, 全程目录存在)
39
- const token = crypto.randomBytes(24).toString("hex");
40
- if (persistedFile) {
41
- try {
42
- ensureDir(path.dirname(persistedFile));
43
- atomicWrite(persistedFile, token); // .tmp + rename: 原子替换, 并发/崩溃不损坏
44
- } catch {
45
- // token 已生成但在内存可用; 落盘失败仅丢失"跨重启复用", 不阻断启动
46
- }
47
- }
48
- return { token, source: "generated", generated: true };
49
- }
50
-
51
- export class HttpChannel extends Channel {
52
- constructor(agent, { port = 8899, host = "127.0.0.1" } = {}) {
53
- super("http", agent);
54
- this.port = port;
55
- this.host = host;
56
- this.server = null;
57
- this.agent = agent;
58
- this.publicDir = path.join(this.agent.root, "public");
59
- // v1.0.9: token 解析延迟到 connect() 的 _ensureToken, 以便持久化文件与 agent.dataDir 就绪
60
- // 这里只缓存显式配置 (env/config), 交给 _ensureToken 走"显式 > 持久化 > 新生成"的优先级
61
- this.authToken = process.env.PPX_AUTH_TOKEN || this._tokenFromConfig();
62
- this._persistedTokenFile = agent.dataDir ? path.join(agent.dataDir, "http-token") : null;
63
- this._buckets = new Map(); // ip -> {tokens, last}
64
- // v1.0.8: webhook 路由注册表 (feishu/wechat 通道挂载), 单一 request handler 分发, 无多 listener 竞态
65
- this.webhookRoutes = new Map(); // path -> async (req, res) => void
66
- // CORS 来源白名单 (v1.0.7): channels.http.cors_origin 数组; 未配置默认 * (向后兼容)
67
- // 配置后仅放行白名单 origin, 其余跨域请求 403 (token 泄露时降低任意跨站读取风险)
68
- this.corsOrigins = this._corsFromConfig();
69
- this._inFlight = 0; // 当前处理中的对话请求数
70
- }
71
-
72
- // 简单并发护栏: 用计数信号量限制同时处理的对话请求, 超出立即 429
73
- // 防止多个慢请求无限叠加占用单线程主 agent (可选的背压层, 区别于每 IP 令牌桶限流)
74
- _acquire(res) {
75
- if (this._inFlight >= MAX_INFLIGHT) {
76
- res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "5" });
77
- res.end(JSON.stringify({ error: "too many concurrent requests, retry shortly" }));
78
- return false;
79
- }
80
- this._inFlight += 1;
81
- return true;
82
- }
83
- _release() { if (this._inFlight > 0) this._inFlight -= 1; }
84
-
85
- // v1.0.8: webhook 通道注册路由 (路径匹配即由该通道处理, 不经过主逻辑)
86
- registerWebhook(path, handler) {
87
- if (typeof handler === "function") this.webhookRoutes.set(path, handler);
88
- return () => this.webhookRoutes.delete(path);
89
- }
90
-
91
- _corsFromConfig() {
92
- try {
93
- const c = this.agent?.config?.channels?.http?.cors_origin;
94
- if (c === undefined || c === null || c === "*") return [];
95
- if (Array.isArray(c)) return c.filter(Boolean);
96
- if (typeof c === "string") return [c];
97
- } catch { /* 配置异常回退默认 */ }
98
- return [];
99
- }
100
-
101
- _tokenFromConfig() {
102
- try {
103
- const p = path.join(this.agent.root, "config", "ppx.json");
104
- if (fs.existsSync(p)) {
105
- const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
106
- return (cfg.channels && cfg.channels.http && cfg.channels.http.auth_token) || "";
107
- }
108
- } catch {}
109
- return "";
110
- }
111
-
112
- // 若未配置 token: 复用持久化文件(重启不变)或自动生成并原子落盘 (类似 Jupyter token)
113
- _ensureToken() {
114
- if (this.authToken) return this.authToken;
115
- const r = resolveAuthToken({ configured: process.env.PPX_AUTH_TOKEN || "", persistedFile: this._persistedTokenFile });
116
- this.authToken = r.token;
117
- if (r.source === "persisted") {
118
- console.log(" ℹ HTTP 认证 token 复用自持久化文件 (重启不更换, 前端无需重贴)");
119
- } else {
120
- console.log("");
121
- console.log(" ⚠️ HTTP 认证 token 未配置, 已自动生成并持久化 (重启复用):");
122
- console.log(` PPX_AUTH_TOKEN=${this.authToken}`);
123
- console.log(` 用法: Authorization: Bearer ${this.authToken}`);
124
- console.log(` token 已保存到 ${this._persistedTokenFile}, 重启不再更换`);
125
- console.log(" (或设置 config/ppx.json 的 channels.http.auth_token)");
126
- console.log("");
127
- }
128
- return this.authToken;
129
- }
130
-
131
- _authed(req, res) {
132
- if (!this.authToken) return true; // 兼容旧调用: _ensureToken 在 connect 时已执行
133
- const h = req.headers["authorization"] || "";
134
- return h === "Bearer " + this.authToken;
135
- }
136
-
137
- // 简单令牌桶限流: 每 IP 60 req/min
138
- _rateLimit(req, res) {
139
- const ip = req.socket?.remoteAddress || "unknown";
140
- const now = Date.now();
141
- let b = this._buckets.get(ip);
142
- if (!b) {
143
- b = { tokens: RATE_PER_MIN, last: now };
144
- this._buckets.set(ip, b);
145
- }
146
- const refill = Math.floor((now - b.last) / RATE_WINDOW_MS);
147
- if (refill > 0) {
148
- b.tokens = Math.min(RATE_PER_MIN, b.tokens + refill * RATE_PER_MIN);
149
- b.last = now;
150
- }
151
- if (b.tokens <= 0) {
152
- res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "60" });
153
- res.end(JSON.stringify({ error: "rate limited" }));
154
- return false;
155
- }
156
- b.tokens -= 1;
157
- return true;
158
- }
159
-
160
- // 读取请求体, 限制大小
161
- async _readBody(req, res) {
162
- let body = "";
163
- for await (const chunk of req) {
164
- body += chunk;
165
- if (body.length > MAX_BODY) {
166
- res.writeHead(413, { "Content-Type": "application/json" });
167
- res.end(JSON.stringify({ error: "request too large" }));
168
- return null;
169
- }
170
- }
171
- return body;
172
- }
173
-
174
- async connect() {
175
- this._ensureToken(); // 启动时确保有 token
176
- this.server = http.createServer(async (req, res) => {
177
- // v1.0.8: webhook 路由分发 (feishu/wechat 等): 匹配路径交给对应通道, 不走主逻辑
178
- const reqPath0 = (req.url || "/").split("?")[0];
179
- const wh = this.webhookRoutes.get(reqPath0);
180
- if (wh) {
181
- try { await wh(req, res); } catch (e) {
182
- try { if (!res.writableEnded) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); } } catch {}
183
- }
184
- return;
185
- }
186
- // CORS (v1.0.7): 默认 * (兼容); 配置 cors_origin 白名单时校验浏览器来源 (无 Origin 的非浏览器请求不受 CORS 约束)
187
- const origin = req.headers.origin;
188
- let allowOrigin = "*";
189
- if (this.corsOrigins.length) {
190
- allowOrigin = !origin ? "*" : (this.corsOrigins.includes(origin) ? origin : null);
191
- }
192
- if (!allowOrigin) {
193
- res.writeHead(403, { "Content-Type": "application/json" });
194
- res.end(JSON.stringify({ error: "origin not allowed" }));
195
- return;
196
- }
197
- res.setHeader("Access-Control-Allow-Origin", allowOrigin);
198
- if (allowOrigin !== "*") res.setHeader("Vary", "Origin");
199
- res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
200
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
201
- if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
202
-
203
- // 静态页面与 /health 不设限, 其余 API 限流
204
- const reqPath = (req.url || "/").split("?")[0];
205
- if (!(req.method === "GET" && (reqPath === "/" || reqPath === "/index.html" || reqPath === "/health"))) {
206
- if (!this._rateLimit(req, res)) return;
207
- }
208
-
209
- if (req.method === "GET" && req.url === "/health") {
210
- res.writeHead(200, { "Content-Type": "application/json" });
211
- res.end(JSON.stringify({ status: "ok", agent: this.agent.config.agent?.name || "ppx" }));
212
- return;
213
- }
214
-
215
- if (req.method === "POST" && req.url === "/message") {
216
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
217
- const body = await this._readBody(req, res);
218
- if (body === null) return;
219
- try {
220
- const data = JSON.parse(body);
221
- const text = data.message || data.text || "";
222
- if (!text) { res.writeHead(400); res.end(JSON.stringify({ error: "missing message" })); return; }
223
- const sessionKey = data.sessionId || "default";
224
- if (!this._acquire(res)) return;
225
- try {
226
- const reply = await this.agent.chat(String(text), { sessionKey });
227
- res.writeHead(200, { "Content-Type": "application/json" });
228
- res.end(JSON.stringify({ reply, sessionId: sessionKey, agent: this.agent.config.agent?.name || "ppx" }));
229
- } finally { this._release(); }
230
- } catch (e) {
231
- res.writeHead(500, { "Content-Type": "application/json" });
232
- res.end(JSON.stringify({ error: e.message }));
233
- }
234
- return;
235
- }
236
-
237
- // SSE 流式对话
238
- if (req.method === "POST" && (req.url === "/message/stream" || req.url === "/chat")) {
239
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
240
- const body = await this._readBody(req, res);
241
- if (body === null) return;
242
- try {
243
- const data = JSON.parse(body);
244
- const text = data.message || data.text || "";
245
- if (!text) { res.writeHead(400); res.end(JSON.stringify({ error: "missing message" })); return; }
246
- const sessionKey = data.sessionId || "default";
247
- res.writeHead(200, {
248
- "Content-Type": "text/event-stream",
249
- "Cache-Control": "no-cache",
250
- "Connection": "keep-alive",
251
- "X-Accel-Buffering": "no",
252
- });
253
- if (!this._acquire(res)) { try { res.end(); } catch {} return; }
254
- try {
255
- const send = (obj) => res.write("data: " + JSON.stringify(obj) + "\n\n");
256
- let full = "";
257
- const reply = await this.agent.chatStream(String(text), {
258
- sessionKey,
259
- onDelta: (d) => { full += d; try { send({ type: "delta", content: d }); } catch {} },
260
- onTool: (ev) => { try { send({ type: "tool", tool: ev.tool, status: ev.type, args: ev.args, ok: ev.ok, durationMs: ev.durationMs }); } catch {} }, // 工具调用可视化
261
- onStep: (ev) => { try { send({ type: "step", round: ev.round, maxRounds: ev.maxRounds }); } catch {} }, // turn/step 推理轮次进度
262
- });
263
- const finalContent = full || reply;
264
- send({ type: "done", content: finalContent, sessionId: sessionKey });
265
- res.end();
266
- } finally { this._release(); }
267
- } catch (e) {
268
- try { res.write("data: " + JSON.stringify({ type: "error", error: e.message }) + "\n\n"); } catch {}
269
- try { res.end(); } catch {}
270
- }
271
- return;
272
- }
273
-
274
- // 会话列表 (Web UI 多会话管理) [P1#6]
275
- if (req.method === "GET" && req.url === "/sessions") {
276
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
277
- const list = (this.agent.sessionStore && this.agent.sessionStore.list) ? this.agent.sessionStore.list() : [];
278
- res.writeHead(200, { "Content-Type": "application/json" });
279
- res.end(JSON.stringify({ sessions: list }));
280
- return;
281
- }
282
-
283
- // 会话历史 (Web UI 切换会话时恢复消息显示): GET /sessions/:key/history
284
- if (req.method === "GET" && /^\/sessions\/[^/]+\/history$/.test(reqPath)) {
285
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
286
- const key = decodeURIComponent(reqPath.split("/")[2]);
287
- const msgs = this.agent.sessionStore && typeof this.agent.sessionStore.deriveMessages === "function"
288
- ? this.agent.sessionStore.deriveMessages(key)
289
- : [];
290
- res.writeHead(200, { "Content-Type": "application/json" });
291
- res.end(JSON.stringify({ sessionId: key, messages: msgs }));
292
- return;
293
- }
294
-
295
- // 会话重命名 [P1#6]
296
- if (req.method === "POST" && req.url === "/sessions/rename") {
297
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
298
- const body = await this._readBody(req, res);
299
- if (body === null) return;
300
- const ok = this.agent.sessionStore && typeof this.agent.sessionStore.rename === "function"
301
- ? this.agent.sessionStore.rename(body.from, body.to) : false;
302
- res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json" });
303
- res.end(JSON.stringify({ ok }));
304
- return;
305
- }
306
- // 会话删除 [P1#6]
307
- if (req.method === "POST" && req.url === "/sessions/delete") {
308
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
309
- const body = await this._readBody(req, res);
310
- if (body === null) return;
311
- if (this.agent.sessionStore) this.agent.sessionStore.delete(body.key || "default");
312
- res.writeHead(200, { "Content-Type": "application/json" });
313
- res.end(JSON.stringify({ ok: true }));
314
- return;
315
- }
316
-
317
- // 会话重置
318
- if (req.method === "POST" && req.url === "/reset") {
319
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
320
- const body = await this._readBody(req, res);
321
- if (body === null) return;
322
- try {
323
- const data = JSON.parse(body || "{}");
324
- this.agent.resetSession(data.sessionId || "default");
325
- } catch {}
326
- res.writeHead(200, { "Content-Type": "application/json" });
327
- res.end(JSON.stringify({ ok: true }));
328
- return;
329
- }
330
-
331
- // ---- 静态界面 + 可观测 API ----
332
- // 通用静态文件服务 (public/ 下任意文件, 含 vendor/ 资源, 防路径穿越)
333
- if (req.method === "GET" && !reqPath.startsWith("/api/") && !["/message","/message/stream","/chat","/reset"].some(p=>reqPath===p)) {
334
- const rel = reqPath === "/" ? "index.html" : reqPath.replace(/^\//, "");
335
- const file = path.resolve(this.publicDir, rel);
336
- if (file.startsWith(this.publicDir + path.sep) && fs.existsSync(file) && fs.statSync(file).isFile()) {
337
- const ext = path.extname(file).toLowerCase();
338
- const mime = { ".html":"text/html; charset=utf-8", ".js":"text/javascript", ".css":"text/css", ".json":"application/json", ".png":"image/png", ".jpg":"image/jpeg", ".svg":"image/svg+xml", ".ico":"image/x-icon" }[ext] || "application/octet-stream";
339
- res.writeHead(200, { "Content-Type": mime });
340
- res.end(fs.readFileSync(file));
341
- return;
342
- }
343
- if (reqPath !== "/") { res.writeHead(404); res.end("not found"); return; }
344
- }
345
- // 静态页面
346
- if (req.method === "GET" && (reqPath === "/" || reqPath === "/index.html")) {
347
- const html = path.join(this.publicDir, "index.html");
348
- if (fs.existsSync(html)) {
349
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
350
- res.end(fs.readFileSync(html, "utf8"));
351
- } else {
352
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
353
- res.end("皮皮虾服务已启动。public/index.html 未找到。");
354
- }
355
- return;
356
- }
357
- // API 端点认证
358
- const protectedApi = reqPath.startsWith("/api/");
359
- if (protectedApi && !this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
360
- // 轨迹 API
361
- if (req.method === "GET" && reqPath === "/api/traces") {
362
- const limit = Number((req.url.split("limit=")[1] || "").split("&")[0] || 50);
363
- res.writeHead(200, { "Content-Type": "application/json" });
364
- res.end(JSON.stringify(this.agent.traces.read(undefined, limit)));
365
- return;
366
- }
367
- // 统计 API (聚合记忆/轨迹/工具/经验/自愈)
368
- if (req.method === "GET" && reqPath === "/api/stats") {
369
- res.writeHead(200, { "Content-Type": "application/json" });
370
- res.end(JSON.stringify(this.agent.stats ? this.agent.stats() : this.agent.traces.stats()));
371
- return;
372
- }
373
- // 记忆 API
374
- if (req.method === "GET" && reqPath === "/api/memory") {
375
- res.writeHead(200, { "Content-Type": "application/json" });
376
- const facts = this.agent.facts ? this.agent.facts.list().slice(0, 20).map((f) => ({ content: f.content, score: f.score, type: f.type })) : [];
377
- const scenes = this.agent.scenes ? this.agent.scenes.listWithDesc().slice(-10) : [];
378
- res.end(JSON.stringify({ facts, scenes }));
379
- return;
380
- }
381
- // 主动任务生成 API (ANS 自主性): 扫描记忆待办/偏好, 返回主动提醒 + 结构化 items (含 id)
382
- if (req.method === "GET" && reqPath === "/api/proactive") {
383
- res.writeHead(200, { "Content-Type": "application/json" });
384
- try {
385
- const out = await suggestProactive(this.agent);
386
- res.end(JSON.stringify({ message: out ? out.text : null, items: out ? out.items : [] }));
387
- } catch (e) {
388
- res.end(JSON.stringify({ message: null, items: [], error: e.message }));
389
- }
390
- return;
391
- }
392
- // 标记待办完成 (ANS): POST /api/proactive/done { id } → 之后不再主动提醒
393
- if (req.method === "POST" && reqPath === "/api/proactive/done") {
394
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
395
- const body = await this._readBody(req, res);
396
- if (body === null) return;
397
- try {
398
- const data = JSON.parse(body || "{}");
399
- const ok = this.agent.proactiveMarkDone(data.id);
400
- res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json" });
401
- res.end(JSON.stringify({ ok }));
402
- } catch (e) {
403
- res.writeHead(400, { "Content-Type": "application/json" });
404
- res.end(JSON.stringify({ error: e.message }));
405
- }
406
- return;
407
- }
408
- // 生命周期状态 API (ANS): 阶段/年龄/进化/繁衍
409
- if (req.method === "GET" && reqPath === "/api/lifecycle") {
410
- res.writeHead(200, { "Content-Type": "application/json" });
411
- res.end(JSON.stringify(this.agent.lifecycleStatus ? this.agent.lifecycleStatus() : {}));
412
- return;
413
- }
414
-
415
- // 提供方 API [本轮新增] - 模型配置 Web UI 后端
416
- // GET /api/providers 列出全部提供方 (key 抹掉, 只返 api_key_set 标志)
417
- // POST /api/providers 新增 (body: { provider: {...} })
418
- // PUT /api/providers 更新 (body: { id, patch: {...} })
419
- // DELETE /api/providers 删除 (body: { id })
420
- // POST /api/providers/test 健康探测 (body: { id }), 复用 LLMClient.health()
421
- // POST /api/providers/reorder 重排 (body: { order: [id1, id2, ...] })
422
- if (req.method === "GET" && reqPath === "/api/providers") {
423
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
424
- const providers = listProviders(this.agent.root);
425
- const defaultId = providers[0] ? providers[0].id : null;
426
- res.writeHead(200, { "Content-Type": "application/json" });
427
- res.end(JSON.stringify({ providers, default_id: defaultId }));
428
- return;
429
- }
430
- if (req.method === "POST" && reqPath === "/api/providers") {
431
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
432
- const body = await this._readBody(req, res);
433
- if (body === null) return;
434
- try {
435
- const data = JSON.parse(body || "{}");
436
- const raw = data.provider || data;
437
- const created = addProvider(this.agent.root, raw);
438
- // 热重载 (新增默认时让 agent 立即可用)
439
- this.agent.reloadProviders();
440
- res.writeHead(200, { "Content-Type": "application/json" });
441
- res.end(JSON.stringify({ ok: true, provider: created }));
442
- } catch (e) {
443
- res.writeHead(400, { "Content-Type": "application/json" });
444
- res.end(JSON.stringify({ error: e.message }));
445
- }
446
- return;
447
- }
448
- if (req.method === "PUT" && reqPath === "/api/providers") {
449
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
450
- const body = await this._readBody(req, res);
451
- if (body === null) return;
452
- try {
453
- const data = JSON.parse(body || "{}");
454
- if (!data.id) throw new Error("缺少 id");
455
- const updated = updateProvider(this.agent.root, data.id, data.patch || {});
456
- this.agent.reloadProviders();
457
- res.writeHead(200, { "Content-Type": "application/json" });
458
- res.end(JSON.stringify({ ok: true, provider: updated }));
459
- } catch (e) {
460
- const code = /不存在|缺少/.test(e.message) ? 404 : 400;
461
- res.writeHead(code, { "Content-Type": "application/json" });
462
- res.end(JSON.stringify({ error: e.message }));
463
- }
464
- return;
465
- }
466
- if (req.method === "DELETE" && reqPath === "/api/providers") {
467
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
468
- const body = await this._readBody(req, res);
469
- if (body === null) return;
470
- try {
471
- const data = JSON.parse(body || "{}");
472
- if (!data.id) throw new Error("缺少 id");
473
- const removed = removeProvider(this.agent.root, data.id);
474
- this.agent.reloadProviders();
475
- res.writeHead(200, { "Content-Type": "application/json" });
476
- res.end(JSON.stringify({ ok: true, provider: removed }));
477
- } catch (e) {
478
- const code = /不存在|缺少/.test(e.message) ? 404 : 400;
479
- res.writeHead(code, { "Content-Type": "application/json" });
480
- res.end(JSON.stringify({ error: e.message }));
481
- }
482
- return;
483
- }
484
- if (req.method === "POST" && reqPath === "/api/providers/test") {
485
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
486
- const body = await this._readBody(req, res);
487
- if (body === null) return;
488
- try {
489
- const data = JSON.parse(body || "{}");
490
- if (!data.id) throw new Error("缺少 id");
491
- // 优先复用 agent 已有的 LLM 客户端 (含测试注入的 stub), 避免对磁盘配置的真实网络探测
492
- // 兜底顺序: allProviders → agent.llm → 磁盘临时构造
493
- let client = (this.agent.allProviders || []).find((c) => c.providerId === data.id);
494
- let fromCache = !!client;
495
- if (!client && this.agent.llm && this.agent.llm.providerId === data.id) {
496
- client = this.agent.llm;
497
- fromCache = true;
498
- }
499
- if (!client) {
500
- const { readConfig } = await import("../config/providers.js");
501
- const { providers } = readConfig(this.agent.root);
502
- const p = providers.find((x) => x.id === data.id);
503
- if (!p) throw new Error("提供方不存在");
504
- const { LLMClient } = await import("../llm/client.js");
505
- client = new LLMClient(p);
506
- }
507
- const healthy = await client.health();
508
- const detail = healthy
509
- ? `${client.backend === "openclaw" ? "openclaw 后端: Node 版本满足" : client.backend === "deepseek" ? "dsh 源码就绪" : fromCache ? "复用 agent 客户端, 探测通过" : "API 端点可达"}`
510
- : "探测失败, 请检查 key/base_url/engine 路径/Node 版本";
511
- res.writeHead(200, { "Content-Type": "application/json" });
512
- res.end(JSON.stringify({ ok: true, healthy, detail, source: fromCache ? "agent-cache" : "disk-config" }));
513
- } catch (e) {
514
- res.writeHead(400, { "Content-Type": "application/json" });
515
- res.end(JSON.stringify({ error: e.message }));
516
- }
517
- return;
518
- }
519
- if (req.method === "POST" && reqPath === "/api/providers/reorder") {
520
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
521
- const body = await this._readBody(req, res);
522
- if (body === null) return;
523
- try {
524
- const data = JSON.parse(body || "{}");
525
- const providers = reorderProviders(this.agent.root, data.order || []);
526
- this.agent.reloadProviders();
527
- res.writeHead(200, { "Content-Type": "application/json" });
528
- res.end(JSON.stringify({ ok: true, providers }));
529
- } catch (e) {
530
- res.writeHead(400, { "Content-Type": "application/json" });
531
- res.end(JSON.stringify({ error: e.message }));
532
- }
533
- return;
534
- }
535
-
536
- // 通用设置 API: 用户名 / HTTP 端口 / 安全 / agent 预设
537
- // GET /api/settings 返回可编辑设置 (敏感字段只回 set 标志)
538
- // PUT /api/settings 更新 (body: { patch: { user?, http?, security?, agent? } })
539
- if (req.method === "GET" && reqPath === "/api/settings") {
540
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
541
- res.writeHead(200, { "Content-Type": "application/json" });
542
- res.end(JSON.stringify({ settings: getSettings(this.agent.root) }));
543
- return;
544
- }
545
- if (req.method === "PUT" && reqPath === "/api/settings") {
546
- if (!this._authed(req, res)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; }
547
- const body = await this._readBody(req, res);
548
- if (body === null) return;
549
- try {
550
- const data = JSON.parse(body || "{}");
551
- const settings = updateSettings(this.agent.root, data.patch || {});
552
- // 热重载: 用户名/安全/agent 预设立即生效
553
- this.agent.reloadSettings();
554
- res.writeHead(200, { "Content-Type": "application/json" });
555
- res.end(JSON.stringify({ ok: true, settings }));
556
- } catch (e) {
557
- res.writeHead(400, { "Content-Type": "application/json" });
558
- res.end(JSON.stringify({ error: e.message }));
559
- }
560
- return;
561
- }
562
-
563
- res.writeHead(404); res.end("not found");
564
-
565
- });
566
-
567
- await new Promise((resolve, reject) => {
568
- this.server.once("error", reject);
569
- this.server.listen(this.port, this.host, resolve);
570
- });
571
- this.connected = true;
572
- return this;
573
- }
574
-
575
- async send(to, text) { return text; } // HTTP 是请求-响应, 直接返回
576
-
577
- // 连通性测试: 用独立实例起临时 server 验证端口可绑定
578
- async test() {
579
- try {
580
- await this.connect();
581
- await this.disconnect();
582
- return { ok: true, detail: `HTTP 通道可启动: http://${this.host}:${this.port}` };
583
- } catch (e) {
584
- return { ok: false, detail: e.message };
585
- }
586
- }
587
-
588
- async disconnect() {
589
- if (this.server) { await new Promise((r) => this.server.close(r)); this.server = null; }
590
- this.connected = false;
591
- }
592
- }
1
+ // src/channels/http.js - HTTP 通道 (零依赖)
2
+ // 起一个本地 HTTP server, 接收 POST /message 消息, 调 agent 回复
3
+ //
4
+ // 2026-09-18 重构: 路由主体原先是一个 ~530 行的巨型 handler, 其中
5
+ // - 「鉴权失败写 401」重复 18 次
6
+ // - 「writeHead + Content-Type + JSON.stringify + end」重复 40+ 次
7
+ // - 「读 body → JSON.parse」重复 12 次
8
+ // 现收敛为 5 个私有工具方法 (_json / _unauthorized / _requireAuth / _readJson / _fail),
9
+ // 并按「CORS → MCP → webhook → 限流 → 路由」分层拆成若干具名方法;
10
+ // 对外接口 (类/方法/路径/状态码/响应体) 完全不变。
11
+ import http from "node:http";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import crypto from "node:crypto";
15
+ import { Channel } from "./base.js";
16
+ import { readBody, sendJson, SSE_HEADERS } from "../utils/http.js";
17
+ import {
18
+ listProviders, addProvider, updateProvider, removeProvider, reorderProviders, readConfig,
19
+ } from "../config/providers.js";
20
+ import { getSettings, updateSettings } from "../config/settings.js";
21
+ import { suggestProactive } from "../ans/proactive.js";
22
+ import { ensureDir, atomicWrite, readText } from "../utils/store.js";
23
+ import { TokenBucket } from "../utils/rate-limit.js";
24
+ import { createMcpEndpoint } from "../mcp/http.js";
25
+ import { createAdminTools } from "../mcp/admin.js";
26
+ import { buildTree, readWorkspaceFile } from "./workspace.js";
27
+ import { debug } from "../utils/logger.js";
28
+
29
+ const MAX_BODY = 1024 * 1024; // 请求体上限 1MB
30
+ const RATE_PER_MIN = 60; // 每 IP 每分钟最大请求数 (令牌桶)
31
+ const MAX_INFLIGHT = 4; // 同时处理的最大对话请求数 (超出立即 429, 防单 agent 被并发压垮)
32
+
33
+ // REST 端点退役开关覆盖的路径 (channels.http.mcp.legacy_rest = false 时统一 410)
34
+ const LEGACY_REST_PATHS = ["/message", "/message/stream", "/chat", "/sessions", "/reset", "/interrupt"];
35
+
36
+ // 恒定时间字符串比较 (2026-09-17 安全修复):
37
+ // 直接 `a === b` 会在首个不同字节处提前返回, 理论上是可观测的计时侧信道。
38
+ // 先把两侧都摘要成定长 32 字节再比对, 长度差异也被消除。
39
+ // @param {string} a
40
+ // @param {string} b
41
+ // @returns {boolean}
42
+ export function safeEqual(a, b) {
43
+ const ha = crypto.createHash("sha256").update(String(a ?? "")).digest();
44
+ const hb = crypto.createHash("sha256").update(String(b ?? "")).digest();
45
+ try { return crypto.timingSafeEqual(ha, hb); } catch { return false; }
46
+ }
47
+
48
+ // 面向客户端的错误文案净化: 只保留业务语义, 屏蔽 Node/流内部实现细节
49
+ // (避免把 "Cannot write headers after they are sent to the client" 这类内部错误原文回给前端)
50
+ const INTERNAL_ERR_RE = /ERR_HTTP_HEADERS_SENT|ERR_STREAM_|Cannot (write headers|call write after)|write after end|EPIPE|ECONNRESET/i;
51
+ export function publicErrorMessage(e) {
52
+ // 兼容三种入参: Error 实例 / 字符串 / 任意对象 (对象且无 message 时不该退化成 "[object Object]")
53
+ const raw = (e && typeof e === "object" && typeof e.message === "string") ? e.message
54
+ : (typeof e === "string" ? e : "");
55
+ const msg = raw.trim();
56
+ if (!msg) return "服务内部错误";
57
+ if (INTERNAL_ERR_RE.test(msg)) return "连接状态异常, 请重试";
58
+ return msg.slice(0, 300);
59
+ }
60
+
61
+ // HTTP 鉴权 token 解析 / 生成 (v1.0.9 起持久化, 重启复用, 免去 Web 前端每次重贴 token)
62
+ // 优先级: 1) 显式配置 (env/ppx.json 的 channels.http.auth_token) > 2) 数据目录持久化文件复用 > 3) 新生成并原子落盘
63
+ // @param {object} opts
64
+ // @param {string} opts.configured 显式配置的 token (PPX_AUTH_TOKEN 或 config 里的 auth_token; 空串视为未配置)
65
+ // @param {string|null} opts.persistedFile 自动生成 token 的持久化文件路径 (无则仅返回生成, 不落盘)
66
+ // @returns {{ token:string, source:"configured"|"persisted"|"generated", generated:boolean }}
67
+ // 纯函数, 便于单测: 不依赖 agent/实例状态, 全部副作用 (读/写文件) 走参数传入的路径
68
+ export function resolveAuthToken({ configured = "", persistedFile = null } = {}) {
69
+ const explicit = String(configured || "").trim();
70
+ // 优先级 1: 显式配置 (env/config) — 以身作则, 不读写持久化文件
71
+ if (explicit) return { token: explicit, source: "configured", generated: false };
72
+
73
+ // 优先级 2: 复用持久化文件里的旧 token (非空)
74
+ if (persistedFile) {
75
+ const old = String(readText(persistedFile, "") || "").trim();
76
+ if (old) return { token: old, source: "persisted", generated: false };
77
+ }
78
+
79
+ // 优先级 3: 新生成随机 token; 若有持久化路径则原子落盘 (写 .tmp 再 rename, 全程目录存在)
80
+ const token = crypto.randomBytes(24).toString("hex");
81
+ if (persistedFile) {
82
+ try {
83
+ ensureDir(path.dirname(persistedFile));
84
+ atomicWrite(persistedFile, token); // .tmp + rename: 原子替换, 并发/崩溃不损坏
85
+ } catch {
86
+ // token 已生成但在内存可用; 落盘失败仅丢失"跨重启复用", 不阻断启动
87
+ }
88
+ }
89
+ return { token, source: "generated", generated: true };
90
+ }
91
+
92
+ export class HttpChannel extends Channel {
93
+ constructor(agent, { port = 8899, host = "127.0.0.1" } = {}) {
94
+ super("http", agent);
95
+ this.port = port;
96
+ this.host = host;
97
+ this.server = null;
98
+ this.agent = agent;
99
+ this.publicDir = path.join(this.agent.root, "public");
100
+ // v1.0.9: token 解析延迟到 connect() 的 _ensureToken, 以便持久化文件与 agent.dataDir 就绪
101
+ // 这里只缓存显式配置 (env/config), 交给 _ensureToken 走"显式 > 持久化 > 新生成"的优先级
102
+ this.authToken = process.env.PPX_AUTH_TOKEN || this._tokenFromConfig();
103
+ this._persistedTokenFile = agent.dataDir ? path.join(agent.dataDir, "http-token") : null;
104
+ this._rateLimiter = new TokenBucket({ perMin: RATE_PER_MIN });
105
+ this._buckets = this._rateLimiter.buckets; // ip -> {tokens, last} (可观测, 与限流器同一 Map)
106
+ // v1.0.8: webhook 路由注册表 (feishu/wechat 通道挂载), 单一 request handler 分发, 无多 listener 竞态
107
+ this.webhookRoutes = new Map(); // path -> async (req, res) => void
108
+ // CORS 来源白名单 (v1.0.7): channels.http.cors_origin 数组; 未配置默认 * (向后兼容)
109
+ // 配置后仅放行白名单 origin, 其余跨域请求 403 (token 泄露时降低任意跨站读取风险)
110
+ this.corsOrigins = this._corsFromConfig();
111
+ this._inFlight = 0; // 当前处理中的对话请求数
112
+ // 2026-10-03: 主动提醒 SSE 中枢 —— GET /events 长连接客户端集合;
113
+ // send() 广播 {type:"reminder"} 给所有在线 Web UI (修复 server.js broadcast 落 no-op 的断链)
114
+ this._sseClients = new Set();
115
+ this._sseBeat = null; // 心跳定时器 (有客户端时启动, 全部断开后自动清)
116
+ // 2026-10-03 接线 (P2): 协议总线 EQ 此前零消费者 —— 订阅后经 SSE /events 广播给
117
+ // Web UI 时间线 (TASK_TURN_*/APPROVAL_REQUESTED 等结构化事件实时到达前端)。
118
+ this._unsubBus = null;
119
+ if (this.agent?.protocolBus?.eq?.subscribe) {
120
+ this._unsubBus = this.agent.protocolBus.eq.subscribe((ev) => this._broadcastEvent(ev));
121
+ }
122
+ // v2.6.0: MCP 标准端点 (Streamable HTTP) — 默认开启, 路径 /mcp
123
+ const mcpCfg = (this.agent?.config?.channels?.http?.mcp) || {};
124
+ this.mcpEnabled = mcpCfg.enabled !== false;
125
+ this.mcpPath = mcpCfg.path || "/mcp";
126
+ // v2.6.0: 产品壳已全部走 MCP (前端不再调用 REST)。服务端默认保留 REST 兼容 (旧脚本/测试不受影响);
127
+ // 想彻底退役可设 channels.http.mcp.legacy_rest=false → /api/* 与 /message* 返回 410 并引导 /mcp
128
+ this.mcpLegacyRest = mcpCfg.legacy_rest !== false;
129
+ if (this.mcpEnabled) {
130
+ // v2.6.0: admin 虚拟工具 (会话/提供方/设置/任务面板) 注入 MCP 端点, 供 web 前端替代 REST /api/*
131
+ const admin = createAdminTools(agent);
132
+ this.mcpAdmin = admin;
133
+ const { server: mcpServer, handler: mcpHandler } = createMcpEndpoint(agent, {
134
+ name: agent.config?.agent?.name || "ppxans-harness",
135
+ // 版本由 createMcpEndpoint -> McpServer 默认读 package.json (不再硬编码)
136
+ supportedVersions: mcpCfg.supported_versions,
137
+ extraTools: admin.tools,
138
+ authenticated: (req, res) => {
139
+ if (this._authed(req, res)) return true;
140
+ // _authed 只做判断不写响应, 这里补 401 (否则客户端永远挂等)
141
+ this._unauthorized(res);
142
+ return false;
143
+ },
144
+ rateLimit: (req, res) => this._rateLimit(req, res),
145
+ // 2026-10-03 修复 (P2): 原 skipOriginCheck:true 被工厂吞参且语义本身危险 ——
146
+ // 宿主 _applyCors 对恶意 Origin 只是"不回显 ACAO"并不拒绝, 无法防 DNS rebinding;
147
+ // 真正防线是 handler 内 origin 校验。改为与宿主共享同一白名单 (未配置时 handler
148
+ // 走安全默认: 仅回环 Origin 放行)。
149
+ allowedOrigins: this.corsOrigins.length ? this.corsOrigins : undefined,
150
+ });
151
+ this.mcpServer = mcpServer;
152
+ this.mcpHandler = mcpHandler;
153
+ }
154
+ }
155
+
156
+ // ==================== 请求/响应工具 (消除路由层重复样板) ====================
157
+
158
+ // 统一 JSON 响应 (writeHead + Content-Type + 序列化 + end); headers 用于 Retry-After / Cache-Control
159
+ _json(res, code, obj, headers = {}) {
160
+ sendJson(res, code, obj, { headers });
161
+ }
162
+
163
+ // 鉴权失败响应
164
+ _unauthorized(res) {
165
+ this._json(res, 401, { error: "unauthorized" });
166
+ }
167
+
168
+ // 鉴权门禁: 通过返回 true; 未通过则已写 401 并返回 false
169
+ // 用法: `if (!this._requireAuth(req, res)) return true;`
170
+ _requireAuth(req, res) {
171
+ if (this._authed(req, res)) return true;
172
+ this._unauthorized(res);
173
+ return false;
174
+ }
175
+
176
+ // 读 body 并解析 JSON (缺省 "{}")。体超限时 _readBody 已写 413 并返回 null → 这里也返回 null。
177
+ // JSON 解析失败向上抛错, 由调用方 try/catch 转 400 (保持各路由既有错误语义)。
178
+ async _readJson(req, res) {
179
+ const body = await this._readBody(req, res);
180
+ if (body === null) return null;
181
+ return JSON.parse(body || "{}");
182
+ }
183
+
184
+ // 统一错误响应 (2026-10-03 修复): 原样回传 e.message 会把内部错误原文
185
+ // (ERR_HTTP_HEADERS_SENT / 文件路径等) 泄露给客户端, 统一过 publicErrorMessage 净化
186
+ _fail(res, e, code = 400) {
187
+ this._json(res, code, { error: publicErrorMessage(e) });
188
+ }
189
+
190
+ // 对话类路由的公共前置 (原 /message 与 /message/stream 各抄一份):
191
+ // 鉴权 → 读 JSON (超限时 _readJson 已回 413) → 抽取消息文本与会话键。
192
+ // 任一步失败时响应已写出, 返回 null, 调用方统一 `return true`。
193
+ async _readChatRequest(req, res) {
194
+ if (!this._requireAuth(req, res)) return null;
195
+ const data = await this._readJson(req, res);
196
+ if (data === null) return null;
197
+ const text = data.message || data.text || "";
198
+ if (!text) { this._json(res, 400, { error: "missing message" }); return null; }
199
+ return { text, sessionKey: data.sessionId || "default" };
200
+ }
201
+
202
+ // 简单并发护栏: 用计数信号量限制同时处理的对话请求, 超出立即 429
203
+ // 防止多个慢请求无限叠加占用单线程主 agent (可选的背压层, 区别于每 IP 令牌桶限流)
204
+ _acquire(res) {
205
+ if (this._inFlight >= MAX_INFLIGHT) {
206
+ this._json(res, 429, { error: "too many concurrent requests, retry shortly" }, { "Retry-After": "5" });
207
+ return false;
208
+ }
209
+ this._inFlight += 1;
210
+ return true;
211
+ }
212
+ _release() { if (this._inFlight > 0) this._inFlight -= 1; }
213
+
214
+ // v1.0.8: webhook 通道注册路由 (路径匹配即由该通道处理, 不经过主逻辑)
215
+ registerWebhook(path, handler) {
216
+ if (typeof handler === "function") this.webhookRoutes.set(path, handler);
217
+ return () => this.webhookRoutes.delete(path);
218
+ }
219
+
220
+ _corsFromConfig() {
221
+ try {
222
+ const c = this.agent?.config?.channels?.http?.cors_origin;
223
+ if (c === undefined || c === null || c === "*") return [];
224
+ if (Array.isArray(c)) return c.filter(Boolean);
225
+ if (typeof c === "string") return [c];
226
+ } catch { /* 配置异常回退默认 */ }
227
+ return [];
228
+ }
229
+
230
+ // 判断请求是否来自本机回环 (127.0.0.1/::1) —— 只有本机才允许把 token 注入首页
231
+ // 安全边界: 服务默认只绑 127.0.0.1; 即使绑到 0.0.0.0, 局域网访问者拿不到注入的 token,
232
+ // 前端会退化为"手填 token"形态, 而恶意网页因 CORS 读不到首页响应, 无法窃取 token。
233
+ _isLoopback(req) {
234
+ const a = (req.socket && req.socket.remoteAddress) || "";
235
+ return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
236
+ }
237
+
238
+ // 来源是否可信 (2026-09-17 安全修复, P0-1):
239
+ // 仅靠 remoteAddress 判断"本机"是不够的 —— 浏览器里任意网站发起的请求, 其源地址同样是 127.0.0.1。
240
+ // 配合默认 `Access-Control-Allow-Origin: *`, 恶意页面可以 fetch('http://127.0.0.1:8899/api/bootstrap')
241
+ // 并把响应体读走, 从而拿到本地 API token (实测可复现)。
242
+ // 因此对"下发 token"的路径追加两道浏览器侧的来源判据:
243
+ // 1) Origin 头存在时, 其 hostname 必须是本机回环 (同源导航不带 Origin → 放行)
244
+ // 2) Sec-Fetch-Site 为 cross-site → 一律拒绝 (现代浏览器的强信号)
245
+ // v3.0.1 (P1#9): 追加第 3 道判据 —— Origin 端口必须等于本服务端口。
246
+ // 原实现只看 hostname: 同机恶意端口 (127.0.0.1:9999) 的页面 hostname 也是 127.0.0.1, 被误判可信。
247
+ _originTrusted(req) {
248
+ const sfs = String(req.headers["sec-fetch-site"] || "").toLowerCase();
249
+ if (sfs === "cross-site") return false;
250
+ const origin = req.headers["origin"];
251
+ if (!origin) return true; // 非浏览器请求 (curl/脚本) 或同源导航
252
+ try {
253
+ const u = new URL(origin);
254
+ const host = u.hostname.replace(/^\[|\]$/g, "");
255
+ if (!(host === "127.0.0.1" || host === "localhost" || host === "::1")) return false;
256
+ // 端口必须与本服务一致 (Origin 未写端口时按协议默认 80/443 折算)
257
+ const originPort = u.port || (u.protocol === "https:" ? "443" : "80");
258
+ const myPort = String(this.port || "");
259
+ if (myPort && originPort !== myPort) return false;
260
+ return true;
261
+ } catch {
262
+ return false; // Origin 畸形 → 不予信任
263
+ }
264
+ }
265
+
266
+ // 可信本地请求 = 回环地址 + 可信来源 (下发 token 的唯一条件)
267
+ _isTrustedLocal(req) {
268
+ return this._isLoopback(req) && this._originTrusted(req);
269
+ }
270
+
271
+ // 版本号取自 package.json (不硬编码, 与 McpServer 同源策略)
272
+ // 2026-09-18: 结果缓存 —— /health 与 /api/bootstrap 每次请求都重读磁盘无必要 (版本运行期不变)
273
+ _pkgVersion() {
274
+ if (this._pkgVersionCache) return this._pkgVersionCache;
275
+ try {
276
+ const p = path.join(this.agent.root, "package.json");
277
+ if (fs.existsSync(p)) {
278
+ const v = JSON.parse(fs.readFileSync(p, "utf8")).version || "0.0.0";
279
+ this._pkgVersionCache = v;
280
+ return v;
281
+ }
282
+ } catch { /* 读不到就用占位 */ }
283
+ return "0.0.0";
284
+ }
285
+
286
+ // Web 前端引导数据 (注入首页 <head>, 本地零配置可用)
287
+ // authToken 仅在"可信本地请求"时下发 (回环 + 来源可信, 见 _isTrustedLocal);
288
+ // 非回环 / 跨站来源只给 token_required 标志, 不下发任何凭据
289
+ bootstrapPayload(req) {
290
+ const loopback = this._isLoopback(req);
291
+ const trusted = this._isTrustedLocal(req);
292
+ // 2026-10-03: 补充 model/toolsCount/root —— 设置面板此前读 ag.llm.model / j.tools /
293
+ // j.root 三个 bootstrap 从未返回的字段, 永远显示 "-" (死绑定)。
294
+ let toolsCount = null;
295
+ try { toolsCount = this.agent?.tools?.list?.().length ?? null; } catch { toolsCount = null; }
296
+ const model = this.agent?.config?.providers?.[0]?.model
297
+ || this.agent?.config?.agent?.model_preference || "";
298
+ return {
299
+ app: "ppxans-harness",
300
+ version: this._pkgVersion(),
301
+ agent: (this.agent?.config?.agent?.name) || "皮皮虾",
302
+ user: (this.agent?.config?.user?.name) || "",
303
+ port: this.port,
304
+ host: this.host,
305
+ base: `http://${this.host === "0.0.0.0" ? "127.0.0.1" : this.host}:${this.port}`,
306
+ authToken: trusted ? (this.authToken || "") : "",
307
+ tokenLoopback: loopback,
308
+ tokenTrusted: trusted,
309
+ tokenRequired: Boolean(this.authToken),
310
+ mcpPath: this.mcpPath,
311
+ legacyRest: this.mcpLegacyRest,
312
+ model,
313
+ toolsCount,
314
+ root: this.agent?.root || "",
315
+ time: new Date().toISOString(),
316
+ };
317
+ }
318
+
319
+ // 读取首页 HTML 并注入 window.__PPX_BOOTSTRAP__ (找不到文件返回 null)
320
+ renderIndex(req) {
321
+ const htmlPath = path.join(this.publicDir, "index.html");
322
+ if (!fs.existsSync(htmlPath)) return null;
323
+ let html = fs.readFileSync(htmlPath, "utf8");
324
+ const cfg = JSON.stringify(this.bootstrapPayload(req)).replace(/</g, "\\u003c");
325
+ const tag = `<script>window.__PPX_BOOTSTRAP__=${cfg};</script>`;
326
+ // 优先插在 </head> 前; 模板没有 head 就退化为插到 </body> 前
327
+ if (html.includes("</head>")) html = html.replace("</head>", `${tag}\n</head>`);
328
+ else if (html.includes("</body>")) html = html.replace("</body>", `${tag}\n</body>`);
329
+ else html = tag + html;
330
+ return html;
331
+ }
332
+
333
+ _tokenFromConfig() {
334
+ try {
335
+ const p = path.join(this.agent.root, "config", "ppx.json");
336
+ if (fs.existsSync(p)) {
337
+ const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
338
+ return (cfg.channels && cfg.channels.http && cfg.channels.http.auth_token) || "";
339
+ }
340
+ } catch (e) { debug(`[channels/http] 已忽略异常: ${e && e.message ? e.message : e}`); }
341
+ return "";
342
+ }
343
+
344
+ // 若未配置 token: 复用持久化文件(重启不变)或自动生成并原子落盘 (类似 Jupyter token)
345
+ _ensureToken() {
346
+ if (this.authToken) return this.authToken;
347
+ const r = resolveAuthToken({ configured: process.env.PPX_AUTH_TOKEN || "", persistedFile: this._persistedTokenFile });
348
+ this.authToken = r.token;
349
+ if (r.source === "persisted") {
350
+ console.log(" ℹ HTTP 认证 token 复用自持久化文件 (重启不更换, 前端无需重贴)");
351
+ } else {
352
+ console.log("");
353
+ console.log(" ⚠️ HTTP 认证 token 未配置, 已自动生成并持久化 (重启复用):");
354
+ console.log(` PPX_AUTH_TOKEN=${this.authToken}`);
355
+ console.log(` 用法: Authorization: Bearer ${this.authToken}`);
356
+ console.log(` token 已保存到 ${this._persistedTokenFile}, 重启不再更换`);
357
+ console.log(" (或设置 config/ppx.json 的 channels.http.auth_token)");
358
+ console.log("");
359
+ }
360
+ return this.authToken;
361
+ }
362
+
363
+ _authed(req, res) {
364
+ if (!this.authToken) return true; // 兼容旧调用: _ensureToken 在 connect 时已执行
365
+ const h = req.headers["authorization"] || "";
366
+ return safeEqual(h, "Bearer " + this.authToken);
367
+ }
368
+
369
+ // 简单令牌桶限流: 每 IP 60 req/min (实现共用 utils/rate-limit, 含过期桶回收)
370
+ // 2026-09-17: 桶表原本只增不减 —— 长期运行 + 多变源地址会持续吃内存; 现由 TokenBucket.sweep 摊还回收。
371
+ // this._buckets 仍指向限流器内部 Map, 保持可观测性 (测试据此注入过期桶)。
372
+ _rateLimit(req, res) {
373
+ const ip = req.socket?.remoteAddress || "unknown";
374
+ if (this._rateLimiter.take(ip)) return true;
375
+ this._json(res, 429, { error: "rate limited" }, { "Retry-After": "60" });
376
+ return false;
377
+ }
378
+
379
+ // 读取请求体, 限制大小 (超限写 413 并返回 null)
380
+ async _readBody(req, res) {
381
+ const body = await readBody(req, { maxBytes: MAX_BODY });
382
+ if (body === null) {
383
+ this._json(res, 413, { error: "request too large" });
384
+ return null;
385
+ }
386
+ return body;
387
+ }
388
+
389
+ // ==================== 路由 ====================
390
+
391
+ async connect() {
392
+ this._ensureToken(); // 启动时确保有 token
393
+ this.server = http.createServer((req, res) => this._dispatch(req, res));
394
+
395
+ await new Promise((resolve, reject) => {
396
+ this.server.once("error", reject);
397
+ this.server.listen(this.port, this.host, resolve);
398
+ });
399
+ // v3.0.1 (P1#9): port=0 (临时端口) 时回填真实端口 —— Origin 同端口判据依赖 this.port
400
+ try {
401
+ const addr = this.server.address();
402
+ if (addr && typeof addr === "object" && addr.port) this.port = addr.port;
403
+ } catch (e) { debug(`[channels/http] 已忽略异常: ${e && e.message ? e.message : e}`); }
404
+ this.connected = true;
405
+ return this;
406
+ }
407
+
408
+ // 单入口分发: CORS/OPTIONS → MCP 端点 → webhook → 限流 → 业务路由, 最后兜底 404/500
409
+ async _dispatch(req, res) {
410
+ try {
411
+ const reqPath = (req.url || "/").split("?")[0];
412
+ if (!this._applyCors(req, res, reqPath)) return;
413
+
414
+ // v2.6.0: MCP 标准端点优先 (Streamable HTTP, 单端点 POST)
415
+ if (this.mcpEnabled && this.mcpHandler && req.method === "POST" && reqPath === this.mcpPath) {
416
+ return this._runHandler(this.mcpHandler, req, res);
417
+ }
418
+ // v1.0.8: webhook 路由分发 (feishu/wechat 等): 匹配路径交给对应通道, 不走主逻辑
419
+ const wh = this.webhookRoutes.get(reqPath);
420
+ if (wh) return this._runHandler(wh, req, res);
421
+
422
+ // 2026-10-03: 主动提醒 SSE 端点 (GET /events)。独立于 /api/* 是因为 legacy_rest=false
423
+ // 时 API 全部 410, 而事件流是 Web UI 实时能力不属于退役面。长连接只建一次, 走正常限流。
424
+ if (req.method === "GET" && reqPath === "/events") return this._handleEvents(req, res);
425
+
426
+ // 静态页面与 /health 不设限, 其余 API 限流
427
+ const isOpenGet = req.method === "GET" && (reqPath === "/" || reqPath === "/index.html" || reqPath === "/health");
428
+ if (!isOpenGet && !this._rateLimit(req, res)) return;
429
+
430
+ // v2.6.0 REST 退役开关: /message* /sessions* /reset 与 /api/* 同受 mcp.legacy_rest 控制
431
+ // (默认关闭: 全部走标准 MCP 端点 /mcp; 旧脚本可配置 legacy_rest: true 恢复)
432
+ if (!this.mcpLegacyRest && LEGACY_REST_PATHS.some((p) => reqPath === p || reqPath.startsWith(p + "/"))) {
433
+ return this._json(res, 410, { error: "REST 端点已退役, 请使用标准 MCP 端点 POST /mcp (channels.http.mcp.legacy_rest=true 可恢复)" });
434
+ }
435
+
436
+ if (await this._route(req, res, reqPath)) return;
437
+ res.writeHead(404); res.end("not found");
438
+ } catch (e) {
439
+ // 兜底: 路由内异常不应泄漏内部实现细节, 也不应让连接悬空
440
+ try {
441
+ if (!res.writableEnded) this._json(res, 500, { error: publicErrorMessage(e) });
442
+ } catch (e) { debug(`[channels/http] 已忽略异常: ${e && e.message ? e.message : e}`); }
443
+ }
444
+ }
445
+
446
+ // 执行「自行写响应」的子 handler (MCP / webhook): 失败时若尚未写头则补 500
447
+ async _runHandler(fn, req, res) {
448
+ try {
449
+ await fn(req, res);
450
+ } catch (e) {
451
+ try {
452
+ if (!res.writableEnded) this._json(res, 500, { error: e.message });
453
+ } catch (e) { debug(`[channels/http] 已忽略异常: ${e && e.message ? e.message : e}`); }
454
+ }
455
+ }
456
+
457
+ // 来源 host 是否本机回环 (不校验端口 —— 浏览器 MCP 客户端场景用, 见 _applyCors)
458
+ _loopbackOrigin(req) {
459
+ const origin = req.headers["origin"];
460
+ if (!origin) return false;
461
+ try {
462
+ const host = new URL(origin).hostname.replace(/^\[|\]$/g, "");
463
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
464
+ } catch { return false; }
465
+ }
466
+
467
+ // 统一 CORS 响应头 (所有路由含 /mcp 共用)。返回 false 表示本次请求无需继续 (已响应或来源被拒)。
468
+ // v3.0.1 (P1#9) 收紧默认语义: 原默认 `*` 把所有 API 响应对任意跨域页面开放。
469
+ // 自带 Web UI 与服务同源, 根本不需要 ACAO; 非浏览器客户端没有 Origin 头, 也不需要。
470
+ // 现默认只回显「本机回环 + 同端口」来源 (即自带 UI);
471
+ // 例外: /mcp 端点对本机回环任意端口回显 —— 浏览器形态 MCP 客户端 (MCP Inspector 等)
472
+ // 是正当场景, 且 /mcp 有 Bearer 门禁, 跨域可读的只是 401 拒绝体, 不泄任何凭据。
473
+ // 其余跨域需求显式配 channels.http.cors_origin 白名单。
474
+ _applyCors(req, res, reqPath = "") {
475
+ const reqOrigin = req.headers.origin;
476
+ let allowOrigin = null;
477
+ if (this.corsOrigins.length) {
478
+ allowOrigin = reqOrigin && this.corsOrigins.includes(reqOrigin) ? reqOrigin : null;
479
+ if (reqOrigin && !allowOrigin) {
480
+ this._json(res, 403, { error: "origin not allowed" });
481
+ return false;
482
+ }
483
+ } else if (reqOrigin) {
484
+ if (this._originTrusted(req)) allowOrigin = reqOrigin;
485
+ else if (reqPath === this.mcpPath && this._loopbackOrigin(req)) allowOrigin = reqOrigin;
486
+ }
487
+ if (allowOrigin) {
488
+ res.setHeader("Access-Control-Allow-Origin", allowOrigin);
489
+ res.setHeader("Vary", "Origin");
490
+ }
491
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
492
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name");
493
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return false; }
494
+ return true;
495
+ }
496
+
497
+ // 业务路由。返回 true 表示已处理 (响应已写); false 交给 404 兜底。
498
+ // 2026-09-18 重构: 原为一个 161 行的巨型分支链, 现按域拆成 4 个具名子路由 + 1 个 API 门禁,
499
+ // 每个子路由「不匹配 → undefined/false, 匹配 → 已写响应并返回 true」。分支顺序与语义完全不变。
500
+ async _route(req, res, reqPath) {
501
+ const get = req.method === "GET";
502
+ const post = req.method === "POST";
503
+
504
+ if (this._routeHealth(req, res, get, reqPath)) return true;
505
+ if (await this._routeChat(req, res, post, reqPath)) return true;
506
+ if (await this._routeSessions(req, res, get, post, reqPath)) return true;
507
+
508
+ // 静态界面三态: undefined = 不匹配, true/false = 已匹配且该值即路由结果
509
+ const stat = this._routeStatic(req, res, get, reqPath);
510
+ if (stat !== undefined) return stat;
511
+
512
+ // API 门禁 (REST 退役开关 + 鉴权); 返回 true 表示已拦下并写响应
513
+ if (this._gateApi(req, res, reqPath)) return true;
514
+ return this._apiRoute(req, res, reqPath);
515
+ }
516
+
517
+ // ---- 路由域 1: 健康检查 ----
518
+ _routeHealth(req, res, get, reqPath) {
519
+ if (!get || reqPath !== "/health") return false;
520
+ this._json(res, 200, {
521
+ status: "ok",
522
+ agent: this.agent.config.agent?.name || "ppx",
523
+ app: "ppxans-harness",
524
+ version: this._pkgVersion(),
525
+ web: "/",
526
+ mcp: this.mcpEnabled ? this.mcpPath : null,
527
+ uptime_ms: Math.round(process.uptime() * 1000),
528
+ });
529
+ return true;
530
+ }
531
+
532
+ // ---- 路由域 2: 对话 (POST /message 与 SSE 流式) ----
533
+ async _routeChat(req, res, post, reqPath) {
534
+ if (!post) return false;
535
+
536
+ // 非流式对话: POST /message
537
+ if (reqPath === "/message") {
538
+ const req0 = await this._readChatRequest(req, res);
539
+ if (!req0) return true;
540
+ const { text, sessionKey } = req0;
541
+ if (!this._acquire(res)) return true;
542
+ try {
543
+ const reply = await this.agent.chat(String(text), { sessionKey });
544
+ this._json(res, 200, { reply, sessionId: sessionKey, agent: this.agent.config.agent?.name || "ppx" });
545
+ } catch (e) {
546
+ this._fail(res, e, 500);
547
+ } finally { this._release(); }
548
+ return true;
549
+ }
550
+
551
+ // SSE 流式对话: POST /message/stream 或 /chat
552
+ if (reqPath === "/message/stream" || reqPath === "/chat") {
553
+ const req0 = await this._readChatRequest(req, res);
554
+ if (!req0) return true;
555
+ const { text, sessionKey } = req0;
556
+ // 2026-09-17 修复 (P0-3 / P1-1):
557
+ // ① 并发护栏必须在 writeHead(200) 之前判定 —— 原先先发 200 SSE 头再 _acquire,
558
+ // 超限时 _acquire 内的 writeHead(429) 抛 ERR_HTTP_HEADERS_SENT, 被外层 catch
559
+ // 转成一条 SSE error 事件, 客户端拿到 "HTTP 200 + text/event-stream" 却永远等不到
560
+ // 内容 (实测可复现), 同时把 Node 内部错误原文泄漏给了客户端。
561
+ // ② 发完 200 后立刻 flushHeaders() —— 否则响应头要等到第一次 res.write 才下发,
562
+ // LLM 慢首包时客户端迟迟收不到任何响应头 (实测 6s 无任何响应)。
563
+ if (!this._acquire(res)) return true;
564
+ res.writeHead(200, SSE_HEADERS);
565
+ if (typeof res.flushHeaders === "function") res.flushHeaders();
566
+ // v3.0.1 (P0#2): 客户端中途断连 → res 变 destroyed, 后续 write 触发 ERR_STREAM_DESTROYED
567
+ // 且 res 无 error 监听器 → 每个 delta 炸一次 uncaughtException (错误风暴)。
568
+ // ① 挂 error 监听兜底; ② close 且响应未正常结束时中断上游生成 (按会话, P1#3);
569
+ // ③ send 前检查 destroyed (writableEnded 不覆盖 destroy 场景)
570
+ res.on("error", (e) => debug(`[channels/http] SSE 流错误(已忽略): ${e && e.message ? e.message : e}`));
571
+ res.on("close", () => {
572
+ if (!res.writableEnded && typeof this.agent.interrupt === "function") {
573
+ try { this.agent.interrupt(sessionKey); } catch (e) { debug(`[channels/http] 已忽略异常: ${e && e.message ? e.message : e}`); }
574
+ debug("[channels/http] SSE 客户端断连, 已中断上游生成 (sessionKey=" + sessionKey + ")");
575
+ }
576
+ });
577
+ try {
578
+ const send = (obj) => {
579
+ if (res.writableEnded || res.destroyed) return false; // v3.0.1 (P0#2): 断连后静默丢弃
580
+ try { res.write("data: " + JSON.stringify(obj) + "\n\n"); return true; } catch (e) { debug(`[channels/http] 已忽略异常: ${e && e.message ? e.message : e}`); return false; }
581
+ };
582
+ let full = "";
583
+ const reply = await this.agent.chatStream(String(text), {
584
+ sessionKey,
585
+ onDelta: (d) => { full += d; send({ type: "delta", content: d }); },
586
+ onTool: (ev) => { send({ type: "tool", tool: ev.tool, id: ev.id, status: ev.type, args: ev.args, ok: ev.ok, durationMs: ev.durationMs }); }, // 工具调用可视化
587
+ onStep: (ev) => { send({ type: "step", round: ev.round, maxRounds: ev.maxRounds }); }, // turn/step 推理轮次进度
588
+ });
589
+ const finalContent = full || reply;
590
+ const usage = this.agent?.usageStats
591
+ ? { calls: this.agent.usageStats.calls, tokens: this.agent.usageStats.tokens }
592
+ : null;
593
+ send({ type: "done", content: finalContent, sessionId: sessionKey, usage, tokens: usage });
594
+ if (!res.writableEnded && !res.destroyed) res.end();
595
+ } catch (e) {
596
+ // 不外泄内部实现细节 (如 ERR_HTTP_HEADERS_SENT 之类)
597
+ send({ type: "error", error: publicErrorMessage(e) });
598
+ try { if (!res.destroyed) res.end(); } catch (e2) { debug(`[channels/http] 已忽略异常: ${e2 && e2.message ? e2.message : e2}`); }
599
+ } finally { this._release(); }
600
+ return true;
601
+ }
602
+
603
+ return false;
604
+ }
605
+
606
+ // ---- 路由域 3: 会话管理 (列表/历史/重命名/删除/重置/中断) ----
607
+ async _routeSessions(req, res, get, post, reqPath) {
608
+ if (get && reqPath === "/sessions") {
609
+ if (!this._requireAuth(req, res)) return true;
610
+ const list = (this.agent.sessionStore && this.agent.sessionStore.list) ? this.agent.sessionStore.list() : [];
611
+ this._json(res, 200, { sessions: list });
612
+ return true;
613
+ }
614
+ if (get && /^\/sessions\/[^/]+\/history$/.test(reqPath)) {
615
+ if (!this._requireAuth(req, res)) return true;
616
+ const key = decodeURIComponent(reqPath.split("/")[2]);
617
+ const msgs = this.agent.sessionStore && typeof this.agent.sessionStore.deriveMessages === "function"
618
+ ? this.agent.sessionStore.deriveMessages(key)
619
+ : [];
620
+ this._json(res, 200, { sessionId: key, messages: msgs });
621
+ return true;
622
+ }
623
+ if (post && reqPath === "/sessions/rename") {
624
+ if (!this._requireAuth(req, res)) return true;
625
+ // 2026-09-18 修复 (缺陷 #1): 原实现把 _readBody 的【原始字符串】当对象用 —— body.from / body.to
626
+ // 恒为 undefined, rename 实际收到 (undefined, undefined) 后恒返回 false, 前端
627
+ // post("/sessions/rename", {from,to}) 的契约从未生效 (重命名一直失败)。改为解析 JSON body。
628
+ const data = await this._readJson(req, res);
629
+ if (data === null) return true;
630
+ const ok = this.agent.sessionStore && typeof this.agent.sessionStore.rename === "function"
631
+ ? this.agent.sessionStore.rename(data.from, data.to) : false;
632
+ this._json(res, ok ? 200 : 404, { ok });
633
+ return true;
634
+ }
635
+ if (post && reqPath === "/sessions/delete") {
636
+ if (!this._requireAuth(req, res)) return true;
637
+ // 2026-09-18 修复 (缺陷 #2): 同上 —— 原实现 body.key 恒为 undefined → `|| "default"` 兜底,
638
+ // 于是"删除某个会话"实际删掉的是 default 主会话 (误删数据)。改为解析 JSON body。
639
+ const data = await this._readJson(req, res);
640
+ if (data === null) return true;
641
+ // 2026-10-03 修复 (P0-2): 前端 delSession 发的是 { sessionKey }, 后端只读 data.key →
642
+ // 恒 undefined → || "default" 兜底把主会话误删 (9-18 修复只改了后端解析方式,
643
+ // 前端字段名没对齐, 缺陷原样存活)。现在两侧字段名兼容读取。
644
+ if (this.agent.sessionStore) this.agent.sessionStore.delete(data.key || data.sessionKey || "default");
645
+ this._json(res, 200, { ok: true });
646
+ return true;
647
+ }
648
+ if (post && reqPath === "/reset") {
649
+ if (!this._requireAuth(req, res)) return true;
650
+ const data = await this._readJson(req, res);
651
+ if (data === null) return true;
652
+ this.agent.resetSession(data.sessionId || "default");
653
+ this._json(res, 200, { ok: true });
654
+ return true;
655
+ }
656
+ if (post && reqPath === "/interrupt") {
657
+ if (!this._requireAuth(req, res)) return true;
658
+ const body = await this._readBody(req, res);
659
+ if (body === null) return true;
660
+ try {
661
+ const data = JSON.parse(body || "{}");
662
+ const sessionId = data.sessionId || "default";
663
+ // v3.0.1 (P1#3): 按会话中断 —— 并发会话各断各的, 不再全进程一起停
664
+ if (typeof this.agent.interrupt === "function") this.agent.interrupt(sessionId);
665
+ this._json(res, 200, { ok: true, interrupted: true, sessionId });
666
+ } catch (e) {
667
+ this._fail(res, e);
668
+ }
669
+ return true;
670
+ }
671
+
672
+ return false;
673
+ }
674
+
675
+ // ---- 路由域 4: 静态界面 (三态返回: undefined 不匹配 / true / false) ----
676
+ _routeStatic(req, res, get, reqPath) {
677
+ if (!get) return undefined;
678
+
679
+ // 首页: 服务端注入 window.__PPX_BOOTSTRAP__ (本机回环请求附带 auth token) → 单进程免配置可用
680
+ if (reqPath === "/" || reqPath === "/index.html") {
681
+ const html = this.renderIndex(req);
682
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
683
+ res.end(html !== null ? html : "<!DOCTYPE html><meta charset=utf-8><p>皮皮虾服务已启动, 但 public/index.html 未找到。</p>");
684
+ return true;
685
+ }
686
+ // 通用静态文件服务 (public/ 下任意文件, 含 vendor/ 资源, 防路径穿越)
687
+ if (!reqPath.startsWith("/api/") && !LEGACY_REST_PATHS.some((p) => reqPath === p)) {
688
+ return this._serveStatic(res, reqPath);
689
+ }
690
+ return undefined;
691
+ }
692
+
693
+ // ---- API 门禁: REST 退役开关 + 鉴权。返回 true = 已写响应 (路由结束), false = 放行到具体 API 路由 ----
694
+ // channels.http.mcp.legacy_rest = true 时保留旧 /api/* REST 端点 (兼容旧脚本/客户端);
695
+ // 默认 false: /api/* 返回 410 Gone, 引导使用标准 MCP 端点 /mcp
696
+ _gateApi(req, res, reqPath) {
697
+ if (!reqPath.startsWith("/api/")) return false;
698
+ if (!this.mcpLegacyRest) {
699
+ this._json(res, 410, { error: "REST /api/* 已退役, 请使用标准 MCP 端点 POST /mcp (channels.http.mcp.legacy_rest=true 可恢复)" });
700
+ return true;
701
+ }
702
+ // /api/bootstrap 例外: 它本身就是"取 token"的入口, 可信本地请求免鉴权放行
703
+ // (必须用 _isTrustedLocal 而非 _isLoopback —— 否则浏览器里任意网站发来的跨源请求
704
+ // 源地址同样是 127.0.0.1, 会被误判为本机并拿到 token)
705
+ const bootstrapLocal = reqPath === "/api/bootstrap" && this._isTrustedLocal(req);
706
+ if (!bootstrapLocal && !this._requireAuth(req, res)) return true;
707
+ return false;
708
+ }
709
+
710
+ // API 路由表: 每个 handler 返回 true (已处理)
711
+ async _apiRoute(req, res, reqPath) {
712
+ const get = req.method === "GET";
713
+ // v3.0 (codex 对齐): 命令面板 / 审批流 / 目标看板 / 审查报告 / 权限热更新
714
+ if (await this._apiV3(req, res, reqPath, get)) return true;
715
+ switch (reqPath) {
716
+ // 引导信息 API (前端启动时刷新用): 版本/端口/token(仅回环)/能力开关
717
+ case "/api/bootstrap":
718
+ if (!get) return false;
719
+ this._json(res, 200, this.bootstrapPayload(req), { "Cache-Control": "no-store" });
720
+ return true;
721
+ case "/api/traces": {
722
+ if (!get) return false;
723
+ const limit = Number((req.url.split("limit=")[1] || "").split("&")[0] || 50);
724
+ this._json(res, 200, this.agent.traces.read(undefined, limit));
725
+ return true;
726
+ }
727
+ case "/api/stats":
728
+ if (!get) return false;
729
+ this._json(res, 200, this.agent.stats ? this.agent.stats() : this.agent.traces.stats());
730
+ return true;
731
+ case "/api/memory": {
732
+ if (!get) return false;
733
+ const facts = this.agent.facts ? this.agent.facts.list().slice(0, 20).map((f) => ({ content: f.content, score: f.score, type: f.type })) : [];
734
+ const scenes = this.agent.scenes ? this.agent.scenes.listWithDesc().slice(-10) : [];
735
+ this._json(res, 200, { facts, scenes });
736
+ return true;
737
+ }
738
+ // 主动任务生成 API (ANS 自主性): 扫描记忆待办/偏好, 返回主动提醒 + 结构化 items (含 id)
739
+ case "/api/proactive":
740
+ if (!get) return false;
741
+ try {
742
+ const out = await suggestProactive(this.agent);
743
+ this._json(res, 200, { message: out ? out.text : null, items: out ? out.items : [] });
744
+ } catch (e) {
745
+ this._json(res, 200, { message: null, items: [], error: e.message });
746
+ }
747
+ return true;
748
+ // 标记待办完成 (ANS): POST /api/proactive/done { id } → 之后不再主动提醒
749
+ case "/api/proactive/done": {
750
+ if (req.method !== "POST") return false;
751
+ if (!this._requireAuth(req, res)) return true;
752
+ const body = await this._readBody(req, res);
753
+ if (body === null) return true;
754
+ try {
755
+ const data = JSON.parse(body || "{}");
756
+ const ok = this.agent.proactiveMarkDone(data.id);
757
+ this._json(res, ok ? 200 : 404, { ok });
758
+ } catch (e) {
759
+ this._fail(res, e);
760
+ }
761
+ return true;
762
+ }
763
+ // 生命周期状态 API (ANS): 阶段/年龄/进化/繁衍
764
+ case "/api/lifecycle":
765
+ if (!get) return false;
766
+ this._json(res, 200, this.agent.lifecycleStatus ? this.agent.lifecycleStatus() : {});
767
+ return true;
768
+
769
+ // 提供方 API - 模型配置 Web UI 后端
770
+ // GET 列出 (key 抹掉, 只回 api_key_set) / POST 新增 / PUT 更新 / DELETE 删除
771
+ case "/api/providers":
772
+ return this._apiProviders(req, res);
773
+ // 健康探测 (body: { id }), 复用 LLMClient.health()
774
+ case "/api/providers/test":
775
+ if (req.method !== "POST") return false;
776
+ return this._apiProviderTest(req, res);
777
+ // 重排 (body: { order: [id1, id2, ...] })
778
+ case "/api/providers/reorder":
779
+ if (req.method !== "POST") return false;
780
+ return this._apiProviderReorder(req, res);
781
+
782
+ // 通用设置 API: 用户名 / HTTP 端口 / 安全 / agent 预设
783
+ case "/api/settings":
784
+ return this._apiSettings(req, res);
785
+
786
+ // 工作区文件树 + 读取 (Web UI 项目文件引用)
787
+ case "/api/workspace/tree":
788
+ if (!get) return false;
789
+ return this._apiWorkspaceTree(res, req);
790
+ case "/api/workspace/read":
791
+ if (!get) return false;
792
+ return this._apiWorkspaceRead(res, req);
793
+
794
+ default:
795
+ return false;
796
+ }
797
+ }
798
+
799
+ // ---- 静态文件 ----
800
+ _serveStatic(res, reqPath) {
801
+ const file = path.resolve(this.publicDir, reqPath.replace(/^\//, ""));
802
+ if (file.startsWith(this.publicDir + path.sep) && fs.existsSync(file) && fs.statSync(file).isFile()) {
803
+ const ext = path.extname(file).toLowerCase();
804
+ const mime = {
805
+ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
806
+ ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png",
807
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif",
808
+ ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json",
809
+ }[ext] || "application/octet-stream";
810
+ const headers = { "Content-Type": mime };
811
+ if ([".html", ".js", ".mjs", ".css"].includes(ext)) headers["Cache-Control"] = "no-cache";
812
+ res.writeHead(200, headers);
813
+ res.end(fs.readFileSync(file));
814
+ return true;
815
+ }
816
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
817
+ res.end("not found");
818
+ return true;
819
+ }
820
+
821
+ // ---- v3.0 API (codex 对齐): 命令/审批/看板/审查/权限热更新 ----
822
+ // 全部端点失败时静默 404 (前端优雅降级隐藏对应能力)
823
+ async _apiV3(req, res, reqPath, get) {
824
+ const agent = this.agent;
825
+ // GET /api/commands — 斜杠命令清单 (claude-code 命令模型)
826
+ if (get && reqPath === "/api/commands") {
827
+ const cmds = agent.commands?.list ? agent.commands.list(true) : [];
828
+ this._json(res, 200, { commands: cmds.map((c) => ({ name: c.name, description: c.description || "", argumentHint: c.argumentHint || "" })) });
829
+ return true;
830
+ }
831
+ // GET /api/approvals/pending — 待审批清单 (codex approval flow)
832
+ if (get && reqPath === "/api/approvals/pending") {
833
+ this._json(res, 200, { approvals: agent.pendingApprovals ? agent.pendingApprovals() : [] });
834
+ return true;
835
+ }
836
+ // POST /api/approvals/:id — 审批裁决 { decision: "approve" | "deny" }
837
+ if (req.method === "POST" && reqPath.startsWith("/api/approvals/")) {
838
+ const id = reqPath.slice("/api/approvals/".length);
839
+ const body = await this._readBody(req, res);
840
+ if (body === null) return true;
841
+ let decision = "deny";
842
+ try { decision = JSON.parse(body || "{}").decision || "deny"; } catch (e) { debug(`[channels/http] 已忽略异常: ${e && e.message ? e.message : e}`); }
843
+ const ok = agent.resolveApproval ? agent.resolveApproval(id, decision) : false;
844
+ this._json(res, ok ? 200 : 404, { ok });
845
+ return true;
846
+ }
847
+ // GET /api/goalboard — 目标看板 (OMH goal board)
848
+ if (get && reqPath === "/api/goalboard") {
849
+ const board = agent.goalBoard;
850
+ if (!board) { this._json(res, 200, { goals: [] }); return true; }
851
+ const goals = board.list ? board.list() : [];
852
+ this._json(res, 200, { goals, text: board.render ? board.render() : "" });
853
+ return true;
854
+ }
855
+ // GET /api/review/latest — 最近一次审查报告 (OCR 分级流水线)
856
+ if (get && reqPath === "/api/review/latest") {
857
+ this._json(res, 200, agent._lastReview || { issues: [], report: null });
858
+ return true;
859
+ }
860
+ // POST /api/permissions — 权限热更新 { approvalMode?, sandbox?, networkAccess? }
861
+ if (req.method === "POST" && reqPath === "/api/permissions") {
862
+ const body = await this._readBody(req, res);
863
+ if (body === null) return true;
864
+ if (!agent.permissions) { this._json(res, 200, { ok: false, reason: "权限引擎未装配" }); return true; }
865
+ let cfg = {};
866
+ try { cfg = JSON.parse(body || "{}"); } catch (e) { debug(`[channels/http] 已忽略异常: ${e && e.message ? e.message : e}`); }
867
+ if (cfg.approvalMode) agent.permissions.approvalMode = cfg.approvalMode;
868
+ if (cfg.sandbox) agent.permissions.sandbox = cfg.sandbox;
869
+ if (cfg.networkAccess != null) agent.permissions.networkAccess = !!cfg.networkAccess;
870
+ this._json(res, 200, { ok: true, approvalMode: agent.permissions.approvalMode, sandbox: agent.permissions.sandbox });
871
+ return true;
872
+ }
873
+ return false;
874
+ }
875
+
876
+ // ---- 提供方 CRUD ----
877
+ async _apiProviders(req, res) {
878
+ if (!this._requireAuth(req, res)) return true;
879
+ const method = req.method;
880
+ if (method === "GET") {
881
+ const providers = listProviders(this.agent.root);
882
+ const defaultId = providers[0] ? providers[0].id : null;
883
+ this._json(res, 200, { providers, default_id: defaultId });
884
+ return true;
885
+ }
886
+ if (!["POST", "PUT", "DELETE"].includes(method)) return false;
887
+ const body = await this._readBody(req, res);
888
+ if (body === null) return true;
889
+ try {
890
+ const data = JSON.parse(body || "{}");
891
+ if (method === "POST") {
892
+ const created = addProvider(this.agent.root, data.provider || data);
893
+ this.agent.reloadProviders(); // 热重载 (新增默认时让 agent 立即可用)
894
+ this._json(res, 200, { ok: true, provider: created });
895
+ return true;
896
+ }
897
+ if (!data.id) throw new Error("缺少 id");
898
+ if (method === "PUT") {
899
+ const updated = updateProvider(this.agent.root, data.id, data.patch || {});
900
+ this.agent.reloadProviders();
901
+ this._json(res, 200, { ok: true, provider: updated });
902
+ return true;
903
+ }
904
+ const removed = removeProvider(this.agent.root, data.id);
905
+ this.agent.reloadProviders();
906
+ this._json(res, 200, { ok: true, provider: removed });
907
+ return true;
908
+ } catch (e) {
909
+ // 不存在/缺参 → 404, 其余校验失败 → 400 (保持原语义)
910
+ this._fail(res, e, /不存在|缺少/.test(e.message) ? 404 : 400);
911
+ return true;
912
+ }
913
+ }
914
+
915
+ // 提供方健康探测: 优先复用 agent 已有客户端 (含测试注入的 stub), 避免对磁盘配置的真实网络探测
916
+ async _apiProviderTest(req, res) {
917
+ if (!this._requireAuth(req, res)) return true;
918
+ const body = await this._readBody(req, res);
919
+ if (body === null) return true;
920
+ try {
921
+ const data = JSON.parse(body || "{}");
922
+ if (!data.id) throw new Error("缺少 id");
923
+ // 兜底顺序: allProviders → agent.llm → 磁盘配置临时构造
924
+ let client = (this.agent.allProviders || []).find((c) => c.providerId === data.id);
925
+ let fromCache = !!client;
926
+ if (!client && this.agent.llm && this.agent.llm.providerId === data.id) {
927
+ client = this.agent.llm;
928
+ fromCache = true;
929
+ }
930
+ if (!client) {
931
+ const { providers } = readConfig(this.agent.root);
932
+ const p = providers.find((x) => x.id === data.id);
933
+ if (!p) throw new Error("提供方不存在");
934
+ // LLM 客户端按需加载 (避免把网络栈带进未使用该端点的进程)
935
+ const { LLMClient } = await import("../llm/client.js");
936
+ client = new LLMClient(p);
937
+ }
938
+ const healthy = await client.health();
939
+ const detail = healthy
940
+ ? (fromCache ? "复用 agent 客户端, 探测通过" : "API 端点可达")
941
+ : "探测失败, 请检查 key/base_url";
942
+ this._json(res, 200, { ok: true, healthy, detail, source: fromCache ? "agent-cache" : "disk-config" });
943
+ } catch (e) {
944
+ this._fail(res, e);
945
+ }
946
+ return true;
947
+ }
948
+
949
+ async _apiProviderReorder(req, res) {
950
+ if (!this._requireAuth(req, res)) return true;
951
+ const body = await this._readBody(req, res);
952
+ if (body === null) return true;
953
+ try {
954
+ const data = JSON.parse(body || "{}");
955
+ const providers = reorderProviders(this.agent.root, data.order || []);
956
+ this.agent.reloadProviders();
957
+ this._json(res, 200, { ok: true, providers });
958
+ } catch (e) {
959
+ this._fail(res, e);
960
+ }
961
+ return true;
962
+ }
963
+
964
+ // ---- 通用设置 ----
965
+ async _apiSettings(req, res) {
966
+ if (!this._requireAuth(req, res)) return true;
967
+ if (req.method === "GET") {
968
+ this._json(res, 200, { settings: getSettings(this.agent.root) });
969
+ return true;
970
+ }
971
+ if (req.method !== "PUT") return false;
972
+ const body = await this._readBody(req, res);
973
+ if (body === null) return true;
974
+ try {
975
+ const data = JSON.parse(body || "{}");
976
+ const settings = updateSettings(this.agent.root, data.patch || {});
977
+ this.agent.reloadSettings(); // 热重载: 用户名/安全/agent 预设立即生效
978
+ this._json(res, 200, { ok: true, settings });
979
+ } catch (e) {
980
+ this._fail(res, e);
981
+ }
982
+ return true;
983
+ }
984
+
985
+ // ---- 工作区 ----
986
+ _apiWorkspaceTree(res, req) {
987
+ try {
988
+ const u = new URL(req.url, "http://127.0.0.1");
989
+ const wsRoot = path.resolve(this.agent.root);
990
+ const { tree, truncated, maxDepth, baseDir } = buildTree(wsRoot, {
991
+ root: u.searchParams.get("root") || "",
992
+ maxDepth: u.searchParams.get("maxDepth") || 3,
993
+ });
994
+ this._json(res, 200, {
995
+ ok: true, root: wsRoot, dir: baseDir, maxDepth, truncated,
996
+ tree: tree || { name: "", path: "", type: "dir", children: [] },
997
+ });
998
+ } catch (e) {
999
+ this._fail(res, e);
1000
+ }
1001
+ return true;
1002
+ }
1003
+
1004
+ _apiWorkspaceRead(res, req) {
1005
+ try {
1006
+ const u = new URL(req.url, "http://127.0.0.1");
1007
+ const out = readWorkspaceFile(path.resolve(this.agent.root), u.searchParams.get("path") || "");
1008
+ this._json(res, 200, { ok: true, ...out });
1009
+ } catch (e) {
1010
+ this._fail(res, e);
1011
+ }
1012
+ return true;
1013
+ }
1014
+
1015
+ // 结构化事件广播 (EQ 订阅回调): 尽力而为, 任何异常不得影响事件流
1016
+ _broadcastEvent(ev) {
1017
+ if (!this._sseClients.size) return;
1018
+ let frame;
1019
+ try { frame = `data: ${JSON.stringify({ type: "event", event: ev })}\n\n`; } catch { return; }
1020
+ for (const r of [...this._sseClients]) {
1021
+ try { if (!r.writableEnded) r.write(frame); else this._sseClients.delete(r); }
1022
+ catch { this._sseClients.delete(r); }
1023
+ }
1024
+ }
1025
+
1026
+ // ---- 主动提醒 SSE (2026-10-03) ----
1027
+ // GET /events: 建立 text/event-stream 长连接。鉴权兼容两种方式:
1028
+ // ① Authorization: Bearer 头 (fetch/测试场景)
1029
+ // ② ?token= 查询参数 (EventSource 无法自定义请求头; 本机服务 + safeEqual 常量时间比较)
1030
+ _handleEvents(req, res) {
1031
+ if (!this._authed(req, res)) {
1032
+ let q = null;
1033
+ try { q = new URL(req.url || "/", "http://x").searchParams.get("token"); } catch { q = null; }
1034
+ if (!q || !safeEqual(q, this.authToken)) { this._json(res, 401, { error: "unauthorized" }); return; }
1035
+ }
1036
+ // 先准入判定完再 writeHead (与 SSE 对话端点同一纪律: 拒绝不能返 200)
1037
+ res.writeHead(200, SSE_HEADERS);
1038
+ res.write("retry: 5000\n\n");
1039
+ this._sseClients.add(res);
1040
+ debug(`[channels/http] SSE 提醒客户端接入 (在线=${this._sseClients.size})`);
1041
+ req.on("close", () => {
1042
+ this._sseClients.delete(res);
1043
+ if (!this._sseClients.size && this._sseBeat) { clearInterval(this._sseBeat); this._sseBeat = null; }
1044
+ });
1045
+ if (!this._sseBeat) {
1046
+ this._sseBeat = setInterval(() => {
1047
+ for (const r of [...this._sseClients]) {
1048
+ try { if (!r.writableEnded) r.write(": ping\n\n"); else this._sseClients.delete(r); }
1049
+ catch { this._sseClients.delete(r); }
1050
+ }
1051
+ if (!this._sseClients.size && this._sseBeat) { clearInterval(this._sseBeat); this._sseBeat = null; }
1052
+ }, 25000);
1053
+ this._sseBeat.unref?.(); // 不阻止进程退出
1054
+ }
1055
+ }
1056
+
1057
+ async send(to, text) {
1058
+ // 2026-10-03 修复: 原为 no-op (`return text`), server.js 主动提醒 ticker 调
1059
+ // manager.broadcast(payload.text) 时 Web UI 永远收不到。升级为 SSE 广播:
1060
+ // 所有 GET /events 在线客户端实时收到 {type:"reminder", text}。无订阅者时静默。
1061
+ if (this._sseClients.size) {
1062
+ const frame = `data: ${JSON.stringify({ type: "reminder", text })}\n\n`;
1063
+ for (const r of [...this._sseClients]) {
1064
+ try { if (!r.writableEnded) r.write(frame); else this._sseClients.delete(r); }
1065
+ catch { this._sseClients.delete(r); }
1066
+ }
1067
+ }
1068
+ return text; // 保持 Channel 契约: 返回投递文本
1069
+ }
1070
+
1071
+ // 连通性测试: 用独立实例起临时 server 验证端口可绑定
1072
+ async test() {
1073
+ try {
1074
+ await this.connect();
1075
+ await this.disconnect();
1076
+ return { ok: true, detail: `HTTP 通道可启动: http://${this.host}:${this.port}` };
1077
+ } catch (e) {
1078
+ return { ok: false, detail: e.message };
1079
+ }
1080
+ }
1081
+
1082
+ async disconnect() {
1083
+ if (this._unsubBus) { try { this._unsubBus(); } catch { /* 重复退订无副作用 */ } this._unsubBus = null; }
1084
+ if (this._sseBeat) { clearInterval(this._sseBeat); this._sseBeat = null; }
1085
+ for (const r of [...this._sseClients]) { try { r.end(); } catch { /* 客户端已断 */ } }
1086
+ this._sseClients.clear();
1087
+ if (this.server) { await new Promise((r) => this.server.close(r)); this.server = null; }
1088
+ this.connected = false;
1089
+ }
1090
+ }