mingdao-harness 0.6.4 → 0.6.5

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
@@ -262,6 +262,13 @@ mingdao cost --by pack # 垂域费用分账
262
262
  Linux 读 `/proc/<pid>/cmdline`、macOS 走 `ps`,两者都能精确校验;**Windows 两者皆无**
263
263
  (不为这一处判定引入 PowerShell/WMI 依赖),此时退回 best-effort 按存活处理,并把限制写在这里。
264
264
  需要精确校验的场景请部署在 Linux / macOS 上
265
+ - **遥测:只有桌面版的一条更新计数,且可一键关闭**(v0.6.5 明确):
266
+ 桌面版在**新版本下载完成**后,向 `https://harness.mingdao.ai/updok` 发一次 POST,
267
+ 内容是 `{kind:'update', os, ver}`——**只有平台名与版本号,不含任何用户标识、会话或代码内容**。
268
+ 它是官网「桌面自动更新次数」的唯一数据源(安装包的下载次数无法区分"人点的"与"更新器拉的",见
269
+ `site-stats.mjs` 的口径说明)。**关闭方式(任一即可)**:环境变量 `MINGDAO_NO_TELEMETRY=1`,
270
+ 或 `config.json` 里写 `"telemetry": false`;关闭后不影响自动更新的下载与安装,只是不再计数。
271
+ CLI 与 WebUI 本身**不发送任何遥测**;`mingdao net report` 可以把本机出网目标逐条列出来自证。
265
272
 
266
273
  ## 配置与扩展
267
274
 
@@ -745,6 +745,212 @@ Windows 腿红。本批的三条守卫(sync-server 写锁、BUG-029 循环头
745
745
  > BUG-015 是**行为 + 源码级**(空 stdin 真的退 1;告警文案由源码守卫钉住);
746
746
  > 文档一致性是**文本级**。**如实登记,不把文本级说成行为级。**
747
747
 
748
+ ## 3.35 高级缺陷 22 项**逐条对账**(2026-09-21,v0.6.4 已发版后)
749
+
750
+ `defect-register.md` 的「一、高级缺陷」按 8 字段逐条登记了 BUG-001 ~ BUG-022。此前各批是**按主题**修的
751
+ (§3.16 / §3.17 / §3.20 / §3.22 / §3.24 / §3.25 / §3.34 …),**从未按这 22 个编号逐条对账**——
752
+ 于是「高级缺陷还剩几条」在这份文件里查不到答案。本批**只做对账**(已修的不再动代码),
753
+ 结论一律以**当前代码 + 实跑**为准,不看登记簿里那句「状态:未修复」(那是审计当时的状态)。
754
+
755
+ | BUG | 审计描述 | 结论 | 依据(当前代码 / 实测) |
756
+ | --- | --- | --- | --- |
757
+ | 001 | `constraints` 的 pattern 直接 `new RegExp` → ReDoS | ✅ 已修 | `src/constraints.js:100-127`:拒绝**嵌套量词**形状(`(a+)+`、`(\d+)*`、`(x+){2,}`),并从**装载期**就判(不是等匹配时);注释里留着实测(29 字符即数秒)——见 §3.16 |
758
+ | 002 | `hooks.js` 的 `spawn(cmd,{shell:true})` → 任意命令执行 | 🟡 **非缺陷(设计取舍)** | `src/hooks.js:56` 仍是 `shell: true`,但 hooks 只来自**用户自己的 config**,Pack **无法**贡献 hook 命令(§3.16 核实)。**残留风险如实写在这里**:拿到 config.json 写权限等于拿到命令执行——这是「配置即代码」的固有面,不是可修的缺陷 |
759
+ | 003 | registry 索引名 `..` → `rmSync` 删掉整个 `MINGDAO_HOME` | ✅ 已修 | `src/skill-registry.js:143` 先过 `assertSafeSkillName()`(`.`/`..`/分隔符一律拒),注释直接点名这条攻击链——见 §3.24 |
760
+ | 004 | 「公网端点必须 Key」无客户端强制 | ✅ 已修 | `src/cli.js:412`:`if (!pc0.apiKey && !isLocalBaseUrl(pc0.baseUrl))` → 明确报错退 1;内网/本机端点按 C4 豁免(`src/model-caps.js:57`) |
761
+ | 005 | 账本哈希链**末行盲区 + 尾部截断**不可检出 | ✅ 已修 | `src/ledger.js`:`run.end` 额外写**封条侧车** `sealFile()`(`*.seal.json`),截断/删尾即被 `verify` 判为「完整性未知」并退 1——见 §3.17 |
762
+ | 006 | 消息级 token 缓存「永远 miss、每步全量 BPE」 | ❌ **不成立** | 实测「永远 miss」不存在(同计数器 200 次调用 0.0ms);真问题是**计数器 identity**,已按模型名缓存(`src/tokenizer.js:379-388` 带 LRU 上限)。`src/context.js:33-40` 的 WeakMap 仍以 `hit.fn === count` 为键,但同一回合内计数器稳定 → 命中——见 §3.15 |
763
+ | 007 | 词表 `loadData` 一次失败**终生**不重试 | ✅ 已修 | `src/tokenizer.js:70-101`:区分 ENOENT(latch,不再 stat)与瞬时错误(**有界重试 + 冷却窗口**),失败原因可读——见 §3.25 |
764
+ | 008 | `SharedArrayBuffer`/`Atomics` 在**模块顶层**执行 | 🟡 **已知边界(Node-only)** | `src/atomic-write.js:84-85` 仍在顶层建 `Int32Array(new SharedArrayBuffer(4))`。本项目 `engines.node>=18.17`、零依赖 Node,主进程/CLI 均可用;**嵌进没有 SAB 的环境(浏览器/渲染进程)会整模块加载失败**。不假装已解决——如实登记为边界 |
765
+ | 009 | `cachestats` 追加在锁外、轮转在锁内 → 丢行 | ✅ 已修 | `src/cachestats.js:72` 起**追加与轮转同锁**(批四)——见 §3.29 |
766
+ | 010 | 安装器临时目录只在「校验失败」一条路上删 | ✅ 已修 | `src/skill-lib.js`:`installFromUrl`/`installFromGit` 的清理都进 `finally`,并补 `meta` 为 null 的显式判定——见 §3.34 |
767
+ | 011 | `stdout.write` 无 EPIPE 处理 → `\| head` 崩 | ✅ 已修 | `src/proc.js:147-178` `installPipeGuards()`(CLI `exitOnEpipe`,库模式不退出),`src/cli.js:128` 安装;实测 `node src/cli.js --help \| head -1` → 退出码 0、无堆栈——见 §3.20 |
768
+ | 012 | `workspace` 写失败被 `catch {}` 吞(假成功) | ✅ 已修 | `src/workspace.js`:`saveWorkspaces()` / `saveSessionWorkspaces()` 改为**返回 `{ok,error}`**,会话映射另有一次性告警与 `sessionWorkspaceWriteError()` 出口——见 §3.18–§3.21 |
769
+ | 013 | `createIO()` 未 close → readline 泄漏、进程挂起 | ❌ **不成立** | readline 是**懒创建**(`src/ui.js` 的 `ensureRl()` 只在需要交互输入时建),`ledger`/`net` 命令从不走交互输入。实测 `ledger list` / `net policy` / `net report` / `pack list` 均 **~50ms 正常退出**(exit 0)——见 §3.20 附 |
770
+ | 014 | `sync passwd` 新密码走命令行参数 | ✅ 已修 | `src/commands/sync.js:123-136`:**拒绝位置参数**(明确提示会进 ps/shell history),只走隐藏输入——见 §3.1 |
771
+ | 015 | `--auth-token` 字面量进 argv | ✅ 已修 | 新增 `--auth-token=-` 从 stdin 读,字面量给告警并指向 `MINGDAO_WEB_TOKEN`——见 §3.34 |
772
+ | 016 | 顶层错误处理与 REPL 主循环无 EPIPE 处理 | ✅ 已修 | 同 011:`installPipeGuards` 同时挂 stdout/stderr,CLI 侧 EPIPE 静默退 0、非 EPIPE 重抛保持可见 |
773
+ | 017 | 发布脚本把 token 经 **ssh argv** 传到服务器 | ✅ 已修 | `scripts/publish-mirror-releases.sh`:凭据经 **ssh stdin** 落到 `umask 077` 的 600 临时文件,读后即删——见 §3.22 |
774
+ | 018 | Gitee token 拼在 **URL query** 里 | ✅ 已修 | 同上:两平台改 **HTTP 头鉴权**(gitee `Authorization: token`),URL 里不再有凭据 |
775
+ | 019 | 同 008(`Atomics.wait` 顶层) | 🟡 已知边界 | 与 008 同根,按同一条登记 |
776
+ | 020 | Web 路由域的 workspace 静默吞写 | ✅ 已修 | 与 012 同一实现(`src/workspace.js`),Web 路由复用它 |
777
+ | 021 | `main().catch` 里 `console.error` 再抛 EPIPE | ✅ 已修 | `src/proc.js:170-178` 的兜底**同时覆盖 stdout 与 stderr**,并在 `cli.js:128` 早于业务代码安装 |
778
+ | 022 | REPL 主循环 `io.print` 无 EPIPE 处理 | ✅ 已修 | 同上(`src/proc.js` 的监听装在 `process.stdout` 上,REPL 与一次性提问共用) |
779
+
780
+ **对账结果**:22 项 = **已修 17** + **不成立 2**(006、013)+ **已知边界/设计取舍 3**(002、008、019)。
781
+ **没有一项处于「待修」**——审计报告 §5.2 路线图里「v0.6.2:P0 全修复(高级问题=0)」这一格,可以据此打勾。
782
+
783
+ > 两点自我约束:
784
+ > ① 008/019 我**不**写成「已修」——顶层 `SharedArrayBuffer` 还在,只是本项目承诺的运行环境里可用;
785
+ > ② 002 我**不**写成「已修」——`shell:true` 还在,只是触发前提是「用户的 config 被改」,
786
+ > 按「配置即代码」记为固有面。把这两类写成「已修」会让后面的人失去警惕。
787
+
788
+ ## 3.36 已修复(第三十六批「批十」:中级缺陷收口 —— 先对账,再修)
789
+
790
+ **这一批的起点不是「挑几条好修的」**:登记簿的「状态:未修复」停在 v0.6.1,而 v0.6.2~v0.6.4 已按**主题**
791
+ 修掉一大批——不逐条核对就分不清「还剩什么」。做法是四个**只读**核查子代理各领一组(BUG-023~083),
792
+ 每条都要在 HEAD 上给出四选一的结论(仍成立 / 已修 / 不成立 / 设计取舍)与证据;
793
+ 「仍成立」的必须附**最小复现 + 实际输出**,涉及性能的必须给**实测数字**(不接受复杂度推断)。
794
+
795
+ ### 36.1 本批修复(13 条,全部带回归断言)
796
+
797
+ | BUG | 位置 | 核实结论(HEAD) | 修法 |
798
+ | --- | --- | --- | --- |
799
+ | 023 / 035 | `src/agent.js:677` | **成立**:外层条件 `guard.downgrade && !downgraded` 让「本回合刚降过一次」之后的每一步**两个分支都不进**——既不切也不拦,静默继续用 flash 发请求继续计费(实测同回合发出 2 次;而「开局就在 flash 上」那条路径本来就会拦,两条路径结论相反) | 判据改为「超限 + 已在最便宜模型」→ 一律 block(与开局即 flash 同源) |
800
+ | 025 | `src/agent.js` 工具批次循环 | **成立**:SIGINT 只把 `aborted` 置真,工具执行后**没有任何收敛点**(实测中断后仍发出下一次请求) | 轮首 + 工具批次循环各加一处收敛,如实返回 `aborted:true` |
801
+ | 026 | `src/model-discovery.js:134` | **成立**(审计原话「越权使用模型」**夸大**:校验通过后路由仍去该名字真正的服务商):`isDiscoveredModel` 遍历**所有**服务商缓存 → A 家拉到的名字能让 B 家的切换校验放行 | 加 `providerName` 形参,调用点传目标服务商 |
802
+ | 027 | `src/pricing.js:113` | **成立**:非法时区时峰谷回退**本机墙钟**,而日界/费用护栏回退 `Asia/Shanghai`(实测 TZ=America/New_York 下同一时刻峰谷按纽约判、日界按上海算,差 8~13 小时) | 抽 `peakWallClock()`,回退统一走 `activeTimezone()` |
803
+ | 034 | `src/net-guard.js:203` | **成立**(成因与审计描述不同:warn 模式白名单放行是**有意**的;真洞在逐跳跟随重定向时**从不剥凭据头**——实测第二跳 `attacker.example` 仍带 `Bearer sk-…`) | 跨 origin 跳转剥 `authorization`/`cookie`/`proxy-authorization`,与 fetch 规范对齐 |
804
+ | 037 | `src/compact.js:43,50` | **成立**:两个摘要函数**连 signal 形参都没有**,压缩期间 Ctrl+C 只能干等(单次最长 `totalMs`,重试叠加;「30 分钟全程不可中断」是夸大——中断会在摘要返回后生效) | 加 `signal` 形参并透传;已中断则不再发第二次尝试 |
805
+ | 038 | `src/providers/openai-compatible.js:186` | **成立**:`name` 用「保留最长的那片」,网关分片下发时得到 `weather`(应为 `get_weather`),而同一网关的 `arguments` 走 `+=` 是对的 | 与 `arguments` 对称拼接:分片接上 / 重复下发忽略 / 后到的完整名取它 |
806
+ | 041 | `src/hooks.js:71` | **成立,且真实后果比审计描述的更重**:stdout 截断用 `slice(-MAX)` 截的是**头**。审计写的是「解析失败 → 误 block」,实测更常见的是**截出来的尾部只剩空白** → `trim()` 为空 → 走「空输出 = 放行」那条路 —— **hook 说了 block 却被静默忽略**(fail-open,比误拦危险得多) | 改保留**前缀** + 把「截断」如实带进提示(原因不再是"无法解析") |
807
+ | 043 | `src/routing.js:134` | **成立**:淘汰用 `keys().next()`(最旧**插入**)而注释自称 LRU(实测:用过的条目照样被淘汰,白调一次分类器) | 命中时 `delete` + `set` 做真 LRU |
808
+ | 044 | `src/routing.js:98,142` | **成立**:两处空 `catch`,分类器挂掉时用户只看到「回退执行模型」,console 输出 0 条 | 一次性告警 + 把失败原因带进 `reason` |
809
+ | 046 | `src/tokenizer.js:378` | **成立**:`>50000` 字符**整条绕过缓存**(实测 CJK 20 万字符 = 128ms/回合;`context.js` 的 WeakMap 只兜住"同一 msg 对象") | 用**内容哈希**当缓存键:保留按内容命中,又只存 40 字节键。刻意**不**按 50k 分块相加——那会让跨块 BPE 合并丢失、token 数被动变大(改口径换性能不是这里该做的事) |
810
+ | 047 | `src/atomic-write.js:242` | **成立**(审计原话「忙等无 sleep」**不准**:主竞争路径早有 `sleepMs(25)`):回收分支的 `continue` **绕过超时闸**,陈旧锁删不掉时(只读目录/EPERM/EIO)99% CPU 无限自旋、5s 超时失效 | 回收分支同样过超时闸后再 sleep;同步版与异步版都改 |
811
+ | 048 | `src/cachestats.js:253` | **成立**:`recordAuxUsage` 漏传 `priceAt`(同文件 `recordUsage` 传了)→ 按**落账时刻**选峰谷价(实测同一笔调用 0.48 vs 0.24,边界上正好差一倍) | 加 `perf` 形参,5 个调用点(routing/titles×2/memory×2)带上 `requestStartAt` |
812
+
813
+ **顺带**:BUG-045(tokenizer 注释自称 LRU、实现是 FIFO)在同一处一并改掉。
814
+
815
+ ### 36.2 其余条目的对账结论(不改代码,但要留结论)
816
+
817
+ | 结论 | 条目 | 依据摘要 |
818
+ | --- | --- | --- |
819
+ | ✅ 已修(本轮复核) | 033、049、058、059、081 | 033 改按**文件大小**触发轮转(实测跨进程首次写入即轮转);049 轮转判据与追加同锁、`cacheStatsCount` 全仓已无;058 缺/错 sha256 一律拒装;059 走 `safe-fetch` 逐跳复检 + 元数据无条件拒绝;081 `assertSafeSkillName` + 安装来源白名单(实测 `..`/`file://`/`a/b` 全拒) |
820
+ | 🟡 设计取舍(附残留风险) | 031、032、039、045、054、055、076、078、082 | 031 `*_API_KEY` 仍可被点名是**有意的可用性取舍**(SECRET/TOKEN/PASSWORD 已排除);032 「环境变量 > 凭证库 > config.json」是文件头明示的口径;039 全清 500 条 pattern 实测仅 0.48ms;045 见上;054 `-y` 跳过的确认在 stdio 管道下**无人能答**(同意点在 `mcp preset add`);055 Pack 同进程执行已由 v0.6.2 的信任门覆盖「clone 即执行」,用户级仍是裸执行(文档明写);076 当前无「起了 start 不走 finish」的可达路径;078 写路径本就是 `mode 0600` + rename(chmod 只是自愈遗留文件);082 token 在地址栏只存在一瞬,CSP `connect-src 'self'` 堵住外发,日志已打码 |
821
+ | ❌ 不成立(审计前提读错) | 060、062、065、066、069、074、080 | 060 macOS 走 `ps` 回退可校验归属(实测真 SIGTERM 掉 sleeper);062 请求级 Agent `keepAlive=false`(实测 `Connection: close`、残留连接 0);065 6 个临界区**全是同步体**且 finally 必 release(30 并发全响应);066 Node 超限是 accept 后立即 close(实测 handler 未执行);069 killTask 3.0s 内杀死避峰等待中的 worker 并落 killed;074 三个 `setInterval` 是模块顶层一次性注册、回调只重写固定文本;080 `appendMessages` 是追加语义(实测不覆盖,碰撞只混写) |
822
+
823
+ > **口径说明**:「设计取舍」与「不成立」都**不是**"不用管"——取舍项各自写了残留风险,
824
+ > 不成立项写了为什么(多数是审计读了注释或按最坏情况外推)。这样下一个人不必再核一遍。
825
+
826
+ ### 36.3 验证
827
+
828
+ - **回归断言**:`test/smoke.js` 新增第 121 组(12 个子断言,覆盖上表 13 条;其中 034 补在既有的
829
+ 「74e 重定向逐跳判定」用例里,与它同源)。断言全部是**行为级或子进程级**,不接受只看源码文本:
830
+ - 023/035、025 用真 `createAgent` + 桩 provider 数请求次数;026/046 直接调函数;027/047 起**子进程**
831
+ (前者强制 `TZ=America/New_York` 让回退差异可观测,后者用 8 秒 `spawnSync` 超时兜住"旧实现会挂死");
832
+ 038 起本地 SSE 桩服务器;041 用真 hook 进程输出 70KB;043/044 连打 100+ 次路由;048 读落盘账目比对峰谷价。
833
+ - **变异验证**(`mutate-batch10.sh`,把每处修复改回旧写法后串行跑 smoke):**6/6 全部被抓住** ——
834
+ 023/035(`实际 2 次`)、025(`实际 1 次`)、038(`实际 weather`)、041(`实际 approve`)、
835
+ 043(`after=102 / afterInsert=101`)、047(`status=null`,即旧行为会挂死到 8 秒超时)。
836
+ - 全门禁:`tsc` / strict 棘轮 / smoke(149 组)/ e2e-local / e2e-web / e2e-schedule / api-contracts / bench。
837
+
838
+ ### 36.4 三条自我纠错(**第一轮变异验证里有 3 个变异没被抓住**)
839
+
840
+ 第一轮跑完,6 个变异里有 **3 个仍然全绿**——正好是这套流程存在的意义。逐条查明并改断言后重验:
841
+
842
+ | 变异 | 为什么第一版抓不到 | 改法 |
843
+ | --- | --- | --- |
844
+ | 025(去掉工具循环里的收敛点) | 用例只覆盖了"轮首"那条收敛路径:一次响应里只有一个工具调用时,工具循环只跑一轮,去掉内层检查仍会被轮首检查兜住 —— 断言**分不出这两处检查** | 加第二个场景:在"已决定要调工具、但还没执行"时中断,断言**权限校验一次都没走到**(一个工具都不许执行) |
845
+ | 041(hook 截断改回 `slice(-N)`) | 第一版填充只有 5 万字节,**根本没超过 64KB 上限**,两个实现都不截断 → 断言恒真。更早那版用 `approve` + 7 万空格,看似有牙,其实两条路径都返回 `approve`(旧实现是"空输出=放行",新实现是"解析成功")——**结果相同、原因不同**,正是最难发现的那种假绿 | 改成"hook 说 **block** + 8 万字节空白":新实现解出 block,旧实现截出空白 → 当放行。断言 `decision === 'block'` 且 `reason` 就是 hook 给的那句 |
846
+ | 043(去掉 LRU touch) | 在**进程内**测:`routeCache` 是模块级状态,前面各节已塞过未知数量的条目,于是"缓存是否恰好满、会不会轮到淘汰 T1"都不确定 → 两个实现碰巧都不触发淘汰 | 挪到**全新子进程**里测(缓存必为空):先恰好填满 100 条,再 touch、再插入第 101 条,最后重问第一条——旧实现必然多调一次分类器 |
847
+
848
+ > 顺带更正审计对 BUG-041 的定性:真实后果不是"误 block",而是**静默忽略 hook 的 block 决定**
849
+ > (fail-open)。这与本文件反复出现的那类教训同源:**"结果看起来对"不等于"走的是对的那条路"**。
850
+
851
+ ## 3.37 下一批(**已核实、待修**的中级条目)
852
+
853
+ 四个核查子代理把「仍成立但本批未改」的条目也逐条留了证据与最小修法,按此接续即可(**不需要重新复核**):
854
+
855
+ | BUG | 位置 | 影响 | 最小修法(复核时已给出) |
856
+ | --- | --- | --- | --- |
857
+ | 052 | `src/mcp.js:70` | 父进程**异常**退出(含 SIGKILL)时 MCP 子进程成孤儿(实测 PPID=1 存活) | 模块级 `live` 集合 + `process.on('exit')` 杀整组;CLI 挂 SIGINT/SIGTERM |
858
+ | 053 | `src/mcp-presets.js:45` | `@playwright/mcp@latest` 每次按 dist-tag 解析 → 版本漂移/上游投毒无锁定 | 预设钉具体版本并在 `presetList()` 显示 |
859
+ | 057 | `src/safe-fetch.js:55` | SSRF 的 **TOCTOU**:check 用 `lookup()`、连接再解析一次(rebinding 窗口);且 `lookup` 抛错即放行 | 用 `Agent({connect:{lookup}})` 把已校验 IP **钉**给连接层;lookup 失败 fail-closed |
860
+ | 061 | `src/schedule.js:101` | 任务 id 用 `Math.random()` 且无存在性检查(实测同毫秒碰撞会**静默覆盖**前一个任务) | `crypto.randomUUID()` + 写前 `existsSync` 重生成 |
861
+ | 064 | `src/sync-server.js:112` | `scryptSync` 阻塞事件循环(实测 10 并发 pair 期间 `/healthz` 最大延迟 87ms) | 改异步 `scrypt`(调用点都是 async);注意改密在写锁内,需评估锁内 await 语义 |
862
+ | 068 | `src/task-state.js:104` | `saveTaskStateMerge` 读-改-写**无锁**(实测 12 进程并发只剩 3 条 artifact) | 用既有 `withFileLockSync` 包住 load+写(与 cachestats/audit 同款) |
863
+ | 070 | `src/batch.js:32,69,90` | 三处裸 `fetch` 无超时(桩服务端不回包 → 批次永久挂起) | 传 `signal`,用 `AbortSignal.any([signal, AbortSignal.timeout(N)])` |
864
+ | 071 | `src/batch.js:90` | `res.text()` 无大小上限(实测 240MB 响应体 → OOM,exit 134) | 先看 `content-length`,超限即 abort;或边读边累计字节数 |
865
+ | 072 | `src/ui.js:783` | `close()` 未调 `stopSpinner()`(实测 close 后定时器仍写 3 帧) | `close()` 里先 stopSpinner |
866
+ | 073 | `src/ui.js:687` | `onSigint` 每次注册新监听且不摘旧的(实测 17 次触发 MaxListenersExceededWarning;agent 侧 finally 已削弱影响) | 进入时先 `removeListener` 上一个(语义改为"替换") |
867
+ | 075 | `src/web/app.js:396` | 权限应答 POST 失败被吞、服务端 `pendingAsk` 无超时(实测 1.5s 仍未 settle) | 失败提示 + 重发;服务端加超时按 deny 处理并落事件 |
868
+ | 077 | `src/sync.js:171` 等 | H-7 只覆盖 init 两条写路径;`loadConfig()||{}` + saveConfig 实测把损坏配置**静默覆盖**且无 `.corrupt-*` 备份 | 统一走 `readConfigStrict()`,损坏先隔离再写 |
869
+ | 079 | `src/context.js:105` | 回收摘要按 UTF-16 单元 `slice` → 可切断代理对(实测孤立代理项 @[198,199]) | 码点安全截断(两处共用一个 helper) |
870
+ | 083 | `desktop/main.js:457` | 打包版下载完成即上报 `{os,ver}`,全仓无隐私说明、无 opt-in 开关 | README/官网补一段隐私说明,或读 `MINGDAO_NO_TELEMETRY`/config 开关 |
871
+
872
+ ## 3.38 已修复(第三十七批「批十一」:中级缺陷收口(续)——§3.37 清单落地)
873
+
874
+ 上批把「已核实、待修」的 14 条连同最小修法写进了 §3.37;这一批把它们全部落地。
875
+
876
+ | BUG | 位置 | 影响(复核结论) | 修法 |
877
+ | --- | --- | --- | --- |
878
+ | 052 | `src/mcp.js` | 父进程**异常**退出(含 `process.exit(1)`)时 MCP 子进程成孤儿(实测 `kill -9` 后 PPID=1 存活) | 模块级 `liveMcpChildren` 登记 + `process.on('exit')` **整组**清理(`process.kill(-pid)`);非正常路径也有兜底 |
879
+ | 053 | `src/mcp-presets.js` | 预设一律不带版本(`@playwright/mcp@latest` 更显式)→ npx 每次按 dist-tag 解析,上游发新版即在本机执行 | 每个可用预设**钉死版本**并由 `presetList()` 显示确切 `pkg@version`;升级改为有意动作 |
880
+ | 057 | `src/safe-fetch.js` | SSRF 的 **TOCTOU**:check 用 `lookup()`、连接再解析一次(rebinding 窗口);且 `lookup` 抛错即**放行** | 改用 `node:http(s).request` 并把**已校验的那组 IP 钉给连接层**(`options.lookup`);解析失败 fail-closed |
881
+ | 061 | `src/schedule.js` | 任务 id 用 `Math.random()`(3 位 base36)且无存在性检查 → 同毫秒碰撞**静默覆盖**前一个任务 | `crypto.randomUUID()` + 写前存在性检查;碰撞时加序号后缀,**绝不覆盖** |
882
+ | 064 | `src/sync-server.js` | `scryptSync` 阻塞事件循环(实测 10 并发 pair 期间 `/healthz` 最大延迟 87ms) | 改异步 `scrypt`;盐与哈希在**拿锁之前**算好,锁内仍是纯同步读改写 |
883
+ | 068 | `src/task-state.js` | `saveTaskStateMerge` 读-改-写**无锁**(实测 12 进程并发只剩 3 条 artifact) | 加锁(同步版给 CLI/REPL + **异步版给 WebUI 请求路径**,两者共用同一个纯合并函数) |
884
+ | 070 | `src/batch.js` | 三处裸 `fetch` 无超时(桩服务端不回包 → 批次永久挂起) | 三条请求都接 `signal` + 超时;**取消请求刻意不带**调用方 signal(见下"自我纠错") |
885
+ | 071 | `src/batch.js` | `res.text()` 无大小上限(实测 240MB 响应体 → OOM) | 先看 `content-length`,再**边读边累计**超限即中止 |
886
+ | 072 | `src/ui.js` | `close()` 未调 `stopSpinner()`(实测 close 后 300ms 仍写 3 帧) | `close()` 里先 `stopSpinner()`(与 `endTurn` 同口径) |
887
+ | 073 | `src/ui.js` | `onSigint` 每次注册新监听且不摘旧的(17 次触发 `MaxListenersExceededWarning`) | 注册前先摘掉上一个,语义改为"替换" |
888
+ | 075 | `src/web/server.js`、`src/web/app.js` | 权限应答 POST 失败被前端 `catch(()=>{})` 吞掉;服务端 `pendingAsk` 无超时(实测永久挂起) | 服务端 120s 超时**按拒绝处理**并明确告知;前端失败时给出可见提示 |
889
+ | 077 | `src/config.js` + 两处写回点 | H-7 只覆盖 init 两条路径;`loadConfig()||{}` 会把损坏配置当首次运行**静默覆盖**且无备份 | 新增 `loadConfigForWrite()`(区分"不存在/读不出来",后者先改名备份);`sync login` 与 `mingdao web` 的写回改走它 |
890
+ | 079 | `src/context.js` | 回收摘要按 UTF-16 单元 `slice` → 切断代理对(实测孤立代理项) | 抽 `safeHead()` 做码点安全截断(tool 40 字与 assistant 200 字两处共用) |
891
+ | 083 | `desktop/main.js` + `README.md` | 打包版下载完成即上报 `{os,ver}`,全仓无隐私说明、无退出开关 | 加 `telemetryDisabled()`(`MINGDAO_NO_TELEMETRY=1` 或 `config.telemetry=false`);README「安全」节写明上报内容、目的与关闭方式 |
892
+
893
+ ### 38.1 顺带查实:**4 个 MCP 预设指向的包已经装不上**(不是"版本漂移",是"根本装不上")
894
+
895
+ 钉版本时逐个 `npm view` 才发现(2026-09-21):
896
+
897
+ | 预设 | 包 | npm 返回 |
898
+ | --- | --- | --- |
899
+ | fetch | `mcp-server-fetch` | `0.0.1-security`(包名被安全保留) |
900
+ | git | `mcp-server-git` | `0.0.1-security`(同上) |
901
+ | time | `@modelcontextprotocol/server-time` | **404**(该名字不可安装) |
902
+ | sqlite | `mcp-server-sqlite` | `0.0.2`(仍在,但上游停更 → 钉住这个"最后可用"版本) |
903
+
904
+ 处理:前三条在目录里标 `unavailableReason` 并在 `mcp preset add` 的一刻**明确拒绝**(附替代建议),
905
+ 而不是写进 `config.mcpServers` 后在 MCP 握手里莫名失败;`presetList()`/WebUI 也会显示原因。
906
+ **行为变更如实登记**:`mingdao mcp preset add fetch|git|time` 从"看起来成功、实际起不来"变成"当场报错并说明"。
907
+
908
+ ### 38.2 验证
909
+
910
+ - **回归断言**:`test/smoke.js` 新增第 122 组(12 个用例覆盖上表 14 条)。层级如实标注:
911
+ - **行为级**:052(起子进程 → 父进程退出 → 断言孙进程被清理)、053、057(解析失败 fail-closed)、
912
+ 061(**冻结 `crypto.randomUUID` 与 `Math.random`** 后仍必须保住两个任务)、064(起真同步服务 +
913
+ 并发注册期间探 `/healthz` 延迟 < 30ms)、068(8 个子进程并发合并写)、070(桩服务端不回包,
914
+ 断言 5 秒内报错)、071(2MB 结果体 + 4KB 上限 → 必须报"超过上限")、072(伪装 TTY 后 close,
915
+ 断言 300ms 内 0 次输出)、073(连注册 5 次,监听器净增 ≤1)、077(损坏配置 → 备份存在且内容原样)、
916
+ 079(代理对被切断的构造输入 → 断言无孤立代理项)。
917
+ - **源码级**(如实标注,不充作行为级):075 的前端提示与服务端超时、083 的遥测开关与 README 声明
918
+ —— 行为级验证分别需要完整 WebUI 权限往返与 Electron 打包环境。
919
+ - **变异验证**(`mutate-batch11.sh`,8 个变异串行 + 240s 看门狗):**8/8 全部被抓住** ——
920
+ · 052 孤儿仍存活(`pid … 仍存活`)
921
+ · 057 解析失败被放行(`getaddrinfo ENOTFOUND …`)
922
+ · 061 同毫秒覆盖(`实际只剩 1 个`)
923
+ · 064 同步 scrypt(被源码守卫 `不得再出现同步 scrypt` 抓住)
924
+ · 068 去掉锁(被**同步锁受审阅清单**守卫抓住——这条是"清单即防线"的直接体现;
925
+ 并发丢失本身的行为断言仍保留在第 122e 组)
926
+ · 070 无超时 → 批次永久挂起(看门狗 240s 杀掉,正是旧行为)
927
+ · 073 监听器 `+5`
928
+ · 079 孤立代理项(`\ud835`)
929
+ - 全门禁:`tsc` / strict 棘轮 / smoke(150 组)/ e2e-local / e2e-web / e2e-schedule / api-contracts / bench。
930
+
931
+ ### 38.3 本批的自我纠错(**改产品代码时踩到既有守卫,改测试时踩到自己的断言**)
932
+
933
+ 这一批的摩擦几乎都发生在"既有守卫/既有断言"上,逐条记下来,因为每一条都是真实的机制在起作用:
934
+
935
+ | # | 现象 | 根因 | 处理 |
936
+ | --- | --- | --- | --- |
937
+ | 1 | `node test/smoke.js` 报「中止时必须请求服务端取消」失败 | 我给 batch 的三条请求都接了调用方 `signal`,**取消请求**也因此继承了"已 abort"的 signal → 当场失败,服务端永远收不到取消 | 取消请求显式传 `null`(它是"用户已中止之后"的清理请求),并在注释里写清理由 |
938
+ | 2 | 报「task-state.js:新增 1 处同步锁(不在已审阅清单内)」 | BUG-068 的锁是新调用点,撞上既有的**同步锁受审阅清单**守卫 | 登记进清单并写明理由;同时把 **WebUI 请求路径**改走异步版 `saveTaskStateMergeAsync`(§3.27 的既定口径) |
939
+ | 3 | 报「检查点调用点应为 6 处,实际 5」 | 改用异步版后,守卫的正则只认 `saveTaskStateMerge(`,认不出 `saveTaskStateMergeAsync(` | 正则补上异步名(守卫跟着实现走,而不是反过来) |
940
+ | 4 | 报「改密必须与设备表写互斥」 | 该守卫用「函数开头 400 字内必须出现 `return withWriteLock(`」来钉互斥;BUG-064 把 scrypt 挪到拿锁之前,函数体变长 → **它在测代码排版,不是在测互斥** | 改为"取函数体、断言体内含 `return withWriteLock(`",并补两条 BUG-064 的守卫(体内 `await verifyPassword`、全文件无 `scryptSync`) |
941
+ | 5 | 报「https registry 必须放行」 | 该断言用「错误信息不含『拒绝』」判断协议层放行,而 fail-closed 的新文案里正好有"拒绝" | 断言改为只查**协议层**(不含"明文 http"/"协议不支持"),并在注释里写明这是两件事 |
942
+ | 6 | 报「safe-fetch 不得再用全局 fetch」 | 源码守卫扫到了**文件头注释**里的 `fetch(url, …)` 示例 | 守卫先剥注释再扫(本仓既有守卫的统一口径) |
943
+ | 7 | 122e「并发合并写必须保住全部 artifacts」实际 0/8 | 子进程各自设了 `MINGDAO_HOME`,而**父进程**读回时还停在上一个用例的 home | 读回前切到同一个 home,并在 finally 里恢复 |
944
+ | 8 | 122c「应至少产生一条回收摘要」不成立 | 预算取得太小:回收后的消息虽然存在,但**留不进尾部窗口**;太大又根本不触发回收 | 按"刚好让回收结果留在尾部"重新定预算(230),并实测确认 |
945
+ | 9 | 122i `ReferenceError: probe122i` | 句柄声明在 `try` 里、`finally` 里引用 → 块级作用域 | 声明提到 `try` 之前 |
946
+ | 10 | **Windows 腿红**:新写的 `doChangePassword` 守卫在 ubuntu/macOS 绿、Windows 报 `actual: false` | 守卫正则写死了 `\n}\n`,而 Windows 的 `actions/checkout` 把工作树转成 **CRLF** → 匹配不到函数体(§3.32 记过同款教训:**在「文本」上断言,就要按各平台的文本形态写**) | 改成 `\r?\n}\r?\n`,并用「同一份源码的 LF/CRLF 两种形态」各跑一遍确认。**这次是 CI 抓到的,不是本地**——本地是 macOS,跑不到 CRLF |
947
+
948
+ | 11 | **CI 卡死**:`ubuntu-latest(20)` 与 `macos-latest(20)` 的「冒烟测试」步骤永远 `in_progress`(断言其实**全过**),而 ubuntu 18/22 与 Windows 20 正常 | 本批新增的两个桩服务端**故意造成半开连接**(批处理超时用例的"永不回包"服务端、被大小上限掐断的大响应),某些 Node/平台组合下 socket 回收不及时 → server 句柄与 undici keep-alive 连接把事件循环拖住,进程不退出 | 三处桩服务端:`listen` 后 `unref()`、`finally` 里先 `closeAllConnections()` 再 `close()`;并在 smoke 末尾**显式 `process.exit(0)`**——"别让残留句柄决定流水线状态"(断言失败仍在抛错处非 0 退出;句柄层面的泄漏仍由 §3.20/§3.26 的专项断言看管) |
949
+
950
+ > 11 条里有 6 条是**既有守卫/既有断言**先发现的,第 10 条是 **Windows 腿**、第 11 条是 **CI 步骤状态**先发现的
951
+ > ——"改一处代码要连着看一眼守卫"、"推完要看 CI",这两件事在这批里被反复证明是省时间的,而不是挡路的。
952
+
953
+
748
954
  ## 4. 其余登记项(**第三方结论,我未逐条复核**)
749
955
 
750
956
  ### 4.1 自评报告(`MingDao-harness-v0.6.1-技术评估报告.md`)
@@ -827,7 +1033,7 @@ Windows 腿红。本批的三条守卫(sync-server 写锁、BUG-029 循环头
827
1033
 
828
1034
  ## 5. 处理顺序与进度
829
1035
 
830
- **总口径**:三份第三方报告的可执行条目,**已核实并处理 35 批**(§2.1–§3.34)。
1036
+ **总口径**:三份第三方报告的可执行条目,**已核实并处理 38 批**(§2.1–§3.39)。
831
1037
  每一批都要求:先复现 → 再定级 → 修复 → 回归断言 → **变异验证断言本身**。
832
1038
  「先复现」对两个方向同样适用:B-CT-1 标题夸大(实测不复现),B-CON-1 / A-LG-1 / B-SR-1 实测确实严重。
833
1039
 
@@ -873,6 +1079,17 @@ Windows 腿红。本批的三条守卫(sync-server 写锁、BUG-029 循环头
873
1079
  **路径穿越实测复现并修复**(§3.24 B-SR-1)、**stdout EPIPE 崩溃**(§3.20)
874
1080
  - **发布纪律固化**:四平台(GitHub + Gitee + GitCode + npm)一致性由
875
1081
  `scripts/verify-release.mjs` 在发版最后一步自动校验;v0.6.2 已按该流程发布(四渠道校验退出 0)
1082
+ - **高级缺陷按编号对账**(§3.35):`defect-register.md` 的 BUG-001~022 逐条给结论
1083
+ (已修 17 / 不成立 2 / 已知边界 3),「高级还剩几条」这个问题第一次能在文件里查到答案
1084
+ - **中级缺陷对账 + 批十修复**(§3.36):先把 BUG-023~083 逐条对账(四组只读核查,各带复现证据),
1085
+ 再修掉其中 13 条(护栏降级后续步骤、工具执行期间中断、跨服务商模型名校验、峰谷回退同源、
1086
+ 跨 origin 重定向带凭据、压缩无 signal、tool name 分片、hook 输出截断误 block、路由缓存 FIFO、
1087
+ 分类器失败静默、超长文本绕过缓存、锁回收绕过超时闸、辅助调用峰谷价)
1088
+ - **下一批已就绪**(§3.37):14 条「已核实仍成立」的中级条目附位置、影响与最小修法,可直接接续
1089
+ - **§3.37 清单全部落地**(§3.38 批十一):MCP 孤儿进程 / 预设钉版本(并查出 4 个预设指向的包
1090
+ **已装不上**)/ SSRF 把已校验 IP 钉给连接层 / 任务 id 唯一 / 异步 scrypt / 检查点合并写加锁 /
1091
+ 批处理超时与结果上限 / spinner 与 SIGINT 监听 / 权限确认超时 / 配置写回先备份 / 代理对安全截断 /
1092
+ 更新遥测可关闭且已声明
876
1093
 
877
1094
  **未闭合(如实登记,不假装完成)**:
878
1095
 
@@ -913,3 +1130,31 @@ Windows 腿红。本批的三条守卫(sync-server 写锁、BUG-029 循环头
913
1130
  所以对它既不能整份照单排期、也不能整份忽略——只能逐条复现。
914
1131
  8. **账本可选签名**(`--sign-key`):仍未实现。封条只提升到「防误删/漏写」,
915
1132
  **不等同于审计级不可否认**,这一点已写进导出物。
1133
+
1134
+ ## 3.39 已修复(v0.6.5 补丁版:独立审计的三条越权链收口)
1135
+
1136
+ **来源**:2026-09-22 的独立审计(8 名只读审计员按信任边界切块逐行精读 + 主审横切普查,
1137
+ 共 **58 项**有实质影响的发现:P0×2 / P1×21 / P2×19 / P3×16,每条都带 `file:line` 与验证层级)。
1138
+ **报告本体不随仓库发布**(它同时列出 55 项**尚未修复**的问题,逐条公开等于给攻击者一份排期表);
1139
+ 本登记簿记录其中**已修复**的部分与复现结论,报告本体交付给负责人。
1140
+
1141
+ **负责人指定的范围**:只做**唯三的越权/任意执行链** + 回归断言,其余按报告 §10 排队。
1142
+
1143
+ | 条目 | 位置 | 机制(主审均已复现) | 修复 | 断言 / 变异验证 |
1144
+ | --- | --- | --- | --- | --- |
1145
+ | P0-1 | `src/tools/fs-tools.js` `withinRoot()` | `realpathSync` 对**目标不存在**的链接抛 `ENOENT` → 旧实现退回"只比较字符串路径" → 判定**链接自己**在围栏内 → `writeFileSync` 跟随链接写到围栏外(实测界外文件真被创建;同路径 `read` 反而被拒) | 失败时改判 `lstatSync`:是符号链接即 `readlinkSync` 解析目标后**递归**判定 + 16 层深度上限(成环有界 fail-closed) | §123①;变异 1/4 ✅(还原后当场写出围栏外) |
1146
+ | P0-2 | `src/commands/pack.js` `info` 分支 | v0.6.2 的信任门只加在 `mountPacks` 上,`pack list` 会显示「⛔ 未信任」,但 `info` 直接 `loadPack` → `import pack.mjs` → **换个子命令就执行未信任代码**(完整 Node 权限) | `loadPack` 之前判 `found.gate`,未信任则打印原因 + `mingdao pack trust <目录>` 指引并非 0 退出 | §123②(含"trust 后必须恢复可用");变异 2/4 ✅ |
1147
+ | P1-20 | `src/web/routes/domains/config.js` `setBaseUrl` | 回环绑定下 `validateRemoteUrl` 的私网校验整段跳过 → 3 个请求即可让服务端把**完整 API Key** 发给攻击者端点(实测 `Bearer sk-victim-FULL-SECRET-…`);`GET /api/models-config` 只回脱敏 Key、凭证 0600 —— 三层保护一起被绕过,且改动持久 | 新增 `endpointChangeGuard()`:已有可用 Key 且目的地 `origin` 变化时,必须在同一请求重交该 Key(SHA-256 摘要比对),否则 `{needKey:true}`;WebUI 提示补输并重试 | §123③;变异 3/4 ✅ |
1148
+ | **P1-20b**(修复期间**新发现**,不在原 58 条内) | 同上 `updateCustom` | 「修改」已有自定义模型的 baseUrl 同样把该模型已存 Key 发往新地址(实测攻击者端点收到 `Bearer sk-victim-CUSTOM-KEY-…`) | 同一个 `endpointChangeGuard`;`addCustom` 不需要(Key 来自同一请求,无已存秘密) | §123③b(**行为级**:先证明正常端点真的收到 Key,再证明改向被拒后攻击者端点 0 次访问);变异 4/4 ✅ |
1149
+
1150
+ **这一类为什么反复出现(本批的方法论收获)**:P0-2 与 P1-20b 都不是"新写错了代码",而是
1151
+ **同一个判据只加在了其中一条入口上**(`mountPacks` 加了、`info` 没加;`setBaseUrl` 加了、`updateCustom` 没加)。
1152
+ 因此修 P1-20 时的动作是"**普查所有会改变某条已存 Key 出网目的地的动作**",而不是只改报告点名的那一行——
1153
+ P1-20b 就是这么找出来的。同理,P0-1 的断言刻意包含**反向用例**(悬空但指向围栏内的链接必须仍可写),
1154
+ 避免"修一个洞、堵一片正常用法"。
1155
+
1156
+ **下一批(报告 §10 的优先级,尚未动)**:① 计费/护栏异常路径三条(`usage` 缺失落 `cost:0` →
1157
+ 三档护栏全失效;`downgrade` 不看**在途**费用;Batch 通道不查 `costGuard`)② 脱敏器不认**带引号的键**
1158
+ (与 v0.6.4 批一同一处的漏网)③ 并发读-改-写丢数据四条(`memory` / `tasks` / `tasks.worker` / `log-writer`)
1159
+ ④ 截断与轮转两条(触发看字节、守卫看行数)⑤ 结构类"单一来源"整改 ⑥ 测试可信度(约 53 处断言作用于
1160
+ **未剥注释**的源码文本)。另:P1-21(`workspace.js` 符号链接"注册后改指向"击穿围栏)已列为本批之后第一项。
package/docs/CONFIG.md CHANGED
@@ -75,6 +75,10 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
75
75
 
76
76
  - **符号链接不会成为逃生通道**——指向围栏之外的链接目录会被 403(此前只做字符串前缀比较,
77
77
  `ln -s / <家>/escape` 之后 `?dir=…/escape` 就能枚举全盘);
78
+ - **悬空的符号链接同样不是通道(v0.6.5 起)**:Agent 的文件工具(`write`/`edit`)此前会跟随
79
+ "目标还不存在"的链接把内容写到围栏之外(`realpath` 对悬空链接报错时退回字符串比较所致)。
80
+ 现在解析失败就按 `lstat` 判定并**递归解析链接目标**,围栏外的悬空链接一律拒绝,
81
+ 链接成环也**有界返回失败**;指向围栏**内**的悬空链接照常可用;
78
82
  - 登记与切换返回的是**规范化后的真实路径**(macOS 上 `/var/folders/…` 会显示为
79
83
  `/private/var/folders/…`),围栏判定与返回值同口径;
80
84
  - 确需家目录之外的位置(外置卷/网络盘):`web.browseRoots` 加进白名单,或
package/docs/PACK-API.md CHANGED
@@ -356,11 +356,16 @@ mingdao pack list # 已加载 Pack / 来源 / 版本 / 兼容状
356
356
  mingdao pack verify <dir> # **静态**校验(manifest + 文件齐全 + pack.mjs 存在性)——**不执行 Pack 代码**,下游 CI 门禁用这个
357
357
  mingdao pack verify <dir> --runtime # 额外 import pack.mjs 验证运行时契约(会以完整 Node 权限执行 Pack 代码,仅在你审过代码时用)
358
358
  mingdao pack new <name> # 脚手架
359
- mingdao pack info <name> # 贡献面:工具/约束/提示词段/权限/费用统计
359
+ mingdao pack info <name> # 贡献面:工具/约束/提示词段/权限/费用统计(未信任的项目级 Pack 拒绝加载,见下)
360
360
  mingdao pack test <name> # 跑内置反例样本(约束 + 工具契约)
361
361
  mingdao constraint test <name> # 单独跑约束反例
362
362
  ```
363
363
 
364
+ > v0.6.5 更正:`pack info` 会 `import pack.mjs`(=执行 Pack 代码),但此前**没有**经过 `pack trust`
365
+ > 信任门——于是"clone 一个仓库 → `pack list` 明明写着 ⛔ 未信任 → 换个 `pack info` 就执行了"。
366
+ > 现在它与挂载路径同口径:**未信任则拒绝加载**并给出 `mingdao pack trust <目录>` 指引、退出码非 0;
367
+ > 这与 `pack verify` 默认静态是同一个原则——**"看一眼"不该等于"执行它"**。
368
+
364
369
  ---
365
370
 
366
371
  ## 8. 与现有扩展点的关系(不替代,只补位)
@@ -61,7 +61,7 @@ export MINGDAO_HOME=$(mktemp -d) # 绝不动真实 ~/.mingdao
61
61
 
62
62
  npm run typecheck # 期望 0 错误
63
63
  npm run typecheck:strict # 期望 当前 0 / 基线 0
64
- node test/smoke.js # 期望 全部通过(当前 98 组断言)
64
+ node test/smoke.js # 期望 全部通过(当前 151 组断言)
65
65
  node test/e2e-local.js # 期望 全通过
66
66
  node test/e2e-web.js # 期望 全通过
67
67
  node test/e2e-schedule.js # 期望 全通过
@@ -84,6 +84,21 @@ node src/cli.js diagnose # 自检报告(脱敏)
84
84
  v0.6.0 C4 起 windows 腿连续 **5 个提交**变红而无人发现——本地是 macOS,跑不到 Windows 分支;
85
85
  而我没有在推送后查看结果,于是红了一个多小时才被这轮梳理发现。
86
86
 
87
+ #### 1.1.1 日常提交也要推三平台(2026-09-21 负责人报障)
88
+
89
+ **每一次推 main 都要推三个平台,不是只在发版时推。** 这次的现象是:GitHub 的 main 到了 `5dc7739`,
90
+ 而 gitee/gitcode 还停在 `b0c872c`(上一个发布提交)——镜像上克隆到的是**过期代码**,
91
+ 而「`publish-mirror-releases.sh` 会推分支」只在**发版那一刻**成立,发版之后的日常提交没人管。
92
+ 这与 §3.0 ② 记的是同一类问题(那时也是"文档写了要求、脚本没落实")。
93
+
94
+ ```bash
95
+ # 用脚本推,别手敲三个 remote:推完会逐个核对远端 main 的 SHA,不一致即非 0 退出
96
+ bash scripts/push-all.sh # 推 main 到 origin / gitee / gitcode
97
+ bash scripts/push-all.sh v0.6.5 # 额外推一个 tag(仍是三平台)
98
+ ```
99
+
100
+ **验收**:输出末尾必须是 `✓ 三平台 main 已对齐 <sha>`;任一平台不一致就是**没推完**,别当成推完了。
101
+
87
102
  ```bash
88
103
  # 查最近一次运行的五条腿(含 windows)
89
104
  curl -s "https://api.github.com/repos/MingDaoTCM/MingDao-Harness/commits/<sha>/check-runs" \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -558,6 +558,22 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
558
558
  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 };
559
559
  while (steps < stepLimit) {
560
560
  steps += 1;
561
+ // v0.6.5(审计 BUG-025,实测复现):Ctrl+C 落在**工具执行期间**时,此前只把 `aborted`
562
+ // 置真、却没有任何收敛点——工具跑完仍会走回这里发出**下一次请求**(实测:SIGINT 之后
563
+ // chat 仍被调用 2 次)。这里与工具循环里的检查一起把它收口成「立刻停止、如实回报 aborted」。
564
+ if (aborted) {
565
+ stripOrphanCalls();
566
+ return {
567
+ text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true,
568
+ note: '已按 Ctrl+C 中断(工具执行期间收到中断信号),本回合未再发出请求。',
569
+ durationMs: Date.now() - startedAt, perf: perf(),
570
+ };
571
+ }
572
+ // 本轮的 AbortController 提到压缩之前创建(审计 BUG-037):压缩是一次**大开销的真实请求**
573
+ // (30K 输入 × executor 模型),此前它在 `ac` 创建之前发生、拿不到任何 signal,
574
+ // 于是压缩期间 Ctrl+C 只能等它返回(远程 600s / 本地 1800s,且还有一次重试)。
575
+ const ac = new AbortController();
576
+ currentAc = ac;
561
577
  // 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
562
578
  // 把被裁段落压成摘要注入,替代「失忆」;失败/不值得时回退普通裁剪。
563
579
  if (cfg.autoCompact !== false) {
@@ -572,6 +588,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
572
588
  // 等到 80% 再压时上下文已累积过多、prefill 与内存双高(v0.4.2 本地模型 507 修复)
573
589
  triggerRatio: Number(cfg.compactTrigger) > 0 ? cfg.compactTrigger : (caps.isLocal ? 0.6 : undefined),
574
590
  force: windowPressure, // v0.3.2:逼近窗口时强制压缩(忽略最小阈值门槛)
591
+ // v0.6.5(审计 BUG-037):摘要请求也要能被打断(此前压缩链路完全没有 signal)
592
+ signal: ac.signal,
575
593
  });
576
594
  if (compacted) {
577
595
  messages.splice(0, messages.length, ...compacted.messages);
@@ -674,8 +692,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
674
692
  perf: perf(),
675
693
  };
676
694
  }
677
- if (guard.downgrade && !downgraded) {
678
- if (guard.downgradeModel !== activeModel) {
695
+ if (guard.downgrade) {
696
+ if (!downgraded && guard.downgradeModel !== activeModel) {
679
697
  // MiniMax P0:降级目标零校验会崩溃——必须与当前模型同服务商且已有 Key,
680
698
  // 否则 provider.chat 必然 400;校验失败按 block 处理并给修复指引。
681
699
  const curPc = resolveProviderConfig(cfg, activeModel);
@@ -694,7 +712,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
694
712
  };
695
713
  }
696
714
  } else {
697
- // 已经是降级目标模型:无法再降,按 block 处理
715
+ // 已经在降级目标模型上:无法再降,按 block 处理。
716
+ // v0.6.5(审计 BUG-023/035,实测复现):此前的外层条件是 `guard.downgrade && !downgraded`,
717
+ // 于是**本回合刚降过一次**之后(downgraded=true),下一步会落到「两个分支都不进」——
718
+ // 既不切换也不再拦,静默继续用 flash 发请求继续计费。判据必须是「超限 + 已在最便宜模型」,
719
+ // 与「一开局就是 flash」那条路径完全同源(那条本来就会拦,两条路径此前结论相反)。
698
720
  stripOrphanCalls();
699
721
  return {
700
722
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
@@ -702,14 +724,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
702
724
  durationMs: Date.now() - startedAt, perf: perf(),
703
725
  };
704
726
  }
705
- } else if (!guard.downgrade) {
727
+ } else {
706
728
  io.print(style(guard.message, C.yellow));
707
729
  }
708
730
  }
709
731
  }
710
732
 
711
- const ac = new AbortController();
712
- currentAc = ac;
713
733
  io.beginTurn();
714
734
  io.startSpinner('正在思考…');
715
735
 
@@ -1072,6 +1092,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1072
1092
 
1073
1093
  let i = 0;
1074
1094
  while (i < res.toolCalls.length) {
1095
+ // v0.6.5(审计 BUG-025):工具执行期间用户按了 Ctrl+C → 立刻停止,不再执行后续批次
1096
+ // (此前没有任何检查点,长工具序列会把中断信号拖到整串跑完)
1097
+ if (aborted) {
1098
+ stripOrphanCalls();
1099
+ return {
1100
+ text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true,
1101
+ note: '已按 Ctrl+C 中断(工具执行期间收到中断信号),本回合未再发出请求。',
1102
+ durationMs: Date.now() - startedAt, perf: perf(),
1103
+ };
1104
+ }
1075
1105
  // 收集批次:连续且可并行的只读工具成批;首个非并行项(写入类/被拒/MCP)结束批次
1076
1106
  const batch = []; // {prep, batchable}
1077
1107
  while (i < res.toolCalls.length) {
@@ -239,11 +239,16 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
239
239
  } catch (err) {
240
240
  if (!acquiring) throw err; // fn 自身的异常绝不进入锁重试
241
241
  if (/** @type {any} */ (err).code !== 'EEXIST') throw err;
242
- if (reclaimIfStale(lockPath, staleMs)) continue;
242
+ // 审计 BUG-047(实测复现):回收陈旧锁的分支原先直接 `continue`,**跳过了下面的超时判断**——
243
+ // 当陈旧锁删不掉(只读目录 / EPERM / EIO;实测 chmod 555 即可复现)时 reclaimIfStale
244
+ // 永远返回 true,于是无限空转:99% CPU、5 秒超时失效、进程不返回。
245
+ // 「回收过」不构成「可以不计时地重试」,两条路径都必须过超时闸。
246
+ const reclaimed = reclaimIfStale(lockPath, staleMs);
243
247
  if (Date.now() - t0 > timeoutMs) {
244
248
  throw lockTimeoutError(lockPath, timeoutMs);
245
249
  }
246
250
  sleepMs(25);
251
+ if (reclaimed) continue; // 回收后立刻重试;否则同样按 25ms 节流再试
247
252
  }
248
253
  }
249
254
  });
@@ -276,11 +281,13 @@ export async function withFileLock(/** @type {string} */ lockPath, /** @type {()
276
281
  releaseLock(lockPath);
277
282
  }
278
283
  }
279
- if (reclaimIfStale(lockPath, staleMs)) continue;
284
+ // 与同步版同一条修正(审计 BUG-047):回收分支不得绕过超时闸,否则陈旧锁删不掉时永远自旋
285
+ const reclaimed = reclaimIfStale(lockPath, staleMs);
280
286
  if (Date.now() - t0 > timeoutMs) {
281
287
  throw lockTimeoutError(lockPath, timeoutMs);
282
288
  }
283
289
  await sleepAsync(pollMs);
290
+ if (reclaimed) continue;
284
291
  }
285
292
  });
286
293
  }