mingdao-harness 0.3.2 → 0.4.1

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/src/batch.js CHANGED
@@ -13,7 +13,6 @@ import { resolveProviderConfig } from './providers/index.js';
13
13
  import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
14
14
  import { recordCacheStats } from './cachestats.js';
15
15
  import { approxTokens } from './context.js';
16
- import { modelPreset } from './models.js';
17
16
 
18
17
  const DEFAULT_WINDOW = '24h';
19
18
  const DEFAULT_ENDPOINT = '/v1/chat/completions';
@@ -120,8 +119,10 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
120
119
  if (deduped > 0) onStatus?.(`去重:${deduped} 条重复问题合并(${list.length} → ${unique.length} 条实际提交)`);
121
120
 
122
121
  // —— 省钱 B2:单问超窗口预检(估算输入 + max_tokens 超过模型窗口 95% 即报错,绝不提交烧钱)——
123
- const preset = modelPreset(model) || {};
124
- const windowTokens = Number(preset.budgetTokens) || 128000;
122
+ // v0.4.1 P1 修复:接入 resolveModelCaps 取真实 contextWindow(此前用 preset.budgetTokens 当窗口,
123
+ // 本地小模型 32k 窗口会套 128000 默认 → 超窗口预检完全失效,提交后烧钱且失败)。
124
+ const { resolveModelCaps } = await import('./model-caps.js');
125
+ const windowTokens = resolveModelCaps(cfg, model).contextWindow;
125
126
  const sysTokens = approxTokens(systemPrompt);
126
127
  for (let i = 0; i < unique.length; i++) {
127
128
  const est = sysTokens + approxTokens(unique[i]);
package/src/cli.js CHANGED
@@ -79,6 +79,7 @@ const HELP_LINES = [
79
79
  [' mingdao --journal 新会话带上最近会话日志(默认不注入,新会话全新开始)', null],
80
80
  [' mingdao --resume 从会话列表选择恢复', null],
81
81
  [' mingdao --model <模型名> 指定模型,例如 deepseek-v4-pro', null],
82
+ [' mingdao --preset <名> 应用智能体预设(工具白名单/权限/参数,v0.4.0 契约化)', null],
82
83
  [' mingdao init 初始化配置向导', null],
83
84
  [' mingdao update [--check] 一键自更新(git 安装形态;--check 只对比版本)', null],
84
85
  [' mingdao rollback 回滚到上次 update 之前的提交', null],
@@ -125,6 +126,8 @@ function parseArgs(/** @type {any} */ argv) {
125
126
  else if (a === '-v' || a === '--version') opts.version = true;
126
127
  else if (a === '-c' || a === '--continue') opts.continueSession = true;
127
128
  else if (a === '--journal') opts.journal = true;
129
+ else if (a === '-p' || a === '--preset') opts.preset = argv[++i];
130
+ else if (a.startsWith('--preset=')) opts.preset = a.slice(9);
128
131
  else if (a === '-r' || a === '--resume') opts.resume = true;
129
132
  else if (a === '--init' || a === 'init') opts.init = true;
130
133
  else if (a === '-m' || a === '--model') opts.model = argv[++i];
@@ -350,6 +353,37 @@ async function main() {
350
353
  // 模型回退链(向导允许跳过模型选择 → cfg.model 可缺省):参数 > config > 该服务商首个预设模型 > flash
351
354
  let modelName = opts.model || cfg.model || /** @type {any} */ (PROVIDERS)[cfg.provider]?.models?.[0] || 'deepseek-v4-flash';
352
355
  const io = createIO();
356
+ const workingDir = process.cwd();
357
+
358
+ // v0.4.0 Agent Preset:--preset <名> 应用声明式预设(工具白名单/权限/模型/参数 + 系统提示定制段)。
359
+ // 预设覆盖优先级:CLI 显式参数 > 预设 > config.json。
360
+ // 会话级 overlay:不改写 cfg(否则 REPL /model、/think 的 saveConfig 会把预设字段持久化进 config.json)。
361
+ let activePreset = /** @type {any} */ (null);
362
+ let presetBlock = '';
363
+ let presetOverlay = /** @type {Record<string, any>} */ ({});
364
+ if (opts.preset) {
365
+ const { loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride, listPresets } = await import('./presets.js');
366
+ activePreset = loadPreset(workingDir, opts.preset);
367
+ if (!activePreset) {
368
+ const names = listPresets(workingDir).map((/** @type {any} */ p) => p.name).join(', ') || '(无可用预设)';
369
+ io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
370
+ } else {
371
+ presetOverlay = { ...presetConfigOverrides(activePreset), presetName: activePreset.name };
372
+ // P0(v0.4.1):预设 permission 提权防护——预设不得把 ask/readonly 静默改成 auto
373
+ const permOv = presetPermissionOverride(activePreset, cfg.permission ?? 'ask');
374
+ if (permOv.escalated) {
375
+ delete presetOverlay.permission;
376
+ io.print(style(`⚠ 预设 "${activePreset.name}" 声明 permission=${activePreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
377
+ } else if (activePreset.permission !== undefined) {
378
+ presetOverlay.permission = permOv.permission;
379
+ }
380
+ if (!opts.model && presetOverlay.model) modelName = presetOverlay.model;
381
+ presetBlock = presetSystemBlock(activePreset);
382
+ io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
383
+ }
384
+ }
385
+ // agent 使用的配置 = cfg + 预设 overlay(presetTools/permission/参数按预设生效,cfg 本体保持干净)
386
+ const agentCfg = Object.keys(presetOverlay).length ? { ...cfg, ...presetOverlay } : cfg;
353
387
 
354
388
  const pc0 = resolveProviderConfig(cfg, modelName);
355
389
  if (!pc0.apiKey) {
@@ -367,8 +401,13 @@ async function main() {
367
401
  }
368
402
 
369
403
  let provider = await createProvider(cfg, modelName);
370
- const permission = createPermission(cfg.permission ?? 'ask', io);
371
- const workingDir = process.cwd();
404
+ const permission = createPermission(agentCfg.permission ?? 'ask', io);
405
+ // v0.4.0 契约化:挂载 config.tools 声明式第三方工具(幂等,重启生效)
406
+ {
407
+ const { mountConfigTools } = await import('./tools/index.js');
408
+ const mounted = mountConfigTools(cfg);
409
+ if (mounted.length) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
410
+ }
372
411
  // 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
373
412
  const sessionUndoStore = { backups: new Map() };
374
413
  // MCP 服务器:后台启动(不阻塞交互),就绪后工具自动出现在后续轮次
@@ -410,7 +449,7 @@ async function main() {
410
449
  io,
411
450
  modelName,
412
451
  workingDir,
413
- cfg,
452
+ cfg: agentCfg,
414
453
  undoStore: sessionUndoStore,
415
454
  mcp: mcpFacade,
416
455
  sessionRef,
@@ -440,7 +479,7 @@ async function main() {
440
479
  const turnIo = jsonMode ? createIO({ quiet: true }) : io;
441
480
  const session = createSession(home);
442
481
  const messages = [
443
- { role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal }) },
482
+ { role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal, presetBlock }) },
444
483
  { role: 'user', content: question },
445
484
  ];
446
485
  let oneShotPersisted = messages.length;
@@ -451,7 +490,7 @@ async function main() {
451
490
  io: turnIo,
452
491
  modelName,
453
492
  workingDir,
454
- cfg,
493
+ cfg: agentCfg,
455
494
  undoStore: sessionUndoStore,
456
495
  mcp: mcpFacade,
457
496
  sessionRef: oneShotRef,
@@ -528,7 +567,7 @@ async function main() {
528
567
 
529
568
  // —— 交互式 TUI(Phase C C2:已抽取至 commands/repl.js) ——
530
569
  const { runRepl } = await import('./commands/repl.js');
531
- await runRepl({ io, cfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, tuiState });
570
+ await runRepl({ io, cfg, agentCfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, presetBlock, tuiState });
532
571
  return;
533
572
 
534
573
  }
@@ -52,6 +52,21 @@ export async function handleDiagnose(/** @type {any} */ _cmd, /** @type {any} */
52
52
  const cfg = loadConfig();
53
53
  push(cfg ? redactSensitive(JSON.stringify(cfg, null, 2)) : '(不存在或读取失败)');
54
54
 
55
+ // v0.4.1:模型能力定位——本地模型未声明 contextWindow 会兜底 32k → maxOutput/budget 偏小,
56
+ // 表现为「输出截断/频繁压缩」。此处显式提示,帮用户自诊。
57
+ if (cfg?.model) {
58
+ push('');
59
+ push('## 当前模型能力');
60
+ try {
61
+ const { resolveModelCaps, safeBudget } = await import('../model-caps.js');
62
+ const caps = resolveModelCaps(cfg, cfg.model);
63
+ push(`- 模型:${cfg.model}(${caps.isLocal ? '本地部署' : '远程'})`);
64
+ push(`- 上下文窗口:${caps.contextWindow}${caps.isLocal && !((cfg.customModels || {})[cfg.model]?.contextWindow) ? '(未在 customModels 声明 contextWindow,按本地兜底 32k——建议声明真实窗口,如 131072,否则输出/预算被压缩)' : ''}`);
65
+ push(`- 最大输出:${caps.maxOutputTokens} · 预算:${safeBudget(cfg, caps)}`);
66
+ push(`- 自动路由:${cfg.routing?.enabled ? '开(planner=' + cfg.routing.planner + ' executor=' + cfg.routing.executor + ')' : '关'}`);
67
+ } catch {}
68
+ }
69
+
55
70
  push('');
56
71
  push('## 凭证库');
57
72
  push(`${redactSensitive(credentialsPath())}:${fs.existsSync(credentialsPath()) ? '已存在(内容不打包)' : '无'}`);
@@ -98,6 +98,7 @@ const HELP_LINES = [
98
98
  ['会话内命令', C.bold + C.yellow],
99
99
  [' /help 显示帮助 /clear 清空上下文', null],
100
100
  [' /model <名> 切换模型 /mode pro/flash 快捷切换', null],
101
+ [' /preset 列出/切换智能体预设(v0.4.0 契约化)', null],
101
102
  [' /compact 压缩上下文 /plan 计划模式(先计划后执行)', null],
102
103
  [' /init 生成 AGENTS.md /memory add <内容> 追加用户记忆', null],
103
104
  [' /skills 列出技能 /status 会话状态 · /cost 累计费用', null],
@@ -140,6 +141,10 @@ async function generatePlan(provider, modelName, task) {
140
141
  */
141
142
  export async function runRepl(ctx) {
142
143
  const { io, cfg, home, pc0, opts, permission, workingDir, sessionUndoStore, mcpFacade, sessionRef, preset, withJournal, tuiState } = ctx;
144
+ let presetBlock = ctx.presetBlock || ''; // v0.4.0 Agent Preset 系统提示定制段(/preset 可切换)
145
+ let permissionNow = permission; // /preset 可换权限模式
146
+ // v0.4.0 Agent Preset:agent 用 agentCfg(预设 overlay 会话级生效);cfg 保持干净供 saveConfig 持久化
147
+ let agentCfg = ctx.agentCfg || cfg;
143
148
  let mcpManager = ctx.mcpManager;
144
149
  let modelName = ctx.modelName;
145
150
  let provider = ctx.provider;
@@ -216,7 +221,7 @@ export async function runRepl(ctx) {
216
221
  sessionRef.name = path.basename(session.file);
217
222
  io.print(style(`会话 ${path.basename(session.file)}`, C.dim));
218
223
 
219
- const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal });
224
+ const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal, presetBlock });
220
225
  // 恢复会话时刷新 system prompt(用户记忆 / AGENTS.md / 技能清单 / 时间戳以当前为准),
221
226
  // 旧 system 消息保留在会话文件中,不影响追加历史。
222
227
  const loadedMsgs = session.messages || [];
@@ -258,11 +263,11 @@ export async function runRepl(ctx) {
258
263
  }
259
264
  agent = createAgent({
260
265
  provider,
261
- permission,
266
+ permission: permissionNow,
262
267
  io,
263
268
  modelName,
264
269
  workingDir,
265
- cfg,
270
+ cfg: agentCfg, // v0.4.0:预设 overlay 恒生效(/model 后白名单不丢)
266
271
  undoStore: sessionUndoStore,
267
272
  mcp: mcpFacade,
268
273
  sessionRef,
@@ -271,7 +276,7 @@ export async function runRepl(ctx) {
271
276
  tuiState.persisted = msgs.length;
272
277
  },
273
278
  });
274
- messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal }) };
279
+ messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal, presetBlock }) };
275
280
  if (!silent) {
276
281
  const p2 = modelPreset(modelName);
277
282
  io.print(style(`✓ 已切换到 ${C.bold}${modelName}${C.reset}${p2 ? `(${p2.label})` : ''}`, C.green));
@@ -309,6 +314,46 @@ export async function runRepl(ctx) {
309
314
  } catch {}
310
315
  tuiState.persisted = messages.length;
311
316
  io.print('已清空上下文(会话文件已同步重置)。');
317
+ } else if (cmd === '/preset') {
318
+ // v0.4.0 Agent Preset:列出/切换声明式智能体预设(工具白名单/权限/参数 + 系统提示定制段)
319
+ const { listPresets, loadPreset, presetConfigOverrides, presetSystemBlock, presetPermissionOverride } = await import('../presets.js');
320
+ if (!arg) {
321
+ const ps = listPresets(workingDir);
322
+ if (!ps.length) io.print(style('(无可用预设。目录:<项目>/.mingdao/presets/、~/.mingdao/presets/、内置 presets/)', C.dim));
323
+ else for (const p of ps) io.print(` - ${p.name}(${p.label || '无标签'},${p.source === 'project' ? '项目' : p.source === 'user' ? '用户' : '内置'})${p.description ? ':' + p.description : ''}`);
324
+ continue;
325
+ }
326
+ const pname = arg.split(/\s+/)[0];
327
+ const p = loadPreset(workingDir, pname);
328
+ if (!p) {
329
+ io.print(style(`预设 "${pname}" 不存在(/preset 列出全部)。`, C.yellow));
330
+ continue;
331
+ }
332
+ const over = presetConfigOverrides(p);
333
+ // 应用覆盖:权限/参数/工具白名单/模型建议——只进 agentCfg(会话级 overlay),
334
+ // 绝不改写 cfg:否则后续 /model、/think 的 saveConfig 会把预设字段持久化进 config.json。
335
+ // P0(v0.4.1):预设 permission 提权防护——不得把 ask/readonly 静默改成 auto
336
+ const permOv = presetPermissionOverride(p, cfg.permission ?? 'ask');
337
+ if (permOv.escalated) {
338
+ delete over.permission;
339
+ io.print(style(`⚠ 预设 "${p.name}" 声明 permission=${p.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
340
+ } else if (p.permission !== undefined) {
341
+ over.permission = permOv.permission;
342
+ }
343
+ agentCfg = { ...cfg, ...over, presetName: p.name };
344
+ if (over.model) await switchToModel(over.model, { silent: true, persist: false });
345
+ presetBlock = presetSystemBlock(p);
346
+ permissionNow = createPermission(agentCfg.permission ?? 'ask', io);
347
+ agent = createAgent({
348
+ provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
349
+ undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
350
+ onCompact: (/** @type {any} */ msgs) => {
351
+ rewriteSession(session.file, msgs);
352
+ tuiState.persisted = msgs.length;
353
+ },
354
+ });
355
+ messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal, presetBlock }) };
356
+ io.print(style(`▣ 已应用智能体预设:${p.name}${p.label ? '(' + p.label + ')' : ''}${over.presetTools ? `,工具白名单 ${over.presetTools.length} 个` : ''}`, C.green));
312
357
  } else if (cmd === '/model') {
313
358
  if (!arg) {
314
359
  io.print(`当前模型:${modelName}`);
@@ -336,7 +381,7 @@ export async function runRepl(ctx) {
336
381
  saveConfig(cfg);
337
382
  } else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
338
383
  agent = createAgent({
339
- provider, permission, io, modelName, workingDir, cfg,
384
+ provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
340
385
  undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
341
386
  onCompact: (/** @type {any} */ msgs) => {
342
387
  rewriteSession(session.file, msgs);
package/src/context.js CHANGED
@@ -88,9 +88,9 @@ export function trimMessages(/** @type {any} */ messages, /** @type {any} */ bud
88
88
  if (total <= budget) break;
89
89
  const m = out[i];
90
90
  const c = typeof m.content === 'string' ? m.content : JSON.stringify(m?.content ?? '');
91
- // 幂等守卫(A4):已回收过的内容跳过——否则「截断标记」把长度再次顶过阈值,逐轮嵌套重截,
92
- // 被保留消息字节逐轮漂移(前缀缓存连尾部都失配)
93
- if (c.includes('已回收')) continue;
91
+ // 注:无幂等守卫——回收结果只写在投影副本 out 上、从不回写原 messages(trimMessages 是投影,
92
+ // 每次调用从原 messages 重新拷贝),故「已回收」标记不会进入下一次调用的输入,无需守卫。
93
+ // 截断逻辑确定(同输入同输出),字节稳定,不会逐轮嵌套重截(审计 v0.4.1 确认的死代码已移除)。
94
94
  let next = null;
95
95
  if (m.role === 'tool' && c.length > 40) {
96
96
  next = { ...m, content: `[工具 ${m.tool_call_id || ''} 结果摘要:${c.slice(0, 40).replace(/\n/g, ' ')}…(已回收,原 ${c.length} 字)]` };
package/src/cost-guard.js CHANGED
@@ -17,6 +17,9 @@ export function costGuardConfig() {
17
17
  // 质检 M10:按 cache-stats 文件 mtime 缓存统计结果——文件未变直接复用,
18
18
  // 避免 Agent 每步对整文件(可能数千行)重复解析与累加。
19
19
  let todayCache = { mtime: -1, size: -1, start: 0, sum: 0 };
20
+ // v0.4.1 P2 修复(TDZ):todayCostWarned 提到 todayCost 使用点之前声明——
21
+ // 此前在第 44 行才 let 声明、第 36 行已使用(模块加载顺序碰巧掩盖了风险,循环依赖场景会抛 ReferenceError)。
22
+ let todayCostWarned = false;
20
23
  export function todayCost() {
21
24
  const start = beijingDayStart().getTime();
22
25
  try {
@@ -40,7 +43,6 @@ export function todayCost() {
40
43
  return null;
41
44
  }
42
45
  }
43
- let todayCostWarned = false;
44
46
 
45
47
  export function costGuardStatus() {
46
48
  const g = costGuardConfig();
package/src/index.js CHANGED
@@ -1,13 +1,35 @@
1
1
  // MingDao-Harness 公共 API:供第三方程序/插件以库形式复用核心能力。
2
+ // 稳定性契约(v0.4.0 起):@stable 在 minor 版本内保持向后兼容;@experimental 可能变更。
3
+ // 详细契约与示例见 docs/DEVELOPER.md。
2
4
 
5
+ // —— @stable:Agent 内核 ——
3
6
  export { createAgent } from './agent.js';
7
+ export { createPermission } from './permissions.js';
8
+ export { createIO, style, C } from './ui.js';
9
+
10
+ // —— @stable:Provider 与模型 ——
4
11
  export { createProvider, resolveProviderConfig } from './providers/index.js';
5
- export { toolSchemas, dispatch } from './tools/index.js';
12
+ export { MODELS, PROVIDERS, modelPreset, providerPreset } from './models.js';
13
+ export { resolveModelCaps, safeBudget, isLocalBaseUrl } from './model-caps.js';
14
+
15
+ // —— @stable:工具(含 v0.4.0 第三方注册)——
16
+ export { toolSchemas, dispatch, registerTool, listRegisteredTools, mountConfigTools, buildToolSchemas } from './tools/index.js';
17
+
18
+ // —— @stable:Agent Preset(v0.4.0)——
19
+ export {
20
+ listPresets,
21
+ loadPreset,
22
+ validatePreset,
23
+ presetConfigOverrides,
24
+ presetSystemBlock,
25
+ presetDirs,
26
+ } from './presets.js';
27
+
28
+ // —— @stable:上下文与压缩 ——
6
29
  export { trimMessages, approxTokens, clampText, TOOL_RESULT_LIMIT } from './context.js';
7
30
  export { compactConversation, summarizeConversation } from './compact.js';
8
- export { createPermission } from './permissions.js';
9
- export { MODELS, PROVIDERS, modelPreset, providerPreset } from './models.js';
10
- export { createIO, style, C } from './ui.js';
31
+
32
+ // —— @stable:配置与凭证 ——
11
33
  export {
12
34
  mingdaoHome,
13
35
  ensureHome,
@@ -26,8 +48,12 @@ export {
26
48
  maskKey,
27
49
  resolveApiKey,
28
50
  } from './credentials.js';
51
+
52
+ // —— @stable:计价与计量 ——
29
53
  export { estimateCost, estimateCostLabel, isPeakHour, PRICE_DATA_AS_OF } from './pricing.js';
30
54
  export { countTokens, heuristicTokens, makeTokenCounter, isTokenizable } from './tokenizer.js';
55
+
56
+ // —— @experimental:更新/审计/技能/会话(接口可能调整)——
31
57
  export { updateCheck, mingdaoUpdate, mingdaoRollback, findRepoRoot } from './update.js';
32
58
  export { writeAudit, listAudit, redactSecrets, auditFile } from './audit.js';
33
59
  export { trustSkill, skillDirHash, readSourceMeta } from './skill-lib.js';
package/src/mcp.js CHANGED
@@ -25,11 +25,12 @@ const HANDSHAKE_TIMEOUT_MS = 20000;
25
25
  let nextId = 1;
26
26
 
27
27
  export class McpClient {
28
- constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {} }, /** @type {any} */ workingDir) {
28
+ constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {}, trusted = false }, /** @type {any} */ workingDir) {
29
29
  this.name = name;
30
30
  this.command = command;
31
31
  this.args = args;
32
32
  this.env = env;
33
+ this.trusted = trusted === true; // v0.4.1 P0:仅 trusted 服务器的 readOnlyHint 才被信任自动放行
33
34
  this.workingDir = workingDir;
34
35
  this.tools = /** @type {any[]} */ ([]);
35
36
  this.child = null;
@@ -262,7 +263,11 @@ export async function startMcpServers(/** @type {any} */ mcpCfg, /** @type {any}
262
263
  },
263
264
  isReadonly(/** @type {any} */ prefixedName) {
264
265
  const found = this.lookup(prefixedName);
265
- return found ? found.client.isReadonly(found.toolName) : false;
266
+ if (!found) return false;
267
+ // v0.4.1 P0:未授信服务器(未设 mcpServers.<name>.trusted)即使工具标注 readOnlyHint 也不自动放行——
268
+ // 服务器可谎称只读绕过权限确认;只有显式 trusted 才信任其标注。
269
+ if (!found.client.trusted) return false;
270
+ return found.client.isReadonly(found.toolName);
266
271
  },
267
272
  status() {
268
273
  const rows = [];
package/src/memory.js CHANGED
@@ -255,7 +255,11 @@ export function retrieveRelevant(/** @type {any[]} */ entries, /** @type {any} *
255
255
  return { e, score: overlap / union };
256
256
  });
257
257
  scored.sort((/** @type {any} */ a, /** @type {any} */ b) => b.score - a.score);
258
- return scored.filter((/** @type {any} */ s) => s.score > 0).slice(0, topN).map((/** @type {any} */ s) => s.e);
258
+ const hits = scored.filter((/** @type {any} */ s) => s.score > 0).slice(0, topN).map((/** @type {any} */ s) => s.e);
259
+ // v0.4.1 P1 修复:无匹配时回退最近 N 条(entries 按「旧→新」追加,尾部即最新)——此前 filter(score>0)
260
+ // 在 query 与记忆无共同词时返回空数组,项目记忆完全消失(相关性判断失败 ≠ 记忆不存在)。
261
+ if (hits.length) return hits;
262
+ return entries.slice(-topN);
259
263
  }
260
264
 
261
265
  export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
@@ -31,8 +31,10 @@ function ruleMatches(rule, name, args) {
31
31
  if (ruleName !== name) return false;
32
32
  const want = rule.slice(idx + 1).trim();
33
33
  const have = summarize(name, args).trim();
34
- // bash 前缀规则防链式命令绕过:含 && / ; / | / ` / $( 的复合命令不匹配前缀规则,回落权限确认
35
- if (name === 'bash' && /&&|\|\||[;|`]|\$\(|\n/.test(have)) return false;
34
+ // bash 前缀规则防链式命令绕过(P0 安全,v0.4.1):白名单字符法——前缀规则仅匹配
35
+ // 单条简单命令(字母数字/空格/下划线/./-/),任何 shell 元字符(&&/||/;|&<>`$()\n\r)都不匹配,
36
+ // 回落权限确认。黑名单枚举永远追不上绕过手法(单 & 后台串联、重定向、\r 等),白名单才可靠。
37
+ if (name === 'bash' && !/^[A-Za-z0-9_ ./\\:-]+$/.test(have)) return false;
36
38
  if (want.endsWith('*')) return have.startsWith(want.slice(0, -1));
37
39
  return have === want;
38
40
  }
package/src/presets.js ADDED
@@ -0,0 +1,175 @@
1
+ // Agent Preset(v0.4.0 契约化):声明式智能体预设——把「系统提示 + 工具集 + 权限 + 模型 + 参数」
2
+ // 打包成一个可安装、可复用、可分享的 JSON 单元,让开发者/用户不改源码就能定制自己的智能体。
3
+ //
4
+ // 发现顺序(同名后者遮蔽前者):
5
+ // 1. 项目级 <工作目录>/.mingdao/presets/*.json
6
+ // 2. 用户级 <mingdao-home>/presets/*.json
7
+ // 3. 内置 随 npm 包分发的 presets/ 目录(只读参考实现)
8
+ //
9
+ // 预设字段(全部可选,缺省时保持当前配置不变):
10
+ // name 唯一名(必填,字母/数字/-/_,1-64)
11
+ // label 展示名(可选,默认 name)
12
+ // description 一句话用途
13
+ // systemPrompt 追加到系统提示的定制段(角色/规则/上下文约定)
14
+ // tools 工具白名单(数组,省略=不限制)
15
+ // permission 权限模式 ask/auto/readonly(省略=当前配置)
16
+ // model 建议模型(省略=当前模型)
17
+ // temperature / maxOutputTokens / maxRounds / contextBudget 参数覆盖
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { mingdaoHome, ensureHome } from './config.js';
22
+
23
+ const PRESET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
24
+ // 合法字段白名单:未知字段报错(防拼写错误静默失效——契约化核心)
25
+ const KNOWN_FIELDS = new Set([
26
+ 'name', 'label', 'description', 'systemPrompt', 'tools',
27
+ 'permission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget',
28
+ ]);
29
+ const PERMISSION_MODES = ['ask', 'auto', 'readonly'];
30
+
31
+ /** 内置预设目录(随 npm 包分发,只读参考)。 */
32
+ export function builtinPresetDir() {
33
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'presets');
34
+ }
35
+
36
+ /** 发现目录与 source 标签对齐(遮蔽顺序:项目 → 用户 → 内置)。 */
37
+ function presetLocations(/** @type {any} */ workingDir) {
38
+ const locs = [];
39
+ if (workingDir) locs.push({ dir: path.join(String(workingDir), '.mingdao', 'presets'), source: 'project' });
40
+ locs.push({ dir: path.join(mingdaoHome(), 'presets'), source: 'user' });
41
+ locs.push({ dir: builtinPresetDir(), source: 'builtin' });
42
+ return locs;
43
+ }
44
+
45
+ /** @param {any} workingDir 预设发现目录(按遮蔽顺序:项目 → 用户 → 内置)。 */
46
+ export function presetDirs(/** @type {any} */ workingDir) {
47
+ return presetLocations(workingDir).map((/** @type {any} */ l) => l.dir);
48
+ }
49
+
50
+ /** @param {any} obj 校验预设对象,返回 { ok, errors: string[] }。 */
51
+ export function validatePreset(/** @type {any} */ obj) {
52
+ const errors = [];
53
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, errors: ['预设必须是 JSON 对象'] };
54
+ const name = String(obj.name ?? '').trim();
55
+ if (!name) errors.push('缺少 name 字段');
56
+ else if (!PRESET_NAME_RE.test(name)) errors.push(`name 非法(${PRESET_NAME_RE}):${name}`);
57
+ for (const k of Object.keys(obj)) {
58
+ if (!KNOWN_FIELDS.has(k)) errors.push(`未知字段:${k}(合法:${[...KNOWN_FIELDS].join('/')})`);
59
+ }
60
+ if (obj.systemPrompt !== undefined && typeof obj.systemPrompt !== 'string') errors.push('systemPrompt 必须是字符串');
61
+ if (obj.tools !== undefined && (!Array.isArray(obj.tools) || obj.tools.some((/** @type {any} */ t) => typeof t !== 'string'))) {
62
+ errors.push('tools 必须是字符串数组');
63
+ }
64
+ if (obj.permission !== undefined && !PERMISSION_MODES.includes(String(obj.permission))) {
65
+ errors.push(`permission 必须是 ${PERMISSION_MODES.join('/')}`);
66
+ }
67
+ for (const k of ['temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget']) {
68
+ if (obj[k] !== undefined && !(Number.isFinite(Number(obj[k])) && Number(obj[k]) > 0)) {
69
+ errors.push(`${k} 必须是正数`);
70
+ }
71
+ }
72
+ return { ok: errors.length === 0, errors };
73
+ }
74
+
75
+ /**
76
+ * 列出全部可用预设(发现顺序:项目遮蔽用户遮蔽内置,同名只留前者)。
77
+ * 返回 [{ name, label, description, source: 'project'|'user'|'builtin', file }]
78
+ */
79
+ export function listPresets(/** @type {any} */ workingDir) {
80
+ ensureHome();
81
+ const seen = new Map();
82
+ const order = /** @type {string[]} */ ([]);
83
+ for (const { dir, source } of presetLocations(workingDir)) {
84
+ let files = [];
85
+ try {
86
+ files = fs.readdirSync(dir).filter((/** @type {any} */ f) => f.endsWith('.json'));
87
+ } catch {
88
+ continue;
89
+ }
90
+ for (const f of files) {
91
+ if (seen.has(f)) continue;
92
+ seen.set(f, { dir, source });
93
+ order.push(f);
94
+ }
95
+ }
96
+ const out = [];
97
+ for (const f of order) {
98
+ const { dir, source } = seen.get(f);
99
+ try {
100
+ const obj = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
101
+ const v = validatePreset(obj);
102
+ if (!v.ok) continue; // 非法预设跳过并静默(不阻塞会话);diagnose 可查
103
+ out.push({
104
+ name: String(obj.name),
105
+ label: String(obj.label || obj.name),
106
+ description: String(obj.description || ''),
107
+ source,
108
+ file: path.join(dir, f),
109
+ ...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
110
+ ...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
111
+ ...(obj.permission ? { permission: String(obj.permission) } : {}),
112
+ ...(obj.model ? { model: String(obj.model) } : {}),
113
+ });
114
+ } catch {
115
+ // JSON 解析失败:跳过
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * 按名解析单个预设(含全部字段),找不到返回 null。
123
+ * @param {any} workingDir @param {any} name
124
+ */
125
+ export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ name) {
126
+ const all = listPresets(workingDir);
127
+ const hit = all.find((/** @type {any} */ p) => p.name === name || path.basename(String(p.file), '.json') === name);
128
+ if (!hit) return null;
129
+ try {
130
+ return JSON.parse(fs.readFileSync(hit.file, 'utf8'));
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 预设 → cfg 覆盖:只返回预设声明的参数键(其余键保持调用方当前配置)。
138
+ * tools 单独走 cfg.presetTools(白名单在 agent 的 toolsFor 处生效)。
139
+ * 注意:permission 提权由调用方用 presetPermissionOverride 过滤(防项目级预设提权,见下)。
140
+ */
141
+ export function presetConfigOverrides(/** @type {any} */ preset) {
142
+ const out = /** @type {Record<string, any>} */ ({});
143
+ for (const k of ['permission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget']) {
144
+ if (preset && preset[k] !== undefined) out[k] = preset[k];
145
+ }
146
+ if (preset && Array.isArray(preset.tools)) out.presetTools = [...preset.tools];
147
+ return out;
148
+ }
149
+
150
+ // 权限宽松度排序(数值越大越宽松/越危险)。
151
+ const PERM_RANK = /** @type {Record<string, number>} */ ({ readonly: 0, ask: 1, auto: 2 });
152
+
153
+ /**
154
+ * 预设 permission 提权防护(P0 安全,v0.4.1):预设声明的 permission 不得比当前配置更宽松
155
+ * (如当前 ask → 预设 auto 属提权,忽略并返回当前值)。clone 恶意仓库含 .mingdao/presets/*.json
156
+ * 声明 auto 时,不能静默跳过用户全部确认。返回 { permission, escalated }:escalated=true 表示已拦截提权。
157
+ * @param {any} preset @param {string} currentPermission
158
+ */
159
+ export function presetPermissionOverride(/** @type {any} */ preset, /** @type {any} */ currentPermission) {
160
+ const want = preset && preset.permission !== undefined ? String(preset.permission) : null;
161
+ if (!want || !(want in PERM_RANK)) return { permission: currentPermission, escalated: false };
162
+ const cur = currentPermission in PERM_RANK ? currentPermission : 'ask';
163
+ if (PERM_RANK[want] > PERM_RANK[cur]) {
164
+ // 提权:忽略预设值,保持当前更保守的权限
165
+ return { permission: cur, escalated: true };
166
+ }
167
+ return { permission: want, escalated: false };
168
+ }
169
+
170
+ /** 预设系统提示定制段(无则空串),插入系统提示 BASE 之后。 */
171
+ export function presetSystemBlock(/** @type {any} */ preset) {
172
+ const s = preset && typeof preset.systemPrompt === 'string' ? preset.systemPrompt.trim() : '';
173
+ if (!s) return '';
174
+ return `\n\n<preset_rules>\n${s}\n</preset_rules>`;
175
+ }
package/src/prompts.js CHANGED
@@ -30,8 +30,8 @@ function loadFile(/** @type {any} */ p, /** @type {any} */ cap) {
30
30
  }
31
31
  }
32
32
 
33
- /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, [key: string]: any }} opts */
34
- export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory }) {
33
+ /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, presetBlock?: string, [key: string]: any }} opts */
34
+ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory, presetBlock }) {
35
35
  // 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
36
36
  // 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
37
37
  // 路由 pro⇄flash 翻转或跨天会改变前缀 → 整段历史按未命中价重计(命中价的 30 倍)。
@@ -40,6 +40,9 @@ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemo
40
40
 
41
41
  当前工作目录:${workingDir}`;
42
42
 
43
+ // v0.4.0 Agent Preset:预设定制的角色/规则段(会话内恒定,前缀稳定)
44
+ if (presetBlock) prompt += presetBlock;
45
+
43
46
  // 用户级记忆(~/.mingdao/AGENTS.md,/memory add 手动追加 + 会话结束自动提炼)
44
47
  const memory = loadFile(path.join(mingdaoHome(), 'AGENTS.md'), 8000);
45
48
  if (memory) prompt += `\n\n<user_memory>\n${memory}\n</user_memory>`;
package/src/routing.js CHANGED
@@ -139,9 +139,13 @@ export async function routeTask(/** @type {any} */ { cfg, provider, currentModel
139
139
  return rc.executor === currentModel ? { model: currentModel, reason: null } : { model: rc.executor, reason: '回退执行模型' };
140
140
  }
141
141
 
142
- // 子代理模型选择:路由开启时固定 executor
142
+ // 子代理模型选择:路由开启时固定 executor——但当前模型不在路由池(如本地/自定义模型)时
143
+ // 必须跟随当前模型,否则子代理会把 executor 模型名(deepseek-v4-flash)发到当前模型的 baseUrl
144
+ // (本地 8081),服务端不认识 → 400 → 子代理全灭,表现为主线程「子代理无反馈」。
143
145
  export function subagentModel(/** @type {any} */ cfg, /** @type {any} */ currentModel) {
144
146
  const rc = routingConfig(cfg);
145
147
  if (!rc) return currentModel;
148
+ // v0.4.1 修复:与 routeTask 的池外检查一致——当前模型不在 planner/executor 池内时不干预
149
+ if (currentModel !== rc.planner && currentModel !== rc.executor) return currentModel;
146
150
  return modelPreset(rc.executor) ? rc.executor : currentModel;
147
151
  }
@@ -53,6 +53,9 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
53
53
  }
54
54
  const provider = await createProvider(cfg, modelName);
55
55
  const io = createIO({ quiet: true });
56
+ // v0.4.0 契约化:后台任务同样挂载 config.tools 声明式工具(幂等)
57
+ const { mountConfigTools } = await import('../tools/index.js');
58
+ mountConfigTools(cfg);
56
59
  const permissionObj = createPermission(perm, io);
57
60
  /** @type {any} */
58
61
  let mcpManager = null;
package/src/tools/bash.js CHANGED
@@ -34,8 +34,16 @@ export function detectSandbox() {
34
34
  sandboxSupport = 'none';
35
35
  if (process.platform === 'linux') {
36
36
  try {
37
- const r = spawnSync('bwrap', ['--version'], { stdio: 'ignore', timeout: 3000 });
38
- sandboxSupport = r.error ? 'none' : 'bwrap';
37
+ // v0.4.1 P1 修复:实际能力探测而非仅 --version——bwrap 存在但 /proc 挂载受限(Docker/devcontainer/
38
+ // CI 流水线等容器环境)时 --version 能跑、真正建沙箱却失败。用最小真实沙箱命令验证:
39
+ // --ro-bind / / --tmpfs /tmp true 能成功退出 0 才算可用,否则降级 none(runBash 会注明降级)。
40
+ const v = spawnSync('bwrap', ['--version'], { stdio: 'ignore', timeout: 3000 });
41
+ if (v.error) {
42
+ sandboxSupport = 'none';
43
+ } else {
44
+ const probe = spawnSync('bwrap', ['--die-with-parent', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', 'true'], { stdio: 'ignore', timeout: 5000 });
45
+ sandboxSupport = probe.error || probe.status !== 0 ? 'none' : 'bwrap';
46
+ }
39
47
  } catch {
40
48
  sandboxSupport = 'none';
41
49
  }