mingdao-harness 0.6.2 → 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.
- 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 +300 -32
- package/docs/CONFIG.md +32 -6
- package/docs/DEVELOPER.md +1 -1
- package/docs/PACK-API.md +26 -3
- package/docs/PROVIDERS.md +22 -0
- package/docs/RELEASE-CHECKLIST.md +13 -2
- package/package.json +1 -1
- package/src/agent.js +133 -15
- package/src/atomic-write.js +247 -71
- package/src/audit.js +23 -18
- package/src/cachestats.js +57 -47
- package/src/cli.js +25 -4
- package/src/commands/diagnose.js +3 -0
- package/src/commands/key.js +23 -1
- package/src/commands/ledger.js +41 -3
- package/src/commands/net.js +5 -1
- package/src/commands/pack.js +23 -6
- package/src/commands/repl.js +9 -1
- package/src/commands/skill.js +30 -1
- package/src/commands/sync.js +7 -0
- package/src/commands/workspace.js +3 -3
- package/src/compact.js +15 -1
- package/src/config.js +71 -6
- package/src/constraints.js +55 -6
- package/src/credentials.js +36 -5
- package/src/hooks.js +37 -2
- package/src/ledger.js +3 -3
- package/src/log-writer.js +22 -17
- package/src/mcp.js +19 -1
- package/src/memory.js +8 -8
- package/src/packs.js +57 -8
- package/src/permissions.js +35 -9
- package/src/presets.js +46 -1
- package/src/providers/index.js +133 -7
- package/src/providers/openai-compatible.js +24 -3
- package/src/redact.js +38 -2
- package/src/replay.js +15 -3
- package/src/safe-fetch.js +8 -2
- package/src/schedule.js +22 -4
- package/src/session-index.js +2 -2
- package/src/session.js +2 -2
- package/src/skill-lib.js +43 -14
- package/src/skill-registry.js +31 -2
- package/src/sync-server.js +47 -28
- package/src/sync.js +17 -3
- package/src/task-state.js +10 -1
- package/src/tasks.js +22 -10
- package/src/tools/bash.js +96 -18
- package/src/tools/fetch.js +24 -0
- package/src/web/app.js +7 -7
- package/src/web/attachments.js +7 -1
- package/src/web/index.html +2 -2
- package/src/web/routes/api.js +27 -2
- package/src/web/routes/domains/sessions.js +3 -3
- package/src/web/routes/domains/sync.js +2 -1
- package/src/web/routes/domains/workspace.js +45 -15
- package/src/web/server.js +22 -5
- package/src/workspace.js +31 -18
package/src/commands/key.js
CHANGED
|
@@ -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();
|
package/src/commands/ledger.js
CHANGED
|
@@ -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 (
|
|
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 (!
|
|
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
|
}
|
package/src/commands/net.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/commands/pack.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// `mingdao pack` 命令族(v0.5.0 阶段 A):垂域 Pack 的查看 / 校验 / 脚手架。
|
|
2
|
-
// 下游 CI 门禁入口是 `mingdao pack verify <dir
|
|
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 +
|
|
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
|
-
//
|
|
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(`[
|
|
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;
|
package/src/commands/repl.js
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/src/commands/skill.js
CHANGED
|
@@ -6,6 +6,7 @@ import { loadConfig, saveConfig, ensureHome } from '../config.js';
|
|
|
6
6
|
import { searchSessions, relativeTime } from '../session.js';
|
|
7
7
|
import { runWebServer } from '../web/server.js';
|
|
8
8
|
import readline from 'node:readline';
|
|
9
|
+
import fs from 'node:fs'; // v0.6.3(BUG-015):--auth-token=- 从 stdin 读
|
|
9
10
|
|
|
10
11
|
// 首次运行询问「是否自动后台启动 WebUI」(交互终端才问;管道/脚本下默认否)
|
|
11
12
|
function askAutoStart() {
|
|
@@ -53,6 +54,13 @@ export async function handleSkill(/** @type {any} */ cmd, /** @type {any} */ arg
|
|
|
53
54
|
const rn = /** @type {any} */ (r);
|
|
54
55
|
const srcLabel = rn.host ? '(线上 registry)' : '';
|
|
55
56
|
console.log(`✓ 已安装技能 ${rn.name}${srcLabel} → ~/.mingdao/skills/${rn.name}/(可编辑/删除,下次会话生效)`);
|
|
57
|
+
if (rn.host) {
|
|
58
|
+
// v0.6.3(M-6):**如实说明这条校验的边界**。
|
|
59
|
+
// sha256 来自索引本身(文件与哈希同源),所以它能挡住传输损坏与第三方镜像篡改,
|
|
60
|
+
// 但**挡不住索引被投毒**——投毒者会同时下发恶意正文与与之匹配的哈希,校验必然通过。
|
|
61
|
+
// 结论:它是完整性检查,不是来源可信性证明。用户有权知道这个区别。
|
|
62
|
+
console.log(' 说明:完整性校验(sha256)与索引同源,只防传输损坏/镜像篡改,不防**索引本身被投毒**。');
|
|
63
|
+
}
|
|
56
64
|
}
|
|
57
65
|
return true;
|
|
58
66
|
}
|
|
@@ -172,8 +180,29 @@ export async function handleWeb(/** @type {any} */ cmd, /** @type {any} */ args)
|
|
|
172
180
|
if (atIdx !== -1) {
|
|
173
181
|
const raw = args[atIdx];
|
|
174
182
|
authToken = raw.includes('=') ? raw.slice(raw.indexOf('=') + 1) : args[atIdx + 1];
|
|
183
|
+
// v0.6.3(BUG-015):`--auth-token <令牌>` 会把令牌留在 **argv** 里 —— `ps aux` 与 shell 历史
|
|
184
|
+
// 都能看到(本仓发布链路早就把"token 不进 argv"定为纪律,这里此前是漏网的一处)。
|
|
185
|
+
// 现在支持 `--auth-token=-` 从 **stdin** 读(脚本里 `printf %s "$TOK" | mingdao web --auth-token=-`),
|
|
186
|
+
// 直接用字面量时给出明确告警而不是默默泄露。
|
|
187
|
+
if (authToken === '-') {
|
|
188
|
+
try {
|
|
189
|
+
authToken = fs.readFileSync(0, 'utf8').trim();
|
|
190
|
+
} catch {
|
|
191
|
+
authToken = '';
|
|
192
|
+
}
|
|
193
|
+
if (!authToken) {
|
|
194
|
+
console.log('[错误] --auth-token=- 需要从 stdin 读入令牌(例如:printf %s "$MINGDAO_WEB_TOKEN" | mingdao web --auth-token=-)');
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
} else if (authToken) {
|
|
199
|
+
console.warn(
|
|
200
|
+
'[MingDao] ⚠ 令牌写在命令行里会留在 argv 与 shell 历史中(ps aux 可见)。\n' +
|
|
201
|
+
' 建议改用:环境变量 MINGDAO_WEB_TOKEN、config.json 的 web.token,或 `--auth-token=-` 从 stdin 读入。'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
175
204
|
if (!authToken) {
|
|
176
|
-
console.log('用法:mingdao web [端口] [--auth-token
|
|
205
|
+
console.log('用法:mingdao web [端口] [--auth-token <令牌>|--auth-token=-]');
|
|
177
206
|
process.exitCode = 1;
|
|
178
207
|
return true;
|
|
179
208
|
}
|
package/src/commands/sync.js
CHANGED
|
@@ -280,6 +280,13 @@ export async function handleSync(/** @type {any} */ cmd, /** @type {any} */ args
|
|
|
280
280
|
return true;
|
|
281
281
|
}
|
|
282
282
|
console.log(`✓ 已推送 ${r.pushed.length} 个会话${r.skipped?.length ? `(跳过 ${r.skipped.length} 个空会话)` : ''}${r.conflicts.length ? `,远端 ${r.conflicts.length} 个不同版本已备份为 .server-*(本地覆盖远端)` : ''}`);
|
|
283
|
+
// v0.6.3(审计 H-2):上传前脱敏必须让用户知道——否则他以为远端存的是原文
|
|
284
|
+
if (r.redacted?.length) {
|
|
285
|
+
console.log(
|
|
286
|
+
`ℹ ${r.redacted.length} 个会话在上传前已脱敏(疑似密钥被掩码):${r.redacted.slice(0, 3).join('、')}${r.redacted.length > 3 ? ' 等' : ''}\n` +
|
|
287
|
+
' 本地文件保持原样;远端与其它设备拿到的是掩码版。会话原文仍建议不要粘贴长期凭据。'
|
|
288
|
+
);
|
|
289
|
+
}
|
|
283
290
|
return true;
|
|
284
291
|
}
|
|
285
292
|
if (sub === 'pull') {
|
|
@@ -12,7 +12,7 @@ export async function handleWorkspace(/** @type {any} */ cmd, /** @type {any} */
|
|
|
12
12
|
process.exitCode = 1;
|
|
13
13
|
return true;
|
|
14
14
|
}
|
|
15
|
-
const r = addWorkspace(name, args[2]);
|
|
15
|
+
const r = await addWorkspace(name, args[2]);
|
|
16
16
|
if (r.error) {
|
|
17
17
|
console.log('[错误] ' + r.error);
|
|
18
18
|
process.exitCode = 1;
|
|
@@ -29,7 +29,7 @@ export async function handleWorkspace(/** @type {any} */ cmd, /** @type {any} */
|
|
|
29
29
|
}
|
|
30
30
|
// v0.6.2:removeWorkspace 现在可能返回 {error}(注册表写失败)。
|
|
31
31
|
// 只判真值会把对象当成成功,把「写失败」说成「✓ 已移除」——那正是本批要消灭的假成功。
|
|
32
|
-
const rm = removeWorkspace(name);
|
|
32
|
+
const rm = await removeWorkspace(name);
|
|
33
33
|
if (rm === true) console.log(`✓ 已移除 ${name}`);
|
|
34
34
|
else if (rm && /** @type {any} */ (rm).error) {
|
|
35
35
|
console.log('[错误] ' + /** @type {any} */ (rm).error);
|
|
@@ -63,7 +63,7 @@ export async function handleWorkspace(/** @type {any} */ cmd, /** @type {any} */
|
|
|
63
63
|
process.exitCode = 1;
|
|
64
64
|
return true;
|
|
65
65
|
}
|
|
66
|
-
touchWorkspace(name);
|
|
66
|
+
await touchWorkspace(name);
|
|
67
67
|
console.log(`✓ 工作空间 ${name}:${p}`);
|
|
68
68
|
console.log(` 快速进入:cd "$(mingdao workspace path ${name})"(建议做成 shell 函数/别名,如 mdw() { cd "$(mingdao workspace path "$1")"; })`);
|
|
69
69
|
return true;
|
package/src/compact.js
CHANGED
|
@@ -13,6 +13,8 @@ const MIN_DROP_TOKENS = 2000; // 被裁段落 token 数低于此值不值得一
|
|
|
13
13
|
// B1/B2(评估建议):达到预算 80% 即提前压缩、压到 60%——滞回缓冲带避免在预算线附近
|
|
14
14
|
// 反复触发裁剪/压缩,让摘要成为稳定前缀、后续轮次全程缓存命中;压缩触发频率也大幅下降。
|
|
15
15
|
const DEFAULT_TRIGGER_RATIO = 0.8;
|
|
16
|
+
/** v0.6.3(BUG-036):triggerRatio 越界只告警一次,避免每回合刷屏 */
|
|
17
|
+
let triggerWarned = false;
|
|
16
18
|
const TARGET_RATIO = 0.6;
|
|
17
19
|
const SUMMARY_MAX_CHARS = 1600; // 摘要上限(约几百 token,远小于被裁段落)
|
|
18
20
|
const INPUT_MAX_CHARS = 30000; // 摘要输入上限(防超长工具输出撑爆摘要请求)
|
|
@@ -69,7 +71,19 @@ export async function compactConversation(/** @type {any} */ { messages, budget,
|
|
|
69
71
|
// boundary 保持 messages.length,于是除 system 外整段会话(含最新用户指令)被全部压成摘要,
|
|
70
72
|
// 且 WebUI 的 onCompact 会把结果 rewriteSession 永久写回会话文件(真·失忆)。
|
|
71
73
|
// config.compactTrigger 是文档化的 0–1 可调项,「想更早压缩」的自然用法正好命中。
|
|
72
|
-
|
|
74
|
+
// v0.6.3(BUG-036):**上限也要夹**。原实现只 `Math.max(..., TARGET_RATIO)`,于是
|
|
75
|
+
// `config.compactTrigger` 配成 >1(或手滑多打一个 0)时,`total <= budget*trigger` 恒真 →
|
|
76
|
+
// 自动压缩**永久失效**(force 分支之外永不触发)→ 长会话一路涨到窗口上限才由服务端报错。
|
|
77
|
+
// 上限定在 1(= 用满预算才压)并**明确告警**,而不是静默失效。
|
|
78
|
+
let triggerIn = Number(triggerRatio);
|
|
79
|
+
if (Number.isFinite(triggerIn) && triggerIn > 1) {
|
|
80
|
+
if (!triggerWarned) {
|
|
81
|
+
triggerWarned = true;
|
|
82
|
+
console.warn(`[MingDao] ⚠ compactTrigger=${triggerIn} 超出有效范围(0–1),已按 1 处理——否则自动压缩永不触发,长会话会一直涨到窗口上限。`);
|
|
83
|
+
}
|
|
84
|
+
triggerIn = 1;
|
|
85
|
+
}
|
|
86
|
+
const trigger = Number.isFinite(triggerIn) ? Math.max(triggerIn, TARGET_RATIO) : DEFAULT_TRIGGER_RATIO;
|
|
73
87
|
// force(v0.3.2 边缘检测):逼近窗口时即便启发式计数低估(total 未达触发线)也强制压缩——
|
|
74
88
|
// 非 DeepSeek 模型启发式计数误差可达 ±2 倍,等它越过触发线时真实 prompt 可能已到窗口边缘。
|
|
75
89
|
if (total <= budget * trigger && !force) return null;
|
package/src/config.js
CHANGED
|
@@ -24,21 +24,86 @@ export function configPath() {
|
|
|
24
24
|
return path.join(mingdaoHome(), 'config.json');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
|
|
27
|
+
/**
|
|
28
|
+
* 严格读取配置:区分「不存在」与「存在但读不出来」。v0.6.3(H-7)。
|
|
29
|
+
*
|
|
30
|
+
* 原实现 `catch { return null }` 把 ENOENT 与「JSON 解析失败 / 权限不足 / 不是对象」抹平成同一件事,
|
|
31
|
+
* 而所有调用点对 null 的处理都是**当作首次运行**——于是改坏一个字符(或存成带 BOM 的 UTF-8)
|
|
32
|
+
* 之后跑 `mingdao init` / 桌面版首启,`customModels`/`mcpServers`/`sync`/`net`/`costGuard`
|
|
33
|
+
* 会被一个全新对象**整文件覆盖**,且不备份、不告警。BOM 这一支尤其冤:内容完全合法,
|
|
34
|
+
* 只是 JSON.parse 不认 BOM。
|
|
35
|
+
*
|
|
36
|
+
* @returns {{ok: boolean, exists: boolean, data: any, error: string|null}}
|
|
37
|
+
*/
|
|
38
|
+
export function readConfigStrict() {
|
|
39
|
+
const file = configPath();
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
43
|
+
} catch (/** @type {any} */ err) {
|
|
44
|
+
if (err?.code === 'ENOENT') return { ok: true, exists: false, data: null, error: null };
|
|
45
|
+
return { ok: false, exists: true, data: null, error: `无法读取 ${file}:${err?.message || err}` };
|
|
46
|
+
}
|
|
47
|
+
// BOM:合法的 JSON 内容 + 文件头 BOM = JSON.parse 直接抛(Excel/记事本另存为的常见产物)
|
|
48
|
+
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
49
|
+
if (!text.trim()) return { ok: false, exists: true, data: null, error: `${file} 是空文件` };
|
|
50
|
+
try {
|
|
51
|
+
const data = JSON.parse(text);
|
|
52
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
53
|
+
return { ok: false, exists: true, data: null, error: `${file} 的内容不是 JSON 对象(实际是 ${Array.isArray(data) ? '数组' : typeof data})` };
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, exists: true, data, error: null };
|
|
56
|
+
} catch (/** @type {any} */ err) {
|
|
57
|
+
return { ok: false, exists: true, data: null, error: `${file} 解析失败:${err?.message || err}` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 把「读不出来的 config.json」**改名**成 `config.json.corrupt-<时间戳>`。
|
|
63
|
+
* 用改名而不是复制:后续任何「写全新配置」都不会再压到用户的原始数据上。
|
|
64
|
+
* @param {string} reason
|
|
65
|
+
* @returns {string|null} 备份路径(无需备份时 null)
|
|
66
|
+
*/
|
|
67
|
+
export function quarantineCorruptConfig(reason) {
|
|
68
|
+
const file = configPath();
|
|
69
|
+
if (!fs.existsSync(file)) return null;
|
|
70
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
71
|
+
// 同一秒内发生两次损坏时,rename 会**覆盖**上一份备份(POSIX rename 是覆盖语义)——
|
|
72
|
+
// 而备份的意义正是"一份都不许丢"。故重名时顺延编号。
|
|
73
|
+
let dest = `${file}.corrupt-${stamp}`;
|
|
74
|
+
for (let k = 1; fs.existsSync(dest); k += 1) dest = `${file}.corrupt-${stamp}-${k}`;
|
|
30
75
|
try {
|
|
31
|
-
|
|
76
|
+
fs.renameSync(file, dest);
|
|
32
77
|
} catch {
|
|
33
78
|
return null;
|
|
34
79
|
}
|
|
80
|
+
console.warn(
|
|
81
|
+
`[MingDao] ⚠ 配置文件读不出来,已**改名备份**而不是覆盖它:\n` +
|
|
82
|
+
` 原因:${reason}\n` +
|
|
83
|
+
` 备份:${dest}\n` +
|
|
84
|
+
` 接下来会按全新配置继续(首次运行向导 / 最小可用配置)。请从备份里把\n` +
|
|
85
|
+
` customModels / mcpServers / sync / net / costGuard 等字段手动并回新的 config.json。`
|
|
86
|
+
);
|
|
87
|
+
return dest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 读取配置对象(不存在/损坏返回 null);返回值为用户可编辑的任意 JSON 配置,类型不定
|
|
91
|
+
*
|
|
92
|
+
* 注意(v0.6.3 / H-7):null **同时**表示「不存在」与「读不出来」。凡是要**写**配置的调用点,
|
|
93
|
+
* 必须先用 `readConfigStrict()` 区分这两件事,否则会把损坏配置静默覆盖掉。
|
|
94
|
+
* @returns {any} */
|
|
95
|
+
export function loadConfig() {
|
|
96
|
+
const r = readConfigStrict();
|
|
97
|
+
return r.ok ? r.data : null;
|
|
35
98
|
}
|
|
36
99
|
|
|
37
100
|
/** 桌面版首次运行:无配置时自动创建最小可用配置(引导在 WebUI 内完成,
|
|
38
101
|
* 不再要求先去终端跑 mingdao init)。CLI 的 mingdao init 向导不受影响。 */
|
|
39
102
|
export function ensureMinimalConfig() {
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
103
|
+
const strict = readConfigStrict();
|
|
104
|
+
if (strict.ok && strict.data) return strict.data;
|
|
105
|
+
// v0.6.3(H-7):存在但读不出来 → 先备份再写,绝不让"首启自动建配置"吃掉用户配置
|
|
106
|
+
if (strict.exists && !strict.ok) quarantineCorruptConfig(strict.error || '未知原因');
|
|
42
107
|
ensureHome();
|
|
43
108
|
const pp = PROVIDERS['deepseek'] || Object.values(PROVIDERS)[0];
|
|
44
109
|
const model = modelPreset(DEFAULT_MODEL) ? DEFAULT_MODEL : pp.models[0];
|
package/src/constraints.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// 而 prompt 是建议不是强制——模型越界一次就是事故。本模块提供三个执行时机:
|
|
6
6
|
//
|
|
7
7
|
// ① PreToolUse —— tool-deny / tool-arg-require / arg-forbid:阻止执行并回填错误给模型
|
|
8
|
+
// confirm:**即使权限档位是 auto 也要人工确认**(v0.6.3 起真正求值,
|
|
9
|
+
// 此前它只是 KINDS 里的一个字符串,永远不会被求值——红线静默消失)
|
|
8
10
|
// ② PostToolUse —— completeness / result-forbid:拒绝工具结果并要求补采
|
|
9
11
|
// ③ 输出前 —— output-forbid:按 action(block / block-and-rewrite / warn)处理
|
|
10
12
|
//
|
|
@@ -26,6 +28,34 @@ export const PATTERN_KINDS = new Set(['arg-forbid', 'output-forbid', 'result-for
|
|
|
26
28
|
/** 作用域限于「某个工具」的 kind:其 pattern 坏掉时可以精确 fail-closed(只拦这个工具) */
|
|
27
29
|
const SCOPED_PATTERN_KINDS = new Set(['arg-forbid', 'result-forbid']);
|
|
28
30
|
|
|
31
|
+
// v0.6.3(审计 BUG-040):completeness(必填项校验)的「缺失」哨兵**不能只认中文**。
|
|
32
|
+
//
|
|
33
|
+
// 原实现硬编码 `String(v).trim() === '未提及'`:英文/多语种会话里模型会写
|
|
34
|
+
// "not mentioned" / "N/A" / "unknown" —— 判定不中,于是"必填项缺失"这条红线
|
|
35
|
+
// **因为语言而静默停止生效**(合规约束里最不该出现的一种失效)。
|
|
36
|
+
// 保守取一组明确的"未提供"措辞;**刻意不含 `无` / `none`**——医疗等域里
|
|
37
|
+
// 「过敏史: 无」是**有效数据**,把它当缺失会误拒。域内若另有约定,
|
|
38
|
+
// 由约束自己声明 `missingValues: [...]` 扩展。
|
|
39
|
+
const MISSING_VALUE_SENTINELS = new Set([
|
|
40
|
+
'null', 'undefined', 'n/a', 'na', 'not mentioned', 'not provided', 'not available',
|
|
41
|
+
'not recorded', 'unknown', 'tbd', '未提及', '未提供', '未采集', '未填写', '未记录',
|
|
42
|
+
'不详', '未知', '待补充', '待采集', '待确认',
|
|
43
|
+
]);
|
|
44
|
+
/**
|
|
45
|
+
* 该字段值是否应判为「缺失」。
|
|
46
|
+
* @param {any} c 约束(可带 missingValues 扩展)
|
|
47
|
+
* @param {any} v 字段值
|
|
48
|
+
*/
|
|
49
|
+
function isMissingFieldValue(/** @type {any} */ c, /** @type {any} */ v) {
|
|
50
|
+
if (v === undefined || v === null) return true;
|
|
51
|
+
const t = String(v).trim();
|
|
52
|
+
if (t === '') return true;
|
|
53
|
+
const lower = t.toLowerCase();
|
|
54
|
+
if (MISSING_VALUE_SENTINELS.has(lower)) return true;
|
|
55
|
+
const extra = Array.isArray(c?.missingValues) ? c.missingValues : [];
|
|
56
|
+
return extra.some((/** @type {any} */ x) => String(x).trim().toLowerCase() === lower);
|
|
57
|
+
}
|
|
58
|
+
|
|
29
59
|
/** 预编译的正则缓存:pattern → RegExp(非法正则返回 null,由调用方 fail-closed) */
|
|
30
60
|
const reCache = new Map();
|
|
31
61
|
/** @param {any} pattern */
|
|
@@ -152,7 +182,7 @@ export function compileConstraints(list) {
|
|
|
152
182
|
continue;
|
|
153
183
|
}
|
|
154
184
|
n += 1;
|
|
155
|
-
if (c.kind === 'tool-deny' || c.kind === 'tool-arg-require' || c.kind === 'arg-forbid') pre.push(c);
|
|
185
|
+
if (c.kind === 'tool-deny' || c.kind === 'tool-arg-require' || c.kind === 'arg-forbid' || c.kind === 'confirm') pre.push(c);
|
|
156
186
|
else if (c.kind === 'completeness' || c.kind === 'result-forbid') post.push(c);
|
|
157
187
|
else output.push(c);
|
|
158
188
|
}
|
|
@@ -177,6 +207,11 @@ export function compileConstraints(list) {
|
|
|
177
207
|
*/
|
|
178
208
|
function event(c, stage, extra = {}) {
|
|
179
209
|
return {
|
|
210
|
+
// v0.6.3(P1-1):事件必须自带 `id`。此前只有 `constraint`,而**两个**消费方都读 `id`
|
|
211
|
+
// (agent.js 的账本 `tool.call.constraint.id`、replay.js 的 `constraintId`),
|
|
212
|
+
// 于是每次被红线拦下,账本里「是哪条红线拦的」恒为 null——不报错、只输出 null,
|
|
213
|
+
// 任何断言都不会失败。`constraint` 保留,兼容既有事件消费方与历史账本。
|
|
214
|
+
id: c?.id || null,
|
|
180
215
|
pack: c?.pack || null,
|
|
181
216
|
constraint: c?.id || null,
|
|
182
217
|
kind: c?.kind || null,
|
|
@@ -188,12 +223,15 @@ function event(c, stage, extra = {}) {
|
|
|
188
223
|
/**
|
|
189
224
|
* ① PreToolUse:工具与参数层面的红线。
|
|
190
225
|
* @param {any} compiled @param {any} toolName @param {any} args
|
|
191
|
-
* @returns {{ blocked: boolean, reason: string, event: any } | null}
|
|
226
|
+
* @returns {{ blocked: boolean, needsConfirm?: boolean, reason: string, event: any } | null}
|
|
192
227
|
*/
|
|
193
228
|
export function checkPreTool(compiled, toolName, args) {
|
|
194
229
|
if (!compiled?.active) return null;
|
|
195
230
|
try {
|
|
231
|
+
// 第一遍:**阻断类**。confirm 必须留到第二遍——否则「confirm 声明在 tool-deny 之前」
|
|
232
|
+
// 会让 confirm 先返回、后面的 deny 再也走不到,等于给红线开了后门(fail-open)。
|
|
196
233
|
for (const c of compiled.pre) {
|
|
234
|
+
if (c.kind === 'confirm') continue;
|
|
197
235
|
if (!toolMatches(c, toolName)) continue;
|
|
198
236
|
if (c.kind === 'tool-deny') {
|
|
199
237
|
return { blocked: true, reason: `领域约束「${c.id}」禁止调用 ${c.tool}`, event: event(c, 'pre-tool', { action: 'block' }) };
|
|
@@ -229,6 +267,20 @@ export function checkPreTool(compiled, toolName, args) {
|
|
|
229
267
|
}
|
|
230
268
|
}
|
|
231
269
|
}
|
|
270
|
+
// 第二遍:confirm(v0.6.3 / P1-2)。本函数是纯函数、拿不到 IO,因此只**声明**
|
|
271
|
+
// 「这次调用需要人工确认」,由调用方(agent.js)去问;问不到/答否一律按阻断处理。
|
|
272
|
+
// 返回 `blocked:false` 而非 `blocked:true`,是为了让调用方能区分
|
|
273
|
+
// 「规则直接禁止」与「规则要求先确认」两种情况,回填给模型的措辞也不同。
|
|
274
|
+
for (const c of compiled.pre) {
|
|
275
|
+
if (c.kind !== 'confirm') continue;
|
|
276
|
+
if (!toolMatches(c, toolName)) continue;
|
|
277
|
+
return {
|
|
278
|
+
blocked: false,
|
|
279
|
+
needsConfirm: true,
|
|
280
|
+
reason: `领域约束「${c.id}」要求人工确认后才可调用 ${c.tool}`,
|
|
281
|
+
event: event(c, 'pre-tool', { action: 'confirm' }),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
232
284
|
return null;
|
|
233
285
|
} catch (/** @type {any} */ err) {
|
|
234
286
|
// fail-closed:约束求值异常按阻断处理,绝不静默放行
|
|
@@ -285,10 +337,7 @@ export function checkPostTool(compiled, toolName, result) {
|
|
|
285
337
|
event: event(c, 'post-tool', { action: 'reject', reason: 'no-data' }),
|
|
286
338
|
};
|
|
287
339
|
}
|
|
288
|
-
const missing = (Array.isArray(c.fields) ? c.fields : []).filter((/** @type {any} */ f) =>
|
|
289
|
-
const v = data[f];
|
|
290
|
-
return v === undefined || v === null || String(v).trim() === '' || String(v).trim() === '未提及';
|
|
291
|
-
});
|
|
340
|
+
const missing = (Array.isArray(c.fields) ? c.fields : []).filter((/** @type {any} */ f) => isMissingFieldValue(c, data[f]));
|
|
292
341
|
if (missing.length) {
|
|
293
342
|
return {
|
|
294
343
|
rejected: true,
|
package/src/credentials.js
CHANGED
|
@@ -15,15 +15,46 @@ export function credentialsPath() {
|
|
|
15
15
|
return path.join(mingdaoHome(), 'credentials.json');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// v0.6.3(审计 H-8):**必须区分「文件不存在」与「内容损坏」**。
|
|
19
|
+
//
|
|
20
|
+
// 原实现把两者都 `catch → {}`,而 key set/remove/import 的写路径是
|
|
21
|
+
// 「loadCredentials() → 改一个键 → saveCredentials() 全量重写」——
|
|
22
|
+
// 于是 credentials.json 一旦损坏(手改/中断写入),执行任意一次 key 写操作就会
|
|
23
|
+
// **把其余全部凭据静默清空并打印成功**(实测复现)。
|
|
24
|
+
//
|
|
25
|
+
// 读路径(getStoredKey / key status)保持宽松语义不变;写路径必须用严格读并**拒绝写**。
|
|
26
|
+
/**
|
|
27
|
+
* 严格读凭证库。
|
|
28
|
+
* @returns {{ok: boolean, missing: boolean, data: Record<string, any>, error: string|null}}
|
|
29
|
+
* ok=false 且 missing=false 表示「文件存在但读不出来/解析失败」——**此时绝不能写**。
|
|
30
|
+
*/
|
|
31
|
+
export function readCredentialsStrict() {
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = fs.readFileSync(credentialsPath(), 'utf8');
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (/** @type {any} */ (err)?.code === 'ENOENT') return { ok: true, missing: true, data: {}, error: null };
|
|
37
|
+
return { ok: false, missing: false, data: {}, error: String(/** @type {any} */ (err)?.message ?? err) };
|
|
38
|
+
}
|
|
19
39
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
40
|
+
// 容错 BOM:Windows 上 PowerShell 的 `Set-Content -Encoding UTF8` 会写出 BOM,
|
|
41
|
+
// 那是**可解析**的内容,不该被当成损坏而拒绝服务(否则用户改一次配置就永远写不进去)。
|
|
42
|
+
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
43
|
+
const data = JSON.parse(text);
|
|
44
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
45
|
+
return { ok: false, missing: false, data: {}, error: '顶层不是 JSON 对象' };
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, missing: false, data, error: null };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { ok: false, missing: false, data: {}, error: String(/** @type {any} */ (err)?.message ?? err) };
|
|
24
50
|
}
|
|
25
51
|
}
|
|
26
52
|
|
|
53
|
+
export function loadCredentials() {
|
|
54
|
+
// 宽松读(读路径):读不出来就当空——调用方只想知道「有哪些 key」
|
|
55
|
+
return readCredentialsStrict().data;
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
/** @param {any} creds */
|
|
28
59
|
export function saveCredentials(creds) {
|
|
29
60
|
ensureHome();
|