mingdao-harness 0.4.4 → 0.4.6
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/ARCHITECTURE.md +1 -1
- package/docs/AUDIT-v0.4.6.md +179 -0
- package/docs/PACK-API.md +250 -0
- package/docs/SAVINGS-BENCHMARK.md +12 -5
- package/docs/STRATEGY-0.5.md +262 -0
- package/docs/STRATEGY-NEXT.md +1 -1
- package/package.json +2 -1
- package/skills-lib/bash/SKILL.md +26 -0
- package/skills-lib/changelog/SKILL.md +24 -0
- package/skills-lib/ci-cd/SKILL.md +25 -0
- package/skills-lib/data-analysis/SKILL.md +25 -0
- package/skills-lib/database-design/SKILL.md +25 -0
- package/skills-lib/docs/SKILL.md +25 -0
- package/skills-lib/email/SKILL.md +25 -0
- package/skills-lib/file-organize/SKILL.md +27 -0
- package/skills-lib/git-workflow/SKILL.md +26 -0
- package/skills-lib/i18n/SKILL.md +24 -0
- package/skills-lib/markdown/SKILL.md +26 -0
- package/skills-lib/meeting-notes/SKILL.md +25 -0
- package/skills-lib/nodejs/SKILL.md +26 -0
- package/skills-lib/performance/SKILL.md +25 -0
- package/skills-lib/python/SKILL.md +26 -0
- package/skills-lib/readme/SKILL.md +25 -0
- package/skills-lib/regex/SKILL.md +25 -0
- package/skills-lib/report/SKILL.md +25 -0
- package/skills-lib/resume/SKILL.md +24 -0
- package/skills-lib/security-audit/SKILL.md +28 -0
- package/skills-lib/sql/SKILL.md +25 -0
- package/skills-lib/translation/SKILL.md +25 -0
- package/src/agent.js +95 -9
- package/src/atomic-write.js +9 -0
- package/src/autostart.js +37 -3
- package/src/batch.js +9 -1
- package/src/cachestats.js +32 -8
- package/src/cli.js +29 -14
- package/src/commands/repl.js +5 -3
- package/src/commands/update.js +1 -1
- package/src/compact.js +16 -1
- package/src/context.js +8 -0
- package/src/cost-guard.js +26 -6
- package/src/hooks.js +20 -4
- package/src/log-writer.js +19 -7
- package/src/mcp.js +4 -0
- package/src/memory.js +6 -5
- package/src/model-caps.js +48 -4
- package/src/permissions.js +10 -4
- package/src/presets.js +13 -3
- package/src/pricing.js +78 -18
- package/src/providers/index.js +43 -13
- package/src/providers/openai-compatible.js +42 -1
- package/src/redact.js +5 -0
- package/src/schedule.js +62 -30
- package/src/session.js +14 -1
- package/src/skill-lib.js +23 -4
- package/src/skills.js +2 -2
- package/src/sync-server.js +29 -1
- package/src/sync.js +6 -3
- package/src/tasks/worker.js +4 -1
- package/src/tasks.js +4 -1
- package/src/tokenizer.js +48 -11
- package/src/tools/bash.js +9 -2
- package/src/tools/fetch.js +97 -8
- package/src/tools/fs-tools.js +48 -3
- package/src/tools/git.js +17 -1
- package/src/tools/index.js +12 -5
- package/src/web/constants.js +10 -0
- package/src/web/routes/api.js +3 -1
- package/src/web/routes/domains/misc.js +6 -0
- package/src/web/server.js +41 -31
package/src/cachestats.js
CHANGED
|
@@ -5,7 +5,6 @@ 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 { modelPreset } from './models.js';
|
|
9
8
|
import { withFileLockSync, atomicWriteFileSync } from './atomic-write.js';
|
|
10
9
|
|
|
11
10
|
export function cacheStatsFile() {
|
|
@@ -48,8 +47,28 @@ export function recordCacheStats(/** @type {any} */ entry) {
|
|
|
48
47
|
const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
|
|
49
48
|
const lines = raw.split('\n').filter(Boolean);
|
|
50
49
|
if (lines.length > MAX_LINES) {
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// v0.4.6 P2 修复:轮转必须保留**当天全部**记录。此前只保留最后 KEEP_LINES 行——
|
|
51
|
+
// 单进程写满 2 万条后,当天早先的费用会被裁掉,todayCost() 随之变小,
|
|
52
|
+
// 日费用护栏被静默重置(用户可再次超支而不自知)。当天行(最多 MAX_LINES 条)
|
|
53
|
+
// 与最近 KEEP_LINES 行取并集,去重且保持原顺序。
|
|
54
|
+
const dayStart = beijingDayStart().getTime();
|
|
55
|
+
const todayLines = lines.filter((/** @type {string} */ l) => {
|
|
56
|
+
try {
|
|
57
|
+
return (JSON.parse(l).at || 0) >= dayStart;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
const tail = lines.slice(-KEEP_LINES);
|
|
63
|
+
const tailSet = new Set(tail);
|
|
64
|
+
const todayCapped = todayLines.length > MAX_LINES ? todayLines.slice(-MAX_LINES) : todayLines;
|
|
65
|
+
const todaySet = new Set(todayCapped);
|
|
66
|
+
const merged = [...todayCapped, ...tail.filter((l) => !todaySet.has(l))];
|
|
67
|
+
// 顺序按原文件恢复(避免打乱 byDay 折线的时间序)
|
|
68
|
+
const order = new Map(lines.map((l, i) => [l, i]));
|
|
69
|
+
merged.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
|
|
70
|
+
atomicWriteFileSync(cacheStatsFile(), merged.join('\n') + '\n');
|
|
71
|
+
cacheStatsCount = merged.length;
|
|
53
72
|
}
|
|
54
73
|
});
|
|
55
74
|
} catch {}
|
|
@@ -109,16 +128,21 @@ export function summarizeCacheStats(/** @type {any} */ entries) {
|
|
|
109
128
|
// 记录一次用量(agent 侧调用):自动计算命中拆分与节省额;perf 为回合性能指标(状态栏)
|
|
110
129
|
export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usage, /** @type {any} */ perf = null) {
|
|
111
130
|
const split = cacheSplit(usage);
|
|
131
|
+
// v0.4.6:峰谷单价按「请求发起时刻」判定。响应落地与发起可能跨 12:00/18:00 边界,
|
|
132
|
+
// 用 new Date()(落账时刻)会把高峰调用记成闲时价(或少记一半),日累计与护栏随之偏移。
|
|
133
|
+
const priceAt = Number(perf?.requestStartAt) > 0 ? new Date(Number(perf.requestStartAt)) : undefined;
|
|
112
134
|
const prompt = usage?.prompt_tokens || 0;
|
|
113
135
|
const completion = usage?.completion_tokens || 0;
|
|
114
136
|
let cost = null;
|
|
115
137
|
let saved = null;
|
|
116
138
|
if (split) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
const base = estimateCost(modelName, prompt, completion, null, priceAt);
|
|
140
|
+
cost = estimateCost(modelName, prompt, completion, split, priceAt);
|
|
141
|
+
if (base != null && cost != null) saved = base - cost;
|
|
142
|
+
} else {
|
|
143
|
+
// P0-4(v0.4.5):estimateCost 无价返 null,直接记录 null(未知)而非 0(免费)——
|
|
144
|
+
// 覆盖内置定价 + 外部定价 + config.pricing.overrides 三条来源,不再依赖 modelPreset 单一判断。
|
|
145
|
+
cost = estimateCost(modelName, prompt, completion, null, priceAt);
|
|
122
146
|
}
|
|
123
147
|
recordCacheStats({
|
|
124
148
|
model: modelName,
|
package/src/cli.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
reconcileSchedules,
|
|
33
33
|
runSleeper,
|
|
34
34
|
formatScheduleRow,
|
|
35
|
+
postRunStatus,
|
|
35
36
|
} from './schedule.js';
|
|
36
37
|
import { createAgent } from './agent.js';
|
|
37
38
|
import { saveTaskStateMerge, clearTaskState } from './task-state.js';
|
|
@@ -120,21 +121,28 @@ function printHelpLines(/** @type {any} */ out) {
|
|
|
120
121
|
|
|
121
122
|
function parseArgs(/** @type {any} */ argv) {
|
|
122
123
|
const opts = /** @type {Record<string, any>} */ ({ prompt: [], model: null, continueSession: false, resume: false, format: 'text' });
|
|
124
|
+
// P1-2 / P2-24(v0.4.5):遇第一个位置参数后停止解析全局 flag——此前贪婪匹配任意位置的 --model/init,
|
|
125
|
+
// 导致 `mingdao run "任务" --model X` 的 --model 被顶层剥除(后台/定时指定模型静默失效),
|
|
126
|
+
// 且提问文本含「init」即误触发初始化向导。此后子命令 flag 原样收入 prompt 交子命令解析。
|
|
127
|
+
let seenArg = false;
|
|
123
128
|
for (let i = 0; i < argv.length; i++) {
|
|
124
129
|
const a = argv[i];
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
if (!seenArg) {
|
|
131
|
+
if (a === '-h' || a === '--help') { opts.help = true; continue; }
|
|
132
|
+
else if (a === '-v' || a === '--version') { opts.version = true; continue; }
|
|
133
|
+
else if (a === '-c' || a === '--continue') { opts.continueSession = true; continue; }
|
|
134
|
+
else if (a === '--journal') { opts.journal = true; continue; }
|
|
135
|
+
else if (a === '-p' || a === '--preset') { opts.preset = argv[++i]; continue; }
|
|
136
|
+
else if (a.startsWith('--preset=')) { opts.preset = a.slice(9); continue; }
|
|
137
|
+
else if (a === '-r' || a === '--resume') { opts.resume = true; continue; }
|
|
138
|
+
else if (a === '--init' || a === 'init') { opts.init = true; continue; }
|
|
139
|
+
else if (a === '-m' || a === '--model') { opts.model = argv[++i]; continue; }
|
|
140
|
+
else if (a.startsWith('--model=')) { opts.model = a.slice(8); continue; }
|
|
141
|
+
else if (a === '-f' || a === '--format') { opts.format = argv[++i] || 'text'; continue; }
|
|
142
|
+
else if (a.startsWith('--format=')) { opts.format = a.slice(9); continue; }
|
|
143
|
+
}
|
|
144
|
+
seenArg = true;
|
|
145
|
+
opts.prompt.push(a);
|
|
138
146
|
}
|
|
139
147
|
if (!['text', 'json'].includes(opts.format)) opts.format = 'text';
|
|
140
148
|
return opts;
|
|
@@ -289,7 +297,14 @@ async function main() {
|
|
|
289
297
|
}
|
|
290
298
|
if (t.status !== 'running') {
|
|
291
299
|
const result = t.status === 'done' ? 'done' : t.status === 'timedout' ? 'timedout' : 'failed';
|
|
292
|
-
|
|
300
|
+
// P1-4(v0.4.5):every 周期任务崩溃恢复不能终态化——复用 postRunStatus 重排下一期回 pending,
|
|
301
|
+
// 否则周期任务静默永停(此前与 once/after 一视同仁写成 done/failed,reconcile 永远跳过)。
|
|
302
|
+
if (j.kind === 'every') {
|
|
303
|
+
const ns = postRunStatus(j, result);
|
|
304
|
+
await writeSchedule(home0, { ...j, ...(ns || { status: result }), lastRunAt: t.startedAt || j.lastRunAt, runs: (j.runs || 0) + 1 });
|
|
305
|
+
} else {
|
|
306
|
+
await writeSchedule(home0, { ...j, status: result, lastRunAt: t.startedAt || j.lastRunAt, runs: (j.runs || 0) + 1 });
|
|
307
|
+
}
|
|
293
308
|
continue;
|
|
294
309
|
}
|
|
295
310
|
continue; // worker 仍在跑:等它(外层 2s 轮询)
|
package/src/commands/repl.js
CHANGED
|
@@ -617,13 +617,14 @@ export async function runRepl(ctx) {
|
|
|
617
617
|
);
|
|
618
618
|
} else io.print('尚无用量记录。');
|
|
619
619
|
} else if (cmd === '/status') {
|
|
620
|
+
const sc = estimateCost(modelName, stats.promptTokens, stats.completionTokens);
|
|
620
621
|
io.box('会话状态', [
|
|
621
622
|
`模型 ${modelName} · 权限 ${permission.mode}`,
|
|
622
623
|
`沙箱 ${cfg.sandbox || 'off'}${routing ? ` · 自动路由 ${routingEnabled ? '开' : '关'}(${routing.planner}⇄${routing.executor})` : ''}`,
|
|
623
624
|
`会话 ${path.basename(session.file)}`,
|
|
624
625
|
`轮次 ${stats.turns} · 消息 ${messages.length} 条`,
|
|
625
626
|
`Tokens ↑${stats.promptTokens} ↓${stats.completionTokens}`,
|
|
626
|
-
`费用
|
|
627
|
+
`费用 ${sc == null ? '≈¥—(无价格数据)' : '≈¥' + sc.toFixed(5)}(累计·按当前模型计价)`,
|
|
627
628
|
`计划模式 ${planMode ? '开' : '关'} · 思考显示 ${io.showReasoning ? '开' : '关'} · 任务 ${agent.getTodos().length} 项`,
|
|
628
629
|
]);
|
|
629
630
|
} else if (cmd === '/cost') {
|
|
@@ -634,7 +635,7 @@ export async function runRepl(ctx) {
|
|
|
634
635
|
`缓存命中率 ${bd.rate != null ? (bd.rate * 100).toFixed(0) + '%' : '暂无缓存数据'}${bd.batchCost > 0 ? ` · Batch 半价任务 ≈¥${bd.batchCost.toFixed(5)}` : ''}`,
|
|
635
636
|
...bd.byModel.slice(0, 8).map((m) => ` ${m.model}:${m.turns} 轮(${m.batchTurns ? m.batchTurns + ' 批' : ''})· ↑${m.prompt} ↓${m.completion} · ≈¥${m.cost.toFixed(5)}${m.saved > 0 ? ` · 省 ¥${m.saved.toFixed(5)}` : ''}`),
|
|
636
637
|
]);
|
|
637
|
-
const guard = costGuardStatus();
|
|
638
|
+
const guard = costGuardStatus(modelName);
|
|
638
639
|
if (guard) {
|
|
639
640
|
io.print(
|
|
640
641
|
style(
|
|
@@ -643,7 +644,8 @@ export async function runRepl(ctx) {
|
|
|
643
644
|
)
|
|
644
645
|
);
|
|
645
646
|
}
|
|
646
|
-
|
|
647
|
+
const sc2 = estimateCost(modelName, stats.promptTokens, stats.completionTokens);
|
|
648
|
+
io.print(style('会话内累计(本次)' + (sc2 == null ? '≈¥—(无价格数据)' : '≈¥' + sc2.toFixed(5)), C.dim));
|
|
647
649
|
} else if (cmd === '/cache') {
|
|
648
650
|
const entries = listCacheStats();
|
|
649
651
|
if (!entries.length) {
|
package/src/commands/update.js
CHANGED
|
@@ -93,7 +93,7 @@ export async function handleUpdateFamily(/** @type {any} */ cmd, /** @type {any}
|
|
|
93
93
|
return true;
|
|
94
94
|
}
|
|
95
95
|
const rr = /** @type {any} */ (r);
|
|
96
|
-
console.log(`✓ 完成 ${rr.results.length} 条 · ↑${rr.usage.prompt_tokens} ↓${rr.usage.completion_tokens} tokens · 费用
|
|
96
|
+
console.log(`✓ 完成 ${rr.results.length} 条 · ↑${rr.usage.prompt_tokens} ↓${rr.usage.completion_tokens} tokens · 费用 ${rr.cost == null ? '未知(该模型无价格数据,已记为「未知」而非免费;可配 config.pricing.overrides)' : `≈¥${rr.cost.toFixed(5)}(已按半价)`}`);
|
|
97
97
|
if (r.deduped) console.log(` (去重合并 ${r.deduped} 条重复问题,结果已回填全部位置)`);
|
|
98
98
|
console.log(` 结果文件:${r.outputFile}`);
|
|
99
99
|
console.log(` 任务 ID:${r.batchId}(结果已计入 /cost 分账)`);
|
package/src/compact.js
CHANGED
|
@@ -64,7 +64,12 @@ export async function compactConversation(/** @type {any} */ { messages, budget,
|
|
|
64
64
|
sizes.push(t);
|
|
65
65
|
total += t;
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
// P1 修复(v0.4.6):触发线不得低于目标保留比。若 trigger < TARGET_RATIO,会出现
|
|
68
|
+
// 「total 已越过触发线、却仍 ≤ budget×TARGET_RATIO」的区间——此时下面的保留循环不会 break,
|
|
69
|
+
// boundary 保持 messages.length,于是除 system 外整段会话(含最新用户指令)被全部压成摘要,
|
|
70
|
+
// 且 WebUI 的 onCompact 会把结果 rewriteSession 永久写回会话文件(真·失忆)。
|
|
71
|
+
// config.compactTrigger 是文档化的 0–1 可调项,「想更早压缩」的自然用法正好命中。
|
|
72
|
+
const trigger = Number.isFinite(Number(triggerRatio)) ? Math.max(Number(triggerRatio), TARGET_RATIO) : DEFAULT_TRIGGER_RATIO;
|
|
68
73
|
// force(v0.3.2 边缘检测):逼近窗口时即便启发式计数低估(total 未达触发线)也强制压缩——
|
|
69
74
|
// 非 DeepSeek 模型启发式计数误差可达 ±2 倍,等它越过触发线时真实 prompt 可能已到窗口边缘。
|
|
70
75
|
if (total <= budget * trigger && !force) return null;
|
|
@@ -78,6 +83,16 @@ export async function compactConversation(/** @type {any} */ { messages, budget,
|
|
|
78
83
|
}
|
|
79
84
|
keepTokens += sizes[i];
|
|
80
85
|
}
|
|
86
|
+
// 兜底(v0.4.6):保留循环没 break = 除 system 外的消息在计数上都装得进保留区
|
|
87
|
+
// (system 提示词通常占绝对多数,容易走到这里)。此时若沿用 boundary=messages.length,
|
|
88
|
+
// 结果就是「system + 摘要」——除 system 外整段会话(含最新用户指令)全部消失,
|
|
89
|
+
// WebUI 的 onCompact 还会把它 rewriteSession 永久写回会话文件。改为保留最后一段原文
|
|
90
|
+
// (MIN_KEEP_TAIL 条,至少覆盖最近一轮问答),只压缩更早的部分。
|
|
91
|
+
const MIN_KEEP_TAIL = 2;
|
|
92
|
+
if (boundary >= messages.length) {
|
|
93
|
+
boundary = messages.length - MIN_KEEP_TAIL;
|
|
94
|
+
}
|
|
95
|
+
if (boundary <= 1) return null; // 没有可压缩的前缀(会话太短):放弃,交回普通裁剪
|
|
81
96
|
const droppedCount = boundary - 1; // 不含 system
|
|
82
97
|
if (!force && droppedCount < MIN_DROP_MESSAGES) return null;
|
|
83
98
|
let droppedTokens = 0;
|
package/src/context.js
CHANGED
|
@@ -15,6 +15,14 @@ function partTokens(/** @type {any} */ msg, /** @type {any} */ count) {
|
|
|
15
15
|
for (const tc of msg.tool_calls || []) {
|
|
16
16
|
total += count(JSON.stringify(tc));
|
|
17
17
|
}
|
|
18
|
+
// P1 修复(v0.4.6):reasoning_content 必须计入预算。带 tool_calls 的 assistant 消息会把
|
|
19
|
+
// 完整 reasoning 原样回传(DeepSeek thinking 模式硬要求,缺/截断即 400,见 agent.js),
|
|
20
|
+
// 而此前这里只算 content + tool_calls——实测单条 4400 字 reasoning = 2000 token,
|
|
21
|
+
// messageTokens 只算 33(低估 61 倍)。后果:预算/压缩触发/护栏前置预估同源低估,
|
|
22
|
+
// 思考型长会话会在「显示还有余量」时把请求顶到窗口边缘。
|
|
23
|
+
if (typeof msg.reasoning_content === 'string') {
|
|
24
|
+
total += count(msg.reasoning_content);
|
|
25
|
+
}
|
|
18
26
|
return total;
|
|
19
27
|
}
|
|
20
28
|
|
package/src/cost-guard.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { listCacheStats, cacheStatsFile } from './cachestats.js';
|
|
9
|
-
import { beijingDayStart } from './pricing.js';
|
|
9
|
+
import { beijingDayStart, hasPricing } from './pricing.js';
|
|
10
10
|
import { loadConfig } from './config.js';
|
|
11
11
|
|
|
12
12
|
export function costGuardConfig() {
|
|
@@ -44,13 +44,22 @@ export function todayCost() {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* @param {any} [modelName] 实际使用模型;缺省回退 config.model(仪表盘/无会话上下文)
|
|
49
|
+
*/
|
|
50
|
+
export function costGuardStatus(modelName) {
|
|
48
51
|
const g = costGuardConfig();
|
|
49
52
|
if (!g) return null;
|
|
50
53
|
const cost = todayCost();
|
|
51
54
|
const limit = Number(g.dailyLimitYuan) || 0;
|
|
52
55
|
const warnAt = Number(g.warnAtYuan) || (limit > 0 ? limit * 0.8 : 0);
|
|
53
56
|
const action = g.action === 'block' || g.action === 'downgrade' ? g.action : 'warn';
|
|
57
|
+
// P0-4(v0.4.5):当前模型无价格数据时费用护栏静默失效(estimateCost 恒 0、overLimit 恒 false)。
|
|
58
|
+
// 显式标记 noPricing,调用方据此告警而非静默放行。modelName 优先取「实际使用模型」(agent 传 activeModel),
|
|
59
|
+
// 缺省回退 config.model(仪表盘/无会话上下文);两者都无则无法判断,不误标 noPricing。
|
|
60
|
+
const cfg = loadConfig();
|
|
61
|
+
const model = modelName ?? cfg?.model ?? null;
|
|
62
|
+
const noPricing = limit > 0 && model != null && !hasPricing(model);
|
|
54
63
|
return {
|
|
55
64
|
cost,
|
|
56
65
|
limit,
|
|
@@ -58,16 +67,27 @@ export function costGuardStatus() {
|
|
|
58
67
|
action,
|
|
59
68
|
downgradeModel: String(g.downgradeModel || 'deepseek-v4-flash'),
|
|
60
69
|
degraded: cost === null, // 统计不可读:护栏降级为「无法判断」
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
noPricing, // 无价格数据:费用护栏无法累计,需显式告警
|
|
71
|
+
overWarn: cost !== null && !noPricing && limit > 0 && cost >= warnAt,
|
|
72
|
+
overLimit: cost !== null && !noPricing && limit > 0 && cost >= limit,
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
// Agent 每轮开始前调用:返回 null 放行;blocked=true 应暂停本轮;downgrade=true 应切换便宜模型
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
/**
|
|
78
|
+
* @param {any} [modelName] 实际使用模型;缺省回退 config.model
|
|
79
|
+
*/
|
|
80
|
+
export function checkCostGuard(modelName) {
|
|
81
|
+
const st = costGuardStatus(modelName);
|
|
69
82
|
if (!st) return null;
|
|
70
83
|
if (st.degraded) return null; // 统计不可读:不误拦也不放水(已告警),按无法判断处理
|
|
84
|
+
if (st.noPricing) {
|
|
85
|
+
// P0-4(v0.4.5):无价格数据时护栏无法累计——显式告警而非静默放行(绝不当「没花钱」)
|
|
86
|
+
return {
|
|
87
|
+
blocked: false,
|
|
88
|
+
message: '⚠ 费用护栏:当前模型无价格数据,今日费用无法累计、dailyLimitYuan 不生效——请在 config.pricing.overrides 为模型补充定价,或改用有价的 DeepSeek 模型(deepseek-v4-pro/flash)。',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
71
91
|
if (st.overLimit && st.action === 'block') {
|
|
72
92
|
return {
|
|
73
93
|
blocked: true,
|
package/src/hooks.js
CHANGED
|
@@ -19,8 +19,12 @@ function normalize(/** @type {any} */ list) {
|
|
|
19
19
|
function match(/** @type {any} */ hook, /** @type {any} */ toolName) {
|
|
20
20
|
const m = String(hook.matcher || '*').trim();
|
|
21
21
|
if (!m || m === '*') return true;
|
|
22
|
-
|
|
22
|
+
// P2 修复(v0.4.6):同时支持 `,` 与 `|` 分隔。docs/CONFIG.md 的官方示例写的是
|
|
23
|
+
// `"matcher": "write|edit|bash"` 并明文声明支持 `|`,而实现只按 `,` 切分——按文档写的
|
|
24
|
+
// PreToolUse 策略钩子永不触发(fail-open),且没有任何日志,一个本该拦写操作的合规钩子静默失效。
|
|
25
|
+
return m.split(/[,|]/).some((part) => {
|
|
23
26
|
const p = part.trim();
|
|
27
|
+
if (!p) return false;
|
|
24
28
|
if (p.endsWith('*')) return toolName.startsWith(p.slice(0, -1));
|
|
25
29
|
return p === toolName;
|
|
26
30
|
});
|
|
@@ -43,12 +47,18 @@ export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir, /** @t
|
|
|
43
47
|
|
|
44
48
|
function run(/** @type {any} */ hook, /** @type {any} */ payload) {
|
|
45
49
|
return new Promise((resolve) => {
|
|
50
|
+
// 评估 6.5(v0.4.4)+ Windows 回归修复(v0.4.5):POSIX 上 detached 自成进程组,
|
|
51
|
+
// 超时 process.kill(-pid) 整组清理(否则只杀 shell,孙进程孤儿);但 Windows 无进程组语义,
|
|
52
|
+
// detached 反而新建控制台、打断 stdio 管道——hook 子进程收不到 stdin EOF 卡 10s 超时,
|
|
53
|
+
// 使 Windows CI 自 v0.4.4 起冒烟测试恒红。改为 Windows 用 windowsHide(不闪窗)且直接 kill child。
|
|
54
|
+
const isWin = process.platform === 'win32';
|
|
46
55
|
const child = spawn(hook.cmd, {
|
|
47
56
|
shell: true,
|
|
48
57
|
cwd: workingDir,
|
|
49
58
|
env: childEnv,
|
|
50
59
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
-
detached:
|
|
60
|
+
detached: !isWin,
|
|
61
|
+
windowsHide: isWin,
|
|
52
62
|
});
|
|
53
63
|
let out = '';
|
|
54
64
|
let err = '';
|
|
@@ -64,9 +74,10 @@ export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir, /** @t
|
|
|
64
74
|
resolve(result);
|
|
65
75
|
};
|
|
66
76
|
const timer = setTimeout(() => {
|
|
67
|
-
// 审计质量项:超时杀整组(shell:true
|
|
77
|
+
// 审计质量项:超时杀整组(shell:true 的孙进程不成孤儿);Windows 无进程组,直接 kill child
|
|
68
78
|
try {
|
|
69
|
-
process.kill(-(/** @type {any} */ (child)).pid, 'SIGKILL');
|
|
79
|
+
if (!isWin) process.kill(-(/** @type {any} */ (child)).pid, 'SIGKILL');
|
|
80
|
+
else child.kill('SIGKILL');
|
|
70
81
|
} catch {
|
|
71
82
|
child.kill('SIGKILL');
|
|
72
83
|
}
|
|
@@ -74,6 +85,11 @@ export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir, /** @t
|
|
|
74
85
|
}, 10000);
|
|
75
86
|
child.stdout.on('data', (d) => (out = cap(out, d)));
|
|
76
87
|
child.stderr.on('data', (d) => (err = cap(err, d)));
|
|
88
|
+
// P1 修复(v0.4.6):stdin 必须有 error 监听。hook 若「不读 stdin 就退出」(如
|
|
89
|
+
// cmd:'true' / echo 形式的策略脚本)且载荷超过管道缓冲(约 64KB,write 大文件时必然发生),
|
|
90
|
+
// write 的 EPIPE 会作为异步 error 事件抛出——无人监听即未捕获异常,整个进程崩溃
|
|
91
|
+
// (WebUI 场景下所有并发会话一起死)。write() 本身的 try/catch 捕不到异步事件。
|
|
92
|
+
child.stdin.on('error', () => {});
|
|
77
93
|
child.on('error', (e) => {
|
|
78
94
|
clearTimeout(timer);
|
|
79
95
|
finish({ ok: false, error: `hook 启动失败:${e.message}` });
|
package/src/log-writer.js
CHANGED
|
@@ -5,19 +5,31 @@ import path from 'node:path';
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1024 } = {}) {
|
|
8
|
+
// 轮转低水位(v0.4.6 P2 修复):保留到 maxBytes/2 而不是刚好卡在上限。
|
|
9
|
+
// 此前 `cut = raw.length - maxBytes` 让保留区恰好等于上限,文件在轮转后立刻又超限,
|
|
10
|
+
// 于是**每次追加都整文件重写**(实测:上限之后 2000 次追加 = 2000 次全量读写 ≈1GB I/O)。
|
|
11
|
+
// 另外 raw.length 是 UTF-16 码元数、st.size 是字节数,中文日志下 cut 直接变负 → 只砍掉一行。
|
|
12
|
+
// 改为按字节定位行边界(Buffer.lastIndexOf(0x0a)),并留出一半余量。
|
|
13
|
+
const keepBytes = Math.max(1, Math.floor(maxBytes / 2));
|
|
8
14
|
return (/** @type {string} */ msg) => {
|
|
9
15
|
try {
|
|
10
16
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
11
17
|
const line = new Date().toISOString() + ' ' + String(msg) + '\n';
|
|
12
|
-
// 自查 #5:日志可能含工具参数等敏感信息——创建与轮转一律 600
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
// 自查 #5:日志可能含工具参数等敏感信息——创建与轮转一律 600(多用户机器防同机读取)。
|
|
19
|
+
// v0.4.6 P3:已存在的 644 日志也要收权——mode 只在创建时生效,此前只在轮转时才 chmod。
|
|
20
|
+
if (!fs.existsSync(file)) {
|
|
21
|
+
fs.appendFileSync(file, line, { mode: 0o600 });
|
|
22
|
+
} else {
|
|
23
|
+
fs.appendFileSync(file, line);
|
|
24
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
25
|
+
}
|
|
15
26
|
const st = fs.statSync(file);
|
|
16
27
|
if (st.size > maxBytes) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
28
|
+
const buf = fs.readFileSync(file);
|
|
29
|
+
// 从「字节位置 st.size - keepBytes」起找下一个换行,保证不在行/多字节字符中间切开
|
|
30
|
+
const from = Math.max(0, buf.length - keepBytes);
|
|
31
|
+
const nl = buf.indexOf(0x0a, from);
|
|
32
|
+
const keep = nl === -1 ? buf.subarray(from) : buf.subarray(nl + 1);
|
|
21
33
|
const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
|
|
22
34
|
fs.writeFileSync(tmp, keep, { mode: 0o600 }); // 轮转重建保持 600
|
|
23
35
|
fs.renameSync(tmp, file);
|
package/src/mcp.js
CHANGED
|
@@ -65,6 +65,10 @@ export class McpClient {
|
|
|
65
65
|
this.child.stderr.on('data', (d) => {
|
|
66
66
|
this.stderrTail = (this.stderrTail + d.toString()).slice(-2000);
|
|
67
67
|
});
|
|
68
|
+
// P1 修复(v0.4.6):stdin 必须有 error 监听。MCP 服务器若在调用途中退出(或某帧超过管道
|
|
69
|
+
// 缓冲,约 64KB 的 tools/call 参数),stdin.write 的 EPIPE 会作为异步 error 事件抛出;
|
|
70
|
+
// 无人监听即未捕获异常 → 整个进程崩溃(WebUI 下所有并发会话一起死)。try/catch 捕不到异步事件。
|
|
71
|
+
this.child.stdin.on('error', () => {});
|
|
68
72
|
this.child.on('error', (err) => {
|
|
69
73
|
this.error = `启动失败:${err.message}`;
|
|
70
74
|
this._failAll(this.error);
|
package/src/memory.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
10
|
import { beijingParts } from './pricing.js';
|
|
11
11
|
import { tokenize } from './session-index.js';
|
|
12
|
+
import { atomicWriteFileSync } from './atomic-write.js';
|
|
12
13
|
|
|
13
14
|
export function memoryFile() {
|
|
14
15
|
return path.join(mingdaoHome(), 'AGENTS.md');
|
|
@@ -47,7 +48,7 @@ function backupMemory() {
|
|
|
47
48
|
export function writeMemory(/** @type {any} */ content) {
|
|
48
49
|
backupMemory();
|
|
49
50
|
ensureHome();
|
|
50
|
-
|
|
51
|
+
atomicWriteFileSync(memoryFile(), String(content ?? ''));
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
// 去重:忽略日期前缀后内容相同的条目只保留第一条
|
|
@@ -70,7 +71,7 @@ export function dedupeMemory() {
|
|
|
70
71
|
}
|
|
71
72
|
if (removed > 0) {
|
|
72
73
|
backupMemory();
|
|
73
|
-
|
|
74
|
+
atomicWriteFileSync(memoryFile(), kept.join('\n') + '\n');
|
|
74
75
|
}
|
|
75
76
|
return removed;
|
|
76
77
|
}
|
|
@@ -91,7 +92,7 @@ export function removeMemoryLines(/** @type {any} */ keyword) {
|
|
|
91
92
|
}
|
|
92
93
|
if (removed > 0) {
|
|
93
94
|
backupMemory();
|
|
94
|
-
|
|
95
|
+
atomicWriteFileSync(memoryFile(), kept.join('\n'));
|
|
95
96
|
}
|
|
96
97
|
return removed;
|
|
97
98
|
}
|
|
@@ -117,7 +118,7 @@ export function appendJournal(/** @type {any} */ home, /** @type {any} */ entry)
|
|
|
117
118
|
const raw = fs.readFileSync(journalFile(), 'utf8');
|
|
118
119
|
const lines = raw.split('\n').filter(Boolean);
|
|
119
120
|
if (lines.length > 600) {
|
|
120
|
-
|
|
121
|
+
atomicWriteFileSync(journalFile(), lines.slice(-500).join('\n') + '\n');
|
|
121
122
|
journalCount = 500;
|
|
122
123
|
}
|
|
123
124
|
} catch {}
|
|
@@ -294,7 +295,7 @@ export function dedupeProjectMemory(/** @type {any} */ workingDir) {
|
|
|
294
295
|
kept.push(t);
|
|
295
296
|
}
|
|
296
297
|
if (removed > 0) {
|
|
297
|
-
try {
|
|
298
|
+
try { atomicWriteFileSync(projectMemoryFile(workingDir), kept.join('\n') + '\n'); } catch {}
|
|
298
299
|
}
|
|
299
300
|
return removed;
|
|
300
301
|
}
|
package/src/model-caps.js
CHANGED
|
@@ -7,6 +7,8 @@ import { modelPreset } from './models.js';
|
|
|
7
7
|
// fc00::/7、fe80::/10、::、::1、IPv4-mapped)——此前只查 IPv4 与 ::1,IPv6 本地模型被误判远程
|
|
8
8
|
// (超时档位错),且与 fetch 工具/SSRF 判定各维护一份、口径漂移。
|
|
9
9
|
import { isPrivateHost } from './tools/fetch.js';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
10
12
|
|
|
11
13
|
// 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
|
|
12
14
|
export const UNKNOWN_LOCAL_WINDOW = 32768;
|
|
@@ -21,11 +23,45 @@ export const COMFORT_RATIO = 0.75;
|
|
|
21
23
|
export const EDGE_RATIO = 0.85;
|
|
22
24
|
|
|
23
25
|
/** 判断 baseUrl 是否指向本机/内网(本地推理框架部署)。 */
|
|
26
|
+
// MacBook 本地 507 根因(v0.4.5):自定义主机名(如 mtplx.server.openai 经 /etc/hosts 指向 127.0.0.1)
|
|
27
|
+
// 的字面量判定抓不到——isPrivateHost(hostname) 对非 IP/非 localhost 恒 false,导致本地模型被误判远程:
|
|
28
|
+
// 只读子代理不串行(9 路大 prefill 并发击穿内存)、压缩触发线用远程档(0.8 而非 0.6)、超时档位错。
|
|
29
|
+
// 同步查 /etc/hosts(含 Windows)复检「主机名 → 私网/回环 IP」的映射,命中即视为本地。
|
|
30
|
+
const hostsCache = /** @type {Map<string, boolean>} */ (new Map()); // hostname → 是否 /etc/hosts 映射私网
|
|
31
|
+
function hostsMapsToPrivate(/** @type {string} */ hostname) {
|
|
32
|
+
if (hostsCache.has(hostname)) return Boolean(hostsCache.get(hostname));
|
|
33
|
+
let mapped = false;
|
|
34
|
+
try {
|
|
35
|
+
const file = process.platform === 'win32'
|
|
36
|
+
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
|
|
37
|
+
: '/etc/hosts';
|
|
38
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
39
|
+
for (const line of text.split(/\r?\n/)) {
|
|
40
|
+
const clean = line.replace(/#.*/, '').trim();
|
|
41
|
+
if (!clean) continue;
|
|
42
|
+
const parts = clean.split(/\s+/);
|
|
43
|
+
const ip = parts[0];
|
|
44
|
+
if (!ip || !isPrivateHost(ip)) continue;
|
|
45
|
+
if (parts.slice(1).some((h) => h.toLowerCase() === hostname)) {
|
|
46
|
+
mapped = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
mapped = false; // 无 /etc/hosts 或不可读:维持字面量判定
|
|
52
|
+
}
|
|
53
|
+
hostsCache.set(hostname, mapped);
|
|
54
|
+
return mapped;
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
|
|
25
58
|
try {
|
|
26
59
|
const u = new URL(String(baseUrl || ''));
|
|
27
60
|
if (!u.hostname) return false;
|
|
28
|
-
|
|
61
|
+
const host = u.hostname.toLowerCase();
|
|
62
|
+
if (isPrivateHost(host)) return true;
|
|
63
|
+
// 自定义主机名(经 /etc/hosts 指向 127.0.0.1 等私网地址)复检;否则维持「远程」
|
|
64
|
+
return hostsMapsToPrivate(host);
|
|
29
65
|
} catch {
|
|
30
66
|
return false;
|
|
31
67
|
}
|
|
@@ -35,13 +71,16 @@ export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
|
|
|
35
71
|
* 解析模型能力。优先级:customModels.<name>.contextWindow/maxOutputTokens > 内置 preset > 兜底。
|
|
36
72
|
* @param {any} cfg
|
|
37
73
|
* @param {string} modelName
|
|
38
|
-
* @returns {{ contextWindow: number, maxOutputTokens: number, isLocal: boolean, budgetTokens: number|null, preset: any }}
|
|
74
|
+
* @returns {{ contextWindow: number, maxOutputTokens: number, maxOutputCeiling: number, isLocal: boolean, budgetTokens: number|null, preset: any }}
|
|
39
75
|
*/
|
|
40
76
|
export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
41
77
|
const preset = modelPreset(modelName);
|
|
42
78
|
const cm = (cfg?.customModels || {})[modelName] || {};
|
|
43
79
|
const baseUrl = cm.baseUrl || cfg?.baseUrl || '';
|
|
44
|
-
|
|
80
|
+
// 显式声明优先:customModels.<name>.local=true/isLocal=true 强制按本地模型处理(压缩/串行/超时走本地档),
|
|
81
|
+
// 覆盖 baseUrl 字面量/hosts 判定不到的场景(如经公网反代回本机、特殊主机名)。
|
|
82
|
+
const explicitLocal = cm.local === true || cm.isLocal === true;
|
|
83
|
+
const isLocal = explicitLocal || isLocalBaseUrl(baseUrl);
|
|
45
84
|
const contextWindow =
|
|
46
85
|
Number(cm.contextWindow) > 0
|
|
47
86
|
? Number(cm.contextWindow)
|
|
@@ -51,7 +90,12 @@ export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ mode
|
|
|
51
90
|
? Number(cm.maxOutputTokens)
|
|
52
91
|
: preset?.maxOutputTokens || Math.min(DEFAULT_MAX_OUTPUT, Math.max(1024, Math.floor(contextWindow / 8)));
|
|
53
92
|
const budgetTokens = preset?.budgetTokens || null;
|
|
54
|
-
|
|
93
|
+
// v0.4.6:把 models.js 的 maxOutputCeiling(官方单次最大输出规格,如 DeepSeek 384K)纳入能力面。
|
|
94
|
+
// 此前该字段只定义、源码零引用——README 宣称「单次输出上限 384K」在框架里拿不到
|
|
95
|
+
// (pro 实际被 maxOutputTokens=65536 封顶,用户显式调大也无处生效)。现在它作为
|
|
96
|
+
// 「用户显式配置 maxOutputTokens 时的硬上限」,让文档承诺可用且仍受窗口约束。
|
|
97
|
+
const maxOutputCeiling = Number(cm.maxOutputCeiling) > 0 ? Number(cm.maxOutputCeiling) : preset?.maxOutputCeiling || maxOutputTokens;
|
|
98
|
+
return { contextWindow, maxOutputTokens, maxOutputCeiling, isLocal, budgetTokens, preset };
|
|
55
99
|
}
|
|
56
100
|
|
|
57
101
|
/**
|
package/src/permissions.js
CHANGED
|
@@ -23,8 +23,8 @@ function summarize(name, args) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** @param {any} rule @param {any} name @param {any} args */
|
|
27
|
-
function ruleMatches(rule, name, args) {
|
|
26
|
+
/** @param {any} rule @param {any} name @param {any} args @param {boolean} [forDeny] */
|
|
27
|
+
function ruleMatches(rule, name, args, forDeny = false) {
|
|
28
28
|
const idx = rule.indexOf(':');
|
|
29
29
|
if (idx > 0) {
|
|
30
30
|
const ruleName = rule.slice(0, idx);
|
|
@@ -34,7 +34,13 @@ function ruleMatches(rule, name, args) {
|
|
|
34
34
|
// bash 前缀规则防链式命令绕过(P0 安全,v0.4.1):白名单字符法——前缀规则仅匹配
|
|
35
35
|
// 单条简单命令(字母数字/空格/下划线/./-/),任何 shell 元字符(&&/||/;|&<>`$()\n\r)都不匹配,
|
|
36
36
|
// 回落权限确认。黑名单枚举永远追不上绕过手法(单 & 后台串联、重定向、\r 等),白名单才可靠。
|
|
37
|
-
|
|
37
|
+
//
|
|
38
|
+
// P1 修复(v0.4.6):该白名单只适用于 **allow** 规则(目的正是防止 `git status && rm -rf /`
|
|
39
|
+
// 借前缀匹配提权)。deny 是「硬拦截」,方向相反——失配 = 不拦 = fail-open:命令里只要出现
|
|
40
|
+
// & ; | $ @ = ' " Tab 等元字符,deny 规则就失配并回落到 mode,而 auto 档下 deny 是唯一防线,
|
|
41
|
+
// 等于形同不存在(实测 deny:['bash:rm *'] 拦得住 `rm -rf /x`,却放过 `cd /tmp && rm -rf /x`)。
|
|
42
|
+
// 故 deny 一律按命令原文匹配,不设字符白名单。
|
|
43
|
+
if (!forDeny && name === 'bash' && !/^[A-Za-z0-9_ ./\\:-]+$/.test(have)) return false;
|
|
38
44
|
if (want.endsWith('*')) return have.startsWith(want.slice(0, -1));
|
|
39
45
|
return have === want;
|
|
40
46
|
}
|
|
@@ -69,7 +75,7 @@ export function createPermission(rawMode, io) {
|
|
|
69
75
|
};
|
|
70
76
|
// 需要特殊授权时弹出对话框与用户交互,而不是静默拒绝:
|
|
71
77
|
// 1) 被 deny 规则拦截 → 询问是否本次强制放行
|
|
72
|
-
if (deny.some((/** @type {any} */ r) => ruleMatches(r, name, args))) {
|
|
78
|
+
if (deny.some((/** @type {any} */ r) => ruleMatches(r, name, args, true))) {
|
|
73
79
|
return askOverride(`规则拦截了 ${name}${summarize(name, args)},是否本次强制放行?[y/N] `);
|
|
74
80
|
}
|
|
75
81
|
if (allow.some((/** @type {any} */ r) => ruleMatches(r, name, args))) return true;
|
package/src/presets.js
CHANGED
|
@@ -157,10 +157,20 @@ const PERM_RANK = /** @type {Record<string, number>} */ ({ readonly: 0, ask: 1,
|
|
|
157
157
|
export function presetPermissionOverride(/** @type {any} */ preset, /** @type {any} */ currentPermission) {
|
|
158
158
|
const want = preset && preset.permission !== undefined ? String(preset.permission) : null;
|
|
159
159
|
if (!want || !(want in PERM_RANK)) return { permission: currentPermission, escalated: false };
|
|
160
|
-
|
|
160
|
+
// P2 修复(v0.4.6):currentPermission 也可能是**对象形态**(docs/CONFIG.md 推荐的
|
|
161
|
+
// {mode, allow, deny},且 cli/repl/web 三个入口传的都是 cfg.permission 对象)——对象做 `in`
|
|
162
|
+
// 运算时键名变成 "[object Object]",不入 PERM_RANK,于是 cur 被当成 'ask':
|
|
163
|
+
// 当前 {mode:'readonly'} 时,预设声明 permission:'ask' 不判为提权,只读档被静默放宽为 ask
|
|
164
|
+
// (写操作从「禁止」变成「逐次确认」),且无 banner、无 escalated 标记。现先归一化 mode。
|
|
165
|
+
const curMode =
|
|
166
|
+
currentPermission && typeof currentPermission === 'object'
|
|
167
|
+
? String(currentPermission.mode ?? '')
|
|
168
|
+
: String(currentPermission ?? '');
|
|
169
|
+
// 识别不出时取最保守的 readonly(fail-closed),而不是 ask
|
|
170
|
+
const cur = curMode in PERM_RANK ? curMode : 'readonly';
|
|
161
171
|
if (PERM_RANK[want] > PERM_RANK[cur]) {
|
|
162
|
-
//
|
|
163
|
-
return { permission: cur, escalated: true };
|
|
172
|
+
// 提权:忽略预设值,保持当前更保守的权限(对象形态保留其 allow/deny)
|
|
173
|
+
return { permission: curMode in PERM_RANK ? currentPermission : cur, escalated: true };
|
|
164
174
|
}
|
|
165
175
|
return { permission: want, escalated: false };
|
|
166
176
|
}
|