mingdao-harness 0.2.5 → 0.2.6

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.
@@ -0,0 +1,58 @@
1
+ # 六方技术评估 · 第二轮甄别与迭代方案(v0.2.5 → v0.2.6)
2
+
3
+ > 2026-09-02。对 CodeBuddy / MiniMax / Kimi / OfficeACE / workbuddy / CodeArts Agent 六家对
4
+ > MingDao Harness v0.2.5 的评估报告逐条对照真实代码甄别(每份报告均由独立子代理核实:
5
+ > 文件+行号证据、区分「真实缺陷」与「过时/误判/空泛套话」)。已修复项随本轮提交 `20693ee`。
6
+
7
+ ## 一、本轮已修复(按严重度)
8
+
9
+ ### 崩溃类(0.2.2 教训:打包期无声、运行期爆炸)
10
+ 1. **域路由 400/403 双写崩溃【最高】**:C1 拆分后 `json()` 无返回值,`return json(...)` 被编排器视为「未处理」→ 兜底 404 对已结束响应二次写入 → Node 18 未捕获 write-after-end 整服务崩溃(CI ubuntu-18 腿连续红灯的真因,报告们误归因于 Windows)。修复:`json()` 返回 truthy。
11
+ 2. **reasoning_content 工具轮未完整回传**:DeepSeek thinking_mode 官方要求带 tool_calls 的 assistant 消息在后续请求中原样回传 reasoning_content,否则 400——多轮工具会话真实 API 必崩(mock 测试看不见)。修复:回填完整 reasoning;裁剪逻辑只对纯文本回复生效。
12
+ 3. **降级模型零校验**:`downgradeModel` 填错/跨服务商 → provider.chat 必然 400 终止回合。修复:校验同服务商,失败按 block 处理并给修复指引。
13
+ 4. **reasoning_effort 无门控**:全局配置发给不支持 reasoning 的模型 → 400。修复:仅 `supportsReasoning` 模型发送;`/think off` 改为显式 `thinking:{type:'disabled'}`(此前 off 无效,pro 默认思考开启)。
14
+
15
+ ### 安全类
16
+ 5. **SSRF 域名 DNS 重绑定绕过**:校验不解析域名。修复:`validateRemoteUrl` 对域名做 `dns.lookup` 全量 A/AAAA 复检;IPv6 字面量去括号归一(`[::1]`/`[::ffff:127.0.0.1]` 此前直接放行)。
17
+ 6. **readBody limit 形同虚设**:1MB 注释 vs 实际 40MB 全接口。修复:limit 参数生效 + 超限返回真 413(排水保留连接)+ 契约断言;chat 保留 40MB(附件 base64)。
18
+ 7. **project 级技能无指纹校验**:仓库投毒可直通提示词注入。修复:project 级同样校验 `.source.json` 指纹(无指纹的旧仓库不受影响)。
19
+ 8. **Windows fs-browse 白名单过宽**:家目录覆盖整个用户配置树(AppData)。修复:win32 收紧为 桌面/文档/下载 + 启动/工作目录 + 显式授权;路径比较大小写归一。
20
+
21
+ ### 省钱与正确性类
22
+ 9. **护栏在途费用失明**:长回合内统计文件不变,可烧穿日限。修复:回合内累计 usage 保守估算并入检查/前置拦截。
23
+ 10. **语义回收缺陷**:>80% 即回收(能塞下也截断丢内容);回收窗口与被丢弃前缀不相交(白做)。修复:仅真超预算触发 + 按丢弃边界回收 + 只接受变短替换。
24
+ 11. **工具 Schema 会话内多次变化破坏前缀缓存**(三家共识):每轮剥描述 + 只读阶段翻转 + MCP 晚到 → 前缀反复失配。**列入 v0.2.6 计划**(需要在线基准裁决收益,见下)。
25
+ 12. **英文任务只读死锁/误路由**:写意图与生成意图正则纯中文,英文会话整回合只读、生成任务落 8192 输出。修复:双语正则 + 路由标注集英文子集(113 条)。
26
+ 13. 杂项:PID 归属 `null`(无法校验)不再盲杀;SSE 尾字节并入 buf 统一行解析;原子写陈旧锁回收 TOCTOU 比对;tokenCache 整体清空改 LRU;`todayCost` 读失败返回 null + 一次性告警(不再静默归零)。
27
+
28
+ ### 质量基础设施
29
+ 14. CI 补跑 `api-contracts`(此前漏掉);`run-all` spawn 加 error 监听 + Windows `npm.cmd`;e2e-web 越界用例改用系统目录(Windows %TEMP% 在家目录内的假阴性)。
30
+
31
+ ## 二、下一迭代(v0.2.6)方案
32
+
33
+ **A. 前缀缓存稳定性(省钱核心,唯一需要「先测量再动手」的项)**
34
+ - A1:会话内工具 Schema 两态冻结——回合首轮定档后恒定(不再逐轮剥描述/MCP 晚到突变),回合间才更新;
35
+ - A2:MCP 预热:启动时 await 连接(带超时,超时本会话冻结工具集);
36
+ - A3:新建 `test/bench/bench-cost.mjs`——mock 模型费用对照(schema 收益、前缀命中、护栏拦截),把「48%/1/30/半价」营销数字变成可回归断言;
37
+ - A4:`trimMessages` 前沿对齐(P1-1:轮重写头部消息导致前缀整体失配)——在 A3 基准下裁决是否采纳。
38
+
39
+ **B. 会话与索引扩展**
40
+ - B1:session-index 按会话名分片(>1000 会话时避免全量 JSON.parse);
41
+ - B2:常量单源化(MAX_CONCURRENT=8 实测值、200KB/5MB 附件上限去重,防 app.js/attachments.js/fs-tools.js 漂移)。
42
+
43
+ **C. 桌面与 WebUI**
44
+ - C1:面板推开布局(已随本次提交:轨迹/子代理/任务面板以 body class 推开内容区,≤560px 退化为全宽面板;附件行不再被遮挡);
45
+ - C2:app.js(88KB)原生 ES Modules 零构建拆分(可选,收益=可维护性)。
46
+
47
+ **D. 文档与门禁**
48
+ - D1:docs/QA-REPORT.md 断言数(14→60+)、CONFIG.md 补 `costGuard.action:'downgrade'`;
49
+ - D2:CI 类型门禁统一为 tsconfig.full.json(现仍跑弱版 tsconfig.json 双轨)。
50
+
51
+ **E. 缓做/不采纳(已甄别)**
52
+ - sync-server 非 loopback 无 TLS 拒启(需 `--insecure` 逃生门,改行为需用户确认);87 处空 catch 加 debug 开关(收益低,优先关键路径);WebUI SPA 拆分;i18n;SQLite(需 Node≥22.5,破坏 18/20 兼容矩阵);报告中的 roadmap 型空泛项(收益数字无测量依据)。
53
+
54
+ ## 三、验收门槛(防 0.2.2 式回归)
55
+ 1. 6 套测试全绿 + api-contracts 每域契约 + 新增 bench-cost 断言;
56
+ 2. strict 棘轮 0/0 不回升;覆盖率 ≥60% 门禁;
57
+ 3. **每次发版前 CI 打包冒烟(xvfb 真实运行打包产物主进程)**——已固化,不可省略;
58
+ 4. 新增「Node 18 兼容」意识:CI 已有 ubuntu-18 腿(本次双写崩溃即由它抓住),任何路由改动必须过该腿。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "desktop": "npm --prefix desktop start",
55
55
  "desktop:dist": "node scripts/sync-versions.mjs && npm --prefix desktop run dist:dir",
56
56
  "desktop:sync": "node scripts/sync-versions.mjs",
57
- "bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs",
57
+ "bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs && node test/bench/bench-cost.mjs",
58
58
  "coverage": "node test/run-all.mjs --coverage && node scripts/coverage-report.mjs",
59
59
  "typecheck:strict": "node scripts/strict-ratchet.mjs",
60
60
  "preversion": "node test/smoke.js && node test/e2e-local.js && npm run bench",
package/src/agent.js CHANGED
@@ -13,6 +13,7 @@ import { subagentModel } from './routing.js';
13
13
  import { writeAudit, redactSecrets } from './audit.js';
14
14
  import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
15
15
  import { estimateCost } from './pricing.js';
16
+ import { resolveProviderConfig } from './providers/index.js';
16
17
 
17
18
  const MAX_STEPS = 24;
18
19
  const SUBAGENT_MAX_STEPS = 12;
@@ -45,15 +46,18 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
45
46
  // 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
46
47
  // + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
47
48
  const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo']);
48
- const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试/;
49
+ // 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
50
+ const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试|implement|fix|create|modify|update|delete|deploy|build|make|generate|install|write|refactor|migrate|test|run|commit|push|remove|add|change|patch/i;
49
51
  const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
50
- const toolsFor = (/** @type {boolean} */ readOnlyPhase) => {
51
- const schemas = buildToolSchemas(usedToolNames, mcpSchemas());
52
+ // A1(前缀稳定):剥描述集合按「回合冻结快照」——回合内恒定(至多两态:只读档/全量档),
53
+ // 新使用的工具只在下一回合才进入剥描述集合;回合边界本身就有新 user 消息,schema 变化免费。
54
+ const toolsFor = (/** @type {boolean} */ readOnlyPhase, /** @type {Set<string>} */ strippedSet) => {
55
+ const schemas = buildToolSchemas(strippedSet, mcpSchemas());
52
56
  if (!readOnlyPhase) return schemas;
53
57
  return schemas.filter((/** @type {any} */ t) => {
54
58
  const n = t?.function?.name;
55
59
  if (!n) return true;
56
- if (READONLY_TIER_SET.has(n) || usedToolNames.has(n)) return true;
60
+ if (READONLY_TIER_SET.has(n) || strippedSet.has(n)) return true;
57
61
  if (n.startsWith('mcp__')) return mcp ? mcp.isReadonly(n) : false;
58
62
  return false;
59
63
  });
@@ -142,6 +146,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
142
146
  // activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
143
147
  let activeModel = modelName;
144
148
  let downgraded = false;
149
+ // 护栏在途费用(MiniMax P0):本回合已累计 usage 的保守估算(无缓存折扣),
150
+ // 护栏检查/前置拦截时并入今日已用——否则长回合内统计文件不变,可烧穿日限。
151
+ const inFlightCost = () => estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, null, new Date());
152
+ const usedTodayWithInflight = () => {
153
+ const today = todayCost();
154
+ return today == null ? null : today + inFlightCost();
155
+ };
145
156
  // 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
146
157
  // 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
147
158
  let readOnlyPhase = true;
@@ -151,6 +162,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
151
162
  } else {
152
163
  readOnlyPhase = false;
153
164
  }
165
+ // A1:本回合的剥描述冻结快照(会话级 usedToolNames 的副本)——回合内 schema 字节不变
166
+ const turnStrippedSet = new Set(usedToolNames);
154
167
  // 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
155
168
  const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
156
169
  const ctx = makeCtx();
@@ -197,14 +210,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
197
210
  }
198
211
  const trimmed = trimMessages(messages, budget, count);
199
212
 
200
- // reasoning 回填防护(MiniMax P0-2):推理链只在 UI 流式展示、不回传消息(本就不回填);
201
- // 防御性保证——任何携带 reasoning_content 的消息发送前裁剪:>4000 字仅留概括,>1000 字截尾 500。
213
+ // reasoning 回填防护(Kimi P0 修正):带 tool_calls 的 assistant 消息必须「完整」回传
214
+ // reasoning_content(DeepSeek thinking_mode 官方要求:后续请求含 tools 时缺/截断即 400,
215
+ // 多轮工具会话必崩);仅纯文本回复的 reasoning 可裁剪(不回传也不影响)。
202
216
  let sanitized = trimmed;
203
217
  for (const m of sanitized) {
204
218
  const rc = m.reasoning_content;
205
- if (typeof rc === 'string' && rc.length > 4000) {
219
+ if (typeof rc !== 'string' || (Array.isArray(m.tool_calls) && m.tool_calls.length)) continue;
220
+ if (rc.length > 4000) {
206
221
  sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
207
- } else if (typeof rc === 'string' && rc.length > 1000) {
222
+ } else if (rc.length > 1000) {
208
223
  sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
209
224
  }
210
225
  }
@@ -219,8 +234,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
219
234
  let promptTokens = 0;
220
235
  for (const m of sanitized) promptTokens += messageTokens(m, count);
221
236
  const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
222
- const used = todayCost();
223
- if (used + worst >= Number(g.dailyLimitYuan)) {
237
+ const used = usedTodayWithInflight(); // 含本回合在途费用(防长回合烧穿)
238
+ if (used == null) {
239
+ // todayCost 读取失败:无法判断,跳过前置拦截(护栏主检查同样跳过并告警)
240
+ } else if (used + worst >= Number(g.dailyLimitYuan)) {
224
241
  stripOrphanCalls();
225
242
  return {
226
243
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
@@ -253,15 +270,29 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
253
270
  }
254
271
  if (guard.downgrade && !downgraded) {
255
272
  if (guard.downgradeModel !== activeModel) {
256
- activeModel = guard.downgradeModel;
257
- downgraded = true;
258
- io.print(style(guard.message, C.yellow));
273
+ // MiniMax P0:降级目标零校验会崩溃——必须与当前模型同服务商且已有 Key,
274
+ // 否则 provider.chat 必然 400;校验失败按 block 处理并给修复指引。
275
+ const curPc = resolveProviderConfig(cfg, activeModel);
276
+ const dgPc = resolveProviderConfig(cfg, guard.downgradeModel);
277
+ // Key 归属服务商(provider 级),同服务商即天然共享同一 Key,无需再查 apiKey
278
+ if (dgPc && dgPc.name === curPc.name) {
279
+ activeModel = guard.downgradeModel;
280
+ downgraded = true;
281
+ io.print(style(guard.message, C.yellow));
282
+ } else {
283
+ stripOrphanCalls();
284
+ return {
285
+ text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
286
+ note: `费用护栏想降级到 ${guard.downgradeModel},但它与当前服务商不一致或缺少 API Key——已暂停执行。请把 config.costGuard.downgradeModel 改为与当前模型同服务商(当前:${curPc.name})的模型名,或调高 dailyLimitYuan。`,
287
+ durationMs: Date.now() - startedAt, perf: perf(),
288
+ };
289
+ }
259
290
  } else {
260
291
  // 已经是降级目标模型:无法再降,按 block 处理
261
292
  stripOrphanCalls();
262
293
  return {
263
294
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
264
- note: `今日费用已达上限(实际 ¥${String(todayCost().toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
295
+ note: `今日费用已达上限(实际 ¥${String((todayCost() ?? 0).toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
265
296
  durationMs: Date.now() - startedAt, perf: perf(),
266
297
  };
267
298
  }
@@ -284,10 +315,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
284
315
  res = await provider.chat({
285
316
  model: activeModel,
286
317
  messages: sanitized,
287
- tools: toolsFor(readOnlyPhase),
318
+ tools: toolsFor(readOnlyPhase, turnStrippedSet),
288
319
  temperature,
289
320
  maxTokens: maxOutput,
290
- reasoningEffort,
321
+ // MiniMax P0:仅当 activeModel 声明支持 reasoning 才发送(否则 400 终止回合);'off' 显式禁用例外
322
+ reasoningEffort: modelPreset(activeModel)?.supportsReasoning || reasoningEffort === 'off' ? reasoningEffort : undefined,
291
323
  signal: ac.signal,
292
324
  onDelta(/** @type {any} */ d) {
293
325
  io.stopSpinner();
@@ -328,7 +360,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
328
360
 
329
361
  if (res.toolCalls?.length) {
330
362
  io.endTurn();
331
- const assistantMsg = { role: 'assistant', content: res.text || null, tool_calls: res.toolCalls };
363
+ const assistantMsg = {
364
+ role: 'assistant',
365
+ content: res.text || null,
366
+ tool_calls: res.toolCalls,
367
+ // Kimi P0:带工具调用的消息必须携带完整 reasoning_content(否则下一轮 400)
368
+ ...(res.toolCalls?.length && res.reasoning ? { reasoning_content: res.reasoning } : {}),
369
+ };
332
370
  messages.push(assistantMsg);
333
371
 
334
372
  // 只读工具并行(P2-8):同一 response 里的连续 read/ls/glob/grep 无相互依赖,
@@ -57,10 +57,16 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
57
57
  try {
58
58
  const st = fs.statSync(lockPath);
59
59
  if (Date.now() - st.mtimeMs > staleMs) {
60
+ // TOCTOU 防护(OfficeACE 报告):unlink 前读锁内容并二次 stat 比对,
61
+ // 防两个进程同时判定陈旧、后者误删前者刚创建的新锁(互斥失效)
60
62
  try {
61
- fs.unlinkSync(lockPath);
63
+ const before = fs.readFileSync(lockPath, 'utf8');
64
+ const st2 = fs.statSync(lockPath);
65
+ if (st2.mtimeMs === st.mtimeMs && before === fs.readFileSync(lockPath, 'utf8')) {
66
+ fs.unlinkSync(lockPath);
67
+ }
62
68
  } catch {}
63
- continue; // 回收陈旧锁后立即重试
69
+ continue; // 无论是否回收成功都重试一次(若锁刚被他人更新则继续等待)
64
70
  }
65
71
  } catch {
66
72
  continue; // 锁文件刚被释放
package/src/cli.js CHANGED
@@ -368,15 +368,19 @@ async function main() {
368
368
  },
369
369
  };
370
370
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
371
- startMcpServers(cfg.mcpServers, workingDir)
372
- .then((mgr) => {
373
- mcpManager = mgr;
374
- const ready = mgr.status().filter((s) => s.ok).length;
375
- if (io && !opts.prompt.length) {
376
- io.print(style(`✓ MCP 就绪:${ready}/${mgr.status().length} 个服务器,共 ${mgr.toolSchemas().length} 个工具`, C.dim));
377
- }
378
- })
379
- .catch(() => {});
371
+ // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
372
+ mcpManager = await Promise.race([
373
+ startMcpServers(cfg.mcpServers, workingDir).catch(() => null),
374
+ new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
375
+ ]);
376
+ if (mcpManager) {
377
+ const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
378
+ if (io && !opts.prompt.length) {
379
+ io.print(style(`✓ MCP 就绪:${ready}/${mcpManager.status().length} 个服务器,共 ${mcpManager.toolSchemas().length} 个工具`, C.dim));
380
+ }
381
+ } else if (io && !opts.prompt.length) {
382
+ io.print(style('⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao 可重试)', C.dim));
383
+ }
380
384
  }
381
385
  const sessionRef = { name: null }; // 会话名在下方 REPL 初始化中回填(审计归因用)
382
386
  // 会话文件与落盘游标共享槽:REPL(commands/repl.js)创建会话/更新游标后回填,
@@ -322,7 +322,7 @@ export async function runRepl(ctx) {
322
322
  io.print(`用法:/think low|high|max|off(当前:${cfg.reasoningEffort || '跟随模型默认 high'})`);
323
323
  continue;
324
324
  }
325
- if (arg === 'off') cfg.reasoningEffort = undefined;
325
+ if (arg === 'off') cfg.reasoningEffort = 'off'; // 显式禁用(provider 转 thinking:{type:'disabled'})
326
326
  else if (vals.includes(arg)) cfg.reasoningEffort = arg;
327
327
  else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
328
328
  agent = createAgent({
@@ -579,7 +579,7 @@ export async function runRepl(ctx) {
579
579
  if (guard) {
580
580
  io.print(
581
581
  style(
582
- `费用护栏:今日 ¥${guard.cost.toFixed(4)} / 上限 ¥${guard.limit.toFixed(2)}${guard.overLimit ? '(已达上限' + (guard.action === 'block' ? ',执行已暂停' : ',仅提醒') + ')' : ''}`,
582
+ `费用护栏:今日 ¥${(guard.cost ?? 0).toFixed(4)} / 上限 ¥${guard.limit.toFixed(2)}${guard.overLimit ? '(已达上限' + (guard.action === 'block' ? ',执行已暂停' : ',仅提醒') + ')' : ''}`,
583
583
  guard.overLimit ? C.yellow : C.dim
584
584
  )
585
585
  );
package/src/context.js CHANGED
@@ -66,19 +66,31 @@ export function trimMessages(/** @type {any} */ messages, /** @type {any} */ bud
66
66
  const system = hasSystem ? [messages[0]] : [];
67
67
  const rest = hasSystem ? messages.slice(1) : messages;
68
68
  const totalOf = (/** @type {any} */ arr) => arr.reduce((/** @type {any} */ s, /** @type {any} */ m) => s + messageTokens(m, count), 0);
69
- // —— 轻量语义回收(Hermes B1):预算 >80% 时先对最老消息做无模型压缩 ——
70
- // 最老 tool 消息 → 单行摘要;老 assistant 长文 → 前 200 字;逐条向新推进直到回到 80% 线。
71
- // 相比直接丢弃,被修改的只有前端旧消息——其后未被触碰的近期尾部字节保持不变,
72
- // 下一轮前缀缓存从首个未修改消息起继续命中(直接丢弃反而让整段前缀失配)。
73
- // 质检 M10:维护增量 total(替换消息后按新旧 token 差调整),不再每步全量重算(原 ≤500×n)。
69
+ // —— 轻量语义回收(Hermes B1 + workbuddy P0-1 修正)——
70
+ // 只在「真的超预算」时回收(此前 >80% 即动手,能塞下的会话也被白白截断丢内容);
71
+ // 回收边界 = 按尾部保留规则确定将被丢弃的前缀(此前固定扫前 500 条,超长会话时回收全白做);
72
+ // 替换仅当摘要确实更短才接受(防短消息被摘要撑长)。
74
73
  const sysTokens = totalOf(system);
75
74
  let total = sysTokens + totalOf(rest);
76
- if (total > budget * 0.8) {
75
+ if (total > budget) {
77
76
  const out = [...rest];
78
- for (let i = 0; i < out.length && i < 500; i++) {
79
- if (total <= budget * 0.8) break;
77
+ // 丢弃边界:从后往前贪心,确定尾部保留起点 s(s 之前的消息将被丢弃,只对它们回收)
78
+ let s = out.length;
79
+ {
80
+ let acc = sysTokens;
81
+ for (let i = out.length - 1; i >= 0; i--) {
82
+ const t = messageTokens(out[i], count);
83
+ if (acc + t > budget) { s = i + 1; break; }
84
+ acc += t;
85
+ }
86
+ }
87
+ for (let i = 0; i < s && i < 500; i++) {
88
+ if (total <= budget) break;
80
89
  const m = out[i];
81
90
  const c = typeof m.content === 'string' ? m.content : JSON.stringify(m?.content ?? '');
91
+ // 幂等守卫(A4):已回收过的内容跳过——否则「截断标记」把长度再次顶过阈值,逐轮嵌套重截,
92
+ // 被保留消息字节逐轮漂移(前缀缓存连尾部都失配)
93
+ if (c.includes('已回收')) continue;
82
94
  let next = null;
83
95
  if (m.role === 'tool' && c.length > 40) {
84
96
  next = { ...m, content: `[工具 ${m.tool_call_id || ''} 结果摘要:${c.slice(0, 40).replace(/\n/g, ' ')}…(已回收,原 ${c.length} 字)]` };
@@ -86,8 +98,12 @@ export function trimMessages(/** @type {any} */ messages, /** @type {any} */ bud
86
98
  next = { ...m, content: c.slice(0, 200) + ` …[已回收,原 ${c.length} 字]` };
87
99
  }
88
100
  if (next) {
89
- total += messageTokens(next, count) - messageTokens(m, count);
90
- out[i] = next;
101
+ const oldT = messageTokens(m, count);
102
+ const newT = messageTokens(next, count);
103
+ if (newT < oldT) {
104
+ total += newT - oldT;
105
+ out[i] = next;
106
+ }
91
107
  }
92
108
  }
93
109
  // 回收后仍超预算 → 从尾部保留(必要时继续丢弃最老,清洗 tool 配对)
package/src/cost-guard.js CHANGED
@@ -32,9 +32,15 @@ export function todayCost() {
32
32
  todayCache = { mtime: st.mtimeMs, size: st.size, start, sum };
33
33
  return sum;
34
34
  } catch {
35
- return 0;
35
+ // MiniMax P0:统计文件损坏/不可读时返回 null(护栏全部失效)而不是静默 0——并一次性告警
36
+ if (!todayCostWarned) {
37
+ todayCostWarned = true;
38
+ console.warn('[MingDao] 费用统计读取失败:今日费用护栏暂时无法判断(修复 cache-stats.jsonl 或重启后恢复)。');
39
+ }
40
+ return null;
36
41
  }
37
42
  }
43
+ let todayCostWarned = false;
38
44
 
39
45
  export function costGuardStatus() {
40
46
  const g = costGuardConfig();
@@ -49,8 +55,9 @@ export function costGuardStatus() {
49
55
  warnAt,
50
56
  action,
51
57
  downgradeModel: String(g.downgradeModel || 'deepseek-v4-flash'),
52
- overWarn: limit > 0 && cost >= warnAt,
53
- overLimit: limit > 0 && cost >= limit,
58
+ degraded: cost === null, // 统计不可读:护栏降级为「无法判断」
59
+ overWarn: cost !== null && limit > 0 && cost >= warnAt,
60
+ overLimit: cost !== null && limit > 0 && cost >= limit,
54
61
  };
55
62
  }
56
63
 
@@ -58,10 +65,11 @@ export function costGuardStatus() {
58
65
  export function checkCostGuard() {
59
66
  const st = costGuardStatus();
60
67
  if (!st) return null;
68
+ if (st.degraded) return null; // 统计不可读:不误拦也不放水(已告警),按无法判断处理
61
69
  if (st.overLimit && st.action === 'block') {
62
70
  return {
63
71
  blocked: true,
64
- message: `今日费用已达上限 ¥${st.limit.toFixed(2)}(实际 ¥${st.cost.toFixed(4)}),已暂停执行——调整 config.costGuard 或明天自动恢复。`,
72
+ message: `今日费用已达上限 ¥${st.limit.toFixed(2)}(实际 ¥${(st.cost ?? 0).toFixed(4)}),已暂停执行——调整 config.costGuard 或明天自动恢复。`,
65
73
  };
66
74
  }
67
75
  if (st.overLimit && st.action === 'downgrade') {
@@ -69,13 +77,13 @@ export function checkCostGuard() {
69
77
  blocked: false,
70
78
  downgrade: true,
71
79
  downgradeModel: st.downgradeModel,
72
- message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} 超上限 ¥${st.limit.toFixed(2)}——已自动降级到便宜模型 ${st.downgradeModel} 继续执行(config.costGuard.action 可改回 warn/block)。`,
80
+ message: `⚠ 费用护栏:今日已用 ¥${(st.cost ?? 0).toFixed(4)} 超上限 ¥${st.limit.toFixed(2)}——已自动降级到便宜模型 ${st.downgradeModel} 继续执行(config.costGuard.action 可改回 warn/block)。`,
73
81
  };
74
82
  }
75
83
  if (st.overWarn) {
76
84
  return {
77
85
  blocked: false,
78
- message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action !== 'warn' ? `(${st.action === 'block' ? '到达即暂停' : '到达自动降级 ' + st.downgradeModel})` : '(仅提醒)'}。`,
86
+ message: `⚠ 费用护栏:今日已用 ¥${(st.cost ?? 0).toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action !== 'warn' ? `(${st.action === 'block' ? '到达即暂停' : '到达自动降级 ' + st.downgradeModel})` : '(仅提醒)'}。`,
79
87
  };
80
88
  }
81
89
  return null;
@@ -16,7 +16,8 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
16
16
  }
17
17
  if (maxTokens) payload.max_tokens = maxTokens;
18
18
  if (responseFormat) payload.response_format = responseFormat;
19
- if (reasoningEffort) payload.reasoning_effort = reasoningEffort; // 思考强度(官方 thinking_mode)
19
+ if (reasoningEffort === 'off') payload.thinking = { type: 'disabled' }; // /think off:显式关闭思考(官方 thinking_mode)
20
+ else if (reasoningEffort) payload.reasoning_effort = reasoningEffort; // 思考强度(官方 thinking_mode)
20
21
  payload.stream = true;
21
22
  // 流式响应默认不带 usage,显式请求以便展示 token/费用统计
22
23
  // (部分网关不支持该字段,可在 config.json 设 "includeUsage": false 关闭)
@@ -153,8 +154,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
153
154
  if (handleLine(line)) break;
154
155
  }
155
156
  }
156
- // 收尾:残行(网关最后一帧不带换行)与多字节字符冲刷
157
- content += decoder.decode();
157
+ // 收尾:残行(网关最后一帧不带换行)与多字节字符冲刷——decode 残余并入 buf 统一走行解析,
158
+ // 不再直接拼进 content(此前尾字节绕过 doneFlag/行解析,流中途截断时正文混入杂质)
159
+ buf += decoder.decode();
158
160
  if (buf.trim()) handleLine(buf); // [DONE] 后残留 usage 尾帧仍吸收(正文/推理已被 doneFlag 拦截)
159
161
 
160
162
  const toolCalls = [...calls.entries()]
package/src/routing.js CHANGED
@@ -19,11 +19,11 @@ export function routingConfig(/** @type {any} */ cfg) {
19
19
  }
20
20
 
21
21
  const PLAN_HINTS =
22
- /设计|架构|重构|审查|规划|分析|方案|评估|优化|排查|疑难|报错|怎么修|修复|设计模式|选型|技术债|roadmap|review|design|refactor|plan|architecture|方案设计|评审/;
22
+ /设计|架构|重构|审查|规划|分析|方案|评估|优化|排查|疑难|报错|怎么修|修复|设计模式|选型|技术债|roadmap|review|design|refactor|plan|architecture|方案设计|评审|fix|analyze|analyse|evaluate|audit|optimize|bug|issue|error|performance/i;
23
23
 
24
24
  // 生成类任务(游戏/网页/文档等)需要大输出(planner 32K vs executor 8K),即使短句也路由 planner
25
25
  const GENERATION_HINTS =
26
- /(生成|制作|编写|创建|写|开发|实现|做).{0,30}(游戏|网页|页面|网站|应用|程序|文档|报告|简历|PPT|演示|完整|详细|小工具)|网页版|游戏/;
26
+ /(生成|制作|编写|创建|写|开发|实现|做|make|build|create|generate|write|implement|develop).{0,30}(游戏|网页|页面|网站|应用|程序|文档|报告|简历|PPT|演示|完整|详细|小工具|game|website|page|site|app|program|document|report|resume|ppt|tool)|网页版|游戏|webpage|website/i;
27
27
 
28
28
  export function heuristicRoute(/** @type {any} */ text, /** @type {any} */ rc) {
29
29
  const s = String(text ?? '');
@@ -31,7 +31,7 @@ export function heuristicRoute(/** @type {any} */ text, /** @type {any} */ rc) {
31
31
  if (PLAN_HINTS.test(s)) {
32
32
  // 规划类关键词优先(审计:此前要求 length>=40,导致「设计一个缓存方案」这类
33
33
  // 13 字短句被误路由到 flash 硬扛——关键词强度优先于文本长度)
34
- return s.length >= 40 || /设计|规划|分析|评估|审查|架构|重构|方案|优化|报错|修复/.test(s) ? rc.planner : null;
34
+ return s.length >= 40 || /设计|规划|分析|评估|审查|架构|重构|方案|优化|报错|修复|fix|analy|review|design|plan|optimiz|refactor|evaluat|audit|error|bug/i.test(s) ? rc.planner : null;
35
35
  }
36
36
  if (s.length <= 60) return rc.executor;
37
37
  return null; // 需要分类器
package/src/schedule.js CHANGED
@@ -164,7 +164,7 @@ export function removeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
164
164
  if (!job) return false;
165
165
  if (job.pid) {
166
166
  const owned = pidOwnedBy(job.pid, id); // 质检 M11:cmdline 含调度 id 才 kill
167
- if (owned !== false) {
167
+ if (owned === true) { // 非 Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
168
168
  try {
169
169
  process.kill(job.pid, 'SIGTERM');
170
170
  } catch {}
@@ -186,7 +186,7 @@ export function pauseSchedule(/** @type {any} */ home, /** @type {any} */ id) {
186
186
  if (!job) return false;
187
187
  if (job.pid) {
188
188
  const owned = pidOwnedBy(job.pid, id); // 质检 M11:cmdline 含调度 id 才 kill
189
- if (owned !== false) {
189
+ if (owned === true) { // 非 Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
190
190
  try {
191
191
  process.kill(job.pid, 'SIGTERM');
192
192
  } catch {}
package/src/skills.js CHANGED
@@ -94,8 +94,10 @@ export function listSkills(/** @type {any} */ workingDir) {
94
94
  continue;
95
95
  }
96
96
  seen.add(e.name); // 优先级:user > project > builtin,先出现的生效
97
- // P3-3:用户级技能指纹不符拒绝加载(绝不静默执行被篡改的提示词)
98
- if (source === 'user' && isTampered(path.join(dir, e.name))) continue;
97
+ // P3-3:技能指纹不符拒绝加载(绝不静默执行被篡改的提示词)。
98
+ // project 级(.mingdao/skills/)同样校验(CodeBuddy 报告:仓库投毒可直通提示词注入);
99
+ // 无 .source.json 指纹的旧仓库/手写技能不受影响(isTampered 返回 false)。
100
+ if ((source === 'user' || source === 'project') && isTampered(path.join(dir, e.name))) continue;
99
101
  out.push({
100
102
  name: e.name,
101
103
  dir: path.join(dir, e.name),
package/src/tasks.js CHANGED
@@ -115,7 +115,7 @@ function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
115
115
  try {
116
116
  owned = fs.readFileSync(`/proc/${t.pid}/cmdline`, 'utf8').includes(id);
117
117
  } catch {}
118
- if (owned !== false) {
118
+ if (owned === true) { // 非 Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
119
119
  try {
120
120
  // worker 是 detached 进程(自成进程组):优先杀整组,避免工具子进程成孤儿
121
121
  process.kill(-t.pid, 'SIGTERM');
package/src/tokenizer.js CHANGED
@@ -290,7 +290,7 @@ export function countTokens(text, modelName) {
290
290
  const hit = byModel.get(s);
291
291
  if (hit !== undefined) return hit;
292
292
  const n = countDeepseek(s);
293
- if (byModel.size >= TOKEN_CACHE_MAX_ENTRIES) byModel.clear();
293
+ if (byModel.size >= TOKEN_CACHE_MAX_ENTRIES) byModel.delete(byModel.keys().next().value); // LRU:删最旧(Map 迭代序)
294
294
  byModel.set(s, n);
295
295
  return n;
296
296
  }
package/src/web/app.js CHANGED
@@ -427,7 +427,7 @@ function renderWorkStatus(){
427
427
  }
428
428
  el.style.display=html?'flex':'none';
429
429
  el.innerHTML=html;
430
- const bg=el.querySelector('.ws-bg'); if(bg) bg.onclick=()=>{ const tp=$('#tasksPanel'); tp.style.display = tp.style.display==='none'?'flex':'none'; if(tp.style.display==='flex'){ $('#trajPanel').style.display='none'; $('#subPanel').style.display='none'; updateTasksPanel(); } };
430
+ const bg=el.querySelector('.ws-bg'); if(bg) bg.onclick=()=>{ const tp=$('#tasksPanel'); tp.style.display = tp.style.display==='none'?'flex':'none'; if(tp.style.display==='flex'){ $('#trajPanel').style.display='none'; $('#subPanel').style.display='none'; updateTasksPanel(); } syncPanelLayout(); };
431
431
  }
432
432
  // 顶部常驻活动条(静默完美解决层):生成期间始终可见,滚动不影响
433
433
  function renderLiveBar(){
@@ -483,6 +483,15 @@ function openTraj(msg){
483
483
  $('#trajPanel').style.display='flex';
484
484
  $('#tasksPanel').style.display='none'; // 仅任务面板(右侧)与轨迹互斥;子代理面板可与轨迹同显
485
485
  $('#trajRailBtn').classList.add('on');
486
+ syncPanelLayout();
487
+ }
488
+ // 面板布局同步(2026-09-02):轨迹/子代理/任务面板打开时以 body class 推开内容区,
489
+ // 聊天文本与附件行不再被固定面板遮挡;面板宽度随屏宽自动收窄(CSS 媒体查询)。
490
+ function syncPanelLayout(){
491
+ const open=(id)=>document.getElementById(id)?.style.display==='flex';
492
+ document.body.classList.toggle('traj-open', open('trajPanel'));
493
+ document.body.classList.toggle('sub-open', open('subPanel'));
494
+ document.body.classList.toggle('tasks-open', open('tasksPanel'));
486
495
  }
487
496
  function resultText(r){
488
497
  if(r==null||r===undefined) return '';
@@ -492,13 +501,14 @@ function resultText(r){
492
501
  return JSON.stringify(r);
493
502
  }
494
503
  function truncText(t,n){ t=String(t||''); return t.length>n?t.slice(0,n)+'\n…(截断,共 '+t.length+' 字)':t; }
495
- $('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); };
504
+ $('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
496
505
  $('#trajRailBtn').onclick=()=>{
497
506
  const p=$('#trajPanel');
498
507
  const willShow=p.style.display==='none';
499
508
  if(willShow){ const m=lastMsgWithTraj(); if(!m){ uiAlert('本轮还没有可展示的轨迹(先发起一个任务)'); return; } openTraj(m); }
500
509
  else p.style.display='none';
501
510
  $('#trajRailBtn').classList.toggle('on', willShow);
511
+ syncPanelLayout();
502
512
  };
503
513
  function lastMsgWithTraj(){
504
514
  const msgs=[...document.querySelectorAll('#chat .msg-ai')].reverse();
@@ -528,9 +538,10 @@ const toggleSubPanel=()=>{
528
538
  p.style.display=willShow?'flex':'none';
529
539
  $('#subRailBtn').classList.toggle('on', willShow);
530
540
  if(willShow){ $('#tasksPanel').style.display='none'; renderSubPanel(); } // 轨迹(左侧)可与子代理(右侧)同显
541
+ syncPanelLayout();
531
542
  };
532
543
  $('#subRailBtn').onclick=toggleSubPanel;
533
- $('#sbClose').onclick=()=>{ $('#subPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); };
544
+ $('#sbClose').onclick=()=>{ $('#subPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); syncPanelLayout(); };
534
545
  // —— 任务面板(多会话并行) ——
535
546
  function setBtn(){
536
547
  sendBtn.textContent = generating ? '■ 停止' : '发送';
@@ -597,8 +608,8 @@ async function refreshStatusBar(){
597
608
  $('#statusBar').textContent = s.turns+' 轮 · '+s.steps+' 步 | LLM '+fmtDur(s.llmMs)+' · 工具调用 '+fmtDur(s.toolMs)+' | 首 token 平均 '+ft+' · '+tps+' | 缓存命中 '+rate+' | 输入 '+fmtTok(s.prompt)+' tok · 输出 '+fmtTok(s.completion)+' tok';
598
609
  }
599
610
  refreshStatusBar(); setInterval(refreshStatusBar, 15000);
600
- $('#tasksBtn').onclick=()=>{ const p=$('#tasksPanel'); p.style.display = p.style.display==='none'?'flex':'none'; if(p.style.display==='flex'){ $('#subPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on'); updateTasksPanel(); } };
601
- $('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; };
611
+ $('#tasksBtn').onclick=()=>{ const p=$('#tasksPanel'); p.style.display = p.style.display==='none'?'flex':'none'; if(p.style.display==='flex'){ $('#subPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on'); updateTasksPanel(); } syncPanelLayout(); };
612
+ $('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; syncPanelLayout(); };
602
613
 
603
614
  async function refreshSessions(q){ const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions'; const r=await fetch(u).catch(()=>null); if(!r)return; const j=await r.json(); const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>'; for(const s of j.sessions){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; sel.appendChild(o); } if(currentSession&&!q) sel.value=currentSession; }
604
615
  let searchTimer=null;
@@ -25,7 +25,27 @@
25
25
  *::-webkit-scrollbar-thumb:hover{background:#545557;border:2px solid transparent;background-clip:content-box}
26
26
  *::-webkit-scrollbar-track{background:transparent}
27
27
  html,body{height:100%}
28
- body{margin:0;background:var(--bg);color:var(--text);font:15px/1.65 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;display:flex;flex-direction:column;overflow-x:hidden}
28
+ body{margin:0;background:var(--bg);color:var(--text);font:15px/1.65 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;display:flex;flex-direction:column;overflow-x:hidden;transition:padding .16s ease}
29
+ /* 面板推开布局(2026-09-02):轨迹/子代理/任务面板打开时推开内容区,聊天文本与附件行不被遮挡;
30
+ 面板宽度随屏宽自动收窄,保证聊天区始终可读(≤560px 退化为全宽面板) */
31
+ body.traj-open{padding-left:294px}
32
+ body.sub-open{padding-right:320px}
33
+ body.tasks-open{padding-right:280px}
34
+ @media (max-width:1200px){
35
+ #trajPanel{width:210px}body.traj-open{padding-left:252px}
36
+ #subPanel{width:270px}body.sub-open{padding-right:270px}
37
+ #tasksPanel{width:230px}body.tasks-open{padding-right:230px}
38
+ }
39
+ @media (max-width:860px){
40
+ #trajPanel{width:160px}body.traj-open{padding-left:202px}
41
+ #subPanel{width:190px}body.sub-open{padding-right:190px}
42
+ #tasksPanel{width:190px}body.tasks-open{padding-right:190px}
43
+ }
44
+ @media (max-width:560px){
45
+ body.traj-open,body.sub-open,body.tasks-open{padding:0}
46
+ #trajPanel{width:calc(100vw - 42px);left:42px}
47
+ #subPanel,#tasksPanel{width:100vw}
48
+ }
29
49
  header{min-height:52px;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);flex:none;flex-wrap:nowrap;white-space:nowrap;overflow-x:auto}
30
50
  header .logo{font-weight:700;font-size:16px;color:var(--accent);letter-spacing:.5px;flex:none}
31
51
  header .logo span{color:var(--text)}
@@ -208,7 +208,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
208
208
  const label = String(body.label || '').trim();
209
209
  const baseUrl = String(body.baseUrl || '').trim();
210
210
  if (!baseUrl) return json(res, 400, { error: 'API 地址(baseUrl)不能为空' });
211
- const vurl = shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
211
+ const vurl = await shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
212
212
  if (vurl.error) return json(res, 400, { error: vurl.error });
213
213
  if (action === 'addCustom' && (cfg.customModels || {})[name]) {
214
214
  return json(res, 400, { error: `自定义模型 ${name} 已存在(可修改)` });
@@ -265,7 +265,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
265
265
  if (action === 'setBaseUrl') {
266
266
  const baseUrl = String(body.baseUrl || '').trim();
267
267
  if (baseUrl) {
268
- const vurl = shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
268
+ const vurl = await shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
269
269
  if (vurl.error) return json(res, 400, { error: vurl.error });
270
270
  }
271
271
  cfg.baseUrl = baseUrl || undefined;
@@ -30,7 +30,8 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
30
30
  });
31
31
  let body;
32
32
  try {
33
- body = await readBody(req, MAX_API_BODY);
33
+ // chat 保留默认 40MB 上限(附件 base64 可达 ~26MB);普通 JSON 接口才用 MAX_API_BODY=1MB
34
+ body = await readBody(req);
34
35
  } catch (/** @type {any} */ e) {
35
36
  refs.inflight -= 1;
36
37
  res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
@@ -39,7 +39,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
39
39
  if (method === 'POST' && p === '/api/sync') {
40
40
  const body = await readBody(req, MAX_API_BODY);
41
41
  if (body.action === 'login') {
42
- const vurl = validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
42
+ const vurl = await validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
43
43
  if (vurl.error) return json(res, 400, { error: vurl.error });
44
44
  const r = await syncLogin({ url: body.url, username: body.username, password: body.password, deviceName: body.deviceName });
45
45
  if (r.error) return json(res, 400, { error: r.error });
@@ -85,11 +85,17 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
85
85
  if (method === 'GET' && p === '/api/fs-browse') {
86
86
  let dir = String(url.searchParams.get('dir') || '').trim();
87
87
  if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
88
- // 质检 A3:目录浏览限定基目录(默认家目录 + config.web.browseRoots 显式授权),
89
- // 拒绝越界——此前可枚举服务器任意绝对路径(整机目录结构探测面)
90
- const browseRoots = [os.homedir(), startupCwd, state.workingDir, ...(Array.isArray(cfg.web?.browseRoots) ? cfg.web.browseRoots : [])].map((r) => path.resolve(String(r)));
91
- const inRoot = browseRoots.some((r) => dir === r || dir.startsWith(r + path.sep));
92
- if (!inRoot) return json(res, 403, { error: '目录不在可浏览范围内(家目录或 web.browseRoots 授权目录)' });
88
+ // 质检 A3:目录浏览限定基目录,拒绝越界。Windows(CodeArts 报告):家目录覆盖整个用户配置树
89
+ // (AppData 等)——收紧为 桌面/文档/下载 三常用目录 + 启动目录 + 工作目录 + web.browseRoots 显式授权;
90
+ // 路径比较在 win32 下大小写归一(D:\\ vs d:\\ 不再误拒)。
91
+ const base = process.platform === 'win32' && process.env.USERPROFILE
92
+ ? [path.join(process.env.USERPROFILE, 'Desktop'), path.join(process.env.USERPROFILE, 'Documents'), path.join(process.env.USERPROFILE, 'Downloads')]
93
+ : [os.homedir()];
94
+ const norm = (/** @type {string} */ x) => (process.platform === 'win32' ? x.toLowerCase() : x);
95
+ const browseRoots = [...base, startupCwd, state.workingDir, ...(Array.isArray(cfg.web?.browseRoots) ? cfg.web.browseRoots : [])].map((r) => norm(path.resolve(String(r))));
96
+ const ndir = norm(dir);
97
+ const inRoot = browseRoots.some((r) => ndir === r || ndir.startsWith(r + path.sep));
98
+ if (!inRoot) return json(res, 403, { error: '目录不在可浏览范围内(授权目录或 web.browseRoots 显式添加)' });
93
99
  try {
94
100
  const st = fs.statSync(dir);
95
101
  if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
@@ -101,7 +107,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
101
107
  .slice(0, 300);
102
108
  let parent = path.dirname(dir);
103
109
  // 父目录同样不得越出基目录
104
- if (!browseRoots.some((r) => parent === r || parent.startsWith(r + path.sep))) parent = /** @type {any} */ (null);
110
+ if (!browseRoots.some((r) => norm(parent) === r || norm(parent).startsWith(r + path.sep))) parent = /** @type {any} */ (null);
105
111
  json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
106
112
  } catch (/** @type {any} */ err) {
107
113
  json(res, 400, { error: String(err?.message || err) });
package/src/web/server.js CHANGED
@@ -68,11 +68,16 @@ const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'inde
68
68
  function json(res, code, obj) {
69
69
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
70
70
  res.end(JSON.stringify(obj));
71
+ // 返回 truthy:域路由 `return json(...)` 必须被编排器视为「已处理」——
72
+ // 否则兜底 404 会对已结束的响应二次写入(Node 18 下 write-after-end 未捕获 → 整服务崩溃)
73
+ return true;
71
74
  }
72
75
 
73
- /** @param {any} req */
74
- function readBody(req) {
75
- const MAX_BODY = 40 * 1024 * 1024; // 附件 base64 上限(4×5MB 图片 + 文本)留余量
76
+ /** 读取请求体。limit 默认 40MB(附件 base64 上限:4×5MB 图片 + 文本);
77
+ * 普通 JSON 接口必须显式传较小 limit(质检 A4;此前第二参数被忽略 → 1MB 限制形同虚设)。 */
78
+ /** @param {any} req @param {number} [limit] */
79
+ function readBody(req, limit = 40 * 1024 * 1024) {
80
+ const MAX_BODY = limit; // 附件 base64 上限(4×5MB 图片 + 文本)留余量
76
81
  return new Promise((resolve, reject) => {
77
82
  /** @type {any[]} */
78
83
  const chunks = [];
@@ -89,9 +94,11 @@ function readBody(req) {
89
94
  req.on('data', (/** @type {any} */ d) => {
90
95
  size += d.length;
91
96
  if (size > MAX_BODY) {
92
- const err = /** @type {Error & { status?: number }} */ (new Error('请求体过大(>40MB)'));
97
+ const err = /** @type {Error & { status?: number }} */ (new Error(`请求体过大(>${Math.round(MAX_BODY / 1048576)}MB)`));
93
98
  err.status = 413;
94
- req.destroy();
99
+ // 排空残余数据但保留连接:让上层 catch 返回真 413(此前 destroy 导致客户端只收到连接重置)
100
+ req.pause();
101
+ req.resume();
95
102
  reject(err);
96
103
  return;
97
104
  }
@@ -184,15 +191,15 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
184
191
  const provider = await getProviderFor(modelName);
185
192
  const undoStore = { backups: new Map() };
186
193
 
187
- // MCP:后台启动,就绪后工具并入后续轮次
194
+ // MCP:A2 预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
188
195
  /** @type {any} */
189
196
  let mcpManager = null;
190
197
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
191
- startMcpServers(cfg.mcpServers, workingDir)
192
- .then((m) => {
193
- mcpManager = m;
194
- })
195
- .catch(() => {});
198
+ mcpManager = await Promise.race([
199
+ startMcpServers(cfg.mcpServers, workingDir).catch(() => null),
200
+ new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
201
+ ]);
202
+ if (!mcpManager) console.error('[MingDao] ⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao web 可重试)');
196
203
  }
197
204
  const mcpFacade = {
198
205
  toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
@@ -228,17 +235,23 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
228
235
  // —— SSRF 防护(质检 S1):远端地址校验 ——
229
236
  /** @param {any} hostname */
230
237
  function isPrivateHost(hostname) {
231
- const h = String(hostname || '').toLowerCase();
238
+ let h = String(hostname || '').toLowerCase();
232
239
  if (!h) return true;
233
- if (h === 'localhost' || h.endsWith('.localhost') || h === '::1' || h === '::ffff:127.0.0.1') return true;
240
+ h = h.replace(/^\[|\]$/g, ''); // IPv6 字面量去括号(URL('http://[::1]/').hostname 带方括号)
241
+ if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
242
+ if (h.includes(':')) {
243
+ // IPv6:回环 ::1、链路本地 fe80::/10、唯一本地 fc00::/7、IPv4 映射 ::ffff:私网
244
+ if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
245
+ return /^fe[89ab]/.test(h) || /^f[c d]/.test(h) || h === '::' || h === '::1';
246
+ }
234
247
  const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
235
- if (!m) return false; // 域名:交给 DNS 解析(无法静态判定)
248
+ if (!m) return false; // 域名:由 validateRemoteUrl 的 DNS 解析复检
236
249
  const a = Number(m[1]);
237
250
  const b = Number(m[2]);
238
251
  return a === 10 || a === 127 || a === 0 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
239
252
  }
240
253
  /** @param {any} raw */
241
- function validateRemoteUrl(raw) {
254
+ async function validateRemoteUrl(raw) {
242
255
  let u;
243
256
  try {
244
257
  u = new URL(String(raw));
@@ -247,10 +260,24 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
247
260
  }
248
261
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return { error: '仅支持 http/https 地址' };
249
262
  // 威胁模型:本机回环绑定时(默认/桌面版),本机模型服务(如 Ollama)属可信场景,放行;
250
- // 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints
263
+ // 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints
264
+ // 域名目标:DNS 解析后逐条校验(2026-09-02 加固,防 DNS 重绑定把域名指向内网绕过字面量检查)。
251
265
  const serverBoundLocal = isLoopbackHost(host);
252
- if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal && isPrivateHost(u.hostname)) {
253
- return { error: `拒绝访问内网/本机地址(${u.hostname})——服务已对外监听,如确需连接可信内网服务,请在 config.json 设 web.allowPrivateEndpoints: true` };
266
+ if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal) {
267
+ const h = String(u.hostname || '').toLowerCase();
268
+ let blocked = isPrivateHost(h);
269
+ if (!blocked && h && h !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
270
+ try {
271
+ const { lookup } = await import('node:dns/promises');
272
+ const addrs = await lookup(h, { all: true, verbatim: true });
273
+ blocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
274
+ } catch {
275
+ // DNS 解析失败:保持放行(服务端会在连接时报错);解析成功且指向内网 → 拦截
276
+ }
277
+ }
278
+ if (blocked) {
279
+ return { error: `拒绝访问内网/本机地址(${u.hostname})——服务已对外监听,如确需连接可信内网服务,请在 config.json 设 web.allowPrivateEndpoints: true` };
280
+ }
254
281
  }
255
282
  return { ok: true };
256
283
  }