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
@@ -14,8 +14,9 @@
14
14
  // 导出物不该顺手把它们带走;要完整导出必须显式传 `includeSecrets:true`(界面二次确认)。
15
15
  // 打码值导回来由 `secret-guard` 拿目标机的旧真值顶替(`mcpm-import` 既有的行为)。
16
16
  import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
17
- import { join, resolve } from 'node:path';
17
+ import { basename, join, resolve } from 'node:path';
18
18
  import { unzipSync, zipSync } from 'fflate';
19
+ import { maskSecretValue, maskUrlQuery } from '../mcp/secret-guard.js';
19
20
  const SNAP_PREFIX = 'dsh-tool-management-snapshot-';
20
21
  /** 快照目录里各部分的名字(导入侧按这些名字找东西,别改)。 */
21
22
  const F_MANIFEST = 'manifest.json';
@@ -29,7 +30,6 @@ const D_SKILLS = 'skills';
29
30
  /** 三个"文件域":整个目录打进一份 zip,键是相对路径。 */
30
31
  const FILE_DOMAINS = [D_MEMORIES, D_SUBAGENTS, D_PROMPTS];
31
32
  const ALL_DOMAINS = ['mcp', D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_SKILLS, 'settings', 'scenes'];
32
- const MASKED = '••••••';
33
33
  function stamp() {
34
34
  return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
35
35
  }
@@ -110,6 +110,43 @@ function namesOf(res, prefer) {
110
110
  }
111
111
  return [];
112
112
  }
113
+ /**
114
+ * 从 `skill-state` 的返回里取「<来源>/<技能名>」清单。
115
+ *
116
+ * 为什么不能复用 `namesOf`(2026-09-30 审查 P1-6):`skill-state` 的形状是
117
+ * `{ ok, data: { roots: [{ key, skills: [{ name, declaredName, … }] }] } }` —— 顶层键是
118
+ * `roots`,而 `namesOf` 只找 `rows / personas / presets / scenes / skills / list / entries /
119
+ * items` 这些**数组**键,一个都对不上 → 恒返回 `[]` → 技能域**永远导出 0 项**,而界面显示的
120
+ * 是"迁移完成"。key 用**声明名**(frontmatter `name`),口径与 `index.ts` 的
121
+ * `skillArchiveKeysOfRoot` 一致(档案里存的就是声明名,用条目名拼会在 stale 校验里被丢掉)。
122
+ */
123
+ function skillKeysOf(res) {
124
+ if (!res || res.ok === false)
125
+ return [];
126
+ for (const bag of [res.data, res].filter(Boolean)) {
127
+ const roots = bag.roots;
128
+ if (!Array.isArray(roots))
129
+ continue;
130
+ const out = [];
131
+ for (const root of roots) {
132
+ const rootKey = String(root?.key || '');
133
+ if (!rootKey)
134
+ continue;
135
+ const skills = root.skills;
136
+ if (!Array.isArray(skills))
137
+ continue;
138
+ for (const sk of skills) {
139
+ const s = sk;
140
+ const name = String((s && (s.declaredName || s.name)) || '');
141
+ if (name)
142
+ out.push(rootKey + '/' + name);
143
+ }
144
+ }
145
+ if (out.length)
146
+ return out;
147
+ }
148
+ return [];
149
+ }
113
150
  /** 场景清单(带 label / description / 提示词绑定):`rules-list` 的 scenes 那一栏,读不到就是空。 */
114
151
  function sceneRecordsOf(res) {
115
152
  const bags = [res && res.data, res].filter(Boolean);
@@ -128,11 +165,18 @@ function sceneRecordsOf(res) {
128
165
  }
129
166
  return [];
130
167
  }
168
+ /**
169
+ * 键值对打码:**走 `secret-guard` 的唯一口径**(`$VAR` / `!!js` 这类间接引用原样保留)。
170
+ *
171
+ * 这里曾经自己写了一份「所有键一律打成 `••••••`」的版本 —— 与 MCP 列表视图的口径不一致,
172
+ * 结果是配置里的间接引用被当成密钥导出、导回来时 `resolveMaskedKv` 顶替不出真值只能丢键。
173
+ * 打码形态只允许有一处定义(审查 P0-4)。
174
+ */
131
175
  function maskKv(map) {
132
176
  const out = {};
133
177
  if (map && typeof map === 'object' && !Array.isArray(map)) {
134
178
  for (const k of Object.keys(map))
135
- out[k] = MASKED;
179
+ out[k] = maskSecretValue(map[k]);
136
180
  }
137
181
  return out;
138
182
  }
@@ -175,8 +219,14 @@ export function createSnapshotOps(deps) {
175
219
  copy.env = maskKv(copy.env);
176
220
  if (copy.headers)
177
221
  copy.headers = maskKv(copy.headers);
222
+ // URL 查询串走 `secret-guard.maskUrlQuery`(与 MCP 列表视图同一口径)。这里原先是**自写的
223
+ // 一条窄正则** `([?&](?:key|token|secret|password)=)`,只认四个固定参数名 ——
224
+ // `?api_key=` / `?access_token=` / `?sig=` / `?client_secret=` 这类 MCP 托管服务最常见的
225
+ // 形态全部漏掉,而这正是「凭据挂在 URL 上」的主流写法。后果不是界面显示问题:默认
226
+ // (`includeSecrets` 缺省 false)导出的 `mcp.json` 里就是**明文凭据**,而这份目录的用途
227
+ // 就是拷去 U 盘 / 网盘 / 另一台机器(审查 P0-4)。
178
228
  if (typeof copy.url === 'string')
179
- copy.url = copy.url.replace(/([?&](?:key|token|secret|password)=)[^&]*/gi, '$1' + MASKED);
229
+ copy.url = maskUrlQuery(copy.url);
180
230
  return copy;
181
231
  });
182
232
  try {
@@ -215,7 +265,7 @@ export function createSnapshotOps(deps) {
215
265
  }
216
266
  // ── 技能:源目录在外部(不在 hub),交给既有的 `bundle-export` 走它自己的收集路径 ──
217
267
  const skillState = await deps.invokeOp('skill-state', {});
218
- const skillKeys = namesOf(skillState, ['key']).filter((k) => k.indexOf('/') > 0);
268
+ const skillKeys = skillKeysOf(skillState);
219
269
  if (!skillKeys.length)
220
270
  notes.push('技能这次没导出:没从 skill-state 读到「<来源>/<名字>」形态的清单');
221
271
  if (skillKeys.length) {
@@ -385,13 +435,23 @@ export function createSnapshotOps(deps) {
385
435
  return { ok: false, error: read.error };
386
436
  const { files } = read;
387
437
  const report = [];
388
- /** 一个域失败只记在这一行上:迁移的意义正是「哪域没过来」看得清,不是整批回滚。 */
438
+ /**
439
+ * 一个域失败只记在这一行上:迁移的意义正是「哪域没过来」看得清,不是整批回滚。
440
+ *
441
+ * `warning` 也要透传(2026-09-30 审查 P2-9):各域的 import op 会用它报"带过来了但有
442
+ * 东西被跳过"(`mcpm-import` 的「这些密钥已是打码占位符,请在编辑里重新填写」就是其中
443
+ * 最重要的一条)。此前只取 imported/skipped,于是"迁移成功"的界面下藏着一份**需要用户
444
+ * 动手补的密钥清单**,用户在另一台机器上等到调用工具时才发现。
445
+ */
389
446
  async function run(domain, task) {
390
447
  if (domains.indexOf(domain) < 0)
391
448
  return;
392
449
  try {
393
450
  const r = await task();
394
- report.push({ domain, imported: r.imported, skipped: r.skipped });
451
+ report.push({
452
+ domain, imported: r.imported, skipped: r.skipped,
453
+ ...(r.warning ? { warnings: [r.warning] } : {}),
454
+ });
395
455
  }
396
456
  catch (e) {
397
457
  report.push({ domain, imported: 0, skipped: 0, error: deps.message(e) });
@@ -404,7 +464,12 @@ export function createSnapshotOps(deps) {
404
464
  const res = await deps.invokeOp('mcpm-import', overwrite ? { json: raw, conflict: 'overwrite' } : { json: raw });
405
465
  if (!res || res.ok === false)
406
466
  throw new Error(String((res && res.error) || 'mcpm-import 失败'));
407
- return { imported: (res.added || []).length + (res.overwritten || []).length, skipped: (res.skipped || []).length };
467
+ return {
468
+ imported: (res.added || []).length + (res.overwritten || []).length,
469
+ skipped: (res.skipped || []).length,
470
+ // 「打码占位符被跳过」这类结论必须跟着走:它是用户要动手补的清单,不是内部细节。
471
+ ...(typeof res.warning === 'string' && res.warning ? { warning: res.warning } : {}),
472
+ };
408
473
  });
409
474
  await run(D_SUBAGENTS, async () => {
410
475
  const zip = files[D_SUBAGENTS + '.zip'];
@@ -499,6 +564,7 @@ export function createSnapshotOps(deps) {
499
564
  (byScene[scene] || (byScene[scene] = [])).push({ name, data: Buffer.from(bytes).toString('base64') });
500
565
  }
501
566
  let imported = 0;
567
+ const reasons = [];
502
568
  for (const scene of Object.keys(byScene)) {
503
569
  const res = await deps.invokeOp('rules-import', { scene, files: byScene[scene] });
504
570
  if (!res || res.ok === false) {
@@ -507,9 +573,19 @@ export function createSnapshotOps(deps) {
507
573
  }
508
574
  const data = res.data || res;
509
575
  imported += (data.imported || []).length;
510
- skipped += (data.skipped || []).length;
576
+ const skippedRows = Array.isArray(data.skipped) ? data.skipped : [];
577
+ skipped += skippedRows.length;
578
+ // `rules-import` 的 skipped 是 `{name, reason}` —— 只数个数会把「为什么没过来」
579
+ // 丢掉,而那正是用户要照着修的东西(名字不合法 / 正文超限 / 附件重名)。
580
+ // 上限 5 条:这是提示不是日志,界面上一行放不下更多(审查 P2-9 同一条口径)。
581
+ for (const row of skippedRows.slice(0, 5)) {
582
+ const name = String((row && row.name) || '');
583
+ const reason = String((row && row.reason) || '');
584
+ if (reason)
585
+ reasons.push(`${name ? name + ':' : ''}${reason}`);
586
+ }
511
587
  }
512
- return { imported, skipped };
588
+ return { imported, skipped, ...(reasons.length ? { warning: reasons.join(';') } : {}) };
513
589
  });
514
590
  await run('scenes', async () => {
515
591
  if (!files[F_SCENES])
@@ -577,9 +653,26 @@ export function createSnapshotOps(deps) {
577
653
  let imported = 0;
578
654
  let skipped = 0;
579
655
  for (const zip of zips) {
580
- const res = await deps.invokeOp('skill-import', overwrite ? { source: zip, conflict: 'overwrite' } : { source: zip });
656
+ // 走 `skill-upload`(把 zip 读成 base64)而**不是** `skill-import`(2026-09-30 审查 P1-6):
657
+ // 导出侧产出的是 `bundle-export` 打的**一个 zip**(内含 `<技能名>/<文件>`),而
658
+ // `skill-import` 的 `analyzeSource` 用 `lstat` + `endsWith('.md')` 判定来源,**完全不认识
659
+ // `.zip`** —— 喂过去必然 `error.source.unrecognized`,该域整行报错。zip 的解包只存在于
660
+ // `skill-upload` 这条链(`importUploadedSkill` → `prepareUploadedSource` → `writeUploadedZip`);
661
+ // 已实测它能正确吃下这种形态(`<技能名>/SKILL.md` → 落 `hub/skills/<技能名>/`)。
662
+ let b64;
663
+ try {
664
+ b64 = (await readFile(zip)).toString('base64');
665
+ }
666
+ catch (e) {
667
+ throw new Error(`读取技能包失败:${deps.message(e)}`);
668
+ }
669
+ const res = await deps.invokeOp('skill-upload', {
670
+ name: basename(zip).replace(/\.zip$/i, ''),
671
+ zip: b64,
672
+ ...(overwrite ? { conflict: 'overwrite' } : {}),
673
+ });
581
674
  if (!res || res.ok === false)
582
- throw new Error(String((res && res.error) || 'skill-import 失败'));
675
+ throw new Error(String((res && res.error) || 'skill-upload 失败'));
583
676
  const data = res.data || {};
584
677
  imported += (data.imported || []).length;
585
678
  skipped += (data.skipped || []).length + (data.failed || []).length;
package/lib/ops/trash.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 删除进回收站、从回收站恢复、回收站列出与彻底删除。与记忆正文 ops 分域,
4
4
  // 但共用 copyIntoMemoriesTrash 等入口,经 MemoriesOpsCtx 显式传入。
5
- import { copyFile, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
5
+ import { copyFile, cp, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
7
  import { isValidTrashId } from '../hub.js';
8
8
  import { LEGACY_BUNDLE_DOC, bundleDocName, isValidGroupSegment, isValidGroupPath, fail, MEMORIES_TRASH_DIR } from '../memories/constants.js';
@@ -45,7 +45,14 @@ export function buildTrashOps(rc) {
45
45
  catch {
46
46
  return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
47
47
  }
48
- if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string' || !isValidGroupSegment(manifest.name)) {
48
+ if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string'
49
+ || !isValidGroupSegment(manifest.name)
50
+ // `group` 是**磁盘数据**(manifest.json 可能被手改 / 别的进程写 / 随一份恶意档案包进来),
51
+ // 而它是拼进 `join(memoriesRoot, group)` 的那一段 —— 只判类型的话 `../..` 就能把正文写到
52
+ // 记忆根之外(`mkdir({recursive:true})` 还会顺手把目录建出来)。同文件 `rulesTrashList`
53
+ // 一直是有这道判据的(`if (group !== '' && !isValidGroupPath(group)) continue`),
54
+ // 两条路径口径必须一致(审查 P0-3)。
55
+ || (manifest.group !== '' && !isValidGroupPath(manifest.group))) {
49
56
  return fail('error.rules.notFound', `回收站条目损坏:${trashId}`);
50
57
  }
51
58
  // 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
@@ -54,14 +61,43 @@ export function buildTrashOps(rc) {
54
61
  return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
55
62
  }
56
63
  const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
57
- if (form === 'bundle') {
58
- await mkdir(join(memoriesRoot, manifest.group), { recursive: true });
59
- const { cp } = await import('node:fs/promises');
60
- await cp(join(trashDir, 'bundle'), join(memoriesRoot, manifest.group, manifest.name), { recursive: true });
64
+ // 落点根内断言(realpath 口径):`isValidGroupPath` 只挡 `..` 与分隔符,挡不住
65
+ // 「中间目录是指向根外的链接」。与 `rulesRemove` 的删除前断言同一道闸。
66
+ const groupDir = join(memoriesRoot, manifest.group);
67
+ const denied = await refuseOutsideRoot(groupDir);
68
+ if (denied)
69
+ return denied;
70
+ // 恢复 = 先复制、后删回收站条目。**复制中途失败必须回滚**(2026-09-30 审查 F11):
71
+ // 原来这里 `cp` / `copyFile` 都没有 try/catch,失败时目标留下半份内容、回收站条目仍在,
72
+ // 于是下一次点恢复会撞上面那条「同名记忆已存在」—— 这条记忆**永远恢复不了**,只能人工
73
+ // 去删目录。场景回收站(`sceneTrashRestore`)早有这条回滚(`moveIntoTrash` 反向搬回),
74
+ // 记忆回收站这条缺了。这里方向相反:源还在回收站,所以回滚是**删掉没放完整的副本**。
75
+ const target = form === 'bundle'
76
+ ? join(groupDir, manifest.name)
77
+ : join(groupDir, manifest.name + '.md');
78
+ try {
79
+ await mkdir(groupDir, { recursive: true });
80
+ if (form === 'bundle')
81
+ await cp(join(trashDir, 'bundle'), target, { recursive: true });
82
+ else
83
+ await copyFile(join(trashDir, 'rule.md'), target);
61
84
  }
62
- else {
63
- await mkdir(join(memoriesRoot, manifest.group), { recursive: true });
64
- await copyFile(join(trashDir, 'rule.md'), join(memoriesRoot, manifest.group, manifest.name + '.md'));
85
+ catch (e) {
86
+ let cleaned = true;
87
+ try {
88
+ await rm(target, { recursive: true, force: true });
89
+ }
90
+ catch (cleanupError) {
91
+ // 落点**本来就没建成**时(父路径不是目录 → ENOTDIR;`force` 已吞掉 ENOENT),
92
+ // 没有残留要清,别把它报成「清理失败」——那会让用户去手工删一个不存在的路径。
93
+ const code = cleanupError?.code;
94
+ cleaned = code === 'ENOENT' || code === 'ENOTDIR';
95
+ }
96
+ // 清理失败也如实说清楚,别让用户以为还能原样重试。
97
+ const tail = cleaned
98
+ ? '(未放回完整的落点已清理,回收站条目仍在,可直接重试)'
99
+ : `(未放回完整的落点没能清理:${target},请先手工删除再重试)`;
100
+ return fail('error.rules.ioFailed', `恢复记忆失败:${String(e?.message ?? e)}${tail}`);
65
101
  }
66
102
  await rm(trashDir, { recursive: true, force: true });
67
103
  invalidateSnapshot();
@@ -17,6 +17,11 @@ import { join } from 'node:path';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { isValidPresetId, LAST_APPLIED_PRESET_ID, normalizePresetId } from './preset-id.js';
19
19
  import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
20
+ // 全局 AGENTS.md 的落盘走**原子写**(temp + rename,见 `memories/index-io.ts`)。
21
+ // 为什么非它不可(2026-09-30 审查 P2-2):这个文件是宿主**每个 agent/pre-step 都会 stat 并
22
+ // 重读**的,裸 `writeFile` 在中断/断电时会留下**半截内容** —— 而半截的提示词会原样进模型
23
+ // 上下文(截断点还可能在代码块中间)。原子写保证读到的是「要么旧、要么新」的完整内容。
24
+ import { writeFileAtomically } from '../memories/index-io.js';
20
25
  const FILENAME = 'AGENTS.md';
21
26
  /** 描述侧车:与 AGENTS.md 同目录,避免描述的文字被当成提示词正文注入。 */
22
27
  const META_FILE = 'meta.json';
@@ -321,7 +326,7 @@ export function createPromptsService(_ctx, deps) {
321
326
  }
322
327
  const prev = await readGlobal();
323
328
  const backedUp = await backupGlobal(prev);
324
- await writeFile(await deps.getGlobalAgentsMdPath(), content, 'utf8');
329
+ await writeFileAtomically(await deps.getGlobalAgentsMdPath(), content);
325
330
  await writeAppliedId(safeId);
326
331
  return { ok: true, id: safeId, backedUp };
327
332
  }
@@ -335,7 +340,7 @@ export function createPromptsService(_ctx, deps) {
335
340
  const prev = await readGlobal();
336
341
  const backedUp = await backupGlobal(prev);
337
342
  try {
338
- await writeFile(await deps.getGlobalAgentsMdPath(), text, 'utf8');
343
+ await writeFileAtomically(await deps.getGlobalAgentsMdPath(), text);
339
344
  }
340
345
  catch (e) {
341
346
  return { ok: false, error: '写入全局 AGENTS.md 失败:' + message(e) };
@@ -4,18 +4,19 @@
4
4
  // * 访问令牌(可选)—— 两条用途:宿主浏览器鉴权的逃生口,以及写操作的纵深防御;
5
5
  // * 写 / 敏感 op 白名单 —— 哪些 op 要凭令牌(含"只读但会泄露明文"与"只读但会改宿主状态");
6
6
  // 清单本身在 `./op-registry.ts`,本文件只拼装(`opsWith` / `frozenOps`)。
7
- // * 场景锁定 —— 任一场景 locked=true 时五个管理域整体冻结;
7
+ // * 场景锁定 —— 任一场景 locked=true 时五个管理域整体冻结。判据是 `createFrozenGate`,
8
+ // HTTP handlers 与**模型侧 op 表**各装一次,用的是同一份函数;
8
9
  // * handlers 后处理 —— 把上面三条装到 op 表上(包装顺序即语义,见 installHandlerGuards)。
9
10
  //
10
11
  // 为什么抽出来:四段分散在 apply() 的 220 / 902 / 1650 / 1928,靠闭包共享 handlers 与
11
12
  // memoriesService。抽成工厂后依赖改为显式入参,index.ts 只剩四行装配 —— 而引用点用解构
12
13
  // 保持原名字,一行没动(改名才是这类搬迁最容易出静默错误的地方)。
13
14
  //
14
- // **一处必须守住的不变量**:handlers 的后处理顺序。`guardLockedOps` / `syncSceneArchiveOnSwitch`
15
+ // **一处必须守住的不变量**:handlers 的后处理顺序。冻结包装 / `syncSceneArchiveOnSwitch`
15
16
  // 都是「读原函数 → 换包装」的原地改写,先装的在内层、后装的在外层;`annotateLocked` 只挂在
16
17
  // 五个读 op 上。整段照搬、顺序未变。
17
18
  import { createHash, timingSafeEqual } from 'node:crypto';
18
- import { frozenOps, opsWith } from './op-registry.js';
19
+ import { OP_REGISTRY, frozenOps, opsWith } from './op-registry.js';
19
20
  /**
20
21
  * 建访问令牌三件套。**必须**从 apply 的第二个参数取配置 ——
21
22
  * 读 `ctx.config` 不是注入服务,启动时会抛 "cannot get property without inject"。
@@ -114,10 +115,13 @@ export function createOpWhitelist(deps) {
114
115
  }
115
116
  // ---------- 场景锁定守卫(v0.8)----------
116
117
  // 任一场景 locked=true = 五个管理域(MCP/技能/子智能体/记忆/提示词)整体冻结:
117
- // 下面列出的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
118
- // 只包 **handlers** 这一层是有意的:进/退模式的运行时应用走的是内部函数与
119
- // service.ops(applyMcpServerSwitches / applySkills / applySubagentSwitches / patchIndex),
120
- // 不经过 handlers —— 锁定就是为了让场景能按原样启动,运行时应用不能被自己挡住。
118
+ // 冻结清单里的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
119
+ //
120
+ // 装在**哪一层**:HTTP handlers 一层 + 模型侧 op 表一层(index.ts 的 `auditOps`),
121
+ // 两层用同一个判据(`createFrozenGate`)。**不装在 service.ops 上**是有意的:进/退模式的
122
+ // 运行时应用走的是内部函数与 service.ops(applyMcpServerSwitches / applySkills /
123
+ // applySubagentSwitches / patchIndex),不经过 handlers —— 锁定就是为了让场景能按原样启动,
124
+ // 运行时应用不能被自己挡住。
121
125
  // 例外里的例外:scene-archive-save / rules-remove-scene 只对**被锁的那个场景**拒绝。
122
126
  export function createSceneLock(deps) {
123
127
  async function lockedSceneNames() {
@@ -144,12 +148,104 @@ export function createSceneLock(deps) {
144
148
  }
145
149
  return { lockedSceneNames, activeSceneName, lockedSceneGuard };
146
150
  }
151
+ /** 「改它」的兜底动词(登记表里没有专门文案时用)。 */
152
+ const FROZEN_DEFAULT_VERB = '修改';
153
+ const ACTIVE_SCENE_LABEL = { 'rules-set-active': '切换场景' };
154
+ const SELF_SCENE_LABEL = { 'scene-archive-save': '改档案', 'rules-remove-scene': '删除' };
155
+ /**
156
+ * 场景冻结的**唯一判据**:`op` + `args` → 拒绝文案(null = 放行)。
157
+ *
158
+ * 三种口径的语义登记在 `./op-registry.ts` 的 `frozenScope` 上,这里只实现:
159
+ * all —— 只要有场景锁着就拒(不看 args)。
160
+ * active-scene —— 只在"锁着的场景正在生效"且这次提交**改了启用集合**时拒
161
+ * (同集合的重复提交放行:它没改变任何东西,不该被"冻结"挡下来)。
162
+ * self-scene —— 只挡被锁的那个场景自己(目标从 `args.scene` / `args.name` 取)。
163
+ *
164
+ * 为什么抽成一个函数:HTTP handlers 与**模型侧 op 表**(index.ts 的 `auditOps`)必须用
165
+ * 同一份判据。此前只包 handlers —— 模型工具拿的是 `service.ops`,于是"锁一个**非活动**
166
+ * 场景"在 HTTP 上被拒、在模型侧五个域全放行(审查 F3,0.17.0 修)。判据只写一遍,
167
+ * 两侧就不可能再分家。
168
+ *
169
+ * 判据只读 `args`、不落盘,所以对同一次调用可以安全地判两次(handlers 与模型侧各一次)。
170
+ */
171
+ export function createFrozenGate(deps) {
172
+ const { lockedSceneNames, activeSceneName } = deps;
173
+ return async function frozenRejection(op, args) {
174
+ const cls = OP_REGISTRY[op];
175
+ if (!cls || cls.frozen !== true)
176
+ return null;
177
+ const scope = cls.frozenScope ?? 'all';
178
+ if (scope === 'active-scene') {
179
+ const scene = await activeSceneName();
180
+ if (!scene || !(await lockedSceneNames()).includes(scene))
181
+ return null;
182
+ const current = new Set([scene]);
183
+ const next = new Set((Array.isArray(args && args.scenes) ? args.scenes : [])
184
+ .map((n) => String(n == null ? '' : n).trim())
185
+ .filter((n) => n !== ''));
186
+ const same = current.size === next.size && [...current].every((n) => next.has(n));
187
+ if (same)
188
+ return null;
189
+ const what = ACTIVE_SCENE_LABEL[op] ?? '改它';
190
+ return `场景「${scene}」已锁定:先到场景页解锁再${what}(锁定期间改启用集合会让模型侧的写门禁失效)`;
191
+ }
192
+ if (scope === 'self-scene') {
193
+ const scene = String((args && (args.scene || args.name)) || '').trim();
194
+ if (!scene)
195
+ return null;
196
+ if (!(await lockedSceneNames()).includes(scene))
197
+ return null;
198
+ return `场景「${scene}」已锁定:先解锁再${SELF_SCENE_LABEL[op] ?? '改它'}`;
199
+ }
200
+ const locked = await lockedSceneNames();
201
+ return locked.length ? `场景已锁定(${locked.join('、')}):先到场景页解锁再${FROZEN_DEFAULT_VERB}` : null;
202
+ };
203
+ }
204
+ // ── 3.6 模型侧 op 表的冻结闸(与 handlers 同一份判据)────────────────────────
205
+ /**
206
+ * 给**模型侧**的 op 表装冻结闸(审查 F3,0.17.0)。
207
+ *
208
+ * 为什么抽成一个函数、而不是在 index.ts 里手写 for 循环:模型侧与 HTTP 侧必须用**同一个**
209
+ * `frozen` 判据对象。写成两个循环、各自去 `createFrozenGate`,就给了"两边又分家"的机会 ——
210
+ * 而 F3 的成因正是两侧判据不同。抽出来之后,契约测试可以直接拿真实的 `createFrozenGate`
211
+ * 与真实的 `installHandlerGuards` 对同一个 op 比对两侧结论。
212
+ *
213
+ * **不改动传入的表**:返回新表。场景引擎直调的是原表(`applySkills` → `service.ops`),
214
+ * 装在原表上会把场景自己锁死。
215
+ *
216
+ * @param ops 原始 op 表
217
+ * @param frozen `createFrozenGate(...)` 的返回值
218
+ * @param after 成功后的回调(挂流水用)。被拒时**不调用** —— 什么都没改。
219
+ */
220
+ export function guardModelOps(ops, frozen, after) {
221
+ const out = {};
222
+ for (const [opName, fn] of Object.entries(ops)) {
223
+ if (typeof fn !== 'function') {
224
+ out[opName] = fn;
225
+ continue;
226
+ }
227
+ out[opName] = async (args) => {
228
+ const denied = await frozen(opName, args || {});
229
+ if (denied)
230
+ return { ok: false, error: denied };
231
+ const result = await fn(args || {});
232
+ if (after)
233
+ after(opName, args || {}, result);
234
+ return result;
235
+ };
236
+ }
237
+ return out;
238
+ }
239
+ /** 单个 op 版本(`guardModelOps` 的一元特例):给不经 op 表的调用点用。 */
240
+ export function guardModelOp(opName, fn, frozen, after) {
241
+ return guardModelOps({ [opName]: fn }, frozen, after)[opName];
242
+ }
147
243
  export function installHandlerGuards(deps) {
148
244
  const { handlers, lockedSceneNames, activeSceneName, syncSwitchToScene, subagentToolFailures } = deps;
149
245
  /**
150
246
  * 包一层:开关成功后把改动同步进当前场景档案(见 syncSwitchToScene)。
151
247
  * 失败只挂 `sceneSyncError`,不改写原操作的成功结论 —— 运行时确实改了,档案没跟上要说得清。
152
- * 锁定仍由 `guardLockedOps` 挡住(更靠内的那层包装先执行)。
248
+ * 锁定由更靠内的冻结包装挡住(先装的在内层,它返回 `ok:false` 时这里不会去同步档案)。
153
249
  */
154
250
  function syncSceneArchiveOnSwitch(opNames) {
155
251
  for (const opName of opNames) {
@@ -165,64 +261,27 @@ export function installHandlerGuards(deps) {
165
261
  };
166
262
  }
167
263
  }
168
- function guardLockedOps(opNames, what) {
169
- for (const opName of opNames) {
264
+ // 冻结清单与档案同步清单都来自 `./op-registry.ts`:加 op 时在那里登记一条,这里不再抄。
265
+ // 三种口径(all / active-scene / self-scene)现在合成**一个**判据函数(`createFrozenGate`),
266
+ // 由 `frozenOps()`(不带 scope = 全部冻结 op)一次装完。原先它们分散成三段手写包装,
267
+ // 而模型侧 op 表压根没装 —— 这正是 F3 的成因(见 `createFrozenGate` 的注释)。
268
+ // 包装顺序不变:冻结仍在**内层**,`syncSceneArchiveOnSwitch` 在外层(前者返回 ok:false 时
269
+ // 后者不会去同步档案)。
270
+ {
271
+ const frozen = createFrozenGate({ lockedSceneNames, activeSceneName });
272
+ for (const opName of frozenOps()) {
170
273
  const original = handlers[opName];
171
274
  if (typeof original !== 'function')
172
275
  continue;
173
276
  handlers[opName] = async (args) => {
174
- const locked = await lockedSceneNames();
175
- if (locked.length)
176
- return { ok: false, error: `场景已锁定(${locked.join('、')}):先到场景页解锁再${what}` };
177
- return original(args);
277
+ const denied = await frozen(opName, args);
278
+ return denied ? { ok: false, error: denied } : original(args);
178
279
  };
179
280
  }
180
281
  }
181
- // 冻结清单与档案同步清单都来自 `./op-registry.ts`:加 op 时在那里登记一条,这里不再抄。
182
- guardLockedOps(frozenOps('all'), '修改');
183
282
  // 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
184
283
  // 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
185
284
  syncSceneArchiveOnSwitch(opsWith('syncsArchive'));
186
- // 口径 `frozenScope: 'active-scene'` 的那几条(判据与理由登记在 op-registry.ts 的
187
- // `rules-set-active` 条目上)。没有锁定场景在生效时(全局态 / 场景未锁)照旧可用。
188
- const ACTIVE_SCENE_LABEL = { 'rules-set-active': '切换场景' };
189
- for (const opName of frozenOps('active-scene')) {
190
- const what = ACTIVE_SCENE_LABEL[opName] ?? '改它';
191
- const original = handlers[opName];
192
- if (typeof original !== 'function')
193
- continue;
194
- handlers[opName] = async (args) => {
195
- const scene = await activeSceneName();
196
- if (scene && (await lockedSceneNames()).includes(scene)) {
197
- // 同集合的重复提交放行:它没有改变任何东西,不该被"冻结"挡下来。
198
- const current = new Set([scene]);
199
- const next = new Set((Array.isArray(args && args.scenes) ? args.scenes : [])
200
- .map((n) => String(n == null ? '' : n).trim())
201
- .filter((n) => n !== ''));
202
- const same = current.size === next.size && [...current].every((n) => next.has(n));
203
- if (!same) {
204
- return { ok: false, error: `场景「${scene}」已锁定:先到场景页解锁再${what}(锁定期间改启用集合会让模型侧的写门禁失效)` };
205
- }
206
- }
207
- return original(args);
208
- };
209
- }
210
- // 被锁场景自身的档案与删除:只挡它自己,别的场景照常(口径 `frozenScope: 'self-scene'`)。
211
- // 目标场景一律从 `args.scene` / `args.name` 里取 —— 这两个键名就是本插件 op 的惯例。
212
- const SELF_SCENE_LABEL = { 'scene-archive-save': '改档案', 'rules-remove-scene': '删除' };
213
- for (const opName of frozenOps('self-scene')) {
214
- const what = SELF_SCENE_LABEL[opName] ?? '改它';
215
- const original = handlers[opName];
216
- if (typeof original !== 'function')
217
- continue;
218
- handlers[opName] = async (args) => {
219
- const scene = String((args && (args.scene || args.name)) || '').trim();
220
- const locked = await lockedSceneNames();
221
- if (scene && locked.includes(scene))
222
- return { ok: false, error: `场景「${scene}」已锁定:先解锁再${what}` };
223
- return original(args);
224
- };
225
- }
226
285
  // 各页列表响应带上 anyLocked + activeScene:界面据此禁用写控件、并说明
227
286
  // 「当前处于场景 X,开关请到档案里改」(读 op,附加字段不影响既有消费方)。
228
287
  async function annotateLocked(res) {
@@ -24,8 +24,11 @@
24
24
  // `driver()` —— 谁在驱动基线(提示词页「应用」据此判断:应用别的份 = 换掉该场景的绑定);
25
25
  // `refs()` —— 每个预设被谁引用(删除保护的唯一依据:场景绑定 / 基线当前内容 / 退出恢复目标);
26
26
  // `baseline()` —— 进场景前的基线快照(退出场景后要恢复的那一份)。
27
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
27
+ import { mkdir, readFile, stat } from 'node:fs/promises';
28
28
  import { dirname } from 'node:path';
29
+ // 基线快照走原子写(temp + rename):它是「退出场景时恢复 AGENTS.md」的**唯一依据**,
30
+ // 裸写中断留下半截 JSON 会让 `readBaseline` 解析失败(审查 P2-16)。
31
+ import { writeFileAtomically } from './memories/index-io.js';
29
32
  export function createScenePromptSync(deps) {
30
33
  const warn = (message) => { try {
31
34
  deps.logger?.warn?.(message);
@@ -48,13 +51,35 @@ export function createScenePromptSync(deps) {
48
51
  return null;
49
52
  }
50
53
  }
54
+ /** 快照文件是否**存在**(不看内容)。用来把「没有快照」与「快照读不出来」分开。 */
55
+ async function baselineExists() {
56
+ try {
57
+ return (await stat(deps.baselineFile)).isFile();
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ /**
64
+ * 写基线快照。**失败必须让调用方看得见**(2026-09-30 审查 F14)。
65
+ *
66
+ * 原来这里 catch 后只 `warn` 并返回 void,于是「快照没写成」与「写成了」在调用方眼里
67
+ * 完全一样 —— 结果就是:AGENTS.md 被切到场景态、而退出场景时恢复不回来,用户看到的是
68
+ * 「没场景驱动所以什么都没做」。这正是「看起来成功」那类失败。
69
+ * @returns 失败原因(原样回给调用方拼进提示);成功返回 null。
70
+ */
51
71
  async function writeBaseline(next) {
52
72
  try {
53
73
  await mkdir(dirname(deps.baselineFile), { recursive: true });
54
- await writeFile(deps.baselineFile, JSON.stringify(next), 'utf8');
74
+ // 原子写(2026-09-30 审查 P2-16):裸写在中断/断电时会留下半截 JSON → `readBaseline`
75
+ // 解析失败返回 null → 退出场景**不恢复** AGENTS.md,用户的提示词基线悄悄留在场景态。
76
+ await writeFileAtomically(deps.baselineFile, JSON.stringify(next));
77
+ return null;
55
78
  }
56
79
  catch (e) {
57
- warn(`scene-prompt: could not persist baseline: ${String(e)}`);
80
+ const reason = String(e);
81
+ warn(`scene-prompt: could not persist baseline: ${reason}`);
82
+ return reason;
58
83
  }
59
84
  }
60
85
  async function state() {
@@ -157,25 +182,39 @@ export function createScenePromptSync(deps) {
157
182
  return { applied: current.presetId, unchanged: true };
158
183
  // 进场景的第一笔写入之前,先记下当时的基线(已有快照就不覆盖:连续切场景时
159
184
  // 基线始终是"进场景之前"那一份)。
185
+ let baselineError;
160
186
  const saved = await readBaseline();
161
187
  if (!saved || saved.content === null) {
162
188
  const cur = await deps.prompts.getCurrent();
163
- await writeBaseline({
189
+ const failed = await writeBaseline({
164
190
  v: 1,
165
191
  at: Date.now(),
166
192
  presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
167
193
  content: cur && cur.ok && cur.exists ? String(cur.content) : null,
168
194
  });
195
+ // 基线写不进去 = 退出场景时恢复不回来。**照旧进场景**(那是用户的要求),
196
+ // 但必须当场说出来,别让用户以为还能退回去(审查 F14)。
197
+ if (failed !== null) {
198
+ baselineError = `进场景前的提示词基线没能写入(${failed}):AGENTS.md 已切到场景「${current.scene}」绑定的预设,但退出场景时恢复不回原来的内容(${deps.baselineFile})。`;
199
+ }
169
200
  }
170
201
  const res = await deps.prompts.apply(current.presetId);
171
202
  if (res && res.ok === false)
172
- return { error: String(res.error || '写入 AGENTS.md 失败') };
173
- return { applied: current.presetId };
203
+ return { error: String(res.error || '写入 AGENTS.md 失败'), ...(baselineError ? { baselineError } : {}) };
204
+ return { applied: current.presetId, ...(baselineError ? { baselineError } : {}) };
174
205
  }
175
206
  // 没有场景驱动 → 如果刚从驱动态退出,把进场景前的基线写回去。
176
207
  const saved = await readBaseline();
177
- if (!saved || saved.content === null)
208
+ if (!saved || saved.content === null) {
209
+ // 「没有快照」是常态(没进过场景);「快照文件在、但读不出来」是**故障** ——
210
+ // 那意味着恢复目标丢了,AGENTS.md 的提示词基线会悄悄留在场景态。这里曾经一律
211
+ // 静默 `unchanged`,用户看到的是"没场景驱动所以什么都没做"(审查 P2-16)。
212
+ if (await baselineExists()) {
213
+ warn(`scene-prompt: baseline snapshot exists but is unreadable: ${deps.baselineFile}`);
214
+ return { error: `基线快照读不出来(文件可能损坏),未能恢复 AGENTS.md:${deps.baselineFile}` };
215
+ }
178
216
  return { unchanged: true };
217
+ }
179
218
  const res = await deps.prompts.restore(saved.content);
180
219
  if (res && res.ok === false)
181
220
  return { error: String(res.error || '恢复 AGENTS.md 失败') };