mingdao-harness 0.6.3 → 0.6.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.
Files changed (52) hide show
  1. package/docs/AUDIT-v0.6.1-/347/254/254/344/270/211/346/226/271/346/212/245/345/221/212/347/231/273/350/256/260.md +208 -16
  2. package/docs/CONFIG.md +32 -6
  3. package/docs/DEVELOPER.md +1 -1
  4. package/docs/PACK-API.md +26 -3
  5. package/package.json +1 -1
  6. package/src/agent.js +57 -9
  7. package/src/atomic-write.js +63 -3
  8. package/src/audit.js +23 -18
  9. package/src/cachestats.js +57 -47
  10. package/src/cli.js +25 -4
  11. package/src/commands/diagnose.js +3 -0
  12. package/src/commands/key.js +23 -1
  13. package/src/commands/ledger.js +41 -3
  14. package/src/commands/net.js +5 -1
  15. package/src/commands/pack.js +23 -6
  16. package/src/commands/repl.js +9 -1
  17. package/src/commands/skill.js +30 -1
  18. package/src/commands/sync.js +7 -0
  19. package/src/compact.js +15 -1
  20. package/src/config.js +71 -6
  21. package/src/constraints.js +55 -6
  22. package/src/credentials.js +36 -5
  23. package/src/hooks.js +37 -2
  24. package/src/ledger.js +3 -3
  25. package/src/log-writer.js +22 -17
  26. package/src/mcp.js +19 -1
  27. package/src/memory.js +8 -8
  28. package/src/packs.js +57 -8
  29. package/src/permissions.js +35 -9
  30. package/src/presets.js +46 -1
  31. package/src/providers/index.js +65 -7
  32. package/src/providers/openai-compatible.js +24 -3
  33. package/src/redact.js +38 -2
  34. package/src/replay.js +15 -3
  35. package/src/safe-fetch.js +8 -2
  36. package/src/schedule.js +22 -4
  37. package/src/session-index.js +2 -2
  38. package/src/session.js +2 -2
  39. package/src/skill-lib.js +43 -14
  40. package/src/skill-registry.js +31 -2
  41. package/src/sync-server.js +47 -28
  42. package/src/sync.js +17 -3
  43. package/src/task-state.js +10 -1
  44. package/src/tasks.js +3 -3
  45. package/src/tools/fetch.js +24 -0
  46. package/src/web/app.js +7 -7
  47. package/src/web/index.html +2 -2
  48. package/src/web/routes/api.js +27 -2
  49. package/src/web/routes/domains/sync.js +2 -1
  50. package/src/web/routes/domains/workspace.js +39 -9
  51. package/src/web/server.js +14 -1
  52. package/src/workspace.js +3 -3
@@ -585,6 +585,166 @@ WebUI 的 `web/` 层**只 import `listTasks`**(读路径、不加锁),**
585
585
  本轮把默认值换成命名常量后,这条断言直接匹配不到(`m92` 为 null)——**源码文本一改就碎**。
586
586
  已改为读导出的 `lockDefaults()`:断言的是**行为契约**(`timeout > stale` 且上限有界),不是字符串长相。
587
587
 
588
+ ## 3.28 已修复(第二十九批:合规静默失效——红线在,但没被求值 / 没被记录 / 没被看见)
589
+
590
+ > 这一批的共同形态:**不报错、不崩溃,只是某个"主打能力"实际空转**。
591
+ > 三份报告里这类条目最容易被"看起来实现了"骗过去,因此本批每条都补了**能被变异验证抓住**的断言。
592
+
593
+ | 项 | 位置 | 核实结论 | 处理 |
594
+ | --- | --- | --- | --- |
595
+ | P1-1(约束事件无 `id`) | `src/constraints.js#event()` | **核实成立**:`event()` 返回 `{pack, constraint, kind, stage, …}`,而**两个**消费方都读 `id`(`agent.js` 的账本 `tool.call.constraint.id`、`replay.js` 的 `constraintId`)→ 每次被红线拦下,账本与回放里「是哪条红线拦的」**恒为 null**。不报错、只输出 null,任何断言都不会失败——这正是审计报告说的"100 组断言也抓不到"的那类字段级契约缺陷 | `event()` 增加 `id`(`constraint` 保留以兼容历史账本);补断言:事件带 `id` + 账本→回放的 `constraintId` 非空 |
596
+ | P1-2(`confirm` 从不求值) | `src/constraints.js` / `src/agent.js` / `src/replay.js` | **核实成立**:`confirm` 在 `KINDS` 里、能通过装载校验、会被计入 `size`(于是 `active=true`),却落进 `output` 桶并被 `if (c.kind !== 'output-forbid') continue` 跳过——作者以为"这条工具要人工确认",实际零效果 | **补齐实现**(而不是删掉 kind:`KINDS` 的 `confirm` 已在 `CHANGELOG-PACK` / `STRATEGY-0.5` / `PLAN-v0.5.0` / `PACK-API` 四处成文):`confirm` 进入 pre 桶后由 agent 走 `io.confirm`;答否**或拿不到交互通道**(`-p`/调度/子 Agent)一律按阻断(与权限引擎同口径)。回放无交互通道,故单列 `confirm-required` 一类如实报告,**不静默读成"通过"** |
597
+ | P1-2 连带(顺序陷阱) | 同上 | **自查发现**:`confirm` 若与阻断类**同轮**求值,把 `confirm` 写在 `tool-deny` 之前就会先返回 `needsConfirm`、后面的 deny 再也走不到——等于给红线开后门 | `checkPreTool` 改为**两遍**:先全部阻断类,再 confirm。补断言「confirm 声明在 tool-deny 之前时必须由 deny 给出结论」,并用**真·顺序变异**验证(第一版变异是等价变异,抓不到——已更正) |
598
+ | P1-12(CLI 回放恒假阴性) | `src/commands/ledger.js` | **核实成立**:CLI 的命令分发(`cli.js`)发生在 `mountPacks` **之前**,`getActivePackContext()` 恒为 null → `constraints=[]` → `compiled.active=false` → 回放恒输出「当前没有任何生效的领域约束」。把它当 CI 门禁时**恒通过** | replay 分支按启动路径同口径 `await mountPacks(...)`(幂等,失败降级为**可见告警**而不是静默空约束)。回归断言用**用户级 Pack**(默认受信任)端到端跑 CLI,断言 `constraintId` 与 `nowBlocked` 都对 |
599
+ | M-12(`--json` 恒失效) | `src/commands/ledger.js` | **核实成立**:`flag('--json')` 是**取值型**解析(`rest[i+1]`),布尔开关写在末尾取不到值 → 返回 null → `ledger replay <id> --json` 恒走人读分支,`\| jq` 直接失败 | 布尔开关与取值型参数分开解析(`boolFlag()`)。断言:末尾 `--json` 的输出**必须能被 `JSON.parse`**,且同一条账本的人读分支仍出现红线 |
600
+ | M-21(失败路径退 0) | `src/commands/diagnose.js`、`ledger.js`、`key.js`、`net.js` | **部分成立**(逐条复核):`diagnose` 打印 `[错误]` 后**退 0**;`ledger` 的非法 runId / 找不到账本 / 未知子命令打印提示后退 0;`key`、`net` 的未知子命令打印用法后退 0。**报告点名的 `update.js` 不成立**:它的 `return false` 是**设计行为**(P3-1 子命令劫持防护——`mingdao update the docs` 应被当作提问),不是"静默当默认并退 0" | 四处失败路径统一 `process.exitCode = 1`;`ledger` 把「未知子命令」判定**前置**(放在 runId 解析之前,否则同样的输入会因分支顺序而时 1 时 0),并与用法提示共用一份 `USAGE` 串。断言覆盖 5 条失败路径 |
601
+
602
+ ### 两条值得记下的教训(都来自本批的 CI 红)
603
+
604
+ **一、"载荷没送到"的判据不能依赖事件到达时机。** BUG-042 的 fail-closed 断言连续两轮在
605
+ ubuntu Node 18/20 上红,而 macOS 本地恒绿。根因是**载荷尺寸 vs 平台 socket 缓冲**:
606
+ Node 在 POSIX 上用 socketpair 做 stdio,Linux 的 AF_UNIX 发送缓冲默认约 **208KB**
607
+ (`net.core.wmem_default`),200KB 的 hook 载荷能**整个塞进内核缓冲**——父进程的写入全部完成、
608
+ `stdin 'finish'` 照常触发,而 macOS 缓冲小得多所以先撞 EPIPE。
609
+ **判据本身(`'finish'` 是否触发)没错,是测试载荷不够大**:改成 **4MB**(大于任何平台的发送缓冲)
610
+ 后,"写不完"这个事实才稳定暴露。留下的通用结论:跨平台测试里凡是"通过管道写大数据、指望对端不读就失败"
611
+ 的用例,载荷必须做到**大于内核缓冲上限**,否则在缓冲大的平台上会静默变成另一条路径。
612
+
613
+ **二、变异也可能"等价"。** confirm 顺序陷阱的第一版变异(删掉第一遍循环里的 `if (c.kind === 'confirm') continue;`)
614
+ **不是有效变异**——因为第一遍循环体本来就不处理 confirm,删掉这行只是让它"穿过"循环,
615
+ 行为完全不变,于是"仍然全绿"被误读成"断言抓不到"。改成**真的让 confirm 在第一遍返回**后,
616
+ 断言立刻按预期失败。**结论:变异验证也要验证变异本身**,看到"没抓住"先怀疑变异是否等价。
617
+
618
+ ## 3.29 已修复(第三十批:静默数据损失——写下去了,但那一行 / 那份配置没了)
619
+
620
+ > 这一批的共同形态:**没有报错,数据就是少了一部分**。四个位置都是"读-改-写"或"读不出来当没有",
621
+ > 且都发生在并发或多进程场景下——单进程手测永远看不到,只有把时序摆出来才现形。
622
+
623
+ | 项 | 位置 | 核实结论 | 处理 |
624
+ | --- | --- | --- | --- |
625
+ | H-7(配置损坏被静默覆盖) | `src/config.js#loadConfig`、`cli.js`、`ensureMinimalConfig` | **核实成立,且比报告多一条**:`catch → null` 把 ENOENT 与"解析失败/权限不足/不是对象"抹平成同一件事,所有调用点都把 null 当**首次运行**。报告只提到"改坏一个字符",实测还有一支同源问题:**带 BOM 的 config.json**(内容完全合法,Excel/记事本另存为的常见产物)也会被判成损坏 | 新增 `readConfigStrict()`(区分不存在/读不出来,解析前剥 BOM)+ `quarantineCorruptConfig()`(**改名**为 `config.json.corrupt-<时间戳>` 而不是覆盖,并明确告警"从备份并回 customModels/mcpServers/sync/net 等字段")。CLI 向导前与桌面首启路径都先隔离。`loadConfig()` 的既有语义(读不出来 → null)保持不变,避免影响既有调用点 |
626
+ | H-4(`/compact` 用追加) | `src/commands/repl.js` | **核实成立**:手动 `/compact` 把"压缩点标记 + 压缩后消息"**追加**到已含旧历史的文件后面,与会话文件里已有的历史重复;而自动压缩走的是 `rewriteSession`(原子重写)——同一件事两套写盘口径 | `/compact` 改用 `rewriteSession(session.file, messages)`,与自动压缩同口径;补断言(会话层"重写即替换" + 源码级钉住 `/compact` 分支不得再用 `appendMessages`) |
627
+ | M-1(压缩后不清读缓存) | `src/agent.js`、`src/tools/fs-tools.js`、`repl.js` | **核实成立**:读取去重按 `mtime+size` 判"同一文件读过",而压缩会把早期消息(含文件正文)替换成摘要 → 模型再读同一文件只拿到「内容与上次读取一致」的占位串,**正文已不在上下文里**,它可能据此继续改文件 | 压缩成功路径(自动)与 `/compact`、`/clear`(手动)都调 `agentReadCache.clear()`;agent 对外暴露 `clearReadCache()`。行为断言:占位串 → 清缓存 → 正文回来;另加源码级钉子钉住自动压缩路径的调用顺序 |
628
+ | BUG-009(追加在锁外) | `src/cachestats.js` | **核实成立**:轮转的 `read-modify-write` 在锁内,而**追加在锁外** —— A 追加 → B 追加 → A 在锁内读到含 A 的快照并原子替换,B 那一行落在读与写之间就被覆盖。丢的是费用明细,而费用明细是日费用护栏的依据 | 追加与轮转收进**同一把锁**(锁内追加 → 立刻判断是否超限 → 超限则瘦身重写)。频率是"每回合一条",不在请求热路径上;同步锁清单同步复核并写明理由 |
629
+ | M-20(轮转窗口吃行) | `src/audit.js`、`src/log-writer.js` | **核实成立,但两处取舍不同**:`audit.jsonl` 丢的是**合规证据**("少一行"等于证据链有洞),故与 cache-stats 同法——追加与轮转同锁;`web-server.log` 的写入器跑在 **WebUI chat 请求路径**上(`srvlog(...)`),加同步锁会引入 `Atomics.wait` 阻塞(正是 §3.26–3.27 花力气消掉的东西) | 审计:同锁。日志:改为**改名式轮转**(把整文件 `rename` 成 `<file>.1` → 立刻按 logrotate 的 create 语义建空文件 → 上一轮 `.1` 先删),没有"读-改-写"窗口因此**不需要锁**;Windows 上 rename 失败即"这次不轮转、文件继续长",**不丢数据**属于可接受降级 |
630
+
631
+ ### 两条自我纠错(都是变异验证逼出来的)
632
+
633
+ **一、"整次调用耗时"证明不了"追加在锁内"。** 第一版并发断言测的是 `recordCacheStats()` 的耗时:
634
+ 锁被别的进程持有时它确实会等待,于是断言通过。但把 `append` 挪到 `withFileLockSync` **之前**再跑,
635
+ 耗时**照样**包含等锁时间——变异验证直接指出"该断言抓不到这个变异"。
636
+ 改成观测**写入时机**:让另一个进程持锁 1.5 秒,从第三个进程发起追加,期间读文件确认那一行
637
+ **还没落盘**,锁释放后才出现。这才是"追加在锁内"的可观测证据。
638
+
639
+ **二、"有没有 `.corrupt-*` 备份"是假绿。** 断言 `readdir(...).some(f => f.startsWith('config.json.corrupt-'))`
640
+ 在本组测试里恒真——因为前面的用例已经留下过一个备份,把 `ensureMinimalConfig` 的隔离调用删掉也照样通过。
641
+ 改成比较**增量**后才真正抓住变异,并顺带暴露一个真缺陷:同一秒内两次隔离时,
642
+ `renameSync` 会**覆盖**上一份备份(POSIX rename 是覆盖语义),于是"备份的意义:一份都不许丢"被破坏。
643
+ 已改为重名顺延编号(`…-1`、`…-2`)。
644
+
645
+ ## 3.30 已修复(第三十一批:Web 安全——围栏逃逸 / 元数据 SSRF / 跨站盲打 / Provider 名穿越)
646
+
647
+ | 项 | 位置 | 核实结论 | 处理 |
648
+ | --- | --- | --- | --- |
649
+ | P0-3(符号链接逃逸) | `src/web/routes/domains/workspace.js` | **核实成立(实测复现)**:`withinAllowed` 只做 `path.resolve` + 字符串前缀比较,而后续 `statSync/readdirSync` **跟随符号链接** → `ln -s / <家目录>/escape` 之后 `?dir=…/escape` 通过检查、stat 落到 `/`,于是任意目录枚举;再用一条 `POST /api/workspaces {dir:…/escape}` 登记,Agent 的 bash/write/edit 就整体跑到围栏之外(`os.tmpdir()` 还被无条件算作允许根,门槛更低) | 围栏改为按 **realpath** 判定,且**目标与允许根两边都归一化**(只归一化一边会把合法目录误判越界——macOS 上 `/var/folders` → `/private/var/folders` 就是实例);目标不存在时回退到"最近的存在祖先"的 realpath;登记(add)与改目录(set)同样先归一化;直接访问符号链接路径同样 403 |
650
+ | P0-3 附(报告的另一半) | 同上 | **核实为不可利用**:报告称"`readdir` 不过滤符号链接条目"是逃逸的一半。实测 `Dirent.isDirectory()` 走 **lstat** 语义,对符号链接本就返回 `false`,因此**去掉** `!e.isSymbolicLink()` 也列不出链接条目——它只是双保险。已在代码注释里如实写明,不把它当防线 | 保留过滤(纵深防御),但断言改成**能真正失败**的那条:「把符号链接路径直接喂进来必须 403」 |
651
+ | P1-8(回环绑定时整段跳过 SSRF) | `src/web/server.js#validateRemoteUrl` | **核实成立**:`serverBoundLocal` 为真时**整段**校验被旁路,于是云元数据(`169.254.169.254`)、CGNAT、DNS 重绑定检测一起失效;而同一进程里 `skill-lib`/`safe-fetch` 的注释明确写着"WebUI 默认拦截(连回环都拦)"——两处策略相反 | 新增**元数据端点无条件拒绝**(`isMetadataHost`,单一来源在 `tools/fetch.js`):回环绑定、`web.allowPrivateEndpoints`、`allowPrivate: true` 都**不放行**。同时把"回环放行"与"对外监听拒绝"的差异写成**显式决定**并保留:本机模型服务(Ollama/Dify)必须可达,否则是拿安全换掉可用性。**刻意不采纳"CGNAT 段无条件拒绝"**:`100.64.0.0/10` 正是 Tailscale 的地址段,自托管场景常见,无条件拒绝会直接打断真实用法——它仍属"私网档",由 `allowPrivateEndpoints` 决定 |
652
+ | H-5(回环 + 无令牌 = 无认证) | `src/web/routes/api.js`、`src/web/server.js` | **核实成立,但采纳的是"关掉浏览器攻击面 + 把决定说出来",而不是"回环也强制令牌"**:桌面版**本来就**随机生成令牌,只有 CLI `mingdao web` 走本机信任;改成默认强制令牌会让"浏览器打开 127.0.0.1 直接用"的日常用法每次都要回终端取令牌(SPA 把令牌存在 sessionStorage,关掉浏览器即失效),代价大于收益 | ① 跨站浏览器请求一律 403(见下条),"任意网页"这一半**彻底关闭**;② 回环未配令牌时启动横幅**明确提示**"本机信任模式:本机其它进程可直接访问 /api/*",并给出启用令牌的两种方式。**残留如实登记**:同机其它进程/其它用户仍可直连 API(多用户机器、共享 CI runner 需自行启用令牌) |
653
+ | H-6(GET 有副作用,可被盲打) | `src/web/routes/api.js` | **核实成立**:CSRF 保护只覆盖非 GET,而 `GET /api/draft` 是"读取即删除"、`GET /api/sync` 会外呼、`GET /api/skill-library?refresh=1` 会联网——`<img>`/`fetch(no-cors)` 盲打即可触发(读不到响应,但副作用已发生) | 引入 **`Sec-Fetch-Site` 判定**:该头由浏览器强制附加且页面脚本无法伪造,`cross-site`/`same-site` 一律 403(含 `img`/`no-cors` 盲打),`same-origin`/`none`(地址栏、书签)放行;并**豁免**"从外站导航到壳页面"(页内子资源仍是 same-origin,拿不到数据)。另外把 **Origin 校验扩展到所有方法**(同源 GET 不带 Origin,因此不误伤)。回归断言直接钉住危害:跨站 GET 不得读走草稿 |
654
+ | R4(自定义 Provider 名穿越) | `src/providers/index.js` | **核实成立**:`pc.name` 来自 `customModels.<名>.provider`,原实现直接 `path.join(home,'providers', name + '.mjs')` 再 `import` → `provider: '../evil'` 可 import 目录之外的 `.mjs`(读+执行任意本地模块,与 v0.4.6 修掉的环境变量外泄是孪生通道) | 两层防护:**名字白名单** `^[A-Za-z0-9][A-Za-z0-9._-]{0,64}$` + **路径包含性检查**(`path.relative` 落在 `providers/` 内)。名字不合法时退化为"没有自定义模块"(走普通 OpenAI 兼容端点并报缺 baseUrl),不抛异常、不改变正常 Provider 的行为;回归断言用"把恶意模块放在 providers/ 之外"端到端验证它**没有**被执行 |
655
+
656
+ ### 三条值得记下
657
+
658
+ **一、变异验证又一次抓出"我以为在测、其实测不到"。** 本批 8 个变异里有两个的结果需要如实修正:
659
+ - 「把符号链接条目过滤去掉」**抓不到**——因为 `Dirent.isDirectory()` 对链接本就是 false,这是一个**等价变异**(同 §3.28 的第二条教训)。于是把断言换成能真正失败的那条。
660
+ - 「`link-out` 指向 `os.homedir()`」**抓不到**——因为 `os.homedir()` **本身就是允许根**,指它根本不算逃逸。改成指向 `/usr` 后才暴露出真实差异。
661
+
662
+ **二、并行跑变异会骗人。** 批五的冒烟用例会起 Web 服务并绑定**固定端口**,9 个副本并行时互相抢端口,
663
+ 上一版并行结果里"变异 2/6/7 被抓住"其实混进了端口冲突导致的假失败;改成串行重跑才看清真实情况
664
+ (其中 m6 的补丁还把代码改出了语法错误,那是"被抓住",但不是被断言抓住)。
665
+ **结论:变异验证必须在与断言同等的条件下跑;有共享资源(端口/文件/锁)时一律串行。**
666
+
667
+ **三、报告的"安全建议"要按真实用法复核,而不是照抄。** 两处刻意没有照做,理由都写在上表里:
668
+ CGNAT 段无条件拒绝会打断 Tailscale 场景;回环强制令牌会让日常用法每次回终端取令牌。
669
+ 把"为什么不照做"写清楚,比默默照做或默默不做都更负责。
670
+
671
+ ## 3.31 已修复(第三十二批:供应链——CI 门禁不得执行被审代码 / 遮蔽必须可见 / 本地路径不进安装器)
672
+
673
+ | 项 | 位置 | 核实结论 | 处理 |
674
+ | --- | --- | --- | --- |
675
+ | H-9 / P0-1(`pack verify` 会执行 Pack 代码) | `src/commands/pack.js`、`src/packs.js` | **核实成立,且是"文档与实现说反话"的典型**:帮助文本写「只做静态校验(不 import Pack 代码)」,实现却无条件 `loadPack()` → **import pack.mjs**。下游 CI 按文档拿它当门禁审第三方 Pack,等于在 CI 上以完整 Node 权限执行被审仓库的任意代码,且**不经 pack trust 信任门** | 新增 `loadPackStatic()`(manifest 字段 / 兼容窗口 / 声明文件齐全 / pack.mjs **存在性**,全程不 import),`pack verify` **默认静态**、要执行必须显式 `--runtime`(并先警告"将以完整 Node 权限执行")。`loadPack()` 复用同一份静态校验,避免两处漂移。**如实写明边界**:约束与提示词段由代码产出,静态阶段无法校验 |
676
+ | M-5(未信任 Pack 顶掉同名内置/用户级) | `src/packs.js#listPacks` | **核实成立**:`seen.set(name, …)` 按 name 后写覆盖,tier 顺序 builtin < user < project → clone 一个仓库并在其中放同名 Pack,就能让**内置 Pack 的 constraints/promptSections 静默失效**(项目版本因为带 gate 不挂载,被顶掉的内置版本也回不来 = 两边都没生效) | 未信任的 tier **不参与遮蔽**(它本来就不会被挂载,无权挤掉别人);同名冲突写进 `warning` 并由 `mountPacks` 变成启动告警;已信任的高优先级遮蔽低优先级时记 `shadowed` 并同样告警("哪个版本真的生效"必须可见) |
677
+ | M-8(项目级预设静默遮蔽 + 注入面) | `src/presets.js`、`cli.js`/`repl.js`/`web` 共用 `loadPreset` | **核实成立**:`--preset <名>` 会被仓库里 `.mingdao/presets/` 的同名预设静默替换,而预设可以定义 `systemPrompt` 与 `tools` 白名单——clone 一个仓库再 cd 进去用自己熟悉的名字调预设,拿到的是对方的提示词 | 不再改变遮蔽语义(那是文档化行为),但**遮蔽必须说出来**:`listPresets` 记录被遮蔽来源,`loadPreset` 在命中项目级且确有遮蔽时打一条含**两份路径**的告警(每 (工作目录,名) 一次) |
678
+ | BUG-056(`installFromGit` 收 `file://` 与本地路径) | `src/skill-lib.js` | **核实成立**:只挡了 `-` 开头的选项注入,`file:///etc` 或直接给本地路径都能让 git 去 clone 本地目录——安装器的语义是"从远端取回内容",本地路径等于把本机目录当技能读走 | 加**形态白名单**:只接受 `https://` / `ssh://` / `git://` / `git@host:path`;`file://` 与本地路径明确拒绝并说明原因 |
679
+ | M-6(技能库索引的 sha256 是自证) | `src/commands/skill.js` | **核实成立但不宜"修"**:文件与哈希同源(都在索引里),投毒者可同时下发恶意正文与匹配哈希,校验必然通过。真正的修法是签名索引/TUF——那要下游生态一起定规格,不是本批能擅自定的 | 安装后**如实说明这条校验的边界**:"sha256 与索引同源,只防传输损坏/镜像篡改,不防索引本身被投毒"。把"它能做什么"和"它不能做什么"同时讲出来,比让用户以为"已校验=可信"更负责 |
680
+
681
+ ## 3.32 已修复(第三十三批:并发与长驻——进程不能死 / 任务卡不死也不能双跑 / pid 复用)
682
+
683
+ | 项 | 位置 | 核实结论 | 处理 |
684
+ | --- | --- | --- | --- |
685
+ | P0-4 A(未处理的 rejection 直接杀进程) | `src/sync-server.js:586` | **核实成立**:`withWriteLock(...)` 是 async 且**未 await、未 .catch**。临界区里任何 I/O 异常(ENOSPC/EACCES)都会让返回的 promise reject,而它没有挂在请求处理链上 → Node 默认 `--unhandled-rejections=throw` → **整个同步服务退出**(所有在线设备同时掉线)。请求层那句 `handle().catch(()=>{})` 接不住它 | `await withWriteLock(...)` 并包 try/catch 留痕(lastSeen 只是"最近在线"标记,写失败不该升级成进程崩溃)。补一条**扫描式守卫**:sync-server.js 里任何"调用 withWriteLock 但不 await/return"的行都会被测试揪出来 |
686
+ | P0-4 B(锁外快照写回) | `doShareAccept`、`doChangePassword` | **核实成立**:accept 在锁**外**读 shares/accepted,锁内把陈旧快照整份写回 → revoke 之后的一次并发 accept 会把已删除的 shareId 写回去(分享复活);改密 `delete devices[username]` 同样在锁外,可被并发 pair 写回 → **已吊销的旧 token 复活** | accept 改为**锁内重读**再写,且重读后发现分享已被撤销就返回 notFound;改密整体纳入写锁(与设备表写互斥),调用点补 await |
687
+ | P1-5(任务永久卡 running / 崩溃恢复双跑) | `src/cli.js` | **核实成立**:`markRunning` 把 `runnerPid` 写成**本 daemon 自己**,而恢复分支的判据是 `if (procAlive(j.runnerPid)) continue;` → `procAlive(自己)` 恒真 → **永久跳过**(面板一直转圈,只能 daemon stop + 删 pidfile 自愈)。反向问题:宿主被 SIGKILL 但 detached worker 还在跑时,恢复分支会把它当孤儿**重排 → 同一任务跑两遍** | ① `runnerIsSelf` 例外:宿主就是自己时不"等它"(那是死等);② worker 仍活着时**绝不重排**(`taskWorkerAlive`,与 tasks.js 共用同一判据),等它收尾后由下一轮的定案分支处理;③ `runSleeper(...).catch(() => {})` 改为**留痕日志**——静默吞掉的异常正是"永久卡住"且无从诊断的原因 |
688
+ | M-11(pid 复用让锁僵死) | `src/atomic-write.js` | **核实成立**:陈旧回收只看"持有者 pid 是否存活",pid 被复用给一个活进程后 `procAlive` 恒真 → 锁**永不回收**,写方等到超时 | 锁内容记下入口脚本文件名作为身份凭据,回收前用既有的 `pidOwnedBy()` 复核"活着的这个 pid 还是不是原来那个进程";确证不是 → 回收;能读到命令行但匹配不上才回收,**读不到(Windows)则维持原判据**(不引入新的不确定行为)。**踩过的坑写在下面第一条** |
689
+ | M-13(避峰长等待不看租约) | `src/schedule.js` | **核实成立**:`await wait(defer-now+2000)` 可睡数小时,期间 daemon 被接管/停止,旧协程仍持 job 视图与新 daemon 交错 | 切片等待 `waitGuarded()`(每片 ≤60s,每片醒来复查 `shouldStop()`,失去租约立刻 aborted) |
690
+
691
+ ### 四条自我纠错(全部由断言/变异/CI 当场抓出)
692
+
693
+ **零、源码级正则断言必须容忍 CRLF。** Windows 腿的 `actions/checkout` 会把 LF 转成 CRLF,
694
+ 于是 `/try \{\n\s+await withWriteLock/` 这类**用 `\n` 写死换行**的正则只在 Linux/macOS 通过,
695
+ Windows 腿红。本批的三条守卫(sync-server 写锁、BUG-029 循环头、安装器 finally)都是这个毛病,
696
+ 已统一改成 `\r?\n`。**教训:源码级断言是在"文本"上断言,就必须按各平台的文本形态写。**
697
+
698
+ ### 另外三条自我纠错(由断言/变异当场抓出)
699
+
700
+ **一、「身份凭据」必须与观察窗口的形态一致。** M-11 第一版把 `process.argv[1]`(Node 解析后的**绝对**路径)写进锁,
701
+ 而 `ps` 显示的是**输入时的形态**(`node test/smoke.js`)——两者匹配不上,于是**同进程内的并发任务也被判成 pid 复用,
702
+ 活锁被抢走**:既有的并发串行断言(3 次自增)当场只剩 1。改成 `path.basename(...)`(两种形态下都出现)。
703
+ 教训:验证"这是不是同一个进程"时,凭据必须取自**双方都能看到的那份表示**。
704
+
705
+ **二、扫描式守卫要钉整行。** P1-5 的守卫第一版只匹配 `taskWorkerAlive(t)) continue;`——把条件短接成
706
+ `if (false && … && taskWorkerAlive(t)) continue;` 的变异**仍然通过**(变异验证当场发现)。改成匹配整行后变异被抓住。
707
+
708
+ **三、调试时的临时还原会吃掉补丁。** 排查 M-11 时用 `cp 备份 → 打调试补丁 → cp 还原` 来回切换,
709
+ 其中一次还原把刚打好的 basename 修复覆盖回了旧版,导致后续几次实验的结论自相矛盾(同一脚本相对/绝对两种启动方式
710
+ 表现不同)。最后用 `git diff` 核对文件真实状态才发现。**结论:调试补丁一律用 git 暂存/还原,不用手工 cp 备份。**
711
+
712
+ ## 3.33 已修复(第三十四批:计费与可用性——重复计费 / 半截回答 / 压缩永久失效 / 归属错模型)
713
+
714
+ | 项 | 位置 | 核实结论 | 处理 |
715
+ | --- | --- | --- | --- |
716
+ | BUG-028(对非幂等请求重试 → 重复计费) | `src/providers/index.js` | **核实成立**:chat 是**非幂等** POST,上游一旦开始生成就按量计费;而重试判据只看"错误是不是瞬态"——流式空闲超时(已经开始输出)也会被重试,于是**用户看到一份回答、账本却记两次** | 新增"收到过任何一帧就不再重试"(`sawFrame`):帧到达说明请求已被受理并开始计费。**代价如实说明**:已开始输出后的空闲超时不再静默重试,失败以错误回到用户手里,而不是悄悄多花一份钱。反向用例一并保留:什么都没收到时仍必须重试 |
717
+ | BUG-029(总量护栏在循环头未复查) | 同上 | **核实成立**:`totalExpired` 只在 catch 的 transient 判定里被读,于是"总量计时器恰好在退避期间触发"这条路径会照常发起新 attempt(重试序列可能超出声明的总时长) | 重试循环**头部**复查 `totalExpired` 并直接抛出 |
718
+ | BUG-030(退避等待不可中断) | 同上 | **核实成立**:`await sleep(backoff)` 最长 30s 期间 Ctrl+C 无效 | `sleep(ms, signal)` 接信号,中断即抛 |
719
+ | BUG-036(`compactTrigger` 只夹下限) | `src/compact.js` | **核实成立**:只 `Math.max(…, TARGET_RATIO)`,配成 >1(手滑多打一个 0)会让 `total <= budget*trigger` 恒真 → **自动压缩永久失效** → 长会话涨到窗口上限才由服务端报错 | 上限夹到 1 并**明确告警**(一次性),不再静默失效 |
720
+ | BUG-024(降级回合的账本用错模型) | `src/agent.js` | **核实成立**:计价用 `modelName`(用户配置),而成本护栏降级后请求实际发给 `activeModel`——两者单价差数倍,账本与日费用护栏系统性偏移,"降级省钱"反而看不出来 | 计价与 `cost` 事件的 `model` 都改用 `activeModel`(`run.start` 已记过用户请求的模型,两者不再混为一谈) |
721
+ | M-16(提前关流被当正常完成) | `src/providers/openai-compatible.js`、`agent.js` | **核实成立**:网关既不发 `[DONE]` 也不给 `finish_reason` 就关流时,`finish` 为 null 却被记成正常完成——半个回答当完整交付,界面上与真完成无法区分 | 流解析返回 `truncated`(有正文却既无 `[DONE]` 也无 `finish_reason`);agent 收到后**界面提示 + 账本标记**,回合结果另加 `upstreamTruncated` 字段(刻意不叫 `truncated`——那个既有字段的含义是"步数兜底总结也失败了") |
722
+ | M-17(网关改写 content-type → 正文全丢) | 同上 | **核实成立**:只看 `text/event-stream`,代理标成 `application/json` 时 `res.json()` 必失败 → 整段正文丢失且无提示 | 读成文本后**按内容嗅探**:出现以 `data:` 开头的行就走流式解析;否则按 JSON 解析,两者都不是时给出带 content-type 与正文前 120 字的错误 |
723
+
724
+ ### 两条自我纠错(都是变异验证抓出来的)
725
+
726
+ **一、"同形调用"会让守卫假绿。** BUG-024 的守卫第一版写成 `/estimateCost\(activeModel, usage\.prompt_tokens/`——
727
+ 而同文件里 `inFlightCost()` 有一处同形调用,于是**把这行改回 modelName 的变异照样通过**。
728
+ 收紧为连同该行特有的参数(`cacheSplit(usage), costDate`)一起匹配后才抓住。
729
+
730
+ **二、"掐断连接"不是瞬态错误,测不出重试语义。** BUG-028 的第一版用例用"中途销毁连接"来验证"收到帧后不重试",
731
+ 但那个错误根本没被 `isTransient` 归为瞬态 → 两种实现都不重试 → 断言恒真(变异验证直接指出"抓不到")。
732
+ 改用**可控的流式空闲超时**(`timeout.streamIdleMs: 400`):它既有帧(说明已开始计费)、又确实属于可重试的瞬态,
733
+ 才真正落在两条实现的分歧点上。
734
+
735
+ ## 3.34 已修复(第三十五批:上游能力与文档——临时目录 / 检查点路径穿越 / 令牌进 argv / 文档漂移)
736
+
737
+ | 项 | 位置 | 核实结论 | 处理 |
738
+ | --- | --- | --- | --- |
739
+ | BUG-010(安装器临时目录泄漏) | `src/skill-lib.js` | **核实成立**:`installFromUrl` 只在"校验失败"这一条早退路径上显式 `rmSync`,`installFromGit` 同理——一旦中途抛异常(`readSkillMeta` 返回 null 时读 `meta.name` 直接 TypeError,或 `ensureHome`/写盘失败)就**留下整个临时目录**,反复失败即持续吃磁盘 | 两条安装器的清理都收进 `finally`;并补 `meta` 为 null 的显式判定(返回可读错误而不是抛 TypeError) |
740
+ | BUG-067(检查点文件名可穿越) | `src/task-state.js` | **核实成立**:`taskStateFile(sessionName)` 直接拼 `${name}.json`,`../../evil` 会把检查点读写到目录之外(三个调用点都只 catch 异常,等于**静默**把错误当"没有检查点") | 加**包含性检查**(`path.relative` 落在 `taskstates/` 内),越界即抛;三个调用点已有的 try/catch 会如实返回 `{ok:false,error}`。用 `path.relative` 而非"过滤 `../`":后者挡不住绝对路径与平台差异 |
741
+ | BUG-015(`--auth-token` 进 argv) | `src/commands/skill.js` | **核实成立**:令牌写在命令行里会留在 argv 与 shell 历史(`ps aux` 可见)——本仓发布链路早已把"token 不进 argv"定为纪律,这是漏网的一处 | 新增 `--auth-token=-` **从 stdin 读**;直接用字面量时给出明确告警并指向 `MINGDAO_WEB_TOKEN` / `config.web.token`(不改默认行为,只消除沉默) |
742
+ | P3(文档与实现漂移) | `docs/PACK-API.md`、`docs/CONFIG.md`、`docs/DEVELOPER.md` | **确实存在**(本批修的多处行为在文档里还是旧说法) | 逐处跟上:`pack verify` 默认静态 / `--runtime` 的语义与理由、目录围栏的 realpath 语义(含"返回规范化路径"这一契约变化)、本机信任模式与令牌的三种来源(含 `--auth-token=-`)、跨站请求一律 403、云元数据端点无条件拒绝、`compactTrigger` 的 0–1 范围与越界行为。并把文档一致性写进断言(文档回退会被测试挡住) |
743
+
744
+ > 关于 §3.34 的验证层级:BUG-010 与 BUG-067 是**行为级**(真的制造失败路径、真的看磁盘上有没有残留);
745
+ > BUG-015 是**行为 + 源码级**(空 stdin 真的退 1;告警文案由源码守卫钉住);
746
+ > 文档一致性是**文本级**。**如实登记,不把文本级说成行为级。**
747
+
588
748
  ## 4. 其余登记项(**第三方结论,我未逐条复核**)
589
749
 
590
750
  ### 4.1 自评报告(`MingDao-harness-v0.6.1-技术评估报告.md`)
@@ -615,14 +775,14 @@ WebUI 的 `web/` 层**只 import `listTasks`**(读路径、不加锁),**
615
775
 
616
776
  | 级别 | 位置 | 摘要 |
617
777
  | --- | --- | --- |
618
- | P2-1 | `src/memory.js:96` | `removeMemoryLines` 写回无尾换行,下次 append 拼出坏行 |
619
- | P2-2 | `src/audit.js:36-45`、`memory.js:101/117` | 截断依赖**进程内**计数 → CLI 下截断是死代码,日志无界增长 |
620
- | P2-3 | `src/audit.js:41` | 截断用 `writeFileSync` 而非原子写,崩溃丢事件 |
778
+ | ~~P2-1~~ | ~~`src/memory.js:96`~~ | ✅ **已修**(`kept.join('\\n') + '\\n'`,见 `src/memory.js:100` 的 v0.6.2 注释) |
779
+ | ~~P2-2~~ | ~~`src/audit.js:36-45`~~ | ✅ **已修**(§3.17:截断判据改为**文件大小**,跨进程有效) |
780
+ | ~~P2-3~~ | ~~`src/audit.js:41`~~ | ✅ **已修**(§3.17:改 `atomicWriteFileSync`;v0.6.3 批四又把追加与轮转收进同一把锁,见 §3.29) |
621
781
  | ~~P2-4~~ | ~~`src/commands/pack.js:31/166`~~ | ✅ **已修**(见 §3.12,并修掉连带发现的文档/实现不一致) |
622
782
  | P2-5 | `src/mcp-presets.js:43-48` | sqlite 预设必填参数含 `{dir}` 未替换,静默落到 cwd |
623
783
  | ~~P2-6~~ | ~~`src/commands/sync.js:81-88`~~ | ✅ **已修**(见 §3.1) |
624
- | P2-7 | `src/skill-registry.js:56` | `redirect:'follow'` 无逐跳复检,与 `skill-lib.js` 两种口径 |
625
- | P2-8 | `src/skill-registry.js:144-150` | sha256 校验可选,缺字段仍打印「✓ 已安装」 |
784
+ | ~~P2-7~~ | ~~`src/skill-registry.js:56`~~ | ✅ **已修**(§3.12:抽 `safe-fetch.js` 单一来源,逐跳复检) |
785
+ | ~~P2-8~~ | ~~`src/skill-registry.js:144-150`~~ | ✅ **已修**(§3.25:缺哈希即**拒绝安装**;v0.6.3 批六补了「sha256 只防传输损坏、不防索引投毒」的说明,见 §3.31) |
626
786
  | ~~P2-9~~ | ~~`src/tools/index.js:332-374`~~ | ✅ **已修**(见 §3.8) |
627
787
  | P2-10 | `src/routing.js:17`、`config.js:160` | 路由默认仍是改名前的旧模型名 |
628
788
  | ~~P2-11~~ | ~~`src/agent.js:104` vs `:34`~~ | ✅ **已修**(见 §3.12) |
@@ -647,25 +807,34 @@ WebUI 的 `web/` 层**只 import `listTasks`**(读路径、不加锁),**
647
807
  | ~~B-SR-1~~ | 安全/注入 | 路径穿越 | ✅ **已核实并修复,且实测复现**(§3.24:registry 索引名 `.`/`..` 拼进安装路径 → `{"name":".."}` 清空整个 MINGDAO_HOME。**注意 §3.23 一度误判为「未发现」,已更正**) |
648
808
  | A-PI-1 | 安全 | 「无 Key 强制」 | ✅ v0.6.0 C4 已处理(本地/内网端点免 Key) |
649
809
  | ~~A-LG-1~~ | 数据完整性 | 哈希链盲区 | ✅ **已核实并修复**(§3.17:盲区是**尾部截断**,实测「只留前 3 行」旧实现报 ok:true。已加封条侧车) |
650
- | B-CS-1 | 数据完整性 | 竞态丢数据 | ⚠ **仍未逐条核实**:已复核「写路径都在跨进程锁内」(workspace/tasks/schedule/cachestats/sync),并为异步锁加了并发读-改-写断言(3 个并发任务必须都落上);但这不等于「全仓无丢更新」,报告未给位置 |
810
+ | B-CS-1 | 数据完整性 | 竞态丢数据 | 🟡 **部分落实(有具体进展,仍不宣称穷尽)**:批四把 `cachestats`/`audit`/`log-writer` 的**追加与轮转收进同一把锁**(原来「锁外追加 + 锁内轮转」存在丢失窗口,§3.29)、批七把 `sync-server` 的分享接受改为**锁内重读**并把改密纳入写锁(§3.32);并发读-改-写断言常驻。**仍不宣称「全仓无丢更新」** |
651
811
  | ~~B-WS-1/2~~ | 数据完整性 | 静默吞写入 | ✅ **已穷举并清完**(§3.17–§3.21:账本/任务检查点/费用明细/会话索引/工作空间/守护 pidfile/记忆去重/审计日志 共 8 处)。剩余静默吞写已列**受审阅白名单**并由测试常驻约束 |
652
812
  | ~~B-TOK-1~~ | 数据完整性 | 词表永不重试 | ✅ **已核实并修复**(§3.25:一次读失败终生锁死 + 无声降级;改为有界重试 + 降级可见) |
653
- | B-SL-1 | 资源泄漏 | 临时目录 | 🟡 **已普查**(全仓 3 处 `mkdtempSync`:`skill-lib.js` ×2 均有 `rmSync`、`skill-registry.js` 有 `finally` 清理)——**未发现泄漏**,但报告未给位置,只能给到普查结论 |
813
+ | ~~B-SL-1~~ | 资源泄漏 | 临时目录 | ✅ **已修**(§3.34 BUG-010:拿到 `defect-register.md` 后定位到 `skill-lib.js` 的两处安装器——原来只在「校验失败」这一条早退上 `rmSync`,中途抛异常即**留下整个临时目录**。**§3.23 的「未发现泄漏」结论由此被推翻**:当时的普查只看了「有没有 rmSync」,没看它是否覆盖所有失败路径) |
654
814
  | ~~B-UI-1 / B-CLI-1 / B-REPL-1~~ | EPIPE | 标准输出 EPIPE 未处理 | ✅ **已核实并修复**(§3.20:实测裸 write 会带堆栈崩溃,已加两种策略的兜底) |
655
- | B-CMD-4 | 凭据泄露 | skill token | ⚠ 待核实 |
815
+ | ~~B-CMD-4~~ | 凭据泄露 | skill token | ✅ **已定位并修复**(§3.34 BUG-015:`--auth-token` 写在命令行 → argv/shell 历史可见;现支持 `--auth-token=-` 从 stdin 读,字面量给出告警) |
656
816
  | ~~D-REL-1/2~~ | 凭据泄露 | gitee token(发布链路) | ✅ **已核实并修复**(§3.22:实测确认 argv / 回显 / URL 三条泄露路径,改走 ssh stdin + HTTP 头鉴权 + 读后即删) |
657
817
 
658
- > **要真正排期这 22 项,需要那份 `defect-register.md`**(含 file:line);
818
+ > **2026-09-15 更新**:那份 `defect-register.md` **后来由项目方提供了**(`BUG-001` … `BUG-105`,含 file:line),
819
+ > 本文件的 §3.28–§3.34 就是按它逐条对照处理的(P1-1/P1-2/P1-12、BUG-009、BUG-010、BUG-015、BUG-024、
820
+ > BUG-028/029/030、BUG-036、BUG-056、BUG-067 等)。下面这段「需要那份 register」的原始说明**保留不改**,
821
+ > 作为当时的记录。
822
+ >
823
+ > **当时的原始说明**:要真正排期这 22 项,需要那份 `defect-register.md`(含 file:line);
659
824
  > 上表的"待核实"不是"已确认存在",而是**该报告未给出可复现位置**、我尚未逐条定位。
660
825
  > 已核实的 B-CT-1 说明了一点:**这份报告的标题级结论可能夸大**("永远 miss"实测不成立),
661
826
  > 引用它的条目时应先复现再排期。
662
827
 
663
828
  ## 5. 处理顺序与进度
664
829
 
665
- **总口径**:三份第三方报告的可执行条目,**已核实并处理 28 批**(§2.1–§3.27)。
830
+ **总口径**:三份第三方报告的可执行条目,**已核实并处理 35 批**(§2.1–§3.34)。
666
831
  每一批都要求:先复现 → 再定级 → 修复 → 回归断言 → **变异验证断言本身**。
667
832
  「先复现」对两个方向同样适用:B-CT-1 标题夸大(实测不复现),B-CON-1 / A-LG-1 / B-SR-1 实测确实严重。
668
833
 
834
+ > **版本标注(2026-09-21 补记)**:本表与 §3.28–§3.35 里的 `v0.6.3 批一…批九` 是**开发期标记**
835
+ > (提交信息与部分源码注释至今仍是该标记)。v0.6.3 已于 2026-09-15 发版,这九批改动在其之后合入,
836
+ > 因此**实际随 v0.6.4 发布**;用户文档(`docs/CONFIG.md`、`docs/PACK-API.md`)已按实际发布版本更正。
837
+
669
838
  **已闭合的类别**:
670
839
 
671
840
  - 三个过程缺陷(§2.1)、缓存跨代理污染(§2.2)、**项目级 Pack 信任门**(§3)
@@ -675,6 +844,24 @@ WebUI 的 `web/` 层**只 import `listTasks`**(读路径、不加锁),**
675
844
  自启文件未转义(§3.5)、MCP 预设参数(§3.6)、**账本写失败无声 + 尾部截断不可检出**(§3.17)、
676
845
  **任务检查点/费用明细/会话索引/工作空间/守护 pidfile/记忆去重/审计日志的"写失败却报成功"**(§3.18–§3.21)、
677
846
  **词表读失败终生锁死**(§3.25)
847
+ - **计费与可用性**(v0.6.3 批八):非幂等请求不再重试(避免重复计费)、总量护栏在循环头复查、
848
+ 退避可被 Ctrl+C 打断、`compactTrigger` 越界不再让压缩永久失效、降级回合按实际模型计价、
849
+ 提前关流可检出、content-type 被改写不再丢正文
850
+ - **上游能力与文档**(v0.6.3 批九):安装器临时目录 finally 清理、检查点路径穿越设防、
851
+ 令牌不进 argv(`--auth-token=-` 从 stdin 读)、文档与实现逐处对齐并有断言钉住
852
+ - **供应链**(v0.6.3 批六):`pack verify` 默认静态(CI 不再执行被审代码)、未信任 Pack 不遮蔽同名内置、
853
+ 预设遮蔽可见、git 安装器拒 `file://` 与本地路径
854
+ - **长驻与并发**(v0.6.3 批七):同步服务不再因未处理的 rejection 整体退出、锁外快照写回(分享/吊销复活)、
855
+ 调度恢复不自锁也不双跑、pid 复用下锁可回收、避峰长等待切片复查租约
856
+ - **Web 攻击面**(v0.6.3 批五):符号链接逃逸围栏(P0-3)、回环绑定跳过 SSRF(P1-8)、
857
+ 无令牌 + GET 副作用可被跨站盲打(H-5/H-6)、自定义 Provider 名穿越 import(R4)
858
+ - **静默数据损失**(v0.6.3 批四):并发轮转吃掉别人那一行(`cachestats` BUG-009 / `audit`+`log-writer` M-20)、
859
+ 压缩后读取去重缓存说谎(M-1)、`/compact` 追加导致会话文件膨胀(H-4)、
860
+ 配置"读不出来"被当成"没有配置"而静默覆盖(H-7,含 BOM 这一支)
861
+ - **合规能力空转**(v0.6.0 主打的账本 / 回放 / 领域约束):
862
+ 约束事件缺 `id` → 「是哪条红线拦的」恒为 null(§3.28 P1-1)、`confirm` 从不求值(P1-2)、
863
+ CLI 下 `ledger replay` 恒看不见 Pack 约束 → 门禁静默假阴性(P1-12)、
864
+ `ledger replay --json` 恒输出人读文本(M-12)、五条失败路径退 0(M-21)
678
865
  - **终端注入**:控制序列过滤(§3.1 P2-5)
679
866
  - **记忆注入**:项目记忆持久化围栏(§3.2 P2-9)
680
867
  - **日志/调度/锁**:写入与轮转按大小触发(§3.7)、进程归属校验与整组清理(§3.8)、
@@ -710,13 +897,18 @@ WebUI 的 `web/` 层**只 import `listTasks`**(读路径、不加锁),**
710
897
  frontmatter 校验直接进 `rmSync`,`{"name":".."}` 会清空整个 `MINGDAO_HOME`)。
711
898
  注意 §3.23 当时误判为"未发现",§3.24 已更正并说明原因。
712
899
  6. ~~**词表永不重试**(B-TOK-1)~~ ✅ **已定位并修复**(§3.25:`loadData` 一次失败终生锁死 + 无声降级)。
713
- **临时目录**(B-SL-1):已普查全仓 3 处 `mkdtempSync`(`skill-lib.js` ×2 有明确 `rmSync`、
714
- `skill-registry.js` 有 `finally` 清理),**未发现泄漏**;报告未给位置,故只能给到"普查结论"。
715
- **skill token**(B-CMD-4):**仍未定位**(全仓搜不到任何 "skill token" 相关代码,
716
- 猜测指技能来源凭据之类,但无从核实)。
900
+ ~~**临时目录**(B-SL-1)~~ ✅ **已定位并修复**(§3.34 BUG-010)。**这里更正一次我自己的结论**:
901
+ 当时写的是「已普查全仓 3 处 `mkdtempSync`,均有 `rmSync`。**未发现泄漏**」——
902
+ 拿到 `defect-register.md` 后发现真正的缺陷不在"有没有 `rmSync`",而在**它没覆盖所有失败路径**
903
+ (只在"校验失败"这一条早退上删,中途抛异常就留下整个目录)。
904
+ **普查得出"没有泄漏"和验证"失败路径也清理"是两件事**——这是本项目反复遇到的同一类教训。
905
+ ~~**skill token**(B-CMD-4)~~ ✅ **已定位并修复**(§3.34 BUG-015:它就是 `mingdao web --auth-token`
906
+ 写在命令行导致令牌进 argv/`ps`/shell 历史;当时的"全仓搜不到 skill token"是因为按报告的措辞去找名字,
907
+ 而不是按**行为**去找)。
717
908
  ~~gitee token 发布链路(D-REL-1/2)~~ ✅ **已修**(§3.22)。
718
- 7. **§4.3 `audit-report.md` 其余约 15 项**:该报告只给了 22 个**代号**、
719
- 明细在**未提供**的 `30-defects/defect-register.md` 里,因此多数条目连位置都不知道。
909
+ 7. **§4.3 `audit-report.md` 其余约 15 项**:该报告只给了 22 个**代号**,
910
+ 明细在 `30-defects/defect-register.md` 里——**该文件后来由项目方提供了**(`BUG-001`…`BUG-105`),
911
+ 因此 §3.28–§3.34 已能按 file:line 逐条对照处理;本条的原始措辞保留不改,作为当时的记录。
720
912
  已核实的 4 条(B-CT-1 / B-CON-1 / B-HK-1 / A-LG-1)说明它**既有夸大也有真货**,
721
913
  所以对它既不能整份照单排期、也不能整份忽略——只能逐条复现。
722
914
  8. **账本可选签名**(`--sign-key`):仍未实现。封条只提升到「防误删/漏写」,
package/docs/CONFIG.md CHANGED
@@ -52,7 +52,8 @@ MingDao 先用 executor 模型(路由关闭时为当前模型)把被裁段
52
52
 
53
53
  触发线(滞回缓冲):默认达到预算 **80%** 即提前压缩、压到约 60%——避免在预算线附近反复
54
54
  裁剪/压缩导致缓存前缀频繁失效(每次失效 = 该轮 prompt 全额按未命中计费)。可用
55
- `"compactTrigger": 0.9` 调整触发线(0–1 之间的小数)。
55
+ `"compactTrigger": 0.9` 调整触发线(0–1 之间的小数;**超出 0–1 会被夹到边界并告警**——
56
+ 此前只夹下限,配成 >1 会让自动压缩永不触发)。
56
57
 
57
58
  ## 会话检索索引(P3-2)
58
59
 
@@ -68,6 +69,17 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
68
69
  `process.chdir`)。载入历史会话时全局工作空间自动聚焦到该会话的目录;头部下拉显式切换
69
70
  时当前会话跟随。映射存于 `~/.mingdao/session-workspaces.json`,会话改名/删除自动维护。
70
71
 
72
+ **目录围栏(v0.6.4 起按真实路径判定)**:可登记/可浏览的目录限定在 家目录 / 启动目录 /
73
+ 当前工作空间 / `web.browseRoots` 显式授权 之内(Windows 上为 桌面·文档·下载 三个常用目录)。
74
+ 判定走 `realpath`,因此:
75
+
76
+ - **符号链接不会成为逃生通道**——指向围栏之外的链接目录会被 403(此前只做字符串前缀比较,
77
+ `ln -s / <家>/escape` 之后 `?dir=…/escape` 就能枚举全盘);
78
+ - 登记与切换返回的是**规范化后的真实路径**(macOS 上 `/var/folders/…` 会显示为
79
+ `/private/var/folders/…`),围栏判定与返回值同口径;
80
+ - 确需家目录之外的位置(外置卷/网络盘):`web.browseRoots` 加进白名单,或
81
+ `web.allowAnyWorkspaceDir: true`(**这会整体放开围栏**,仅在完全可信的本机环境使用)。
82
+
71
83
  ## 权限规则(工具级 allow/deny)
72
84
 
73
85
  ```json
@@ -82,7 +94,16 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
82
94
 
83
95
  - `allow` / `deny` 为工具名列表,支持「工具名:参数前缀」匹配(如 `bash:git *` 只放行 git 命令);
84
96
  - `deny` 优先于 `allow`;匹配不到时回落到 `mode`(`ask` 逐次确认);
85
- - **需要特殊授权时弹窗交互**:被 `deny` 规则拦截、或 `readonly` 模式下执行写操作时,WebUI/TUI 会弹出询问(「是否本次强制放行?」),同意即放行、拒绝/无响应即拒绝——不再静默拦截。
97
+ - **需要特殊授权时弹窗交互**:被 `deny` 规则拦截、或 `readonly` 模式下执行写操作时,WebUI/TUI 会弹出询问(「是否本次强制放行?」),同意即放行、拒绝/无响应即拒绝——不再静默拦截。提示里会**点名命中了哪条规则**,便于判断这次放行是不是你想要的;
98
+ - **`denyStrict: true`(v0.6.4 起)**:把 `deny` 升级为**不可临时放行**的硬拦截(连弹窗都没有,直接拒绝并在输出里说明命中了哪条规则)。默认 `false`,保持上面那条"可放行"的既有语义。
99
+
100
+ ```json
101
+ { "permission": { "mode": "auto", "deny": ["bash:rm *", "fetch:*"], "denyStrict": true } }
102
+ ```
103
+
104
+ > 什么时候该开:无人值守 / 强合规场景(定时任务、CI 门禁、下游容器),
105
+ > 那里"一次误按 y 就绕过自己写的禁令"是不可接受的;
106
+ > 交互式日常使用保持默认即可——每次放行都是一次明确的、点名规则的确认。
86
107
 
87
108
  ## 项目记忆(自动写入项目目录,默认已自忽略)
88
109
 
@@ -202,12 +223,17 @@ mingdao net report --since 7d --json # 支持 7d / 24h / 90m
202
223
  }
203
224
  ```
204
225
 
205
- - 仅本机使用保持默认 `127.0.0.1`(无需令牌);
226
+ - 仅本机使用保持默认 `127.0.0.1`(**本机信任模式**:不校验令牌)。启动横幅会明确提示这一点——
227
+ 同机的其它进程/其它用户可以直接访问 `/api/*`,多用户机器与共享 CI 上请启用令牌;
206
228
  - 绑定 `0.0.0.0`/局域网地址时**强制令牌认证**:未配置则每次启动随机生成并打印
207
- `http://<地址>:<端口>/?token=…` 访问链接;固定令牌三种方式(优先级从高到低):
208
- `mingdao web --auth-token <令牌>`、环境变量 `MINGDAO_WEB_TOKEN`、`web.token`;
229
+ `http://<地址>:<端口>/?token=…` 访问链接;固定令牌(优先级从高到低):
230
+ 环境变量 `MINGDAO_WEB_TOKEN`、`web.token`、`mingdao web --auth-token <令牌>`(或 `--auth-token=-` 从 stdin 读);
231
+ **命令行字面量会进入 argv 与 shell 历史(`ps` 可见)**,故用它时会给出告警;
209
232
  - 令牌同时接受 URL `?token=`、请求头 `X-MingDao-Token` 或 `Authorization: Bearer`;
210
- - 服务端校验 `Host` 头必须等于回环名或绑定地址(防 DNS rebinding),代理场景会 403 属预期。
233
+ - 服务端校验 `Host` 头必须等于回环名或绑定地址(防 DNS rebinding),代理场景会 403 属预期;
234
+ - **跨站浏览器请求一律 403**(按 `Sec-Fetch-Site` 判定,含 `<img>`/`no-cors` 盲打):
235
+ 本机端口不受同源策略保护,这是"任意网页盲打本机 API"的唯一有效拦截点;
236
+ - 云元数据端点(`169.254.169.254` 等)在任何绑定与任何开关下**都拒绝**。
211
237
 
212
238
  ## 沙箱环境变量过滤
213
239
 
package/docs/DEVELOPER.md CHANGED
@@ -90,7 +90,7 @@ Preset 定制的是「提示词 + 工具白名单 + 权限」;**Pack 定制的
90
90
 
91
91
  ```bash
92
92
  mingdao pack new tcm # 脚手架:pack.json + pack.mjs + prompts/domain.md
93
- mingdao pack verify ./packs/tcm # 契约校验(下游 CI 门禁:非 0 退出即失败)
93
+ mingdao pack verify ./packs/tcm # 静态契约校验(下游 CI 门禁:非 0 退出即失败;**不执行 Pack 代码**)
94
94
  ```
95
95
 
96
96
  最小 `pack.mjs`:
package/docs/PACK-API.md CHANGED
@@ -219,18 +219,35 @@ export function createPack(ctx) {
219
219
 
220
220
  | 时机 | 可用的 kind | 行为 |
221
221
  | --- | --- | --- |
222
- | PreToolUse | `tool-deny`、`tool-arg-require`、`arg-forbid` | 命中即阻止执行,回填工具错误给模型,写审计 |
222
+ | PreToolUse | `tool-deny`、`tool-arg-require`、`arg-forbid`、`confirm` | 前三个命中即阻止执行,回填工具错误给模型,写审计;`confirm` 见下方说明 |
223
223
  | PostToolUse | `completeness`、`result-forbid` | `completeness` 缺项 → 拒绝该工具结果,要求模型补采;`result-forbid` → 屏蔽结果并提示 |
224
224
  | 输出前 | `output-forbid`、`require-citation` | 命中 → 按 `action` 处理:`block`(改为固定合规文案)/ `block-and-rewrite`(再请求一次修正)/ `warn`(放行并标注) |
225
225
 
226
+ **`confirm`(v0.6.4 起真正求值)**:即使在 `auto` 权限档位下,命中的工具调用也**必须**先得到人工确认才执行
227
+ ——它的存在意义正是「权限已经放行,领域还要再问一次」。两种情形一律按 fail-closed 阻止:
228
+
229
+ - 用户答否;
230
+ - 当前环境**无法交互**(`-p` 单次提问、调度任务、子 Agent 等)——拿不到确认即不执行,
231
+ 与权限引擎在同类情形下「按拒绝处理」保持同一口径。
232
+
233
+ 求值顺序上,`tool-deny` / `tool-arg-require` / `arg-forbid` **永远先于** `confirm`,
234
+ 因此把 `confirm` 写在 `tool-deny` 之前不会遮住后者。
235
+
236
+ > `confirm` 在 v0.5.0–v0.6.2 之间是 `KINDS` 里的一个字符串:能通过装载校验、会被计入约束条数
237
+ > (于是 `active=true`),但引擎里没有任何一处求值它——作者以为「这条工具要人工确认」,实际零效果。
238
+ > 这是「红线静默消失」,与本文档的 fail-closed 声明相反;v0.6.4 补齐实现并补了回归断言。
239
+
226
240
  约束事件统一结构(进执行账本):
227
241
 
228
242
  ```jsonc
229
- { "at": 1757…, "pack": "tcm", "constraint": "no-diagnosis-conclusion",
243
+ { "at": 1757…, "id": "no-diagnosis-conclusion", "pack": "tcm", "constraint": "no-diagnosis-conclusion",
230
244
  "kind": "output-forbid", "stage": "pre-output", "action": "block-and-rewrite",
231
245
  "matched": "好转", "session": "…", "model": "deepseek-v4-flash" }
232
246
  ```
233
247
 
248
+ > `id` 自 v0.6.4 起写入事件。此前事件只有 `constraint` 字段,而账本与回放两处消费方都读 `id`,
249
+ > 于是「是哪条红线拦的」在账本里恒为 `null`——不报错、只输出 null,任何断言都不会失败。
250
+
234
251
  **字段要求(v0.6.0 起在装载时强制校验,写错不会静默失效)**
235
252
 
236
253
  | kind | 必填 | 说明 |
@@ -241,6 +258,7 @@ export function createPack(ctx) {
241
258
  | `output-forbid` | `pattern`、`action` | 同上 |
242
259
  | `result-forbid` | `tool`、`pattern` | 同上 |
243
260
  | `completeness` | `tool`、`fields[]` | |
261
+ | `confirm` | `tool` | 无 `tool` 的 `confirm` 在 `toolMatches` 里恒不命中,等于红线不存在,故装载即拒绝 |
244
262
 
245
263
  **pattern 写错会怎样(这一条值得单独读)**:v0.5.0 的行为是**静默放行**——
246
264
  `arg-forbid` 给一个非法正则时求值失败、`re` 为 null,红线**永不命中**且不进 `invalid` 列表,
@@ -323,6 +341,10 @@ manifest 可声明 `budget.dailyYuan` + `budget.action`。超限时 `ctx.llm()`
323
341
 
324
342
  **下游义务**:在 `pack.json` 声明 `apiVersion` 与 `engines.mingdao`,并把 `mingdao pack verify` 放进 CI。
325
343
 
344
+ > v0.6.4 更正:此前帮助文本写的是"只做静态校验",实现却会 `import pack.mjs` —— 于是按文档把
345
+ > `pack verify` 当 CI 门禁,等于**在 CI 上执行被审仓库的任意 Node 代码**(且不经 `pack trust` 信任门)。
346
+ > 现在默认确实是静态的;约束与提示词段的合法性由代码产出,静态阶段无法校验,需要那种覆盖请显式加 `--runtime`。
347
+
326
348
  **上游义务**:任何 Pack API 变更必须同步更新本文 + 兼容性矩阵 + `docs/CHANGELOG-PACK.md`。
327
349
 
328
350
  ---
@@ -331,7 +353,8 @@ manifest 可声明 `budget.dailyYuan` + `budget.action`。超限时 `ctx.llm()`
331
353
 
332
354
  ```bash
333
355
  mingdao pack list # 已加载 Pack / 来源 / 版本 / 兼容状态
334
- mingdao pack verify <dir> # 静态校验 manifest + 文件齐全 + 约束合法性(下游 CI 门禁)
356
+ mingdao pack verify <dir> # **静态**校验(manifest + 文件齐全 + pack.mjs 存在性)——**不执行 Pack 代码**,下游 CI 门禁用这个
357
+ mingdao pack verify <dir> --runtime # 额外 import pack.mjs 验证运行时契约(会以完整 Node 权限执行 Pack 代码,仅在你审过代码时用)
335
358
  mingdao pack new <name> # 脚手架
336
359
  mingdao pack info <name> # 贡献面:工具/约束/提示词段/权限/费用统计
337
360
  mingdao pack test <name> # 跑内置反例样本(约束 + 工具契约)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -380,6 +380,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
380
380
  async function runTurn(/** @type {any} */ messages) {
381
381
  let steps = 0;
382
382
  let finish = null;
383
+ /** v0.6.3(M-16):本回合是否出现过"上游提前关流"(回答可能不完整)。
384
+ * 刻意不叫 truncated——既有 `truncated` 的含义是"步数兜底总结也失败了",两者必须分开。 */
385
+ let upstreamTruncated = false;
383
386
  // v0.3.1 自动续跑(长程执行):跑满 stepLimit 步后不再直接中断,而是注入进度摘要再续跑,
384
387
  // 最多 maxRounds 轮(默认 3,可用 cfg.maxRounds 调);审计/重构等大任务不再「一步中断」。
385
388
  const maxRounds = Math.max(1, Number(cfg.maxRounds) || 3);
@@ -588,6 +591,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
588
591
  windowPressure = false;
589
592
  pressureWarned = false;
590
593
  }
594
+ // v0.6.3(M-1):压缩把早期消息(含文件正文)换成了摘要,但**读取去重缓存**还记着
595
+ // "这个文件你看过(mtime+size 未变)"——模型随后再读同一文件只会拿到「内容与上次读取一致」
596
+ // 的占位串,而正文已经不在上下文里了,于是它可能凭残缺信息继续改文件。
597
+ // 压缩是"上下文被替换"的事件,缓存必须跟着失效(代价只是重新读一次文件)。
598
+ agentReadCache.clear();
591
599
  try {
592
600
  onCompact?.(messages);
593
601
  } catch {}
@@ -761,6 +769,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
761
769
  }
762
770
 
763
771
  finish = res.finish ?? finish;
772
+ // v0.6.3(M-16):上游**提前关流**(没有 [DONE]、也没有 finish_reason)说明这次回答很可能是半截的。
773
+ // 此前它与正常结束完全无法区分:用户拿到半句、账本记 done、也没有任何告警。
774
+ // 这里至少做到"说出来"——置标记并在界面上提示,让人知道该复核输出而不是直接采信。
775
+ if (res.truncated) {
776
+ upstreamTruncated = true;
777
+ try {
778
+ io.print(style('⚠ 上游提前结束响应(未收到结束标记),本次回答可能不完整——请复核输出后再采用。', C.yellow));
779
+ } catch {}
780
+ }
764
781
  // v0.6.0 C1:模型轮次事件(耗时/首 token/用量/完成原因)——「这一步花了多少钱、等了多久」的最小依据
765
782
  turnLedger.modelRound({
766
783
  round,
@@ -769,6 +786,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
769
786
  firstTokenMs: firstTokenAt ? firstTokenAt - llmT0 : null,
770
787
  requestStartAt: lastRequestStartAt,
771
788
  finish: res.finish ?? null,
789
+ // 提前关流是"这次回答不可信"的事实,必须进账本(否则事后无法区分它和正常完成)
790
+ ...(res.truncated ? { truncated: true } : {}),
772
791
  usage: res.usage ?? null,
773
792
  });
774
793
  // 省钱 B1:只读阶段中模型文字明确表达写意图 → 下一轮注入全量工具(多一轮,几乎无感)
@@ -803,7 +822,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
803
822
  if (res.text) {
804
823
  const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
805
824
  messages.push({ role: 'assistant', content: ap.text });
806
- return { text: ap.text, reasoning: res.reasoning || '', usage, steps, finish, truncated: false, aborted: false, note: ap.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
825
+ return { text: ap.text, reasoning: res.reasoning || '', usage, steps, finish, truncated: false, aborted: false, upstreamTruncated, note: ap.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
807
826
  }
808
827
  break;
809
828
  }
@@ -904,6 +923,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
904
923
  // v0.5.0 A3 ①:领域约束(PreToolUse)——权限放行之后、执行之前强制。
905
924
  // 与权限引擎的分工:权限回答「用户是否允许」,约束回答「领域是否允许」;两者都通过才执行。
906
925
  const cv = checkPreTool(constraints, name, args);
926
+ // v0.6.3(P1-2):confirm 类约束——权限放行之后**仍要人工确认**。
927
+ // 它存在的意义正是「权限档位是 auto 时也要问一次」;拿不到确认通道(非交互:
928
+ // -p / 调度 / 子 Agent)或用户答否,一律按阻断处理,与权限引擎在同一情形下的
929
+ // 「按拒绝处理」保持同一口径(fail-closed,见下方 permission.check 的 catch)。
930
+ let confirmFailure = /** @type {string|null} */ (null);
931
+ if (cv?.needsConfirm) {
932
+ let granted = false;
933
+ try {
934
+ granted = await io.confirm(`【领域约束】${cv.reason}。是否继续执行?(y/N)`);
935
+ } catch {
936
+ granted = false;
937
+ }
938
+ if (granted) {
939
+ if (auditOn) auditEntry({ confirmRequired: true, granted: true, reason: cv.reason });
940
+ turnLedger.permission({ name, mode: permission?.mode ?? cfg.permission ?? null, decision: 'confirm-granted', source: 'constraint.confirm', rule: cv.event?.id ?? null });
941
+ } else {
942
+ confirmFailure = `领域约束「${cv.event?.id ?? ''}」要求人工确认,未获确认(拒绝或当前环境无法交互)——已按 fail-closed 阻止执行`;
943
+ }
944
+ }
907
945
  // v0.6.0 C1:工具调用事件——参数留「脱敏明细 + 原文指纹」两份,指纹用于比对/防篡改;
908
946
  // 权限与约束**两者都记**:受监管场景要能回答「权限放行了,但领域红线拦住了」这类问题。
909
947
  turnLedger.toolCall({
@@ -913,15 +951,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
913
951
  rawArgs: args,
914
952
  args,
915
953
  permission: { decision: 'allow', source: 'permission.check' },
916
- constraint: cv?.blocked ? { blocked: true, id: cv.event?.id ?? null, kind: cv.event?.kind ?? null } : null,
954
+ constraint: cv?.blocked || confirmFailure ? { blocked: true, id: cv?.event?.id ?? null, kind: cv?.event?.kind ?? null } : null,
917
955
  });
918
- if (cv?.blocked) {
919
- io.renderToolDenied(name, args, cv.reason);
920
- if (auditOn) auditEntry({ denied: true, reason: cv.reason });
921
- auditConstraint(cv.event);
956
+ if (cv?.blocked || confirmFailure) {
957
+ const reason = confirmFailure ?? cv?.reason ?? '领域约束拦截';
958
+ io.renderToolDenied(name, args, reason);
959
+ if (auditOn) auditEntry({ denied: true, reason });
960
+ if (cv?.event) auditConstraint(cv.event);
922
961
  // 被约束拦下的调用不会有 tool.result,这里补一条终态,避免账本出现「只有调用没有结果」的悬空步
923
962
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '领域约束拦截' });
924
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `【领域约束】${cv.reason}` });
963
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: `【领域约束】${reason}` });
925
964
  return null;
926
965
  }
927
966
  return { tc, name, args, isMcp };
@@ -1309,10 +1348,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1309
1348
  if (turnLedger) {
1310
1349
  try {
1311
1350
  const costDate = lastRequestStartAt ? new Date(lastRequestStartAt) : new Date();
1312
- const yuan = estimateCost(modelName, usage.prompt_tokens, usage.completion_tokens, cacheSplit(usage), costDate);
1351
+ // v0.6.3(BUG-024):计价必须用**本回合实际使用**的模型。
1352
+ // 此前用 modelName(用户配置的模型),而成本护栏降级后请求实际发给 activeModel——
1353
+ // 两者单价不同(flash 便宜数倍),于是账本与日费用护栏系统性偏移,
1354
+ // 而"降级省钱"恰恰是靠这里体现的。
1355
+ const yuan = estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, cacheSplit(usage), costDate);
1313
1356
  const priced = typeof yuan === 'number' && Number.isFinite(yuan);
1314
1357
  turnLedger.cost({
1315
- model: modelName,
1358
+ // BUG-024:归属同样要用实际计费的模型(run.start 里已记过"用户请求的模型",
1359
+ // 这里再写 modelName 会让"花了钱的那次调用"归属到一个没被调用的模型)
1360
+ model: activeModel,
1316
1361
  usage,
1317
1362
  yuan: priced ? yuan : null,
1318
1363
  priced,
@@ -1368,5 +1413,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1368
1413
  runTurn,
1369
1414
  spawnTask: (/** @type {any} */ prompt, /** @type {any} */ opts) => spawnTask(prompt, opts),
1370
1415
  getTodos: () => todos.slice(),
1416
+ // v0.6.3(M-1):凡是"上下文被整体替换"的地方(/compact、/clear、手动重写历史)都要能
1417
+ // 让读取去重缓存一起失效,否则模型会被"你已读过"的占位串误导。
1418
+ clearReadCache: () => agentReadCache.clear(),
1371
1419
  };
1372
1420
  }