mingdao-harness 0.4.2 → 0.4.4

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.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # MingDao Harness
2
2
 
3
3
  > 开源智能体框架(Agent Harness):**零运行时依赖、开箱即用**,针对 DeepSeek-V4 首发深度优化,开放主流模型接入。一条命令安装,终端与浏览器双界面,命令:`mingdao`(简写 `mdh`)。
4
+ >
5
+ > 项目简称 **MDH**(MingDao Harness),自 v0.4.3 起文档/日志/发布说明统一使用。
4
6
 
5
7
  轻量的「模型循环 + 工具 + 权限」内核,能力以 ESM 库导出、接口全部开放。架构见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
6
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,8 +2,8 @@
2
2
  "name": "local-audit",
3
3
  "label": "本地模型审计",
4
4
  "description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 + 只读权限 + 保守参数,配合 v0.3.2 本地自适应长任务不中断",
5
- "systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
6
- "tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo"],
5
+ "systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。对于大仓库可分模块派发只读子代理并行审查(task 工具传 readOnly:true,子代理同样只读:read/ls/glob/grep/skill)。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
6
+ "tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo", "task"],
7
7
  "permission": "readonly",
8
8
  "maxRounds": 4,
9
9
  "maxOutputTokens": 4096
package/src/agent.js CHANGED
@@ -26,9 +26,10 @@ const SUBAGENT_MAX_STEPS = 24;
26
26
  /**
27
27
  * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
28
28
  * @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
29
- * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any }} params
29
+ * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
30
+ * onUsage?: (usage: any) => void }} params
30
31
  */
31
- export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef }) {
32
+ export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage }) {
32
33
  const preset = modelPreset(modelName) || {};
33
34
  // v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
34
35
  // 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
@@ -45,7 +46,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
45
46
  const toolResultCap = Math.min(20000, Math.max(2000, Math.floor(caps.contextWindow / 16)));
46
47
  const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
47
48
  const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
48
- const hooks = createHooks(cfg.hooks, workingDir);
49
+ const hooks = createHooks(cfg.hooks, workingDir, cfg);
49
50
  const todos = /** @type {any[]} */ ([]);
50
51
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
51
52
  const undo = undoStore || { backups: new Map() };
@@ -61,7 +62,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
61
62
  const usedToolNames = new Set();
62
63
  // 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
63
64
  // + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
64
- const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch']);
65
+ // v0.4.4:加 task——审计/调研等只读长任务此前因 task 不在只读档而看不到「派只读子代理」能力
66
+ // (readOnly 子代理只读,权限引擎仍门控写操作,无越权)。
67
+ const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch', 'task']);
65
68
  // 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
66
69
  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;
67
70
  const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
@@ -220,6 +223,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
220
223
  const turnToolCache = new Map();
221
224
  for (round = 0; round < maxRounds; round++) {
222
225
  steps = 0;
226
+ // v0.4.4:每轮结束回调本轮增量 usage(长任务费用逐轮入账——此前只在 runTurn 全结束后才
227
+ // recordUsage,长任务期间「今日费用」恒为 0 被误读为「无统计」;中断时已完成轮次费用也保留)。
228
+ const roundUsageStart = { prompt_tokens: usage.prompt_tokens, completion_tokens: usage.completion_tokens, prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens || 0, prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens || 0 };
223
229
  while (steps < stepLimit) {
224
230
  steps += 1;
225
231
  // 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
@@ -734,6 +740,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
734
740
  }
735
741
  // v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
736
742
  if (round < maxRounds - 1 && !aborted) {
743
+ // 每轮结束:回调本轮增量 usage(含缓存命中拆分),供调用方逐轮入账
744
+ try {
745
+ onUsage?.({
746
+ prompt_tokens: usage.prompt_tokens - roundUsageStart.prompt_tokens,
747
+ completion_tokens: usage.completion_tokens - roundUsageStart.completion_tokens,
748
+ prompt_cache_hit_tokens: (usage.prompt_cache_hit_tokens || 0) - roundUsageStart.prompt_cache_hit_tokens,
749
+ prompt_cache_miss_tokens: (usage.prompt_cache_miss_tokens || 0) - roundUsageStart.prompt_cache_miss_tokens,
750
+ });
751
+ } catch {}
737
752
  const art = deliverables.length ? '已交付文件:' + deliverables.join('、') + '。' : '';
738
753
  messages.push({
739
754
  role: 'user',
@@ -789,6 +804,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
789
804
  }
790
805
  // 理论不可达(for 循环末轮必 return);给 tsc 一个兜底,保证 runTurn 恒有返回值
791
806
  return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
807
+ } catch (/** @type {any} */ err) {
808
+ // 审计 P2-6(v0.4.2):工具管线异常(hooks.pre / permission.check / prepTool 等)沿大 try 上抛时,
809
+ // assistant tool_calls 已 push 进 messages 却无对应 tool 回填——会话恢复后 API 因孤儿 tool_call_id 400。
810
+ // 清理孤儿调用后再抛(stripOrphanCalls 此前只在中断/收尾路径调用)。
811
+ stripOrphanCalls();
812
+ throw err;
792
813
  } finally {
793
814
  currentAc = null;
794
815
  offSigint();
package/src/cachestats.js CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { mingdaoHome, ensureHome } from './config.js';
7
7
  import { estimateCost, cacheSplit, beijingDayStart, beijingParts } from './pricing.js';
8
8
  import { modelPreset } from './models.js';
9
+ import { withFileLockSync, atomicWriteFileSync } from './atomic-write.js';
9
10
 
10
11
  export function cacheStatsFile() {
11
12
  return path.join(mingdaoHome(), 'cache-stats.jsonl');
@@ -41,12 +42,16 @@ export function recordCacheStats(/** @type {any} */ entry) {
41
42
  } catch {}
42
43
  if (cacheStatsCount > MAX_LINES && cacheStatsCount % 200 === 0) {
43
44
  try {
44
- const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
45
- const lines = raw.split('\n').filter(Boolean);
46
- if (lines.length > MAX_LINES) {
47
- fs.writeFileSync(cacheStatsFile(), lines.slice(-KEEP_LINES).join('\n') + '\n');
48
- cacheStatsCount = KEEP_LINES;
49
- }
45
+ // 审计 P2-3(v0.4.2):轮转 read-modify-write 加跨进程锁——web/CLI/worker 多进程并发轮转时,
46
+ // 读与写之间他人追加的行会被覆写丢失;锁内重读再瘦身,写用原子替换。
47
+ withFileLockSync(cacheStatsFile() + '.lock', () => {
48
+ const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
49
+ const lines = raw.split('\n').filter(Boolean);
50
+ if (lines.length > MAX_LINES) {
51
+ atomicWriteFileSync(cacheStatsFile(), lines.slice(-KEEP_LINES).join('\n') + '\n');
52
+ cacheStatsCount = KEEP_LINES;
53
+ }
54
+ });
50
55
  } catch {}
51
56
  }
52
57
  }
package/src/cli.js CHANGED
@@ -172,6 +172,9 @@ async function main() {
172
172
  }
173
173
  // 最近会话日志默认不注入(新会话全新开始);--journal 显式带上
174
174
  const withJournal = Boolean(opts.journal);
175
+ // 审计 P2-5(v0.4.2):jsonMode 提前到 opts 解析后判定——此前在单次提问分支内才判定,
176
+ // 预设/tools/MCP 等提示在判定前已打到 stdout,污染 --format json 的单行 JSON 输出。
177
+ const jsonMode = opts.format === 'json';
175
178
 
176
179
  // —— 命令分发(已拆至 src/commands/,评估 P0-1 拆 cli.js)——
177
180
  // 各 handler 返回 true = 已处理;false = 按普通提问继续(保留词劫持防护)。
@@ -366,20 +369,20 @@ async function main() {
366
369
  activePreset = loadPreset(workingDir, opts.preset);
367
370
  if (!activePreset) {
368
371
  const names = listPresets(workingDir).map((/** @type {any} */ p) => p.name).join(', ') || '(无可用预设)';
369
- io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
372
+ if (!jsonMode) io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
370
373
  } else {
371
374
  presetOverlay = { ...presetConfigOverrides(activePreset), presetName: activePreset.name };
372
375
  // P0(v0.4.1):预设 permission 提权防护——预设不得把 ask/readonly 静默改成 auto
373
376
  const permOv = presetPermissionOverride(activePreset, cfg.permission ?? 'ask');
374
377
  if (permOv.escalated) {
375
378
  delete presetOverlay.permission;
376
- io.print(style(`⚠ 预设 "${activePreset.name}" 声明 permission=${activePreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
379
+ if (!jsonMode) io.print(style(`⚠ 预设 "${activePreset.name}" 声明 permission=${activePreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
377
380
  } else if (activePreset.permission !== undefined) {
378
381
  presetOverlay.permission = permOv.permission;
379
382
  }
380
383
  if (!opts.model && presetOverlay.model) modelName = presetOverlay.model;
381
384
  presetBlock = presetSystemBlock(activePreset);
382
- io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
385
+ if (!jsonMode) io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
383
386
  }
384
387
  }
385
388
  // agent 使用的配置 = cfg + 预设 overlay(presetTools/permission/参数按预设生效,cfg 本体保持干净)
@@ -406,7 +409,7 @@ async function main() {
406
409
  {
407
410
  const { mountConfigTools } = await import('./tools/index.js');
408
411
  const mounted = mountConfigTools(cfg);
409
- if (mounted.length) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
412
+ if (mounted.length && !jsonMode) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
410
413
  }
411
414
  // 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
412
415
  const sessionUndoStore = { backups: new Map() };
@@ -424,7 +427,7 @@ async function main() {
424
427
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
425
428
  // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)。
426
429
  // 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
427
- const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
430
+ const mcpStartP = startMcpServers(cfg.mcpServers, workingDir, cfg).catch(() => null);
428
431
  mcpManager = await Promise.race([
429
432
  mcpStartP,
430
433
  new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
@@ -432,10 +435,10 @@ async function main() {
432
435
  if (!mcpManager) mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
433
436
  if (mcpManager) {
434
437
  const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
435
- if (io && !opts.prompt.length) {
438
+ if (io && !opts.prompt.length && !jsonMode) {
436
439
  io.print(style(`✓ MCP 就绪:${ready}/${mcpManager.status().length} 个服务器,共 ${mcpManager.toolSchemas().length} 个工具`, C.dim));
437
440
  }
438
- } else if (io && !opts.prompt.length) {
441
+ } else if (io && !opts.prompt.length && !jsonMode) {
439
442
  io.print(style('⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao 可重试)', C.dim));
440
443
  }
441
444
  }
@@ -463,7 +466,6 @@ async function main() {
463
466
 
464
467
  // —— 单次提问模式 ——
465
468
  if (opts.prompt.length > 0) {
466
- const jsonMode = opts.format === 'json';
467
469
  const question = opts.prompt.join(' ');
468
470
  // 自动路由:规划类任务切 planner,执行类走 executor(JSON 模式静默)
469
471
  const route = await routeTask({ cfg, provider, currentModel: modelName, text: question });
@@ -504,12 +506,16 @@ async function main() {
504
506
  const res = await turnAgent.runTurn(messages);
505
507
  appendMessages(session.file, messages.slice(oneShotPersisted));
506
508
  if (!jsonMode && cfg.autoTitle !== false && res.text) {
507
- const tModel = titleModel(cfg, modelName);
508
- const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, question);
509
- if (title) {
510
- const renamed = renameSessionFile(fs, path, home, session, title);
511
- if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
512
- }
509
+ // 审计 P2-4(v0.4.2):autoTitle 独立 try/catch——此前 titleModel 目标服务商无 Key 时
510
+ // helperProvider 抛错落入外层 catch,成功回答已输出却被误报失败(exitCode=2)。
511
+ try {
512
+ const tModel = titleModel(cfg, modelName);
513
+ const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, question);
514
+ if (title) {
515
+ const renamed = renameSessionFile(fs, path, home, session, title);
516
+ if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
517
+ }
518
+ } catch {}
513
519
  }
514
520
  // v0.3.0 P0-2:单次提问跑满步数/中断落检查点(--continue 可续跑),正常完成清除
515
521
  if (res.capHit || res.aborted) {
@@ -173,8 +173,12 @@ export async function runRepl(ctx) {
173
173
  if (cfg.web?.autoStart && !process.env.MINGDAO_NO_WEB_AUTOSTART) {
174
174
  try {
175
175
  const { spawn } = await import('node:child_process');
176
+ const path = await import('node:path');
176
177
  const { fileURLToPath } = await import('node:url');
177
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'web'], {
178
+ // 审计 P1-1(v0.4.2):此前 spawn 的是 repl.js 自身(纯模块无 main 入口),web 参数被静默
179
+ // 丢弃、进程即退——自启 100% 失效还误报「后台启动中」。改为 spawn cli.js web(命令分发入口)。
180
+ const cliEntry = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
181
+ const child = spawn(process.execPath, [cliEntry, 'web'], {
178
182
  detached: true,
179
183
  stdio: 'ignore',
180
184
  env: process.env,
@@ -39,7 +39,8 @@ export async function handleSkill(/** @type {any} */ cmd, /** @type {any} */ arg
39
39
  return true;
40
40
  }
41
41
  if (sub === 'install') {
42
- const r = await installSkill(arg);
42
+ // CLI 显式输入 URL 属用户自担意图:放行内网地址(与 curl 等价);WebUI 路径默认拦截(SSRF 防护)
43
+ const r = await installSkill(arg, { allowPrivateUrl: true });
43
44
  if (r.error) {
44
45
  console.log('[错误] ' + (/** @type {any} */ (r)).error);
45
46
  process.exitCode = 1;
package/src/hooks.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // - matcher 支持精确工具名、逗号分隔多个名、'*' 通配。
10
10
 
11
11
  import { spawn } from 'node:child_process';
12
+ import { isSensitiveEnv } from './tools/bash.js';
12
13
 
13
14
  function normalize(/** @type {any} */ list) {
14
15
  if (!Array.isArray(list)) return [];
@@ -25,17 +26,29 @@ function match(/** @type {any} */ hook, /** @type {any} */ toolName) {
25
26
  });
26
27
  }
27
28
 
28
- export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir) {
29
+ export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir, /** @type {any} */ cfg = {}) {
29
30
  const pre = normalize((/** @type {any} */ (hooksCfg))?.PreToolUse);
30
31
  const post = normalize((/** @type {any} */ (hooksCfg))?.PostToolUse);
32
+ // 审计 P3-6(v0.4.2):hook 子进程不再全量透传 process.env(含 API Key)——与 bash 工具同口径
33
+ // 默认过滤敏感变量;config.bashEnvKeep 按名放行、bashEnvFilter=false 整体关闭。
34
+ const keepEnv = new Set((cfg?.bashEnvKeep || []).map(String));
35
+ let childEnv = process.env;
36
+ if (cfg?.bashEnvFilter !== false) {
37
+ const filtered = /** @type {any} */ ({});
38
+ for (const [k, v] of Object.entries(process.env)) {
39
+ if (!isSensitiveEnv(k) || keepEnv.has(k)) filtered[k] = v;
40
+ }
41
+ childEnv = filtered;
42
+ }
31
43
 
32
44
  function run(/** @type {any} */ hook, /** @type {any} */ payload) {
33
45
  return new Promise((resolve) => {
34
46
  const child = spawn(hook.cmd, {
35
47
  shell: true,
36
48
  cwd: workingDir,
37
- env: process.env,
49
+ env: childEnv,
38
50
  stdio: ['pipe', 'pipe', 'pipe'],
51
+ detached: true, // 评估 6.5:自成进程组,超时 process.kill(-pid) 整组清理(否则只杀 shell,孙进程孤儿)
39
52
  });
40
53
  let out = '';
41
54
  let err = '';
package/src/mcp.js CHANGED
@@ -8,6 +8,17 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import { spawn } from 'node:child_process';
11
+ import { isSensitiveEnv } from './tools/bash.js';
12
+
13
+ // 评估 6.2(v0.4.3):MCP 子进程不再继承完整 process.env(含 API Key)——与 bash/hooks 同口径
14
+ // 默认过滤敏感变量;config.mcpEnvKeep 按名放行、config.mcpEnvFilter=false 整体关闭。
15
+ function filteredProcessEnv(/** @type {Set<string>} */ keepSet) {
16
+ const out = /** @type {any} */ ({});
17
+ for (const [k, v] of Object.entries(process.env)) {
18
+ if (!isSensitiveEnv(k) || keepSet.has(k)) out[k] = v;
19
+ }
20
+ return out;
21
+ }
11
22
 
12
23
  // 客户端版本读 package.json(评估 P3-9:此前硬编码 '0.6.0' 与真实版本脱节)
13
24
  const CLIENT_VERSION = (() => {
@@ -25,11 +36,12 @@ const HANDSHAKE_TIMEOUT_MS = 20000;
25
36
  let nextId = 1;
26
37
 
27
38
  export class McpClient {
28
- constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {}, trusted = false }, /** @type {any} */ workingDir) {
39
+ constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {}, trusted = false }, /** @type {any} */ workingDir, /** @type {any} */ baseEnv = process.env) {
29
40
  this.name = name;
30
41
  this.command = command;
31
42
  this.args = args;
32
43
  this.env = env;
44
+ this.baseEnv = baseEnv; // 过滤敏感变量后的基础环境(startMcpServers 注入)
33
45
  this.trusted = trusted === true; // v0.4.1 P0:仅 trusted 服务器的 readOnlyHint 才被信任自动放行
34
46
  this.workingDir = workingDir;
35
47
  this.tools = /** @type {any[]} */ ([]);
@@ -45,7 +57,7 @@ export class McpClient {
45
57
  if (this.child) return this;
46
58
  this.child = spawn(this.command, this.args, {
47
59
  cwd: this.workingDir,
48
- env: { ...process.env, ...this.env },
60
+ env: { ...this.baseEnv, ...this.env }, // 敏感变量已过滤(this.baseEnv),this.env 为用户显式覆盖
49
61
  stdio: ['pipe', 'pipe', 'pipe'],
50
62
  detached: true, // 自成进程组:stop 时整组清理(npx 孙进程不成孤儿)
51
63
  });
@@ -215,13 +227,16 @@ export class McpClient {
215
227
  }
216
228
 
217
229
  // 多服务器管理器:部分服务器启动失败不影响其余
218
- export async function startMcpServers(/** @type {any} */ mcpCfg, /** @type {any} */ workingDir) {
230
+ // topCfg 为顶层 config.json(提供 mcpEnvKeep/mcpEnvFilter 环境过滤口径,与 bash/hooks 一致)
231
+ export async function startMcpServers(/** @type {any} */ mcpCfg, /** @type {any} */ workingDir, /** @type {any} */ topCfg = {}) {
232
+ const keep = new Set((topCfg?.mcpEnvKeep || []).map(String));
233
+ const baseEnv = topCfg?.mcpEnvFilter === false ? process.env : filteredProcessEnv(keep);
219
234
  const clients = new Map();
220
235
  const entries = Object.entries(mcpCfg || {});
221
236
  await Promise.all(
222
237
  entries.map(async ([name, cfg]) => {
223
238
  if (!cfg || typeof cfg.command !== 'string' || !cfg.command.trim()) return;
224
- const client = new McpClient(name, cfg, workingDir);
239
+ const client = new McpClient(name, cfg, workingDir, baseEnv);
225
240
  clients.set(name, client);
226
241
  try {
227
242
  await client.start();
package/src/model-caps.js CHANGED
@@ -3,6 +3,10 @@
3
3
  // 供预算推导、超时、工具截断统一引用——避免各层各自猜一份 128000 默认,
4
4
  // 本地小模型(窗口小/内存少)自动收紧预算与超时,不撑爆、不误杀。
5
5
  import { modelPreset } from './models.js';
6
+ // 审计 P3-3(v0.4.2):本地判定复用 fetch.js 的 isPrivateHost(IPv4 私网/回环/CGNAT/多播 + IPv6
7
+ // fc00::/7、fe80::/10、::、::1、IPv4-mapped)——此前只查 IPv4 与 ::1,IPv6 本地模型被误判远程
8
+ // (超时档位错),且与 fetch 工具/SSRF 判定各维护一份、口径漂移。
9
+ import { isPrivateHost } from './tools/fetch.js';
6
10
 
7
11
  // 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
8
12
  export const UNKNOWN_LOCAL_WINDOW = 32768;
@@ -20,14 +24,8 @@ export const EDGE_RATIO = 0.85;
20
24
  export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
21
25
  try {
22
26
  const u = new URL(String(baseUrl || ''));
23
- const h = u.hostname.toLowerCase();
24
- if (!h) return false;
25
- if (h === 'localhost' || h === '::1') return true;
26
- const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
27
- if (!m) return false;
28
- const a = Number(m[1]);
29
- const b = Number(m[2]);
30
- return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
27
+ if (!u.hostname) return false;
28
+ return isPrivateHost(u.hostname);
31
29
  } catch {
32
30
  return false;
33
31
  }
package/src/presets.js CHANGED
@@ -78,8 +78,9 @@ export function validatePreset(/** @type {any} */ obj) {
78
78
  */
79
79
  export function listPresets(/** @type {any} */ workingDir) {
80
80
  ensureHome();
81
+ // 审计 P3-2(v0.4.2):遮蔽 key 用预设的 name 字段而非文件名——此前两个不同文件名声明同名
82
+ // 预设会都被列出(违背「同名遮蔽」契约),且非法 JSON 在遮蔽判定时不可见。
81
83
  const seen = new Map();
82
- const order = /** @type {string[]} */ ([]);
83
84
  for (const { dir, source } of presetLocations(workingDir)) {
84
85
  let files = [];
85
86
  try {
@@ -88,34 +89,31 @@ export function listPresets(/** @type {any} */ workingDir) {
88
89
  continue;
89
90
  }
90
91
  for (const f of files) {
91
- if (seen.has(f)) continue;
92
- seen.set(f, { dir, source });
93
- order.push(f);
94
- }
95
- }
96
- const out = [];
97
- for (const f of order) {
98
- const { dir, source } = seen.get(f);
99
- try {
100
- const obj = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
92
+ const file = path.join(dir, f);
93
+ let obj;
94
+ try {
95
+ obj = JSON.parse(fs.readFileSync(file, 'utf8'));
96
+ } catch {
97
+ continue; // JSON 解析失败:跳过
98
+ }
101
99
  const v = validatePreset(obj);
102
100
  if (!v.ok) continue; // 非法预设跳过并静默(不阻塞会话);diagnose 可查
103
- out.push({
104
- name: String(obj.name),
105
- label: String(obj.label || obj.name),
106
- description: String(obj.description || ''),
107
- source,
108
- file: path.join(dir, f),
109
- ...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
110
- ...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
111
- ...(obj.permission ? { permission: String(obj.permission) } : {}),
112
- ...(obj.model ? { model: String(obj.model) } : {}),
113
- });
114
- } catch {
115
- // JSON 解析失败:跳过
101
+ const key = String(obj.name);
102
+ if (seen.has(key)) continue; // 同名遮蔽:项目 → 用户 → 内置(先发现者胜)
103
+ seen.set(key, { obj, source, file });
116
104
  }
117
105
  }
118
- return out;
106
+ return [...seen.values()].map(({ obj, source, file }) => ({
107
+ name: String(obj.name),
108
+ label: String(obj.label || obj.name),
109
+ description: String(obj.description || ''),
110
+ source,
111
+ file,
112
+ ...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
113
+ ...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
114
+ ...(obj.permission ? { permission: String(obj.permission) } : {}),
115
+ ...(obj.model ? { model: String(obj.model) } : {}),
116
+ }));
119
117
  }
120
118
 
121
119
  /**
package/src/pricing.js CHANGED
@@ -16,20 +16,23 @@ export const PRICE_DATA_AS_OF = '2026-08';
16
16
  // cfg.pricing.source 拉取(TTL 默认 7 天,cfg.pricing.ttlDays 可调);TTL 内覆盖内置表,
17
17
  // 过期自动回退内置并置 stale 标记(/cost 与费用标签会提示)。
18
18
  function pricingFilePath() { return path.join(mingdaoHome(), 'pricing.json'); }
19
- /** @type {{ mtime: number, data: any, stale: boolean }} */
20
- let extCache = { mtime: -1, data: null, stale: false };
19
+ /** @type {{ mtime: number, ttlDays: number, data: any, stale: boolean }} */
20
+ let extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
21
21
  function externalPricing() {
22
22
  try {
23
+ // 审计 P2-2(v0.4.2):此前直接读 tzCache.ttlDays——首次调用若早于 peakCfg()(isPeakHour),
24
+ // tzCache 还是初值(无 ttlDays),用户配的 pricing.ttlDays 被忽略按 7 天判过期且缓存整个进程寿命。
25
+ // 改走 peakCfg()(mtime 缓存,代价极低);ttlDays 变化也触发重算(不依赖 pricing.json mtime 变化)。
26
+ const ttlDays = Number(peakCfg().ttlDays || 7);
23
27
  const f = pricingFilePath();
24
28
  const st = fs.statSync(f);
25
- if (st.mtimeMs !== extCache.mtime) {
29
+ if (st.mtimeMs !== extCache.mtime || ttlDays !== extCache.ttlDays) {
26
30
  const d = JSON.parse(fs.readFileSync(f, 'utf8'));
27
- const ttlDays = Number(tzCache.ttlDays ?? 7);
28
31
  const age = Date.now() - Number(d?.fetchedAt || 0);
29
- extCache = { mtime: st.mtimeMs, data: d, stale: !Number.isFinite(age) || age > ttlDays * 86400000 };
32
+ extCache = { mtime: st.mtimeMs, ttlDays, data: d, stale: !Number.isFinite(age) || age > ttlDays * 86400000 };
30
33
  }
31
34
  } catch {
32
- extCache = { mtime: -1, data: null, stale: false };
35
+ extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
33
36
  }
34
37
  return extCache;
35
38
  }
@@ -61,7 +64,7 @@ export async function refreshPricingFromSource(cfg) {
61
64
  const file = pricingFilePath();
62
65
  fs.mkdirSync(path.dirname(file), { recursive: true });
63
66
  fs.writeFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
64
- extCache = { mtime: -1, data: null, stale: false };
67
+ extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
65
68
  const names = Object.keys(models).join('、');
66
69
  return { ok: true, lines: ['✓ 价格表已刷新(' + names + '),TTL 内费用估算/护栏/避峰自动跟随'] };
67
70
  }
package/src/redact.js CHANGED
@@ -21,6 +21,10 @@ export function redactSensitive(/** @type {any} */ text) {
21
21
  s = s.replace(/\b(?:10|127)(?:\.\d{1,3}){3}\b|\b192\.168(?:\.\d{1,3}){2}\b|\b169\.254(?:\.\d{1,3}){2}\b|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b|\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\b/g, '[私网IP]');
22
22
  s = s.replace(/(?:fe80:[\da-f:]+|::1|::)/gi, '[链路本地/回环IPv6]');
23
23
  const home = os.homedir();
24
- if (home && home.length > 1) s = s.split(home).join('~');
24
+ // 审计 P3-1(v0.4.2):split().join() 无边界——/home/user2/xxx 会被误脱敏为 ~2/xxx。
25
+ // 正则要求家目录后跟路径分隔符或字符串结尾(如 /home/user2 的 2 紧跟目录名不匹配)。
26
+ if (home && home.length > 1) {
27
+ s = s.replace(new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=/|$)', 'g'), '~');
28
+ }
25
29
  return s;
26
30
  }
package/src/schedule.js CHANGED
@@ -199,19 +199,22 @@ export function pauseSchedule(/** @type {any} */ home, /** @type {any} */ id) {
199
199
  }
200
200
 
201
201
  export function resumeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
202
- const job = readSchedule(home, id);
203
- if (!job || job.status !== 'paused') return false;
204
- let next = job.nextRunAt;
205
- if (job.kind === 'every') {
206
- next = job.anchor ? nextAnchorAfter(job.anchor, job.interval) || Date.now() + job.interval : Date.now() + job.interval;
207
- } else if (job.kind === 'once') {
208
- if (next && next <= Date.now()) next = Date.now() + 30000; // 已过期的一次性任务恢复后 30s 执行
209
- } else next = Date.now();
210
- const nextJob = { ...job, status: 'pending', nextRunAt: next };
211
- writeSchedule(home, nextJob);
212
- // 审计修复:守护进程在时只更新状态交给 daemon 接管;否则旧式 sleeper 兜底(避免双跑)
213
- if (process.env.MINGDAO_NO_DAEMON === '1' || !daemonAlive(home)) spawnSleeper(home, nextJob);
214
- return true;
202
+ // 评估 6.3(v0.4.3):读-改-写序列加锁,与 remove/pause 及 sleeper 状态写互斥(防丢更新)
203
+ return withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
204
+ const job = readSchedule(home, id);
205
+ if (!job || job.status !== 'paused') return false;
206
+ let next = job.nextRunAt;
207
+ if (job.kind === 'every') {
208
+ next = job.anchor ? nextAnchorAfter(job.anchor, job.interval) || Date.now() + job.interval : Date.now() + job.interval;
209
+ } else if (job.kind === 'once') {
210
+ if (next && next <= Date.now()) next = Date.now() + 30000; // 已过期的一次性任务恢复后 30s 执行
211
+ } else next = Date.now();
212
+ const nextJob = { ...job, status: 'pending', nextRunAt: next };
213
+ writeSchedule(home, nextJob);
214
+ // 审计修复:守护进程在时只更新状态交给 daemon 接管;否则旧式 sleeper 兜底(避免双跑)
215
+ if (process.env.MINGDAO_NO_DAEMON === '1' || !daemonAlive(home)) spawnSleeper(home, nextJob);
216
+ return true;
217
+ });
215
218
  }
216
219
 
217
220
  // 每日锚点:锚点时刻(HH:MM)对齐到 now 之后的最近一次
@@ -455,8 +458,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
455
458
  }
456
459
  writeSchedule(home, { ...cur, status: 'running' });
457
460
  const result = await runOnce();
458
- const cur2 = readSchedule(home, id);
459
- if (cur2) writeSchedule(home, { ...cur2, status: result });
461
+ // 评估 6.4(v0.4.3):once 最终状态读-改-写加锁(与 pause/remove 互斥,防 pause 被 done 覆盖)
462
+ withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
463
+ const cur2 = readSchedule(home, id);
464
+ if (cur2) writeSchedule(home, { ...cur2, status: result });
465
+ });
460
466
  return;
461
467
  } else {
462
468
  // after:轮询依赖,满足即执行一次后结束;任一依赖失败则跳过
@@ -471,8 +477,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
471
477
  }
472
478
  writeSchedule(home, { ...cur, status: 'running' });
473
479
  const result = await runOnce();
474
- const cur2 = readSchedule(home, id);
475
- if (cur2) writeSchedule(home, { ...cur2, status: result });
480
+ // 评估 6.4(v0.4.3):after 最终状态读-改-写加锁(同上)
481
+ withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
482
+ const cur2 = readSchedule(home, id);
483
+ if (cur2) writeSchedule(home, { ...cur2, status: result });
484
+ });
476
485
  return;
477
486
  }
478
487
  }
package/src/skill-lib.js CHANGED
@@ -13,9 +13,11 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import crypto from 'node:crypto';
16
- import { spawnSync } from 'node:child_process';
16
+ import { spawn } from 'node:child_process';
17
+ import { lookup } from 'node:dns/promises';
17
18
  import { fileURLToPath } from 'node:url';
18
19
  import { mingdaoHome, ensureHome } from './config.js';
20
+ import { isPrivateHost } from './tools/fetch.js';
19
21
 
20
22
  const LIB_DIR = fileURLToPath(new URL('../skills-lib', import.meta.url));
21
23
 
@@ -250,7 +252,7 @@ export function installFromDir(dir) {
250
252
  /**
251
253
  * @param {any} url
252
254
  */
253
- export async function installFromUrl(url) {
255
+ export async function installFromUrl(url, { allowPrivate = false } = {}) {
254
256
  let u;
255
257
  try {
256
258
  u = new URL(url);
@@ -264,7 +266,41 @@ export async function installFromUrl(url) {
264
266
  const timer = setTimeout(() => ctrl.abort(), 30000);
265
267
  let text;
266
268
  try {
267
- const res = await fetch(u, { signal: ctrl.signal, redirect: 'follow' });
269
+ // 审计 P2-1(v0.4.2):SSRF 防护——与 fetch 工具/validateRemoteUrl 同口径:
270
+ // 初始与每一跳重定向都做私网/回环字面量判定 + DNS 复检(防域名重绑定),跳数上限 5。
271
+ // allowPrivate(CLI 显式输入 URL 时开启):本地用户自担意图,内网地址可安装;WebUI 默认拦截。
272
+ let cur = u;
273
+ let res = /** @type {any} */ (null);
274
+ for (let hop = 0; hop <= 5; hop++) {
275
+ const ch = String(cur.hostname || '').toLowerCase();
276
+ let blocked = !allowPrivate && isPrivateHost(ch);
277
+ if (!blocked && ch && ch !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ch)) {
278
+ try {
279
+ const addrs = await lookup(ch, { all: true, verbatim: true });
280
+ blocked = !allowPrivate && addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
281
+ } catch {
282
+ // DNS 解析失败:放行,连接阶段会报错
283
+ }
284
+ }
285
+ if (blocked) return { error: `拒绝访问内网/本机地址(${ch})——SSRF 防护。` };
286
+ res = await fetch(cur, { signal: ctrl.signal, redirect: 'manual' });
287
+ if (res.status >= 300 && res.status < 400) {
288
+ if (hop >= 5) return { error: '重定向次数超过上限(5 跳)。' };
289
+ const loc = res.headers.get('location');
290
+ if (!loc) break;
291
+ try {
292
+ cur = new URL(loc, cur);
293
+ } catch {
294
+ return { error: `非法重定向地址:${loc}` };
295
+ }
296
+ if (cur.protocol !== 'http:' && cur.protocol !== 'https:') {
297
+ return { error: '重定向到非 http(s) 地址,已拒绝。' };
298
+ }
299
+ continue;
300
+ }
301
+ break;
302
+ }
303
+ if (!res) return { error: '下载失败:无响应' };
268
304
  if (!res.ok) return { error: `下载失败:HTTP ${res.status}` };
269
305
  text = await res.text();
270
306
  if (text.length > 512 * 1024) return { error: 'SKILL.md 超过 512KB 上限' };
@@ -290,23 +326,34 @@ export async function installFromUrl(url) {
290
326
  return r;
291
327
  }
292
328
 
329
+ /** 异步 spawn(审计 P1-3):child_process.spawn + Promise,返回 { error?, code, signal }。 */
330
+ function runSpawn(/** @type {string} */ cmd, /** @type {string[]} */ args, /** @type {{ timeoutMs?: number }} */ { timeoutMs } = {}) {
331
+ return new Promise((resolve) => {
332
+ const child = spawn(cmd, args, { stdio: 'ignore', timeout: timeoutMs });
333
+ child.on('error', (/** @type {any} */ err) => resolve({ error: err }));
334
+ child.on('close', (code, signal) => resolve({ code, signal }));
335
+ });
336
+ }
337
+
293
338
  /**
294
339
  * @param {any} gitUrl
295
340
  */
296
- export function installFromGit(gitUrl) {
341
+ export async function installFromGit(gitUrl) {
297
342
  if (typeof gitUrl !== 'string' || gitUrl.trim().startsWith('-')) {
298
343
  return { error: 'git 地址不能以 - 开头(防选项注入)' };
299
344
  }
300
- const check = spawnSync('git', ['--version'], { stdio: 'ignore' });
301
- if (check.error || check.status !== 0) {
345
+ // 审计 P1-3(v0.4.2):spawnSync 最长阻塞 120s 冻结整个 Node 事件循环(WebUI 全部并发会话/
346
+ // 权限确认/SSE 流无响应)。改异步 spawn,与 v0.4.1 mountConfigTools 修复同口径。
347
+ const check = await runSpawn('git', ['--version']);
348
+ if (check.error || check.code !== 0) {
302
349
  return { error: '未找到 git(git 仓库安装需要系统 git,可用 URL 安装单文件技能)' };
303
350
  }
304
351
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mingdao-skill-git-'));
305
352
  // -- 分隔符:gitUrl 即使形似选项也只按路径处理
306
- const r = spawnSync('git', ['clone', '--depth', '1', '--', gitUrl, tmp], { stdio: 'ignore', timeout: 120000 });
307
- if (r.error || r.status !== 0) {
353
+ const r = await runSpawn('git', ['clone', '--depth', '1', '--', gitUrl, tmp], { timeoutMs: 120000 });
354
+ if (r.error || r.code !== 0) {
308
355
  fs.rmSync(tmp, { recursive: true, force: true });
309
- return { error: `git clone 失败:${r.error?.message || `退出码 ${r.status}`}` };
356
+ return { error: `git clone 失败:${r.error?.message || (r.signal ? `超时/被终止(${r.signal})` : `退出码 ${r.code}`)}` };
310
357
  }
311
358
  const found = [];
312
359
  const stack = [tmp];
@@ -388,15 +435,16 @@ export async function reinstallSkill(name) {
388
435
  // 统一入口:自动识别 库名 | 本地目录 | SKILL.md URL | git 仓库
389
436
  /**
390
437
  * @param {any} arg
438
+ * @param {{ allowPrivateUrl?: boolean }} [opts] allowPrivateUrl=true 时 URL 安装放行内网地址(CLI 显式输入场景)
391
439
  */
392
- export async function installSkill(arg) {
440
+ export async function installSkill(arg, opts = {}) {
393
441
  const a = String(arg || '').trim();
394
442
  if (!a) return { error: '缺少参数:mingdao skill install <库名|目录|SKILL.md URL|git 仓库地址>' };
395
443
  const libHit = libraryList().find((s) => s.name === a);
396
444
  if (libHit) return installFromLibrary(a);
397
445
  if (fs.existsSync(path.resolve(a))) return installFromDir(a);
398
446
  if (/^https?:\/\//i.test(a)) {
399
- if (/\.md(#.*)?$/i.test(a.split('?')[0])) return installFromUrl(a);
447
+ if (/\.md(#.*)?$/i.test(a.split('?')[0])) return installFromUrl(a, { allowPrivate: opts.allowPrivateUrl === true });
400
448
  return installFromGit(a);
401
449
  }
402
450
  if (/^git@/.test(a)) return installFromGit(a);
@@ -347,10 +347,10 @@ function doPush(username, body) {
347
347
  const dir = sessionsDir(username);
348
348
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
349
349
  const file = path.join(dir, name);
350
- const tmp = file + '.tmp';
351
- fs.writeFileSync(tmp, content, { mode: 0o600 });
352
- fs.renameSync(tmp, file);
350
+ // 审计 P1-2(v0.4.2):内容落盘必须在锁内且原子——此前固定 .tmp + 锁外写,
351
+ // 并发同名 push 互相覆盖 tmp、rename 竞态,内容与 meta size 可能错位。
353
352
  return withWriteLock(() => {
353
+ atomicWriteFileSync(file, content, { mode: 0o600 });
354
354
  const meta = readJson(metaFile(username), {});
355
355
  meta[name] = { mtime: Date.now(), size: Buffer.byteLength(content) };
356
356
  writeJson(metaFile(username), meta);
@@ -68,7 +68,7 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
68
68
  if (mcpManager) mcpManager.stop();
69
69
  },
70
70
  };
71
- startMcpServers(cfg.mcpServers, workingDir).then((m) => (mcpManager = m)).catch(() => {});
71
+ startMcpServers(cfg.mcpServers, workingDir, cfg).then((m) => (mcpManager = m)).catch(() => {});
72
72
  }
73
73
  const sessionRef = /** @type {{name: any}} */ ({ name: null });
74
74
  let persistedCount = 0;
package/src/tools/bash.js CHANGED
@@ -15,7 +15,8 @@ const MAX_TIMEOUT_SECONDS = 600;
15
15
  // 整体关闭(回到完全透传)。
16
16
  const SENSITIVE_ENV_PAIR = /(api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)/i;
17
17
  const SENSITIVE_ENV_SEGMENT = /(^|_)(token|secret|password|passwd|credential|authorization|auth)(_|$)/i;
18
- const isSensitiveEnv = (/** @type {any} */ k) => SENSITIVE_ENV_PAIR.test(k) || SENSITIVE_ENV_SEGMENT.test(k);
18
+ // 审计 P3-6(v0.4.2):导出供 hooks.js 复用——hook 子进程 env bash 工具同口径过滤敏感变量。
19
+ export const isSensitiveEnv = (/** @type {any} */ k) => SENSITIVE_ENV_PAIR.test(k) || SENSITIVE_ENV_SEGMENT.test(k);
19
20
 
20
21
  function buildChildEnv(/** @type {any} */ ctx, /** @type {any} */ filterSensitive) {
21
22
  if (!filterSensitive) return process.env;
@@ -87,7 +88,9 @@ export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
87
88
  if (!command.trim()) return { ok: false, error: 'command 参数为空。' };
88
89
  const timeoutSec = Math.min(Number(args.timeout) || 120, MAX_TIMEOUT_SECONDS);
89
90
  // 配置优先:模型不能通过传 sandbox:'off' 自行降级(配置里选了 safe/readonly 就必须沙箱)
90
- const mode = String(ctx?.cfg?.sandbox ?? args.sandbox ?? 'off');
91
+ // 审计 P3-9(v0.4.2):cfg.sandbox=''(空串)时 ?? 不触发,mode 为空串落入 readonly 沙箱分支——
92
+ // || 'off' 归一化空串回默认 off。
93
+ const mode = String(ctx?.cfg?.sandbox ?? args.sandbox ?? 'off') || 'off';
91
94
  const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
92
95
  const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
93
96
 
@@ -2,7 +2,9 @@
2
2
  // SSRF 防护:字面量私网/回环拒绝 + DNS 解析复检(防域名重绑定),口径与 server.js validateRemoteUrl 一致。
3
3
  import { lookup } from 'node:dns/promises';
4
4
 
5
- function isPrivateHost(/** @type {string} */ hostname) {
5
+ // 审计 4.1(v0.4.2):isPrivateHost 导出复用——skill-lib.installFromUrl 等外联入口共用同一判定,
6
+ // 避免「漏一处」的 SSRF 防护缺口(此前 fetch 工具与 server.validateRemoteUrl 各自维护一份)。
7
+ export function isPrivateHost(/** @type {string} */ hostname) {
6
8
  let h = String(hostname || '').toLowerCase();
7
9
  if (!h) return true;
8
10
  h = h.replace(/^\[|\]$/g, '');
package/src/web/app.js CHANGED
@@ -398,7 +398,16 @@ async function send(){
398
398
  else if(ev.type==='error'){ console.log('[MingDao] error 事件:' + ev.message); onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
399
399
  else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
400
400
  });
401
- }catch(e){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):(e&&e.message)||'网络错误'; msg.appendChild(d); scroll(); }
401
+ }catch(e){ onActivity(); // 诊断(v0.4.2 network error 排查):静默中断此前无任何日志,无法区分
402
+ // 「手点停止 / 看门狗 / fetch 流静默断裂」。v0.4.3 修:第二参改 JSON 字符串——Electron 日志
403
+ // 对对象只会落 [object Object],异常细节丢失。
404
+ const diag = { name: e && e.name, message: e && e.message, err: String(e), taskId, rawLen: raw.length, steps: stepsCount, elapsedS: Math.round((Date.now() - workT0) / 1000) };
405
+ console.error('[MingDao] chat 流异常 ' + JSON.stringify(diag));
406
+ const d=document.createElement('div'); d.className='errline';
407
+ // 统一 catch 文案(v0.4.3):非用户中断/看门狗时,无论有无 e.message 都优先给「可续跑」的友好提示,
408
+ // 具体错误形态已进日志(fetch failed 等不再绕开续跑提示)。
409
+ d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。');
410
+ msg.appendChild(d); scroll(); }
402
411
  finally{ clearInterval(hintTimer); disarm(); generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); activeAiMsg=null; curWorkT0=0; renderWorkStatus(); scroll(); updateTasksPanel(); }
403
412
  }
404
413
 
@@ -578,10 +587,17 @@ setInterval(updateTasksPanel, 2000);
578
587
  async function refreshCostBadge(){
579
588
  const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
580
589
  const j=await r.json().catch(()=>null); if(!j) return;
581
- const bd=j.breakdown||{}; const gd=j.guard||null;
582
- let t='📊 今日 ≈¥'+(bd.today||0).toFixed(4);
583
- if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
584
- if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
590
+ const bd=j.breakdown||{}; const gd=j.guard||null; const sm=j.summary||{};
591
+ // v0.4.3:本地/自定义模型无定价时费用恒 0——此前显示「¥0.0000」被误读为「无统计」。
592
+ // 有用量但累计费用为 0 即判定为无计价模型,明确标注而非含糊 ¥0
593
+ let t;
594
+ if ((sm.turns||0) > 0 && (bd.totalCost||0) === 0) {
595
+ t='📊 本地模型不计费 · 累计 ↑'+(sm.prompt||0)+' ↓'+(sm.completion||0)+' tokens';
596
+ } else {
597
+ t='📊 今日 ≈¥'+(bd.today||0).toFixed(4);
598
+ if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
599
+ if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
600
+ }
585
601
  $('#costBadge').textContent=t;
586
602
  if($('#dashPanel').style.display==='flex') renderDashboard(j);
587
603
  }
@@ -30,7 +30,8 @@ export function buildUserContent(/** @type {any} */ message, /** @type {any} */
30
30
  } else if (a.type === 'text') {
31
31
  const content = String(a.content ?? '');
32
32
  if (!content.trim()) continue;
33
- if (content.length > MAX_TEXT_BYTES) {
33
+ // 评估 6.7(v0.4.3):上限按字节数而非字符数(中文每字 3 字节,字符数口径会放大到声明值 3 倍)
34
+ if (Buffer.byteLength(content, 'utf8') > MAX_TEXT_BYTES) {
34
35
  return { error: `文本文件过大:${a.name || '未命名'}(≤200KB)` };
35
36
  }
36
37
  finalText += `${finalText ? '\n\n' : ''}[文件 ${a.name || '未命名'}]\n${content}`;
@@ -85,6 +85,9 @@ 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
+ // 评估 6.1(v0.4.3):先 path.resolve 消解 .. 段,再做前缀比较与 stat/readdir——此前字符串
89
+ // 前缀比较用未规范化的 dir,`/home/u/../../etc` 能通过 startsWith('/home/u/') 但 stat 解析到 /etc。
90
+ dir = path.resolve(dir);
88
91
  // 质检 A3:目录浏览限定基目录,拒绝越界。Windows(CodeArts 报告):家目录覆盖整个用户配置树
89
92
  // (AppData 等)——收紧为 桌面/文档/下载 三常用目录 + 启动目录 + 工作目录 + web.browseRoots 显式授权;
90
93
  // 路径比较在 win32 下大小写归一(D:\\ vs d:\\ 不再误拒)。
package/src/web/server.js CHANGED
@@ -119,8 +119,8 @@ function readBody(req, limit = 40 * 1024 * 1024) {
119
119
  });
120
120
  }
121
121
 
122
- /** @param {{ host?: string, port?: number, authToken?: string|null, [key: string]: any }} [opts] */
123
- export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken } = {}) {
122
+ /** @param {{ host?: string, port?: number, authToken?: string|null, onBusy?: (busy: boolean) => void, [key: string]: any }} [opts] */
123
+ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken, onBusy } = {}) {
124
124
  const home = ensureHome();
125
125
  const cfg = loadConfig();
126
126
  if (!cfg) {
@@ -206,7 +206,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
206
206
  let mcpManager = null;
207
207
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
208
208
  // 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
209
- const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
209
+ const mcpStartP = startMcpServers(cfg.mcpServers, workingDir, cfg).catch(() => null);
210
210
  mcpManager = await Promise.race([
211
211
  mcpStartP,
212
212
  new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
@@ -230,6 +230,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
230
230
  let inflight = 0; // 质检 S2:在途聊天请求计数(与请求生命周期绑定,防 readBody 期间并发超限)
231
231
  const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
232
232
  let taskSeq = 0;
233
+ // 忙状态通知(v0.4.3 network error 修复):有 running 任务即「忙」——桌面版据此在生成期
234
+ // 防睡眠/防熄屏(macOS 熄屏会中断 Chromium 网络栈导致 SSE 断连)。onBusy 由调用方注入。
235
+ let lastBusy = false;
236
+ function notifyBusy() {
237
+ const busy = [...tasks.values()].some((/** @type {any} */ t) => t.status === 'running');
238
+ if (busy !== lastBusy) {
239
+ lastBusy = busy;
240
+ try {
241
+ onBusy?.(busy);
242
+ } catch {}
243
+ }
244
+ }
233
245
  // 会话文件互斥(质检 C1/M3):同一 session 文件的 append 与 compact 整文件重写必须串行
234
246
  const sessionLocks = new Map();
235
247
  /** @param {any} file @param {any} fn */
@@ -321,6 +333,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
321
333
  const entry = /** @type {any} */ ({ res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 });
322
334
  srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
323
335
  tasks.set(taskId, entry);
336
+ notifyBusy(); // 进入 running → 忙
324
337
  /** @param {any} obj */
325
338
  const send = (obj) => {
326
339
  try {
@@ -350,6 +363,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
350
363
  const built = buildUserContent(userMessage, body.attachments, visionSupported);
351
364
  if (built.error) {
352
365
  entry.status = 'failed';
366
+ notifyBusy();
353
367
  send({ type: 'error', message: built.error });
354
368
  clearInterval(progressTimer);
355
369
  res.end();
@@ -490,6 +504,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
490
504
  providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
491
505
  } catch (/** @type {any} */ err) {
492
506
  entry.status = 'failed';
507
+ notifyBusy();
493
508
  entry.durationMs = Date.now() - entry.startedAt;
494
509
  send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
495
510
  clearInterval(progressTimer);
@@ -497,6 +512,17 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
497
512
  pruneTasks();
498
513
  return;
499
514
  }
515
+ // v0.4.4:长任务费用逐轮入账——记录已按轮入账的累计 usage,最终补记最后一轮 + 兜底总结的剩余。
516
+ const recorded = { prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0 };
517
+ const onUsage = (/** @type {any} */ delta) => {
518
+ recorded.prompt_tokens += delta?.prompt_tokens || 0;
519
+ recorded.completion_tokens += delta?.completion_tokens || 0;
520
+ recorded.prompt_cache_hit_tokens += delta?.prompt_cache_hit_tokens || 0;
521
+ recorded.prompt_cache_miss_tokens += delta?.prompt_cache_miss_tokens || 0;
522
+ try {
523
+ recordUsage(runModel, delta, null);
524
+ } catch {}
525
+ };
500
526
  const agent = createAgent({
501
527
  provider: providerNow,
502
528
  permission,
@@ -511,11 +537,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
511
537
  withSessionLock(session.file, () => rewriteSession(session.file, msgs)).catch(() => {}); // onCompact 为同步回调:入队串行即可
512
538
  persistedBefore = msgs.length;
513
539
  },
540
+ // 每轮结束回调本轮增量 usage → 逐轮入账(长任务期间「今日费用」实时累计)
541
+ onUsage,
514
542
  // 审计记录归入当前会话(P3-5)
515
543
  sessionRef: { name: path.basename(session.file) },
516
544
  });
517
545
 
518
546
  res.on('close', () => {
547
+ // 诊断(v0.4.2 network error 排查):区分「服务端正常收尾」与「客户端中途断开」——
548
+ // writableEnded=false 且任务仍在跑 = 浏览器/渲染层静默断连(此前无任何日志,根因不可见)。
549
+ if (!res.writableEnded && entry.status === 'running') {
550
+ srvlog('chat 客户端断连 ' + taskId + ' 已跑=' + Math.round((Date.now() - entry.startedAt) / 1000) + 's status=' + entry.status + ' writableEnded=' + res.writableEnded);
551
+ }
519
552
  // 浏览器断开:中止正在跑的生成(否则白白烧 token),挂起的权限确认按拒绝处理
520
553
  if (entry.pendingAsk) {
521
554
  entry.pendingAsk.resolve('');
@@ -526,6 +559,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
526
559
  entry.abortHandler?.();
527
560
  } catch {}
528
561
  entry.status = 'failed';
562
+ notifyBusy();
529
563
  entry.durationMs = Date.now() - entry.startedAt;
530
564
  pruneTasks();
531
565
  }
@@ -539,7 +573,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
539
573
  session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
540
574
  await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
541
575
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
542
- recordUsage(r.perf?.usedModel || runModel, r.usage, /** @type {any} */ (r.perf));
576
+ // v0.4.4:补记最后一轮 + 兜底总结的剩余 usage(自动续跑轮已在 onUsage 逐轮入账,避免重复计)
577
+ const remaining = {
578
+ prompt_tokens: Math.max(0, (r.usage?.prompt_tokens || 0) - recorded.prompt_tokens),
579
+ completion_tokens: Math.max(0, (r.usage?.completion_tokens || 0) - recorded.completion_tokens),
580
+ prompt_cache_hit_tokens: Math.max(0, (r.usage?.prompt_cache_hit_tokens || 0) - recorded.prompt_cache_hit_tokens),
581
+ prompt_cache_miss_tokens: Math.max(0, (r.usage?.prompt_cache_miss_tokens || 0) - recorded.prompt_cache_miss_tokens),
582
+ };
583
+ recordUsage(r.perf?.usedModel || runModel, remaining, /** @type {any} */ (r.perf));
543
584
  // v0.3.0 P0-3:轮末提取项目记忆(有工具工作才提,fire-and-forget)。写文件供「未来会话」用,
544
585
  // 当前会话用快照(上文已注入),故不会中途改系统提示、不破坏前缀缓存。
545
586
  if (io.stats().toolCount > 0) {
@@ -574,6 +615,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
574
615
  clearTaskState(finalSessionName);
575
616
  }
576
617
  entry.status = r.aborted ? 'aborted' : 'done';
618
+ notifyBusy();
577
619
  entry.durationMs = Date.now() - entry.startedAt;
578
620
  srvlog('chat 发送 done ' + taskId + ' status=' + entry.status + ' 总耗时=' + entry.durationMs + 'ms');
579
621
  // 预算可视化(评估 A5):会话当前 token 占用 / 预算
@@ -602,6 +644,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
602
644
  });
603
645
  } catch (/** @type {any} */ err) {
604
646
  entry.status = 'failed';
647
+ notifyBusy();
605
648
  entry.durationMs = Date.now() - entry.startedAt;
606
649
  srvlog('chat 错误 ' + taskId + ' ' + String(err?.message || err));
607
650
  send({ type: 'error', message: String(err?.message || err) });
package/src/workspace.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { mingdaoHome, ensureHome } from './config.js';
9
+ import { atomicWriteFileSync } from './atomic-write.js';
9
10
 
10
11
  export function workspacesFile() {
11
12
  return path.join(mingdaoHome(), 'workspaces.json');
@@ -23,11 +24,8 @@ export function loadWorkspaces() {
23
24
  export function saveWorkspaces(/** @type {any} */ ws) {
24
25
  try {
25
26
  ensureHome();
26
- // 原子写:临时文件 + rename,避免崩溃后注册表被冲空
27
- const target = workspacesFile();
28
- const tmp = target + '.tmp';
29
- fs.writeFileSync(tmp, JSON.stringify(ws, null, 2) + '\n');
30
- fs.renameSync(tmp, target);
27
+ // 原子写(评估 6.6):随机 tmp 名 + rename,避免崩溃冲空与跨进程共名 tmp 串扰
28
+ atomicWriteFileSync(workspacesFile(), JSON.stringify(ws, null, 2) + '\n');
31
29
  } catch {}
32
30
  }
33
31
 
@@ -121,10 +119,8 @@ export function loadSessionWorkspaces() {
121
119
  export function saveSessionWorkspaces(/** @type {any} */ map) {
122
120
  try {
123
121
  ensureHome();
124
- const target = sessionWorkspacesFile();
125
- const tmp = target + '.tmp';
126
- fs.writeFileSync(tmp, JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
127
- fs.renameSync(tmp, target);
122
+ // 原子写(评估 6.6):随机 tmp 名 + rename
123
+ atomicWriteFileSync(sessionWorkspacesFile(), JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
128
124
  } catch {}
129
125
  }
130
126