dsh-plugin-tool-management 0.16.6 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +2 -1
  3. package/README_EN.md +2 -1
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +43 -0
  21. package/lib/audit-log.js +6 -2
  22. package/lib/client.js +44 -17
  23. package/lib/compat/probe.js +75 -24
  24. package/lib/context-inject.js +73 -7
  25. package/lib/host-names.js +150 -0
  26. package/lib/hub.js +82 -11
  27. package/lib/imports/parsers.js +40 -15
  28. package/lib/index.js +168 -74
  29. package/lib/mcp/loader-token.js +26 -0
  30. package/lib/mcp/manager.js +314 -87
  31. package/lib/mcp/patch-yaml.js +207 -40
  32. package/lib/mcp/secret-guard.js +122 -16
  33. package/lib/memories/archive-engine.js +21 -11
  34. package/lib/memories/index-io.js +125 -12
  35. package/lib/memories/service.js +163 -40
  36. package/lib/memories/snapshot.js +32 -9
  37. package/lib/op-registry.js +51 -8
  38. package/lib/ops/compat.js +6 -2
  39. package/lib/ops/prompts.js +18 -5
  40. package/lib/ops/scene-records.js +117 -10
  41. package/lib/ops/sessions.js +50 -4
  42. package/lib/ops/snapshot.js +105 -12
  43. package/lib/ops/trash.js +45 -9
  44. package/lib/prompts/service.js +7 -2
  45. package/lib/request-gate.js +115 -56
  46. package/lib/scene-prompt-sync.js +46 -7
  47. package/lib/scenes/candidates.js +21 -3
  48. package/lib/sessions/workspace.js +40 -5
  49. package/lib/skills/core.js +74 -4
  50. package/lib/skills/service.js +9 -4
  51. package/lib/subagents/service.js +6 -1
  52. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -19,7 +19,7 @@ import { createSkillsService, pluginLog } from './skills/service.js';
19
19
  import { renameWithRetry } from './skills/core.js';
20
20
  import { createPromptsService } from './prompts/service.js';
21
21
  import { isInsideRootResolved } from './paths.js';
22
- import { DEFAULT_PROFILE_NAME, PROFILE_CANDIDATES } from './host-names.js';
22
+ import { DEFAULT_PROFILE_NAME, MCP_TOOL_PREFIX, PROFILE_CANDIDATES, serverNameCandidates, splitMcpToolName } from './host-names.js';
23
23
  import { createMemoriesService } from './memories/service.js';
24
24
  import { createArchiveEngine } from './memories/archive-engine.js';
25
25
  import { createSnapshotOps } from './ops/snapshot.js';
@@ -44,15 +44,15 @@ import { createCandidates } from './scenes/candidates.js';
44
44
  // 「已知工具」归一化与「当前可用工具数」原本定义在本文件(导出仅为测试接缝),
45
45
  // 2026-09-19 随 MCP 域搬进 ./mcp/manager.ts。这里保持原路径可导出,契约不变。
46
46
  export { normalizeKnownTools, countEnabledTools } from './mcp/manager.js';
47
- import { applyLoaderToken, applyLoaderTokenDisabled, buildLoaderOverrideEntry, readLoaderToken } from './mcp/loader-token.js';
47
+ import { applyLoaderToken, applyLoaderTokenDisabled, buildLoaderOverrideEntry, readLoaderToken, scanLoaderRows } from './mcp/loader-token.js';
48
48
  import { hubBackupDir, hubPath, migrateHubLayoutSync, relocateEntries } from './hub.js';
49
49
  // 分域的 HTTP ops(2026-09-19 从本文件 handlers 表抽出,依赖显式传参)。
50
50
  import { buildCompatOps } from './ops/compat.js';
51
51
  import { buildPromptOps } from './ops/prompts.js';
52
52
  import { buildSessionOps } from './ops/sessions.js';
53
53
  // HTTP 请求准入与 handlers 后处理(2026-09-19 从本文件 apply 闭包抽出,依赖显式传参)。
54
- import { createAccessToken, createOpWhitelist, createSceneLock, installHandlerGuards } from './request-gate.js';
55
- import { auditOpRegistry, auditProblems } from './op-registry.js';
54
+ import { createAccessToken, createFrozenGate, createOpWhitelist, createSceneLock, guardModelOp, guardModelOps, installHandlerGuards } from './request-gate.js';
55
+ import { auditOpRegistry, auditProblems, isReadCall } from './op-registry.js';
56
56
  import { buildSceneSyncOps } from './ops/scene-sync.js';
57
57
  import { buildCandidateOps } from './ops/candidates.js';
58
58
  // model 工具(ctx.tools.register)按域分文件;共享的依赖形状见 tools/deps.ts。
@@ -221,23 +221,37 @@ export default {
221
221
  ...(Number.isFinite(Number(config?.rulesMaxBytes))
222
222
  ? { maxBytes: Number(config.rulesMaxBytes) }
223
223
  : {}),
224
+ // 全局 AGENTS.md 的**宿主实际读的那一份**(profile home 下,与上面 `getGlobalAgentsMdPath`
225
+ // 同源)。`readGlobalAgentsMdSync` 的去重/兜底必须读它 —— `resolveDshHome()` 是另一条
226
+ // 独立来源(见 `ensurePaths` 的注释「不合并是有意的」),两边不同时会取到另一个文件、
227
+ // 去重永远不相等 → 同一份预设正文注入两遍(审查 P2-19)。
228
+ // 惰性求值:`cached` 在下面的 path discovery 段才声明(`let`),而本箭头函数只在 op
229
+ // 被调用时才求值 —— 那时 `ensurePaths()` 早已跑过。外面这层 `try` 是防**将来**有人在
230
+ // apply 中途同步调用 memories op 那种时序(`let` 的 TDZ 会抛 ReferenceError)。
231
+ globalAgentsMdPath: () => {
232
+ try {
233
+ const p = cached;
234
+ if (!p)
235
+ return null;
236
+ const sep = p.home.indexOf('\\') >= 0 ? '\\' : '/';
237
+ return p.home + sep + 'AGENTS.md';
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ },
224
243
  });
225
244
  // 场景记忆段的注册/生命周期由下面的注入通道统一管(memoriesService.memoryText / promptText)。
226
245
  // ---------- 场景档案引擎(设计 §2)----------
227
246
  // deps 把既有写通道(MCP 单工具启停 / 技能启停)注入引擎;引擎自身串行,
228
247
  // 状态机与纯逻辑在 ./memories/archive*.ts。mcpmToolEnabled / readDisabledTools
229
248
  // 为函数声明(提升),此处引用安全。
230
- const toolKeyParts = (toolName) => {
231
- if (!toolName.startsWith('mcp__'))
232
- return null;
233
- const rest = toolName.slice(5);
234
- const i = rest.indexOf('__');
235
- if (i <= 0)
236
- return null;
237
- const server = rest.slice(0, i);
238
- const tool = rest.slice(i + 2);
239
- return server && tool ? { key: server + '/' + tool, server, tool } : null;
240
- };
249
+ // MCP 工具全名 → `{ server, tool }`。**切分规则只有一套**:`splitMcpToolName`
250
+ // (`host-names.ts`),与执行侧守卫 `mcp.isToolDisabled` 走的是同一个函数(2026-09-30 审查 N1)。
251
+ // 这里只负责把**候选 serverName 集合**传进去 —— 集合不同是切分结论不同的另一个来源,
252
+ // 所以集合也用同一个 builder(`serverNameCandidates`)从**同一批表**(停用表 ∪ 已知工具缓存)
253
+ // 取并集,不在这里手写 `Object.keys`。
254
+ const toolKeyParts = (toolName, serverNames) => splitMcpToolName(toolName, serverNames);
241
255
  /**
242
256
  * 一次扫描同时给出三样东西(避免为了三份视图各扫一遍磁盘):
243
257
  * - `states`:**真实生效**的技能 `<rootKey>/<name>` → boolean;
@@ -336,33 +350,41 @@ export default {
336
350
  },
337
351
  applyToolTableHidden: applyToolTableHiddenNow,
338
352
  loadSlice: async () => ({ ...(await memoriesService.readArchiveSlice()) }),
339
- saveSlice: (slice) => memoriesService.patchIndex(slice),
340
- configuredServers: async () => {
341
- const r = await mcp.mcpmListView();
342
- const out = [];
343
- for (const row of ((r && r.rows) || [])) {
344
- const n = String((row && row.serverName) || '');
345
- if (n && out.indexOf(n) < 0)
346
- out.push(n);
347
- }
348
- return out;
349
- },
353
+ // `fields` 必须原样透传:它决定"本次只写哪几片",是审查 P2-3 里防丢更新的关键。
354
+ saveSlice: (slice, fields) => memoriesService.patchIndex(slice, fields),
355
+ saveArchive: (scene, archive) => memoriesService.saveArchive(scene, archive),
356
+ // 与 `serverKnownTools` 用同一个出口:都是「补丁里配了哪些 serverName」。
357
+ // 早先这里走 `mcpmListView()` —— 那份会顺带枚举工具 schema 并回写「已知工具」缓存,
358
+ // 而档案引擎只要名字(2026-09-30 审查 N1 顺带收窄)。
359
+ configuredServers: () => mcp.configuredServerNames(),
350
360
  serverKnownTools: async () => {
351
361
  const out = {};
352
362
  const raw = await mcp.readDisabledTools();
353
363
  for (const [server, list] of Object.entries(raw))
354
364
  out[server] = list.filter((t) => t !== '*');
355
365
  // 「已知工具」缓存:未运行服务器也能按最后见过的名单计算补集。
356
- for (const [server, list] of Object.entries(await mcp.readKnownMcpTools())) {
366
+ const known = await mcp.readKnownMcpTools();
367
+ for (const [server, list] of Object.entries(known)) {
357
368
  out[server] = [...new Set([...(out[server] || []), ...list.map((item) => item.name)])].sort();
358
369
  }
370
+ // 候选集合必须在**往 out 里补 live 工具之前**取:补进去的键是从 schema 名切出来的,
371
+ // 拿它当候选就成了循环论证(切错了也照样"命中")。
372
+ //
373
+ // 三个来源里,补丁那份(`configuredServerNames`)是唯一与切分结果无关的 —— 停用表的键
374
+ // 来自界面勾选、缓存键来自上一次切分,单靠它们会锁死错误结论(审查 N1 / R3)。
375
+ let configured = [];
376
+ try {
377
+ configured = await mcp.configuredServerNames();
378
+ }
379
+ catch { /* 补丁读不到 → 退化为另两份 */ }
380
+ const serverNames = serverNameCandidates(configured, raw, known);
359
381
  let schemas = [];
360
382
  try {
361
383
  schemas = await tools.schemas();
362
384
  }
363
385
  catch { /* 无 live 工具 → 仅停用表 + 缓存 */ }
364
386
  for (const s of schemas) {
365
- const p = toolKeyParts(String((s && s.name) || ''));
387
+ const p = toolKeyParts(String((s && s.name) || ''), serverNames);
366
388
  if (!p)
367
389
  continue;
368
390
  const list = out[p.server] || (out[p.server] = []);
@@ -929,6 +951,14 @@ export default {
929
951
  iso: new Date().toISOString(),
930
952
  version: PKG_VERSION,
931
953
  injects: [...INJECT_SERVICES],
954
+ // 宿主**进程身份**(2026-09-30 审查 §5 F1):插件与宿主同进程,所以 `pid` 就是宿主的 pid,
955
+ // 而 `hostStartedAt` 是本进程的启动时刻(`Date.now() - uptime`)。有了这两个值,doctor
956
+ // 才能把「这份心跳是不是**这一轮**宿主写的」变成一条可计算的结论:
957
+ // pid 已经不存在 → 宿主自那以后重启过(或那次运行已结束);
958
+ // pid 还在 → 心跳来自当前正在跑的宿主进程,插件这一轮**确实挂上了**。
959
+ // 此前只有时间戳,用户必须自己记得"我什么时候重启的"才能解读它。
960
+ pid: process.pid,
961
+ hostStartedAt: Date.now() - Math.round(process.uptime() * 1000),
932
962
  }).catch((e) => { ctx.logger?.warn?.('mount heartbeat write failed: ' + message(e)); });
933
963
  // B3 符号断言:probe.ts 的 unwrap 用硬编码的 `Symbol.for('cordis.original')`,与 cordis
934
964
  // 导出的 `symbols.original` 必须是同一个符号。不是的话,探针会把代理当原始对象、身份判定失真。
@@ -989,18 +1019,18 @@ export default {
989
1019
  async function rebindSubagentInArchives(from, to) {
990
1020
  try {
991
1021
  const slice = await memoriesService.readArchiveSlice();
992
- const archives = { ...(slice.archives || {}) };
993
- let changed = 0;
994
- for (const [scene, archive] of Object.entries(archives)) {
1022
+ const touched = {};
1023
+ for (const [scene, archive] of Object.entries(slice.archives || {})) {
995
1024
  const list = archive.subagents;
996
1025
  if (!Array.isArray(list) || !list.includes(from))
997
1026
  continue;
998
- archives[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
999
- changed++;
1027
+ touched[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
1000
1028
  }
1001
- if (changed)
1002
- await memoriesService.patchIndex({ archives });
1003
- return changed;
1029
+ const names = Object.keys(touched);
1030
+ // 逐场景增量(审查 P2-3):此前整表回写,会把窗口期里别人保存的**别的场景**档案抹掉。
1031
+ for (const scene of names)
1032
+ await memoriesService.saveArchive(scene, touched[scene]);
1033
+ return names.length;
1004
1034
  }
1005
1035
  catch {
1006
1036
  return 0;
@@ -1376,10 +1406,22 @@ export default {
1376
1406
  // learned this the hard way: a bad edit to cordis.patch.yml can stop DSH
1377
1407
  // from booting, so the pre-write file must stay recoverable without git.
1378
1408
  const KEEP_PATCH_BACKUPS = 5;
1409
+ /**
1410
+ * 备份名里的时间戳:**到毫秒 + 进程内序号**(定宽)。
1411
+ *
1412
+ * 为什么不能只到秒(2026-09-30 审查 P2-5):同一次操作里常常连写两遍补丁(`mcpm-set-enabled`
1413
+ * 的强制写 + 恢复写、整理补丁的批量写),两次落在同一秒时**后一份备份会覆盖前一份** ——
1414
+ * `KEEP_PATCH_BACKUPS=5` 于是不是"留 5 个历史状态"而是"留 5 个秒",最坏情况下唯一能回滚的
1415
+ * 中间副本被挤掉。序号保证同一毫秒内也不撞名。
1416
+ *
1417
+ * 两段都是**定宽**,所以按名字排序仍然等于按时间排序(`listPatchBackups` 的剪枝依赖这一点)。
1418
+ */
1419
+ let backupSeq = 0;
1379
1420
  function backupStamp() {
1380
1421
  const d = new Date();
1381
- const p = (n) => String(n).padStart(2, '0');
1382
- return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
1422
+ const p = (n, w = 2) => String(n).padStart(w, '0');
1423
+ backupSeq = (backupSeq + 1) % 1000;
1424
+ return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + p(d.getMilliseconds(), 3) + p(backupSeq, 3);
1383
1425
  }
1384
1426
  // 改 patch 前的自动备份:写在 `hub/backups/`(不再堆在 `$DSH_HOME` 根下)。
1385
1427
  // 两个 patch 都叫 `cordis.patch.yml`(全局 + `profiles/<名字>/`),所以备份名里带上层级,
@@ -1415,7 +1457,10 @@ export default {
1415
1457
  * 用它列目录会得到 0 份。它的用途是启动期搬运旧文件,语义不同,别混。
1416
1458
  */
1417
1459
  function isBackupFileName(name) {
1418
- return /^cordis\.patch\.yml(?:\.[A-Za-z0-9._-]+)?\.bak-\d{8}-\d{6}$/.test(basename(String(name || '')));
1460
+ // 两代时间戳都认:老名字到秒(`\d{6}`),0.17.0 起加了毫秒与序号(共 `\d{12}`)。
1461
+ // 只认新格式会让**存量备份**在清单与自动剪枝里集体消失 —— 它们既不再被剪掉,
1462
+ // 也不再出现在「清理旧备份」里,等于把明文密钥副本永久留在盘上。
1463
+ return /^cordis\.patch\.yml(?:\.[A-Za-z0-9._-]+)?\.bak-\d{8}-\d{6}(?:\d{6})?$/.test(basename(String(name || '')));
1419
1464
  }
1420
1465
  /**
1421
1466
  * 一份备份属于**哪一份 patch**(备份名里的层级标签)。
@@ -1563,12 +1608,21 @@ export default {
1563
1608
  return { ...result, warning: extra };
1564
1609
  }
1565
1610
  /**
1566
- * 找到本插件 loader 行所在的补丁文件。返回 0 / 1 / 多份,多份时调用方必须拒绝自动改:
1567
- * 同一条 loader 行出现在两份补丁里会让宿主起不来(重复 id),与 duplicateGuard 同一口径。
1611
+ * 找到本插件 loader 行所在的补丁文件,并**按形状分类**。
1612
+ *
1613
+ * 冲突只认 **insert** 条目:同 id 的两条 insert 才会重复挂载、让 DSH 起不来(与
1614
+ * duplicateGuard 同一口径)。顶层**覆盖条目**(`- id:` 在第 0 列)是官方支持的改法,
1615
+ * 与 insert 并存是正常形状 —— 把它也算成"第二份补丁"会让 0.1.7 bundle 挂载下的令牌
1616
+ * 操作被全量拒绝(2026-09-30 审查 P2-12)。
1617
+ *
1618
+ * `files` 的**顺序即写点优先级**:覆盖条目在前。理由:官方 `applyEntryPatches` 在所有
1619
+ * bundle 层**之后**应用覆盖条目、且是整体替换那条 loader 的 `config` —— 所以两种形状
1620
+ * 并存时,令牌写进 insert 行会被覆盖条目盖掉(改了不生效,两边还都看不出分歧)。
1568
1621
  */
1569
1622
  async function loaderTokenFiles() {
1570
1623
  const p = await ensurePaths();
1571
- const files = [];
1624
+ const insertFiles = [];
1625
+ const overrideFiles = [];
1572
1626
  const infoOf = new Map();
1573
1627
  for (const abs of [p.projectPatch, p.globalPatch]) {
1574
1628
  let content = '';
@@ -1578,11 +1632,17 @@ export default {
1578
1632
  catch {
1579
1633
  continue;
1580
1634
  }
1635
+ const rows = scanLoaderRows(content);
1636
+ if (!rows.insert.length && !rows.override.length)
1637
+ continue;
1581
1638
  const read = readLoaderToken(content);
1582
- if (read.found) {
1583
- files.push(abs);
1584
- infoOf.set(abs, { token: read.token, disabled: read.disabled });
1585
- }
1639
+ if (!read.found)
1640
+ continue;
1641
+ infoOf.set(abs, { token: read.token, disabled: read.disabled });
1642
+ if (rows.insert.length)
1643
+ insertFiles.push(abs);
1644
+ else
1645
+ overrideFiles.push(abs);
1586
1646
  }
1587
1647
  // 0.1.7 bundle 挂载:loader 条目由插件包内的 cordis.patch.yml(bundle 层)提供,两份
1588
1648
  // 补丁里都没有可就地改写的行。bundleBase 只回答「覆盖目标存在吗」—— 写点是 profile
@@ -1594,7 +1654,7 @@ export default {
1594
1654
  catch {
1595
1655
  bundleBase = false;
1596
1656
  }
1597
- return { files, infoOf, bundleBase };
1657
+ return { files: [...overrideFiles, ...insertFiles], insertFiles, infoOf, bundleBase };
1598
1658
  }
1599
1659
  /**
1600
1660
  * 配置**文件里**写了令牌,但当前进程还没生效(或反过来)—— 也就是"改了配置还没重启"。
@@ -1685,12 +1745,19 @@ export default {
1685
1745
  return { ok: true, changed: false, note: '和当前令牌相同,未改动。', restartRequired: false };
1686
1746
  }
1687
1747
  const p = await ensurePaths();
1688
- const { files, bundleBase } = await loaderTokenFiles();
1748
+ const { files, insertFiles, bundleBase } = await loaderTokenFiles();
1689
1749
  if (!files.length && !(mode === 'set' && bundleBase)) {
1690
1750
  return { ok: false, error: '没找到本插件的 loader 行,也无法确认 bundle 层的挂载条目,无法自动改写配置:请在 profile 的 cordis.patch.yml 里手动加一条覆盖条目(- id: dsh-plugin-tool-management → config: → token: "…"),或改用环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN。' };
1691
1751
  }
1752
+ // 只有**两条 insert 条目**才是"重复挂载、DSH 起不来"(审查 P2-12:覆盖条目与 insert
1753
+ // 并存是 0.1.7 的正常形状,把它也算进来会让令牌操作被全量拒绝)。
1754
+ if (insertFiles.length > 1) {
1755
+ return { ok: false, error: '本插件的 loader 行作为 insert 条目同时出现在两份补丁文件里(重复挂载会导致 DSH 无法启动):请先清掉重复那条,再回来设置令牌。' };
1756
+ }
1757
+ // 覆盖条目多于一份则写点不唯一:官方按文件顺序 last-wins,我们无法确定该改哪一份 ——
1758
+ // 与其赌一份,不如让用户先合并。
1692
1759
  if (files.length > 1) {
1693
- return { ok: false, error: '本插件的 loader 行同时出现在两份补丁文件里(会导致 DSH 无法启动):请先清掉重复那条,再回来设置令牌。' };
1760
+ return { ok: false, error: '本插件的 loader 行在多处出现且都不是唯一的 insert 条目(多半是多条覆盖条目):无法确定该改哪一处,请先手工合并成一条再设置令牌。' };
1694
1761
  }
1695
1762
  if (!files.length) {
1696
1763
  // 0.1.7 bundle 挂载:两份补丁里都没有本插件的 loader 行(条目由 bundle 层提供,那份
@@ -1878,7 +1945,7 @@ export default {
1878
1945
  catch {
1879
1946
  hostNames = [];
1880
1947
  }
1881
- const mcp = hostNames.filter((name) => name.startsWith('mcp__'));
1948
+ const mcp = hostNames.filter((name) => name.startsWith(MCP_TOOL_PREFIX));
1882
1949
  const known = new Set(hostNames);
1883
1950
  const presetId = currentPresetId(agentCtx);
1884
1951
  if (presetId !== null)
@@ -2519,18 +2586,23 @@ export default {
2519
2586
  * 调用方就是猜。这里给工具看到的表包一层,界面上 handlers 表那份原样不动,两条路各记各的。
2520
2587
  * 只多记一条流水:不改行为、不改返回值、也不吞异常(写类与成败的判据在 ./audit-log.ts)。
2521
2588
  */
2522
- const auditFn = (opName, fn) => async (args) => {
2523
- const result = await fn(args || {});
2524
- recordOpAudit(opName, args || {}, 'model', result);
2525
- return result;
2526
- };
2527
- const auditOps = (ops) => {
2528
- const out = {};
2529
- for (const [name, fn] of Object.entries(ops)) {
2530
- out[name] = typeof fn === 'function' ? auditFn(name, fn) : fn;
2531
- }
2532
- return out;
2533
- };
2589
+ /**
2590
+ * **模型侧的冻结闸**(审查 F3,0.17.0):与 HTTP handlers 用同一个判据函数。
2591
+ *
2592
+ * 此前冻结只装在 `handlers` 上,而模型工具拿的是 `service.ops` 的副本 —— 于是「锁一个
2593
+ * **非活动**场景」在 HTTP 上被拒、在模型侧五个域(技能/记忆/MCP/子智能体/场景档案)
2594
+ * 全放行:`lockedSceneGuard()` 只看**当前**场景,锁的是别的场景时它返回 null。
2595
+ * 一个界面禁用、模型可写的门禁,等于没有门禁。
2596
+ *
2597
+ * 装在这里而不是 service.ops 上:进/退场景的运行时应用直调 `service.ops`
2598
+ * (`applySkills` / `applyMcpServerSwitches` / …),装在那一层会把场景自己锁死(见
2599
+ * `createFrozenGate` 的注释)。`auditOps` 返回的是**新表**,原表不受影响。
2600
+ */
2601
+ const frozenGate = createFrozenGate({ lockedSceneNames, activeSceneName });
2602
+ // 被拒时不记流水:什么都没改,记下来只会把真变更淹没(与只读 op 同一口径)。
2603
+ const modelAudit = (opName, args, result) => recordOpAudit(opName, args, 'model', result);
2604
+ const auditOps = (ops) => guardModelOps(ops, frozenGate, modelAudit);
2605
+ const auditFn = (opName, fn) => guardModelOp(opName, fn, frozenGate, modelAudit);
2534
2606
  const toolDeps = {
2535
2607
  defineTool,
2536
2608
  // 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
@@ -2564,7 +2636,16 @@ export default {
2564
2636
  buildSkillTools({ ...toolDeps, skillsOps: auditOps(skillsService.ops) });
2565
2637
  // 传 op 表而不是 promptsService:「生效中」的判定只有 `agentsmd-list` 里有(场景绑定
2566
2638
  // 的那份才算),直调服务会得到文件比对口径 —— 场景驱动时工具会报一个与界面不同的答案。
2567
- buildPromptTools({ ...toolDeps, promptOps: auditOps(promptOps), applyPresetGuarded, promptsDir });
2639
+ //
2640
+ // `applyPresetGuarded` 是模型侧**唯一**不经 op 表的写路径(`prompt_manager_apply` 直接调它,
2641
+ // 见上面 `frozenGate` 的注释),所以冻结闸要在这里显式套一层 —— 它对应的 HTTP op 是
2642
+ // `agentsmd-apply`(冻结清单里那条)。少了这一层,"锁着**别的**场景"时模型仍能换掉当前
2643
+ // 场景的提示词绑定(审查 F3 的另一半)。
2644
+ const applyPresetForModel = async (id) => {
2645
+ const denied = await frozenGate('agentsmd-apply', { id });
2646
+ return denied ? { ok: false, error: denied } : applyPresetGuarded(id);
2647
+ };
2648
+ buildPromptTools({ ...toolDeps, promptOps: auditOps(promptOps), applyPresetGuarded: applyPresetForModel, promptsDir });
2568
2649
  buildMemoryTools({ ...toolDeps, rulesOps: auditOps(memoriesService.ops) });
2569
2650
  // 场景族(tools/scene.ts):列场景与档案(`_list`)+ 建场景 / 写档案 / 绑提示词(`_save`)
2570
2651
  // + 进入与退出(`_switch`)。
@@ -2970,14 +3051,14 @@ export default {
2970
3051
  return;
2971
3052
  }
2972
3053
  }
2973
- // 「读改写」两态 op:不带 `set:true` 时是**纯读**,与写门禁无关。
2974
- // `inject-settings`(兼容页的「注入」块)、`tool-table`(同页的「模型工具表」)、
2975
- // `scene-settings`(场景页的提醒开关)与 `mcpm-settings`(MCP 页的轮询设置)
2976
- // 都用同一个 op 承担读与写,于是整条 op 被列进 WRITE_OPS —— 读侧也一起被拦,
2977
- // 界面表现是"没填令牌时整块设置消失、填了还要刷新才出现"(2026-09-19 用户报的)。
2978
- // 判据是**这次调用要不要写**,不是 op 名在不在名单里。
2979
- const readOnlyCall = (op === 'inject-settings' || op === 'tool-table' || op === 'scene-settings' || op === 'mcpm-settings')
2980
- && !(payload.args && payload.args.set === true);
3054
+ // 「读改写」两态 op(`inject-settings` / `tool-table` / `scene-settings` /
3055
+ // `mcpm-settings`):同一个名字既读又写,于是整条 op 被列进 WRITE_OPS —— 读侧也
3056
+ // 一起被拦,界面表现是"没填令牌时整块设置消失、填了还要刷新才出现"(2026-09-19
3057
+ // 用户报的)。判据是**这次调用要不要写**,不是 op 名在不在名单里。
3058
+ // 判据本身在登记表(`writeWhen`),这里不再手写一份 —— 手写那份只认 `set:true`,
3059
+ // 于是 `tool-table` 的 `presetSave` / `presetDelete` 被当成读放行,配了令牌也能
3060
+ // 只凭宿主栅栏改侧车(审查 F2,0.17.0 修)。
3061
+ const readOnlyCall = isReadCall(op, payload.args);
2981
3062
  if (TOKEN && WRITE_OPS.has(op) && !readOnlyCall && !tokenMatches(hdr('x-dsh-token'))) {
2982
3063
  // 文案与明文门禁同源(http-fence.ts),code 也统一 —— 界面据此在最右侧挂
2983
3064
  // 「去填令牌」跳转按钮。这条错误会出现在**任意**页面,而入口只有一个。
@@ -3032,9 +3113,9 @@ export default {
3032
3113
  }
3033
3114
  const result = await fn(payload.args || {});
3034
3115
  // 「最近改动」流水:来源 = 面板(这条 HTTP 路由是界面唯一的入口)。
3035
- // `readOnlyCall` 是上面那四个「读改写两态 op」的纯读分支 —— 它们没改任何状态,不记。
3036
- if (!readOnlyCall)
3037
- recordOpAudit(op, payload.args || {}, 'panel', result);
3116
+ // 「这次算不算写」的判据在 recordOpAudit 里(与写门禁同源:登记表的 `writeWhen`)
3117
+ // —— 不在这里再传一次 `readOnlyCall`,两处各判一次迟早会分家。
3118
+ recordOpAudit(op, payload.args || {}, 'panel', result);
3038
3119
  res.end(JSON.stringify(withPatchWarnings(result === undefined ? { ok: true } : result)));
3039
3120
  }
3040
3121
  catch (e) {
@@ -3046,6 +3127,19 @@ export default {
3046
3127
  catch (e) {
3047
3128
  // A registration failure must never take down the whole entry: log and continue.
3048
3129
  console.error('[dsh-plugin-tool-management] webServer route registration failed:', message(e));
3130
+ // 结构性弱点(2026-09-30 审查 §5 F4):compat-status **本身就走这条路由**,所以路由没注册上
3131
+ // 时兼容页也拿不到这条降级行 —— 面板是**整页失联**,不是"少一行"。这里至少把它写进运行期
3132
+ // 上报通道(日志里必然有一条,进程内可查),并在 README 里写明"面板空白先看宿主日志"。
3133
+ // 之所以不是"必然可见"的告警:可见的通道就是这条路由,它自己挂了就没得显示。
3134
+ noteRuntime({
3135
+ id: 'web-server-route',
3136
+ label: 'HTTP 路由注册',
3137
+ kind: 'write',
3138
+ fallback: 'inform-only',
3139
+ detail: '向宿主 webServer 注册 API 路由失败(' + message(e) + '):插件面板与全部 HTTP op 不可用(兼容页也走这条路由,所以它也打不开)。宿主日志里有同一条记录;模型工具不受影响。',
3140
+ detailKey: 'web-server-route.register-failed',
3141
+ params: { reason: message(e) },
3142
+ });
3049
3143
  }
3050
3144
  }
3051
3145
  },
@@ -119,6 +119,32 @@ function locate(content, pluginId) {
119
119
  }
120
120
  return { lines, start, end, indent, configAt, configEnd, configUnrecognised, configDuplicated: configCount > 1 };
121
121
  }
122
+ /**
123
+ * 文件里**所有**本插件 loader 行的形状(行号 + 是 insert 子条目还是顶层覆盖条目)。
124
+ *
125
+ * 为什么需要区分(2026-09-30 审查 P2-12):`readLoaderToken` 只认**第一条**(`locate` 的行为),
126
+ * 而「本插件的 loader 行出现在两份补丁里 = 重复挂载 = DSH 起不来」这个判据**只对 insert 条目
127
+ * 成立** —— 覆盖条目(`- id:` 在第 0 列,官方 `applyEntryPatches` 的 id-targeted override)
128
+ * 是官方支持的改法,与 insert 并存是**正常**形状(0.1.7 bundle 挂载下,令牌的写点就是它)。
129
+ * 把覆盖条目也算成"第二份补丁",会让 bundle 挂载下的令牌操作被**全量拒绝**。
130
+ *
131
+ * `- id:` 的缩进就是形状:0 列 = 顶层覆盖条目;> 0 列 = 嵌在 `- insert:` 里的子条目。
132
+ */
133
+ export function scanLoaderRows(content, pluginId = PLUGIN_LOADER_ID) {
134
+ const insert = [];
135
+ const override = [];
136
+ const lines = String(content == null ? '' : content).split(/\r?\n/);
137
+ for (let i = 0; i < lines.length; i++) {
138
+ const m = ROW_ID_RE.exec(lines[i]);
139
+ if (!m || m[2] !== pluginId)
140
+ continue;
141
+ if (m[1].length === 0)
142
+ override.push(i);
143
+ else
144
+ insert.push(i);
145
+ }
146
+ return { insert, override };
147
+ }
122
148
  /** 读插件 loader 行里的 `config.token` / `config.tokenDisabled`(没写 / 没这条行都返回空值)。 */
123
149
  export function readLoaderToken(content, pluginId = PLUGIN_LOADER_ID) {
124
150
  const { lines, start, configAt, configEnd, indent } = locate(content, pluginId);