mingdao-harness 0.6.0 → 0.6.2
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.md +1 -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 +647 -0
- package/docs/MIGRATION-DEYI-v0.5.md +18 -0
- package/docs/PACK-API.md +62 -5
- package/docs/RELEASE-CHECKLIST.md +210 -7
- package/docs/RELEASE-TRAIN.md +20 -1
- package/package.json +1 -1
- package/src/agent.js +69 -6
- package/src/atomic-write.js +22 -2
- package/src/audit.js +45 -15
- package/src/autostart.js +67 -16
- package/src/cachestats.js +66 -13
- package/src/cli.js +16 -7
- package/src/commands/desktop.js +2 -1
- package/src/commands/diagnose.js +11 -2
- package/src/commands/ledger.js +7 -3
- package/src/commands/pack.js +58 -4
- package/src/commands/repl.js +28 -13
- package/src/commands/schedule.js +15 -2
- package/src/commands/sync.js +102 -20
- package/src/commands/update.js +2 -1
- package/src/commands/workspace.js +8 -1
- package/src/config.js +4 -4
- package/src/constraints.js +31 -5
- package/src/cost-guard.js +4 -3
- package/src/hooks.js +5 -2
- package/src/ledger.js +110 -10
- package/src/mcp-presets.js +25 -4
- package/src/mcp.js +3 -1
- package/src/memory.js +64 -18
- package/src/model-discovery.js +38 -5
- package/src/models.js +57 -2
- package/src/net-guard.js +35 -11
- package/src/notify.js +4 -3
- package/src/packs.js +155 -9
- package/src/permissions.js +24 -0
- package/src/proc.js +94 -4
- package/src/prompts.js +36 -3
- package/src/redact.js +57 -0
- package/src/routing.js +14 -8
- package/src/safe-fetch.js +83 -0
- package/src/schedule.js +81 -12
- package/src/session-index.js +26 -2
- package/src/skill-lib.js +25 -46
- package/src/skill-registry.js +55 -30
- package/src/task-state.js +50 -5
- package/src/tasks/worker.js +2 -1
- package/src/tasks.js +74 -10
- package/src/tokenizer.js +76 -6
- package/src/tools/bash.js +3 -1
- package/src/tools/fs-tools.js +31 -13
- package/src/tools/git.js +31 -2
- package/src/tools/index.js +24 -1
- package/src/ui.js +33 -1
- package/src/web/app.js +10 -4
- package/src/web/routes/domains/config.js +10 -4
- package/src/web/routes/domains/workspace.js +3 -1
- package/src/web/server.js +31 -7
- package/src/web/util.js +20 -0
- package/src/workspace.js +46 -5
package/src/audit.js
CHANGED
|
@@ -8,10 +8,14 @@ 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
12
|
|
|
12
|
-
|
|
13
|
+
// v0.6.2(第三方代码审计 P2-2):截断触发改为看**文件大小**。
|
|
14
|
+
// 原判据 `auditCount > 20000` 是**进程内**计数:CLI 每次会话只 append 几次、进程结束即归零,
|
|
15
|
+
// 于是这个条件永远不成立 → 截断是**死代码**,audit.jsonl 对 CLI 用户无界增长
|
|
16
|
+
// (与注释里「低频截断」的意图正好相反)。按大小判断跨进程有效且同样是 O(1)。
|
|
17
|
+
const MAX_BYTES = 4 * 1024 * 1024; // 约合 20000 行
|
|
13
18
|
const KEEP_LINES = 10000;
|
|
14
|
-
let auditCount = 0; // 内存计数(评估 P3-2):避免每次写入都整文件读一遍只为查行数
|
|
15
19
|
|
|
16
20
|
export function auditFile() {
|
|
17
21
|
return path.join(mingdaoHome(), 'audit.jsonl');
|
|
@@ -20,6 +24,18 @@ export function auditFile() {
|
|
|
20
24
|
// 轻量脱敏(统一单一来源 v0.3.1 P1-1):sk-/ghp_ 等常见前缀掩码,见 src/redact.js
|
|
21
25
|
export { redactSecrets };
|
|
22
26
|
|
|
27
|
+
let auditWarned = false;
|
|
28
|
+
let auditError = /** @type {string|null} */ (null);
|
|
29
|
+
let auditFailures = 0;
|
|
30
|
+
/** 审计写入失败次数(0 = 本次进程内从未失败)。 */
|
|
31
|
+
export function auditWriteFailures() {
|
|
32
|
+
return auditFailures;
|
|
33
|
+
}
|
|
34
|
+
/** 审计最近一次写失败原因(无则 null)。 */
|
|
35
|
+
export function auditWriteError() {
|
|
36
|
+
return auditError;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
export function writeAudit(/** @type {any} */ entry) {
|
|
24
40
|
try {
|
|
25
41
|
ensureHome();
|
|
@@ -28,21 +44,35 @@ export function writeAudit(/** @type {any} */ entry) {
|
|
|
28
44
|
try {
|
|
29
45
|
fs.chmodSync(file, 0o600);
|
|
30
46
|
} catch {}
|
|
31
|
-
|
|
32
|
-
} catch {
|
|
33
|
-
|
|
47
|
+
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// v0.6.2(B-WS-1/2 第八处):仍然「不影响会话」(审计不能反过来打断用户干活),
|
|
50
|
+
// 但**不再无声**——此前连"这次运行的审计记录是空的"都无从得知。
|
|
51
|
+
// 保留计数与原因供诊断/测试;一次性 console.warn 避免每步刷屏。
|
|
52
|
+
auditFailures += 1;
|
|
53
|
+
auditError = String(/** @type {any} */ (err)?.message ?? err);
|
|
54
|
+
if (!auditWarned) {
|
|
55
|
+
auditWarned = true;
|
|
56
|
+
console.warn(
|
|
57
|
+
`[MingDao] ⚠ 审计记录写入失败:${auditError}\n` +
|
|
58
|
+
` 该次运行的 audit.jsonl 会缺事件(执行账本 ledger/ 不受影响);请检查 ${auditFile()} 的磁盘空间与权限。`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
34
62
|
}
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
try
|
|
38
|
-
|
|
39
|
-
const lines =
|
|
40
|
-
if (lines.length >
|
|
41
|
-
|
|
42
|
-
|
|
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 });
|
|
43
73
|
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
46
76
|
}
|
|
47
77
|
|
|
48
78
|
export function listAudit(limit = 20) {
|
package/src/autostart.js
CHANGED
|
@@ -50,41 +50,92 @@ export function autostartStatus() {
|
|
|
50
50
|
return fs.existsSync(autostartPath());
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
// ---------- v0.6.2(第三方审计 P2-14):三个平台的自启文件都缺转义 ----------
|
|
54
|
+
// 路径是**用户可控**的(家目录/用户名/node 安装位置)。含 & < > 的路径插进 plist 会让 XML
|
|
55
|
+
// 非法 → launchctl 加载失败,而写文件本身"成功",于是表现为「开关打开了但登录后不自启」,
|
|
56
|
+
// 且错误只在 StandardErrorPath 里(用户看不到)。Linux 的 .desktop 与 Windows 的 .bat 同理。
|
|
57
|
+
// 转义规则按各自格式的规范来,抽成纯函数以便直接测试(不动用户真实的自启配置)。
|
|
58
|
+
|
|
59
|
+
/** XML 文本转义(plist 的 <string> 内容) */
|
|
60
|
+
export function escapeXml(/** @type {any} */ s) {
|
|
61
|
+
return String(s ?? '')
|
|
62
|
+
.replace(/&/g, '&')
|
|
63
|
+
.replace(/</g, '<')
|
|
64
|
+
.replace(/>/g, '>');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Desktop Entry 规范:双引号参数内的 \ " $ ` 必须反斜杠转义 */
|
|
68
|
+
export function escapeDesktopEntry(/** @type {any} */ s) {
|
|
69
|
+
return String(s ?? '')
|
|
70
|
+
.replace(/\\/g, '\\\\')
|
|
71
|
+
.replace(/"/g, '\\"')
|
|
72
|
+
.replace(/\$/g, '\\$')
|
|
73
|
+
.replace(/`/g, '\\`');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Windows 批处理:% 是变量展开符,须写成 %% */
|
|
77
|
+
export function escapeBatch(/** @type {any} */ s) {
|
|
78
|
+
return String(s ?? '').replace(/%/g, '%%');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** macOS LaunchAgent plist 内容(纯函数,便于断言转义正确) */
|
|
82
|
+
export function plistContent(/** @type {any} */ node, /** @type {any} */ cli, /** @type {any} */ pathEnv) {
|
|
83
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
63
84
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
64
85
|
<plist version="1.0"><dict>
|
|
65
86
|
<key>Label</key><string>org.mingdao.web</string>
|
|
66
87
|
<key>ProgramArguments</key><array>
|
|
67
|
-
<string>${node}</string><string>${cli}</string><string>web</string><string>3820</string>
|
|
88
|
+
<string>${escapeXml(node)}</string><string>${escapeXml(cli)}</string><string>web</string><string>3820</string>
|
|
68
89
|
</array>
|
|
69
90
|
<key>RunAtLoad</key><true/>
|
|
70
91
|
<key>EnvironmentVariables</key><dict>
|
|
71
|
-
<key>PATH</key><string>${
|
|
92
|
+
<key>PATH</key><string>${escapeXml(pathEnv)}</string>
|
|
72
93
|
</dict>
|
|
73
94
|
<key>StandardOutPath</key><string>/tmp/mingdao-web.log</string>
|
|
74
95
|
<key>StandardErrorPath</key><string>/tmp/mingdao-web.err</string>
|
|
75
96
|
</dict></plist>
|
|
76
97
|
`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Linux XDG Autostart .desktop 内容 */
|
|
101
|
+
export function desktopEntryContent(/** @type {any} */ node, /** @type {any} */ cli) {
|
|
102
|
+
return (
|
|
103
|
+
'[Desktop Entry]\nType=Application\nName=MingDao 自动启动\n' +
|
|
104
|
+
'Comment=MingDao-Harness WebUI 服务器(登录时启动)\n' +
|
|
105
|
+
`Exec="${escapeDesktopEntry(node)}" "${escapeDesktopEntry(cli)}" web 3820\n` +
|
|
106
|
+
'X-GNOME-Autostart-enabled=true\nHidden=false\n'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Windows 启动文件夹 .bat 内容 */
|
|
111
|
+
export function batchContent(/** @type {any} */ node, /** @type {any} */ cli) {
|
|
112
|
+
return `@echo off\r\nstart "" "${escapeBatch(node)}" "${escapeBatch(cli)}" web 3820\r\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function enableAutostart() {
|
|
116
|
+
const target = autostartPath();
|
|
117
|
+
try {
|
|
118
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
119
|
+
const node = nodeBin();
|
|
120
|
+
const cli = cliEntry();
|
|
121
|
+
if (process.platform === 'win32') {
|
|
122
|
+
fs.writeFileSync(target, batchContent(node, cli));
|
|
123
|
+
} else if (process.platform === 'darwin') {
|
|
124
|
+
const plist = plistContent(node, cli, `${path.dirname(node)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`);
|
|
77
125
|
fs.writeFileSync(target, plist);
|
|
126
|
+
// 写完先自检:转义写错 / 路径怪异会让 plist 非法,而 launchd 只在加载时报错、
|
|
127
|
+
// 用户看不到(表现为「开关打开了但登录后不自启」)。这里把它变成**立即失败**。
|
|
128
|
+
try {
|
|
129
|
+
const chk = spawnSync('plutil', ['-lint', target], { stdio: 'ignore' });
|
|
130
|
+
if (!chk.error && chk.status !== 0) return false;
|
|
131
|
+
} catch {}
|
|
78
132
|
// 立即注册(否则要等下次登录才生效,用户以为开关没起作用)。失败不致命:
|
|
79
133
|
// 文件已落盘,下次登录仍会由 RunAtLoad 拉起。
|
|
80
134
|
try {
|
|
81
135
|
spawnSync('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? 0}`, target], { stdio: 'ignore' });
|
|
82
136
|
} catch {}
|
|
83
137
|
} else {
|
|
84
|
-
fs.writeFileSync(
|
|
85
|
-
target,
|
|
86
|
-
`[Desktop Entry]\nType=Application\nName=MingDao 自动启动\nComment=MingDao-Harness WebUI 服务器(登录时启动)\nExec="${node}" "${cli}" web 3820\nX-GNOME-Autostart-enabled=true\nHidden=false\n`
|
|
87
|
-
);
|
|
138
|
+
fs.writeFileSync(target, desktopEntryContent(node, cli));
|
|
88
139
|
}
|
|
89
140
|
return true;
|
|
90
141
|
} catch {
|
package/src/cachestats.js
CHANGED
|
@@ -11,11 +11,35 @@ export function cacheStatsFile() {
|
|
|
11
11
|
return path.join(mingdaoHome(), 'cache-stats.jsonl');
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
14
|
+
// 低频轮转(评估 P3-3:此前无限增长且每次全量解析)
|
|
15
|
+
// v0.6.2(第三方代码审计 P2-2):触发改为**文件大小**,删掉进程内计数。
|
|
16
|
+
// 三份「追加 + 超限截断」实现(audit / journal / cachestats)此前都用进程内计数,
|
|
17
|
+
// 对 CLI 用户永远不成立(每次只写几条、进程结束归零)——轮转全是**死代码**,文件无界增长。
|
|
18
|
+
// 这里尤其要紧:todayCost() 与日费用护栏读的就是本文件。
|
|
15
19
|
const MAX_LINES = 20000;
|
|
16
20
|
const KEEP_LINES = 10000;
|
|
17
|
-
|
|
21
|
+
const MAX_BYTES = 4 * 1024 * 1024; // 约合 20000 行
|
|
18
22
|
|
|
23
|
+
// v0.6.2(第三方报告 B-WS-1/2 第三处):写入失败**不再静默**。
|
|
24
|
+
//
|
|
25
|
+
// 为什么这一处特别要紧:cache-stats.jsonl 是 todayCost() 与日费用护栏(costGuard)的**唯一数据源**。
|
|
26
|
+
// 读侧早就做了「读不了就告警一次、返回 null 表示无法判断」(见 cost-guard.js)——
|
|
27
|
+
// 而写侧一直 `catch {}`:**追加失败时文件根本不变**,于是读侧的 mtime 缓存继续命中旧值,
|
|
28
|
+
// 护栏拿到一个「看起来正常」的偏小数字,用户可能超支而毫不知情。
|
|
29
|
+
// 读侧已经会说真话,写侧不会——这就是本次要补的不对称。
|
|
30
|
+
let writeWarned = false;
|
|
31
|
+
let rotateWarned = false;
|
|
32
|
+
let lastWriteError = /** @type {string|null} */ (null);
|
|
33
|
+
|
|
34
|
+
/** 费用明细最近一次写失败原因(无则 null)。给诊断与测试用,避免只能靠 console 观察。 */
|
|
35
|
+
export function cacheStatsWriteError() {
|
|
36
|
+
return lastWriteError;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {any} entry
|
|
41
|
+
* @returns {{ok: boolean, error: string|null, phase: 'append'|'rotate'|null}}
|
|
42
|
+
*/
|
|
19
43
|
export function recordCacheStats(/** @type {any} */ entry) {
|
|
20
44
|
try {
|
|
21
45
|
ensureHome(); // 审计 B10:与 audit/journal 一致,目录缺失不静默丢统计
|
|
@@ -46,10 +70,23 @@ export function recordCacheStats(/** @type {any} */ entry) {
|
|
|
46
70
|
auxReason: entry.auxReason ? String(entry.auxReason) : undefined,
|
|
47
71
|
});
|
|
48
72
|
fs.appendFileSync(cacheStatsFile(), line + '\n');
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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) {
|
|
53
90
|
// 审计 P2-3(v0.4.2):轮转 read-modify-write 加跨进程锁——web/CLI/worker 多进程并发轮转时,
|
|
54
91
|
// 读与写之间他人追加的行会被覆写丢失;锁内重读再瘦身,写用原子替换。
|
|
55
92
|
withFileLockSync(cacheStatsFile() + '.lock', () => {
|
|
@@ -77,11 +114,20 @@ export function recordCacheStats(/** @type {any} */ entry) {
|
|
|
77
114
|
const order = new Map(lines.map((l, i) => [l, i]));
|
|
78
115
|
merged.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
|
|
79
116
|
atomicWriteFileSync(cacheStatsFile(), merged.join('\n') + '\n');
|
|
80
|
-
cacheStatsCount = merged.length;
|
|
81
117
|
}
|
|
82
118
|
});
|
|
83
|
-
}
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// 轮转失败与追加失败是两件事:这里丢的不是账,而是文件会持续增长
|
|
122
|
+
const msg = String(/** @type {any} */ (err)?.message ?? err);
|
|
123
|
+
lastWriteError = msg;
|
|
124
|
+
if (!rotateWarned) {
|
|
125
|
+
rotateWarned = true;
|
|
126
|
+
console.warn(`[MingDao] ⚠ 费用明细轮转失败:${msg}\n ${cacheStatsFile()} 会持续增长(不影响计费,但请检查磁盘空间/权限)。`);
|
|
127
|
+
}
|
|
128
|
+
return { ok: false, error: msg, phase: 'rotate' };
|
|
84
129
|
}
|
|
130
|
+
return { ok: true, error: null, phase: null };
|
|
85
131
|
}
|
|
86
132
|
|
|
87
133
|
// listCacheStats 读取缓存(审计:costGuard 每步 / WebUI 每 15s / /cost 命令都调用——
|
|
@@ -153,7 +199,7 @@ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usa
|
|
|
153
199
|
// 覆盖内置定价 + 外部定价 + config.pricing.overrides 三条来源,不再依赖 modelPreset 单一判断。
|
|
154
200
|
cost = estimateCost(modelName, prompt, completion, null, priceAt);
|
|
155
201
|
}
|
|
156
|
-
recordCacheStats({
|
|
202
|
+
return recordCacheStats({
|
|
157
203
|
model: modelName,
|
|
158
204
|
prompt,
|
|
159
205
|
completion,
|
|
@@ -188,14 +234,14 @@ export function formatCacheSummary(/** @type {any} */ sum) {
|
|
|
188
234
|
* @param {any} modelName @param {any} usage @param {string} [reason]
|
|
189
235
|
*/
|
|
190
236
|
export function recordAuxUsage(modelName, usage, reason = 'aux') {
|
|
191
|
-
if (!usage) return;
|
|
237
|
+
if (!usage) return { ok: true, error: null, phase: null };
|
|
192
238
|
try {
|
|
193
239
|
const prompt = usage.prompt_tokens || 0;
|
|
194
240
|
const completion = usage.completion_tokens || 0;
|
|
195
|
-
if (!prompt && !completion) return;
|
|
241
|
+
if (!prompt && !completion) return { ok: true, error: null, phase: null };
|
|
196
242
|
const split = cacheSplit(usage);
|
|
197
243
|
const cost = split ? estimateCost(modelName, prompt, completion, split) : estimateCost(modelName, prompt, completion, null);
|
|
198
|
-
recordCacheStats({
|
|
244
|
+
return recordCacheStats({
|
|
199
245
|
model: modelName,
|
|
200
246
|
prompt,
|
|
201
247
|
completion,
|
|
@@ -206,7 +252,14 @@ export function recordAuxUsage(modelName, usage, reason = 'aux') {
|
|
|
206
252
|
aux: true,
|
|
207
253
|
auxReason: reason,
|
|
208
254
|
});
|
|
209
|
-
} catch {
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// 辅助调用的计费同样不能静默丢(自动路由/标题/记忆提炼的费用也是钱)。
|
|
257
|
+
// 注意不在这里写 lastWriteError:本 try 也覆盖计价计算,真正的**写**失败已由
|
|
258
|
+
// recordCacheStats 自己记录,在此覆盖会把「计算失败」误报成「写入失败」。
|
|
259
|
+
const msg = String(/** @type {any} */ (err)?.message ?? err);
|
|
260
|
+
return { ok: false, error: msg, phase: null };
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, error: null, phase: null };
|
|
210
263
|
}
|
|
211
264
|
|
|
212
265
|
/**
|
package/src/cli.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// /mode 模型快捷切换、/verbose 思考开关、/status、/cost、会话选择恢复。
|
|
5
5
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
|
+
import { DEFAULT_MODEL } from './models.js';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import readline from 'node:readline';
|
|
9
10
|
import { loadConfig, saveConfig, runWizard, ensureHome, mingdaoHome } from './config.js';
|
|
@@ -16,6 +17,7 @@ import { makeTokenCounter } from './tokenizer.js';
|
|
|
16
17
|
import { messageTokens } from './context.js';
|
|
17
18
|
import { startMcpServers } from './mcp.js';
|
|
18
19
|
import { startTask, listTasks, patchTask, killTask, formatTaskRow } from './tasks.js';
|
|
20
|
+
import { installPipeGuards } from './proc.js';
|
|
19
21
|
import { enableAutostart, disableAutostart, autostartStatus, autostartPath } from './autostart.js';
|
|
20
22
|
import { isLocalBaseUrl } from './model-caps.js';
|
|
21
23
|
import { notifyTaskDone } from './notify.js';
|
|
@@ -37,7 +39,7 @@ import {
|
|
|
37
39
|
postRunStatus,
|
|
38
40
|
} from './schedule.js';
|
|
39
41
|
import { createAgent } from './agent.js';
|
|
40
|
-
import { saveTaskStateMerge, clearTaskState } from './task-state.js';
|
|
42
|
+
import { saveTaskStateMerge, clearTaskState, checkpointHint } from './task-state.js';
|
|
41
43
|
import { createPermission } from './permissions.js';
|
|
42
44
|
import { buildSystemPrompt } from './prompts.js';
|
|
43
45
|
import { listSkills, tamperedSkillNames } from './skills.js';
|
|
@@ -121,6 +123,9 @@ async function generatePlan(/** @type {any} */ provider, /** @type {any} */ mode
|
|
|
121
123
|
// —— 后台任务 worker(Phase C C2):已抽取至 src/tasks/worker.js,入口处动态导入 ——
|
|
122
124
|
|
|
123
125
|
async function main() {
|
|
126
|
+
// v0.6.2(B-UI-1/B-CLI-1/B-REPL-1):先装管道兜底,再输出任何东西。
|
|
127
|
+
// `mingdao … | head -1` 这类最常见的用法此前会因 stdout EPIPE 未捕获而带堆栈崩溃。
|
|
128
|
+
installPipeGuards({ exitOnEpipe: true });
|
|
124
129
|
const opts = parseArgs(process.argv.slice(2));
|
|
125
130
|
if (opts.help) {
|
|
126
131
|
printHelpLines(console.log);
|
|
@@ -251,7 +256,7 @@ async function main() {
|
|
|
251
256
|
for (const j of jobs) {
|
|
252
257
|
// 崩溃恢复(审计):'running' 但无人监督且 worker 已死 → 按任务结果定案或重新排队,
|
|
253
258
|
// 避免任务永久卡在 running(此前 daemon 重启后既不重跑也不收尾)
|
|
254
|
-
if (j.status === 'running' && !supervising.has(j.id) && !sleeperAlive(j.pid)) {
|
|
259
|
+
if (j.status === 'running' && !supervising.has(j.id) && !sleeperAlive(j.pid, j.id)) {
|
|
255
260
|
const t = j.lastTaskId ? readTask(home0, j.lastTaskId) : null;
|
|
256
261
|
// v0.4.7(P2 T15):先看「跑这个任务的宿主进程」是否仍存活。存活说明它正在跑
|
|
257
262
|
// (可能还没写 lastTaskId),**等它**——否则「本 daemon 刚接管 + 旧 daemon 在途」
|
|
@@ -279,7 +284,7 @@ async function main() {
|
|
|
279
284
|
continue;
|
|
280
285
|
}
|
|
281
286
|
if (j.status !== 'pending' || handled.has(j.id)) continue;
|
|
282
|
-
if (sleeperAlive(j.pid)) continue; // 旧式 sleeper 仍在:交回给它,避免双跑
|
|
287
|
+
if (sleeperAlive(j.pid, j.id)) continue; // 旧式 sleeper 仍在:交回给它,避免双跑
|
|
283
288
|
if (j.lastTaskId) {
|
|
284
289
|
const t = readTask(home0, j.lastTaskId);
|
|
285
290
|
if (t && t.status === 'running') continue; // worker 仍在跑(异常窗口),不重拉
|
|
@@ -345,7 +350,7 @@ async function main() {
|
|
|
345
350
|
}
|
|
346
351
|
|
|
347
352
|
// 模型回退链(向导允许跳过模型选择 → cfg.model 可缺省):参数 > config > 该服务商首个预设模型 > flash
|
|
348
|
-
let modelName = opts.model || cfg.model || /** @type {any} */ (PROVIDERS)[cfg.provider]?.models?.[0] ||
|
|
353
|
+
let modelName = opts.model || cfg.model || /** @type {any} */ (PROVIDERS)[cfg.provider]?.models?.[0] || DEFAULT_MODEL;
|
|
349
354
|
const io = createIO();
|
|
350
355
|
const workingDir = process.cwd();
|
|
351
356
|
|
|
@@ -533,16 +538,20 @@ async function main() {
|
|
|
533
538
|
} catch {}
|
|
534
539
|
}
|
|
535
540
|
// v0.3.0 P0-2:单次提问跑满步数/中断落检查点(--continue 可续跑),正常完成清除
|
|
541
|
+
// v0.6.2:检查点写入结果必须被检查——上面 banner 已经承诺「发送『继续』即可」,
|
|
542
|
+
// 写失败却不出声,就等于给了一个兑现不了的承诺。
|
|
536
543
|
if (res.capHit || res.aborted) {
|
|
537
|
-
saveTaskStateMerge(path.basename(session.file), {
|
|
544
|
+
const hint = checkpointHint(saveTaskStateMerge(path.basename(session.file), {
|
|
538
545
|
goal: question,
|
|
539
546
|
progress: res.text || '',
|
|
540
547
|
artifacts: res.perf?.deliverables || [],
|
|
541
548
|
status: res.capHit ? 'cap' : 'interrupted',
|
|
542
549
|
updatedAt: new Date().toISOString(),
|
|
543
|
-
});
|
|
550
|
+
}), 'save');
|
|
551
|
+
if (hint) io.print(style(hint, C.yellow));
|
|
544
552
|
} else {
|
|
545
|
-
clearTaskState(path.basename(session.file));
|
|
553
|
+
const hint = checkpointHint(clearTaskState(path.basename(session.file)), 'clear');
|
|
554
|
+
if (hint) io.print(style(hint, C.yellow));
|
|
546
555
|
}
|
|
547
556
|
try {
|
|
548
557
|
await finalizeSession({ cfg, provider, model: titleModel(cfg, modelName), home, workingDir, messages, turns: 1, lastText: res.text || '' });
|
package/src/commands/desktop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 命令族:mingdao desktop —— 任意目录启动 Electron 桌面版
|
|
2
2
|
// 定位仓库 → 检查 desktop/node_modules 里的 electron → 拉起;缺依赖给出镜像安装指引。
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawnOpts } from '../proc.js';
|
|
4
5
|
import fs from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { findRepoRoot } from '../update.js';
|
|
@@ -32,7 +33,7 @@ export async function handleDesktop(/** @type {any} */ args) {
|
|
|
32
33
|
return true;
|
|
33
34
|
}
|
|
34
35
|
console.log('正在启动 MingDao Harness 桌面版…(窗口弹出即成功;托盘常驻,关闭窗口 = 最小化到托盘)');
|
|
35
|
-
const child = spawn(localBin, [desktopDir], { cwd: desktopDir, stdio: 'ignore', detached: true });
|
|
36
|
+
const child = spawn(localBin, [desktopDir], { cwd: desktopDir, stdio: 'ignore', detached: true, ...spawnOpts({ detached: true }) });
|
|
36
37
|
child.on('error', (err) => {
|
|
37
38
|
console.log('[错误] 启动 Electron 失败:' + (err?.message || err));
|
|
38
39
|
process.exitCode = 1;
|
package/src/commands/diagnose.js
CHANGED
|
@@ -6,6 +6,7 @@ import os from 'node:os';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { createIO, style, C } from '../ui.js';
|
|
8
8
|
import { mingdaoHome, ensureHome, loadConfig } from '../config.js';
|
|
9
|
+
import { redactConfig } from '../redact.js';
|
|
9
10
|
import { credentialsPath } from '../credentials.js';
|
|
10
11
|
import { listAudit } from '../audit.js';
|
|
11
12
|
import { redactSensitive } from '../redact.js';
|
|
@@ -50,7 +51,9 @@ export async function handleDiagnose(/** @type {any} */ _cmd, /** @type {any} */
|
|
|
50
51
|
push('');
|
|
51
52
|
push('## 配置(config.json,已脱敏)');
|
|
52
53
|
const cfg = loadConfig();
|
|
53
|
-
|
|
54
|
+
// v0.6.2(P2-13):改用**结构感知**脱敏——按字段名的正则堵不住
|
|
55
|
+
// `mcpServers.*.env.<自定义名>` 这类值,而诊断包是用户会贴到公开渠道的产物。
|
|
56
|
+
push(cfg ? JSON.stringify(redactConfig(cfg), null, 2) : '(不存在或读取失败)');
|
|
54
57
|
|
|
55
58
|
// v0.4.1:模型能力定位——本地模型未声明 contextWindow 会兜底 32k → maxOutput/budget 偏小,
|
|
56
59
|
// 表现为「输出截断/频繁压缩」。此处显式提示,帮用户自诊。
|
|
@@ -98,7 +101,13 @@ export async function handleDiagnose(/** @type {any} */ _cmd, /** @type {any} */
|
|
|
98
101
|
const cwdMem = projectMemoryFile(process.cwd());
|
|
99
102
|
push(`当前目录项目记忆:${redactSensitive(cwdMem)}(${fs.existsSync(cwdMem) ? '存在' : '无'})`);
|
|
100
103
|
|
|
101
|
-
|
|
104
|
+
// v0.6.2(P2-13):诊断包按 0600 落盘——它与 config/日志/审计同属敏感产物,
|
|
105
|
+
// 本仓其它敏感文件(credentials.json 等)一律 0600,只有这里漏了。先写后 chmod,
|
|
106
|
+
// 因为 mode 只在**创建**时生效,已存在的旧文件不会自动收紧。
|
|
107
|
+
fs.writeFileSync(outFile, L.join('\n') + '\n', { mode: 0o600 });
|
|
108
|
+
try {
|
|
109
|
+
fs.chmodSync(outFile, 0o600);
|
|
110
|
+
} catch {}
|
|
102
111
|
io.print(style(`✓ 诊断报告已生成:${outFile}`, C.green));
|
|
103
112
|
io.print(style('请把该文件内容贴到反馈渠道排查;密钥/token/私网路径已脱敏。', C.dim));
|
|
104
113
|
} catch (/** @type {any} */ err) {
|
package/src/commands/ledger.js
CHANGED
|
@@ -74,13 +74,17 @@ export async function handleLedger(cmd, args) {
|
|
|
74
74
|
|
|
75
75
|
if (sub === 'verify') {
|
|
76
76
|
const v = verifyRun(runId);
|
|
77
|
-
if (v.ok) {
|
|
78
|
-
io.print(`✅ ${runId}
|
|
77
|
+
if (v.ok && v.sealed) {
|
|
78
|
+
io.print(`✅ ${runId} 校验通过:${v.total} 条事件链内一致,且与封条吻合(未被改动、尾部未被截断)。`);
|
|
79
|
+
} else if (v.ok) {
|
|
80
|
+
// 链内一致 ≠ 完整。原实现只报「哈希链完整」,把「尾部被删掉一截」说成了完整。
|
|
81
|
+
io.print(style(`⚠ ${runId} 链内一致(${v.total} 条事件),但完整性无法确认:${v.warning}。`, C.yellow));
|
|
82
|
+
process.exitCode = 1;
|
|
79
83
|
} else {
|
|
80
84
|
io.print(style(`❌ ${runId} 校验失败:${v.error}`, C.red));
|
|
81
85
|
process.exitCode = 1;
|
|
82
86
|
}
|
|
83
|
-
io.print(style('
|
|
87
|
+
io.print(style('说明:哈希链 + 封条只能证明「自写入后未被改动、尾部未被截断」,不含可信时间戳,不等同于审计级不可否认。', C.dim));
|
|
84
88
|
return true;
|
|
85
89
|
}
|
|
86
90
|
|
package/src/commands/pack.js
CHANGED
|
@@ -3,13 +3,19 @@
|
|
|
3
3
|
// 因此可以在没有内核运行时的环境里安全执行。
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { listPacks, loadPack, validateManifest, coreVersionOf, SUPPORTED_PACK_API, CONSTRAINT_KINDS, packedDirsForHelp } from '../packs.js';
|
|
6
|
+
import { listPacks, loadPack, validateManifest, coreVersionOf, SUPPORTED_PACK_API, CONSTRAINT_KINDS, packedDirsForHelp, packTrustState, trustPack, untrustPack } from '../packs.js';
|
|
7
|
+
import { loadConfig } from '../config.js';
|
|
7
8
|
|
|
8
9
|
const HELP = `用法:
|
|
9
10
|
mingdao pack list 已发现的 Pack(名/版本/来源/兼容状态)
|
|
10
11
|
mingdao pack verify <目录> 静态校验 manifest + 文件齐全 + 约束合法(下游 CI 门禁)
|
|
11
12
|
mingdao pack new <名字> 生成最小可用 Pack 脚手架
|
|
12
13
|
mingdao pack info <名字> 单个 Pack 的贡献面明细
|
|
14
|
+
mingdao pack trust <项目目录> 信任该项目的 .mingdao/packs(未信任则**不挂载**)
|
|
15
|
+
mingdao pack untrust <项目目录> 撤销信任
|
|
16
|
+
|
|
17
|
+
项目级 Pack 默认不挂载:项目里的 pack.mjs 会以完整 Node 权限在本进程内执行,
|
|
18
|
+
不受 permission 模式约束。clone 来的仓库必须先 trust 才会加载(内容一变即自动失效)。
|
|
13
19
|
|
|
14
20
|
说明:Pack API v${SUPPORTED_PACK_API.join('/')}(契约见 docs/PACK-API.md)。
|
|
15
21
|
约束 kind:${[...CONSTRAINT_KINDS].join(' / ')}`;
|
|
@@ -28,7 +34,9 @@ export async function handlePack(cmd, args) {
|
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
if (sub === 'list') {
|
|
31
|
-
|
|
37
|
+
// v0.6.2(代码审计 P2-4):这里硬传 `{}` 导致**读不到 config.packs 声明的 Pack**——
|
|
38
|
+
// 用户按文档在 config.json 里声明了目录,`mingdao pack list` 却看不见它(info 同病)。
|
|
39
|
+
const found = listPacks(loadConfig() || {}, process.cwd());
|
|
32
40
|
if (!found.length) {
|
|
33
41
|
console.log('未发现任何 Pack。');
|
|
34
42
|
console.log(`放置位置(三级遮蔽):<项目>/.mingdao/packs/ > ${packedDirsForHelp()} > 内置 packs/`);
|
|
@@ -37,13 +45,59 @@ export async function handlePack(cmd, args) {
|
|
|
37
45
|
}
|
|
38
46
|
console.log(`共 ${found.length} 个 Pack(内核 ${coreVersionOf()}):`);
|
|
39
47
|
for (const p of found) {
|
|
40
|
-
|
|
48
|
+
// 未信任的项目级 Pack 会被 mountPacks 跳过——列表里必须**看得出来**,
|
|
49
|
+
// 否则用户会以为它生效了(这正是原缺陷「静默」的另一半)。
|
|
50
|
+
const status = p.gate
|
|
51
|
+
? `⛔ 未信任(不挂载)——${p.gate === 'changed' ? '信任后内容已变化' : '执行 mingdao pack trust ' + (p.gateDir || '')}`
|
|
52
|
+
: p.error
|
|
53
|
+
? `❌ ${p.error}`
|
|
54
|
+
: `✅ apiVersion ${p.apiVersion}`;
|
|
41
55
|
console.log(` ${p.name}${p.displayName ? '(' + p.displayName + ')' : ''} v${p.version || '?'} · 来源 ${p.source} · ${status}`);
|
|
42
56
|
console.log(` ${p.dir}`);
|
|
43
57
|
}
|
|
44
58
|
return true;
|
|
45
59
|
}
|
|
46
60
|
|
|
61
|
+
if (sub === 'trust' || sub === 'untrust') {
|
|
62
|
+
const dir = String(args[1] || '').trim();
|
|
63
|
+
if (!dir) {
|
|
64
|
+
console.log(`[错误] 用法:mingdao pack ${sub} <项目目录>(会作用于该目录下的 .mingdao/packs)`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
// 传项目目录或 packs 目录都接受:统一解析到 .mingdao/packs
|
|
69
|
+
const abs = path.resolve(dir);
|
|
70
|
+
const root = path.basename(abs) === 'packs' && path.basename(path.dirname(abs)) === '.mingdao' ? abs : path.join(abs, '.mingdao', 'packs');
|
|
71
|
+
if (!fs.existsSync(root)) {
|
|
72
|
+
console.log(`[错误] 未找到 Pack 目录:${root}`);
|
|
73
|
+
console.log(' 该目录不存在时无需信任(没有项目级 Pack 会被加载)。');
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (sub === 'untrust') {
|
|
78
|
+
const r = untrustPack(root);
|
|
79
|
+
if (r.error) {
|
|
80
|
+
console.log(`[错误] ${r.error}`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
console.log(`✓ 已撤销信任:${r.dir}(该项目的 Pack 不再挂载)`);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
const st = packTrustState(root);
|
|
88
|
+
const r = trustPack(root);
|
|
89
|
+
if (r.error) {
|
|
90
|
+
console.log(`[错误] ${r.error}`);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
console.log(`✓ 已信任项目级 Pack 目录:${r.dir}`);
|
|
95
|
+
console.log(` 内容指纹 ${String(r.sha256 || '').slice(0, 16)}…${st.reason === 'changed' ? '(与上次信任时不同,已更新为当前内容)' : ''}`);
|
|
96
|
+
console.log(' ⚠ 该目录下的 pack.mjs 会以完整 Node 权限在本进程内执行——只信任你自己审过的代码。');
|
|
97
|
+
console.log(' 指纹变化后信任自动失效,需要重新执行本命令。');
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
47
101
|
if (sub === 'verify') {
|
|
48
102
|
const dir = String(args[1] || '').trim();
|
|
49
103
|
if (!dir) {
|
|
@@ -163,7 +217,7 @@ export function createPack(ctx) {
|
|
|
163
217
|
|
|
164
218
|
if (sub === 'info') {
|
|
165
219
|
const name = String(args[1] || '').trim();
|
|
166
|
-
const found = listPacks({}, process.cwd()).find((p) => p.name === name);
|
|
220
|
+
const found = listPacks(loadConfig() || {}, process.cwd()).find((p) => p.name === name);
|
|
167
221
|
if (!found) {
|
|
168
222
|
console.log(`[错误] 未发现 Pack "${name}"。可用:mingdao pack list`);
|
|
169
223
|
process.exitCode = 1;
|