dsh-plugin-tool-management 0.9.1 → 0.11.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 (79) hide show
  1. package/CHANGELOG.md +119 -1
  2. package/README.md +227 -201
  3. package/README_EN.md +227 -199
  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 +144 -12
  21. package/lib/client.js +8437 -5929
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -25
  24. package/lib/context-inject.js +396 -53
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +31 -3
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +940 -3765
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +20 -9
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/mcp/state-section.js +64 -21
  37. package/lib/{rules → memories}/archive-engine.js +65 -10
  38. package/lib/{rules → memories}/archive.js +6 -7
  39. package/lib/memories/constants.js +128 -0
  40. package/lib/memories/index-io.js +330 -0
  41. package/lib/memories/projection.js +280 -0
  42. package/lib/memories/service.js +686 -0
  43. package/lib/memories/snapshot.js +672 -0
  44. package/lib/ops/candidates.js +64 -0
  45. package/lib/ops/compat.js +136 -0
  46. package/lib/ops/ctx.js +9 -0
  47. package/lib/ops/memory.js +678 -0
  48. package/lib/ops/prompts.js +107 -0
  49. package/lib/ops/scene-records.js +460 -0
  50. package/lib/ops/scene-sync.js +17 -0
  51. package/lib/ops/sessions.js +603 -0
  52. package/lib/ops/trash.js +140 -0
  53. package/lib/paths.js +103 -0
  54. package/lib/prompts/preset-id.js +49 -0
  55. package/lib/{agents-md → prompts}/service.js +1 -1
  56. package/lib/request-gate.js +320 -0
  57. package/lib/scene-prompt-sync.js +4 -4
  58. package/lib/scenes/candidates.js +344 -0
  59. package/lib/{history → sessions}/bridge.js +22 -5
  60. package/lib/sessions/history.js +323 -0
  61. package/lib/{history → sessions}/tombstone.js +1 -2
  62. package/lib/{history → sessions}/workspace.js +92 -24
  63. package/lib/skills/catalog.js +6 -9
  64. package/lib/skills/core.js +79 -43
  65. package/lib/skills/readonly-discovery.js +4 -1
  66. package/lib/skills/service.js +98 -13
  67. package/lib/subagents/catalog.js +31 -15
  68. package/lib/subagents/service.js +506 -89
  69. package/lib/subagents/tools.js +29 -4
  70. package/lib/tools/deps.js +8 -0
  71. package/lib/tools/mcp.js +110 -0
  72. package/lib/tools/memory.js +87 -0
  73. package/lib/tools/prompt.js +70 -0
  74. package/lib/tools/skills.js +139 -0
  75. package/lib/tools/subagent.js +40 -0
  76. package/package.json +105 -102
  77. package/lib/agents-md/preset-id.js +0 -49
  78. package/lib/history/projcache.js +0 -335
  79. package/lib/rules/service.js +0 -2971
package/lib/http-fence.js CHANGED
@@ -21,9 +21,12 @@ const headerValue = (req, name) => {
21
21
  /**
22
22
  * 判定请求是否应被拒(返回 null = 放行)。
23
23
  *
24
- * 优先用宿主栅栏;它不在场时(非 web 组合、服务未注册)退回等价的本地判定,
25
- * 而不是静默放行。栅栏自身抛错时同样退回本地判定 —— 宁可拒绝也不要因为宿主
26
- * 内部变动而变成开放路由。
24
+ * 优先用宿主栅栏;它不在场时(非 web 组合、服务未注册)退回**本地最小判定**,而不是静默放行。
25
+ * 栅栏自身抛错时同样退回本地判定 —— 但这句**不是**"宁可拒绝":本地链不含 cookie 鉴权,
26
+ * 于是丢掉的恰好是宿主栅栏比本地判定多出来的那一层。无 Origin 的非浏览器请求只要带上
27
+ * `Host: localhost` 就能过(本机任意进程都做得到),而写 op 里包含 `mcpm-add`(宿主 stdio
28
+ * 传输会按 command/args spawn 它)与 `history-delete`(永久删除会话)。
29
+ * 准确的说法是:退回一个**不依赖宿主内部**的判定 —— 仍要求回环 Host,但没有会话凭证。
27
30
  *
28
31
  * @param req - Node 请求对象(只读 headers)。
29
32
  * @param connection - ctx.get('connection'),可缺失。
@@ -71,24 +74,41 @@ export function fenceRejection(req, connection) {
71
74
  }
72
75
  return null;
73
76
  }
77
+ // 令牌相关的拒绝文案**只有这一句**,所有出口都引用它(用户裁定 2026-09-18:
78
+ // 「这些关于密钥没填的提示词应该统一文案」;2026-09-19 再简化成一句话)。
79
+ //
80
+ // 2026-09-19 晚再去掉"去哪儿填、点什么"那两句(用户截图:提示条在好几个地方都折成两行)——
81
+ // 提示条右侧本来就挂着「填写令牌」按钮,把按钮名念一遍等于同一件事在一行里说两遍,还占掉
82
+ // 一整行的宽度。这一句只说"缺什么 / 错在哪",动作交给那颗按钮;它跳到哪、填完什么状态,
83
+ // 由兼容页的胶囊与三行自己说。**不要再往这句里加指引**:契约测试钉着它的长度。
84
+ //
85
+ // 为什么必须同源:同一个"没填令牌"会在四个地方冒出来(宿主写门禁、明文门禁的两条分支、
86
+ // 界面的错误码词典),此前各写一套 —— 截图里同一件事出现了三种说法,用户无法判断它们
87
+ // 是不是同一件事。界面靠**文本相等**识别这句话(见 client.js 的 isTokenGateText),
88
+ // 所以改这句必须同时改界面词典里的那三个键。
89
+ export const TOKEN_MSG = '缺少访问令牌,或令牌不对';
90
+ /**
91
+ * 界面按 code 在最右侧挂「填写令牌」跳转按钮(这一族里的 code 都算)。
92
+ *
93
+ * 2026-09-19 之后 `secretOpRejection` **不再产生** NO_HOST(没配令牌就直接放行,见上),
94
+ * 但常量与界面词典里的 `error.secret.noToken` 都留着:宿主没重启时旧响应里还可能出现它,
95
+ * 而界面同时按 code 与**文本相等**两条路识别这一族(见 client.js 的 isTokenGateText)。
96
+ */
97
+ export const TOKEN_CODE_NO_HOST = 'error.secret.noToken';
98
+ export const TOKEN_CODE_BAD = 'error.token.required';
74
99
  /**
75
100
  * 判定敏感 op 是否应被拒(返回 null = 放行)。
76
101
  *
77
- * 两种情况分开报,因为处置方式不同:没配令牌要去宿主配置里加,配了但没带/带错
78
- * 只要在界面里填对即可(界面按 code 决定给不给输入框)。
102
+ * 没配令牌 ⇒ 放行(与写门禁同一激活条件,理由见上方段落)。有令牌 ⇒ 必须带对的。
103
+ *
104
+ * 两个 code 仍然分开报:界面用不到(话已经一样了),但日志与排查需要区分
105
+ * 「宿主根本没配令牌」与「这次带的令牌不对」—— 前者是配置问题,后者是输入问题。
79
106
  */
80
107
  export function secretOpRejection(state) {
81
- if (!state.tokenConfigured) {
82
- return {
83
- code: 'error.secret.noToken',
84
- error: '明文查看与导出已被禁用:宿主未配置访问令牌。请在本插件配置里加 token(或设环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN)后重启 DSH,再在界面上填入同一个令牌。',
85
- };
86
- }
108
+ if (!state.tokenConfigured)
109
+ return null;
87
110
  if (!state.tokenAccepted) {
88
- return {
89
- code: 'error.secret.badToken',
90
- error: '访问令牌缺失或不正确:请在界面里填入与宿主配置相同的令牌(随请求以 x-dsh-token 发送)。',
91
- };
111
+ return { code: TOKEN_CODE_BAD, error: TOKEN_MSG };
92
112
  }
93
113
  return null;
94
114
  }
package/lib/hub.js CHANGED
@@ -24,7 +24,13 @@
24
24
  // ├─ mcp-disabled-tools.json | mcp-known-tools.json | mcp-notes.json | mcp-settings.json | mcp-export.json
25
25
  // ├─ tool-management.log 插件日志(滚动 .1)
26
26
  // ├─ backups/cordis.patch.yml.bak-<时间戳> 改宿主 patch 前的备份
27
- // └─ trash/{skills,subagents,prompts,scenes,memories}-trash/<id>/
27
+ // ├─ trash/{skills,subagents,prompts,scenes}-trash/<id>/ 回收站(除记忆外的四类)
28
+ // └─ memories-trash/<id>/ 记忆回收站(**hub 根下独立目录**)
29
+ //
30
+ // 记忆回收站**不在 `trash/` 下**(本机实测 `trash/` 只有 4 个 `-trash` 目录):它走
31
+ // `memories/service.ts` 自己的路径(`join(stateDir, 'memories-trash', id)`,stateDir = hub 根),
32
+ // 不经本模块的 `moveToTrash`。此前这行把它画进 `trash/{…}` 里,按它写备份/迁移脚本会既漏搬
33
+ // 又误判(那是一类真实存在、条目数最多的用户数据)。
28
34
  //
29
35
  // 留在 `$DSH_HOME` 根下的两个文件**不是**插件的:`AGENTS.md`(宿主每轮读取的全局基线,
30
36
  // 插件只是按场景/预设写它)与 `cordis.patch.yml`(宿主加载插件的配置入口)。
@@ -116,7 +122,9 @@ export function migrateHubLayoutSync(home = resolveDshHome()) {
116
122
  }
117
123
  };
118
124
  // ① 更早的 hub 目录名(插件叫 dsh-skill-mcp-manager 的时期):**先**逐项并入 hub,
119
- // 让下面 ② 的改名也覆盖从旧目录搬进来的那些(同名保留 hub 里已有的那份)。
125
+ // 让下面 ② 的改名也覆盖从旧目录搬进来的那些(同名保留 hub 里已有的那份 ——
126
+ // move 对已存在目标跳过且旧份不删:合并是「只进不覆盖」,滞留旧目录的条目
127
+ // 不丢失但也不可见,清掉旧目录即可整体放弃)。
120
128
  for (const legacyHub of ['dsh-plugin-tool-management', 'skill-mcp-manager']) {
121
129
  const from = join(home, legacyHub);
122
130
  try {
@@ -363,14 +371,34 @@ export async function listTrashEntries(kind) {
363
371
  export async function readTrashEntry(kind, id) {
364
372
  return await readManifest(kind, id);
365
373
  }
374
+ /**
375
+ * 条目里的一个负载名是不是"就在这个条目目录里"。
376
+ *
377
+ * 为什么 id 与场景名都有谓词、这里还得多一道:`manifest.json` 的 `files[]` 是**磁盘上的数据**,
378
+ * 它跟 id 不一样 —— id 只由本模块生成(`isValidTrashId` 严格白名单),而 files 可能来自
379
+ * 用户手改、别的进程、或一份被塞进来的恶意档案包。不校验就 `join(dir, dest)` 等于给了
380
+ * "任意相对路径读源 + 任意绝对目录建目标"的能力(`mkdir(dirname(to))` 会顺手把目录建出来)。
381
+ */
382
+ export function isValidTrashPayloadName(dest) {
383
+ const text = String(dest ?? '');
384
+ if (!text || text.startsWith('.') || text.includes('\0'))
385
+ return false;
386
+ if (/[\\/]/.test(text))
387
+ return false;
388
+ if (text === 'manifest.json')
389
+ return false;
390
+ return true;
391
+ }
366
392
  /**
367
393
  * 把条目里的一个负载搬回 `to`。**不覆盖**:调用方必须先确认 `to` 不存在。
368
- * @throws 条目或负载缺失时抛错(调用方翻成人话)。
394
+ * @throws 条目、负载名或负载缺失时抛错(调用方翻成人话)。
369
395
  */
370
396
  export async function moveOutOfTrash(kind, id, dest, to) {
371
397
  const dir = trashEntryPath(kind, id);
372
398
  if (dir === null)
373
399
  throw new Error(`回收站条目 id 非法:${id}`);
400
+ if (!isValidTrashPayloadName(dest))
401
+ throw new Error(`回收站负载名非法:${dest}`);
374
402
  const from = join(dir, dest);
375
403
  await mkdir(dirname(to), { recursive: true });
376
404
  try {
@@ -18,19 +18,22 @@ export function extractText(content) {
18
18
  if (typeof part === 'string')
19
19
  out += part;
20
20
  else if (part && typeof part === 'object') {
21
- if (typeof part.text === 'string')
22
- out += part.text;
23
- else if (typeof part.content === 'string')
24
- out += part.content;
21
+ // JSON 块的实际形状由下方 typeof 运行时比较决定,这里按记录形状读取字段
22
+ const record = part;
23
+ if (typeof record.text === 'string')
24
+ out += record.text;
25
+ else if (typeof record.content === 'string')
26
+ out += record.content;
25
27
  }
26
28
  }
27
29
  return out;
28
30
  }
29
31
  if (content && typeof content === 'object') {
30
- if (typeof content.text === 'string')
31
- return content.text;
32
- if (typeof content.content === 'string')
33
- return content.content;
32
+ const record = content;
33
+ if (typeof record.text === 'string')
34
+ return record.text;
35
+ if (typeof record.content === 'string')
36
+ return record.content;
34
37
  }
35
38
  return '';
36
39
  }
@@ -58,6 +61,7 @@ export function detectFormat(fileName, content) {
58
61
  const first = String(content || '').split(/\r?\n/).map((s) => s.trim()).find((s) => s) || '';
59
62
  if (first.startsWith('{')) {
60
63
  try {
64
+ // JSON 值实际形状未知;若为对象,type/role 字段由下方运行时比较判定
61
65
  const obj = JSON.parse(first);
62
66
  if (obj && typeof obj === 'object' && (obj.type === 'user' || obj.type === 'assistant' || obj.role === 'user' || obj.role === 'assistant'))
63
67
  return 'jsonl';
@@ -83,6 +87,7 @@ export function parseJsonlTranscript(text) {
83
87
  const line = raw.trim();
84
88
  if (!line)
85
89
  continue;
90
+ // JSONL 每行的实际形状未知;字段有效性由下方运行时比较判定
86
91
  let obj;
87
92
  try {
88
93
  obj = JSON.parse(line);
@@ -95,7 +100,7 @@ export function parseJsonlTranscript(text) {
95
100
  let role = null;
96
101
  let content;
97
102
  if (obj.type === 'user' || obj.type === 'assistant') {
98
- const message = obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message) ? obj.message : null;
103
+ const message = (obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message) ? obj.message : null);
99
104
  role = message && (message.role === 'user' || message.role === 'assistant') ? message.role : (obj.type === 'user' ? 'user' : 'assistant');
100
105
  content = message ? message.content : undefined;
101
106
  }
@@ -112,6 +117,7 @@ export function parseJsonlTranscript(text) {
112
117
  lastText += '\n' + piece;
113
118
  }
114
119
  else {
120
+ // lastText 非空 ⇒ lastRole 已随 lastText 同步赋值(非 null),类型层无法表达该不变式
115
121
  if (lastText)
116
122
  turns.push({ role: lastRole, text: lastText });
117
123
  lastRole = role;
@@ -3,12 +3,28 @@
3
3
  // 场景记忆(~/.dsh/tool-management/memories/<场景>/<名>.md;场景留空 = 保留场景 global)。
4
4
  // 约定:只认 .md;zip 内任意层级;隐藏项 / 绝对路径 / `..` 穿越 / 超限条目一律跳过并回报;
5
5
  // 重名策略(跳过 or 覆盖)不在这里实现——由调用方按文件系统现状裁决(本项目取「跳过并报告」)。
6
+ //
7
+ // 两条限额的**口径**要分清(2026-09-19 审计 T-09):`MAX_IMPORT_TOTAL_BYTES` 管的是
8
+ // **上传(编码后)**字节,管不住解压后的体积 —— 一个 8 MiB 条目 × 2000 条 ≈ 16 GiB 会在
9
+ // 单次 `unzipSync` 里被实体化。所以另有 `MAX_IMPORT_UNCOMPRESSED_BYTES` 管**解压后**的
10
+ // 累计量,在 filter 里按档案自报的 `info.originalSize` 累加。
6
11
  import { unzipSync } from 'fflate';
12
+ import { isValidSegment } from '../paths.js';
7
13
  export const MAX_IMPORT_FILES = 200;
8
14
  export const MAX_IMPORT_ENTRY_BYTES = 8 * 1024 * 1024;
15
+ /** 上传(编码后)总量:与传输层的 88 MiB 体限是同一层口径,防止一次请求塞进几百 MiB。 */
9
16
  export const MAX_IMPORT_TOTAL_BYTES = 32 * 1024 * 1024;
10
17
  export const MAX_IMPORT_ENTRIES = 2000;
11
18
  export const MAX_IMPORT_NAME_LENGTH = 64;
19
+ /**
20
+ * 解压后累计上限(与技能上传器 `MAX_UPLOAD_TOTAL_BYTES = 64 MiB` 同层口径)。
21
+ *
22
+ * 为什么必须单列:单条目 8 MiB × 2000 条 ≈ 16 GiB 会在一次 `unzipSync` 里全部展开进内存 ——
23
+ * 传输层与 `MAX_IMPORT_TOTAL_BYTES` 都只数**压缩后**的字节,一个数不到。
24
+ * 已知前提:信任档案自报的 `info.originalSize`(fflate 不独立约束输出长度);谎报只能让
25
+ * 解压产物比申报的大,**不会**绕过这个上限之前的条目数门禁。
26
+ */
27
+ export const MAX_IMPORT_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
12
28
  const message = (e) => String((e && e.message) || e);
13
29
  /** base64 → 字节;容忍 `data:...;base64,` 前缀。 */
14
30
  export function decodeBase64(data) {
@@ -57,6 +73,8 @@ export function expandUploads(files) {
57
73
  problems.push({ name: `(其余 ${all.length - MAX_IMPORT_FILES} 个文件)`, reason: `一次最多导入 ${MAX_IMPORT_FILES} 个文件` });
58
74
  }
59
75
  let total = 0;
76
+ /** 解压后超限只报一次(与"超条目数"同口径,避免几千条问题刷屏)。 */
77
+ let uncompressedReported = false;
60
78
  for (const raw of list) {
61
79
  const file = (raw || {});
62
80
  const name = String(file.name || '').trim() || '(未命名)';
@@ -76,6 +94,7 @@ export function expandUploads(files) {
76
94
  }
77
95
  if (isZipBytes(bytes)) {
78
96
  let unzipped;
97
+ let uncompressed = 0;
79
98
  try {
80
99
  let count = 0;
81
100
  unzipped = unzipSync(bytes, {
@@ -95,6 +114,18 @@ export function expandUploads(files) {
95
114
  problems.push({ name: info.name, reason: `zip 内单条目超过 ${MAX_IMPORT_ENTRY_BYTES >> 20} MiB,已跳过` });
96
115
  return false;
97
116
  }
117
+ // 解压后累计:单条目与条目数都挡不住「2000 × 8 MiB」这种组合,只有累计量挡得住。
118
+ // 目录条目不占解压预算(originalSize 为 0 且不产出内容)。
119
+ if (!info.name.endsWith('/')) {
120
+ uncompressed += info.originalSize;
121
+ if (uncompressed > MAX_IMPORT_UNCOMPRESSED_BYTES) {
122
+ if (!uncompressedReported) {
123
+ uncompressedReported = true;
124
+ problems.push({ name: info.name, reason: `zip 解压后合计超过 ${MAX_IMPORT_UNCOMPRESSED_BYTES >> 20} MiB,该条目及其后条目已忽略` });
125
+ }
126
+ return false;
127
+ }
128
+ }
98
129
  return true;
99
130
  },
100
131
  });
@@ -120,14 +151,22 @@ export function expandUploads(files) {
120
151
  problems.push({ name, reason: '只支持 .md 或 .zip' });
121
152
  continue;
122
153
  }
123
- entries.push({ path: name, bytes });
154
+ // 非 zip 分支同样要走 `normalizeEntryPath`:客户端给的文件名是**外部输入**,
155
+ // `"../../x.md"` / `".hidden/x.md"` 直接进 `RawEntry[]` 就把"隐藏项/穿越一律跳过"
156
+ // 这条承诺交给下游 planner 兜着 —— 本模块的文件头正是这么承诺的(审计 T-10)。
157
+ // 不变量该由承诺方强制:新增的第三个消费者不该靠"运气好下游也查了"才安全。
158
+ const normalized = normalizeEntryPath(name);
159
+ if (!normalized) {
160
+ problems.push({ name, reason: '路径非法或隐藏项,已跳过' });
161
+ continue;
162
+ }
163
+ entries.push({ path: normalized, bytes });
124
164
  }
125
165
  return { entries, problems };
126
166
  }
127
- /** 单个名字段(人设名 / 记忆名 / 场景路径的一段)合法性:与宿主侧校验同口径。 */
167
+ /** 单个名字段(人设名 / 记忆名 / 场景路径的一段)合法性:谓词收敛到 `../paths.ts`。 */
128
168
  export function isValidImportName(name) {
129
- const s = String(name || '');
130
- return s.length > 0 && s.length <= MAX_IMPORT_NAME_LENGTH && s === s.trim() && !s.startsWith('.') && !/[\\/<>:"|?*]/.test(s);
169
+ return isValidSegment(String(name || ''), MAX_IMPORT_NAME_LENGTH);
131
170
  }
132
171
  /** 场景/分组路径合法性:逐段校验(空串 = 根/全局,合法)。 */
133
172
  export function isValidImportGroup(group) {