mingdao-harness 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1158,3 +1158,36 @@ P1-20b 就是这么找出来的。同理,P0-1 的断言刻意包含**反向用
1158
1158
  (与 v0.6.4 批一同一处的漏网)③ 并发读-改-写丢数据四条(`memory` / `tasks` / `tasks.worker` / `log-writer`)
1159
1159
  ④ 截断与轮转两条(触发看字节、守卫看行数)⑤ 结构类"单一来源"整改 ⑥ 测试可信度(约 53 处断言作用于
1160
1160
  **未剥注释**的源码文本)。另:P1-21(`workspace.js` 符号链接"注册后改指向"击穿围栏)已列为本批之后第一项。
1161
+
1162
+ ## 3.40 第三方技术评估(v0.6.5,2026-09-23):Windows 测试门禁三处
1163
+
1164
+ **来源**:第三方对 `3bbc7f7`(v0.6.5)的评估报告 + 随附 PR/补丁(`MingDao-harness技术评估报告_v0.6.5.md`、
1165
+ `mingdao-0.6.5-PR-Windows测试门禁修复.md`、`mingdao-0.6.5-fix-windows-test-gates.patch`)。
1166
+ 总评 8.9/10,扣分项集中在**「三平台全绿」在真实 Windows 开发机上不成立**(本登记簿按本仓纪律逐条复现后处理)。
1167
+
1168
+ | # | 报告结论 | 我的复现/判定 | 处置 |
1169
+ | --- | --- | --- | --- |
1170
+ | 4.3 | `test/run-all.mjs` 在 Windows `spawn EINVAL` 崩溃 → **bench 的 214 条断言从不执行** | ✅ **成立(代码级确认 + 文档依据)**:Node ≥18.20.2/20.12.2(CVE-2024-27980 那批)起 spawn `.cmd`/`.bat` 无 shell **同步**抛 EINVAL;`on('error')` 收不到(ChildProcess 未被创建),Promise 直接 reject → 汇总器崩溃。**CI 从未暴露**:`npm run coverage`(run-all 的唯一入口)只在 `Linux + Node 20` 那条腿上跑 | ✅ 已修:win32 加 `shell: true` + 同步 `try/catch`(记为套件失败,不崩汇总器);`test/smoke.js` §125 加**源码级**守卫(本机无法行为级复现,如实标注层级) |
1171
+ | 4.2 | `e2e-schedule` 租约自退用例确定性失败;根因是 `every` 的等待是整段 ≤60s 且**期间不查租约** | ✅ **成立(主审复现)**:写探针——`--every 60s` 任务 + 预热 5s 让 sleeper 进入等待片,再篡改 pidfile 租约 → **>40s 旧 daemon 仍存活**(上限 60s)。修复后同一探针 **4.0s 退出**。**顺带查出 `once` 分支有同一处**(报告只点了 `every`) | ✅ 已修:`every`/`once` 的等待都改走 `waitGuarded(…, 3000)`(≤3s 切片逐片查租约),最坏接管延迟 60s → ≈5s(监督轮询 2s + 一片 3s)。**行为级断言**:接管延迟 ≤12s |
1172
+ | 4.1 | 标准 Windows 账户下 `fs.symlinkSync` **不抛错也不建链接**(生成普通空文件)→ smoke 崩在软链用例、后续约 140 组断言不执行 | 🟡 **结论成立、机制按我的核实改写**:Node 在无权限时通常是**抛 EPERM**(多家项目因此改用 junction,见下);"静默生成普通文件"更像**该审计沙箱的文件系统降级**行为,不是标准 Windows 的默认行为——但**两种环境都必须被正确处理**,而且报告的第二个洞是硬的:`try { symlinkSync } catch {}` 这种写法**只判"有没有抛错",不判"有没有建出链接"** | ✅ 已修(比原补丁更彻底):新增 `makeRealSymlink()` 能力探测(`lstat().isSymbolicLink()` 复核)并替换**全部 6 处**软链用例(报告只点了 2 处);建不成显式打印「⚠ 跳过」;新增 §125 反向自检(smoke.js 里再出现裸 `fs.symlinkSync` 即测试失败) |
1173
+ | 4.1(后半) | 建议把 `withinRoot()` 导出为纯函数,用桩 fs 做不依赖平台权限的单测 | ✅ 采纳并落地 | ✅ `withinRoot(root, target, depth, io = fs)` 支持注入 fs 并导出;新增 `test/smoke.js` §124:悬空指向外(拒)/悬空指向内(放行)/成环(有界拒,断言 `readlink` 调用 ≤20)/新建文件(放行)/根与子路径(放行),**任何平台都跑**;另加探测器自身的桩测试(降级环境 / 真建成 / EPERM 三种) |
1174
+
1175
+ **变异验证 7/7**(把每处修复逐个改回缺陷,断言当场失败):围栏递归解析、能力探测判据、裸 symlinkSync 回归、
1176
+ run-all 的 shell、run-all 的 try/catch、调度切片(源码级)、调度切片(行为级)。
1177
+
1178
+ **方法论收获(与前几批同源)**:这次的四处问题里**三处**都不是"代码写错了",而是
1179
+ **判据只落在能验证的那一侧**:CI 只在 Linux 上跑 run-all;软链用例只判"抛不抛错";
1180
+ 测试计时依赖"daemon 恰好已经进入等待片"(我在补丁里加了预热,让它变成确定性用例——
1181
+ 否则"修复前后都通过"的假绿会一直在)。**"没报错"和"验证过了"是两件事。**
1182
+
1183
+ **未纳入本批(如实登记,按报告 §7 排期)**:
1184
+ - **P0-2 在线缓存命中基准**(`bench-cache-online.mjs`,需真实 Key、CI 可选)——这是"删 token 是否反而
1185
+ 破坏缓存"的唯一裁决方式,报告指出 bench 里"回收不额外劣化"目前是**推理而非实测**。需要负责人决定
1186
+ 用哪个 Key/额度跑,故先不动。
1187
+ - **P1-1** `agent.js` 拆分(`runTurn` ~1000 行)、**P1-2** 结构化摘要 + 关键事实钉死、
1188
+ **P1-3** 预算状态注入上下文、**P1-4** 出网闸门对子进程 curl 的补强、**P1-5** `docs/ARCHITECTURE.md` 重写。
1189
+ - P1-5 的**事实核对**(本文件记录用):`docs/ARCHITECTURE.md` 共 108 行,**确实**停留在 v0.1.x 口径——
1190
+ 全文 0 次提到 `packs` / `constraints` / `ledger` / `net-policy` / `presets` / `model-caps`,
1191
+ 第 7 节路线图列的多为已完成项。报告的这条批评**成立**。
1192
+ - P2 各项(Pack 沙箱档、成本归因下钻、降级目标自动选择、JSDoc 治理)与报告 §六 横向对比结论,
1193
+ 仅作输入,不逐条排期。
@@ -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 # 期望 全部通过(当前 151 组断言)
64
+ node test/smoke.js # 期望 全部通过(当前 153 组断言)
65
65
  node test/e2e-local.js # 期望 全通过
66
66
  node test/e2e-web.js # 期望 全通过
67
67
  node test/e2e-schedule.js # 期望 全通过
@@ -75,6 +75,34 @@ node src/cli.js diagnose # 自检报告(脱敏)
75
75
  - [ ] `RELEASE-NOTES-<版本>.md` 已写好(三平台正文共用)
76
76
  - [ ] `node src/cli.js --version` 与版本号一致
77
77
  - [ ] 工作区干净(`git status` 无未提交改动),且已推到 `origin/main`
78
+ - [ ] **在标准 Windows 账户(未开 Developer Mode / 非管理员)上 `npm test` 全绿**(见 §1.3)
79
+
80
+ ---
81
+
82
+ ### 1.3 Windows 实机验收(必做,2026-09-23 新增)
83
+
84
+ **CI 五腿全绿 ≠ Windows 上真的能跑。** 两次真实事故都源于"CI runner 的权限/环境掩盖平台差异":
85
+
86
+ | 事故 | 现象 | CI 为什么是绿的 |
87
+ | --- | --- | --- |
88
+ | v0.2.5 报告(e2e-web 红灯) | `%TEMP%` ⊂ `%USERPROFILE%` 导致工作目录围栏误判 | runner 的临时目录与家目录关系与普通账户不同(还叠了 8.3 短名侥幸) |
89
+ | v0.6.5 第三方评估 §4.1 / §4.3 | 标准账户下 `smoke` 崩在软链用例、`run-all` 因 `spawn('.cmd')` 同步抛 EINVAL 而崩溃(bench 的 214 条断言**从不执行**) | runner 以管理员运行,能建真符号链接;且 `npm run coverage`(run-all 的唯一入口)**只在 Linux + Node 20 那条腿上跑** |
90
+
91
+ 因此把下面这条列进验收(**光靠 CI 绿不足以证明**):
92
+
93
+ ```powershell
94
+ # 在 Windows 11 的**普通账户**(未开 Developer Mode)上,仓库根目录:
95
+ npm ci
96
+ npm test # = node test/run-all.mjs:六套套件 + 汇总表,必须全绿且有汇总表
97
+ npm run typecheck # 0 错误
98
+ ```
99
+
100
+ 要点:
101
+ - `npm test` 会跑 **bench 五套(214 断言,含省钱基准回归)**——这条在 Windows 上曾经是静默空转的;
102
+ - 软链相关用例在标准账户上应**显式打印「⚠ 跳过」**而不是崩溃(能力探测见 `test/smoke.js` 的
103
+ `makeRealSymlink`;围栏判据本身由 `test/smoke.js` §124 的**桩 fs 单测**覆盖,与平台权限解耦);
104
+ - 若出现红色,先看是不是"平台能力差异被当成失败",但**不要**用"加个 catch 跳过"了事:
105
+ 与安全边界相关的(围栏、路径、凭据)必须补平台无关的桩测试,见 §124 的做法。
78
106
 
79
107
  ---
80
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/schedule.js CHANGED
@@ -459,12 +459,14 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id,
459
459
  * 期间 daemon 被接管/停止,旧协程仍持有 job 视图一路睡到底,醒来再与新 daemon 交错执行同一任务。
460
460
  * 现在切成 ≤60s 的片,每片醒来都问一次 shouldStop(),失去租约即刻返回 'aborted'。
461
461
  * @param {number} ms
462
+ * @param {number} [sliceMs] 单片长度(默认 60s;every/once 的等待传 3000,
463
+ * 把"被接管后的最坏退出延迟"从 ≤60s 压到 ≈5s —— 见 v0.6.6 注释)
462
464
  * @returns {Promise<'ok'|'aborted'>}
463
465
  */
464
- const waitGuarded = async (/** @type {number} */ ms) => {
466
+ const waitGuarded = async (/** @type {number} */ ms, /** @type {number} */ sliceMs = 60000) => {
465
467
  let left = Math.max(0, Number(ms) || 0);
466
468
  while (left > 0) {
467
- const slice = Math.min(left, 60000);
469
+ const slice = Math.min(left, sliceMs);
468
470
  await wait(slice);
469
471
  left -= slice;
470
472
  if (shouldStop()) return 'aborted';
@@ -611,7 +613,13 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id,
611
613
  const now = Date.now();
612
614
  if (cur.kind === 'every') {
613
615
  if (!cur.nextRunAt || cur.nextRunAt > now) {
614
- await wait(Math.min(Math.max((cur.nextRunAt || now) - now, 1000), 60000));
616
+ // v0.6.6(第三方评估 §4.2,主审复现):此前这里是一整段最长 60s 的 sleep,期间不查租约——
617
+ // 实测「篡改租约后旧 daemon 仍存活 >40s」,即"防双 daemon 重复执行"的最坏退出延迟达 60s,
618
+ // 期间旧 daemon 继续与新 daemon 交错操作同一批调度状态(e2e-schedule 的租约用例因此在
619
+ // 标准 Windows 账户上确定性失败:用例只等 20s)。改成 ≤3s 的片、逐片复查租约:
620
+ // 最坏接管延迟 = 监督循环轮询 2s + 一个等待片 3s ≈ 5s,语义不变(睡够才继续)。
621
+ const need = Math.min(Math.max((cur.nextRunAt || now) - now, 1000), 60000);
622
+ if ((await waitGuarded(need, 3000)) === 'aborted') return;
615
623
  continue;
616
624
  }
617
625
  if (!markRunning()) return; // P1-15:标记 running 前复查 paused(被暂停则直接退出)
@@ -629,7 +637,8 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id,
629
637
  if (nextState.status === 'failed') return; // 熔断后退出主循环(与旧行为一致)
630
638
  } else if (cur.kind === 'once') {
631
639
  if (cur.nextRunAt && cur.nextRunAt > now) {
632
- await wait(Math.min(cur.nextRunAt - now, 60000));
640
+ // 同 every 分支(v0.6.6 / 评估 §4.2):once 的等待同样可能长达 60s,也切片刻并逐片查租约
641
+ if ((await waitGuarded(Math.min(cur.nextRunAt - now, 60000), 3000)) === 'aborted') return;
633
642
  continue;
634
643
  }
635
644
  if (!markRunning()) return; // P1-15:标记 running 前复查 paused
@@ -26,9 +26,13 @@ function resolvePath(cwd, p) {
26
26
  function normCmp(/** @type {any} */ p) {
27
27
  return process.platform === 'win32' ? String(p).toLowerCase() : String(p);
28
28
  }
29
- function realPathOrNull(/** @type {any} */ p) {
29
+ /**
30
+ * @param {any} p
31
+ * @param {any} [io] fs 实现(默认 node:fs;测试可注入桩)
32
+ */
33
+ function realPathOrNull(/** @type {any} */ p, /** @type {any} */ io = fs) {
30
34
  try {
31
- return normCmp(fs.realpathSync(p));
35
+ return normCmp(io.realpathSync(p));
32
36
  } catch {
33
37
  return null;
34
38
  }
@@ -44,28 +48,38 @@ function realPathOrNull(/** @type {any} */ p) {
44
48
  // · 真的不存在 → 正常上跳(新建文件的合法路径);
45
49
  // · 存在且是符号链接(悬空)→ 读出链接目标、**递归判定目标**(目标不存在也照样判),
46
50
  // 这样"悬空但指向根内"仍然放行,指向根外/链接成环则拒绝。
47
- function withinRoot(/** @type {any} */ root, /** @type {any} */ target, /** @type {number} */ depth = 0) {
51
+ //
52
+ // v0.6.6(第三方评估 §4.1):**导出 + 允许注入 fs 实现**。理由是这条围栏是安全边界,
53
+ // 而"能不能建出真符号链接"取决于机器(Windows 非管理员/未开开发者模式时建不出;
54
+ // 某些沙箱文件系统甚至会把建链请求降级成普通文件)——只在能建软链的机器上被测到,
55
+ // 等于这条边界在开发机上从没被验证过。有了 `io` 注入口,可以用桩 fs 在任何平台上
56
+ // 覆盖:悬空指向根外(拒)/ 悬空指向根内(放行)/ 成环(有界拒)/ 目标不存在(上跳)。
57
+ /**
58
+ * @param {any} root @param {any} target @param {number} [depth] @param {any} [io]
59
+ * @returns {boolean}
60
+ */
61
+ export function withinRoot(/** @type {any} */ root, /** @type {any} */ target, /** @type {number} */ depth = 0, /** @type {any} */ io = fs) {
48
62
  if (depth > 16) return false; // 链接成环:fail-closed,不再递归
49
- const rr = realPathOrNull(root);
63
+ const rr = realPathOrNull(root, io);
50
64
  if (!rr) return false;
51
65
  let cur = target;
52
66
  for (let i = 0; i < 64; i++) {
53
- const rp = realPathOrNull(cur);
67
+ const rp = realPathOrNull(cur, io);
54
68
  if (rp) return rp === rr || rp.startsWith(rr + path.sep);
55
69
  let lst = null;
56
70
  try {
57
- lst = fs.lstatSync(cur);
71
+ lst = io.lstatSync(cur);
58
72
  } catch {
59
73
  lst = null;
60
74
  }
61
75
  if (lst && lst.isSymbolicLink()) {
62
76
  let dest = null;
63
77
  try {
64
- dest = fs.readlinkSync(cur);
78
+ dest = io.readlinkSync(cur);
65
79
  } catch {
66
80
  return false;
67
81
  }
68
- return withinRoot(root, path.resolve(path.dirname(cur), String(dest)), depth + 1);
82
+ return withinRoot(root, path.resolve(path.dirname(cur), String(dest)), depth + 1, io);
69
83
  }
70
84
  const parent = path.dirname(cur);
71
85
  if (parent === cur) return false;