mingdao-harness 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/docs/AUDIT-v0.6.1-/347/254/254/344/270/211/346/226/271/346/212/245/345/221/212/347/231/273/350/256/260.md +208 -16
  2. package/docs/CONFIG.md +32 -6
  3. package/docs/DEVELOPER.md +1 -1
  4. package/docs/PACK-API.md +26 -3
  5. package/package.json +1 -1
  6. package/src/agent.js +57 -9
  7. package/src/atomic-write.js +63 -3
  8. package/src/audit.js +23 -18
  9. package/src/cachestats.js +57 -47
  10. package/src/cli.js +25 -4
  11. package/src/commands/diagnose.js +3 -0
  12. package/src/commands/key.js +23 -1
  13. package/src/commands/ledger.js +41 -3
  14. package/src/commands/net.js +5 -1
  15. package/src/commands/pack.js +23 -6
  16. package/src/commands/repl.js +9 -1
  17. package/src/commands/skill.js +30 -1
  18. package/src/commands/sync.js +7 -0
  19. package/src/compact.js +15 -1
  20. package/src/config.js +71 -6
  21. package/src/constraints.js +55 -6
  22. package/src/credentials.js +36 -5
  23. package/src/hooks.js +37 -2
  24. package/src/ledger.js +3 -3
  25. package/src/log-writer.js +22 -17
  26. package/src/mcp.js +19 -1
  27. package/src/memory.js +8 -8
  28. package/src/packs.js +57 -8
  29. package/src/permissions.js +35 -9
  30. package/src/presets.js +46 -1
  31. package/src/providers/index.js +65 -7
  32. package/src/providers/openai-compatible.js +24 -3
  33. package/src/redact.js +38 -2
  34. package/src/replay.js +15 -3
  35. package/src/safe-fetch.js +8 -2
  36. package/src/schedule.js +22 -4
  37. package/src/session-index.js +2 -2
  38. package/src/session.js +2 -2
  39. package/src/skill-lib.js +43 -14
  40. package/src/skill-registry.js +31 -2
  41. package/src/sync-server.js +47 -28
  42. package/src/sync.js +17 -3
  43. package/src/task-state.js +10 -1
  44. package/src/tasks.js +3 -3
  45. package/src/tools/fetch.js +24 -0
  46. package/src/web/app.js +7 -7
  47. package/src/web/index.html +2 -2
  48. package/src/web/routes/api.js +27 -2
  49. package/src/web/routes/domains/sync.js +2 -1
  50. package/src/web/routes/domains/workspace.js +39 -9
  51. package/src/web/server.js +14 -1
  52. package/src/workspace.js +3 -3
@@ -19,6 +19,7 @@
19
19
  // 零依赖:仅 node:fs / node:path / node:crypto / node:async_hooks / Atomics.wait / timers/promises。
20
20
 
21
21
  import fs from 'node:fs';
22
+ import { pidOwnedBy } from './proc.js';
22
23
  import path from 'node:path';
23
24
  import crypto from 'node:crypto';
24
25
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -39,6 +40,41 @@ export function atomicWriteFileSync(/** @type {string} */ target, /** @type {str
39
40
  }
40
41
  }
41
42
 
43
+ // ---------------------------------------------------------------------------
44
+ // 「私有文件」写入(v0.6.3,审计 H-1)
45
+ //
46
+ // 背景:会话/记忆/任务/调度/工作空间/索引这些文件此前直接 appendFileSync / 原子写**不带 mode**,
47
+ // 默认 umask 下是 0644——而会话原文会原样记录用户粘贴的 sk-*、PEM、JWT,同机其它用户可读。
48
+ // 同仓的账本/审计/凭据早已 0600,属于「同模块两套口径」。
49
+ //
50
+ // 两条纪律:
51
+ // ① 创建时带 mode: 0o600;
52
+ // ② **每次写都补一次 chmod 自愈**——mode 只在创建时生效,对旧版本留下的 0644 文件不起作用
53
+ // (凭据/审计早已这么做,这里收敛成共享助手,避免八处各写一遍再漂移)。
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * 追加写入并保证 0600(对已存在文件收权自愈)。
58
+ * @param {string} file @param {string|Buffer} data
59
+ */
60
+ export function appendFilePrivateSync(file, data) {
61
+ fs.appendFileSync(file, data, { mode: 0o600 });
62
+ try {
63
+ fs.chmodSync(file, 0o600);
64
+ } catch {}
65
+ }
66
+
67
+ /**
68
+ * 原子写入并保证 0600(对已存在文件收权自愈)。
69
+ * @param {string} file @param {string|Buffer} data
70
+ */
71
+ export function atomicWritePrivateSync(file, data) {
72
+ atomicWriteFileSync(file, data, { mode: 0o600 });
73
+ try {
74
+ fs.chmodSync(file, 0o600);
75
+ } catch {}
76
+ }
77
+
42
78
  export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any} */ value, { mode = 0o600 } = {}) {
43
79
  atomicWriteFileSync(target, JSON.stringify(value, null, 2) + '\n', { mode });
44
80
  }
@@ -72,7 +108,19 @@ function tryAcquire(lockPath) {
72
108
  throw err;
73
109
  }
74
110
  try {
75
- fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
111
+ // v0.6.3(M-11):锁内容额外记下本进程的**入口脚本**,作为 pid 的身份凭据。
112
+ // 起因:陈旧回收只看「持有者 pid 是否存活」,而 pid 会被复用——复用给一个活进程后
113
+ // `procAlive` 恒真,这把锁**永不回收**,所有写方等到超时失败(死锁)。
114
+ // 有了 cmd(argv[1]),回收方可以用既有的 pidOwnedBy() 校验"活着的这个 pid 还是不是原来那个进程"。
115
+ // v0.6.3(M-11):锁内容额外记下入口脚本的**文件名**作为 pid 的身份凭据。
116
+ // 起因:陈旧回收只看「持有者 pid 是否存活」,而 pid 会被复用——复用给一个活进程后
117
+ // `procAlive` 恒真,这把锁**永不回收**,所有写方等到超时失败(死锁)。
118
+ //
119
+ // 为什么是 basename 而不是 argv[1]:`ps` 显示的是**输入时的形态**,而 Node 把 argv[1]
120
+ // 解析成绝对路径——`node test/smoke.js` 在 ps 里就是 `node test/smoke.js`。
121
+ // 第一版存 argv[1],于是"同进程内的并发任务"也被判成 pid 复用 → **把活锁抢走**,
122
+ // 既有的并发串行断言当场抓到(3 次自增只剩 1)。basename 在两种形态下都出现,是稳妥的交集。
123
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now(), cmd: path.basename(process.argv[1] || process.execPath) }));
76
124
  } finally {
77
125
  fs.closeSync(fd);
78
126
  }
@@ -105,10 +153,22 @@ function reclaimIfStale(lockPath, staleMs) {
105
153
  return true; // 锁文件刚被释放:立刻重试
106
154
  }
107
155
  let holderPid = 0;
156
+ let holderCmd = '';
108
157
  try {
109
- holderPid = Number(JSON.parse(fs.readFileSync(lockPath, 'utf8'))?.pid) || 0;
158
+ const meta = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
159
+ holderPid = Number(meta?.pid) || 0;
160
+ holderCmd = String(meta?.cmd || '');
110
161
  } catch {}
111
- const holderAlive = holderPid > 0 ? procAlive(holderPid) : null;
162
+ let holderAlive = holderPid > 0 ? procAlive(holderPid) : null;
163
+ // M-11:pid 存活 ≠ 原持有者还活着。用命令行归属校验识破 pid 复用:
164
+ // · pidOwnedBy 返回 false → 这个 pid 现在跑的是**别的**程序 → 原持有者已死,可以回收;
165
+ // · 返回 true(同一入口脚本,可能是也可能不是原进程)或 null(Windows/取不到命令行)→ 保持"活着"。
166
+ // 这一层是**严格改进**:原先这种情况一律不回收,现在只有"能确证不是原进程"时才回收。
167
+ if (holderAlive === true && holderCmd) {
168
+ try {
169
+ if (pidOwnedBy(holderPid, holderCmd) === false) holderAlive = false;
170
+ } catch {}
171
+ }
112
172
  const reclaimable = holderAlive === false || (holderAlive === null && Date.now() - st.mtimeMs > staleMs);
113
173
  if (!reclaimable) return false;
114
174
  // TOCTOU 防护(OfficeACE 报告):unlink 前读锁内容并二次 stat 比对,
package/src/audit.js CHANGED
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { mingdaoHome, ensureHome } from './config.js';
10
10
  import { redactSecrets } from './redact.js';
11
- import { atomicWriteFileSync } from './atomic-write.js';
11
+ import { atomicWriteFileSync, withFileLockSync } from './atomic-write.js';
12
12
 
13
13
  // v0.6.2(第三方代码审计 P2-2):截断触发改为看**文件大小**。
14
14
  // 原判据 `auditCount > 20000` 是**进程内**计数:CLI 每次会话只 append 几次、进程结束即归零,
@@ -40,10 +40,28 @@ export function writeAudit(/** @type {any} */ entry) {
40
40
  try {
41
41
  ensureHome();
42
42
  const file = auditFile();
43
- fs.appendFileSync(file, JSON.stringify(entry) + '\n');
44
- try {
45
- fs.chmodSync(file, 0o600);
46
- } catch {}
43
+ // v0.6.3(M-20):**追加与轮转必须在同一把锁里**。原实现是「锁外追加 + 锁外读改写轮转」,
44
+ // 丢失窗口很实在:A 追加 → B 追加 → A 读到含 A 的快照 → A 原子写回 → B 那一行消失。
45
+ // 审计是合规证据,"少一行"等于证据链有洞,而且不报错。锁的范围含 append:代价是每次
46
+ // 工具调用多一次毫秒级文件锁(审计本就每工具调用一条,不在热路径上)。
47
+ let appended = false;
48
+ withFileLockSync(file + '.lock', () => {
49
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
50
+ appended = true;
51
+ try {
52
+ fs.chmodSync(file, 0o600);
53
+ } catch {}
54
+ // 低频截断:statSync 廉价,只有真的超过阈值才整文件读一次并重写
55
+ if (fs.statSync(file).size > MAX_BYTES) {
56
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
57
+ if (lines.length > KEEP_LINES) {
58
+ // v0.6.2(P2-3):**原子写**——原先直接 writeFileSync,截断过程中崩溃会留下半截
59
+ // audit.jsonl(审计证据丢事件)。atomicWriteFileSync 的 tmp 名含 pid+随机后缀,
60
+ // 写完 rename 原子替换,读者永远看到完整文件;mode 保持 0600。
61
+ atomicWriteFileSync(file, lines.slice(-KEEP_LINES).join('\n') + '\n', { mode: 0o600 });
62
+ }
63
+ }
64
+ });
47
65
 
48
66
  } catch (err) {
49
67
  // v0.6.2(B-WS-1/2 第八处):仍然「不影响会话」(审计不能反过来打断用户干活),
@@ -60,19 +78,6 @@ export function writeAudit(/** @type {any} */ entry) {
60
78
  }
61
79
  return;
62
80
  }
63
- // 低频截断:statSync 廉价,只有真的超过阈值才整文件读一次并重写
64
- try {
65
- const f = auditFile(); // 上面 try 里的 file 是块内作用域,这里单独取一次
66
- if (fs.statSync(f).size > MAX_BYTES) {
67
- const lines = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean);
68
- if (lines.length > KEEP_LINES) {
69
- // v0.6.2(P2-3):**原子写**——原先直接 writeFileSync,截断过程中崩溃会留下半截
70
- // audit.jsonl(审计证据丢事件)。atomicWriteFileSync 的 tmp 名含 pid+随机后缀,
71
- // 写完 rename 原子替换,读者永远看到完整文件;mode 保持 0600。
72
- atomicWriteFileSync(f, lines.slice(-KEEP_LINES).join('\n') + '\n', { mode: 0o600 });
73
- }
74
- }
75
- } catch {}
76
81
  }
77
82
 
78
83
  export function listAudit(limit = 20) {
package/src/cachestats.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { mingdaoHome, ensureHome } from './config.js';
7
7
  import { estimateCost, cacheSplit, beijingDayStart, beijingParts } from './pricing.js';
8
- import { withFileLockSync, atomicWriteFileSync } from './atomic-write.js';
8
+ import { withFileLockSync, atomicWriteFileSync, appendFilePrivateSync } from './atomic-write.js';
9
9
 
10
10
  export function cacheStatsFile() {
11
11
  return path.join(mingdaoHome(), 'cache-stats.jsonl');
@@ -69,63 +69,73 @@ export function recordCacheStats(/** @type {any} */ entry) {
69
69
  aux: entry.aux === true ? true : undefined,
70
70
  auxReason: entry.auxReason ? String(entry.auxReason) : undefined,
71
71
  });
72
- fs.appendFileSync(cacheStatsFile(), line + '\n');
73
- } catch (err) {
74
- const msg = String(/** @type {any} */ (err)?.message ?? err);
75
- lastWriteError = msg;
76
- // 只提示一次:写不进去通常是持续性问题(磁盘满/权限),每回合刷屏反而让人忽略它
77
- if (!writeWarned) {
78
- writeWarned = true;
79
- console.warn(
80
- `[MingDao] ⚠ 费用明细写入失败:${msg}\n` +
81
- ` 今日费用护栏会**少计**这部分消费(可能超支而不知);请检查 ${cacheStatsFile()} 的磁盘空间与权限。`
82
- );
83
- }
84
- // 追加都失败了,后面的轮转毫无意义(statSync 也大概率抛错),直接返回真实原因,
85
- // 不要让轮转分支的异常把 append 的原因覆盖掉
86
- return { ok: false, error: msg, phase: 'append' };
87
- }
88
- try {
89
- if (fs.statSync(cacheStatsFile()).size > MAX_BYTES) {
90
- // 审计 P2-3(v0.4.2):轮转 read-modify-write 加跨进程锁——web/CLI/worker 多进程并发轮转时,
91
- // 读与写之间他人追加的行会被覆写丢失;锁内重读再瘦身,写用原子替换。
72
+ // v0.6.3(BUG-009):**追加与轮转共用同一把锁**。
73
+ // 原实现是「锁外追加 + 锁内轮转」,仍留有丢失窗口:A 追加 → B 追加 → A 进锁读整文件、
74
+ // 算出瘦身结果、原子替换 —— B 那一行若落在 A 的「读」与「写」之间,就被整文件替换覆盖掉。
75
+ // 丢的是费用明细,而费用明细正是日费用护栏的依据(少计 → 超支而不自知)。
76
+ let appended = false;
77
+ try {
92
78
  withFileLockSync(cacheStatsFile() + '.lock', () => {
93
- const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
94
- const lines = raw.split('\n').filter(Boolean);
95
- if (lines.length > MAX_LINES) {
96
- // v0.4.6 P2 修复:轮转必须保留**当天全部**记录。此前只保留最后 KEEP_LINES 行——
79
+ appendFilePrivateSync(cacheStatsFile(), line + '\n');
80
+ appended = true;
81
+ if (fs.statSync(cacheStatsFile()).size > MAX_BYTES) {
82
+ // 审计 P2-3(v0.4.2):轮转的 read-modify-write 同样有并发窗口,现在它与追加在同一锁内。
83
+ // v0.4.6 P2:轮转必须保留**当天全部**记录。此前只保留最后 KEEP_LINES 行——
97
84
  // 单进程写满 2 万条后,当天早先的费用会被裁掉,todayCost() 随之变小,
98
85
  // 日费用护栏被静默重置(用户可再次超支而不自知)。当天行(最多 MAX_LINES 条)
99
86
  // 与最近 KEEP_LINES 行取并集,去重且保持原顺序。
100
- const dayStart = beijingDayStart().getTime();
101
- const todayLines = lines.filter((/** @type {string} */ l) => {
102
- try {
103
- return (JSON.parse(l).at || 0) >= dayStart;
104
- } catch {
105
- return false;
106
- }
107
- });
108
- const tail = lines.slice(-KEEP_LINES);
109
- const tailSet = new Set(tail);
110
- const todayCapped = todayLines.length > MAX_LINES ? todayLines.slice(-MAX_LINES) : todayLines;
111
- const todaySet = new Set(todayCapped);
112
- const merged = [...todayCapped, ...tail.filter((l) => !todaySet.has(l))];
113
- // 顺序按原文件恢复(避免打乱 byDay 折线的时间序)
114
- const order = new Map(lines.map((l, i) => [l, i]));
115
- merged.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
116
- atomicWriteFileSync(cacheStatsFile(), merged.join('\n') + '\n');
87
+ const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
88
+ const rows = raw.split('\n').filter(Boolean);
89
+ if (rows.length > MAX_LINES) {
90
+ const dayStart = beijingDayStart().getTime();
91
+ const todayLines = rows.filter((/** @type {string} */ l) => {
92
+ try {
93
+ return (JSON.parse(l).at || 0) >= dayStart;
94
+ } catch {
95
+ return false;
96
+ }
97
+ });
98
+ const tail = rows.slice(-KEEP_LINES);
99
+ const tailSet = new Set(tail);
100
+ const todayCapped = todayLines.length > MAX_LINES ? todayLines.slice(-MAX_LINES) : todayLines;
101
+ const todaySet = new Set(todayCapped);
102
+ const merged = [...todayCapped, ...tail.filter((l) => !todaySet.has(l))];
103
+ // 顺序按原文件恢复(避免打乱 byDay 折线的时间序)
104
+ const order = new Map(rows.map((l, k) => [l, k]));
105
+ merged.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
106
+ atomicWriteFileSync(cacheStatsFile(), merged.join('\n') + '\n');
107
+ }
117
108
  }
118
109
  });
110
+ } catch (err) {
111
+ const msg = String(/** @type {any} */ (err)?.message ?? err);
112
+ lastWriteError = msg;
113
+ if (!appended) {
114
+ // 只提示一次:写不进去通常是持续性问题(磁盘满/权限),每回合刷屏反而让人忽略它
115
+ if (!writeWarned) {
116
+ writeWarned = true;
117
+ console.warn(
118
+ `[MingDao] ⚠ 费用明细写入失败:${msg}\n` +
119
+ ` 今日费用护栏会**少计**这部分消费(可能超支而不知);请检查 ${cacheStatsFile()} 的磁盘空间与权限。`
120
+ );
121
+ }
122
+ return { ok: false, error: msg, phase: 'append' };
123
+ }
124
+ // 追加已成功、只有轮转失败:丢的不是账,而是文件会持续增长
125
+ if (!rotateWarned) {
126
+ rotateWarned = true;
127
+ console.warn(`[MingDao] ⚠ 费用明细轮转失败:${msg}\n ${cacheStatsFile()} 会持续增长(不影响计费,但请检查磁盘空间/权限)。`);
128
+ }
129
+ return { ok: false, error: msg, phase: 'rotate' };
119
130
  }
120
131
  } catch (err) {
121
- // 轮转失败与追加失败是两件事:这里丢的不是账,而是文件会持续增长
122
132
  const msg = String(/** @type {any} */ (err)?.message ?? err);
123
133
  lastWriteError = msg;
124
- if (!rotateWarned) {
125
- rotateWarned = true;
126
- console.warn(`[MingDao] ⚠ 费用明细轮转失败:${msg}\n ${cacheStatsFile()} 会持续增长(不影响计费,但请检查磁盘空间/权限)。`);
134
+ if (!writeWarned) {
135
+ writeWarned = true;
136
+ console.warn(`[MingDao] ⚠ 费用明细写入失败:${msg}\n 今日费用护栏会**少计**这部分消费(可能超支而不知);请检查 ${cacheStatsFile()} 的磁盘空间与权限。`);
127
137
  }
128
- return { ok: false, error: msg, phase: 'rotate' };
138
+ return { ok: false, error: msg, phase: 'append' };
129
139
  }
130
140
  return { ok: true, error: null, phase: null };
131
141
  }
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs';
7
7
  import { DEFAULT_MODEL } from './models.js';
8
8
  import path from 'node:path';
9
9
  import readline from 'node:readline';
10
- import { loadConfig, saveConfig, runWizard, ensureHome, mingdaoHome } from './config.js';
10
+ import { loadConfig, saveConfig, runWizard, ensureHome, mingdaoHome, readConfigStrict, quarantineCorruptConfig } from './config.js';
11
11
  import { helpLines } from './help.js';
12
12
  import { modelPreset, PROVIDERS } from './models.js';
13
13
  import { maskKey, getStoredKey } from './credentials.js';
@@ -231,7 +231,7 @@ async function main() {
231
231
  if (opts.prompt[0] === 'schedule-daemon') {
232
232
  const home0 = ensureHome();
233
233
  const { listSchedules, runSleeper, sleeperAlive, procAlive, daemonPidFile, writeSchedule } = await import('./schedule.js');
234
- const { readTask } = await import('./tasks.js');
234
+ const { readTask, taskWorkerAlive } = await import('./tasks.js');
235
235
  const handled = new Set();
236
236
  const supervising = new Set(); // 本 daemon 正在监督的任务(防崩溃恢复误判正在执行的任务)
237
237
  const nonce = String(opts.prompt[1] || '');
@@ -261,7 +261,16 @@ async function main() {
261
261
  // v0.4.7(P2 T15):先看「跑这个任务的宿主进程」是否仍存活。存活说明它正在跑
262
262
  // (可能还没写 lastTaskId),**等它**——否则「本 daemon 刚接管 + 旧 daemon 在途」
263
263
  // 会被当成崩溃残留 → 重置 pending → 并发重跑同一任务。
264
- if (procAlive(j.runnerPid)) continue;
264
+ //
265
+ // v0.6.3(P1-5):但「宿主就是自己」时必须例外——markRunning 写的 runnerPid 就是
266
+ // 本 daemon 的 pid,于是 `procAlive(自己)` 恒真 → `continue` → 这条 job **永久跳过**:
267
+ // 面板一直转圈、既不重跑也不收尾,用户只能 daemon stop + 删 pidfile 自愈。
268
+ // 同理:宿主只是"标记",真正在跑的是 detached worker,绝不能凭宿主死活就重排。
269
+ const runnerIsSelf = Number(j.runnerPid) === process.pid;
270
+ if (!runnerIsSelf && procAlive(j.runnerPid)) continue;
271
+ // worker 仍活着(宿主被 SIGKILL 但 detached worker 还在跑)→ **绝不重排**,
272
+ // 否则同一个任务会跑第二遍;等它收尾,下一轮恢复会走「t.status !== running」的定案分支。
273
+ if (t && t.status === 'running' && taskWorkerAlive(t)) continue;
265
274
  if (!t) {
266
275
  await writeSchedule(home0, { ...j, status: 'pending' });
267
276
  continue;
@@ -292,7 +301,14 @@ async function main() {
292
301
  handled.add(j.id);
293
302
  supervising.add(j.id);
294
303
  runSleeper(home0, j.id, { shouldStop: () => leaseLost })
295
- .catch(() => {})
304
+ // v0.6.3(P1-5):不能静默吞。协程一次异常(锁超时/写盘失败)此前**无痕**消失,
305
+ // 于是 job 停在 running、下一轮恢复又因 runnerPid 指向自己而跳过 → 永久卡住。
306
+ // 现在至少留一条可诊断的日志(用户能在 daemon 日志里看到"为什么没跑")。
307
+ .catch((/** @type {any} */ e) => {
308
+ try {
309
+ console.error(`[MingDao] ⚠ 调度协程异常(任务 ${j.id}):${e?.message || e}`);
310
+ } catch {}
311
+ })
296
312
  .finally(() => {
297
313
  handled.delete(j.id);
298
314
  supervising.delete(j.id);
@@ -341,6 +357,11 @@ async function main() {
341
357
  return;
342
358
  }
343
359
  const home = ensureHome();
360
+ // v0.6.3(H-7):进向导**之前**先分清「没有配置」与「配置读不出来」。
361
+ // 后者若被当成首次运行,向导会用全新对象整文件覆盖掉用户配置(customModels/mcpServers/
362
+ // sync/net/costGuard 全丢)且不备份。这里先把损坏文件改名备份并告警,再继续走首次向导。
363
+ const cfgStrict = readConfigStrict();
364
+ if (cfgStrict.exists && !cfgStrict.ok) quarantineCorruptConfig(cfgStrict.error || '未知原因');
344
365
  let cfg = loadConfig();
345
366
  if (!cfg || opts.init) {
346
367
  const wio = createIO();
@@ -112,6 +112,9 @@ export async function handleDiagnose(/** @type {any} */ _cmd, /** @type {any} */
112
112
  io.print(style('请把该文件内容贴到反馈渠道排查;密钥/token/私网路径已脱敏。', C.dim));
113
113
  } catch (/** @type {any} */ err) {
114
114
  io.print(style('[错误] ' + (err?.message || err), C.red));
115
+ // v0.6.3(M-21):诊断包生成失败必须体现在退出码上——脚本化收集诊断信息时
116
+ // 「打印了错误但退 0」会让调用方以为拿到了报告。
117
+ process.exitCode = 1;
115
118
  } finally {
116
119
  io.close();
117
120
  }
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import { createIO, style, C } from '../ui.js';
4
4
  import { ensureHome } from '../config.js';
5
- import { setStoredKey, removeStoredKey, credentialsPath, loadCredentials, maskKey } from '../credentials.js';
5
+ import { setStoredKey, removeStoredKey, credentialsPath, loadCredentials, readCredentialsStrict, maskKey } from '../credentials.js';
6
6
  import { PROVIDERS } from '../models.js';
7
7
 
8
8
  export async function handleKey(/** @type {any} */ cmd, /** @type {any} */ args) {
@@ -10,6 +10,25 @@ export async function handleKey(/** @type {any} */ cmd, /** @type {any} */ args)
10
10
  try {
11
11
  const sub = args[0] || 'status';
12
12
  const target = args[1];
13
+ // v0.6.3(审计 H-8):**写路径前置守卫**——凭证库存在但读不出来时,绝不进入
14
+ // 「读→改→全量重写」这一步:那会把其余凭据静默清空(实测复现)。
15
+ // 读路径(status)不受影响:它只需要知道"有哪些 key",损坏时显示空即可。
16
+ if (sub === 'set' || sub === 'remove' || sub === 'import') {
17
+ const st = readCredentialsStrict();
18
+ if (!st.ok) {
19
+ io.print(style(`✗ 凭证库无法读取,已拒绝写入:${st.error}`, C.red));
20
+ io.print(style(` 文件:${credentialsPath()}`, C.dim));
21
+ io.print(
22
+ style(
23
+ ' 继续写会**清空其余全部凭据**,所以这里选择拒绝。请先人工检查/修复该文件;' +
24
+ '若内容已不可恢复,把它改名备份(如 credentials.json.bak)后重试,届时会当作全新凭证库。',
25
+ C.dim
26
+ )
27
+ );
28
+ process.exitCode = 1;
29
+ return true;
30
+ }
31
+ }
13
32
  if (sub === 'status') {
14
33
  ensureHome();
15
34
  io.print(style(`本地凭证库:${credentialsPath()}`, C.bold));
@@ -86,7 +105,10 @@ export async function handleKey(/** @type {any} */ cmd, /** @type {any} */ args)
86
105
  }
87
106
  if (!count) io.print('没有可导入的环境变量(如 DEEPSEEK_API_KEY)。');
88
107
  } else {
108
+ // v0.6.3(M-21):未知子命令此前打印用法后**退 0**——`mingdao key setx …` 会被
109
+ // 脚本当成「设置成功」。用法提示与失败同码是退出码语义的静默失效。
89
110
  io.print('用法:mingdao key [status|set <服务商> [key]|remove <服务商>|import]');
111
+ process.exitCode = 1;
90
112
  }
91
113
  } finally {
92
114
  io.close();
@@ -14,17 +14,33 @@ import { createIO, style, C } from '../ui.js';
14
14
  import { listRuns, readRun, verifyRun, exportRun, isValidRunId, ledgerDir } from '../ledger.js';
15
15
  import { replayRun, renderReplay, KIND } from '../replay.js';
16
16
  import { loadConfig } from '../config.js';
17
- import { getActivePackContext } from '../packs.js';
17
+ import { getActivePackContext, mountPacks } from '../packs.js';
18
+
19
+ /** 用法串:未知子命令与用法提示共用一份,避免两处漂移 */
20
+ const USAGE = '用法:mingdao ledger list [数量] | show <runId> | export <runId> [--format json|md] [--out 文件] | verify <runId> | replay <runId> [--json]';
21
+ const KNOWN_SUBS = new Set(['list', 'show', 'verify', 'replay', 'export']);
18
22
 
19
23
  /** @param {any} cmd @param {any} args */
20
24
  export async function handleLedger(cmd, args) {
21
25
  const io = createIO();
22
26
  const sub = args[0] || 'list';
23
27
  const rest = args.slice(1);
28
+ // v0.6.3(M-21):未知子命令此前会一路走到函数末尾「打印用法并退 0」,脚本/CI 无法与
29
+ // 「命令成功」区分。这里**先**判子命令——顺序很重要:放在 runId 解析之后会被
30
+ // 「runId 格式不合法」分支抢先命中,于是同样的输入有时退 1、有时退 0。
31
+ if (!KNOWN_SUBS.has(sub)) {
32
+ io.print(USAGE);
33
+ process.exitCode = 1;
34
+ return true;
35
+ }
24
36
  const flag = (/** @type {string} */ name, /** @type {any} */ dflt = null) => {
25
37
  const i = rest.indexOf(name);
26
38
  return i >= 0 && rest[i + 1] ? rest[i + 1] : dflt;
27
39
  };
40
+ // v0.6.3(M-12):布尔开关必须与「取值型参数」分开解析。
41
+ // 原实现只有上面的取值型 `flag()`:`--json` 写在末尾时取不到「下一个值」→ 返回 null →
42
+ // `ledger replay <id> --json` 恒走人读分支,`| jq` 直接失败(而且失败得很安静)。
43
+ const boolFlag = (/** @type {string} */ name) => rest.includes(name);
28
44
  // 位置参数:排除掉 --xxx 及其取值
29
45
  const positional = rest.filter((/** @type {any} */ a, /** @type {number} */ i) => !String(a).startsWith('--') && !String(rest[i - 1] || '').startsWith('--'));
30
46
 
@@ -54,11 +70,15 @@ export async function handleLedger(cmd, args) {
54
70
  }
55
71
  if (!isValidRunId(runId)) {
56
72
  io.print(`runId 格式不合法:${runId}(形如 mtx16g3y-628443)`);
73
+ // v0.6.3(M-21):凡是「用户要求的事没做成」,退出码必须是 1。
74
+ // 此前 `ledger verify <乱写的 id>` 会打印一行错误然后退 0,CI 拿它当门禁即假通过。
75
+ process.exitCode = 1;
57
76
  return true;
58
77
  }
59
78
  const events = readRun(runId);
60
79
  if (!events.length) {
61
80
  io.print(`没有找到账本 ${runId}。用 mingdao ledger list 查看现有账本。`);
81
+ process.exitCode = 1;
62
82
  return true;
63
83
  }
64
84
 
@@ -92,7 +112,20 @@ export async function handleLedger(cmd, args) {
92
112
  // 当前规则栈:显式配置的 constraints 优先,否则取进程级已挂载的 Pack 约束;
93
113
  // 权限取 config.permission(缺省 ask)。回放**不联网、无副作用**。
94
114
  const cfg = loadConfig() || {};
115
+ // v0.6.3(P1-12):CLI 的命令分发发生在 cli.js 的 mountPacks **之前**,因此
116
+ // `getActivePackContext()` 在这里恒为 null → constraints=[] → compiled.active=false →
117
+ // 回放恒输出「当前没有任何生效的领域约束」,**恒通过**。把它当 CI 门禁时是静默假阴性。
118
+ // 与启动路径同口径地挂一次 Pack(幂等),让「新红线能不能拦住历史操作」这个问题
119
+ // 在 CLI 下真正有答案。
120
+ if (!Array.isArray(cfg.constraints) && !getActivePackContext()) {
121
+ try {
122
+ await mountPacks(cfg, { cwd: process.cwd() });
123
+ } catch (/** @type {any} */ e) {
124
+ io.print(style(`⚠ Pack 挂载失败,本次回放将看不到 Pack 约束:${e?.message || e}`, C.yellow));
125
+ }
126
+ }
95
127
  const currentConstraints = Array.isArray(cfg.constraints) ? cfg.constraints : getActivePackContext()?.constraints ?? [];
128
+ const asJson = boolFlag('--json');
96
129
  const r = replayRun(runId, { constraints: currentConstraints, permission: cfg.permission ?? 'ask' });
97
130
  if (r.error || !r.summary) {
98
131
  io.print(String(r.error ?? '回放失败'));
@@ -100,7 +133,7 @@ export async function handleLedger(cmd, args) {
100
133
  return true;
101
134
  }
102
135
  const summary = r.summary;
103
- if (flag('--json')) {
136
+ if (asJson) {
104
137
  io.print(JSON.stringify({ runId: r.runId, summary, steps: r.steps, notes: r.notes }, null, 2));
105
138
  } else {
106
139
  io.print(renderReplay(r).trimEnd());
@@ -108,7 +141,7 @@ export async function handleLedger(cmd, args) {
108
141
  // 非零退出码让它能当门禁用:CI 里「新红线必须能拦住历史上那批操作」就是这个断言
109
142
  if (summary.nowBlocked > 0) {
110
143
  process.exitCode = 1;
111
- if (!flag('--json')) io.print(style(`\n↑ 有 ${summary.nowBlocked} 步今天会被红线拦住:若这正是新红线的目的,回放通过;否则说明规则收得过紧。`, C.yellow));
144
+ if (!asJson) io.print(style(`\n↑ 有 ${summary.nowBlocked} 步今天会被红线拦住:若这正是新红线的目的,回放通过;否则说明规则收得过紧。`, C.yellow));
112
145
  }
113
146
  return true;
114
147
  }
@@ -117,11 +150,13 @@ export async function handleLedger(cmd, args) {
117
150
  const format = String(flag('--format', 'json'));
118
151
  if (format !== 'json' && format !== 'md') {
119
152
  io.print('--format 只支持 json 或 md');
153
+ process.exitCode = 1;
120
154
  return true;
121
155
  }
122
156
  const r = exportRun(runId, { format });
123
157
  if (r.error) {
124
158
  io.print(r.error);
159
+ process.exitCode = 1;
125
160
  return true;
126
161
  }
127
162
  const out = flag('--out');
@@ -140,6 +175,9 @@ export async function handleLedger(cmd, args) {
140
175
  return true;
141
176
  }
142
177
 
178
+ // v0.6.3(M-21):未知子命令此前静默落到「打印用法并退 0」——CI/脚本无法区分
179
+ // 「用法提示」与「命令成功」。这是退出码语义的静默失效,明确退 1。
143
180
  io.print('用法:mingdao ledger list [数量] | show <runId> | export <runId> [--format json|md] [--out 文件] | verify <runId> | replay <runId> [--json]');
181
+ process.exitCode = 1;
144
182
  return true;
145
183
  }
@@ -24,7 +24,9 @@ function parseSince(/** @type {any} */ v) {
24
24
  /** @param {any} cmd @param {any} args */
25
25
  export async function handleNet(cmd, args) {
26
26
  const io = createIO();
27
- const sub = args[0] || 'report';
27
+ // `mingdao net --json` 这类「只给开关、没给子命令」的调用此前会把 `--json` 当成子命令,
28
+ // 于是一个合法调用反而落到用法提示;以 `-` 开头的参数一律视为 report 的开关。
29
+ const sub = args[0] && !String(args[0]).startsWith('-') ? args[0] : 'report';
28
30
  const cfg = loadConfig() || {};
29
31
 
30
32
  if (sub === 'policy') {
@@ -81,6 +83,8 @@ export async function handleNet(cmd, args) {
81
83
  return true;
82
84
  }
83
85
 
86
+ // v0.6.3(M-21):未知子命令此前打印用法后**退 0**,脚本/CI 无法与「成功」区分。
84
87
  io.print('用法:mingdao net report [--since 7d|24h|90m] [--json] | mingdao net policy');
88
+ process.exitCode = 1;
85
89
  return true;
86
90
  }
@@ -1,14 +1,16 @@
1
1
  // `mingdao pack` 命令族(v0.5.0 阶段 A):垂域 Pack 的查看 / 校验 / 脚手架。
2
- // 下游 CI 门禁入口是 `mingdao pack verify <dir>`——它只做静态校验(不 import Pack 代码),
3
- // 因此可以在没有内核运行时的环境里安全执行。
2
+ // 下游 CI 门禁入口是 `mingdao pack verify <dir>`——**默认只做静态校验,不 import Pack 代码**
3
+ // (v0.6.3 / H-9:此前帮助文案这么写、实现却无条件 loadPack → 等于在 CI 上执行被审仓库的任意代码,
4
+ // 且不经 pack trust 信任门。现在默认静态、要执行须显式 --runtime)。
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
6
- import { listPacks, loadPack, validateManifest, coreVersionOf, SUPPORTED_PACK_API, CONSTRAINT_KINDS, packedDirsForHelp, packTrustState, trustPack, untrustPack } from '../packs.js';
7
+ import { listPacks, loadPack, loadPackStatic, validateManifest, coreVersionOf, SUPPORTED_PACK_API, CONSTRAINT_KINDS, packedDirsForHelp, packTrustState, trustPack, untrustPack } from '../packs.js';
7
8
  import { loadConfig } from '../config.js';
8
9
 
9
10
  const HELP = `用法:
10
11
  mingdao pack list 已发现的 Pack(名/版本/来源/兼容状态)
11
- mingdao pack verify <目录> 静态校验 manifest + 文件齐全 + 约束合法(下游 CI 门禁)
12
+ mingdao pack verify <目录> 静态校验 manifest + 文件齐全(**不执行 pack.mjs**,下游 CI 门禁用这个)
13
+ mingdao pack verify <目录> --runtime 额外 import pack.mjs 验证运行时契约(会执行 Pack 代码,慎用)
12
14
  mingdao pack new <名字> 生成最小可用 Pack 脚手架
13
15
  mingdao pack info <名字> 单个 Pack 的贡献面明细
14
16
  mingdao pack trust <项目目录> 信任该项目的 .mingdao/packs(未信任则**不挂载**)
@@ -133,7 +135,22 @@ export async function handlePack(cmd, args) {
133
135
  process.exitCode = 1;
134
136
  return true;
135
137
  }
136
- // 再走一次完整加载(会 import pack.mjs,验证运行时契约)
138
+ // v0.6.3(H-9):默认**静态**收口——`verify` 是给 CI 审第三方 Pack 用的,不能顺手执行它的代码。
139
+ const runtime = args.includes('--runtime');
140
+ if (!runtime) {
141
+ const st2 = loadPackStatic(abs);
142
+ if (!st2.ok) {
143
+ console.log(`[失败] ${manifest.name} 静态校验未通过:`);
144
+ st2.errors.forEach((/** @type {any} */ e, /** @type {number} */ i) => console.log(` ${i + 1}. ${e}`));
145
+ process.exitCode = 1;
146
+ return true;
147
+ }
148
+ console.log(`[通过·静态] ${manifest.name} v${manifest.version}(apiVersion ${manifest.apiVersion},内核窗口 ${manifest.engines?.mingdao})`);
149
+ console.log(' 已校验:manifest 字段 / 兼容窗口 / 声明文件齐全 / pack.mjs 存在性。**未执行 Pack 代码。**');
150
+ console.log(' 约束与提示词段的合法性由代码在装载时产出,静态阶段无法校验;需要时请加 --runtime(会执行代码)。');
151
+ return true;
152
+ }
153
+ console.log(' ⚠ --runtime:即将 import pack.mjs——它会在**本进程内以完整 Node 权限执行**。');
137
154
  const res = await loadPack(abs);
138
155
  if (!res.ok) {
139
156
  console.log(`[失败] ${manifest.name} 加载校验未通过:`);
@@ -142,7 +159,7 @@ export async function handlePack(cmd, args) {
142
159
  return true;
143
160
  }
144
161
  const c = res.contributions;
145
- console.log(`[通过] ${manifest.name} v${manifest.version}(apiVersion ${manifest.apiVersion},内核窗口 ${manifest.engines?.mingdao})`);
162
+ console.log(`[通过·运行时] ${manifest.name} v${manifest.version}(apiVersion ${manifest.apiVersion},内核窗口 ${manifest.engines?.mingdao})`);
146
163
  console.log(` 工具 ${Array.isArray(c.tools) ? c.tools.length : 0} 个 · 约束 ${Array.isArray(c.constraints) ? c.constraints.length : 0} 条 · 提示词段 ${Array.isArray(c.promptSections) ? c.promptSections.length : 0} 段`);
147
164
  for (const w of Array.isArray(res.warnings) ? res.warnings : []) console.log(` ⚠ ${w}`);
148
165
  return true;
@@ -271,6 +271,8 @@ export async function runRepl(ctx) {
271
271
  rewriteSession(session.file, messages);
272
272
  } catch {}
273
273
  tuiState.persisted = messages.length;
274
+ // 同上:上下文被清空后,"这个文件你看过"的记忆也必须一起清掉
275
+ agent.clearReadCache?.();
274
276
  io.print('已清空上下文(会话文件已同步重置)。');
275
277
  } else if (cmd === '/preset') {
276
278
  // v0.4.0 Agent Preset:列出/切换声明式智能体预设(工具白名单/权限/参数 + 系统提示定制段)
@@ -391,8 +393,14 @@ export async function runRepl(ctx) {
391
393
  continue;
392
394
  }
393
395
  messages = compacted.messages;
394
- appendMessages(session.file, [{ role: 'system', content: '── /compact 压缩点 ──' }, ...messages.slice(1)]);
396
+ // v0.6.3(H-4):手动 /compact 此前用**追加**落盘(再补一条"压缩点"标记),而压缩前的历史
397
+ // 早已在文件里 → 每次 /compact 文件近乎翻倍,`-c/--continue` 恢复后历史重复并立刻再次触发压缩。
398
+ // 自动压缩走的是 rewriteSession(原子重写)——两处写盘口径不一致,这里统一到重写。
399
+ rewriteSession(session.file, messages);
395
400
  tuiState.persisted = messages.length;
401
+ // v0.6.3(M-1):压缩把早期消息(含文件正文)换成了摘要,但读取去重缓存仍记着"这个文件你看过"
402
+ // → 模型再读同一文件只会拿到「内容与上次读取一致」的占位串,而正文已不在上下文里。
403
+ agent.clearReadCache?.();
396
404
  io.print(style(`✓ 已压缩上下文:${compacted.droppedCount} 条早期消息 → 摘要(回收约 ${compacted.droppedTokens} tokens)`, C.green));
397
405
  } else if (cmd === '/init') {
398
406
  const target = path.join(workingDir, 'AGENTS.md');