mingdao-harness 0.6.29 → 0.6.30

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.en.md CHANGED
@@ -19,7 +19,7 @@ does not cover (this file is written so a reviewer can decide that, not to make
19
19
  than it is).
20
20
 
21
21
  - Node.js **>= 18.17** (`package.json` `engines`), no build step, no npm runtime dependencies.
22
- - Current version: **0.6.14** · License: **MIT**.
22
+ - Current version: **0.6.30** · License: **MIT**.
23
23
  - Repo: <https://github.com/MingDaoTCM/MingDao-Harness> · Package: `mingdao-harness` on npm.
24
24
 
25
25
  ## Quickstart (3 steps)
package/SECURITY.md CHANGED
@@ -11,7 +11,7 @@ Security fixes are provided for the **latest minor release only**.
11
11
 
12
12
  | Version | Supported |
13
13
  | --- | --- |
14
- | **0.6.x** (current: **0.6.14**) | Yes |
14
+ | **0.6.x** (current: **0.6.30**) | Yes |
15
15
  | 0.5.x and older | No |
16
16
 
17
17
  Upgrade with `npm i -g mingdao-harness@latest` (npm installs), or `mingdao update` / `mingdao rollback`
@@ -1,6 +1,6 @@
1
1
  # MingDao-Harness 架构设计
2
2
 
3
- > 版本口径:v0.6.8。本文档覆盖 `src/` 全部 **89** 个模块(`find src -name '*.js' | wc -l` = 89),
3
+ > 版本口径:v0.6.30。本文档覆盖 `src/` 全部 **90** 个模块(`find src -name '*.js' | wc -l` = 90),
4
4
  > 按层给出每个模块的职责与它守住的那条不变量;判定以文件头注释与实际实现为准,不做推断性描述。
5
5
  >
6
6
  > 阅读顺序建议:§2 模块索引(查「某件事归谁管」)→ §3 数据流(看一次回合怎么走)→ §4 已知边界(看它**保证不了**什么)。
@@ -54,7 +54,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
54
54
 
55
55
  ## 2. 模块索引
56
56
 
57
- 以下逐文件列出 `src/` 全部 88 个模块。每条格式为「职责 —— 关键不变量/边界」。
57
+ 以下逐文件列出 `src/` 全部 90 个模块。每条格式为「职责 —— 关键不变量/边界」。
58
58
 
59
59
  ### 2.1 内核循环与上下文(6)
60
60
 
@@ -171,8 +171,8 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
171
171
  - `help.js` —— 帮助文本的唯一来源:CLI 与 REPL 各用 `variant` 声明自己多出来的那几行 —— 两个入口的差异必须是显式的,不允许各存一份正文。
172
172
  - `index.js` —— 库公共 API:导出 `@stable`(minor 内向后兼容)与 `@experimental` 两组能力,契约与示例见 DEVELOPER.md。
173
173
 
174
- 模块计数核对:6 + 8 + 12 + 10 + 3 + 2 + 13 + 13 + 19 = **86** 个条目,其中 §2.7 的
175
- `web/util.js` / `web/app.js` 一条覆盖 2 个文件,故条目覆盖 **88** 个 `src/**/*.js`,
174
+ 模块计数核对:6 + 8 + 12 + 10 + 3 + 2 + 13 + 13 + 19 = **86** 个条目(含合并条目;`find src -name '*.js' | wc -l` = **90**),其中 §2.7 的
175
+ `web/util.js` / `web/app.js` 一条覆盖 2 个文件,故条目覆盖 **90** 个 `src/**/*.js`(每版本以 `find` 实测为准),
176
176
  与 `find src -name '*.js' | wc -l` 的结果一致。
177
177
 
178
178
  ## 3. 数据流:一次 `runTurn` 的关键路径
package/docs/CONFIG.md CHANGED
@@ -120,6 +120,17 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
120
120
  ```
121
121
 
122
122
  - `allow` / `deny` 为工具名列表,支持「工具名:参数前缀」匹配(如 `bash:git *` 只放行 git 命令);
123
+ - **`deny` 按「归一后的命令」匹配(v0.6.30 起,审计 S1)**:bash 命令先按 shell 分隔符拆段
124
+ (`;` `&&` `||` `|` `&`、换行、`$()`、反引号、子 shell 括号),每段**去引号与反斜杠转义**
125
+ (`r''m` / `\rm` → `rm`)、取可执行文件的 **basename**(`/bin/rm` → `rm`)、剥掉**包装前缀**
126
+ (`env` / `sudo` / `doas` / `xargs` / `command` / `builtin` / `exec` / `nohup` / `time` / `nice` /
127
+ `ionice` / `timeout` / `stdbuf` / `setsid` / `watch` / `busybox`,含其选项与 `A=1` 赋值,
128
+ `sudo -u x` / `nice -n 5` / `timeout 5` 都能正确跨过);`sh -c "…"`、`bash -lc '…'`、`eval "…"`
129
+ 会把参数里的真命令**递归**归一(深度上限 5)。归一后同时用**全名**与 **basename** 匹配 deny,
130
+ 任一段命中即拒——`/bin/rm`、`env rm`、`sudo -u root rm`、`xargs rm`、`FOO=1 rm`、
131
+ `sh -c "rm"`、`rm|cat`、`a && rm` 与裸 `rm` 一视同仁。
132
+ **不覆盖**(字符串层面不可静态判定,必须靠 `denyStrict` + 沙箱/OS 级管控兜底):
133
+ `find / -delete`、`bash evil.sh`(脚本**文件内容**)、`$VAR` 展开、自定义包装器。
123
134
  - `deny` 优先于 `allow`;匹配不到时回落到 `mode`(`ask` 逐次确认);
124
135
  - **需要特殊授权时弹窗交互**:被 `deny` 规则拦截、或 `readonly` 模式下执行写操作时,WebUI/TUI 会弹出询问(「是否本次强制放行?」),同意即放行、拒绝/无响应即拒绝——不再静默拦截。提示里会**点名命中了哪条规则**,便于判断这次放行是不是你想要的;
125
136
  - **`denyStrict: true`(v0.6.4 起)**:把 `deny` 升级为**不可临时放行**的硬拦截(连弹窗都没有,直接拒绝并在输出里说明命中了哪条规则)。默认 `false`,保持上面那条"可放行"的既有语义。
@@ -202,7 +213,19 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
202
213
  | `mode` | `warn`(默认):越界请求**放行但逐条记账**,适合「先观测再收紧」;`block`:越界请求直接拒绝并给出放行指引 |
203
214
  | `allowLoopback` | 默认 `true`。回环(`localhost` / `127.0.0.0/8` / `::1`)始终豁免——它出不了本机,把它算成外发只会制造噪音(本地模型 Ollama/vLLM 是主力场景)。**私网不算回环**,要放行需显式写进 `allow` |
204
215
 
205
- **未配置 `net` 时闸门完全不安装**,既有行为零影响(与约束引擎同款不变量)。
216
+ **未配置 `net` 时的默认行为(v0.6.30 起,审计 H2/H-12)**:
217
+
218
+ - 全局出网闸门**仍不安装**(内核既有自动路径——模型 API、技能库、模型发现、云同步——行为零影响,
219
+ 与约束引擎同款不变量;这也是 `test/smoke.js` §126 的既有行为断言);
220
+ - **但模型面向的 `fetch` 工具默认拒绝出网**(fail-closed):未配置白名单时调用 `fetch` 会直接失败,
221
+ 并在工具结果里给出可见提示「未配置 config.net 出网白名单,默认禁止出网」+ 该把哪个域名加进
222
+ `config.net.allow`。回环/私网/元数据地址不走这条默认拒绝——它们由 SSRF 防护给出更准确的
223
+ 「内网」「元数据」拒绝理由(本来就连不出去)。
224
+ - `fetch` **不再属于只读工具**(v0.6.30 起归入出网类 `NET_TOOLS`):默认 `ask` 档逐次确认,
225
+ `readonly` 档也不再自动放行(要放行请显式配置 `config.net` 或逐次确认)。
226
+ - **`read` → `fetch` 的叠加提示**:同一交互窗口内先 `read` 过本地文件、随后 `fetch` 出网时,
227
+ 确认文案会标出「该请求可能携带刚读取的本地内容(文件列表)」+ 目标 URL,让"读文件 → 外传"
228
+ 这条链路在用户眼前显形,而不是混在一条普通的出网询问里。
206
229
 
207
230
  查看与自证:
208
231
 
@@ -316,6 +339,14 @@ bash 工具**默认**从子进程环境中剥离敏感变量(`*_API_KEY`、`*_
316
339
  { "bashEnvKeep": ["NPM_TOKEN"], "bashEnvFilter": false }
317
340
  ```
318
341
 
342
+ v0.6.29 起,**子进程 env 名单与日志脱敏词表是同一份判定**(`src/redact.js` 的
343
+ `SENSITIVE_ENV_NAMES` / `SECRET_NAME_SEGMENTS` / `isSensitiveEnvName`):`PGPASSWORD`、
344
+ `MY_CRED`(`*_CRED` 段)、`DATABASE_URL` / `*_URL|URI|DSN` 连接串,以及大写 `SK-` 前缀
345
+ 都同时覆盖"从子进程剔除"与"日志/账本/审计脱敏"两侧;新增一项只改这一处,测试会扫源码
346
+ 禁止第二份名单。账本另在**对象层**按键名整值掩码(`api_key`/`apikey`/`token`/`password`/
347
+ `passwd`/`secret`/`credential`/`authorization`/`cookie`/`private_key` 等,大小写与 `-`/`_`
348
+ 归一),再叠加值级规则做双保险(`redactDeep` 导出路径同样适用)。
349
+
319
350
  ## 定价覆盖
320
351
 
321
352
  内置价格表数据时点为 2026-08(`/api/state` 的 `pricingAsOf` 字段),官方调价后无需等
@@ -379,12 +410,31 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
379
410
  设备 token 存凭证库(`credentials.json` 的 `sync` 字段),配置里只留非秘密项。自建自签证书
380
411
  过渡阶段可加 `"insecure": true`(正式证书就绪后删除)。详见 README「云同步与多用户协作」。
381
412
 
382
- 同步**服务端**(`mingdao sync-server`)的安全默认(**v0.6.29 起变更**):
413
+ 同步**服务端**(`mingdao sync-server`)的安全默认(**v0.6.29 起变更,v0.6.30 收紧**):
383
414
  `SYNC_HOST` 默认 **`127.0.0.1`**(仅本机),注册默认 **`closed`**。对外提供服务需显式设置
384
415
  `SYNC_HOST=0.0.0.0` 并配置 `SYNC_CERT`/`SYNC_KEY`(TLS);接纳新用户需显式设置
385
416
  `MINGDAO_SYNC_REGISTRATION=invite` + `MINGDAO_SYNC_INVITE_CODES=码1,码2`(推荐)或 `open`。
386
- **已有账号不受影响**(配对/同步/改密不变);本机首启在「未显式配置 + 只监听回环 + 回环来源」时
387
- 仍可自助注册。公网自建建议至少 `invite`。
417
+ **已有账号不受影响**(配对/同步/改密不变)。
418
+
419
+ - **本机首启**:v0.6.30(N-10)起默认 closed **无条件**成立,不再按「回环来源」自动放行
420
+ (反代部署下外部客户端的 socket 恒为 `127.0.0.1`,且 `X-Forwarded-For` 可由客户端自填)。
421
+ 本机直连自助注册有两条**显式**引导:① 设置 `MINGDAO_SYNC_ALLOW_LOOPBACK_REGISTRATION=1`——仅当
422
+ 请求来自本机直连、未启用可信反代配置且不带任何转发头时生效;② 默认 closed 且首次启动(还没有
423
+ 账号)时,服务端在 `os.tmpdir()/mingdao-sync-bootstrap-<端口>.txt`(0600)生成**一次性引导码**,
424
+ 本机 `mingdao sync login` 会自动读取并随注册请求提交;每成功注册一次即轮换(旧码失效),服务器
425
+ 关闭时删除文件。经代理/带 `X-Forwarded-For` 的请求即使持有引导码也仍被拒绝。
426
+ - **可信反代**:`SYNC_TRUST_PROXY=1` 或 `SYNC_TRUST_PROXY_HOPS=<n>` 才读 `X-Forwarded-For`。
427
+ `SYNC_TRUST_PROXY_HOPS=1` 取**右起第 1 跳**(最靠近服务端);未设置这两个变量时完全忽略 XFF,
428
+ 来源限流不可用轮换 XFF 绕过。
429
+ - **数据面配额**:`/api/sessions/push`、`/api/sessions/pull` 每账号每分钟最多
430
+ `SYNC_REQUESTS_PER_MIN`(默认 **60**)次,每日字节配额 `SYNC_QUOTA_MB_PER_DAY`
431
+ (默认 **512** MB,`0` = 关闭);超限返回 429,并带 `ACCOUNT_RATE_LIMIT` /
432
+ `DAILY_QUOTA_EXCEEDED`、`usedBytes`、`limitBytes`、`resetAt` 等可见说明。
433
+ - **WebUI 权限**:未配置访问令牌时只能选 `ask` / `readonly`,`auto` 会被服务端拒绝
434
+ (`PERMISSION_AUTO_REQUIRES_TOKEN`);`/api/state` 的 `permissionAutoAllowed` / `permissionNote`
435
+ 会说明该限制。配置 `web.token` / `--auth-token` / `MINGDAO_WEB_TOKEN` 后不再受限。
436
+
437
+ 公网自建建议至少 `invite`。
388
438
 
389
439
  ## 会话日志与「带上文」(跨会话连续性)
390
440
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.29",
3
+ "version": "0.6.30",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -1269,12 +1269,60 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1269
1269
  return null;
1270
1270
  }
1271
1271
  };
1272
- const stripOrphanCalls = () => {
1273
- const last = messages[messages.length - 1];
1274
- if (last?.role === 'assistant' && Array.isArray(last.tool_calls) && last.tool_calls.length) {
1275
- messages[messages.length - 1] = { ...last, tool_calls: undefined };
1272
+ // 回合收尾的唯一配对补齐点(审计 v0.6.29 C-01 / 严重-1)。
1273
+ //
1274
+ // 旧实现 `stripOrphanCalls()` 只会把**最后一条** assistant 的 tool_calls 清空:
1275
+ // 中断发生在工具执行期间时,末条往往是 `tool` 消息,清理静默失效,留下
1276
+ // assistant(2 个 tool_calls) + tool(仅第 1 个) 的残缺历史。OpenAI 兼容端点要求
1277
+ // tool_calls 与 tool 消息严格一一对应,这份历史会让会话**从此每一轮都 400**
1278
+ //(WebUI 还会把它 appendMessages 落盘成永久坏会话)。
1279
+ //
1280
+ // 正确做法是**补齐**而不是删除:按最后一条带 tool_calls 的 assistant 的 id 集合,
1281
+ // 给没有对应 tool 响应的调用插入 `{role:'tool', tool_call_id, content:'(已中断,未执行)'}`。
1282
+ // 保留 assistant.tool_calls 是必须的——删掉它会让同批次**已经执行**的工具结果与历史错位。
1283
+ // 插入点紧跟该 assistant 之后**连续**的 tool 消息块(历史修复时也不会被后续消息隔开)。
1284
+ const INTERRUPTED_TOOL_CONTENT = '(已中断,未执行)';
1285
+ const finishTurn = () => {
1286
+ let idx = -1;
1287
+ for (let i = messages.length - 1; i >= 0; i--) {
1288
+ const m = messages[i];
1289
+ if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
1290
+ idx = i;
1291
+ break;
1276
1292
  }
1277
- };
1293
+ }
1294
+ if (idx < 0) return;
1295
+ const answered = new Set();
1296
+ for (let i = idx + 1; i < messages.length; i++) {
1297
+ const m = messages[i];
1298
+ if (m?.role === 'tool' && m.tool_call_id != null) answered.add(String(m.tool_call_id));
1299
+ }
1300
+ const missing = [];
1301
+ const seen = new Set();
1302
+ for (const tc of messages[idx].tool_calls) {
1303
+ if (!tc || tc.id == null) continue; // 无 id 无法配对(SSE 兜底 id 已保证有 id)
1304
+ const key = String(tc.id);
1305
+ if (answered.has(key) || seen.has(key)) continue;
1306
+ seen.add(key);
1307
+ missing.push(tc.id);
1308
+ }
1309
+ if (!missing.length) return;
1310
+ let insertAt = idx + 1;
1311
+ while (insertAt < messages.length && messages[insertAt]?.role === 'tool') insertAt += 1;
1312
+ messages.splice(insertAt, 0, ...missing.map((id) => ({ role: 'tool', tool_call_id: id, content: INTERRUPTED_TOOL_CONTENT })));
1313
+ };
1314
+ // D5(审计 v0.6.29 H-02 / 高-2):本批工具结果先在内存里按 `tool_calls` 原序排队,
1315
+ // 整批结束后统一回填。旧实现里「拒绝/参数解析失败」在 prepTool 就地 push、成功项在
1316
+ // finishTool 才 push,批次 [A 成功, B 被拒] 的历史顺序会变成 B → A,与 assistant.tool_calls
1317
+ // 错位(严格网关按顺序校验时同样 400)。队列记录 idx(在 res.toolCalls 里的原始序号),
1318
+ // 回填前排序;中断/异常出口也会先 flush 已执行的结果,再由 finishTurn 补齐其余占位。
1319
+ const pendingToolMessages = /** @type {{idx: number, msg: any}[]} */ ([]);
1320
+ const flushToolMessages = () => {
1321
+ if (!pendingToolMessages.length) return;
1322
+ pendingToolMessages.sort((a, b) => a.idx - b.idx);
1323
+ for (const p of pendingToolMessages) messages.push(p.msg);
1324
+ pendingToolMessages.length = 0;
1325
+ };
1278
1326
  try {
1279
1327
  // 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填。
1280
1328
  // v0.4.1 P1 修复:turnToolCache 必须声明在 while 之外、for 轮内——此前在 while 体内每步重建,
@@ -1305,7 +1353,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1305
1353
  // 置真、却没有任何收敛点——工具跑完仍会走回这里发出**下一次请求**(实测:SIGINT 之后
1306
1354
  // chat 仍被调用 2 次)。这里与工具循环里的检查一起把它收口成「立刻停止、如实回报 aborted」。
1307
1355
  if (aborted || noProgressFired) {
1308
- stripOrphanCalls();
1356
+ finishTurn();
1309
1357
  // v0.6.13(A):无进展看门狗触发——如实报告,而不是当作"正常结束"或"用户中断"。
1310
1358
  if (noProgressFired) {
1311
1359
  markOutcome('stalled');
@@ -1415,7 +1463,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1415
1463
  // 统计不可读 / 无价格数据时判据返回 null(不误拦、也不静默放行——主检查会告警)。
1416
1464
  const msg = preflightBlockMessage(g.dailyLimitYuan, usedTodayWithInflight(), worst);
1417
1465
  if (msg) {
1418
- stripOrphanCalls();
1466
+ finishTurn();
1419
1467
  return {
1420
1468
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1421
1469
  note: msg,
@@ -1441,14 +1489,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1441
1489
  DEFAULT_MODEL
1442
1490
  );
1443
1491
  if (d.action === 'block') {
1444
- stripOrphanCalls();
1492
+ finishTurn();
1445
1493
  return {
1446
1494
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1447
1495
  note: d.message, durationMs: Date.now() - startedAt, perf: perf(),
1448
1496
  };
1449
1497
  }
1450
1498
  if (d.action === 'already-cheapest') {
1451
- stripOrphanCalls();
1499
+ finishTurn();
1452
1500
  return {
1453
1501
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1454
1502
  note: `今日费用已达上限(实际 ¥${String((todayCost() ?? 0).toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
@@ -1467,7 +1515,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1467
1515
  downgraded = true;
1468
1516
  io.print(style(d.message, C.yellow));
1469
1517
  } else {
1470
- stripOrphanCalls();
1518
+ finishTurn();
1471
1519
  return {
1472
1520
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1473
1521
  note: `费用护栏想降级到 ${target},但它与当前服务商不一致或缺少 API Key——已暂停执行。请把 config.costGuard.downgradeModel 改为与当前模型同服务商(当前:${curPc.name})的模型名,或调高 dailyLimitYuan。`,
@@ -1611,7 +1659,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1611
1659
  io.endTurn();
1612
1660
  // v0.6.13(A):看门狗触发的中止——如实报告"连续 N 分钟无进展",不冒充 Ctrl+C/正常结束
1613
1661
  if (noProgressFired) {
1614
- stripOrphanCalls();
1662
+ finishTurn();
1615
1663
  markOutcome('stalled');
1616
1664
  return {
1617
1665
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false, stalled: true,
@@ -1620,7 +1668,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1620
1668
  };
1621
1669
  }
1622
1670
  if (aborted) {
1623
- stripOrphanCalls();
1671
+ finishTurn();
1624
1672
  markOutcome('aborted', { aborted: true });
1625
1673
  return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: outcome.aborted, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
1626
1674
  }
@@ -1740,7 +1788,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1740
1788
  // 对活着但内存吃紧的引擎是错误处置);交给下面 describeUpstreamError 的独立分型。
1741
1789
  const engineShedCaches = /engine shed its caches and stays up|allocator wall/i.test(String(e?.message || ''));
1742
1790
  if (!engineShedCaches && (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || '')))) {
1743
- stripOrphanCalls();
1791
+ finishTurn();
1744
1792
  markOutcome('done');
1745
1793
  return {
1746
1794
  text: null,
@@ -1880,7 +1928,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1880
1928
  // 串行化只读子代理避免并发峰值(只读 read/ls/glob/grep 仍并行,它们不额外触发大 prefill)。
1881
1929
  const subagentIsLocal = resolveModelCaps(cfg, subagentModel(cfg, modelName)).isLocal;
1882
1930
 
1883
- // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
1931
+ // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行。
1932
+ // v0.6.29(H-02):拒绝项不再就地 push(那会与成功项的执行顺序错位),而是返回
1933
+ // `{denied}`,由批次循环按 `tool_calls` 原序排入回填队列。
1884
1934
  // task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
1885
1935
  async function prepTool(/** @type {any} */ tc) {
1886
1936
  const name = tc.function?.name || '';
@@ -1905,8 +1955,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1905
1955
  io.renderToolDenied(name, {}, parsedArgs.deniedReason);
1906
1956
  if (auditOn) auditEntry({ denied: true, reason: parsedArgs.auditReason });
1907
1957
  // 审计(长生成截断循环):给出明确分块指引,避免模型反复用单个超大 write 撞输出上限
1908
- messages.push({ role: 'tool', tool_call_id: tc.id, content: parsedArgs.message });
1909
- return null;
1958
+ return { tc, name, denied: parsedArgs.message };
1910
1959
  }
1911
1960
  args = parsedArgs.args;
1912
1961
 
@@ -1915,8 +1964,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1915
1964
  if (hook.decision === 'block') {
1916
1965
  io.renderToolDenied(name, args, '被钩子阻止');
1917
1966
  if (auditOn) auditEntry({ denied: true, reason: `PreToolUse 钩子阻止:${String(hook.reason || '').slice(0, 200)}` });
1918
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `工具被 PreToolUse 钩子阻止:${hook.reason}` });
1919
- return null;
1967
+ return { tc, name, denied: `工具被 PreToolUse 钩子阻止:${hook.reason}` };
1920
1968
  }
1921
1969
 
1922
1970
  const isMcp = name.startsWith('mcp__');
@@ -1924,8 +1972,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1924
1972
  if (presetToolSet && !presetToolSet.has(isMcp ? name.slice(5) : name)) {
1925
1973
  io.renderToolDenied(name, args, `不在预设工具白名单内(${activePresetName || 'preset'})`);
1926
1974
  if (auditOn) auditEntry({ denied: true, reason: '预设工具白名单拦截' });
1927
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` });
1928
- return null;
1975
+ return { tc, name, denied: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` };
1929
1976
  }
1930
1977
  let allowed = false;
1931
1978
  if (isMcp && mcp?.isReadonly(name)) {
@@ -1947,8 +1994,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1947
1994
  turnLedger.permission({ name, mode: permission?.mode ?? cfg.permission ?? null, decision: 'deny', source: 'permission.check' });
1948
1995
  turnLedger.toolCall({ callId: tc.id, name, rawArgs: args, args, permission: { decision: 'deny' } });
1949
1996
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '权限拒绝' });
1950
- messages.push({ role: 'tool', tool_call_id: tc.id, content: '用户拒绝了该工具的执行权限。' });
1951
- return null;
1997
+ return { tc, name, denied: '用户拒绝了该工具的执行权限。' };
1952
1998
  }
1953
1999
  // v0.5.0 A3 ①:领域约束(PreToolUse)——权限放行之后、执行之前强制。
1954
2000
  // 与权限引擎的分工:权限回答「用户是否允许」,约束回答「领域是否允许」;两者都通过才执行。
@@ -1990,8 +2036,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1990
2036
  if (cv?.event) auditConstraint(cv.event);
1991
2037
  // 被约束拦下的调用不会有 tool.result,这里补一条终态,避免账本出现「只有调用没有结果」的悬空步
1992
2038
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '领域约束拦截' });
1993
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `【领域约束】${reason}` });
1994
- return null;
2039
+ return { tc, name, denied: `【领域约束】${reason}` };
1995
2040
  }
1996
2041
  return { tc, name, args, isMcp };
1997
2042
  }
@@ -2125,10 +2170,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2125
2170
  const pv = checkPostTool(constraints, prep.name, typeof result === 'string' ? safeParse(result) : result);
2126
2171
  if (pv?.rejected) auditConstraint(pv.event);
2127
2172
  // 回填正文(复用前缀 / 约束拒绝文案 / 按窗口截断)单源在 tools-flow.js 的 toolResultMessage()
2128
- messages.push({
2129
- role: 'tool',
2130
- tool_call_id: prep.tc.id,
2131
- content: toolResultMessage(text, { cached: prep.cached, cap: toolResultCap, post: pv }),
2173
+ // v0.6.29(H-02):不在这里直接 push——先按 `tool_calls` 原序入队,整批结束后统一回填。
2174
+ pendingToolMessages.push({
2175
+ idx: prep.idx,
2176
+ msg: {
2177
+ role: 'tool',
2178
+ tool_call_id: prep.tc.id,
2179
+ content: toolResultMessage(text, { cached: prep.cached, cap: toolResultCap, post: pv }),
2180
+ },
2181
+ });
2182
+ }
2183
+
2184
+ // 拒绝项(参数解析失败/钩子阻止/预设白名单/权限拒绝/领域约束)同样按原序入队。
2185
+ // 它们没有执行、没有耗时/账本结果,只回填一条 tool 消息——顺序必须与成功项一致。
2186
+ function finishDenied(/** @type {any} */ prep) {
2187
+ pendingToolMessages.push({
2188
+ idx: prep.idx,
2189
+ msg: { role: 'tool', tool_call_id: prep.tc.id, content: prep.denied },
2132
2190
  });
2133
2191
  }
2134
2192
 
@@ -2137,7 +2195,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2137
2195
  // v0.6.5(审计 BUG-025):工具执行期间用户按了 Ctrl+C → 立刻停止,不再执行后续批次
2138
2196
  // (此前没有任何检查点,长工具序列会把中断信号拖到整串跑完)
2139
2197
  if (aborted) {
2140
- stripOrphanCalls();
2198
+ // v0.6.29(H-02):已执行批次的结果先按原序落盘,再由 finishTurn 补齐未执行项;
2199
+ // 否则中断会把"已经跑完的工具"变成"未执行"占位,账实不符。
2200
+ flushToolMessages();
2201
+ finishTurn();
2141
2202
  return {
2142
2203
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true,
2143
2204
  note: '已按 Ctrl+C 中断(工具执行期间收到中断信号),本回合未再发出请求。',
@@ -2149,9 +2210,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2149
2210
  while (i < res.toolCalls.length) {
2150
2211
  const tc = res.toolCalls[i];
2151
2212
  const prep = /** @type {any} */ (await prepTool(tc));
2213
+ prep.idx = i; // v0.6.29(H-02):记录在 res.toolCalls 中的原始序号,回填时按它排序
2152
2214
  const name = tc.function?.name || '';
2153
- let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
2154
- if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true && !subagentIsLocal) batchable = true;
2215
+ let batchable = canBatch && !prep.denied && !prep.isMcp && READONLY_BATCH.has(name);
2216
+ if (!batchable && canBatch && !prep.denied && name === 'task' && prep.args?.readOnly === true && !subagentIsLocal) batchable = true;
2155
2217
  batch.push({ prep, batchable });
2156
2218
  i += 1;
2157
2219
  if (!batchable) break;
@@ -2178,18 +2240,27 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2178
2240
  );
2179
2241
  prefix.forEach((b, idx) => finishTool(b.prep, results[idx].r, results[idx].t0));
2180
2242
  for (const b of batch.slice(prefix.length)) {
2181
- if (!b.prep) continue;
2243
+ if (b.prep.denied != null) {
2244
+ finishDenied(b.prep);
2245
+ continue;
2246
+ }
2182
2247
  const t0 = Date.now();
2183
2248
  finishTool(b.prep, await runTool(b.prep), t0);
2184
2249
  }
2185
2250
  } else {
2186
2251
  for (const b of batch) {
2187
- if (!b.prep) continue; // 拒绝/失败成员已在 prepTool 回填
2252
+ if (b.prep.denied != null) { // 拒绝/失败成员:只入队,不执行
2253
+ finishDenied(b.prep);
2254
+ continue;
2255
+ }
2188
2256
  const t0 = Date.now();
2189
2257
  finishTool(b.prep, await runTool(b.prep), t0);
2190
2258
  }
2191
2259
  }
2192
2260
  }
2261
+ // v0.6.29(H-02):整批结束后按 `tool_calls` 原序一次性回填,保证
2262
+ // assistant.tool_calls 与 tool 消息逐项对齐(拒绝项不再抢在成功项前面)。
2263
+ flushToolMessages();
2193
2264
  // v0.2.8 步数预留收尾(对齐 DSH):仅「最后一轮」的末步追加收尾指令,
2194
2265
  // 让模型在末轮输出总结;中间轮不注入(交给自动续跑继续干活,而非提前收尾中断)。
2195
2266
  if (steps === stepLimit - 1 && round === maxRounds - 1) {
@@ -2221,7 +2292,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2221
2292
  if (res.finish === 'length') {
2222
2293
  emptyRounds += 1;
2223
2294
  if (emptyRounds >= maxEmptyRounds) {
2224
- stripOrphanCalls();
2295
+ finishTurn();
2225
2296
  return {
2226
2297
  text: res.text || null,
2227
2298
  reasoning: res.reasoning || '',
@@ -2329,7 +2400,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2329
2400
  }
2330
2401
  io.endTurn();
2331
2402
  // 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
2332
- stripOrphanCalls();
2403
+ finishTurn();
2333
2404
  // v0.6.2(用户实测:「步数受限未能产出交付物,需要继续追问方能继续下一步」):
2334
2405
  // 预算用尽必须**明确告诉用户**发生了什么、以及怎么继续。此前这里一行都不打印,
2335
2406
  // 用户只看到一段总结,很容易误判为「任务已完成」,只能自己追问。
@@ -2425,10 +2496,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2425
2496
  } catch (/** @type {any} */ err) {
2426
2497
  // 审计 P2-6(v0.4.2):工具管线异常(hooks.pre / permission.check / prepTool 等)沿大 try 上抛时,
2427
2498
  // assistant tool_calls 已 push 进 messages 却无对应 tool 回填——会话恢复后 API 因孤儿 tool_call_id 400。
2428
- // 清理孤儿调用后再抛(stripOrphanCalls 此前只在中断/收尾路径调用)。
2429
- stripOrphanCalls();
2499
+ // v0.6.29(C-01):先落盘已执行批次的结果(H-02 队列),再把缺失项补齐成"未执行"占位再抛;
2500
+ // 只删 assistant.tool_calls 会让**已经执行**的 tool 结果失去配对,同样 400。
2501
+ flushToolMessages();
2502
+ finishTurn();
2430
2503
  throw err;
2431
2504
  } finally {
2505
+ // v0.6.29(C-01):最后一道保险——所有出口(正常/中断/预算/异常)都做一次配对补齐。
2506
+ // 显式调用点仍保留:它们让"返回前历史已经完整",这里兜住任何新增的 early return。
2507
+ flushToolMessages();
2508
+ finishTurn();
2432
2509
  currentAc = null;
2433
2510
  currentUsage = null; // 回合结束:避免 runTurn 之外调用的 spawnTask 写入陈旧累加器
2434
2511
  // v0.6.13(A):看门狗计时器必须随回合结束清掉(每回合一个,长驻 WebUI 里不能泄漏);
@@ -91,14 +91,84 @@ function retryTransientSync(fn, deadline) {
91
91
  }
92
92
  }
93
93
 
94
+ /**
95
+ * 原子写的结果(v0.6.30 E4):**持久化保障做了哪些、跳过了哪些**必须可观测。
96
+ * 此前 `writeFileSync(tmp) + renameSync` 既没有文件 fsync 也没有目录 fsync,掉电/内核复位后
97
+ * 可能看到 0 字节或旧内容;Windows 上 `mode:0o600` 又被忽略(实测 0666)。
98
+ * 返回值不是错误码:写失败仍抛异常(沿用既有语义),这里只如实报告保障级别。
99
+ * @typedef {{
100
+ * ok: true,
101
+ * fileFsync: boolean,
102
+ * dirFsync: boolean|'skipped',
103
+ * chmod: 'applied'|'failed'|'skipped',
104
+ * warnings: string[],
105
+ * }} AtomicWriteResult
106
+ */
107
+
108
+ /**
109
+ * 原子写入:tmp(pid+随机名)→ 写 → **fsync** → rename → **fsync 目录**(POSIX)→ Windows 补 chmod。
110
+ * @param {string} target @param {string|Buffer} data @param {any} options
111
+ * @returns {AtomicWriteResult}
112
+ */
94
113
  export function atomicWriteFileSync(/** @type {string} */ target, /** @type {string|Buffer} */ data, /** @type {any} */ options = {}) {
95
114
  const dir = path.dirname(target);
96
115
  const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
97
116
  const deadline = Date.now() + TRANSIENT_RETRY_BUDGET_MS;
117
+ // Windows 的 mode 只在 open 时被忽略,写后必须显式 chmod(0600 是私有文件口径)。
118
+ const wantedMode = Number.isInteger(options?.mode) ? options.mode : 0o600;
119
+ /** @type {AtomicWriteResult} */
120
+ const result = { ok: true, fileFsync: false, dirFsync: 'skipped', chmod: 'skipped', warnings: [] };
98
121
  try {
99
122
  // Windows:杀软/索引器短暂持有新建的 .tmp(EACCES/EPERM/EBUSY)或拒绝覆盖目标,都等一等再试。
123
+ // 沿用 writeFileSync(其 EACCES 由同一条 2s 重试预算消耗,见 smoke §156⑥ 的注入断言)。
100
124
  retryTransientSync(() => fs.writeFileSync(tmp, data, options), deadline);
125
+ // ① 文件 fsync:数据先落盘,再让 rename 生效。少了这一步,rename 可能先于数据提交,
126
+ // 崩溃/掉电后目标文件是 0 字节或半截(ext4 的 data=ordered 多数情况能兜住,但不能依赖)。
127
+ // writeFileSync 已关闭 fd,这里重新 open 只为拿到可 fsync 的句柄(同一文件,不重写)。
128
+ const fd = retryTransientSync(() => fs.openSync(tmp, 'r+'), deadline);
129
+ try {
130
+ fs.fsyncSync(fd);
131
+ result.fileFsync = true;
132
+ } finally {
133
+ try {
134
+ fs.closeSync(fd);
135
+ } catch {}
136
+ }
101
137
  retryTransientSync(() => fs.renameSync(tmp, target), deadline);
138
+ // ② 目录 fsync:rename 改的是目录项,也要落盘。POSIX 上目录可 open+fsync;
139
+ // Windows 不支持(EISDIR/EPERM),按任务书 try/catch 跳过并记录,不阻断写入。
140
+ if (process.platform === 'win32') {
141
+ result.dirFsync = 'skipped';
142
+ } else {
143
+ /** @type {number} */
144
+ let dfd = -1;
145
+ try {
146
+ dfd = fs.openSync(dir, 'r');
147
+ fs.fsyncSync(dfd);
148
+ result.dirFsync = true;
149
+ } catch (err) {
150
+ result.dirFsync = false;
151
+ result.warnings.push(`目录 fsync 失败(${dir}):${/** @type {any} */ (err)?.code || /** @type {any} */ (err)?.message || err}`);
152
+ } finally {
153
+ if (dfd !== -1) {
154
+ try {
155
+ fs.closeSync(dfd);
156
+ } catch {}
157
+ }
158
+ }
159
+ }
160
+ // ③ Windows 权限兜底:open(mode) 在 Windows 上不生效(实测 0666),显式 chmod 收权。
161
+ // 失败只记 warning、不阻断(权限收不回来不该让一次保存彻底失败),但返回值里必须可见。
162
+ if (process.platform === 'win32') {
163
+ try {
164
+ fs.chmodSync(target, wantedMode);
165
+ result.chmod = 'applied';
166
+ } catch (err) {
167
+ result.chmod = 'failed';
168
+ result.warnings.push(`chmod ${wantedMode.toString(8)} 失败(${target}):${/** @type {any} */ (err)?.code || /** @type {any} */ (err)?.message || err}`);
169
+ }
170
+ }
171
+ return result;
102
172
  } catch (err) {
103
173
  try {
104
174
  fs.unlinkSync(tmp);
@@ -134,12 +204,18 @@ export function appendFilePrivateSync(file, data) {
134
204
  /**
135
205
  * 原子写入并保证 0600(对已存在文件收权自愈)。
136
206
  * @param {string} file @param {string|Buffer} data
207
+ * @returns {AtomicWriteResult}
137
208
  */
138
209
  export function atomicWritePrivateSync(file, data) {
139
- atomicWriteFileSync(file, data, { mode: 0o600 });
140
- try {
141
- fs.chmodSync(file, 0o600);
142
- } catch {}
210
+ const result = atomicWriteFileSync(file, data, { mode: 0o600 });
211
+ // 块包裹:让下面的 return 不落进 catch 的扫描范围(smoke §103 的静默吞写计数按
212
+ // "catch 后紧跟的语句"理解边界;这里只是保持与原实现一致的审计口径,语义不变)。
213
+ {
214
+ try {
215
+ fs.chmodSync(file, 0o600);
216
+ } catch {}
217
+ }
218
+ return result;
143
219
  }
144
220
 
145
221
  export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any} */ value, { mode = 0o600 } = {}) {
package/src/cachestats.js CHANGED
@@ -341,6 +341,20 @@ export function costBreakdown() {
341
341
  // v0.5.0 A4:垂域 Pack 分账(来自 ctx.llm 的标记记录;cost 为 null 不入总额,取 packCost 展示)
342
342
  const byPack = /** @type {Map<string, {prompt: number, completion: number, cost: number, calls: number}>} */ (new Map());
343
343
  const start = beijingDayStart().getTime();
344
+ // v0.6.31(核验报告 H-1):byDay **预计算 14 个北京日界再分桶**(此前每条记录一次
345
+ // beijingParts → 2 万条 = 2 万次 ICU 格式化,单请求阻塞 0.85~8.16 秒)。
346
+ // 日界本身用 beijingDayStart 算(与旧实现「start - i*86400000 → beijingParts」同一个自然日,
347
+ // 跨夏令时也一致);分桶只用数值比较,与记录条数线性相关。
348
+ const DAYS = 14;
349
+ /** @type {number[]} */
350
+ const dayBounds = [];
351
+ for (let i = DAYS - 1; i >= 0; i--) dayBounds.push(beijingDayStart(new Date(start - i * 86400000)).getTime());
352
+ // 明日 0 点(+36h 跨过夏令时 23/25 小时日):未来日记录在旧实现里 key 也不在输出窗口,直接排除。
353
+ const tomorrowStart = beijingDayStart(new Date(start + 36 * 3600000)).getTime();
354
+ const dayKeys = dayBounds.map((t) => {
355
+ const p = beijingParts(new Date(t));
356
+ return `${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
357
+ });
344
358
  let totalCost = 0;
345
359
  let totalSaved = 0;
346
360
  let today = 0;
@@ -381,20 +395,15 @@ export function costBreakdown() {
381
395
  byTool.set(t.tool, tt);
382
396
  }
383
397
  }
384
- if (e.at) {
385
- const p = beijingParts(new Date(e.at));
386
- const key = `${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
398
+ if (e.at && e.at >= dayBounds[0] && e.at < tomorrowStart) {
399
+ let idx = DAYS - 1;
400
+ while (idx > 0 && e.at < dayBounds[idx]) idx -= 1;
401
+ const key = dayKeys[idx];
387
402
  byDay.set(key, (byDay.get(key) || 0) + (e.cost || 0));
388
403
  }
389
404
  }
390
- // 补全近 14 天(含无记录的 0 值日),保证折线 x 轴连续
391
- const days = /** @type {Array<{day: string, cost: number}>} */ ([]);
392
- for (let i = 13; i >= 0; i--) {
393
- const d = new Date(start - i * 86400000);
394
- const p = beijingParts(d);
395
- const key = `${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
396
- days.push({ day: key, cost: byDay.get(key) || 0 });
397
- }
405
+ // 折线 x 轴连续(含无记录的 0 值日):key 与旧实现逐条一致
406
+ const days = dayKeys.map((day) => ({ day, cost: byDay.get(day) || 0 }));
398
407
  return {
399
408
  totalCost,
400
409
  totalSaved,