dsh-plugin-tool-management 0.5.1 → 0.7.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 (49) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +185 -238
  3. package/README_EN.md +185 -304
  4. package/cordis.patch.yml +9 -55
  5. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  6. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  7. package/docs/images/2MCP.png +0 -0
  8. package/docs/images/2MCP_en.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  10. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  12. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  14. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  16. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  18. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  20. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  21. package/docs/update.md +55 -0
  22. package/lib/agents-md/preset-id.js +49 -0
  23. package/lib/agents-md/service.js +180 -57
  24. package/lib/client.js +4710 -3560
  25. package/lib/compat/preset-reach.js +425 -0
  26. package/lib/compat/probe.js +665 -0
  27. package/lib/history/bridge.js +293 -0
  28. package/lib/history/workspace.js +498 -52
  29. package/lib/http-fence.js +94 -0
  30. package/lib/hub.js +160 -2
  31. package/lib/index.js +759 -157
  32. package/lib/mcp/override-blocks.js +195 -0
  33. package/lib/rules/provider.js +3 -3
  34. package/lib/rules/service.js +342 -31
  35. package/lib/scene-prompt-sync.js +112 -0
  36. package/lib/skills/core.js +123 -35
  37. package/lib/skills/service.js +9 -1
  38. package/lib/subagents/service.js +197 -23
  39. package/lib/subagents/tools.js +8 -2
  40. package/package.json +6 -3
  41. package/screenshots.json +10 -9
  42. package/docs/Changelog.md +0 -517
  43. package/docs/images/MCP.png +0 -0
  44. package/docs/images//344/274/232/350/257/235.png +0 -0
  45. package/docs/images//345/234/272/346/231/257.png +0 -0
  46. package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  47. package/docs/images//346/212/200/350/203/275.png +0 -0
  48. package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
  49. package/docs/images//350/256/260/345/277/206.png +0 -0
@@ -0,0 +1,94 @@
1
+ // 插件 HTTP 路由的请求栅栏。
2
+ //
3
+ // 为什么需要它:宿主的 BrowserAuth 栅栏**只包在宿主自己注册的路由上**
4
+ // (dsh-client-connection 的 register()/upgrade、dsh-api-gateway、
5
+ // dsh-host-open-in-app),dsh-host-webserver 本身没有全局中间件。宿主生态的
6
+ // 既定约定是每个注册方自己调 `connection.requestRejection(req)`,它做两件事:
7
+ //
8
+ // 1. Host/Origin 栅栏 —— Host 必须是回环地址、deployment 派生的 LAN IP 字面量
9
+ // 或声明的 trustedHosts。Host 是 DNS rebinding 唯一伪造不了的头:攻击者
10
+ // 控制域名 → 浏览器发 `Host: evil.com`,但插件的自比对式 Origin 检查
11
+ // (origin.host === host)会同时成立,于是被误判为同源。
12
+ // 2. browser-session cookie 鉴权(HttpOnly + SameSite=Strict,Path=/)。
13
+ //
14
+ // 只查公开常量 `x-dsh-plugin` 与自比对 Origin 等于没鉴权:本机任意进程都能调写 op,
15
+ // 而写 op 里包含 `mcpm-add`(command/args 无白名单,宿主 stdio 传输会 spawn 它
16
+ // → 任意命令执行)与 `history-delete`(永久删除会话)。
17
+ const headerValue = (req, name) => {
18
+ const v = req.headers?.[name];
19
+ return Array.isArray(v) ? v[0] ?? '' : v ?? '';
20
+ };
21
+ /**
22
+ * 判定请求是否应被拒(返回 null = 放行)。
23
+ *
24
+ * 优先用宿主栅栏;它不在场时(非 web 组合、服务未注册)退回等价的本地判定,
25
+ * 而不是静默放行。栅栏自身抛错时同样退回本地判定 —— 宁可拒绝也不要因为宿主
26
+ * 内部变动而变成开放路由。
27
+ *
28
+ * @param req - Node 请求对象(只读 headers)。
29
+ * @param connection - ctx.get('connection'),可缺失。
30
+ */
31
+ export function fenceRejection(req, connection) {
32
+ try {
33
+ const rejection = connection?.requestRejection?.(req);
34
+ if (typeof rejection === 'number') {
35
+ return {
36
+ status: rejection,
37
+ error: rejection === 401
38
+ ? '未通过宿主的浏览器鉴权:请在 DSH Web GUI 内操作,或为本插件配置访问令牌(config.token / DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN)'
39
+ : 'Host/Origin 栅栏拒绝:该请求不是发往本机地址的同源请求',
40
+ };
41
+ }
42
+ // 栅栏在场且未返回拒绝码 ⇒ 已通过,不再叠加本地判定(宿主的 trustedHosts
43
+ // 部署信息这里拿不到,重复判定只会更松或更严,都不对)。
44
+ if (typeof connection?.requestRejection === 'function')
45
+ return null;
46
+ }
47
+ catch (e) { /* 见上方说明:退回本地判定 */ }
48
+ const hostHdr = headerValue(req, 'host');
49
+ let host;
50
+ try {
51
+ host = new URL('http://' + hostHdr);
52
+ }
53
+ catch {
54
+ return { status: 403, error: 'Host 头缺失或无法解析' };
55
+ }
56
+ const loopback = host.hostname === 'localhost' || host.hostname === '[::1]'
57
+ || /^127(?:\.\d{1,3}){3}$/.test(host.hostname);
58
+ if (!loopback)
59
+ return { status: 403, error: 'Host 必须是本机回环地址(防 DNS rebinding)' };
60
+ if (headerValue(req, 'sec-fetch-site') === 'cross-site')
61
+ return { status: 403, error: '跨站请求被拒' };
62
+ const origin = headerValue(req, 'origin');
63
+ if (origin) {
64
+ try {
65
+ if (new URL(origin).host !== host.host)
66
+ return { status: 403, error: '跨源请求被拒' };
67
+ }
68
+ catch {
69
+ return { status: 403, error: 'Origin 头无法解析' };
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * 判定敏感 op 是否应被拒(返回 null = 放行)。
76
+ *
77
+ * 两种情况分开报,因为处置方式不同:没配令牌要去宿主配置里加,配了但没带/带错
78
+ * 只要在界面里填对即可(界面按 code 决定给不给输入框)。
79
+ */
80
+ 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
+ }
87
+ if (!state.tokenAccepted) {
88
+ return {
89
+ code: 'error.secret.badToken',
90
+ error: '访问令牌缺失或不正确:请在界面里填入与宿主配置相同的令牌(随请求以 x-dsh-token 发送)。',
91
+ };
92
+ }
93
+ return null;
94
+ }
package/lib/hub.js CHANGED
@@ -24,8 +24,9 @@
24
24
  // 搬不动的(被占用、跨卷失败)留在原地,下次启动再试。
25
25
  // 2. **绝不覆盖**——目标已存在同名项时跳过该项,保留目标。
26
26
  // 3. **每进程一次**——迁移是启动期的一次性动作,失败也不阻断服务(旧目录仍可读)。
27
- import { cp, lstat, mkdir, readdir, rename, rm } from 'node:fs/promises';
28
- import { join } from 'node:path';
27
+ import { cp, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
28
+ import { randomUUID } from 'node:crypto';
29
+ import { join, dirname } from 'node:path';
29
30
  import { resolveDshHome } from './skills/core.js';
30
31
  /** hub 根目录名(`$DSH_HOME` 下)。 */
31
32
  export const HUB_DIR = 'tool-management';
@@ -92,3 +93,160 @@ export async function relocateEntries(from, to, filter) {
92
93
  }
93
94
  return moved;
94
95
  }
96
+ /** 某一类回收站的根目录:`hub/trash/<kind>-trash`。 */
97
+ export function trashKindDir(kind) {
98
+ return hubPath('trash', `${kind}-trash`);
99
+ }
100
+ /** 新条目 id(时间戳 base36 + uuid 前 8 位,与记忆回收站同形)。 */
101
+ export function newTrashId() {
102
+ return Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
103
+ }
104
+ /** id 只由本模块生成:严格白名单,杜绝路径穿越。 */
105
+ export function isValidTrashId(id) {
106
+ return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(id);
107
+ }
108
+ /** 条目目录(id 非法时返回 null)。 */
109
+ export function trashEntryPath(kind, id) {
110
+ if (!isValidTrashId(id))
111
+ return null;
112
+ return join(trashKindDir(kind), id);
113
+ }
114
+ async function readManifest(kind, id) {
115
+ const dir = trashEntryPath(kind, id);
116
+ if (dir === null)
117
+ return null;
118
+ try {
119
+ const parsed = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
120
+ if (parsed === null || typeof parsed !== 'object')
121
+ return null;
122
+ if (String(parsed.kind) !== kind || String(parsed.id) !== id)
123
+ return null;
124
+ return {
125
+ v: Number(parsed.v) || 1,
126
+ kind,
127
+ id,
128
+ name: String(parsed.name ?? ''),
129
+ deletedAt: String(parsed.deletedAt ?? ''),
130
+ files: Array.isArray(parsed.files) ? parsed.files.map((n) => String(n)) : [],
131
+ ...(parsed.data === undefined ? {} : { data: parsed.data }),
132
+ };
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ /**
139
+ * 把一个文件/目录移入回收站(新建条目)。
140
+ *
141
+ * @param kind 种类(决定子目录)
142
+ * @param name 恢复时用的名字
143
+ * @param moves 要搬的项:`from` 绝对路径 → 条目目录内的 `dest` 名
144
+ * @param data 可选的非文件型负载(如场景的记录与档案)
145
+ */
146
+ export async function moveToTrash(kind, name, moves, data) {
147
+ const id = newTrashId();
148
+ const dir = join(trashKindDir(kind), id);
149
+ const done = [];
150
+ try {
151
+ await mkdir(dir, { recursive: true });
152
+ for (const move of moves) {
153
+ const to = join(dir, move.dest);
154
+ try {
155
+ await rename(move.from, to);
156
+ }
157
+ catch {
158
+ // 跨卷 / 被占用 → 复制后删源;两步都失败就抛出去走回滚。
159
+ await cp(move.from, to, { recursive: true });
160
+ await rm(move.from, { recursive: true, force: true });
161
+ }
162
+ done.push({ from: move.from, to });
163
+ }
164
+ const manifest = {
165
+ v: 1,
166
+ kind,
167
+ id,
168
+ name,
169
+ deletedAt: new Date().toISOString(),
170
+ files: moves.map((m) => m.dest),
171
+ ...(data === undefined ? {} : { data }),
172
+ };
173
+ await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
174
+ return { ok: true, id };
175
+ }
176
+ catch (e) {
177
+ // 回滚:把已经搬走的搬回原处,再删掉半成品条目目录。
178
+ for (const item of done.reverse()) {
179
+ try {
180
+ await rename(item.to, item.from);
181
+ }
182
+ catch {
183
+ try {
184
+ await cp(item.to, item.from, { recursive: true });
185
+ await rm(item.to, { recursive: true, force: true });
186
+ }
187
+ catch { /* 回滚失败:把原因交给调用方的错误信息 */ }
188
+ }
189
+ }
190
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
191
+ return { ok: false, error: String(e?.message ?? e) };
192
+ }
193
+ }
194
+ /** 列出某一类回收站的条目(按删除时间倒序)。 */
195
+ export async function listTrashEntries(kind) {
196
+ let names;
197
+ try {
198
+ names = (await readdir(trashKindDir(kind), { withFileTypes: true }))
199
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
200
+ .map((e) => e.name);
201
+ }
202
+ catch {
203
+ return [];
204
+ }
205
+ const entries = [];
206
+ for (const id of names) {
207
+ const manifest = await readManifest(kind, id);
208
+ if (!manifest)
209
+ continue;
210
+ entries.push({
211
+ id: manifest.id,
212
+ name: manifest.name,
213
+ deletedAt: manifest.deletedAt,
214
+ files: manifest.files,
215
+ hasData: manifest.data !== undefined,
216
+ });
217
+ }
218
+ return entries.sort((a, b) => (a.deletedAt === b.deletedAt ? b.id.localeCompare(a.id) : b.deletedAt.localeCompare(a.deletedAt)));
219
+ }
220
+ /** 读一条回收站条目的清单(不存在/损坏返回 null)。 */
221
+ export async function readTrashEntry(kind, id) {
222
+ return await readManifest(kind, id);
223
+ }
224
+ /**
225
+ * 把条目里的一个负载搬回 `to`。**不覆盖**:调用方必须先确认 `to` 不存在。
226
+ * @throws 条目或负载缺失时抛错(调用方翻成人话)。
227
+ */
228
+ export async function moveOutOfTrash(kind, id, dest, to) {
229
+ const dir = trashEntryPath(kind, id);
230
+ if (dir === null)
231
+ throw new Error(`回收站条目 id 非法:${id}`);
232
+ const from = join(dir, dest);
233
+ await mkdir(dirname(to), { recursive: true });
234
+ try {
235
+ await rename(from, to);
236
+ }
237
+ catch {
238
+ await cp(from, to, { recursive: true });
239
+ await rm(from, { recursive: true, force: true });
240
+ }
241
+ }
242
+ /** 永久删除一条回收站条目(连带负载)。 */
243
+ export async function purgeTrashEntry(kind, id) {
244
+ const dir = trashEntryPath(kind, id);
245
+ if (dir === null)
246
+ return false;
247
+ const manifest = await readManifest(kind, id);
248
+ if (!manifest)
249
+ return false;
250
+ await rm(dir, { recursive: true, force: true });
251
+ return true;
252
+ }