dsh-plugin-tool-management 0.10.0 → 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 (74) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/README.md +64 -49
  3. package/README_EN.md +58 -37
  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 +105 -12
  21. package/lib/client.js +2763 -546
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -20
  24. package/lib/context-inject.js +11 -0
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +28 -2
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +804 -3887
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +10 -3
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/{rules → memories}/archive-engine.js +1 -1
  37. package/lib/{rules → memories}/archive.js +1 -1
  38. package/lib/memories/constants.js +128 -0
  39. package/lib/memories/index-io.js +330 -0
  40. package/lib/memories/projection.js +280 -0
  41. package/lib/memories/service.js +686 -0
  42. package/lib/memories/snapshot.js +672 -0
  43. package/lib/ops/candidates.js +64 -0
  44. package/lib/ops/compat.js +136 -0
  45. package/lib/ops/ctx.js +9 -0
  46. package/lib/ops/memory.js +678 -0
  47. package/lib/ops/prompts.js +107 -0
  48. package/lib/ops/scene-records.js +460 -0
  49. package/lib/ops/scene-sync.js +17 -0
  50. package/lib/ops/sessions.js +603 -0
  51. package/lib/ops/trash.js +140 -0
  52. package/lib/paths.js +103 -0
  53. package/lib/prompts/preset-id.js +49 -0
  54. package/lib/{agents-md → prompts}/service.js +1 -1
  55. package/lib/request-gate.js +320 -0
  56. package/lib/scene-prompt-sync.js +4 -4
  57. package/lib/scenes/candidates.js +344 -0
  58. package/lib/{history → sessions}/bridge.js +15 -5
  59. package/lib/sessions/history.js +323 -0
  60. package/lib/{history → sessions}/tombstone.js +1 -1
  61. package/lib/{history → sessions}/workspace.js +92 -24
  62. package/lib/skills/core.js +74 -39
  63. package/lib/skills/readonly-discovery.js +4 -1
  64. package/lib/skills/service.js +98 -13
  65. package/lib/subagents/service.js +199 -55
  66. package/lib/tools/deps.js +8 -0
  67. package/lib/tools/mcp.js +110 -0
  68. package/lib/tools/memory.js +87 -0
  69. package/lib/tools/prompt.js +70 -0
  70. package/lib/tools/skills.js +139 -0
  71. package/lib/tools/subagent.js +40 -0
  72. package/package.json +7 -7
  73. package/lib/agents-md/preset-id.js +0 -49
  74. package/lib/rules/service.js +0 -3078
@@ -56,6 +56,7 @@
56
56
  * exactly what its author asked for; the plugin's job is to say so out loud and
57
57
  * point at the switch that overrides it.
58
58
  */
59
+ import { MCP_CLIENT_MODULE } from '../host-names.js';
59
60
  /** Whether a preset declares "no extra text": persona `complete: true` or runtime context off. */
60
61
  export function isSuppressingPreset(facts) {
61
62
  return facts.personaComplete === true || facts.includeRuntimeContext === false;
@@ -74,16 +75,6 @@ export function injectionFactsOf(facts) {
74
75
  const PERSONA_MODULE = '@deepseek-ai/dsh-persona';
75
76
  const AGENT_INSTRUCTIONS_MODULE = '@deepseek-ai/dsh-agent-instructions';
76
77
  const TOOL_SKILL_MODULE = '@deepseek-ai/dsh-tool-skill';
77
- /**
78
- * An MCP client mounted INSIDE a composition. The shipped presets mount none,
79
- * so MCP tools normally come from the host plane (the `$DSH_HOME/cordis.patch.yml`
80
- * layer) and are callable under every preset; a user-authored preset that mounts
81
- * its own client only carries those tools under itself, and this scan is how the
82
- * page can tell the two apart. Tool reachability is not the column's answer,
83
- * though — the server list and the user's notes are a prompt section, which a
84
- * complete persona suppresses whichever client serves the tools.
85
- */
86
- const MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client';
87
78
  const NAME_LINE = /^(\s*)name:\s*(['"]?)([^'"\s#]+)\2\s*(?:#.*)?$/;
88
79
  const ENTRY_LINE = /^(\s*)-\s/;
89
80
  const DISABLED_LINE = /^\s*disabled:\s*(.+?)\s*(?:#.*)?$/;
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * WHY THIS REPLACES THE TEXT-COMPARISON GATE
6
6
  * ------------------------------------------
7
- * The previous gate in `history/bridge.js` compared each host method against
7
+ * The previous gate in `sessions/bridge.ts` compared each host method against
8
8
  * this plugin's own copy of the official prototype with
9
9
  * `Function.prototype.toString()`. That only ever answered one question —
10
10
  * "is the host running the same release I was written against?" — and it
@@ -139,22 +139,88 @@ function prototypeOf(instance) {
139
139
  const proto = Object.getPrototypeOf(instance);
140
140
  return proto !== null && typeof proto === 'object' ? proto : undefined;
141
141
  }
142
- /** Official prototypes this plugin's adapters mirror, when importable. */
143
- function referencePrototypes() {
144
- const out = {};
142
+ // The official package + class each capability owner's members live on. This is
143
+ // the single answer to "which class defines this owner" — both the live
144
+ // reference-prototype lookup below and the exported static table read it, so a
145
+ // host drift is described in exactly one place.
146
+ const WORKSPACE_CLASS = { pkg: '@deepseek-ai/dsh-workspace', target: 'WorkspaceRegistry' };
147
+ const PROJECTION_CLASS = { pkg: '@deepseek-ai/dsh-session-projection-cache', target: 'SessionProjectionCache' };
148
+ // `sessions.*` members are host-private members, but they are still declared on
149
+ // an official class: `@deepseek-ai/dsh-session` is a peer this plugin declares,
150
+ // and `SessionStore` is where `flush`/`liveEntryFor`/`detachEntered`/`enter`/
151
+ // `announce` live. Without this entry the doctor could not see the two
152
+ // capabilities the delete route depends on.
153
+ const SESSIONS_CLASS = { pkg: '@deepseek-ai/dsh-session', target: 'SessionStore' };
154
+ const OWNER_CLASSES = {
155
+ workspace: WORKSPACE_CLASS,
156
+ projectionCache: PROJECTION_CLASS,
157
+ sessions: SESSIONS_CLASS,
158
+ };
159
+ /** The named export's prototype, or undefined when the package is absent/trimmed. */
160
+ function classPrototypeOf(ref) {
145
161
  try {
146
- // eslint-disable-next-line @typescript-eslint/no-var-requires
147
- const ws = createRequire(import.meta.url)('@deepseek-ai/dsh-workspace');
148
- if (ws.WorkspaceRegistry?.prototype !== undefined)
149
- out.workspace = ws.WorkspaceRegistry.prototype;
162
+ const mod = createRequire(import.meta.url)(ref.pkg);
163
+ const klass = mod[ref.target];
164
+ return klass?.prototype;
150
165
  }
151
- catch { /* optional in tests and in trimmed deployments */ }
166
+ catch {
167
+ return undefined; // optional in tests and in trimmed deployments
168
+ }
169
+ }
170
+ /**
171
+ * 哨兵值:不可能命中任何真实会话/工作区(会话 id 由宿主生成,不含此串)。
172
+ * 行为探针真调宿主方法时一律传它 —— 最坏情形是宿主受控拒绝(not found),
173
+ * 而「哨兵输入都能产生意外效果」本身就是要探出来的「不该路由信任」。
174
+ */
175
+ const PROBE_SENTINEL = '__dshm_probe_never_exists__';
176
+ /**
177
+ * 用哨兵实参**真调**一个宿主方法(2026-09-19 分层探针第二层),验证「存在且可调用」:
178
+ * - 同步抛 `TypeError` → 实现坏了/签名漂移,返回失败详情(shape-mismatch);
179
+ * - 受控同步拒绝(普通 `Error`,如 not found)或返回值(含 rejected Promise)→ 通过。
180
+ *
181
+ * 只判同步段:探针框架是同步的(`assessHost` 不能 await),异步结果分类留待
182
+ * 异步化改造(见审查 07 跟进项)。返回的 Promise 一律挂 `.catch` 吞掉,探针
183
+ * 不制造 unhandled rejection。调用必须以 `target` 为 receiver —— 私有方法全靠
184
+ * `this.*` 拿内部状态,脱离 receiver 调用会把好实现误判成 TypeError。
185
+ *
186
+ * 副作用口径(按官方源码逐个核过):liveEntryFor/announce/flush 哨兵输入在
187
+ * 查表处受控抛错,零副作用;detachEntered 传普通对象在未知 id 处早退;
188
+ * deleteSession/archiveSession 等原生入口对未知 id 是读路径校验后受控拒绝。
189
+ * `enter` 不在此列(会往 store 里写哨兵条目),它只做存在性 + 文本比对。
190
+ */
191
+ function callableWithSentinel(target, name, ...args) {
152
192
  try {
153
- const pc = createRequire(import.meta.url)('@deepseek-ai/dsh-session-projection-cache');
154
- if (pc.SessionProjectionCache?.prototype !== undefined)
155
- out.cache = pc.SessionProjectionCache.prototype;
193
+ const method = target[name];
194
+ if (!isFn(method))
195
+ return `${name} 不是函数(methods 检查应已拦下,此处兜底)`;
196
+ const returned = method.call(target, ...args);
197
+ if (returned !== null && typeof returned === 'object' && isFn(returned.then)) {
198
+ const settled = returned;
199
+ if (isFn(settled.catch))
200
+ settled.catch(() => { });
201
+ }
202
+ return undefined;
203
+ }
204
+ catch (error) {
205
+ if (error instanceof TypeError)
206
+ return `${name} 哨兵真调抛 TypeError:${String(error?.message ?? error)}`;
207
+ return undefined;
156
208
  }
157
- catch { /* optional in tests */ }
209
+ }
210
+ /** Official prototypes this plugin's adapters mirror, when importable. */
211
+ function referencePrototypes() {
212
+ const out = {};
213
+ const workspace = classPrototypeOf(WORKSPACE_CLASS);
214
+ if (workspace !== undefined)
215
+ out.workspace = workspace;
216
+ const cache = classPrototypeOf(PROJECTION_CLASS);
217
+ if (cache !== undefined)
218
+ out.cache = cache;
219
+ // sessions 的五个私有方法全声明在官方 SessionStore.prototype 上(本插件 peer 依赖
220
+ // 同包同版本),与 workspace/cache 同机制接入文本比对 —— 此前该域只有存在性检查。
221
+ const sessions = classPrototypeOf(SESSIONS_CLASS);
222
+ if (sessions !== undefined)
223
+ out.sessions = sessions;
158
224
  return out;
159
225
  }
160
226
  /**
@@ -170,7 +236,7 @@ const CAPABILITY_SPECS = [
170
236
  label: '读取工作区状态',
171
237
  kind: 'read',
172
238
  owner: 'workspace',
173
- fallback: 'degrade-read',
239
+ fallback: 'refuse-operation',
174
240
  methods: ['requireState'],
175
241
  probe: (t) => {
176
242
  try {
@@ -194,7 +260,7 @@ const CAPABILITY_SPECS = [
194
260
  label: '读取工作区表',
195
261
  kind: 'read',
196
262
  owner: 'workspace',
197
- fallback: 'degrade-read',
263
+ fallback: 'refuse-operation',
198
264
  methods: ['requireTable'],
199
265
  probe: (t) => {
200
266
  try {
@@ -213,7 +279,7 @@ const CAPABILITY_SPECS = [
213
279
  label: '工作区索引结构',
214
280
  kind: 'read',
215
281
  owner: 'workspace',
216
- fallback: 'degrade-read',
282
+ fallback: 'refuse-operation',
217
283
  fields: [
218
284
  { name: 'headers', instanceOf: 'Map' },
219
285
  { name: 'sessionPaths', instanceOf: 'Map' },
@@ -226,7 +292,7 @@ const CAPABILITY_SPECS = [
226
292
  label: '读取会话头部',
227
293
  kind: 'read',
228
294
  owner: 'workspace',
229
- fallback: 'degrade-read',
295
+ fallback: 'refuse-operation',
230
296
  methods: ['readSessionHeader'],
231
297
  },
232
298
  // ---- workspace registry: write side ------------------------------------
@@ -262,6 +328,9 @@ const CAPABILITY_SPECS = [
262
328
  fallback: 'native-entry',
263
329
  methods: ['archiveSession'],
264
330
  optional: true,
331
+ // 分层探针第二层(哨兵真调):官方实现对未知 id 走读路径校验后受控拒绝
332
+ //(WorkspaceUnknownSessionError);同步 TypeError 才是「不该路由信任」的信号。
333
+ probe: (t) => callableWithSentinel(t, 'archiveSession', PROBE_SENTINEL),
265
334
  },
266
335
  {
267
336
  id: 'workspace.unarchive-native',
@@ -271,6 +340,7 @@ const CAPABILITY_SPECS = [
271
340
  fallback: 'native-entry',
272
341
  methods: ['unarchiveSession'],
273
342
  optional: true,
343
+ probe: (t) => callableWithSentinel(t, 'unarchiveSession', PROBE_SENTINEL),
274
344
  },
275
345
  {
276
346
  id: 'workspace.batch-native',
@@ -280,6 +350,7 @@ const CAPABILITY_SPECS = [
280
350
  fallback: 'native-entry',
281
351
  methods: ['archiveWorkspaceSessions'],
282
352
  optional: true,
353
+ probe: (t) => callableWithSentinel(t, 'archiveWorkspaceSessions', [PROBE_SENTINEL]),
283
354
  },
284
355
  {
285
356
  id: 'workspace.delete-native',
@@ -292,6 +363,10 @@ const CAPABILITY_SPECS = [
292
363
  fallback: 'native-entry',
293
364
  methods: ['deleteSession'],
294
365
  optional: true,
366
+ // 删除路由的 native 判定此前只看方法存在(07 审查五档问题 2):宿主升级把同名
367
+ // 方法换成别的签名,路由仍会走 native 裸调。哨兵真调把口径提到「可调用」,
368
+ // 文本比对收紧(见 inspectCapability 的 delete 类分支)负责「实现漂移」那一层。
369
+ probe: (t) => callableWithSentinel(t, 'deleteSession', PROBE_SENTINEL),
295
370
  },
296
371
  // ---- sessions runtime (private members, used only on the live branch) ---
297
372
  {
@@ -301,6 +376,11 @@ const CAPABILITY_SPECS = [
301
376
  owner: 'sessions',
302
377
  fallback: 'disable-destructive',
303
378
  methods: ['flush', 'liveEntryFor', 'detachEntered'],
379
+ // 零副作用真调(按官方源码核对):liveEntryFor/flush 对哨兵在查表处受控抛错;
380
+ // detachEntered 传普通对象在未知 id 处早退(entry 形状 {id} 即可)。
381
+ probe: (t) => callableWithSentinel(t, 'liveEntryFor', PROBE_SENTINEL)
382
+ ?? callableWithSentinel(t, 'flush', PROBE_SENTINEL)
383
+ ?? callableWithSentinel(t, 'detachEntered', { id: PROBE_SENTINEL }),
304
384
  },
305
385
  {
306
386
  id: 'sessions.cold-announce',
@@ -309,6 +389,9 @@ const CAPABILITY_SPECS = [
309
389
  owner: 'sessions',
310
390
  fallback: 'disable-destructive',
311
391
  methods: ['enter', 'announce'],
392
+ // announce 哨兵真调:内部先 liveEntryFor 查表,哨兵输入在查表处受控抛错。
393
+ // **enter 不真调**(官方实现对任意输入都会往 store 写入条目),它只有存在性 + 文本比对。
394
+ probe: (t) => callableWithSentinel(t, 'announce', PROBE_SENTINEL),
312
395
  },
313
396
  // ---- projection cache ---------------------------------------------------
314
397
  {
@@ -324,7 +407,7 @@ const CAPABILITY_SPECS = [
324
407
  label: '投影缓存删除屏障',
325
408
  kind: 'delete',
326
409
  owner: 'projectionCache',
327
- // Absent on rc.2: `history/bridge.js` installs a checked write barrier
410
+ // Absent on rc.2: `sessions/bridge.ts` installs a checked write barrier
328
411
  // instead, so absence is a routing fact, not a failure. Only a cache whose
329
412
  // write path cannot be wrapped at all is a real problem.
330
413
  fallback: 'native-entry',
@@ -365,7 +448,7 @@ export const OPERATION_ROUTES = {
365
448
  native: ['workspace.delete-native'],
366
449
  // NOTE: `projection.delete-native` is deliberately NOT a route requirement.
367
450
  // A host cache without its own delete barrier is expected on rc.2; the
368
- // bridge wraps it (`workspace.js` / `history/bridge.js`) and reports a
451
+ // bridge wraps it (`workspace.js` / `sessions/bridge.ts`) and reports a
369
452
  // refusal itself when even that is impossible. Requiring the native barrier
370
453
  // here would disable deletion on exactly the host this plugin was verified
371
454
  // against.
@@ -437,6 +520,21 @@ function inspectCapability(spec, target, reference) {
437
520
  return { ...base, state: 'shape-mismatch', detail: failure, missing: [], ...(textMatch === undefined ? {} : { textMatch }) };
438
521
  }
439
522
  }
523
+ // 删除类收紧(2026-09-19,07 审查五档问题 2):textMatch === false 意味着宿主运行的
524
+ // 不是本插件适配并验证过的实现(参考副本与宿主同源时恒真 —— junction 同物理文件;
525
+ // 只有宿主升级/漂移才会 false)。读/写类维持「按能力使用」的宽口径,但删除不可逆:
526
+ // 漂移的 flush/detachEntered/announce/deleteSession 一律不盲调,路由降级 adapter 或
527
+ // 拒绝,恢复文案引导更新插件。bridge.js 曾因无害重构误报而移除过文本比对 —— 本次
528
+ // 只收紧 delete 类,且参考副本不可解析时 textMatch 为 undefined,不拦截(优雅回退)。
529
+ if (textMatch === false && spec.kind === 'delete') {
530
+ return {
531
+ ...base,
532
+ state: 'shape-mismatch',
533
+ detail: '成员齐备但实现文本与本插件适配的版本不同:删除类能力不盲调漂移实现',
534
+ missing: [],
535
+ textMatch,
536
+ };
537
+ }
440
538
  return {
441
539
  ...base,
442
540
  state: 'ok',
@@ -458,6 +556,31 @@ function inspectCapability(spec, target, reference) {
458
556
  export const SUBSTITUTED_CAPABILITIES = CAPABILITY_SPECS
459
557
  .filter((spec) => spec.optional === true)
460
558
  .map((spec) => spec.id);
559
+ /**
560
+ * The capability table as a *static* view: for each spec, the official class
561
+ * whose prototype must carry the required members. This exists so a CLI without
562
+ * a running host (the doctor) can still answer "can this build archive / delete /
563
+ * list?".
564
+ *
565
+ * Derived from {@link CAPABILITY_SPECS} — the previous hand-written second table
566
+ * drifted silently: it carried 9 of the 15 ids and missed every `sessions.*` and
567
+ * native-slot capability the routing actually needs.
568
+ */
569
+ export const CAPABILITY_STATIC = CAPABILITY_SPECS.map((spec) => {
570
+ const ref = OWNER_CLASSES[spec.owner];
571
+ const methods = spec.methods;
572
+ return {
573
+ id: spec.id,
574
+ label: spec.label,
575
+ kind: spec.kind,
576
+ optional: spec.optional === true,
577
+ // A `fields`-only spec (instance Maps) and a spec whose judgement lives in
578
+ // its `probe` have nothing a prototype can answer — they stay runtime-only.
579
+ check: ref !== undefined && methods !== undefined && methods.length > 0
580
+ ? { pkg: ref.pkg, target: ref.target, members: methods }
581
+ : null,
582
+ };
583
+ });
461
584
  /**
462
585
  * Inspect the live host behind one plugin context.
463
586
  *
@@ -478,6 +601,9 @@ export function assessHost(ctx) {
478
601
  }
479
602
  };
480
603
  // Cordis exposes traceable proxies; compare and inspect the original objects.
604
+ // `Symbol.for('cordis.original')` 与 cordis 导出的 `symbols.original` 是**同一个符号**
605
+ // (该包内即 `original: Symbol.for("cordis.original")`)。这里不 import cordis,是为了让
606
+ // 兼容探测在宿主包加载失败时仍能工作 —— 本文件对宿主零硬依赖(见文件头的 createRequire)。
481
607
  const unwrap = (value) => {
482
608
  if (value === null || typeof value !== 'object')
483
609
  return value;
@@ -496,7 +622,7 @@ export function assessHost(ctx) {
496
622
  const targets = {
497
623
  workspace: { target: registry, reference: references.workspace },
498
624
  projectionCache: { target: cache, reference: references.cache },
499
- sessions: { target: sessions, reference: undefined },
625
+ sessions: { target: sessions, reference: references.sessions },
500
626
  persistence: { target: unwrap(get('sessionPersistence')), reference: undefined },
501
627
  };
502
628
  const findings = CAPABILITY_SPECS.map((spec) => {
@@ -507,6 +633,7 @@ export function assessHost(ctx) {
507
633
  const modules = {};
508
634
  const sameAsHost = {};
509
635
  const blockers = [];
636
+ const unverified = [];
510
637
  const hostRoot = hostPackageRoot();
511
638
  for (const name of IDENTITY_PACKAGES) {
512
639
  const resolved = safeResolve(name);
@@ -518,6 +645,7 @@ export function assessHost(ctx) {
518
645
  }
519
646
  if (hostRoot === null) {
520
647
  sameAsHost[name] = null;
648
+ unverified.push(name);
521
649
  continue;
522
650
  }
523
651
  // Resolve the same name from the host installation's own anchor, then
@@ -534,6 +662,10 @@ export function assessHost(ctx) {
534
662
  sameAsHost[name] = same;
535
663
  if (same === false)
536
664
  blockers.push(`${name}:插件与宿主加载的是两份不同拷贝(运行 node scripts/host-deps.mjs --fix)`);
665
+ // 解析得到、却比不了:宿主锚点里找不到它。不能静默 —— 否则整块身份校验等于没做,
666
+ // 而页头仍报「全部可用」(pnpm 的 .pnpm 隔离目录就是这种情形,见 hostPackageRoot)。
667
+ if (same === null)
668
+ unverified.push(name);
537
669
  }
538
670
  // Degraded = something is genuinely unavailable. Optional slots are excluded:
539
671
  // their absence selects the adapter route and leaves the feature fully
@@ -552,6 +684,7 @@ export function assessHost(ctx) {
552
684
  modules,
553
685
  sameAsHost,
554
686
  blockers,
687
+ unverified,
555
688
  },
556
689
  findings,
557
690
  degraded,
@@ -566,6 +699,11 @@ export function assessHost(ctx) {
566
699
  * Anchoring on the resolved entry rather than on `require.resolve('@deepseek-ai/dsh')`
567
700
  * matters: the plugin never imports the `dsh` app package, so it need not be
568
701
  * resolvable from the plugin at all — only the shared libraries are.
702
+ *
703
+ * 注意 pnpm:这里在 `.pnpm/<name>@<ver>/node_modules/` 布局下会返回**该包的隔离目录**
704
+ * (那也是一个 `node_modules/@deepseek-ai`)。它本身不是问题 —— node 的解析会继续向上走到
705
+ * 顶层 `node_modules`,所以能解析出的包集合与顶层锚点相同(实测确认)。真正的风险在
706
+ * 调用方:从锚点解析不到的包会被判成 `same = null`,见 assessHost 里的 `unverified`。
569
707
  */
570
708
  function hostPackageRoot() {
571
709
  for (const anchor of IDENTITY_PACKAGES) {
@@ -568,6 +568,17 @@ export function createContextInjector(deps) {
568
568
  if (!ctx || typeof ctx.on !== 'function')
569
569
  return { dispose: () => { }, live, noteToolUse };
570
570
  const stop = ctx.on('agent/pre-step', async (payload, next) => {
571
+ // 令牌门禁在**最前面**:没验过令牌时这一轮整个不放行,`next()` 都不必跑(后面那些注入
572
+ // 本来就是给模型看的,模型这一步根本不会被调用)。宿主据此把 turn 收成 `blocked`。
573
+ // 代价(宿主文档写明):被认领的那条用户消息会被丢弃 —— 这是"拦住"的固有代价,界面侧
574
+ // 的可见提示由兼容页那条「去填令牌」横幅承担。
575
+ try {
576
+ if (deps.tokenGateActive && deps.tokenGateActive()) {
577
+ log('令牌未验证:本轮对话被拒绝(在「工具 → 兼容」页填入访问令牌后恢复)');
578
+ return { kind: 'reject' };
579
+ }
580
+ }
581
+ catch { /* 门禁判定失败不能反过来卡住对话:当作放行 */ }
571
582
  const decision = await next();
572
583
  try {
573
584
  if (!decision || decision.kind === 'reject')
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 宿主身份常量:插件在文件里认的这几个名字只在这里写一次。
3
+ *
4
+ * 以前 `@deepseek-ai/dsh-mcp-client` 在 `index.ts` 里有 6 份副本(2 份写给补丁的 YAML、
5
+ * 2 份解析补丁时的比对、2 份运行时清单匹配),`preset-reach.ts` 里还有第 7 份 —— 官方改包名时
6
+ * 少改一处就是「开关点了没反应」或「列表凭空少一行」。档案目录名同理。
7
+ */
8
+ export const MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client';
9
+ /** `ensurePaths()` 的档案探测顺序(先 web 后 headless);都不在才退到「任意带 patch 的档案」。 */
10
+ export const PROFILE_CANDIDATES = ['web', 'headless'];
11
+ /** 探测不到任何档案时的兜底目录名(历史上的默认档案)。 */
12
+ export const DEFAULT_PROFILE_NAME = 'web';
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`(宿主加载插件的配置入口)。
@@ -365,14 +371,34 @@ export async function listTrashEntries(kind) {
365
371
  export async function readTrashEntry(kind, id) {
366
372
  return await readManifest(kind, id);
367
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
+ }
368
392
  /**
369
393
  * 把条目里的一个负载搬回 `to`。**不覆盖**:调用方必须先确认 `to` 不存在。
370
- * @throws 条目或负载缺失时抛错(调用方翻成人话)。
394
+ * @throws 条目、负载名或负载缺失时抛错(调用方翻成人话)。
371
395
  */
372
396
  export async function moveOutOfTrash(kind, id, dest, to) {
373
397
  const dir = trashEntryPath(kind, id);
374
398
  if (dir === null)
375
399
  throw new Error(`回收站条目 id 非法:${id}`);
400
+ if (!isValidTrashPayloadName(dest))
401
+ throw new Error(`回收站负载名非法:${dest}`);
376
402
  const from = join(dir, dest);
377
403
  await mkdir(dirname(to), { recursive: true });
378
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;