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
@@ -215,8 +215,18 @@ export function normalizeInjectSettings(raw) {
215
215
  * —— 官方自己会送,再注入一遍只会重复;预设事实读不到(`undefined`)时按"已承载"处理
216
216
  * (宁可与现状一致,也不制造重复);
217
217
  * - 同一段文本出现两次只保留前一个(跨域保险)。
218
+ *
219
+ * `onError`(2026-09-30 审查 P1-8):某个域的 `text()` **取数失败**时回调一次,并跳过该域。
220
+ * 为什么不能像原来那样静默吞成空串:调用方据此判定"该域这一轮没内容",而下游还有一条
221
+ * 「上一轮可见、这一轮没内容 → 发一条『已清空』」的通知 —— 于是一次取数失败会让模型收到
222
+ * **假的"已清空"**,据此认为那份内容已失效,下一轮内容恢复后又重发一次。
223
+ *
224
+ * ⚠️ 这条通道**只认抛异常**,所以域的 `text()` 实现必须把"读失败"与"真的空"分开:
225
+ * 返回空串一律被当成后者(2026-09-30 审查 F7 —— 修复第一版只覆盖了抛异常那一半,
226
+ * 而真实世界里 IO 失败走的是 `catch → return ''` 那条路)。`src/memories/service.ts`
227
+ * 的 `requireSceneMemory` / `sceneCatalog` / `readPresetTextSync` 已按此改。
218
228
  */
219
- export function selectInjections(domains, settings, facts, agent) {
229
+ export function selectInjections(domains, settings, facts, agent, onError) {
220
230
  const suppressing = facts !== undefined && facts.suppressing;
221
231
  if (suppressing && !settings.underSuppressingPresets)
222
232
  return [];
@@ -239,8 +249,13 @@ export function selectInjections(domains, settings, facts, agent) {
239
249
  try {
240
250
  text = String(domain.text(agent) ?? '');
241
251
  }
242
- catch {
243
- text = '';
252
+ catch (error) {
253
+ // 取数失败 ≠ 没有内容:交给调用方(上报 + 这一轮不发"已清空"),本域这一轮不发。
254
+ try {
255
+ onError?.(domain.key, error);
256
+ }
257
+ catch { /* 上报本身绝不能影响注入 */ }
258
+ continue;
244
259
  }
245
260
  if (text.trim() === '')
246
261
  continue;
@@ -264,7 +279,7 @@ export function selectInjections(domains, settings, facts, agent) {
264
279
  * 界面看到的是"未投递"(琥珀)而不是"已关闭"(灰)。这是 0.9.1 的既有行为,本次未改,
265
280
  * 改动它属于另一个话题("跟随预设"到底该报成故障还是报成选择)。
266
281
  */
267
- export function explainInjections(domains, settings, facts, agent) {
282
+ export function explainInjections(domains, settings, facts, agent, failed) {
268
283
  const reasons = {};
269
284
  for (const domain of domains) {
270
285
  if (settings.domains[domain.key] !== true) {
@@ -280,6 +295,13 @@ export function explainInjections(domains, settings, facts, agent) {
280
295
  reasons[domain.key] = 'official';
281
296
  continue;
282
297
  }
298
+ // 取数失败过 → 报 `error` 而不是 `empty`:两者在界面上的含义完全不同(一个是故障、
299
+ // 一个是"本来就没内容"),而原来这条路上永远只能看到后者(审查 P1-8)。`failed` 由调用方
300
+ // 从 `selectInjections` 的 `onError` 收集;**只认抛异常**,返回空串仍算 `empty`(F7)。
301
+ if (failed?.has(domain.key)) {
302
+ reasons[domain.key] = 'error';
303
+ continue;
304
+ }
283
305
  let text = '';
284
306
  try {
285
307
  text = String(domain.text(agent) ?? '');
@@ -635,7 +657,8 @@ export function createContextInjector(deps) {
635
657
  // 本插件发过又清空的,报「已清空」。
636
658
  ? (entry.official ? 'official' : entry.form === 'notice' ? 'cleared' : 'in-context')
637
659
  // 没在上下文里:用最近一步的原因解释;`sent`(该发)却没看到 = 压缩后还没补发。
638
- : reason === 'off' ? 'off' : reason === 'official' ? 'official' : reason === 'empty' ? 'empty' : reason === 'child' ? 'child' : 'absent';
660
+ // `error`(取数抛异常)单列 —— 它和「未投递」不是同一件事,混在一起会把故障读成延迟。
661
+ : reason === 'off' ? 'off' : reason === 'official' ? 'official' : reason === 'empty' ? 'empty' : reason === 'child' ? 'child' : reason === 'error' ? 'error' : 'absent';
639
662
  const text = (state === 'in-context' || state === 'official') && entry !== undefined ? entry.text : '';
640
663
  return {
641
664
  key,
@@ -731,6 +754,11 @@ export function createContextInjector(deps) {
731
754
  }
732
755
  catch { /* 门禁判定失败不能反过来卡住对话:当作放行 */ }
733
756
  const decision = await next();
757
+ // 本步的失败痕迹(2026-09-30,审查 P1-8):域取数抛异常 / 整段注入抛异常。
758
+ // 两者都必须是**用户看得见**的降级(兼容页 + 「注入实况」),不能只剩控制台一行日志 ——
759
+ // 这条通道整段停掉时,此前唯一的痕迹就是 `console.error`(脆弱性地图 F2)。
760
+ let injectionFailed = false;
761
+ let failedKeys = new Set();
734
762
  try {
735
763
  if (!decision || decision.kind === 'reject')
736
764
  return decision;
@@ -784,9 +812,23 @@ export function createContextInjector(deps) {
784
812
  suppressionVerdict(false, 0, suppressionCounter);
785
813
  }
786
814
  const facts = await deps.factsFor(agent);
787
- const sections = selectInjections(domains, settings, facts, agent);
815
+ // 域取数抛异常 → 记下来(本步不发它的「已清空」,实况报 error,兼容页留一条降级)。
816
+ failedKeys = new Set();
817
+ const sections = selectInjections(domains, settings, facts, agent, (key, error) => {
818
+ failedKeys.add(key);
819
+ const label = domains.find((domain) => domain.key === key)?.label ?? key;
820
+ noteRuntime({
821
+ id: 'context-injection-runtime',
822
+ label: '上下文注入通道',
823
+ kind: 'read',
824
+ fallback: 'inform-only',
825
+ detail: `注入域「${label}」这一轮取数失败(${String(error?.message || error)}):该域本轮没有发送任何内容,也不会误发一条「已清空」(保留上一轮内容不动),下一步会自动重试。`,
826
+ detailKey: 'context-injection-runtime.domain',
827
+ params: { domain: label },
828
+ });
829
+ });
788
830
  // 记录每个域这一步"为什么发 / 为什么不发",供「注入实况」解释状态(口径见 explainInjections)。
789
- lastReasons = explainInjections(domains, settings, facts, agent);
831
+ lastReasons = explainInjections(domains, settings, facts, agent, failedKeys);
790
832
  const visible = newestDomainTexts(agent, PLUGIN_KINDS);
791
833
  const additions = [];
792
834
  const appended = [];
@@ -819,11 +861,15 @@ export function createContextInjector(deps) {
819
861
  injectedKeys.add(section.key);
820
862
  }
821
863
  // 曾经注入过、这一轮没有内容的域 → 一条「已清空」;从没注入过的域什么都不用说。
864
+ // **取数失败的域不发「已清空」**:那条通知的字面意思是"这份内容已失效",而真实情况是
865
+ // "我们这一轮没取到" —— 发出去等于向模型谎报(审查 P1-8)。保留上一轮内容不动更安全。
822
866
  const labelOf = new Map(domains.map((domain) => [domain.key, domain.label]));
823
867
  const clearedKeys = new Set();
824
868
  for (const key of INJECT_DOMAIN_KEYS) {
825
869
  if (published.has(key))
826
870
  continue;
871
+ if (failedKeys.has(key))
872
+ continue;
827
873
  const previous = visible.get(key);
828
874
  if (previous === undefined)
829
875
  continue;
@@ -874,9 +920,27 @@ export function createContextInjector(deps) {
874
920
  }
875
921
  catch (error) {
876
922
  // 注入是尽力而为:任何异常都不能把这一步弄失败。
923
+ injectionFailed = true;
877
924
  log('context injection failed: ' + String((error && error.message) || error));
925
+ // 但"尽力而为"不等于"无声无息":这一步整段抛异常时,模型**一个域的内容都没收到**,
926
+ // 而此前唯一的痕迹是上面那行 console.error(审查 P1-8 / 脆弱性地图 F2)。
927
+ // 兼容页据此至少能看到一条降级 —— 否则用户只会觉得"模型怎么不知道我的配置"。
928
+ noteRuntime({
929
+ id: 'context-injection-runtime',
930
+ label: '上下文注入通道',
931
+ kind: 'read',
932
+ fallback: 'inform-only',
933
+ detail: `本步注入抛异常,这一轮全部注入域的内容都没有送进模型(${String((error && error.message) || error)})。通道仍在,下一步会自动重试。`,
934
+ detailKey: 'context-injection-runtime.throw',
935
+ });
878
936
  return decision;
879
937
  }
938
+ finally {
939
+ // 正常走完(含"没东西可发"的早退)→ 收掉上一次的上报,别让已经恢复的故障挂在兼容页上。
940
+ // 只在两个条件都成立时收:本步没抛异常、且本步没有取数失败的域。
941
+ if (!injectionFailed && failedKeys.size === 0)
942
+ clearRuntimeNote('context-injection-runtime');
943
+ }
880
944
  }, { prepend: true });
881
945
  return {
882
946
  dispose: () => {
@@ -887,6 +951,8 @@ export function createContextInjector(deps) {
887
951
  catch { /* ignore */ }
888
952
  // 注入通道没了,自检结论也失效(B4)——留着会让兼容页报一件不存在的事。
889
953
  clearRuntimeNote('official-suppression');
954
+ // 运行期上报同理:通道都不在了,"这一轮注入抛异常"没有意义(且不会再被刷新)。
955
+ clearRuntimeNote('context-injection-runtime');
890
956
  },
891
957
  live,
892
958
  noteToolUse,
package/lib/host-names.js CHANGED
@@ -6,6 +6,156 @@
6
6
  * 少改一处就是「开关点了没反应」或「列表凭空少一行」。档案目录名同理。
7
7
  */
8
8
  export const MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client';
9
+ /**
10
+ * MCP 工具在模型工具表里的**命名前缀**:`mcp__<serverName>__<toolName>`。
11
+ *
12
+ * 这是宿主侧(MCP client 注册工具时)定的字符串契约,插件里十余处按它拼、按它解析
13
+ * (生成侧 `disabledToolNames`、执行侧 `isToolDisabledIn`、列表侧 `liveNames`、工具计数)。
14
+ * 抽到这里是为了"官方改了前缀"只需改一处 —— 但**改一处不够**:前缀一漂,停用表 / 门禁 /
15
+ * 工具计数会一起**静默**失明(守卫以为"这工具不在我的表里"于是放行)。
16
+ *
17
+ * 前向兼容的诚实边界(2026-09-30 审查 §5 F6):这个契约**没有能力探测可做** ——
18
+ * `tools.schemas()` 只回 `name`/`description`(官方源码明确它「NEVER sent to the model」
19
+ * 的其余字段不暴露),拿不到"这条工具属于哪个模块",所以无法独立判定一条 schema 是不是
20
+ * MCP 工具。唯一的可用线索是"工具名以 `mcp` 开头但不是本前缀"(官方改前缀多半会保留
21
+ * `mcp` 词根),据此在 `mcpmListView` 里做一次 inform-only 上报;换成完全不同的词根就
22
+ * 只能靠宿主升级清单人工核对。真要靠探测兜住,得先有官方暴露工具归属的接口。
23
+ */
24
+ export const MCP_TOOL_PREFIX = 'mcp__';
25
+ /**
26
+ * 一个工具名算不算「MCP 前缀漂移」的**证据**:以 `mcp` 开头,但词根后面**不是** `_`。
27
+ *
28
+ * 为什么要这么窄(2026-09-30 审查 §5 F6 实现时的自查):本插件自己的工具就叫
29
+ * `mcp_manager_list` / `mcp_manager_…` —— 它们同样「以 mcp 开头但不是 `mcp__`」,把它们
30
+ * 算成证据会让这条上报在**每台机器上**误报(只要当前没有 MCP 服务器在跑)。而真正的前缀
31
+ * 漂移(`mcp:` / `mcp-` / `mcpX`)恰恰表现为「词根后面的分隔符变了」这一种形态。
32
+ * `mcp___` 这类改法会落进「没有证据」—— 宁可不报,也不给用户一个错的诊断。
33
+ */
34
+ export function looksLikeMcpPrefixDrift(name) {
35
+ return /^mcp[^_]/.test(String(name == null ? '' : name));
36
+ }
37
+ /**
38
+ * MCP 工具全名 `mcp__<server>__<tool>` 的**唯一切分口径**。
39
+ *
40
+ * 为什么必须只有一个:同一个全名在插件里有两处要还原成 `(server, tool)` —— 执行侧守卫
41
+ * (`mcp/manager.ts` 的 `isToolDisabledIn`,决定「这个工具还能不能调」)与显示侧
42
+ * (`index.ts` 的 `toolKeyParts`,决定界面上的勾选键 `server/tool`)。它们此前**各写一套规则**:
43
+ * 守卫取「最长已知 serverName 前缀」,显示侧取「最后一个 `__`」。规则不同 = 同一份数据两边
44
+ * 结论不同;而 `serverName` 的校验(`/^[A-Za-z0-9_-]{1,32}$/`)**允许含 `__`**,所以这个歧义
45
+ * 真的能踩到(2026-09-30 审查 N1)。现在两处都走本函数。
46
+ *
47
+ * **残余歧义(如实标注)**:`mcp__a__b__t` 既可能是「server=a, tool=b__t」也可能是
48
+ * 「server=a__b, tool=t」—— 光看全名无法判定,必须有候选 serverName 集合才谈得上「切对」。
49
+ * 所以集合是**必填参数**,且两个调用点的集合**天然不同**,这不是漏改:
50
+ *
51
+ * * 守卫问的是「这个工具被停用了吗」,权威依据就是**停用表**,集合 = 停用表的键。
52
+ * 把集合放宽到「全部已配置服务器」会**漏拦**:设 `a` 的 `b__c` 被停用、而 `a__b` 只是
53
+ * 配置了、没停用任何工具,全名 `mcp__a__b__c` 放宽后最长匹配取 `a__b`(tool 只剩 `c`)
54
+ * → 查 `a__b` 未命中 → 放行一个真的被停用的工具(fail-open)。歧义时「偏向停用表里有的
55
+ * 那个服务器」才是守卫该有的 fail-closed 方向。
56
+ * * 显示侧问的是「这个键该显示成哪个服务器下的哪个工具」,依据是**全部已知的真实服务器名**
57
+ * ——补丁文件里的 `serverName`(权威、与切分结果无关)+ 停用表键 + 已知工具缓存键,
58
+ * 集合 = 三者并集(见 `serverNameCandidates`)。补丁那份必须在内:另外两份都是插件自己
59
+ * 写出来的(勾选键 / 上次切分的产物),单靠它们会在「首次遇到含 `__` 的名字」时切错,
60
+ * 而错键一旦进集合就会**锁死**错误结论(不自愈)。
61
+ *
62
+ * 两者在「服务器名含 `__`」时仍可能给出不同答案 —— 这是 `__` 当分隔符的固有歧义,靠集合
63
+ * 无法根除;根除要从入口拒绝含 `__` 的 serverName(契约层收紧,本轮明确不做)。`certain`
64
+ * 就是给这件事留的出口。
65
+ *
66
+ * 候选里取**最长**的那个(短候选会把 tool 截断成 `b__c`)—— 真实服务器名比「按分隔符猜」可信,
67
+ * 这也是 0.16.7 那版守卫选「最长已知前缀」的同一个理由。
68
+ * @param fullName - 形如 `mcp__<server>__<tool>` 的工具全名。
69
+ * @param serverNames - 候选 serverName 集合(见上;传空集合等于只走兜底)。
70
+ * @returns 切分结果;不是 MCP 工具名、或切不出非空 server/tool 时返回 null。
71
+ */
72
+ export function splitMcpToolName(fullName, serverNames) {
73
+ const name = String(fullName == null ? '' : fullName);
74
+ if (!name.startsWith(MCP_TOOL_PREFIX))
75
+ return null;
76
+ const rest = name.slice(MCP_TOOL_PREFIX.length);
77
+ let matched = null;
78
+ if (serverNames) {
79
+ for (const candidate of serverNames) {
80
+ if (typeof candidate !== 'string' || candidate === '')
81
+ continue;
82
+ // tool 必须非空,否则 `mcp__a__` 会被切出空 tool
83
+ if (rest.length <= candidate.length + 2)
84
+ continue;
85
+ if (!rest.startsWith(candidate + '__'))
86
+ continue;
87
+ if (matched === null || candidate.length > matched.length)
88
+ matched = candidate;
89
+ }
90
+ }
91
+ if (matched !== null)
92
+ return { key: matched + '/' + rest.slice(matched.length + 2), server: matched, tool: rest.slice(matched.length + 2), certain: true };
93
+ // 一个候选都没命中 → 按最后一个 `__` 兜底(显示侧原先的口径),并如实标注「不确定」。
94
+ // 守卫侧不需要特别处理:猜出来的 server 不在停用表里,查表结果与「没有候选命中」一致。
95
+ const i = rest.lastIndexOf('__');
96
+ if (i <= 0)
97
+ return null;
98
+ const server = rest.slice(0, i);
99
+ const tool = rest.slice(i + 2);
100
+ return server && tool ? { key: server + '/' + tool, server, tool, certain: false } : null;
101
+ }
102
+ /**
103
+ * 显示侧切分的候选 serverName 集合:若干张「serverName → 任意」的表取键并集,
104
+ * 或直接给一份名字清单(`Iterable<string>`)。
105
+ *
106
+ * 为什么要这个函数:显示侧有多个调用点(`mcpmList` / `serverKnownTools` / 场景档案的
107
+ * `toolStates`),各自手写一遍 `Object.keys(...)` 迟早又会分叉 —— 而「集合不同」正是切分
108
+ * 结论不同的原因之一。集合的来源必须是**同一批**:补丁文件里的真实 serverName(唯一与切分
109
+ * 结果无关的来源)+ 停用表 + 已知工具缓存。前两者是插件自己写出来的,单靠它们会**循环论证**
110
+ * (见 `configuredServerNames` 的文档)。
111
+ */
112
+ export function serverNameCandidates(...sources) {
113
+ const out = new Set();
114
+ for (const source of sources) {
115
+ if (!source || typeof source !== 'object')
116
+ continue;
117
+ // 名字清单(数组 / Set)优先按可迭代处理;`Record` 不可迭代,走下面的键枚举。
118
+ if (typeof source[Symbol.iterator] === 'function') {
119
+ for (const name of source)
120
+ if (typeof name === 'string' && name)
121
+ out.add(name);
122
+ continue;
123
+ }
124
+ for (const name of Object.keys(source))
125
+ if (name)
126
+ out.add(name);
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * 候选 serverName 里有没有「`__` 说不清」的形状 —— N2 的判据(2026-09-30 审查)。
132
+ *
133
+ * 为什么要有它:`splitMcpToolName` 能在一批名字里选出**最长**的那个,但"选对"这件事本身
134
+ * 无法自证 —— 名字一旦含 `__`,「server 是 `a`、tool 是 `b__c`」与「server 是 `a__b`、tool 是
135
+ * `c`」在全名上完全同形。宿主自己的解析口径(取第一个 `__`)与展示口径(取最后一个)也不一致,
136
+ * 所以这不是"谁写错了",是分隔符选得不合适。既不能改(改 serverName 校验会把存量服务器从
137
+ * "能管"变成"管不了",见计划 §4 未决 1),那就**出声**:这两种形状一出现就上报,让用户知道
138
+ * 界面上的勾选可能落不到停用表里那条键上。
139
+ *
140
+ * **判据必须一条都不误报** —— 与 `looksLikeMcpPrefixDrift` 同一条纪律:没有这两种形状时
141
+ * 必须返回空,否则这条上报会变成每台机器都亮的噪声,等于没有。
142
+ * 名字不含 `__` 时,切分结果与"取第一个 `__`"、"取最后一个 `__`"三者一致,歧义不存在。
143
+ */
144
+ export function ambiguousServerNames(names) {
145
+ const all = [...new Set(names)].filter((n) => typeof n === 'string' && n !== '');
146
+ const withSeparator = all.filter((n) => n.includes('__')).sort();
147
+ const prefixPairs = [];
148
+ for (const short of all) {
149
+ for (const long of all) {
150
+ // `short` 是 `long` 的 `__` 前缀:`mcp__<short>__<long.slice(...)>` 与
151
+ // `mcp__<long>__<剩下的>` 同形。`all` 已去重,所以每对只会出现一次。
152
+ if (short !== long && long.startsWith(short + '__'))
153
+ prefixPairs.push([short, long]);
154
+ }
155
+ }
156
+ prefixPairs.sort((a, b) => (a[0] + ' ' + a[1] < b[0] + ' ' + b[1] ? -1 : a[0] + ' ' + a[1] > b[0] + ' ' + b[1] ? 1 : 0));
157
+ return { withSeparator, prefixPairs };
158
+ }
9
159
  /** `ensurePaths()` 的档案探测顺序(先 web 后 headless);都不在才退到「任意带 patch 的档案」。 */
10
160
  export const PROFILE_CANDIDATES = ['web', 'headless'];
11
161
  /** 探测不到任何档案时的兜底目录名(历史上的默认档案)。 */
package/lib/hub.js CHANGED
@@ -45,6 +45,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from '
45
45
  import { randomUUID } from 'node:crypto';
46
46
  import { basename, dirname, join } from 'node:path';
47
47
  import { resolveDshHome } from './skills/core.js';
48
+ import { isSameOrDescendant } from './paths.js';
48
49
  /** hub 根目录名(`$DSH_HOME` 下)。 */
49
50
  export const HUB_DIR = 'tool-management';
50
51
  /** hub 根绝对路径。 */
@@ -91,7 +92,10 @@ const ROOT_FILE_MOVES = [
91
92
  ];
92
93
  /** 宿主 patch 的备份名(`cordis.patch.yml.bak-<时间戳>`)。 */
93
94
  export function isPatchBackupName(name) {
94
- return /^cordis\.patch\.yml\.bak-\d{8}-\d{6}$/.test(basename(name));
95
+ // 两代时间戳都认(0.17.0 起加了毫秒与进程内序号,见 index.ts 的 backupStamp)。
96
+ // 这里只用于「把散落在 $DSH_HOME / profiles 下的旧备份搬进 hub/backups」—— 新格式备份
97
+ // 本来就直写 hub/backups,但两处谓词保持同一口径,免得将来手工搬进来一份就漏掉。
98
+ return /^cordis\.patch\.yml\.bak-\d{8}-\d{6}(?:\d{6})?$/.test(basename(name));
95
99
  }
96
100
  /**
97
101
  * 布局迁移(同步、幂等、只搬不删、绝不覆盖)。**必须在任何读盘之前调用一次**:
@@ -297,10 +301,21 @@ export async function moveToTrash(kind, name, moves, data) {
297
301
  const id = newTrashId();
298
302
  const dir = join(trashKindDir(kind), id);
299
303
  const done = [];
304
+ // 入口先过一遍谓词(原先只有恢复侧 `moveOutOfTrash` 校验,删除侧不看):`dest` 最终会写进
305
+ // manifest 的 `files[]`,恢复时按它搬回来 —— 入口放一个非法名进去,等于把失败推给
306
+ // "将来某一天点恢复"(场景回收站那条链就是这么坏掉的)。现在当场拒绝、什么都不搬,
307
+ // 由调用方如实报错(审查 P0-2 / P2-14)。
308
+ for (const move of moves) {
309
+ if (!isValidTrashPayloadPath(move.dest))
310
+ return { ok: false, error: `负载名非法,未搬动任何文件:${move.dest}` };
311
+ }
300
312
  try {
301
313
  await mkdir(dir, { recursive: true });
302
314
  for (const move of moves) {
303
315
  const to = join(dir, move.dest);
316
+ // 嵌套负载(`bundle1/bundle1.md`)的父目录不存在时 `rename` 必然失败 —— 那本来不是错,
317
+ // 但接着靠 `cp` 的隐式建父目录兜底是不可依赖的跨平台行为。显式建出来。
318
+ await mkdir(dirname(to), { recursive: true });
304
319
  try {
305
320
  await rename(move.from, to);
306
321
  }
@@ -309,6 +324,9 @@ export async function moveToTrash(kind, name, moves, data) {
309
324
  await cp(move.from, to, { recursive: true });
310
325
  await rm(move.from, { recursive: true, force: true });
311
326
  }
327
+ // 后置断言(与恢复侧同一道):谓词已挡掉 `..`,这里防的是"条目目录里恰好有个链接"。
328
+ if (!isSameOrDescendant(dir, to))
329
+ throw new Error(`负载越出条目目录:${move.dest}`);
312
330
  done.push({ from: move.from, to });
313
331
  }
314
332
  const manifest = {
@@ -372,20 +390,42 @@ export async function readTrashEntry(kind, id) {
372
390
  return await readManifest(kind, id);
373
391
  }
374
392
  /**
375
- * 条目里的一个负载名是不是"就在这个条目目录里"。
393
+ * 条目里的一个负载名是不是"一条**安全的相对路径**"(可以含 `/`)。
394
+ *
395
+ * 为什么允许 `/`(2026-09-30,审查 P0-2):场景回收站的负载**天然是目录树** —— 一个场景目录
396
+ * 里的记忆可能是 `<场景>/<名>.md`,也可能是 bundle 形态 `<场景>/<名>/<名>.md` 或任意子目录。
397
+ * 原来这里对任何含 `/` 的名字一律返回 false,于是「含嵌套记忆的场景」删除能成功(`moveToTrash`
398
+ * 侧靠 `cp` 兜底)、恢复**必然失败**,且失败后场景目录已非空 → 再也恢复不了。
399
+ *
400
+ * 判据(与 `paths.ts` 的单段校验同源,只是放宽到多段):
401
+ * - 只认 `/` 作分隔符(反斜杠在 Windows 上等价,留着只会让判据分叉);
402
+ * - 拒绝对路径(前导 `/` `\` 或盘符)与 `..` / `.` / 空段 —— 这是**路径穿越**那一类;
403
+ * - 每段拒绝控制字符、Windows 非法字符 `<>:"|?*`、首尾空白与尾点(Windows 会静默规整它们,
404
+ * 于是"写进去的名字"与"读出来的名字"不再是同一个);
405
+ * - 条目根下的 `manifest.json` 是清单,不许当负载。
376
406
  *
377
- * 为什么 id 与场景名都有谓词、这里还得多一道:`manifest.json` 的 `files[]` 是**磁盘上的数据**,
378
- * 它跟 id 不一样 —— id 只由本模块生成(`isValidTrashId` 严格白名单),而 files 可能来自
379
- * 用户手改、别的进程、或一份被塞进来的恶意档案包。不校验就 `join(dir, dest)` 等于给了
380
- * "任意相对路径读源 + 任意绝对目录建目标"的能力(`mkdir(dirname(to))` 会顺手把目录建出来)。
407
+ * 仍然拒绝 `.` 开头的段吗?**不**(旧谓词拒绝)。理由:`..` 与 `.` 这两个才是穿越,靠精确判据
408
+ * 挡住即可;而 `.DS_Store` 这类隐藏文件可能真的躺在用户的场景目录里,一刀切会让「删除这个场景」
409
+ * 从"能用但恢复不了"变成"直接删不掉" —— 那是把问题换个地方,不是修好它。
381
410
  */
382
- export function isValidTrashPayloadName(dest) {
411
+ export function isValidTrashPayloadPath(dest) {
383
412
  const text = String(dest ?? '');
384
- if (!text || text.startsWith('.') || text.includes('\0'))
413
+ if (!text || text.includes('\0'))
385
414
  return false;
386
- if (/[\\/]/.test(text))
415
+ if (text.startsWith('/') || text.startsWith('\\') || /^[A-Za-z]:/.test(text))
387
416
  return false;
388
- if (text === 'manifest.json')
417
+ if (text.includes('\\'))
418
+ return false;
419
+ const parts = text.split('/');
420
+ for (const part of parts) {
421
+ if (part === '' || part === '.' || part === '..')
422
+ return false;
423
+ if (/[<>:"|?*\u0000-\u001f]/.test(part))
424
+ return false;
425
+ if (part.endsWith(' ') || part.endsWith('.'))
426
+ return false;
427
+ }
428
+ if (parts.length === 1 && parts[0] === 'manifest.json')
389
429
  return false;
390
430
  return true;
391
431
  }
@@ -397,9 +437,40 @@ export async function moveOutOfTrash(kind, id, dest, to) {
397
437
  const dir = trashEntryPath(kind, id);
398
438
  if (dir === null)
399
439
  throw new Error(`回收站条目 id 非法:${id}`);
400
- if (!isValidTrashPayloadName(dest))
440
+ if (!isValidTrashPayloadPath(dest))
401
441
  throw new Error(`回收站负载名非法:${dest}`);
402
442
  const from = join(dir, dest);
443
+ // 后置断言:谓词已经挡掉了 `..`,这一条挡的是「条目目录里恰好有个指向别处的链接」——
444
+ // 那种情况下 `join` 看着在条目内,实际会从条目外取源。
445
+ if (!isSameOrDescendant(dir, from))
446
+ throw new Error(`回收站负载越出条目目录:${dest}`);
447
+ await mkdir(dirname(to), { recursive: true });
448
+ try {
449
+ await rename(from, to);
450
+ }
451
+ catch {
452
+ await cp(from, to, { recursive: true });
453
+ await rm(from, { recursive: true, force: true });
454
+ }
455
+ }
456
+ /**
457
+ * 把一个文件/目录搬**进已存在的条目**(`moveOutOfTrash` 的反向补充,专给回滚用)。
458
+ *
459
+ * 为什么需要它:恢复是逐个负载搬的,中途失败时必须把**已经搬出去的那几个**放回条目里,
460
+ * 否则它们留在目标目录里 —— 而恢复的下一次尝试会撞「目标目录已有内容」的检查,
461
+ * 这个条目就再也恢复不了了(场景回收站那条链正是这么坏掉的,审查 P0-2)。
462
+ * 谓词与后置断言与 `moveOutOfTrash` 完全一致(同一道闸,两个方向)。
463
+ * @throws 条目 id 或负载名非法时抛错(调用方按"回滚失败"处理)。
464
+ */
465
+ export async function moveIntoTrash(kind, id, from, dest) {
466
+ const dir = trashEntryPath(kind, id);
467
+ if (dir === null)
468
+ throw new Error(`回收站条目 id 非法:${id}`);
469
+ if (!isValidTrashPayloadPath(dest))
470
+ throw new Error(`回收站负载名非法:${dest}`);
471
+ const to = join(dir, dest);
472
+ if (!isSameOrDescendant(dir, to))
473
+ throw new Error(`回收站负载越出条目目录:${dest}`);
403
474
  await mkdir(dirname(to), { recursive: true });
404
475
  try {
405
476
  await rename(from, to);
@@ -45,33 +45,58 @@ function flushTurn(current, turns) {
45
45
  if (text)
46
46
  turns.push({ role: current.role, text });
47
47
  }
48
+ /** 嗅探时最多看几行(见 `sniffFormat`)。 */
49
+ const SNIFF_LINES = 5;
48
50
  /**
49
- * Detect the transcript format. Extension wins; content sniffing only applies
50
- * when the name carries no recognized extension.
51
+ * 按**内容**嗅探转录格式。只看前若干条非空行,不读全篇。
52
+ *
53
+ * 为什么不是「只看第一行」:Claude Code 的转录稿常在正文前带 `summary` / `system` 行,
54
+ * 那些行也是 JSON 但 `type` 不是 user/assistant;只看第一行会把它们判成 generic。
55
+ * 只扫前 `SNIFF_LINES` 行是为了压低误报 —— 一份 Markdown 正文里偶然粘了一段
56
+ * `{"role":"user"}` 的 API 载荷,不该让整份稿子按 JSONL 解析。
51
57
  * @returns {'jsonl' | 'markdown' | 'generic'}
52
58
  */
53
- export function detectFormat(fileName, content) {
54
- const name = String(fileName || '').toLowerCase();
55
- if (/\.jsonl$/.test(name))
56
- return 'jsonl';
57
- if (/\.(md|markdown)$/.test(name))
58
- return 'markdown';
59
- if (/\.txt$/.test(name))
60
- return 'generic';
61
- const first = String(content || '').split(/\r?\n/).map((s) => s.trim()).find((s) => s) || '';
62
- if (first.startsWith('{')) {
59
+ function sniffFormat(content) {
60
+ const lines = String(content || '')
61
+ .split(/\r?\n/)
62
+ .map((s) => s.trim())
63
+ .filter((s) => s);
64
+ for (const line of lines.slice(0, SNIFF_LINES)) {
65
+ if (!line.startsWith('{'))
66
+ continue;
63
67
  try {
64
68
  // JSON 值实际形状未知;若为对象,type/role 字段由下方运行时比较判定
65
- const obj = JSON.parse(first);
66
- if (obj && typeof obj === 'object' && (obj.type === 'user' || obj.type === 'assistant' || obj.role === 'user' || obj.role === 'assistant'))
69
+ const obj = JSON.parse(line);
70
+ if (obj && typeof obj === 'object' && !Array.isArray(obj) &&
71
+ (obj.type === 'user' || obj.type === 'assistant' || obj.role === 'user' || obj.role === 'assistant'))
67
72
  return 'jsonl';
68
73
  }
69
- catch (e) { /* not JSONL after all */ }
74
+ catch (e) { /* 这一行不是 JSON,继续往下看 */ }
70
75
  }
76
+ const first = lines[0] || '';
71
77
  if (HEADING_RE.test(first) || BOLD_PREFIX_RE.test(first))
72
78
  return 'markdown';
73
79
  return 'generic';
74
80
  }
81
+ /**
82
+ * Detect the transcript format. Extension wins; content sniffing applies
83
+ * when the name carries no recognized extension **or is `.txt`**.
84
+ *
85
+ * `.txt` 走嗅探,不是无条件 generic(2026-09-30 审查 F12):`.txt` 恰恰是**最常被改名**的
86
+ * 扩展名 —— Claude Code / Cursor 的 JSONL、Markdown 转录稿都可能被存成 `transcript.txt`
87
+ *(客户端拿不到文件名时自己就默认这个名字)。原来无条件判 generic,于是整份对话被折成
88
+ * **一条 user 消息**、分轮结构全失、零警告,op 还回 `{ok:true,count:1}`。
89
+ * `.jsonl` / `.md` 仍按扩展名直判(那两种名字下内容必然是同一种,嗅探没有收益)。
90
+ * @returns {'jsonl' | 'markdown' | 'generic'}
91
+ */
92
+ export function detectFormat(fileName, content) {
93
+ const name = String(fileName || '').toLowerCase();
94
+ if (/\.jsonl$/.test(name))
95
+ return 'jsonl';
96
+ if (/\.(md|markdown)$/.test(name))
97
+ return 'markdown';
98
+ return sniffFormat(content);
99
+ }
75
100
  /**
76
101
  * Parse a Claude Code / Cursor style JSONL transcript.
77
102
  * Recognizes `{type:'user'|'assistant', message:{role,content}}` (Claude Code)