dsh-plugin-tool-management 0.17.0 → 0.18.5

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 (84) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +19 -4
  3. package/README_EN.md +19 -4
  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 +29 -0
  21. package/lib/client.js +1662 -161
  22. package/lib/compat/capability-specs.js +321 -0
  23. package/lib/compat/host-probe.js +261 -0
  24. package/lib/compat/host-roots.js +168 -0
  25. package/lib/compat/probe-types.js +6 -0
  26. package/lib/compat/probe.js +9 -720
  27. package/lib/context-inject-contract.js +114 -0
  28. package/lib/context-inject-frame.js +147 -0
  29. package/lib/context-inject-select.js +127 -0
  30. package/lib/context-inject-surface.js +97 -0
  31. package/lib/context-inject.js +10 -478
  32. package/lib/exports/transcript.js +223 -0
  33. package/lib/http-api.js +229 -0
  34. package/lib/hub.js +2 -0
  35. package/lib/index.js +138 -868
  36. package/lib/mcp/inspect-probe.js +199 -0
  37. package/lib/mcp/manager.js +30 -548
  38. package/lib/mcp/tool-visibility.js +388 -0
  39. package/lib/memories/archive-engine.js +142 -5
  40. package/lib/memories/archive.js +10 -1
  41. package/lib/memories/discovery.js +261 -0
  42. package/lib/memories/export-plan.js +42 -0
  43. package/lib/memories/memories-types.js +1 -0
  44. package/lib/memories/service.js +4 -38
  45. package/lib/memories/snapshot.js +10 -252
  46. package/lib/op-registry.js +21 -0
  47. package/lib/ops/compat.js +11 -0
  48. package/lib/ops/quick-prompts.js +26 -0
  49. package/lib/ops/scene-records.js +40 -0
  50. package/lib/ops/scene-sync.js +71 -1
  51. package/lib/ops/sessions.js +34 -14
  52. package/lib/ops/snapshot.js +18 -12
  53. package/lib/prompts/quick-service.js +211 -0
  54. package/lib/scene-linkage.js +184 -0
  55. package/lib/sessions/archive-schemas.js +121 -0
  56. package/lib/sessions/path-key.js +46 -0
  57. package/lib/sessions/workspace.js +29 -166
  58. package/lib/settings-sidecars.js +143 -0
  59. package/lib/skills/core-types.js +8 -0
  60. package/lib/skills/core-util.js +81 -0
  61. package/lib/skills/core.js +56 -2326
  62. package/lib/skills/dir-watch.js +71 -0
  63. package/lib/skills/frontmatter.js +96 -0
  64. package/lib/skills/homes.js +14 -0
  65. package/lib/skills/import-pipeline.js +231 -0
  66. package/lib/skills/manager-state.js +800 -0
  67. package/lib/skills/official.js +269 -0
  68. package/lib/skills/provider-registration.js +89 -0
  69. package/lib/skills/service.js +5 -240
  70. package/lib/skills/skill-roots.js +287 -0
  71. package/lib/skills/skill-trash.js +265 -0
  72. package/lib/skills/system-trash.js +97 -0
  73. package/lib/skills/upload-pipeline.js +374 -0
  74. package/lib/slash-settings.js +25 -0
  75. package/lib/subagents/persona-parse.js +312 -0
  76. package/lib/subagents/persona-types.js +5 -0
  77. package/lib/subagents/persona-write.js +93 -0
  78. package/lib/subagents/run-provider.js +159 -0
  79. package/lib/subagents/service.js +14 -638
  80. package/lib/subagents/tool-filter.js +98 -0
  81. package/lib/token-config.js +240 -0
  82. package/lib/tool-table-switch.js +163 -0
  83. package/lib/tools/scene.js +40 -9
  84. package/package.json +3 -2
@@ -0,0 +1,223 @@
1
+ // 会话转录导出:把宿主的 SessionEvent[] 提成可再导入的转录文本。
2
+ //
3
+ // 从 src/index.ts 剥出来(0.18.5),同时补上两件事:
4
+ //
5
+ // 1. **按块类型取文本**。宿主的内容块是带 type 标签的联合(dsh-llm types.d.ts:114-122):
6
+ // `text` 是用户可见正文,`reasoning` 是思考,`tool-call` 是模型发起的调用。此前导出走
7
+ // `extractText`,它只判「这个对象有没有 text 字段」—— 而 `ReasoningBlock` 恰好就是
8
+ // `{type:'reasoning', text}`,所以**思考一直混在助手正文里导出去**。同一类缺陷 0.16.x
9
+ // 已在子智能体侧用 `textOfBlocks` 修过(persona-parse.ts:275),导出侧是它的第二个出口。
10
+ // 2. **导出分三档,且是累进的**:正文 → 另加工具 → 再加思考。每档都是上一档的超集,
11
+ // 所以一个下拉就说得清(两个独立勾选框是另一种答案,用户不要)。
12
+ // 三档仍是**可读转录,不是完整备份**:每块有上限并明说截断,token 统计与压缩结构不在里面。
13
+ //
14
+ // `extractText` 本身不动:它服务的是外部文件导入,那份宽松是故意的(外来转录稿没有
15
+ // `reasoning` 词表可守)。
16
+ import { textOfBlocks } from '../subagents/persona-parse.js';
17
+ // 上限是**病态保护**:工具结果可以是一次几 MB 的文件读取,不设限的话一个会话就能
18
+ // 导出上百 MB。取最靠前的那一段 —— 工具输出的头部几乎总是最有用的那部分。
19
+ const REASONING_MAX = 6000;
20
+ const TOOL_INPUT_MAX = 1000;
21
+ const TOOL_RESULT_MAX = 2000;
22
+ /** 裁到 max 个字符并留下标记。标记固定英文:op 跑在服务端,没有客户端词典可用。 */
23
+ function cap(text, max) {
24
+ const s = String(text || '');
25
+ if (s.length <= max)
26
+ return s;
27
+ return `${s.slice(0, max)}\n[truncated: showing ${max} of ${s.length} chars]`;
28
+ }
29
+ function str(value) {
30
+ return typeof value === 'string' ? value : '';
31
+ }
32
+ function blocksOf(content) {
33
+ return Array.isArray(content) ? content : [];
34
+ }
35
+ function formatBytes(n) {
36
+ const v = Number(n);
37
+ if (!Number.isFinite(v) || v < 0)
38
+ return 'unknown size';
39
+ if (v < 1024)
40
+ return `${v} B`;
41
+ if (v < 1024 * 1024)
42
+ return `${Math.round(v / 1024)} KB`;
43
+ return `${(Math.round((v / 1024 / 1024) * 10) / 10).toFixed(1)} MB`;
44
+ }
45
+ /** 附件只报名字 / 类型 / 尺寸 / 字节数 —— `attachmentId` 官方定义为不透明串,进了转录稿只是噪声。 */
46
+ function attachmentLines(content) {
47
+ const lines = [];
48
+ for (const block of blocksOf(content)) {
49
+ const ref = (block.attachment && typeof block.attachment === 'object' ? block.attachment : {});
50
+ if (block.type === 'image') {
51
+ const media = str(ref.mediaType) || 'image';
52
+ const w = Number(ref.width);
53
+ const h = Number(ref.height);
54
+ const dims = Number.isFinite(w) && Number.isFinite(h) ? ` ${w}×${h}` : '';
55
+ const name = str(ref.name);
56
+ lines.push(`image: ${name ? name + ' — ' : ''}${media}${dims} (${formatBytes(ref.bytes)})`);
57
+ }
58
+ else if (block.type === 'file') {
59
+ lines.push(`file: ${str(ref.name) || '(未命名)'} (${formatBytes(ref.bytes)})`);
60
+ }
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+ function toolLabel(name, kind, failed = false) {
65
+ return `Tool: ${name} — ${kind}${failed ? ' (failed)' : ''}`;
66
+ }
67
+ /**
68
+ * 把会话事件提成导出用的轮次。
69
+ *
70
+ * 工具的**发起**在两处都有记录:`assistant/message` 里的 `tool-call` 块(模型可见真相),
71
+ * 与 log-only 的 `tool/call` 事件(持久化记录)。两边都收、按 call id 去重 —— 只认一边
72
+ * 会在某种宿主形状下静默丢掉全部工具调用,而"导出里看不见工具调用"正是这份文件此前的毛病。
73
+ * 配不上任何调用的 `tool/result` 也照样单独成段,同样不静默丢。
74
+ */
75
+ export function extractExportTurns(events, detail) {
76
+ const wantTools = detail !== 'simple';
77
+ const wantThinking = detail === 'full';
78
+ const turns = [];
79
+ const namesByCallId = new Map();
80
+ const emittedCallIds = new Set();
81
+ let current = null;
82
+ const flush = () => {
83
+ // 空正文 + 空附加段 = 这一轮什么都没有,不占位。
84
+ if (current && (current.text || current.details.length))
85
+ turns.push(current);
86
+ current = null;
87
+ };
88
+ /** 工具细节总归属于助手轮;正文已经翻篇时就另起一轮。 */
89
+ const openAssistant = () => {
90
+ if (!current || current.role !== 'assistant') {
91
+ flush();
92
+ current = { role: 'assistant', text: '', details: [] };
93
+ }
94
+ return current;
95
+ };
96
+ for (const event of events || []) {
97
+ if (!event || typeof event !== 'object')
98
+ continue;
99
+ const e = event;
100
+ const data = (e.data && typeof e.data === 'object' ? e.data : {});
101
+ if (e.type === 'user/message') {
102
+ flush();
103
+ const turn = { role: 'user', text: textOfBlocks(data.content, 'text'), details: [] };
104
+ // 附件跟工具一起进中间档:它是「对话里出现过什么」的一部分,不是「模型想了什么」。
105
+ if (wantTools) {
106
+ const attachments = attachmentLines(data.content);
107
+ if (attachments)
108
+ turn.details.push({ label: 'Attachment', text: attachments });
109
+ }
110
+ current = turn;
111
+ continue;
112
+ }
113
+ if (e.type === 'assistant/message') {
114
+ flush();
115
+ const message = (data.message && typeof data.message === 'object' ? data.message : {});
116
+ const turn = { role: 'assistant', text: textOfBlocks(message.content, 'text'), details: [] };
117
+ // 顺序即模型产出的顺序:先思考、后调用。两档都开时(full)这个次序天然成立,
118
+ // 因为 full 必然也开 wantTools。
119
+ if (wantThinking) {
120
+ const reasoning = textOfBlocks(message.content, 'reasoning');
121
+ if (reasoning)
122
+ turn.details.push({ label: 'Thinking', text: cap(reasoning, REASONING_MAX) });
123
+ }
124
+ if (wantTools) {
125
+ for (const block of blocksOf(message.content)) {
126
+ if (block.type !== 'tool-call')
127
+ continue;
128
+ const id = str(block.id);
129
+ const name = str(block.name) || '(unnamed)';
130
+ if (id) {
131
+ namesByCallId.set(id, name);
132
+ emittedCallIds.add(id);
133
+ }
134
+ turn.details.push({ label: toolLabel(name, 'in'), text: cap(str(block.arguments), TOOL_INPUT_MAX) });
135
+ }
136
+ }
137
+ current = turn;
138
+ continue;
139
+ }
140
+ // 往下只剩工具与结果两类事件,中间档就要,只有简洁档跳过。
141
+ if (!wantTools)
142
+ continue;
143
+ if (e.type === 'tool/call') {
144
+ const id = str(data.callId);
145
+ const name = str(data.name) || namesByCallId.get(id) || '(unnamed)';
146
+ if (id)
147
+ namesByCallId.set(id, name);
148
+ // assistant 块里已经列过同一个调用,就别重复成段。
149
+ if (id && emittedCallIds.has(id))
150
+ continue;
151
+ if (id)
152
+ emittedCallIds.add(id);
153
+ openAssistant().details.push({ label: toolLabel(name, 'in'), text: cap(str(data.arguments), TOOL_INPUT_MAX) });
154
+ continue;
155
+ }
156
+ if (e.type === 'tool/result') {
157
+ const message = (data.message && typeof data.message === 'object' ? data.message : {});
158
+ const id = str(message.toolCallId);
159
+ const name = namesByCallId.get(id) || '(unknown)';
160
+ const failed = message.isError === true;
161
+ const body = textOfBlocks(message.content, 'text');
162
+ const blocks = blocksOf(message.content);
163
+ openAssistant().details.push({
164
+ label: toolLabel(name, 'out', failed),
165
+ text: cap(body || (blocks.length ? `(无文本输出,${blocks.length} 个内容块)` : '(无输出)'), TOOL_RESULT_MAX),
166
+ });
167
+ // `error` 官方注释明说它在模型可见消息之外 —— 带工具的那两档是用户唯一能看到它的地方。
168
+ const err = (data.error && typeof data.error === 'object' ? data.error : undefined);
169
+ if (err) {
170
+ const reason = str(err.reason) || str(err.code) || str(err.name);
171
+ if (reason)
172
+ openAssistant().details.push({ label: toolLabel(name, 'error'), text: cap(`${str(err.name)}/${str(err.code)} — ${reason}`, TOOL_RESULT_MAX) });
173
+ }
174
+ continue;
175
+ }
176
+ }
177
+ flush();
178
+ return turns;
179
+ }
180
+ /**
181
+ * 把载荷整段缩进 4 空格。
182
+ *
183
+ * 这是**回流保护**,不是排版:工具结果可以是任意文件内容,读进一份 markdown 转录稿就会把
184
+ * 里面的 `## User` 原样吐进助手正文,再导入时把轮次劈开。缩进同时废掉 `HEADING_RE` 与
185
+ * `BOLD_PREFIX_RE` 两个行首锚点(两者都不允许前导空白)。空行保持真空,不留尾随空格。
186
+ */
187
+ function indentBlock(text) {
188
+ return String(text || '')
189
+ .split('\n')
190
+ .map((line) => (line.trim() ? ` ${line}` : ''))
191
+ .join('\n');
192
+ }
193
+ /**
194
+ * 序列化为可再导入的转录文本:Codex 风格 Markdown 或 Claude Code 风格 JSONL。
195
+ *
196
+ * 签名与 0.18.5 之前一致 —— 附加信息全在 `turn.details` 里,所以 `details` 为空时两种输出
197
+ * 与旧版逐字节相同。`## User` / `### Assistant` 两级标题不能改:导入侧靠它切轮次与嗅探格式。
198
+ */
199
+ export function serializeTranscript(turns, format) {
200
+ const list = Array.isArray(turns) ? turns : [];
201
+ if (format === 'jsonl') {
202
+ // content 保持**字符串**:改成类型化块数组的话,导入侧的 extractText 会把 reasoning
203
+ // 又拼回正文(正是我们在修的那个泄漏),并让 tool-call 无声消失 —— 严格劣于字符串。
204
+ return list
205
+ .map((turn) => {
206
+ let content = turn.text;
207
+ for (const item of turn.details)
208
+ content += `${content ? '\n\n' : ''}[${item.label}]\n${item.text}`;
209
+ return JSON.stringify({ type: turn.role, message: { role: turn.role, content } });
210
+ })
211
+ .join('\n');
212
+ }
213
+ return list
214
+ .map((turn) => {
215
+ const head = turn.role === 'user' ? '## User' : '### Assistant';
216
+ const blocks = [turn.text ? `${head}\n${turn.text}` : head];
217
+ // 四级标题:`#{1,3}` 匹配不到,所以附加段再导入时只会并入所属轮次,不会伪装成边界。
218
+ for (const item of turn.details)
219
+ blocks.push(`#### ${item.label}\n${indentBlock(item.text)}`);
220
+ return blocks.join('\n\n');
221
+ })
222
+ .join('\n\n');
223
+ }
@@ -0,0 +1,229 @@
1
+ import { noteRuntime } from './compat/runtime-notes.js';
2
+ import { recordOpAudit } from './audit-log.js';
3
+ import { isReadCall } from './op-registry.js';
4
+ import { TOKEN_CODE_BAD, TOKEN_MSG, fenceRejection, secretOpRejection } from './http-fence.js';
5
+ export function installHttpApi(deps) {
6
+ // ---------- HTTP API route (UI half), registered defensively ----------
7
+ if (deps.webServer) {
8
+ // Cap request bodies (88 MiB): skill-upload carries Base64 folder/ZIP
9
+ // payloads (64 MiB of raw content). Reachability is decided by
10
+ // fenceRejection() below — the host's Host/Origin + browser-session fence
11
+ // (or a correct explicit token). `config.maxBodyBytes` overrides the cap
12
+ // (tests use a small value).
13
+ const configuredMaxBody = Number(deps.config?.maxBodyBytes);
14
+ // 默认 88 MiB:够塞下一份带多张大附件(图片 / PDF)的技能或记忆打包体,又不至于让
15
+ // 一个失控的客户端把进程内存吃光。`config.maxBodyBytes` 可覆盖(测试用小值)。
16
+ const DEFAULT_MAX_BODY_BYTES = 88 * 1024 * 1024;
17
+ const MAX_BODY = Number.isFinite(configuredMaxBody) && configuredMaxBody > 0 ? configuredMaxBody : DEFAULT_MAX_BODY_BYTES;
18
+ const readBody = (req) => new Promise((resolve, reject) => {
19
+ // 先看 content-length:注定超限的请求在**读第一个字节之前**就拒掉。以前只能边收边数,
20
+ // 一个 5 GB 的上传会先让我们缓冲满 88 MiB 才回错。读取流照旧 drain(一个字节都不留),
21
+ // 否则响应写完后服务端会直接拆连接,客户端可能在上传中途拿到网络错误而不是下面这句 JSON。
22
+ // 错误消息必须含 `body too large` —— 后面的 catch 靠这个子串分流。
23
+ const declared = Number(req.headers && req.headers['content-length']);
24
+ if (Number.isFinite(declared) && declared > MAX_BODY) {
25
+ const drain = req.resume;
26
+ if (typeof drain === 'function')
27
+ drain.call(req);
28
+ reject(new Error('request body too large'));
29
+ return;
30
+ }
31
+ // Accumulate Buffers and decode ONCE at the end: decoding each chunk
32
+ // separately corrupts multi-byte UTF-8 characters that straddle a
33
+ // chunk boundary (e.g. long Chinese skill bodies).
34
+ const chunks = [];
35
+ let size = 0;
36
+ req.on('data', (c) => {
37
+ const buf = Buffer.isBuffer(c) ? c : Buffer.from(String(c));
38
+ size += buf.length;
39
+ if (size > MAX_BODY) {
40
+ reject(new Error('request body too large'));
41
+ return;
42
+ }
43
+ chunks.push(buf);
44
+ });
45
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
46
+ req.on('error', reject);
47
+ });
48
+ // 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
49
+ // 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
50
+ // 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
51
+ // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
52
+ // 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
53
+ // 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
54
+ const connection = (() => {
55
+ try {
56
+ return deps.ctx.get('connection');
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ })();
62
+ try {
63
+ // ctx.effect wires the route's disposer into this plugin's scope, so an
64
+ // unload (HMR removal, disable, update) unregisters the route — the
65
+ // documented cleanup contract (webServer.register does not auto-scope).
66
+ deps.ctx.effect(() => deps.webServer.register({
67
+ kind: 'exact',
68
+ path: '/dsh-plugin-tool-management/api',
69
+ handler: async (req, res) => {
70
+ // 鉴权顺序:POST-only → 插件门禁头(防误触,不是凭证)→ 宿主栅栏
71
+ // (Host/Origin + browser-session cookie)→ 可选访问令牌。令牌是本地工具与
72
+ // LAN 部署的逃生门:**令牌正确即视为已授权**,可越过浏览器鉴权。
73
+ //
74
+ // 「未配置令牌时」别读成"仍有门禁":下面的写 op 判据是
75
+ // `if (TOKEN && WRITE_OPS.has(op))` —— `TOKEN === ''` 时整条短路,79 个写 op
76
+ // 只剩 `fenceRejection` 一道。而它自身有两种形态:宿主栅栏在场时含 cookie 鉴权;
77
+ // 栅栏缺席或抛错时退回本地判定,**只查回环 Host 与(若给了)Origin 自比对**,
78
+ // 没有 cookie 那一层 —— 本机任意进程都能调写 op。所以准确说法是:
79
+ // 未配置令牌时不再有「**无栅栏**放行」的缺口,但进程级边界只有配了令牌才有。
80
+ const hdr = (name) => {
81
+ const v = req.headers?.[name];
82
+ return Array.isArray(v) ? v[0] ?? '' : v ?? '';
83
+ };
84
+ if (String(req.method || 'POST').toUpperCase() !== 'POST') {
85
+ res.writeHead(405, { 'content-type': 'application/json' });
86
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }));
87
+ return;
88
+ }
89
+ if (hdr('x-dsh-plugin') !== 'dsh-plugin-tool-management') {
90
+ res.writeHead(403, { 'content-type': 'application/json' });
91
+ res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
92
+ return;
93
+ }
94
+ // 「本次带的这串对不对」比的是**存量**令牌:关掉令牌功能之后仍然要凭它才能开回来、
95
+ // 换掉、或看明文(要求 9)。
96
+ const tokenAccepted = deps.CONFIG_TOKEN !== '' && deps.tokenMatches(hdr('x-dsh-token'));
97
+ // 验过就置闩(本次进程一次即可)。`agent/pre-step` 的令牌门禁读的就是它 ——
98
+ // 那一边看不到请求头,只能由这里告诉它"这台机器已经有人验过令牌了"。
99
+ if (tokenAccepted)
100
+ deps.markAccepted();
101
+ if (!tokenAccepted) {
102
+ const fence = fenceRejection(req, connection);
103
+ if (fence) {
104
+ res.writeHead(fence.status, { 'content-type': 'application/json' });
105
+ res.end(JSON.stringify({ ok: false, error: fence.error }));
106
+ return;
107
+ }
108
+ }
109
+ res.writeHead(200, { 'content-type': 'application/json' });
110
+ try {
111
+ let payload = {};
112
+ try {
113
+ payload = JSON.parse((await readBody(req)) || '{}');
114
+ }
115
+ catch (e) {
116
+ if (String(e?.message).includes('body too large')) {
117
+ res.end(JSON.stringify({ ok: false, error: '请求体过大' }));
118
+ return;
119
+ }
120
+ /* otherwise fall through with {} */
121
+ }
122
+ const op = String(payload.op || '');
123
+ // 敏感 op(会吐明文密钥与完整配置):**配了令牌就必须带对的**,没配令牌则不设防
124
+ // (判定与理由见 http-fence.ts 的 secretOpRejection —— 用户裁定 2026-09-19:
125
+ // 没配令牌时那道门没有出路,用户无从填一个不存在的令牌)。
126
+ // 之前这里只查"有没有 Origin 头",而 token 为空时它形同虚设 —— 任何能
127
+ // 打开 GUI 的浏览器都拿得到全部密钥原文。
128
+ if (deps.SENSITIVE_OPS.has(op)) {
129
+ // 明文门禁看的是**存量**令牌(CONFIG_TOKEN 而不是生效值 TOKEN):关掉令牌
130
+ // 功能之后仍然"要凭存量令牌才给明文"—— 那条路是通的(填对就能看),
131
+ // 与"从来没配过"不同。
132
+ const gate = secretOpRejection({ tokenConfigured: deps.CONFIG_TOKEN !== '', tokenAccepted });
133
+ if (gate) {
134
+ res.end(JSON.stringify({ ok: false, error: gate.error, code: gate.code }));
135
+ return;
136
+ }
137
+ }
138
+ // 「读改写」两态 op(`inject-settings` / `tool-table` / `scene-settings` /
139
+ // `mcpm-settings`):同一个名字既读又写,于是整条 op 被列进 WRITE_OPS —— 读侧也
140
+ // 一起被拦,界面表现是"没填令牌时整块设置消失、填了还要刷新才出现"(2026-09-19
141
+ // 用户报的)。判据是**这次调用要不要写**,不是 op 名在不在名单里。
142
+ // 判据本身在登记表(`writeWhen`),这里不再手写一份 —— 手写那份只认 `set:true`,
143
+ // 于是 `tool-table` 的 `presetSave` / `presetDelete` 被当成读放行,配了令牌也能
144
+ // 只凭宿主栅栏改侧车(审查 F2,0.17.0 修)。
145
+ const readOnlyCall = isReadCall(op, payload.args);
146
+ if (deps.TOKEN && deps.WRITE_OPS.has(op) && !readOnlyCall && !deps.tokenMatches(hdr('x-dsh-token'))) {
147
+ // 文案与明文门禁同源(http-fence.ts),code 也统一 —— 界面据此在最右侧挂
148
+ // 「去填令牌」跳转按钮。这条错误会出现在**任意**页面,而入口只有一个。
149
+ res.end(JSON.stringify({ ok: false, code: TOKEN_CODE_BAD, error: TOKEN_MSG }));
150
+ return;
151
+ }
152
+ // 令牌状态(只读,**不需要**令牌本身):回答"宿主配没配"、"本次带的这串对不对"、
153
+ // "配置文件里的改动是不是还没重启生效"、"本次进程的标识"。就地回答而不是进 handlers 表:
154
+ // 前者的答案来自本次请求头与进程状态,而后者的 handlers 只拿得到 args。
155
+ // 不返回令牌、也不返回它的任何片段 —— 泄给同源页面等于把最后一道门交出去。
156
+ if (op === 'token-status') {
157
+ const state = await deps.tokenConfigState();
158
+ res.end(JSON.stringify({
159
+ ok: true,
160
+ // 「宿主配置了令牌」= 配置里**有**值(关掉也算有:随时能开回来)。
161
+ hostConfigured: deps.CONFIG_TOKEN !== '',
162
+ // 当前进程里令牌功能是开还是关(关掉 = 写操作不要求令牌)。
163
+ disabled: deps.TOKEN_DISABLED,
164
+ // 「当前进程里令牌**在生效**」= 写操作要不要令牌。关掉或没配都是 false。
165
+ // 界面据此判断"填令牌"这件事有没有意义(没生效时填了也没人验,还会把
166
+ // 一句无处可填的提示挂在屏幕上 —— 用户 2026-09-19 报的就是这个)。
167
+ active: deps.TOKEN !== '',
168
+ accepted: tokenAccepted,
169
+ bootId: deps.BOOT_ID,
170
+ configHasToken: state.configHasToken,
171
+ configDisabled: state.configDisabled,
172
+ configFound: state.found,
173
+ pendingRestart: state.pendingRestart,
174
+ }));
175
+ return;
176
+ }
177
+ // 设置 / 关闭令牌:同样就地处理(理由见 tokenConfigure 的注释 —— 也正因为不进
178
+ // handlers,模型侧没有任何工具能间接关掉它)。
179
+ if (op === 'token-configure') {
180
+ res.end(JSON.stringify(deps.withPatchWarnings(await deps.tokenConfigure(payload.args || {}, tokenAccepted))));
181
+ return;
182
+ }
183
+ // 「清除令牌」的配套:把「本次启动已验过」的闩重新挂上。没有它,解锁一次之后
184
+ // 清除令牌 + 强刷新,浏览器里已无令牌,闩却挂着 —— pre-step 门禁读闩
185
+ // (tokenGateActive),整个启动期都放行对话(2026-09-19 用户实测的漏洞)。
186
+ // 不要求凭证:它只会收紧(要求重新验令牌),给不了任何人任何权限;同样不进
187
+ // handlers —— 模型不该有能力拨这颗闩。
188
+ if (op === 'token-unaccept') {
189
+ deps.unmarkAccepted();
190
+ res.end(JSON.stringify({ ok: true }));
191
+ return;
192
+ }
193
+ const fn = deps.handlers[op];
194
+ if (!fn) {
195
+ res.end(JSON.stringify({ ok: false, error: '未知操作: ' + op }));
196
+ return;
197
+ }
198
+ const result = await fn(payload.args || {});
199
+ // 「最近改动」流水:来源 = 面板(这条 HTTP 路由是界面唯一的入口)。
200
+ // 「这次算不算写」的判据在 recordOpAudit 里(与写门禁同源:登记表的 `writeWhen`)
201
+ // —— 不在这里再传一次 `readOnlyCall`,两处各判一次迟早会分家。
202
+ recordOpAudit(op, payload.args || {}, 'panel', result);
203
+ res.end(JSON.stringify(deps.withPatchWarnings(result === undefined ? { ok: true } : result)));
204
+ }
205
+ catch (e) {
206
+ res.end(JSON.stringify({ ok: false, error: deps.message(e) }));
207
+ }
208
+ },
209
+ }), 'dsh-plugin-tool-management: api route');
210
+ }
211
+ catch (e) {
212
+ // A registration failure must never take down the whole entry: log and continue.
213
+ console.error('[dsh-plugin-tool-management] webServer route registration failed:', deps.message(e));
214
+ // 结构性弱点(2026-09-30 审查 §5 F4):compat-status **本身就走这条路由**,所以路由没注册上
215
+ // 时兼容页也拿不到这条降级行 —— 面板是**整页失联**,不是"少一行"。这里至少把它写进运行期
216
+ // 上报通道(日志里必然有一条,进程内可查),并在 README 里写明"面板空白先看宿主日志"。
217
+ // 之所以不是"必然可见"的告警:可见的通道就是这条路由,它自己挂了就没得显示。
218
+ noteRuntime({
219
+ id: 'web-server-route',
220
+ label: 'HTTP 路由注册',
221
+ kind: 'write',
222
+ fallback: 'inform-only',
223
+ detail: '向宿主 webServer 注册 API 路由失败(' + deps.message(e) + '):插件面板与全部 HTTP op 不可用(兼容页也走这条路由,所以它也打不开)。宿主日志里有同一条记录;模型工具不受影响。',
224
+ detailKey: 'web-server-route.register-failed',
225
+ params: { reason: deps.message(e) },
226
+ });
227
+ }
228
+ }
229
+ }
package/lib/hub.js CHANGED
@@ -245,6 +245,8 @@ const LEGACY_TRASH_KINDS = {
245
245
  prompts: ['agents-md'],
246
246
  subagents: ['agents'],
247
247
  scenes: [],
248
+ // 0.18.0 才有的域,没有旧名字要认。
249
+ 'quick-prompts': [],
248
250
  };
249
251
  /** 某一类回收站的根目录:`hub/trash/<kind>-trash`。 */
250
252
  export function trashKindDir(kind) {