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.
- package/CHANGELOG.md +71 -1
- package/README.md +64 -49
- package/README_EN.md +58 -37
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +105 -12
- package/lib/client.js +2763 -546
- package/lib/compat/preset-reach.js +1 -10
- package/lib/compat/probe.js +158 -20
- package/lib/context-inject.js +11 -0
- package/lib/host-names.js +12 -0
- package/lib/http-fence.js +35 -15
- package/lib/hub.js +28 -2
- package/lib/imports/parsers.js +15 -9
- package/lib/imports/upload.js +43 -4
- package/lib/index.js +804 -3887
- package/lib/mcp/loader-token.js +238 -0
- package/lib/mcp/manager.js +1681 -0
- package/lib/mcp/override-blocks.js +10 -3
- package/lib/mcp/patch-yaml.js +351 -0
- package/lib/mcp/secret-guard.js +145 -0
- package/lib/{rules → memories}/archive-engine.js +1 -1
- package/lib/{rules → memories}/archive.js +1 -1
- package/lib/memories/constants.js +128 -0
- package/lib/memories/index-io.js +330 -0
- package/lib/memories/projection.js +280 -0
- package/lib/memories/service.js +686 -0
- package/lib/memories/snapshot.js +672 -0
- package/lib/ops/candidates.js +64 -0
- package/lib/ops/compat.js +136 -0
- package/lib/ops/ctx.js +9 -0
- package/lib/ops/memory.js +678 -0
- package/lib/ops/prompts.js +107 -0
- package/lib/ops/scene-records.js +460 -0
- package/lib/ops/scene-sync.js +17 -0
- package/lib/ops/sessions.js +603 -0
- package/lib/ops/trash.js +140 -0
- package/lib/paths.js +103 -0
- package/lib/prompts/preset-id.js +49 -0
- package/lib/{agents-md → prompts}/service.js +1 -1
- package/lib/request-gate.js +320 -0
- package/lib/scene-prompt-sync.js +4 -4
- package/lib/scenes/candidates.js +344 -0
- package/lib/{history → sessions}/bridge.js +15 -5
- package/lib/sessions/history.js +323 -0
- package/lib/{history → sessions}/tombstone.js +1 -1
- package/lib/{history → sessions}/workspace.js +92 -24
- package/lib/skills/core.js +74 -39
- package/lib/skills/readonly-discovery.js +4 -1
- package/lib/skills/service.js +98 -13
- package/lib/subagents/service.js +199 -55
- package/lib/tools/deps.js +8 -0
- package/lib/tools/mcp.js +110 -0
- package/lib/tools/memory.js +87 -0
- package/lib/tools/prompt.js +70 -0
- package/lib/tools/skills.js +139 -0
- package/lib/tools/subagent.js +40 -0
- package/package.json +7 -7
- package/lib/agents-md/preset-id.js +0 -49
- 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*(?:#.*)?$/;
|
package/lib/compat/probe.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* WHY THIS REPLACES THE TEXT-COMPARISON GATE
|
|
6
6
|
* ------------------------------------------
|
|
7
|
-
* The previous gate in `
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
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 {
|
|
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
|
|
154
|
-
if (
|
|
155
|
-
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: `
|
|
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` / `
|
|
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:
|
|
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) {
|
package/lib/context-inject.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
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 {
|
package/lib/imports/parsers.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
out +=
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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;
|