mingdao-harness 0.6.28 → 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,9 +410,31 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
379
410
  设备 token 存凭证库(`credentials.json` 的 `sync` 字段),配置里只留非秘密项。自建自签证书
380
411
  过渡阶段可加 `"insecure": true`(正式证书就绪后删除)。详见 README「云同步与多用户协作」。
381
412
 
382
- 同步**服务端**(`mingdao sync-server`)支持注册开关环境变量:
383
- `MINGDAO_SYNC_REGISTRATION=open|invite|closed`(默认 open)+ `MINGDAO_SYNC_INVITE_CODES=码1,码2`
384
- (invite 模式生效),公网自建建议至少 `invite`。
413
+ 同步**服务端**(`mingdao sync-server`)的安全默认(**v0.6.29 起变更,v0.6.30 收紧**):
414
+ `SYNC_HOST` 默认 **`127.0.0.1`**(仅本机),注册默认 **`closed`**。对外提供服务需显式设置
415
+ `SYNC_HOST=0.0.0.0` 并配置 `SYNC_CERT`/`SYNC_KEY`(TLS);接纳新用户需显式设置
416
+ `MINGDAO_SYNC_REGISTRATION=invite` + `MINGDAO_SYNC_INVITE_CODES=码1,码2`(推荐)或 `open`。
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`。
385
438
 
386
439
  ## 会话日志与「带上文」(跨会话连续性)
387
440
 
@@ -577,6 +630,11 @@ API id,不参与 `modelLabels`(其条目仍带 `id`,等于自定义模型
577
630
 
578
631
  自定义模型的 Key 存凭证库 `custom:<模型名>` 键下;增删改建议直接用 WebUI 设置面板完成。
579
632
 
633
+ > **Key 掩码口径(v0.6.29)**:WebUI / HTTP API(`/api/models-config` 的 `keyMasked`)只返回
634
+ > **末 4 位**(固定形态 `…wxyz`),**不再回显前 6 位**——Web 响应可能进入浏览器历史、截图或
635
+ > 剪贴板,前缀也是不该外露的指纹。本机 CLI(`mingdao key list`)仍显示「前 6…后 4」,
636
+ > 便于在终端里辨认是哪把 Key;完整 Key 任何出口都不会返回。
637
+
580
638
  自定义端点若跑的是 DeepSeek 系模型(模型名不以 `deepseek` 开头时默认走启发式估算、预算误差
581
639
  可达 ±2 倍),加 `"tokenizer": "deepseek"` 即按官方词表精确计数:
582
640
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.28",
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
@@ -1002,6 +1002,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1002
1002
  zeroToolCalls: ensureZeroToolCallNotice(),
1003
1003
  });
1004
1004
  let aborted = false;
1005
+ // 连续空/截断输出计数(防止无限续写)。v0.6.28(M-10):**必须真的复位**——
1006
+ // 有进展的工具轮(成功且不重复,与看门狗同判据)与有内容的模型轮都会清零,
1007
+ // 否则「3 次非连续空轮」会被误判成「连续 3 轮」而中止(文案与实现的判据必须一致)。
1005
1008
  let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
1006
1009
  // v0.6.13(A):本轮用户消息是否为"任务态"(含写/执行意图)——用于收尾时的「0 次工具调用」提示
1007
1010
  const turnUserTaskLike = hasWriteIntent(String([...messages].reverse().find((m) => m?.role === 'user')?.content ?? ''));
@@ -1266,12 +1269,60 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1266
1269
  return null;
1267
1270
  }
1268
1271
  };
1269
- const stripOrphanCalls = () => {
1270
- const last = messages[messages.length - 1];
1271
- if (last?.role === 'assistant' && Array.isArray(last.tool_calls) && last.tool_calls.length) {
1272
- 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;
1273
1292
  }
1274
- };
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
+ };
1275
1326
  try {
1276
1327
  // 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填。
1277
1328
  // v0.4.1 P1 修复:turnToolCache 必须声明在 while 之外、for 轮内——此前在 while 体内每步重建,
@@ -1294,11 +1345,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1294
1345
  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 };
1295
1346
  while (steps < stepLimit) {
1296
1347
  steps += 1;
1348
+ // v0.6.28(L-13):`finish` 是**本次模型请求**的 finish_reason,不是回合级状态。
1349
+ // 每步开始重置,且只由本步的 res.finish 写入(见下方赋值)——否则上一轮 length/capped
1350
+ // 会残留到下一轮正常回复,把 `truncated`/`capped` 判给一次正常收尾。
1351
+ finish = null;
1297
1352
  // v0.6.5(审计 BUG-025,实测复现):Ctrl+C 落在**工具执行期间**时,此前只把 `aborted`
1298
1353
  // 置真、却没有任何收敛点——工具跑完仍会走回这里发出**下一次请求**(实测:SIGINT 之后
1299
1354
  // chat 仍被调用 2 次)。这里与工具循环里的检查一起把它收口成「立刻停止、如实回报 aborted」。
1300
1355
  if (aborted || noProgressFired) {
1301
- stripOrphanCalls();
1356
+ finishTurn();
1302
1357
  // v0.6.13(A):无进展看门狗触发——如实报告,而不是当作"正常结束"或"用户中断"。
1303
1358
  if (noProgressFired) {
1304
1359
  markOutcome('stalled');
@@ -1408,7 +1463,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1408
1463
  // 统计不可读 / 无价格数据时判据返回 null(不误拦、也不静默放行——主检查会告警)。
1409
1464
  const msg = preflightBlockMessage(g.dailyLimitYuan, usedTodayWithInflight(), worst);
1410
1465
  if (msg) {
1411
- stripOrphanCalls();
1466
+ finishTurn();
1412
1467
  return {
1413
1468
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1414
1469
  note: msg,
@@ -1434,14 +1489,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1434
1489
  DEFAULT_MODEL
1435
1490
  );
1436
1491
  if (d.action === 'block') {
1437
- stripOrphanCalls();
1492
+ finishTurn();
1438
1493
  return {
1439
1494
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1440
1495
  note: d.message, durationMs: Date.now() - startedAt, perf: perf(),
1441
1496
  };
1442
1497
  }
1443
1498
  if (d.action === 'already-cheapest') {
1444
- stripOrphanCalls();
1499
+ finishTurn();
1445
1500
  return {
1446
1501
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1447
1502
  note: `今日费用已达上限(实际 ¥${String((todayCost() ?? 0).toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
@@ -1460,7 +1515,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1460
1515
  downgraded = true;
1461
1516
  io.print(style(d.message, C.yellow));
1462
1517
  } else {
1463
- stripOrphanCalls();
1518
+ finishTurn();
1464
1519
  return {
1465
1520
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1466
1521
  note: `费用护栏想降级到 ${target},但它与当前服务商不一致或缺少 API Key——已暂停执行。请把 config.costGuard.downgradeModel 改为与当前模型同服务商(当前:${curPc.name})的模型名,或调高 dailyLimitYuan。`,
@@ -1604,7 +1659,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1604
1659
  io.endTurn();
1605
1660
  // v0.6.13(A):看门狗触发的中止——如实报告"连续 N 分钟无进展",不冒充 Ctrl+C/正常结束
1606
1661
  if (noProgressFired) {
1607
- stripOrphanCalls();
1662
+ finishTurn();
1608
1663
  markOutcome('stalled');
1609
1664
  return {
1610
1665
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false, stalled: true,
@@ -1613,7 +1668,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1613
1668
  };
1614
1669
  }
1615
1670
  if (aborted) {
1616
- stripOrphanCalls();
1671
+ finishTurn();
1617
1672
  markOutcome('aborted', { aborted: true });
1618
1673
  return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: outcome.aborted, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
1619
1674
  }
@@ -1733,7 +1788,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1733
1788
  // 对活着但内存吃紧的引擎是错误处置);交给下面 describeUpstreamError 的独立分型。
1734
1789
  const engineShedCaches = /engine shed its caches and stays up|allocator wall/i.test(String(e?.message || ''));
1735
1790
  if (!engineShedCaches && (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || '')))) {
1736
- stripOrphanCalls();
1791
+ finishTurn();
1737
1792
  markOutcome('done');
1738
1793
  return {
1739
1794
  text: null,
@@ -1769,10 +1824,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1769
1824
  throw err;
1770
1825
  }
1771
1826
 
1827
+ // v0.6.28(L-13):`?? finish` 只在**本步**已写入时才有值;上面每步开始已重置为 null,
1828
+ // 因此缺失 finish_reason 时这里落到 null,绝不会把上一步的 length/capped 带进本轮收尾。
1772
1829
  finish = res.finish ?? finish;
1773
1830
  // v0.6.3(M-16):上游**提前关流**(没有 [DONE]、也没有 finish_reason)说明这次回答很可能是半截的。
1774
1831
  // 此前它与正常结束完全无法区分:用户拿到半句、账本记 done、也没有任何告警。
1775
1832
  // 这里至少做到"说出来"——置标记并在界面上提示,让人知道该复核输出而不是直接采信。
1833
+ // v0.6.28(M-10):**有内容的模型轮**复位空轮计数。`finish === 'length'` 的轮次不算
1834
+ // (正文即使非空也是被截断的半截,正是本护栏要计数的形态),否则截断续写护栏会被清零。
1835
+ if (String(res.text || '').trim() && res.finish !== 'length') emptyRounds = 0;
1776
1836
  if (res.truncated) {
1777
1837
  upstreamTruncated = true;
1778
1838
  try {
@@ -1868,7 +1928,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1868
1928
  // 串行化只读子代理避免并发峰值(只读 read/ls/glob/grep 仍并行,它们不额外触发大 prefill)。
1869
1929
  const subagentIsLocal = resolveModelCaps(cfg, subagentModel(cfg, modelName)).isLocal;
1870
1930
 
1871
- // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
1931
+ // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行。
1932
+ // v0.6.29(H-02):拒绝项不再就地 push(那会与成功项的执行顺序错位),而是返回
1933
+ // `{denied}`,由批次循环按 `tool_calls` 原序排入回填队列。
1872
1934
  // task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
1873
1935
  async function prepTool(/** @type {any} */ tc) {
1874
1936
  const name = tc.function?.name || '';
@@ -1893,8 +1955,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1893
1955
  io.renderToolDenied(name, {}, parsedArgs.deniedReason);
1894
1956
  if (auditOn) auditEntry({ denied: true, reason: parsedArgs.auditReason });
1895
1957
  // 审计(长生成截断循环):给出明确分块指引,避免模型反复用单个超大 write 撞输出上限
1896
- messages.push({ role: 'tool', tool_call_id: tc.id, content: parsedArgs.message });
1897
- return null;
1958
+ return { tc, name, denied: parsedArgs.message };
1898
1959
  }
1899
1960
  args = parsedArgs.args;
1900
1961
 
@@ -1903,8 +1964,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1903
1964
  if (hook.decision === 'block') {
1904
1965
  io.renderToolDenied(name, args, '被钩子阻止');
1905
1966
  if (auditOn) auditEntry({ denied: true, reason: `PreToolUse 钩子阻止:${String(hook.reason || '').slice(0, 200)}` });
1906
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `工具被 PreToolUse 钩子阻止:${hook.reason}` });
1907
- return null;
1967
+ return { tc, name, denied: `工具被 PreToolUse 钩子阻止:${hook.reason}` };
1908
1968
  }
1909
1969
 
1910
1970
  const isMcp = name.startsWith('mcp__');
@@ -1912,8 +1972,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1912
1972
  if (presetToolSet && !presetToolSet.has(isMcp ? name.slice(5) : name)) {
1913
1973
  io.renderToolDenied(name, args, `不在预设工具白名单内(${activePresetName || 'preset'})`);
1914
1974
  if (auditOn) auditEntry({ denied: true, reason: '预设工具白名单拦截' });
1915
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` });
1916
- return null;
1975
+ return { tc, name, denied: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` };
1917
1976
  }
1918
1977
  let allowed = false;
1919
1978
  if (isMcp && mcp?.isReadonly(name)) {
@@ -1935,8 +1994,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1935
1994
  turnLedger.permission({ name, mode: permission?.mode ?? cfg.permission ?? null, decision: 'deny', source: 'permission.check' });
1936
1995
  turnLedger.toolCall({ callId: tc.id, name, rawArgs: args, args, permission: { decision: 'deny' } });
1937
1996
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '权限拒绝' });
1938
- messages.push({ role: 'tool', tool_call_id: tc.id, content: '用户拒绝了该工具的执行权限。' });
1939
- return null;
1997
+ return { tc, name, denied: '用户拒绝了该工具的执行权限。' };
1940
1998
  }
1941
1999
  // v0.5.0 A3 ①:领域约束(PreToolUse)——权限放行之后、执行之前强制。
1942
2000
  // 与权限引擎的分工:权限回答「用户是否允许」,约束回答「领域是否允许」;两者都通过才执行。
@@ -1978,8 +2036,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1978
2036
  if (cv?.event) auditConstraint(cv.event);
1979
2037
  // 被约束拦下的调用不会有 tool.result,这里补一条终态,避免账本出现「只有调用没有结果」的悬空步
1980
2038
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '领域约束拦截' });
1981
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `【领域约束】${reason}` });
1982
- return null;
2039
+ return { tc, name, denied: `【领域约束】${reason}` };
1983
2040
  }
1984
2041
  return { tc, name, args, isMcp };
1985
2042
  }
@@ -2050,6 +2107,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2050
2107
  if (toolOk && repeatedBefore === 0) markProgress(`工具 ${prep.name} 返回`);
2051
2108
  else if (toolOk) { turnProgress.unproductive += 1; turnProgress.repeatedCalls = (turnProgress.repeatedCalls || 0) + 1; }
2052
2109
  else turnProgress.unproductive += 1;
2110
+ // v0.6.28(M-10):有进展的工具轮复位空轮计数(失败/重复调用不算进展,与看门狗同判据)。
2111
+ if (toolOk && repeatedBefore === 0) emptyRounds = 0;
2053
2112
  // 省钱 B3:逐工具调用/耗时累加(费用二级分账的 byTool 维度)
2054
2113
  const ts = toolStats.get(prep.name) || { calls: 0, ms: 0 };
2055
2114
  ts.calls += 1;
@@ -2111,10 +2170,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2111
2170
  const pv = checkPostTool(constraints, prep.name, typeof result === 'string' ? safeParse(result) : result);
2112
2171
  if (pv?.rejected) auditConstraint(pv.event);
2113
2172
  // 回填正文(复用前缀 / 约束拒绝文案 / 按窗口截断)单源在 tools-flow.js 的 toolResultMessage()
2114
- messages.push({
2115
- role: 'tool',
2116
- tool_call_id: prep.tc.id,
2117
- 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 },
2118
2190
  });
2119
2191
  }
2120
2192
 
@@ -2123,7 +2195,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2123
2195
  // v0.6.5(审计 BUG-025):工具执行期间用户按了 Ctrl+C → 立刻停止,不再执行后续批次
2124
2196
  // (此前没有任何检查点,长工具序列会把中断信号拖到整串跑完)
2125
2197
  if (aborted) {
2126
- stripOrphanCalls();
2198
+ // v0.6.29(H-02):已执行批次的结果先按原序落盘,再由 finishTurn 补齐未执行项;
2199
+ // 否则中断会把"已经跑完的工具"变成"未执行"占位,账实不符。
2200
+ flushToolMessages();
2201
+ finishTurn();
2127
2202
  return {
2128
2203
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true,
2129
2204
  note: '已按 Ctrl+C 中断(工具执行期间收到中断信号),本回合未再发出请求。',
@@ -2135,9 +2210,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2135
2210
  while (i < res.toolCalls.length) {
2136
2211
  const tc = res.toolCalls[i];
2137
2212
  const prep = /** @type {any} */ (await prepTool(tc));
2213
+ prep.idx = i; // v0.6.29(H-02):记录在 res.toolCalls 中的原始序号,回填时按它排序
2138
2214
  const name = tc.function?.name || '';
2139
- let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
2140
- 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;
2141
2217
  batch.push({ prep, batchable });
2142
2218
  i += 1;
2143
2219
  if (!batchable) break;
@@ -2164,18 +2240,27 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2164
2240
  );
2165
2241
  prefix.forEach((b, idx) => finishTool(b.prep, results[idx].r, results[idx].t0));
2166
2242
  for (const b of batch.slice(prefix.length)) {
2167
- if (!b.prep) continue;
2243
+ if (b.prep.denied != null) {
2244
+ finishDenied(b.prep);
2245
+ continue;
2246
+ }
2168
2247
  const t0 = Date.now();
2169
2248
  finishTool(b.prep, await runTool(b.prep), t0);
2170
2249
  }
2171
2250
  } else {
2172
2251
  for (const b of batch) {
2173
- if (!b.prep) continue; // 拒绝/失败成员已在 prepTool 回填
2252
+ if (b.prep.denied != null) { // 拒绝/失败成员:只入队,不执行
2253
+ finishDenied(b.prep);
2254
+ continue;
2255
+ }
2174
2256
  const t0 = Date.now();
2175
2257
  finishTool(b.prep, await runTool(b.prep), t0);
2176
2258
  }
2177
2259
  }
2178
2260
  }
2261
+ // v0.6.29(H-02):整批结束后按 `tool_calls` 原序一次性回填,保证
2262
+ // assistant.tool_calls 与 tool 消息逐项对齐(拒绝项不再抢在成功项前面)。
2263
+ flushToolMessages();
2179
2264
  // v0.2.8 步数预留收尾(对齐 DSH):仅「最后一轮」的末步追加收尾指令,
2180
2265
  // 让模型在末轮输出总结;中间轮不注入(交给自动续跑继续干活,而非提前收尾中断)。
2181
2266
  if (steps === stepLimit - 1 && round === maxRounds - 1) {
@@ -2207,7 +2292,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2207
2292
  if (res.finish === 'length') {
2208
2293
  emptyRounds += 1;
2209
2294
  if (emptyRounds >= maxEmptyRounds) {
2210
- stripOrphanCalls();
2295
+ finishTurn();
2211
2296
  return {
2212
2297
  text: res.text || null,
2213
2298
  reasoning: res.reasoning || '',
@@ -2315,7 +2400,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2315
2400
  }
2316
2401
  io.endTurn();
2317
2402
  // 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
2318
- stripOrphanCalls();
2403
+ finishTurn();
2319
2404
  // v0.6.2(用户实测:「步数受限未能产出交付物,需要继续追问方能继续下一步」):
2320
2405
  // 预算用尽必须**明确告诉用户**发生了什么、以及怎么继续。此前这里一行都不打印,
2321
2406
  // 用户只看到一段总结,很容易误判为「任务已完成」,只能自己追问。
@@ -2411,10 +2496,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2411
2496
  } catch (/** @type {any} */ err) {
2412
2497
  // 审计 P2-6(v0.4.2):工具管线异常(hooks.pre / permission.check / prepTool 等)沿大 try 上抛时,
2413
2498
  // assistant tool_calls 已 push 进 messages 却无对应 tool 回填——会话恢复后 API 因孤儿 tool_call_id 400。
2414
- // 清理孤儿调用后再抛(stripOrphanCalls 此前只在中断/收尾路径调用)。
2415
- stripOrphanCalls();
2499
+ // v0.6.29(C-01):先落盘已执行批次的结果(H-02 队列),再把缺失项补齐成"未执行"占位再抛;
2500
+ // 只删 assistant.tool_calls 会让**已经执行**的 tool 结果失去配对,同样 400。
2501
+ flushToolMessages();
2502
+ finishTurn();
2416
2503
  throw err;
2417
2504
  } finally {
2505
+ // v0.6.29(C-01):最后一道保险——所有出口(正常/中断/预算/异常)都做一次配对补齐。
2506
+ // 显式调用点仍保留:它们让"返回前历史已经完整",这里兜住任何新增的 early return。
2507
+ flushToolMessages();
2508
+ finishTurn();
2418
2509
  currentAc = null;
2419
2510
  currentUsage = null; // 回合结束:避免 runTurn 之外调用的 spawnTask 写入陈旧累加器
2420
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 } = {}) {