mingdao-harness 0.6.6 → 0.6.7

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.
@@ -1191,3 +1191,62 @@ run-all 的 shell、run-all 的 try/catch、调度切片(源码级)、调度
1191
1191
  第 7 节路线图列的多为已完成项。报告的这条批评**成立**。
1192
1192
  - P2 各项(Pack 沙箱档、成本归因下钻、降级目标自动选择、JSDoc 治理)与报告 §六 横向对比结论,
1193
1193
  仅作输入,不逐条排期。
1194
+
1195
+ ### 3.40.1 发版现场另外发现的两处发布链路缺陷(v0.6.6 一并修)
1196
+
1197
+ 这两条不在第三方报告里,是本次发版实操踩出来的,都属于"**门禁自己被骗**"这一形态:
1198
+
1199
+ | 位置 | 现象 | 根因 | 处置 |
1200
+ | --- | --- | --- | --- |
1201
+ | `MingDao-Harness-Site/scripts/harvest-release.mjs`、`scripts/update-downloads.mjs` | Release 附件明明已齐(`releases/<id>/assets` 列出 14 个),脚本却报「没有可采集的附件(是不是构建还没跑完?)」/「缺少安装包」 | `releases/tags/<tag>` 被 GitHub 的 **CDN 缓存**成"创建瞬间的空 assets"(Release 由工作流刚创建时) | 加 cache-buster;为空时按 release id 直连 assets 端点复核 |
1202
+ | `scripts/verify-release.mjs`("防半发布"最后一道闸) | 打出 `✓ GitHub/Release 存在(附件 0 个)`——把一个无法下载、无法收割的 Release 判成通过 | 同上(读了被缓存的空 assets),且**附件数为 0 不影响判定** | 同上修复,并把「GitHub 附件为 0」改判**失败**(Gitee/GitCode 按政策不附附件,故只对 GitHub 判) |
1203
+ | `MingDao-Harness-Site/scripts/harvest-release.mjs` | 线上 `/downloads/latest.yml` 自 v0.6.1 起再没更新过(v0.6.4/0.6.5 依次"跳过",无人察觉) | `latest*.yml` 是**不带版本号的指针文件**、体积都是几百字节,而幂等判据是"已存在且体积一致就跳过" | 指针类文件(`latest(-linux).yml`)一律覆盖——代价几百字节 |
1204
+
1205
+ **教训**:这一批四个问题(含第三方的三处)全是"**验证逻辑自身的判据太宽**"——只判"抛不抛错"、
1206
+ 只判"体积一致"、只判"Release 存在"。**判据要对着"我们真正在意的那件事"写**:
1207
+ 在意的不是"文件在不在",而是"它是这一个版本的、且能被下载"。
1208
+
1209
+ ## 3.41 已修复(v0.6.7 批十二~批十五:三份 v0.6.6 审计报告的收口)
1210
+
1211
+ **来源**:三份第三方报告(全量代码审计 58 项 / 第六轮复审 / 安全与质量审计),共同把
1212
+ 「**同一规则多份实现 → 最弱的那份说了算**」指为本项目安全缺陷的唯一共性根因。
1213
+ 本批按它们的 v0.6.7 建议范围收口,逐条走「先复现 → 定级 → 修复 → 回归断言 → 变异验证」。
1214
+
1215
+ | 批 | 条目 | 报告出处 | 落地 |
1216
+ | --- | --- | --- | --- |
1217
+ | 十二 | **fetch 工具 SSRF 单一口径**:DNS 复检 fail-open、check/connect 两次解析(DNS rebinding)、元数据只按主机名判 | 报告一 C-1/H-1、报告二 P2-2、报告三 P1(三个报告的头号发现) | 新增 `src/ssrf-guard.js`(判定+钉扎单源);`runFetch` 整条走 `safeFetchText`;`tools/fetch.js` 只留工具契约并再导出同一函数对象。**行为级复现**:桩解析失败 → 旧路径放行、新路径 fail-closed;桩解析→127.0.0.1 + 不可解析主机名仍能连通(证明连接用的是**钉住的 IP**) |
1218
+ | 十二 | **出网闸门盲区**:`safe-fetch` 走 `node:http(s)`,完全绕过只钩 `globalThis.fetch` 的闸门 | 报告三 M1/M2 | net-guard 新增 `guardEgress()`(与 fetch 钩子共用同一入口),safe-fetch 每跳显式过闸+记账;**行为级**:block 档下 skill/registry/模型发现路径被拦且入账 |
1219
+ | 十二 | **`fetch(Request, init)` 丢失 Request 语义 → 静默降级成空 GET** | 报告二 P2-3 | 逐字段合并(method/headers/body/signal/duplex + 若干 init 字段),**行为级**:服务端收到 POST + body |
1220
+ | 十二 | **跨 origine 只剥三个头(黑名单)**:`x-api-key` 等自定义凭据头原样送给重定向目标 | 报告一 M-4 | 改**反向白名单**(`stripHeadersForCrossOrigin`);**行为级**:第二跳 `x-api-key`/`authorization` 已剥、`accept`/`user-agent` 保留 |
1221
+ | 十三 | **账本结局口径分裂**(返回值 capHit 与 runEnd 不一致、`'max_steps'` 死条件、截断方向反向) | 报告二 P2-1 | runTurn 内新增 `outcome` 单一来源,所有出口 `markOutcome()`,runEnd 与返回值同源;**行为级**:跑满步数时 `run.end.status==='capped' && capHit===true` 且与返回值一致 |
1222
+ | 十三 | **费用护栏读失败当 0**(statSync 过、readFileSync 失败 → 静默 ¥0) | 报告一 H-3 | 新增 `listCacheStatsStrict()`(区分 ENOENT 与读失败),护栏走严格读;**行为级(子进程)**:文件存在但 chmod 000 → `todayCost()` 返 **null** 而非 0,且告警一次 |
1223
+ | 十三 | **usage 缺失记 ¥0 → 三档护栏失明且无信号** | 报告一 H-4、登记 §3.39①(a) | agent 置 `usageUnknown` 并在有响应无 usage 时告警一次;`recordUsage` 记 `cost:null + usageUnknown`;账本 cost 事件带 `usageUnknown`;护栏给出"今日 N 次用量未知"提示 |
1224
+ | 十三 | **Batch 失败/取消路径漏账**、**Batch 不查护栏** | 报告一 M-10、登记 §3.39①(c) | 四条失败路径记「用量未知」;提交前 `checkCostGuard`(block 档拒绝提交,**服务端零请求**——行为级断言) |
1225
+ | 十三 | **downgrade 不看在途费用**(降级成免检通道) | 登记 §3.39①(b) | 降级换模型后按在途费用复查,仍超限即停(行为级:`providerCalls === 0`) |
1226
+ | 十三 | `run.start.packs` 恒为空数组(取不存在的键) | 报告二 P3-1 | 改取 `mounted.map(p => p.name)`;行为级:挂载 Pack 后账本里能看到它 |
1227
+ | 十三 | capHit 口径:Web 标 `done`、worker 标 `failed` | 报告二小项 | 统一为「不算完成」:Web 侧 `'capped'`(前端给明确横幅),worker 维持 `'failed'` |
1228
+ | 十四 | **脱敏器不认带引号键**(`{"api_key": …}` / `{'api_key': …}` 明文进审计与诊断包) | 报告一 H-2、登记 §3.39② | 正则允许成对引号包裹键名/值;**测试刻意用无前缀密钥值**(`sk-` 被另一条规则掩掉,拿它测会假绿——变异验证当场指出) |
1229
+ | 十四 | **凭证写用宽松读**(损坏时一次 init/WebUI 设 Key 清空其余凭据) | 报告一 M-1(H-8 只修了 key 命令层) | `setStoredKey`/`removeStoredKey` 内置严格读、损坏即拒绝写;三个 WebUI 调用点 + init 向导把失败说出来 |
1230
+ | 十四 | **deny 拆段漏命令替换/子 shell/`\r`/续行**;`denyStrict` 被 `sh -c` 绕过 | 报告一 M-2、报告二 P3-6 | 分隔面补全(fail-closed 方向)+ denyStrict 下包装执行直接拒绝并给可操作指引 |
1231
+ | 十四 | **bash env 词表漏 `key` 段**(`OPENAI_KEY`/`GITHUB_KEY` 原样透传子进程) | 报告一 M-3 | 与脱敏器共用 `ENV_SECRET_SEGMENTS`(唯一例外 `pwd`,避免剥离 `PWD`) |
1232
+ | 十四 | **constraints ReDoS 判定弱于 grep**(`(a|aa)+`/`((a|ab)x)+` 装载即通过) | 报告一 M-5 | 新增 `src/regex-safety.js` 作为单一来源(嵌套量词 + 歧义分支,含嵌套组),grep/constraints/packs 三处共用;刻意不拦 `(a+)?`(`?` 只重复 0/1 次,不会爆炸) |
1233
+ | 十四 | `workspaces set` 存**未归一化**路径 → 改链指向即围栏塌陷 | 报告一 H-5、登记 P1-21 | 存 `t2`(realpathDeep 结果),与同文件 add 分支对齐 |
1234
+ | 十五 | `redactDeep` 对象键数无界、`/memory add` 裸 appendFileSync(0644/无日期戳)、worker 的 ask 漏对象形态、`pack new` 写进非发现路径、自定义 Provider 模块按 `Date.now()` 无限重载、`clampText` 裸 slice 切代理对、schedule 锁内做进程调用、share-accept 文件写在锁外且非原子、会话 id 同秒碰撞 | 报告一 L-1/L-10/M-6/M-7/M-13、报告二 P3-3/P3-4/P3-5/P3-7 | 逐项落地;**测试基建**:新增 `test/mutate/`(变异验证脚本入库,报告一 S-3② 的欠账——此前"7/7"是手工过程、谁也复现不了) |
1235
+
1236
+ **变异验证(本批累计 38 条)**:`node test/mutate/run.mjs`
1237
+ (批十二 8/8、批十三 10/10、批十四·十五 10/10,另有 v0.6.5/v0.6.6 的 4+7 条历史条目)。
1238
+ 过程中被抓出 **3 处假绿**(断言第一版抓不到变异):
1239
+ ① 用 `sk-…` 测"带引号的键"——它被前缀规则掩掉了,测不到 H-2;
1240
+ ② M-13 两次调用落在同一毫秒 → 变异体与修复版表现一致;
1241
+ ③ 变异验证脚本自身的关键词对不上(失败的断言不是要钉的那条)。
1242
+ 三条都按"收紧后重验才算数"处理。
1243
+
1244
+ **未纳入本批(如实登记,按报告 §五/§八 排期)**:
1245
+ - 报告一 **M-8**(sync-server 设备级吊销 / push 配额与限流)、**M-9**(memory 子系统 4 处锁外读-改-写)、
1246
+ **M-11**(逐轮回调计价锚点——本批已随 H-4 一并把 `perf.requestStartAt` 透到 recordUsage,逐轮路径同源)、
1247
+ **L-2/L-4/L-5/L-12/L-15/L-16/L-17** 等低危项;
1248
+ - 报告一 **P1-1**(agent.js 拆分,runTurn ~1057 行)、**P1-2**(结构化摘要 + 关键事实钉死)、
1249
+ **P1-3**(预算状态注入上下文)、**P1-4**(出网闸门对 bash curl 的补强)、**P1-5**(ARCHITECTURE.md 重建)、
1250
+ 报告二 P3-2 的消费方、报告三 §4 阶段二/三(对抗测试集扩面、容器 CI job、关键模块 TS 迁移、发布状态机);
1251
+ - 报告一 **H-6/L-18**(自更新多镜像取最大版本、install.sh 无校验和)——供应链完整性锚需要发布流程配合,
1252
+ 与 `ledger --sign-key`、在线缓存基准(P0-2)同列为 v1.0 前项。
@@ -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 # 期望 全部通过(当前 153 组断言)
64
+ node test/smoke.js # 期望 全部通过(当前 156 组断言)
65
65
  node test/e2e-local.js # 期望 全通过
66
66
  node test/e2e-web.js # 期望 全通过
67
67
  node test/e2e-schedule.js # 期望 全通过
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { trimMessages, clampText, messageTokens, approxTokens } from './context.js';
6
6
  import { compactConversation } from './compact.js';
7
7
  import { buildToolSchemas, dispatch } from './tools/index.js';
8
- import { modelPreset } from './models.js';
8
+ import { DEFAULT_MODEL, modelPreset } from './models.js';
9
9
  import { resolveModelCaps, safeBudget, EDGE_RATIO } from './model-caps.js';
10
10
  import { makeTokenCounter } from './tokenizer.js';
11
11
  import { createHooks } from './hooks.js';
@@ -383,6 +383,22 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
383
383
  /** v0.6.3(M-16):本回合是否出现过"上游提前关流"(回答可能不完整)。
384
384
  * 刻意不叫 truncated——既有 `truncated` 的含义是"步数兜底总结也失败了",两者必须分开。 */
385
385
  let upstreamTruncated = false;
386
+ // v0.6.7(报告二 P2-1):**结局判定的单一来源**。
387
+ // 此前账本 runEnd 在 finally 里拿不到返回值,只能用局部变量 `finish` 重算结局,于是四处口径分裂:
388
+ // ① 跑满步数:返回值 capHit:true,账本却记 status='done'、capHit=false;
389
+ // ② `finish === 'max_steps'` 是**死条件**(全文件无人赋该值,析取支永不成立);
390
+ // ③ 步数耗尽的另一条分支(末轮仍有正文+工具调用)返回时**根本没有 capHit 字段**,
391
+ // 与同一 if 的 `break` 分支(capHit:true)自相矛盾;
392
+ // ④ `finish === 'length'` 时账本记 capHit:true,而循环跑满的返回值是 truncated:true —— 同一次运行两个说法。
393
+ // 现在:所有出口都调用 markOutcome 写进同一个 outcome,返回值与 runEnd 都从它取。
394
+ /** @type {{status: string, capHit: boolean, truncated: boolean, aborted: boolean, upstreamTruncated: boolean, usageUnknown: boolean}} */
395
+ const outcome = { status: 'done', capHit: false, truncated: false, aborted: false, upstreamTruncated: false, usageUnknown: false };
396
+ /** 收敛本回合结局(后写覆盖,便于"先默认、后升级")。
397
+ * @param {string} status @param {Partial<{capHit: boolean, truncated: boolean, aborted: boolean}>} [extra] */
398
+ const markOutcome = (status, extra = {}) => {
399
+ outcome.status = status;
400
+ Object.assign(outcome, extra);
401
+ };
386
402
  // v0.3.1 自动续跑(长程执行):跑满 stepLimit 步后不再直接中断,而是注入进度摘要再续跑,
387
403
  // 最多 maxRounds 轮(默认 3,可用 cfg.maxRounds 调);审计/重构等大任务不再「一步中断」。
388
404
  const maxRounds = Math.max(1, Number(cfg.maxRounds) || 3);
@@ -406,7 +422,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
406
422
  cwd: workingDir,
407
423
  permission: cfg.permission ?? permission?.mode ?? null,
408
424
  preset: preset?.name ?? cfg.preset ?? null,
409
- packs: getActivePackContext()?.packs ?? [],
425
+ // v0.6.7(报告二 P3-1):activeCtx 的形状是 { mounted, warnings, promptSections, constraints, toolCount },
426
+ // **没有 `.packs` 键** —— 这里此前恒为 [],账本里"本次运行挂了哪些 Pack"永远记空。
427
+ packs: (getActivePackContext()?.mounted || []).map((/** @type {any} */ p) => p?.name).filter(Boolean),
410
428
  });
411
429
  // 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
412
430
  let llmMsTotal = 0;
@@ -430,6 +448,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
430
448
  usedModel: activeModel, // 省钱 B4:本回合实际使用模型(降级后归属它)
431
449
  requestStartAt: lastRequestStartAt, // v0.4.6:峰谷计价锚点(请求发起时刻,非落账时刻)
432
450
  deliverables: [...deliverables], // v0.3.1:CLI/REPL 续跑检查点复用(此前 artifacts 恒空)
451
+ // v0.6.7(报告一 H-4):本回合是否出现过"模型有响应但服务端没回 usage"
452
+ usageUnknown: outcome.usageUnknown,
433
453
  });
434
454
  let aborted = false;
435
455
  let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
@@ -675,7 +695,26 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
675
695
  // 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
676
696
  // downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
677
697
  if (cfg.costGuard) {
678
- const guard = checkCostGuard(activeModel);
698
+ let guard = checkCostGuard(activeModel);
699
+ // v0.6.7(登记 §3.39①(b),报告一 §2.5 复核「仍成立」):**在途费用必须参与降级决策**。
700
+ // 此前 downgrade 档只看**落账**(checkCostGuard → todayCost),而单看落账要在回合结束后才更新——
701
+ // 于是长回合里贵模型会一直用到跑完,实测单回合可超日限 9.1×(note=null,用户毫无察觉)。
702
+ // 现在:若「今日已用 + 本回合在途」已越线,且当前还不在最便宜档,就**当场触发降级**
703
+ // (不否决降级本身——那等于把这个功能废掉;只是把它触发得更早)。
704
+ if (!guard && !downgraded) {
705
+ const g0 = costGuardConfig();
706
+ if (g0 && String(g0.action) === 'downgrade' && Number(g0.dailyLimitYuan) > 0) {
707
+ const usedNow = usedTodayWithInflight();
708
+ if (usedNow != null && usedNow >= Number(g0.dailyLimitYuan)) {
709
+ guard = {
710
+ blocked: false,
711
+ downgrade: true,
712
+ downgradeModel: String(g0.downgradeModel || DEFAULT_MODEL),
713
+ message: `⚠ 费用护栏:今日已用(含本回合在途)≈¥${usedNow.toFixed(4)} 已达上限 ¥${Number(g0.dailyLimitYuan).toFixed(2)}——已自动降级到便宜模型继续执行。`,
714
+ };
715
+ }
716
+ }
717
+ }
679
718
  if (guard) {
680
719
  if (guard.blocked) {
681
720
  stripOrphanCalls();
@@ -765,13 +804,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
765
804
  io.endTurn();
766
805
  if (aborted) {
767
806
  stripOrphanCalls();
768
- return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true, durationMs: Date.now() - startedAt, perf: perf() };
807
+ markOutcome('aborted', { aborted: true });
808
+ return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: outcome.aborted, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
769
809
  }
770
810
  // MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
771
811
  // 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
772
812
  const e = /** @type {any} */ (err);
773
813
  if (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || ''))) {
774
814
  stripOrphanCalls();
815
+ markOutcome('done');
775
816
  return {
776
817
  text: null,
777
818
  reasoning: '',
@@ -780,6 +821,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
780
821
  finish,
781
822
  truncated: false,
782
823
  aborted: false,
824
+ upstreamTruncated,
783
825
  note: '本地模型内存不足(507 memory_refusal)——请压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后再继续。',
784
826
  durationMs: Date.now() - startedAt,
785
827
  perf: perf(),
@@ -812,6 +854,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
812
854
  });
813
855
  // 省钱 B1:只读阶段中模型文字明确表达写意图 → 下一轮注入全量工具(多一轮,几乎无感)
814
856
  if (readOnlyPhase && hasWriteIntent(res.text)) readOnlyPhase = false;
857
+ if (!res.usage && (res.text || res.toolCalls?.length)) {
858
+ // v0.6.7(报告一 H-4):网关/自定义端点不返回 usage 时,此前这回合在账上**不存在**——
859
+ // warn/block/downgrade 三档护栏全部失明且没有任何信号。这里置位 + 明确告知,
860
+ // 落账时按"用量未知"处理(cost:null / priced:false),绝不冒充"这次花了 ¥0"。
861
+ if (!outcome.usageUnknown) {
862
+ outcome.usageUnknown = true;
863
+ try {
864
+ io.print(
865
+ style(
866
+ '⚠ 服务端未返回本次调用的用量(usage 缺失):这一回合无法计费,今日费用护栏会少计这部分消费。\n' +
867
+ ' 常见于自建网关/中转端点;如需精确护栏,请让网关透传 usage 字段。',
868
+ C.yellow
869
+ )
870
+ );
871
+ } catch {}
872
+ }
873
+ }
815
874
  if (res.usage) {
816
875
  usage.prompt_tokens += res.usage.prompt_tokens || 0;
817
876
  usage.completion_tokens += res.usage.completion_tokens || 0;
@@ -842,7 +901,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
842
901
  if (res.text) {
843
902
  const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
844
903
  messages.push({ role: 'assistant', content: ap.text });
845
- 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() };
904
+ // 步数已耗尽:这一条与下方 `break`(→ 兜底总结/截断收尾)必须是同一口径
905
+ markOutcome('capped', { capHit: true });
906
+ return { text: ap.text, reasoning: res.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: outcome.capHit, upstreamTruncated, note: ap.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
846
907
  }
847
908
  break;
848
909
  }
@@ -1203,6 +1264,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1203
1264
  // 静默空输出(无工具无正文):同样回填续写提示,避免界面"没动静"(审计 Q1:与截断续写统一护栏)
1204
1265
  emptyRounds += 1;
1205
1266
  if (emptyRounds >= maxEmptyRounds) {
1267
+ markOutcome('done');
1206
1268
  return {
1207
1269
  text: null,
1208
1270
  reasoning: res.reasoning || '',
@@ -1211,6 +1273,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1211
1273
  finish,
1212
1274
  truncated: false,
1213
1275
  aborted: false,
1276
+ upstreamTruncated,
1214
1277
  note: '模型本轮没有输出正文。',
1215
1278
  durationMs: Date.now() - startedAt,
1216
1279
  perf: perf(),
@@ -1239,14 +1302,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1239
1302
  });
1240
1303
  continue;
1241
1304
  }
1305
+ markOutcome(finish === 'length' ? 'capped' : 'done', { capHit: false, truncated: finish === 'length' });
1242
1306
  return {
1243
1307
  text: finalText,
1244
1308
  reasoning: res.reasoning || '',
1245
1309
  usage,
1246
1310
  steps,
1247
1311
  finish,
1248
- truncated: false,
1312
+ truncated: outcome.truncated,
1249
1313
  aborted: false,
1314
+ upstreamTruncated,
1250
1315
  note: outputNote || undefined,
1251
1316
  durationMs: Date.now() - startedAt,
1252
1317
  perf: perf(),
@@ -1352,16 +1417,19 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1352
1417
  : { text: null, note: null };
1353
1418
  if (wrapApplied.text) messages.push({ role: 'assistant', content: wrapApplied.text });
1354
1419
  // capHit:本轮因步数上限被迫收尾(任务可能未真正完成)→ 供上层落检查点续跑
1355
- return { text: wrapApplied.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: true, note: wrapApplied.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
1420
+ markOutcome('capped', { capHit: true });
1421
+ return { text: wrapApplied.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: outcome.capHit, upstreamTruncated, note: wrapApplied.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
1356
1422
  } catch (/** @type {any} */ err) {
1357
1423
  // v0.4.1:不再静默吞异常——总结失败原因透出,便于定位(此前用户只见「输出截断/无反馈」)
1358
1424
  try { io.print(style(`⚠ 兜底总结失败:${String(err?.message || err)}`, C.yellow)); } catch {}
1359
1425
  }
1360
1426
  }
1361
- return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
1427
+ markOutcome('capped', { capHit: true, truncated: true });
1428
+ return { text: null, reasoning: '', usage, steps, finish, truncated: outcome.truncated, aborted: false, capHit: outcome.capHit, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
1362
1429
  }
1363
1430
  // 理论不可达(for 循环末轮必 return);给 tsc 一个兜底,保证 runTurn 恒有返回值
1364
- return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
1431
+ markOutcome('capped', { capHit: true, truncated: true });
1432
+ return { text: null, reasoning: '', usage, steps, finish, truncated: outcome.truncated, aborted: false, capHit: outcome.capHit, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
1365
1433
  } catch (/** @type {any} */ err) {
1366
1434
  // 审计 P2-6(v0.4.2):工具管线异常(hooks.pre / permission.check / prepTool 等)沿大 try 上抛时,
1367
1435
  // assistant tool_calls 已 push 进 messages 却无对应 tool 回填——会话恢复后 API 因孤儿 tool_call_id 400。
@@ -1383,7 +1451,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1383
1451
  // 两者单价不同(flash 便宜数倍),于是账本与日费用护栏系统性偏移,
1384
1452
  // 而"降级省钱"恰恰是靠这里体现的。
1385
1453
  const yuan = estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, cacheSplit(usage), costDate);
1386
- const priced = typeof yuan === 'number' && Number.isFinite(yuan);
1454
+ // v0.6.7(H-4):用量未知时哪怕 0/0 也算出一个真 0(estimateCost(0,0)===0),
1455
+ // 那会把"没记账"伪装成"没花钱"。未知一律 priced:false + yuan:null。
1456
+ const usageKnown = !outcome.usageUnknown && (usage.prompt_tokens > 0 || usage.completion_tokens > 0 || steps === 0);
1457
+ const priced = usageKnown && typeof yuan === 'number' && Number.isFinite(yuan);
1387
1458
  turnLedger.cost({
1388
1459
  // BUG-024:归属同样要用实际计费的模型(run.start 里已记过"用户请求的模型",
1389
1460
  // 这里再写 modelName 会让"花了钱的那次调用"归属到一个没被调用的模型)
@@ -1391,6 +1462,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1391
1462
  usage,
1392
1463
  yuan: priced ? yuan : null,
1393
1464
  priced,
1465
+ // v0.6.7(H-4):把"用量未知"作为显式事实落账,事后可区分「没花钱」与「没记账」
1466
+ ...(usageKnown ? {} : { usageUnknown: true }),
1394
1467
  pricing: { requestStartAt: lastRequestStartAt, peak: isPeakHour(costDate) },
1395
1468
  });
1396
1469
  turnLedger.runEnd({
@@ -1398,15 +1471,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1398
1471
  // status 是「这次运行怎么结束的」,与模型层的 finish_reason(stop/tool_calls/length)
1399
1472
  // 不是一回事:前者给人看账本,后者记在 model.round 里。混用会让「status=stop」这种
1400
1473
  // 记录无法回答「这次到底完成了没有」。
1401
- status: aborted ? 'aborted' : finish === 'length' || finish === 'max_steps' ? 'capped' : 'done',
1474
+ // v0.6.7(P2-1):一律取自 outcome —— 与返回值**同一个来源**,不可能再分裂
1475
+ status: outcome.status,
1402
1476
  steps,
1403
1477
  rounds: round + 1,
1404
1478
  yuanTotal: priced ? yuan : null,
1405
1479
  priced,
1406
- // capHit/truncated 的判定与下方返回值保持同一口径:跑到步数上限被迫收尾
1407
- capHit: Boolean(finish === 'length' || finish === 'max_steps'),
1408
- truncated: Boolean(finish === 'length'),
1409
- aborted,
1480
+ capHit: outcome.capHit,
1481
+ truncated: outcome.truncated,
1482
+ aborted: outcome.aborted,
1483
+ // v0.6.7(P3-2):上游提前关流此前只置位、不落账("检测到了、传递断了")
1484
+ upstreamTruncated: Boolean(upstreamTruncated || outcome.upstreamTruncated),
1410
1485
  });
1411
1486
  // v0.6.2(B-WS-1/2):记账降级必须让用户看见。
1412
1487
  // 此前写账本失败只把 alive 置 false,用户拿到一段「正常」总结却不知道这次运行
package/src/batch.js CHANGED
@@ -11,6 +11,7 @@ import path from 'node:path';
11
11
  import crypto from 'node:crypto';
12
12
  import { resolveProviderConfig } from './providers/index.js';
13
13
  import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
14
+ import { checkCostGuard } from './cost-guard.js';
14
15
  import { recordCacheStats } from './cachestats.js';
15
16
  import { approxTokens } from './context.js';
16
17
 
@@ -223,6 +224,18 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
223
224
  }
224
225
  }
225
226
 
227
+ // —— v0.6.7(登记 §3.39①(c)):Batch 也要过费用护栏 ——
228
+ // 此前 Batch 通道**完全不查 costGuard**(只认用户显式给的 --max-cost):一次提交几百条问题,
229
+ // 恰恰是"烧穿日限"最省事的路径,而护栏在交互通道上却是硬的。这里与交互通道同一口径:
230
+ // block 档直接拒绝提交,warn/downgrade 档把护栏的话转达给调用方(不静默)。
231
+ {
232
+ const guard = checkCostGuard(model);
233
+ if (guard) {
234
+ if (guard.blocked) return { error: `费用护栏拦截,未提交批处理:${guard.message}` };
235
+ onStatus?.(guard.message);
236
+ }
237
+ }
238
+
226
239
  // —— 省钱 B2:--max-cost 预算上限(提交前估算拦截)——
227
240
  let estimatedCost = /** @type {number|null} */ (0);
228
241
  if (maxCost > 0) {
@@ -264,6 +277,34 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
264
277
  )
265
278
  .join('\n') + '\n';
266
279
 
280
+ /**
281
+ * 失败/取消路径的**最少诚实记账**(v0.6.7 / 报告一 M-10)。
282
+ * 批次一旦提交,服务端就可能已经处理并计费,而此前这四条路径(本地取消 / 超 24h / failed /
283
+ * expired·cancelled)本地**一条记录都不写**——账单在涨、账上却没有这笔。
284
+ * 拿不到 token 数就如实记「用量未知」(cost:null + usageUnknown + 服务端已处理条数),
285
+ * 让今日护栏能说出"有 N 次未知消费",而不是把未知记成 0。
286
+ * @param {any} j 最近一次批次状态(可能为 null)
287
+ * @param {string} reason
288
+ */
289
+ const recordUnaccountedBatch = (j, reason) => {
290
+ try {
291
+ const rc = j?.request_counts || {};
292
+ const done = rc.completed != null ? `${rc.completed}/${rc.total ?? '?'}` : '未知';
293
+ recordCacheStats({
294
+ model,
295
+ prompt: 0,
296
+ completion: 0,
297
+ hit: null,
298
+ miss: null,
299
+ cost: null, // 未知:绝不写 0(0 会被读成"没花钱")
300
+ saved: null,
301
+ batch: true,
302
+ usageUnknown: true,
303
+ note: `batch ${reason}(服务端已处理 ${done} 条,用量未知)`,
304
+ });
305
+ } catch {}
306
+ };
307
+
267
308
  try {
268
309
  onStatus?.('上传输入文件…');
269
310
  const fileId = await uploadFile(base, apiKey, jsonl, signal);
@@ -286,6 +327,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
286
327
  if (signal?.aborted) {
287
328
  // 本地取消必须尽力转化为服务端取消,否则用户以为停了、账单照涨
288
329
  const cancelled = await cancelServerBatch(base, apiKey, batch.id);
330
+ recordUnaccountedBatch(null, '本地取消');
289
331
  return {
290
332
  error: cancelled
291
333
  ? '已取消(已请求服务端停止该批次)'
@@ -297,6 +339,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
297
339
  if (Date.now() - t0 > 24 * 3600 * 1000) {
298
340
  // 超窗口同样要尝试停掉,避免留下一个无人接管却在计费的批次
299
341
  const cancelled = await cancelServerBatch(base, apiKey, batch.id);
342
+ recordUnaccountedBatch(null, '超 24h 窗口');
300
343
  return {
301
344
  error: `批处理超过 24h 窗口${cancelled ? '(已请求服务端停止)' : '(服务端取消失败,批次可能仍在计费)'}`,
302
345
  batchId: batch.id,
@@ -309,7 +352,10 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
309
352
  failures = 0;
310
353
  } catch (err) {
311
354
  failures += 1;
312
- if (failures >= 10) return { error: `轮询失败:${(/** @type {any} */ (err))?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
355
+ if (failures >= 10) {
356
+ recordUnaccountedBatch(null, '轮询连续失败');
357
+ return { error: `轮询失败:${(/** @type {any} */ (err))?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
358
+ }
313
359
  await new Promise((r) => setTimeout(r, Math.min(baseInterval * 1.5 ** failures, 30000)));
314
360
  continue;
315
361
  }
@@ -320,6 +366,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
320
366
  }
321
367
  if (['failed', 'expired', 'cancelled', 'canceled'].includes(st)) {
322
368
  const detail = j?.errors?.data?.[0]?.message || j?.errors?.message || '';
369
+ recordUnaccountedBatch(j, `服务端状态 ${st}`);
323
370
  return { error: `批处理失败:${st}${detail ? '(' + detail + ')' : ''}`, batchId: batch.id };
324
371
  }
325
372
  polls += 1;
@@ -351,12 +398,18 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
351
398
  if (!outputs[i]) outputs[i] = { id: String(i), content: '' };
352
399
  }
353
400
  const usage = { prompt_tokens: prompt, completion_tokens: completion };
354
- const cost = estimateBatchCost(model, prompt, completion);
401
+ // v0.6.7(H-4 的 batch 变体):结果里没有 usage(网关不回 / 全部条目失败)时,
402
+ // prompt+completion 恒为 0 —— 那不是"免费",而是"没有用量数据",必须区分。
403
+ const usageUnknown = prompt === 0 && completion === 0;
404
+ const cost = usageUnknown ? null : estimateBatchCost(model, prompt, completion);
355
405
  const outFile = path.join(workingDir, `mingdao-batch-result-${Date.now()}.jsonl`);
356
406
  fs.writeFileSync(outFile, outputs.map((o) => JSON.stringify(o)).join('\n') + '\n');
357
- recordCacheStats({ model, prompt, completion, hit: null, miss: null, cost, saved: null, batch: true });
407
+ recordCacheStats({
408
+ model, prompt, completion, hit: null, miss: null, cost, saved: null, batch: true,
409
+ ...(usageUnknown ? { usageUnknown: true, note: 'batch 结果未返回 usage(用量未知)' } : {}),
410
+ });
358
411
  onStatus?.(`完成:${outputs.length} 条结果${deduped ? `(去重合并 ${deduped} 条)` : ''}`);
359
- return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT, deduped, estimatedCost };
412
+ return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, usageUnknown, discount: BATCH_DISCOUNT, deduped, estimatedCost };
360
413
  } catch (err) {
361
414
  // 批处理端点不支持(404/405 等)→ 明确告知,不静默
362
415
  return { error: `批处理不可用:${(/** @type {any} */ (err))?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
package/src/cachestats.js CHANGED
@@ -68,6 +68,11 @@ export function recordCacheStats(/** @type {any} */ entry) {
68
68
  // 这些调用不在 agent 回合 usage 里,单独入账,不与回合级重复计费。
69
69
  aux: entry.aux === true ? true : undefined,
70
70
  auxReason: entry.auxReason ? String(entry.auxReason) : undefined,
71
+ // v0.6.7(报告一 H-4 / M-10):这笔消费**用量未知**(服务端没回 usage、或批次在拿不到
72
+ // token 数的情况下失败/被取消)。金额按 null 记,但 todayCost 会把条数汇总成可见信号,
73
+ // 免得"没记账"被读成"没花钱"。
74
+ usageUnknown: entry.usageUnknown === true ? true : undefined,
75
+ note: entry.note ? String(entry.note).slice(0, 200) : undefined,
71
76
  });
72
77
  // v0.6.3(BUG-009):**追加与轮转共用同一把锁**。
73
78
  // 原实现是「锁外追加 + 锁内轮转」,仍留有丢失窗口:A 追加 → B 追加 → A 进锁读整文件、
@@ -146,6 +151,22 @@ export function recordCacheStats(/** @type {any} */ entry) {
146
151
  let /** @type {any} */ _statsCache = null;
147
152
 
148
153
  export function listCacheStats(limit = 2000) {
154
+ const r = listCacheStatsStrict(limit);
155
+ return r.ok ? r.entries : [];
156
+ }
157
+
158
+ /**
159
+ * 严格读取(v0.6.7 / 报告一 H-3):区分「文件不存在」与「真的读不出来」。
160
+ *
161
+ * 为什么必须区分:`todayCost()` 护栏此前经过**宽松版**读取——`listCacheStats` 内部 `catch → []`,
162
+ * 于是"statSync 能过、readFileSync 失败"(Windows 杀软锁文件、权限竞态、盘符瞬断)会被当成
163
+ * **空列表 = 今日消费 ¥0**,护栏据此判定"没超支"并继续放行,且**没有任何告警**——
164
+ * 与该文件自己写下的「绝不静默当 0」(cost-guard.js)的纪律直接冲突。
165
+ * 护栏要的是「能不能读到」,不是「读到几条」。
166
+ * @param {number} [limit]
167
+ * @returns {{ok: true, entries: any[]} | {ok: false, error: string, code: string}}
168
+ */
169
+ export function listCacheStatsStrict(limit = 2000) {
149
170
  try {
150
171
  const file = cacheStatsFile();
151
172
  const st = fs.statSync(file);
@@ -160,9 +181,10 @@ export function listCacheStats(limit = 2000) {
160
181
  }
161
182
  _statsCache = { mtimeMs: st.mtimeMs, size: st.size, limit, lines: out };
162
183
  }
163
- return _statsCache.lines.slice(-limit);
164
- } catch {
165
- return [];
184
+ return { ok: true, entries: _statsCache.lines.slice(-limit) };
185
+ } catch (/** @type {any} */ err) {
186
+ // 读失败时**不能**复用旧缓存:这份数据是护栏的判据,宁可说"读不到"也不能给一份过期账
187
+ return { ok: false, error: String(err?.message ?? err), code: String(err?.code || '') };
166
188
  }
167
189
  }
168
190
 
@@ -198,9 +220,17 @@ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usa
198
220
  const priceAt = Number(perf?.requestStartAt) > 0 ? new Date(Number(perf.requestStartAt)) : undefined;
199
221
  const prompt = usage?.prompt_tokens || 0;
200
222
  const completion = usage?.completion_tokens || 0;
223
+ // v0.6.7(报告一 H-4 / 报告二 S-2「未知 ≠ 0」):服务端没回 usage 的调用**绝不能**以 ¥0 入账——
224
+ // 那会让"少计"看起来像"没花钱",护栏(warn/block/downgrade)随之失明且无信号。
225
+ // 这里记 cost:null + usageUnknown:true,todayCost 会把它计成"今日有 N 次用量未知",
226
+ // 由护栏给出可见告警(金额无法估算,但事实被记下来了)。
227
+ const usageUnknown = perf?.usageUnknown === true;
201
228
  let cost = null;
202
229
  let saved = null;
203
- if (split) {
230
+ if (usageUnknown) {
231
+ cost = null;
232
+ saved = null;
233
+ } else if (split) {
204
234
  const base = estimateCost(modelName, prompt, completion, null, priceAt);
205
235
  cost = estimateCost(modelName, prompt, completion, split, priceAt);
206
236
  if (base != null && cost != null) saved = base - cost;
@@ -217,6 +247,8 @@ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usa
217
247
  miss: split?.miss ?? null,
218
248
  cost,
219
249
  saved,
250
+ // v0.6.7(H-4):显式标记"这次用量未知",供 todayCost 汇总出可见信号
251
+ ...(usageUnknown ? { usageUnknown: true } : {}),
220
252
  steps: perf?.steps ?? undefined,
221
253
  llmMs: perf?.llmMs ?? undefined,
222
254
  toolMs: perf?.toolMs ?? undefined,
@@ -172,7 +172,11 @@ export async function handlePack(cmd, args) {
172
172
  process.exitCode = 1;
173
173
  return true;
174
174
  }
175
- const dir = path.resolve(process.cwd(), 'packs', name);
175
+ // v0.6.7(报告二 P3-5):此前写进 `cwd/packs/<name>` —— 该目录**不在任何发现路径**里
176
+ // (发现层级:内置 <repo>/packs → ~/.mingdao/packs → 项目 .mingdao/packs(信任门)→ config.packs),
177
+ // 于是照做的新用户"Pack 不生效且没有任何报错"。默认写到项目级 `.mingdao/packs/`,
178
+ // 并在下一步提示里给出 trust 命令(项目级必须显式信任才挂载)。
179
+ const dir = path.resolve(process.cwd(), '.mingdao', 'packs', name);
176
180
  if (fs.existsSync(dir)) {
177
181
  console.log(`[错误] 目录已存在:${dir}`);
178
182
  process.exitCode = 1;
@@ -228,6 +232,7 @@ export function createPack(ctx) {
228
232
  console.log(`✓ 已生成脚手架:${dir}`);
229
233
  console.log('下一步:');
230
234
  console.log(` 1. 编辑 ${path.join(dir, 'pack.json')}(描述/权限)与 pack.mjs(工具/约束)`);
235
+ console.log(` 2. mingdao pack trust ${path.dirname(path.dirname(dir))} # 项目级 Pack 必须显式信任才会挂载`);
231
236
  console.log(` 2. mingdao pack verify ${dir}`);
232
237
  return true;
233
238
  }
@@ -425,7 +425,10 @@ export async function runRepl(ctx) {
425
425
  io.print('用法:/memory add <内容>');
426
426
  continue;
427
427
  }
428
- fs.appendFileSync(memPath, `- ${text}\n`);
428
+ // v0.6.7(报告二 P3-3):此前是**裸 appendFileSync** —— ①没有日期戳(与 appendMemory 的
429
+ // `- [date] …` 格式不一致,后续 dedupe/检索的日期解析会漏掉这类条目);
430
+ // ②文件不存在时按默认 0644 新建,绕过记忆文件 0600 的私有权纪律。改走同一条写路径。
431
+ appendMemory([String(text)]); // 与 memoryFile() 同一路径(<home>/AGENTS.md,即上面的 memPath)
429
432
  io.print(style(`✓ 已追加到用户记忆 ${memPath}(后续会话自动生效)`, C.green));
430
433
  } else if (arg === 'extract') {
431
434
  io.startSpinner('正在从当前对话提炼记忆…');
package/src/config.js CHANGED
@@ -181,7 +181,15 @@ export async function runWizard(io) {
181
181
  const envDetected = hasEnvKey() ? '(检测到环境变量,回车直接使用)' : '';
182
182
  const input = await io.ask(`② ${provider} 的 API Key${envDetected}:`, { hidden: true });
183
183
  apiKey = String(input || '').trim();
184
- if (apiKey) setStoredKey(provider, apiKey);
184
+ if (apiKey) {
185
+ // v0.6.7(M-1):写函数已 fail-closed,这里必须把失败**说出来**(否则向导会继续往下走、
186
+ // 用户以为 Key 已保存,而实际什么都没写)。
187
+ const sk = setStoredKey(provider, apiKey);
188
+ if (sk && sk.ok === false) {
189
+ io.print(`[错误] ${sk.error}`); // 不用彩色工具:config.js 不依赖 ui.js 的样式模块(避免循环依赖)
190
+ continue;
191
+ }
192
+ }
185
193
  if (!apiKey && !hasEnvKey()) {
186
194
  const skipped = await io.confirm(' 未输入 API Key,跳过密钥设置?');
187
195
  if (!skipped) continue;
@@ -19,6 +19,8 @@
19
19
  /** @typedef {{ id: string, kind: string, pack?: string, tool?: string, pattern?: string, action?: string, fields?: string[], requireArg?: string, arg?: string, onMissing?: string }} Constraint */
20
20
 
21
21
  /** 合法的输出处置动作 */
22
+ import { hasNestedQuantifier, hasAmbiguousAlternation } from './regex-safety.js'; // v0.6.7(M-5):ReDoS 判定的单一来源
23
+
22
24
  export const OUTPUT_ACTIONS = new Set(['block', 'block-and-rewrite', 'warn']);
23
25
 
24
26
  /** 约束支持的 kind(与 packs.js / PACK-API §4 对齐) */
@@ -97,10 +99,11 @@ export function toolMatches(c, toolName) {
97
99
  * 比作者本意严得多,且理由里会印出 `/undefined/`,看起来像引擎坏了。
98
100
  * @param {any} pattern
99
101
  */
100
- // 经典灾难性回溯形状:**被量词修饰的组,其内部也以量词结尾**——
101
- // `(a+)+`、`(a*)*`、`(\d+)*`、`(x+){2,}`。这类模式在最坏输入上指数级回溯。
102
- // 刻意保守:只拦嵌套量词,不动 `(a|b)+`、`(ab)+`、`(a+)?` 这些常见且安全的写法。
103
- const REDOS_NESTED_QUANT = /\([^()]*[+*]\)(?:[+*]|\{\d*,?\d*\})/;
102
+ // v0.6.7(报告一 M-5 / M-14②):ReDoS 判定改为**共用 src/regex-safety.js**。
103
+ // 此前这里只看嵌套量词(`(a+)+`),而 grep 侧另有一份"歧义分支"判定——两份都不完整,
104
+ // 于是 `(a|aa)+`、`(a|a?)+`、`((a|ab)x)+` 在**约束引擎**这条路径上装载即通过
105
+ // (约束匹配的是模型输出,报告实测 28 字符即耗时数十秒 → 同步卡死整个进程)。
106
+ // 单一来源同时覆盖 约束装载 / Pack 校验 / grep 工具三处。
104
107
 
105
108
  /**
106
109
  * 约束 pattern 的拒绝原因(null = 可用)。
@@ -123,8 +126,9 @@ export function patternRejectionReason(/** @type {any} */ pattern) {
123
126
  } catch (/** @type {any} */ e) {
124
127
  return `正则无法编译:${e?.message || e}`;
125
128
  }
126
- if (REDOS_NESTED_QUANT.test(pattern)) {
127
- return 'pattern 含嵌套量词(如 (a+)+、(\\d+)*),在长文本上会指数级回溯——实测 29 字符即耗时数秒;请改写为不含嵌套量词的形式';
129
+ // 共用判定:嵌套量词 + 歧义分支(含嵌套组),见 src/regex-safety.js
130
+ if (hasNestedQuantifier(pattern) || hasAmbiguousAlternation(pattern)) {
131
+ return 'pattern 会灾难性回溯(嵌套量词或歧义分支,如 (a+)+、(a|aa)+、(a|a?)+、((a|ab)x)+)——实测 28–29 字符即耗时数秒到数十秒,会卡死内核;请改写为无歧义、无嵌套量词的形式';
128
132
  }
129
133
  return null;
130
134
  }
package/src/context.js CHANGED
@@ -145,5 +145,12 @@ export function trimMessages(/** @type {any} */ messages, /** @type {any} */ bud
145
145
  export function clampText(/** @type {any} */ text, maxChars = TOOL_RESULT_LIMIT) {
146
146
  const s = String(text);
147
147
  if (s.length <= maxChars) return s;
148
- return s.slice(0, maxChars) + `\n…[输出过长已截断,原文共 ${s.length} 字符]`;
148
+ // v0.6.7(报告一 L-1):此前是裸 slice —— 截断点落在**代理对**中间会切出半个 emoji,
149
+ // 后续序列化/渲染得到 U+FFFD(BUG-079 只修了 safeHead 那条路径)。这里与它同源:回退一个码元。
150
+ const safeHead = (/** @type {string} */ t, /** @type {number} */ n) => {
151
+ if (t.length <= n) return t;
152
+ const c = t.charCodeAt(n - 1);
153
+ return c >= 0xd800 && c <= 0xdbff ? t.slice(0, n - 1) : t.slice(0, n);
154
+ };
155
+ return safeHead(s, maxChars) + `\n…[输出过长已截断,原文共 ${s.length} 字符]`;
149
156
  }