mingdao-harness 0.2.5 → 0.2.7

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.7",
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",
@@ -11,13 +11,14 @@ description: 准备发布新版本、打包交付前使用
11
11
  2. **版本**:版本号统一(package.json / 配置 / 文档互相一致),遵循语义化版本。
12
12
  3. **变更说明**:changelog 用用户语言描述新增/修复/破坏性变更。
13
13
  4. **构建验证**:实际构建/打包一次,并在干净环境验证产物可安装可运行。
14
- 5. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
14
+ 5. **发布前自检(常设步骤,2026-09-02 起)**:不依赖外部评估报告,纯代码审查主动找 bug——重点覆盖本次改动的调用链、锁/并发、权限/安全、跨平台与「打包期无声、运行期爆炸」类边界;每一条发现必须给出**文件+行号证据并按严重度排序**(高危优先修,不可带着已知高危发布)。历史战例:v0.2.6 自检一次捞出 6 条(含外部六份评估全漏的 `schedule stop` 双守护进程 bug)与 2 条自改回归。
15
+ 6. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
15
16
  - CI 已内置:desktop.yml 在 Linux 腿用 xvfb 运行 `dist/linux-unpacked/mingdao --no-sandbox`(`MINGDAO_DESKTOP_SMOKE=1`),模块求值通过输出 `MINGDAO_DESKTOP_SMOKE_OK` 并退出 0,否则 workflow 失败;
16
17
  - 本地有显示器时可加验:`cd desktop && MINGDAO_DESKTOP_SMOKE=1 dist/linux-unpacked/mingdao --no-sandbox`(AppImage 加 `--appimage-extract-and-run`);
17
18
  - 教训:0.2.2 三平台启动即崩(main.js 顶层调用 createLogWriter 漏导入),打包期不报错、运行期才炸——静态护栏(test/smoke.js「桌面主进程静态护栏」组)与打包冒烟双保险。
18
- 6. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
19
- 7. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
20
- 8. **发布后冒烟**:发布完成后立即做一次端到端验证。
19
+ 7. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
20
+ 8. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
21
+ 9. **发布后冒烟**:发布完成后立即做一次端到端验证。
21
22
 
22
23
  ## 桌面版发布流水线(官网分发 + 镜像 + 转运附件回收)
23
24
 
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
@@ -257,8 +257,16 @@ async function main() {
257
257
  const { readTask } = await import('./tasks.js');
258
258
  const handled = new Set();
259
259
  const supervising = new Set(); // 本 daemon 正在监督的任务(防崩溃恢复误判正在执行的任务)
260
+ const nonce = String(opts.prompt[1] || '');
260
261
  try {
261
262
  for (;;) {
263
+ // 守护进程租约自检(2026-09-03,防双 daemon):pidfile 不再等于「我的 pid nonce」
264
+ // 即视为已被 stop 或已被新 daemon 取代——立即退出,绝不与新 daemon 并跑重复执行任务
265
+ try {
266
+ if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() !== `${process.pid} ${nonce}`) break;
267
+ } catch {
268
+ break; // pidfile 被删 = 已被 stop
269
+ }
262
270
  const jobs = listSchedules(home0);
263
271
  if (!jobs.some((j) => j.status === 'pending' || j.status === 'running')) break;
264
272
  for (const j of jobs) {
@@ -295,8 +303,12 @@ async function main() {
295
303
  await new Promise((r) => setTimeout(r, 2000));
296
304
  }
297
305
  } finally {
306
+ // 只删除「仍指向自己」的 pidfile:租约被改写/被新 daemon 取代而退出时,
307
+ // 绝不误删新 daemon 的 pidfile(否则新 daemon 下一次租约检查会连锁退出,双守护全灭)
298
308
  try {
299
- fs.rmSync(daemonPidFile(home0), { force: true });
309
+ if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() === `${process.pid} ${nonce}`) {
310
+ fs.rmSync(daemonPidFile(home0), { force: true });
311
+ }
300
312
  } catch {}
301
313
  }
302
314
  return;
@@ -368,15 +380,22 @@ async function main() {
368
380
  },
369
381
  };
370
382
  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(() => {});
383
+ // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)。
384
+ // 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
385
+ const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
386
+ mcpManager = await Promise.race([
387
+ mcpStartP,
388
+ new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
389
+ ]);
390
+ if (!mcpManager) mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
391
+ if (mcpManager) {
392
+ const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
393
+ if (io && !opts.prompt.length) {
394
+ io.print(style(`✓ MCP 就绪:${ready}/${mcpManager.status().length} 个服务器,共 ${mcpManager.toolSchemas().length} 个工具`, C.dim));
395
+ }
396
+ } else if (io && !opts.prompt.length) {
397
+ io.print(style('⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao 可重试)', C.dim));
398
+ }
380
399
  }
381
400
  const sessionRef = { name: null }; // 会话名在下方 REPL 初始化中回填(审计归因用)
382
401
  // 会话文件与落盘游标共享槽: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;
package/src/log-writer.js CHANGED
@@ -9,7 +9,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
9
9
  try {
10
10
  fs.mkdirSync(path.dirname(file), { recursive: true });
11
11
  const line = new Date().toISOString() + ' ' + String(msg) + '\n';
12
- fs.appendFileSync(file, line);
12
+ // 自查 #5:日志可能含工具参数等敏感信息——创建与轮转一律 600(多用户机器防同机读取)
13
+ if (!fs.existsSync(file)) fs.appendFileSync(file, line, { mode: 0o600 });
14
+ else fs.appendFileSync(file, line);
13
15
  const st = fs.statSync(file);
14
16
  if (st.size > maxBytes) {
15
17
  const raw = fs.readFileSync(file, 'utf8');
@@ -17,8 +19,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
17
19
  const nl = raw.indexOf('\n', cut);
18
20
  const keep = nl === -1 ? raw.slice(cut) : raw.slice(nl + 1); // 行对齐保留尾部
19
21
  const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
20
- fs.writeFileSync(tmp, keep);
22
+ fs.writeFileSync(tmp, keep, { mode: 0o600 }); // 轮转重建保持 600
21
23
  fs.renameSync(tmp, file);
24
+ try { fs.chmodSync(file, 0o600); } catch {} // rename 后兜底收权(历史 644 日志迁移)
22
25
  }
23
26
  } catch {
24
27
  // 日志失败绝不抛错(best-effort)
package/src/pricing.js CHANGED
@@ -42,9 +42,18 @@ export function pricingDataStale() { return externalPricing().stale; }
42
42
  export async function refreshPricingFromSource(cfg) {
43
43
  const src = String(cfg?.pricing?.source || '').trim();
44
44
  if (!src) return { ok: false, lines: ['未配置 pricing.source(config.json 的 pricing.source 填官方价格 JSON 地址后重试)'] };
45
+ // 自查 #4:与 sync/自定义模型同类的第三外联入口——协议白名单 + 响应大小上限(防误配/恶意源灌内存)
46
+ try {
47
+ const u = new URL(src);
48
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, lines: ['价格源仅支持 http/https 地址'] };
49
+ } catch {
50
+ return { ok: false, lines: ['价格源必须是合法的 http(s) URL'] };
51
+ }
45
52
  const res = await fetch(src, { signal: AbortSignal.timeout(15000) });
46
53
  if (!res.ok) return { ok: false, lines: ['价格源请求失败:HTTP ' + res.status] };
47
- const d = await res.json();
54
+ const buf = await res.arrayBuffer();
55
+ if (buf.byteLength > 512 * 1024) return { ok: false, lines: ['价格源响应超过 512KB 上限,已拒绝'] };
56
+ const d = JSON.parse(Buffer.from(buf).toString('utf8'));
48
57
  const models = d && typeof d === 'object' && !Array.isArray(d) ? /** @type {any} */ (d).models : null;
49
58
  if (!models || typeof models !== 'object' || Array.isArray(models)) {
50
59
  return { ok: false, lines: ['价格源格式不符:应为 {"models": {"模型名": {"input":…,"output":…,"cacheHit":…,"peak":{…}}}}'] };
package/src/prompts.js CHANGED
@@ -14,7 +14,7 @@ const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI
14
14
  2. 精准修改:优先用 edit 做小步精确替换;新建文件用 write;保持改动最小化;改错可用 undo 撤销。
15
15
  3. 大内容分批写:单个 write 的参数总长控制在 6000 字符以内;大文件先写核心骨架,再分多次 write/edit 逐步补充——单次输出超限会被截断并导致参数解析失败。
16
16
  4. 任务管理:多步骤任务先建 todo 清单并逐项更新;与技能(skill)相关的任务先加载对应 SKILL.md。
17
- 5. 委托与专注:独立、可并行的子问题(调研、复核、独立实现)用 task 委托子代理,把结果拿回来继续主线。
17
+ 5. 委托与专注:把「互不依赖、可并行」的子问题(同时调研多个方向、复核多份文件、独立实现多模块)用 task 委托子代理;多个只读调研子代理应在同一轮一起派发(readOnly=true,自动并行,效率最高);依赖当前对话细节的小改动自己做,不要委托。子代理返回后拿结果继续主线。
18
18
  6. 命令谨慎:执行 bash 前想清楚影响;命令失败时阅读错误输出并修复,同一问题最多重试 3 次。
19
19
  7. 回答风格:使用用户的语言(默认中文),简洁直接,先结论后说明。
20
20
  8. 诚实可靠:不编造不存在的文件、行号、API 或执行结果。
@@ -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 {}
@@ -293,7 +293,9 @@ export function daemonAlive(/** @type {any} */ home) {
293
293
  }
294
294
  export function stopDaemon(/** @type {any} */ home) {
295
295
  try {
296
- const pid = Number(fs.readFileSync(daemonPidFile(home), 'utf8'));
296
+ // pidfile 格式 "<pid> <nonce>"——必须取首段(此前整串 Number()=NaN,SIGTERM 永远不发,
297
+ // 只删 pidfile → 孤儿 daemon 继续跑,新 daemon 再被拉起 → 双守护重复执行任务)
298
+ const pid = Number(String(fs.readFileSync(daemonPidFile(home), 'utf8')).trim().split(/\s+/)[0]);
297
299
  if (pid) {
298
300
  try {
299
301
  process.kill(pid, 'SIGTERM');
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
  }
@@ -83,9 +83,9 @@ const SKILL_SCHEMA = {
83
83
  const TASK_SCHEMA = {
84
84
  type: 'object',
85
85
  properties: {
86
- description: { type: 'string', description: '子任务一句话描述。' },
87
- prompt: { type: 'string', description: '子代理的完整任务说明。' },
88
- readOnly: { type: 'boolean', description: '只读调研任务,可并行。' },
86
+ description: { type: 'string', description: '子任务一句话描述(用于进度展示)。' },
87
+ prompt: { type: 'string', description: '子代理的完整任务说明(全新上下文,需自带全部必要背景)。' },
88
+ readOnly: { type: 'boolean', description: '只读调研任务(不写文件不执行命令)。多个 readOnly 子任务同一轮派发会并行执行,效率最高。' },
89
89
  },
90
90
  required: ['prompt'],
91
91
  };
@@ -185,7 +185,7 @@ const TOOLS = [
185
185
  type: 'function',
186
186
  function: {
187
187
  name: 'task',
188
- description: '把独立子任务委托给新上下文子代理并返回汇报;readOnly 只读任务可并行。',
188
+ description: '把独立、可并行的子任务委托给全新上下文子代理并返回汇报。适合:多方向调研/复核/独立实现等互不依赖的任务(多个只读调研子任务应同一轮一起派发,自动并行,效率最高);不适合:依赖当前对话细节的小改动。',
189
189
  parameters: TASK_SCHEMA,
190
190
  },
191
191
  },
package/src/web/app.js CHANGED
@@ -101,6 +101,7 @@ $('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#di
101
101
  $('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
102
102
  const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
103
103
  let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0, curTools=0, curTasks=0; // 本轮进度(活动条/状态条/轨迹共用)
104
+ let bgTasks=[]; // 后台任务列表快照(chip tooltip 详情用,updateTasksPanel 每 2s 刷新)
104
105
  let curPhase='模型推理中'; // 阶段语义(服务端 progress 事件下发)
105
106
  const sessionSubs=[]; // 本会话全部子代理(task 工具)条目:{seq, question, result, msg}
106
107
  // 回到底部悬浮按钮:滚动容器为 main;上滚超过 300px 时出现,点击平滑回底
@@ -421,13 +422,17 @@ function renderWorkStatus(){
421
422
  let html='';
422
423
  if(generating){
423
424
  const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
424
- html='<span class="ws-busy"><span class="spinner"></span>⏳ '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步</span>';
425
+ html='<span class="ws-busy"><span class="spinner"></span><span class="ws-phase">'+esc(curPhase)+'</span><span>⏳ '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子代理':'')+'</span></span>';
425
426
  } else if(bgRunning>0){
426
- html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中(下载/定时任务等)— 点击查看,完成后会自动提示</span>';
427
+ // 后台任务 chip(非顶部,位于输入框上方):计数 + 最新任务 + 悬浮详情 tooltip,点击打开详情面板
428
+ const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
429
+ const latest=running.length?running[running.length-1]:null;
430
+ const tip=bgTasks.map((/** @type {any} */ t)=>(t.kind==='schedule'?'⏰':'🛠')+' '+String(t.message||t.id||'').slice(0,40)+' · '+t.status).join('\n');
431
+ html='<span class="ws-bg" title="'+esc(String(tip)).replace(/"/g,'&quot;')+'">🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+esc(String(latest.message||'').slice(0,22)):'')+' — 点击查看详情</span>';
427
432
  }
428
433
  el.style.display=html?'flex':'none';
429
434
  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(); } };
435
+ 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
436
  }
432
437
  // 顶部常驻活动条(静默完美解决层):生成期间始终可见,滚动不影响
433
438
  function renderLiveBar(){
@@ -483,6 +488,15 @@ function openTraj(msg){
483
488
  $('#trajPanel').style.display='flex';
484
489
  $('#tasksPanel').style.display='none'; // 仅任务面板(右侧)与轨迹互斥;子代理面板可与轨迹同显
485
490
  $('#trajRailBtn').classList.add('on');
491
+ syncPanelLayout();
492
+ }
493
+ // 面板布局同步(2026-09-02):轨迹/子代理/任务面板打开时以 body class 推开内容区,
494
+ // 聊天文本与附件行不再被固定面板遮挡;面板宽度随屏宽自动收窄(CSS 媒体查询)。
495
+ function syncPanelLayout(){
496
+ const open=(id)=>document.getElementById(id)?.style.display==='flex';
497
+ document.body.classList.toggle('traj-open', open('trajPanel'));
498
+ document.body.classList.toggle('sub-open', open('subPanel'));
499
+ document.body.classList.toggle('tasks-open', open('tasksPanel'));
486
500
  }
487
501
  function resultText(r){
488
502
  if(r==null||r===undefined) return '';
@@ -492,13 +506,14 @@ function resultText(r){
492
506
  return JSON.stringify(r);
493
507
  }
494
508
  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'); };
509
+ $('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
496
510
  $('#trajRailBtn').onclick=()=>{
497
511
  const p=$('#trajPanel');
498
512
  const willShow=p.style.display==='none';
499
513
  if(willShow){ const m=lastMsgWithTraj(); if(!m){ uiAlert('本轮还没有可展示的轨迹(先发起一个任务)'); return; } openTraj(m); }
500
514
  else p.style.display='none';
501
515
  $('#trajRailBtn').classList.toggle('on', willShow);
516
+ syncPanelLayout();
502
517
  };
503
518
  function lastMsgWithTraj(){
504
519
  const msgs=[...document.querySelectorAll('#chat .msg-ai')].reverse();
@@ -528,9 +543,10 @@ const toggleSubPanel=()=>{
528
543
  p.style.display=willShow?'flex':'none';
529
544
  $('#subRailBtn').classList.toggle('on', willShow);
530
545
  if(willShow){ $('#tasksPanel').style.display='none'; renderSubPanel(); } // 轨迹(左侧)可与子代理(右侧)同显
546
+ syncPanelLayout();
531
547
  };
532
548
  $('#subRailBtn').onclick=toggleSubPanel;
533
- $('#sbClose').onclick=()=>{ $('#subPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); };
549
+ $('#sbClose').onclick=()=>{ $('#subPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); syncPanelLayout(); };
534
550
  // —— 任务面板(多会话并行) ——
535
551
  function setBtn(){
536
552
  sendBtn.textContent = generating ? '■ 停止' : '发送';
@@ -542,6 +558,7 @@ async function updateTasksPanel(){
542
558
  const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
543
559
  // 质检(等待状态静默):后台任务(worker/调度)并入计数与面板;状态转换时在聊天区弹可见横幅
544
560
  bgRunning=(Number(j.running)||0)+(Number(j.bgRunning)||0);
561
+ bgTasks=j.background||[];
545
562
  for(const t of (j.background||[])){
546
563
  const prev=lastBgStatus.get(t.id);
547
564
  if(prev && prev.status==='running' && t.status!=='running'){
@@ -580,7 +597,7 @@ async function refreshCostBadge(){
580
597
  const bd=j.breakdown||{}; const gd=j.guard||null;
581
598
  let t='今日 ≈¥'+(bd.today||0).toFixed(4);
582
599
  if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
583
- if(gd&&gd.limit>0) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
600
+ if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
584
601
  $('#costBadge').textContent=t;
585
602
  }
586
603
  refreshCostBadge(); setInterval(refreshCostBadge, 15000);
@@ -597,8 +614,8 @@ async function refreshStatusBar(){
597
614
  $('#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
615
  }
599
616
  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'; };
617
+ $('#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(); };
618
+ $('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; syncPanelLayout(); };
602
619
 
603
620
  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
621
  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)}
@@ -42,7 +62,8 @@ header select{flex:none}
42
62
  #tasksPanel{position:fixed;right:0;top:52px;bottom:0;width:280px;background:var(--bg2);border-left:1px solid var(--border);z-index:40;display:flex;flex-direction:column}
43
63
  /* 输入框上方工作状态条(审计:长任务静默硬伤——实时显示执行步数/耗时与后台任务数)。
44
64
  与输入框同宽对齐(复用 #composer 的居中容器),不再横贯整个聊天窗口 */
45
- #workStatus{max-width:860px;margin:0 auto 4px;display:flex;align-items:center;gap:10px;padding:6px 14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;font-size:12.5px;color:var(--dim);flex:none}
65
+ #workStatus{width:fit-content;max-width:100%;margin:0 auto 4px;display:flex;align-items:center;gap:10px;padding:6px 14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;font-size:12.5px;color:var(--dim);flex:none;overflow:hidden}
66
+ #workStatus .ws-busy{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
46
67
  /* 状态条 busy 行:承载完整阶段信息(合并原顶部活动条),与输入框同列不遮挡聊天区 */
47
68
  #workStatus .ws-phase{color:var(--accent2);font-weight:600;white-space:nowrap}
48
69
  /* 左侧轨迹固定栏 + 右侧子代理面板(布局重构:轨迹在左、子代理在右) */
@@ -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,20 @@ 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
+ // 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
199
+ const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
200
+ mcpManager = await Promise.race([
201
+ mcpStartP,
202
+ new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
203
+ ]);
204
+ if (!mcpManager) {
205
+ console.error('[MingDao] ⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao web 可重试)');
206
+ mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
207
+ }
196
208
  }
197
209
  const mcpFacade = {
198
210
  toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
@@ -228,17 +240,23 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
228
240
  // —— SSRF 防护(质检 S1):远端地址校验 ——
229
241
  /** @param {any} hostname */
230
242
  function isPrivateHost(hostname) {
231
- const h = String(hostname || '').toLowerCase();
243
+ let h = String(hostname || '').toLowerCase();
232
244
  if (!h) return true;
233
- if (h === 'localhost' || h.endsWith('.localhost') || h === '::1' || h === '::ffff:127.0.0.1') return true;
245
+ h = h.replace(/^\[|\]$/g, ''); // IPv6 字面量去括号(URL('http://[::1]/').hostname 带方括号)
246
+ if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
247
+ if (h.includes(':')) {
248
+ // IPv6:回环 ::1、链路本地 fe80::/10、唯一本地 fc00::/7、IPv4 映射 ::ffff:私网
249
+ if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
250
+ return /^fe[89ab]/.test(h) || /^f[c d]/.test(h) || h === '::' || h === '::1';
251
+ }
234
252
  const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
235
- if (!m) return false; // 域名:交给 DNS 解析(无法静态判定)
253
+ if (!m) return false; // 域名:由 validateRemoteUrl 的 DNS 解析复检
236
254
  const a = Number(m[1]);
237
255
  const b = Number(m[2]);
238
256
  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
257
  }
240
258
  /** @param {any} raw */
241
- function validateRemoteUrl(raw) {
259
+ async function validateRemoteUrl(raw) {
242
260
  let u;
243
261
  try {
244
262
  u = new URL(String(raw));
@@ -247,10 +265,24 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
247
265
  }
248
266
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return { error: '仅支持 http/https 地址' };
249
267
  // 威胁模型:本机回环绑定时(默认/桌面版),本机模型服务(如 Ollama)属可信场景,放行;
250
- // 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints
268
+ // 对外监听(0.0.0.0)时拒绝私网目标——除非显式 allowPrivateEndpoints
269
+ // 域名目标:DNS 解析后逐条校验(2026-09-02 加固,防 DNS 重绑定把域名指向内网绕过字面量检查)。
251
270
  const serverBoundLocal = isLoopbackHost(host);
252
- if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal && isPrivateHost(u.hostname)) {
253
- return { error: `拒绝访问内网/本机地址(${u.hostname})——服务已对外监听,如确需连接可信内网服务,请在 config.json 设 web.allowPrivateEndpoints: true` };
271
+ if (!cfg.web?.allowPrivateEndpoints && !serverBoundLocal) {
272
+ const h = String(u.hostname || '').toLowerCase();
273
+ let blocked = isPrivateHost(h);
274
+ if (!blocked && h && h !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
275
+ try {
276
+ const { lookup } = await import('node:dns/promises');
277
+ const addrs = await lookup(h, { all: true, verbatim: true });
278
+ blocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
279
+ } catch {
280
+ // DNS 解析失败:保持放行(服务端会在连接时报错);解析成功且指向内网 → 拦截
281
+ }
282
+ }
283
+ if (blocked) {
284
+ return { error: `拒绝访问内网/本机地址(${u.hostname})——服务已对外监听,如确需连接可信内网服务,请在 config.json 设 web.allowPrivateEndpoints: true` };
285
+ }
254
286
  }
255
287
  return { ok: true };
256
288
  }