dsh-plugin-tool-management 0.16.6 → 0.18.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 +115 -0
- package/README.md +20 -4
- package/README_EN.md +20 -4
- 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 +59 -0
- package/lib/audit-log.js +6 -2
- package/lib/client.js +1615 -148
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +54 -714
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +57 -459
- package/lib/host-names.js +150 -0
- package/lib/http-api.js +229 -0
- package/lib/hub.js +84 -11
- package/lib/imports/parsers.js +40 -15
- package/lib/index.js +235 -843
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/loader-token.js +26 -0
- package/lib/mcp/manager.js +267 -558
- package/lib/mcp/patch-yaml.js +207 -40
- package/lib/mcp/secret-guard.js +122 -16
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +163 -16
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/index-io.js +125 -12
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +167 -78
- package/lib/memories/snapshot.js +41 -260
- package/lib/op-registry.js +72 -8
- package/lib/ops/compat.js +17 -2
- package/lib/ops/prompts.js +18 -5
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +157 -10
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +63 -13
- package/lib/ops/snapshot.js +123 -24
- package/lib/ops/trash.js +45 -9
- package/lib/prompts/quick-service.js +211 -0
- package/lib/prompts/service.js +7 -2
- package/lib/request-gate.js +115 -56
- package/lib/scene-linkage.js +184 -0
- package/lib/scene-prompt-sync.js +46 -7
- package/lib/scenes/candidates.js +21 -3
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +45 -168
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +97 -2297
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +4 -234
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +20 -639
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
package/lib/mcp/patch-yaml.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// 行级块编辑(removeEntryAll / removeMarked / appendBlock / spliceRanges)。
|
|
5
5
|
// 纯字符串运算,不碰 ctx / 文件系统 —— 读写盘由调用方负责。
|
|
6
6
|
import { MCP_CLIENT_MODULE } from '../host-names.js';
|
|
7
|
-
import { maskedKeysIn } from './secret-guard.js';
|
|
7
|
+
import { isMaskedUrl, maskedKeysIn } from './secret-guard.js';
|
|
8
|
+
import { readToggleEntry } from './override-blocks.js';
|
|
8
9
|
// ---------- YAML generation ----------
|
|
9
10
|
function yq(v) { return typeof v === 'string' ? JSON.stringify(v) : String(v); }
|
|
10
11
|
function yplain(v) { return /^[A-Za-z0-9_.:@%+=/-]+$/.test(v) ? v : yq(v); }
|
|
@@ -44,20 +45,33 @@ export function buildInsertBlock(row) {
|
|
|
44
45
|
lines.push(' ' + yq(k) + ': ' + yq(env[k]));
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
// 超时值只在**能解析成有限数、且非 0** 时才写这一行(2026-09-30 审查 P2-13)。
|
|
49
|
+
// 此前是 `if (row.toolCallTimeoutMs)`:手写补丁里这个键被读成字符串(`unquote` 只认纯数字),
|
|
50
|
+
// 于是 `"30s"` / `"abc"` 这类值会走 `Number()` → `NaN` 落盘,补丁里出现一个**不是数字**的
|
|
51
|
+
// `toolCallTimeoutMs` —— 宿主读到的要么是字符串、要么是 YAML 里的字符串 "NaN",两种都不是
|
|
52
|
+
// 超时毫秒数,而界面显示的是"已配置"。认不出来就不写这一行(键缺失 = 用宿主默认值)。
|
|
53
|
+
// `0` 同样不写:界面上"没填"读出来就是 0,写成 `toolCallTimeoutMs: 0` 会让每次工具调用立刻超时。
|
|
54
|
+
if (row.toolCallTimeoutMs && Number.isFinite(Number(row.toolCallTimeoutMs))) {
|
|
48
55
|
lines.push(' toolCallTimeoutMs: ' + Number(row.toolCallTimeoutMs));
|
|
56
|
+
}
|
|
49
57
|
// 结构性兜底:这里是所有 MCP 写路径的**唯一** YAML 出口。打码值到这一步还没被拦下,
|
|
50
58
|
// 说明某个调用点漏过了 ./secret-guard.js —— 那是代码缺陷,宁可整次写入失败,
|
|
51
59
|
// 也不能把 `••••••` 写进补丁文件(真密钥一旦被覆盖就找不回来了:本机 2026-09-18
|
|
52
60
|
// `TAVILY_API_KEY` 就是这样丢的)。所以这里**抛错**而不是静默剔除。
|
|
53
61
|
//
|
|
54
|
-
//
|
|
55
|
-
// URL
|
|
56
|
-
//
|
|
62
|
+
// 两种形态都查(2026-09-30 审查 F4 补上第二种):env / headers 的整串 `•`,
|
|
63
|
+
// 以及 URL 的 userinfo / 查询串 / 片段哨兵(`isMaskedUrl`)。此前只有前者,
|
|
64
|
+
// 注释自己写着"URL 的打码是另一套……不在这里"—— 于是 `mcpmAdd` 与 `mcpmImport`
|
|
65
|
+
// 两条写路径都没有 `resolveMaskedUrl`,打码 URL 会原样落盘,**且不产生任何 warning**。
|
|
66
|
+
// 各调用点仍应自己做「有旧值就顶替、没有就拒绝/跳过」的裁决(那需要上下文),
|
|
67
|
+
// 这里的断言只是"漏了就吵"的最后一道。
|
|
57
68
|
const leaked = [...maskedKeysIn(row.env), ...maskedKeysIn(row.headers)];
|
|
58
69
|
if (leaked.length) {
|
|
59
70
|
throw new Error('拒绝写入:' + leaked.join('、') + ' 的值仍是打码占位符(调用方应先经 resolveMaskedKv 收敛)');
|
|
60
71
|
}
|
|
72
|
+
if (row.transport === 'streamable-http' && isMaskedUrl(row.url)) {
|
|
73
|
+
throw new Error('拒绝写入:url 仍是打码形态(' + String(row.url) + ')—— 调用方应先经 resolveMaskedUrl 收敛(有旧值顶替、没有则拒绝或跳过)');
|
|
74
|
+
}
|
|
61
75
|
return lines.join('\n');
|
|
62
76
|
}
|
|
63
77
|
export function buildDisableBlock(id, disabled) {
|
|
@@ -133,8 +147,16 @@ function parseEntry(lines) {
|
|
|
133
147
|
entry.id = unquote(kv.value);
|
|
134
148
|
else if (kv.key === 'name')
|
|
135
149
|
entry.name = unquote(kv.value);
|
|
136
|
-
else if (kv.key === 'disabled')
|
|
137
|
-
|
|
150
|
+
else if (kv.key === 'disabled') {
|
|
151
|
+
// 只认**字面量** true / false(2026-09-30 审查 P2-6):`!!js` 这类读不出来的写法
|
|
152
|
+
// 此前被算成**显式 false**,于是"读不出来"与"明确启用"混成同一个值 —— 覆盖块会拿它
|
|
153
|
+
// 去改写当前生效值(`parseRows` 的 `entry.disabled !== undefined` 那道闸因此失效)。
|
|
154
|
+
// 读不出来就保持 `undefined` = 不表态,让生效值维持原样。
|
|
155
|
+
if (kv.value === 'true')
|
|
156
|
+
entry.disabled = true;
|
|
157
|
+
else if (kv.value === 'false')
|
|
158
|
+
entry.disabled = false;
|
|
159
|
+
}
|
|
138
160
|
continue;
|
|
139
161
|
}
|
|
140
162
|
if (indent <= configIndent) {
|
|
@@ -164,6 +186,68 @@ function parseEntry(lines) {
|
|
|
164
186
|
}
|
|
165
187
|
return entry;
|
|
166
188
|
}
|
|
189
|
+
/** 按顶层 `- ` 切块:每个块从一行 `- ` 开始,到下一个 `- ` 之前。首块之前的内容丢弃。 */
|
|
190
|
+
function splitTopBlocks(lines) {
|
|
191
|
+
const blocks = [];
|
|
192
|
+
let current = null;
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
if (/^- /.test(line)) {
|
|
195
|
+
if (current !== null)
|
|
196
|
+
blocks.push(current);
|
|
197
|
+
current = line;
|
|
198
|
+
}
|
|
199
|
+
else if (current !== null) {
|
|
200
|
+
current += '\n' + line;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (current !== null)
|
|
204
|
+
blocks.push(current);
|
|
205
|
+
return blocks;
|
|
206
|
+
}
|
|
207
|
+
/** 一个 `- insert:` 块里的子条目(按 ` - ` 4 空格缩进切)。 */
|
|
208
|
+
function splitInsertChildren(blockText) {
|
|
209
|
+
const parts = blockText.split('\n');
|
|
210
|
+
const children = [];
|
|
211
|
+
let j = 0;
|
|
212
|
+
while (j < parts.length) {
|
|
213
|
+
if (/^ - /.test(parts[j])) {
|
|
214
|
+
const lines = [parts[j]];
|
|
215
|
+
j++;
|
|
216
|
+
while (j < parts.length && !/^ - /.test(parts[j])) {
|
|
217
|
+
lines.push(parts[j]);
|
|
218
|
+
j++;
|
|
219
|
+
}
|
|
220
|
+
children.push(lines);
|
|
221
|
+
}
|
|
222
|
+
else
|
|
223
|
+
j++;
|
|
224
|
+
}
|
|
225
|
+
return children;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 文件里**所有** insert 子条目的 id(**不限 loader 名**)。
|
|
229
|
+
*
|
|
230
|
+
* 为什么不能复用 `parseRows`:它只收 `name === MCP_CLIENT_MODULE` 的行(本插件只管 MCP),
|
|
231
|
+
* 而「两条同 id 的 insert 会让插件组装失败、**DSH 起不来**」这个约束对**任何** loader 都成立。
|
|
232
|
+
* 只统计 MCP 行的话,导入一条非 MCP loader 就能撞出第二条同 id 条目而守卫看不见
|
|
233
|
+
* (2026-09-30 审查 P1-5)。
|
|
234
|
+
*
|
|
235
|
+
* 只收 **insert 子条目**,不收顶层覆盖块:覆盖块的 id 本来就应该与某个 insert 相同,
|
|
236
|
+
* 那是正常用法而非重复。
|
|
237
|
+
*/
|
|
238
|
+
export function insertEntryIds(content) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const blockText of splitTopBlocks(content.split(/\r?\n/))) {
|
|
241
|
+
if (!/^- insert:/.test(blockText.split('\n')[0]))
|
|
242
|
+
continue;
|
|
243
|
+
for (const childLines of splitInsertChildren(blockText)) {
|
|
244
|
+
const entry = parseEntry(childLines);
|
|
245
|
+
if (entry.id)
|
|
246
|
+
out.push(entry.id);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
167
251
|
export function parseRows(content) {
|
|
168
252
|
const lines = content.split(/\r?\n/);
|
|
169
253
|
const managedIds = new Set();
|
|
@@ -175,38 +259,11 @@ export function parseRows(content) {
|
|
|
175
259
|
managedIds.add(m[1].trim());
|
|
176
260
|
}
|
|
177
261
|
const rows = [];
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
for (const line of lines) {
|
|
181
|
-
if (/^- /.test(line)) {
|
|
182
|
-
current = { text: line };
|
|
183
|
-
blocks.push(current);
|
|
184
|
-
}
|
|
185
|
-
else if (current) {
|
|
186
|
-
current.text += '\n' + line;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
for (const block of blocks) {
|
|
190
|
-
const head = block.text.split('\n')[0];
|
|
262
|
+
for (const blockText of splitTopBlocks(lines)) {
|
|
263
|
+
const head = blockText.split('\n')[0];
|
|
191
264
|
if (/^- insert:/.test(head)) {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
let j = 0;
|
|
195
|
-
while (j < parts.length) {
|
|
196
|
-
if (/^ - /.test(parts[j])) {
|
|
197
|
-
const child = { lines: [parts[j]] };
|
|
198
|
-
j++;
|
|
199
|
-
while (j < parts.length && !/^ - /.test(parts[j])) {
|
|
200
|
-
child.lines.push(parts[j]);
|
|
201
|
-
j++;
|
|
202
|
-
}
|
|
203
|
-
children.push(child);
|
|
204
|
-
}
|
|
205
|
-
else
|
|
206
|
-
j++;
|
|
207
|
-
}
|
|
208
|
-
for (const child of children) {
|
|
209
|
-
const entry = parseEntry(child.lines);
|
|
265
|
+
for (const childLines of splitInsertChildren(blockText)) {
|
|
266
|
+
const entry = parseEntry(childLines);
|
|
210
267
|
if (entry && entry.name === MCP_CLIENT_MODULE) {
|
|
211
268
|
rows.push({ id: entry.id, name: entry.name, disabled: entry.disabled, config: entry.config, managed: managedIds.has(entry.id) });
|
|
212
269
|
}
|
|
@@ -217,7 +274,7 @@ export function parseRows(content) {
|
|
|
217
274
|
// `@deepseek-ai/dsh-app-boot` 的 applyEntryPatches 只给此刻已入索引的 id 打补丁
|
|
218
275
|
//(insert 是插入时立即入索引),命中不到就 warn + skip。所以写在 insert 之前的
|
|
219
276
|
// 覆盖块在宿主侧是 no-op —— 这里同样丢弃,界面才和生效值一致。
|
|
220
|
-
const entry = parseEntry(
|
|
277
|
+
const entry = parseEntry(blockText.split('\n'));
|
|
221
278
|
if (entry && entry.name === MCP_CLIENT_MODULE && entry.disabled !== undefined) {
|
|
222
279
|
const row = rows.find((r) => r.id === entry.id);
|
|
223
280
|
if (row)
|
|
@@ -229,8 +286,111 @@ export function parseRows(content) {
|
|
|
229
286
|
}
|
|
230
287
|
// ---------- line-based block editing ----------
|
|
231
288
|
export function splitLines(content) { return content.split(/\r?\n/); }
|
|
289
|
+
/** 映射键(裸 / 双引号 / 单引号三种写法)。 */
|
|
290
|
+
const YAML_KEY_PART = String.raw `(?:[^#\s][^:]*|"[^"]*"|'[^']*')`;
|
|
291
|
+
/** 节点属性:锚点 `&a` 与标签 `!tag` / `!!js` / `!<verbatim>`,可多个、可任意顺序。 */
|
|
292
|
+
const YAML_NODE_PROPS = String.raw `(?:[&!]\S+\s+)*`;
|
|
293
|
+
/**
|
|
294
|
+
* 块标量头部的指示符,**两种顺序都合法**(YAML 规范 `c-b-block-header`):
|
|
295
|
+
* `( 缩进指示符 截断指示符 ) | ( 截断指示符 缩进指示符 )`
|
|
296
|
+
* 缩进指示符是 `[1-9]`,截断指示符是 `[+-]`。所以 `|2-` 与 `|-2` 都算。
|
|
297
|
+
*/
|
|
298
|
+
const YAML_BLOCK_INDICATOR = String.raw `[|>](?:\d[+-]?|[+-]\d?)?`;
|
|
299
|
+
/**
|
|
300
|
+
* 一条**块标量头部**行:映射值(`key: <属性> <指示符>`)或序列项(`- <属性> <指示符>`)。
|
|
301
|
+
*
|
|
302
|
+
* 为什么必须认全(2026-09-30 审查 F6):判据此前是 `key:\s*[|>][+-]?\d*`,它只认裸 `script: |`
|
|
303
|
+
* 这一种。于是 `script: !!js |`(CHANGELOG 与注释**点名**要覆盖的那个形态)、`script: &a |`、
|
|
304
|
+
* `script: |2-` 全都落进"不在块标量里",`joinLines` 照旧把它们内部的 3+ 连续空行折成 1 行 ——
|
|
305
|
+
* 补丁照旧能读、表达式照旧能 eval,**只是算出来的值变了**。方向恰好是改写用户手写的内容。
|
|
306
|
+
*/
|
|
307
|
+
const BLOCK_SCALAR_HEADER_RE = new RegExp(`^(\\s*)(?:${YAML_KEY_PART}\\s*:|-(?=\\s|$))\\s*${YAML_NODE_PROPS}${YAML_BLOCK_INDICATOR}\\s*(?:#.*)?$`);
|
|
308
|
+
/**
|
|
309
|
+
* 「像块标量头部、但上面那条严格判据认不出来」的行。
|
|
310
|
+
*
|
|
311
|
+
* 认不出的形状一律**整体不折叠**(函数头注释里的第二道闸):折叠的代价是"少一次排版归一化"
|
|
312
|
+
* (文件里可能积空行,肉眼可见、可再折叠),而误判的代价是**静默改写用户手写的表达式** ——
|
|
313
|
+
* 两者不对称,所以认不出时选保守的那一边。
|
|
314
|
+
*
|
|
315
|
+
* 为什么不会误伤本插件自己生成的行:`buildInsertBlock` 的标量值一律走 `JSON.stringify`
|
|
316
|
+
* (行尾必是 `"`),数字与布尔没有 `|` / `>`。所以只有**人手写**的形状才可能命中这条。
|
|
317
|
+
*/
|
|
318
|
+
const SUSPICIOUS_BLOCK_HEADER_RE = new RegExp(`^\\s*(?:${YAML_KEY_PART}\\s*:|-(?=\\s|$)).*[|>]\\s*(?:#.*)?$`);
|
|
319
|
+
/**
|
|
320
|
+
* 标出「哪些行在**块标量**(`key: |` / `key: >`)里」。
|
|
321
|
+
*
|
|
322
|
+
* 为什么折叠空行前必须知道这件事(2026-09-30 审查 P2-7):`joinLines` 每次行级编辑
|
|
323
|
+
* (任何一次 MCP 增 / 改 / 删 / 启停)都会把整份文件里 3+ 连续换行折成 1 个空行 —— 那是为了
|
|
324
|
+
* 不让反复增删在文件里积出越来越长的空行。但块标量的**内容就是换行与缩进**,它内部的空行
|
|
325
|
+
* 属于内容本身:折掉一次等于改写了用户手写的表达式(`!!js |`),而补丁照旧能读、表达式
|
|
326
|
+
* 照旧能 eval —— 只是算出来的值变了,**静默**改掉一个用户写的东西。
|
|
327
|
+
*
|
|
328
|
+
* 两道闸:
|
|
329
|
+
* ① 严格判据(`BLOCK_SCALAR_HEADER_RE`)按 YAML 规范认头:属性(tag / anchor)+ 指示符,
|
|
330
|
+
* 两种指示符顺序都算;块体是之后所有**空行**或**缩进深于头行**的行,遇到第一个非空且
|
|
331
|
+
* 缩进不深于头行的行即结束。
|
|
332
|
+
* ② 文件里只要出现**一条**认不出的疑似块标量头(`SUSPICIOUS_BLOCK_HEADER_RE`),
|
|
333
|
+
* 整份文件都不折叠 —— 宁可少一次排版归一化,也不猜着改写内容。
|
|
334
|
+
*/
|
|
335
|
+
function blockScalarLineMask(lines) {
|
|
336
|
+
const inScalar = new Array(lines.length).fill(false);
|
|
337
|
+
let i = 0;
|
|
338
|
+
while (i < lines.length) {
|
|
339
|
+
const m = BLOCK_SCALAR_HEADER_RE.exec(lines[i]);
|
|
340
|
+
if (!m) {
|
|
341
|
+
i++;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const keyIndent = m[1].length;
|
|
345
|
+
let j = i + 1;
|
|
346
|
+
while (j < lines.length) {
|
|
347
|
+
const line = lines[j];
|
|
348
|
+
if (line.trim() === '') {
|
|
349
|
+
inScalar[j] = true;
|
|
350
|
+
j++;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const indent = line.match(/^\s*/)[0].length;
|
|
354
|
+
if (indent <= keyIndent)
|
|
355
|
+
break;
|
|
356
|
+
inScalar[j] = true;
|
|
357
|
+
j++;
|
|
358
|
+
}
|
|
359
|
+
i = Math.max(j, i + 1);
|
|
360
|
+
}
|
|
361
|
+
// 第二道闸:认不出的疑似块标量头 → 整体不折叠(全部标成"受保护")。
|
|
362
|
+
for (const line of lines) {
|
|
363
|
+
if (SUSPICIOUS_BLOCK_HEADER_RE.test(line) && !BLOCK_SCALAR_HEADER_RE.test(line)) {
|
|
364
|
+
return new Array(lines.length).fill(true);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return inScalar;
|
|
368
|
+
}
|
|
232
369
|
function joinLines(lines) {
|
|
233
|
-
|
|
370
|
+
// 折叠**跳过块标量内部的行**:那些空行是内容,不是排版(见 blockScalarLineMask 的注释)。
|
|
371
|
+
const protectedLine = blockScalarLineMask(lines);
|
|
372
|
+
const folded = [];
|
|
373
|
+
let blanks = 0;
|
|
374
|
+
for (let i = 0; i < lines.length; i++) {
|
|
375
|
+
if (protectedLine[i]) {
|
|
376
|
+
blanks = 0;
|
|
377
|
+
folded.push(lines[i]);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (lines[i].trim() === '') {
|
|
381
|
+
blanks++;
|
|
382
|
+
// 最多留 1 个空行 —— 与改动前 `/\n{3,}/ → /\n\n/` 的力度一致。
|
|
383
|
+
if (blanks <= 1)
|
|
384
|
+
folded.push(lines[i]);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
blanks = 0;
|
|
388
|
+
folded.push(lines[i]);
|
|
389
|
+
}
|
|
390
|
+
// 尾部若正处在块标量里,就不做「补一个换行」的归一化(那也会动到内容);只去首部空行。
|
|
391
|
+
const tailProtected = lines.length > 0 && protectedLine[lines.length - 1];
|
|
392
|
+
let res = folded.join('\n').replace(/^\n+/, '');
|
|
393
|
+
res = tailProtected ? res : res.replace(/\n*$/, '\n');
|
|
234
394
|
if (!res.trim()) {
|
|
235
395
|
res = '[]\n';
|
|
236
396
|
}
|
|
@@ -318,6 +478,13 @@ function bareOverrideRanges(lines, id) {
|
|
|
318
478
|
let end = i + 1;
|
|
319
479
|
while (end < n && !/^- /.test(lines[end]))
|
|
320
480
|
end++;
|
|
481
|
+
// 只删**纯块**(2026-09-30 审查 P2-6):`mcpm-edit` / `mcpm-remove` 原来把同 id 的顶层
|
|
482
|
+
// 覆盖块**一律**删掉,而用户完全可能在里面手写 `config`(补丁是按 **loader id** 生效的,
|
|
483
|
+
// 别的模块名写同一个 id 时改的仍是那个条目,手写 config 因此是合法且有用的)。删掉它
|
|
484
|
+
// 就是静默丢掉用户手写的配置。判据与 `override-blocks.ts` 的 `readToggleEntry().pure`
|
|
485
|
+
// **同源**(认不出的写法一律判非纯 → 保留,保守方向是对的)。
|
|
486
|
+
if (!readToggleEntry(lines.slice(i, end)).pure)
|
|
487
|
+
continue;
|
|
321
488
|
ranges.push([i, end]);
|
|
322
489
|
}
|
|
323
490
|
return ranges;
|
package/lib/mcp/secret-guard.js
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
// (本机 `TAVILY_API_KEY` 就是这样变成 `••••••` 的)。
|
|
8
8
|
//
|
|
9
9
|
// URL 是**第二种形态**,别把它漏掉(2026-09-19 审计 T-23):`maskUrlQuery` 不产出 `••••••`,
|
|
10
|
-
//
|
|
10
|
+
// 而是把承载凭据的那几段换成 `<redacted>` 哨兵(经 URL 序列化后是 `%3Credacted%3E`)。
|
|
11
11
|
// 打码值判据只认整串 `•` 时它永远命中不了,于是 `mcpmEdit` 会把 `?<redacted>` 当成真 URL
|
|
12
12
|
// 写回补丁 —— 一次普通「编辑保存」即把 URL 里的凭据**永久**改成占位串。所以本模块同时
|
|
13
13
|
// 提供 `isMaskedUrl` / `resolveMaskedUrl`,与下面的 env / headers 判据并列。
|
|
14
14
|
//
|
|
15
|
+
// 「那几段」= **userinfo / 查询串 / 片段**三处(2026-09-30 审查 F5):`new URL()` 把 userinfo
|
|
16
|
+
// 与 fragment 存在 `username/password` 与 `hash` 里,`toString()` 原样带出。只换 `search`
|
|
17
|
+
// 的话,`https://user:pass@host/mcp`(Basic-Auth 形态)与 `https://host/mcp#access_token=…`
|
|
18
|
+
// 会整条原样返回 —— 而这两条路正是免令牌出口(`mcpm-list` / `mcpm-inspect` / 模型工具 /
|
|
19
|
+
// 快照导出)。路径与主机**不动**:它们是该服务的身份,算进来会把真 URL 误判成打码值。
|
|
20
|
+
//
|
|
15
21
|
// 判据为什么可以这么硬:打码值只可能表示"用户没改这一项",绝不可能表示"用户想把它设成这个"。
|
|
16
22
|
// 所以三道闸的方向都是「往旧值收」:
|
|
17
23
|
// ① 有旧真值 → 用旧真值顶替(用户意图 = 不改这一项);
|
|
@@ -45,14 +51,57 @@ export function maskedKeysIn(map) {
|
|
|
45
51
|
return [];
|
|
46
52
|
return Object.keys(map).filter((key) => isMaskedValue(map[key]));
|
|
47
53
|
}
|
|
48
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* 单个值的打码形态(env / headers 那一类键值对)。
|
|
56
|
+
*
|
|
57
|
+
* 为什么提到本模块(2026-09-30,审查 P0-4):这个函数原先私有在 `mcp/manager.ts`,而整机迁移
|
|
58
|
+
* 的导出侧(`ops/snapshot.ts`)自己另写了一份「所有键一律打成 `••••••`」的版本 —— 同一件事
|
|
59
|
+
* 两套口径,于是 `$VAR` / `!!js` 这类**间接引用**在两条出口上的命运不同。本模块是两条打码
|
|
60
|
+
* 形态(占位符 / URL)的唯一口径,键值对这条也归这里。
|
|
61
|
+
*
|
|
62
|
+
* `$VAR` 与 `!!js` 是**间接引用**而不是密钥,原样保留:把它们打成 `••••••` 再导回来,目标机
|
|
63
|
+
* 既顶替不出真值(它没有这一项)、也没有原值可回退,`resolveMaskedKv` 只能把这个键丢掉 ——
|
|
64
|
+
* 一次普通迁移就毁掉了配置里的间接引用。
|
|
65
|
+
*/
|
|
66
|
+
export function maskSecretValue(value) {
|
|
67
|
+
const text = String(value == null ? '' : value);
|
|
68
|
+
if (text === '')
|
|
69
|
+
return '';
|
|
70
|
+
if (text.startsWith('$'))
|
|
71
|
+
return text;
|
|
72
|
+
if (/^!!js\s/.test(text))
|
|
73
|
+
return text;
|
|
74
|
+
return MASK_PLACEHOLDER;
|
|
75
|
+
}
|
|
76
|
+
/** `maskUrlQuery` 写进 URL 各段的哨兵(userinfo / 查询串 / 片段共用同一个字面值)。 */
|
|
49
77
|
const URL_REDACTED_QUERY = '<redacted>';
|
|
50
78
|
/**
|
|
51
|
-
*
|
|
79
|
+
* 一段(userinfo / 查询串 / 片段)是不是哨兵。
|
|
52
80
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
81
|
+
* `<` `>` 会被 URL 序列化百分号编码(`%3Credacted%3E`),所以比较前先解码 ——
|
|
82
|
+
* 手写补丁里直接写 `<redacted>` 也要认出来。`decodeURIComponent` 对残缺的 `%` 会抛,
|
|
83
|
+
* 那种输入不是哨兵。
|
|
84
|
+
*/
|
|
85
|
+
function isRedactedPart(part) {
|
|
86
|
+
if (part === URL_REDACTED_QUERY)
|
|
87
|
+
return true;
|
|
88
|
+
try {
|
|
89
|
+
return decodeURIComponent(part) === URL_REDACTED_QUERY;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 一个 URL 的**任一凭据段**是不是被 `maskUrlQuery` 打过码。
|
|
97
|
+
*
|
|
98
|
+
* 判据只覆盖 userinfo / 查询串 / 片段这三处:路径与主机是该服务的身份,打码不会碰它们,
|
|
99
|
+
* 把它们算进来会把真 URL 误判成打码值(判错的方向是丢掉真配置)。
|
|
100
|
+
*
|
|
101
|
+
* 为什么三处各自独立判、命中一处即算(2026-09-30 审查 F5):`resolveMaskedUrl` 的语义是
|
|
102
|
+
* "这条 URL 是打码形态 ⇒ 不能写回盘"。只判查询串的话,`https://user:pass@host/mcp`
|
|
103
|
+
* (userinfo 承载 Basic 凭据,MCP 托管服务真实存在的形态)与 `...#access_token=…`
|
|
104
|
+
* 都会被判成真值原样落盘 —— 一次普通「编辑保存」即把凭据永久换成占位串。
|
|
56
105
|
*/
|
|
57
106
|
export function isMaskedUrl(value) {
|
|
58
107
|
const text = String(value == null ? '' : value).trim();
|
|
@@ -60,22 +109,68 @@ export function isMaskedUrl(value) {
|
|
|
60
109
|
return false;
|
|
61
110
|
try {
|
|
62
111
|
const parsed = new URL(text);
|
|
63
|
-
if (
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
112
|
+
if (isRedactedPart(parsed.username) || isRedactedPart(parsed.password))
|
|
113
|
+
return true;
|
|
114
|
+
if (parsed.search && isRedactedPart(parsed.search.replace(/^\?/, '')))
|
|
115
|
+
return true;
|
|
116
|
+
if (parsed.hash && isRedactedPart(parsed.hash.replace(/^#/, '')))
|
|
117
|
+
return true;
|
|
118
|
+
return false;
|
|
67
119
|
}
|
|
68
120
|
catch {
|
|
121
|
+
// 非绝对 URL(`new URL` 抛错)走字符串判据,与下面 `maskUrlQuery` 的兜底分支配对 ——
|
|
122
|
+
// 少了这一半,兜底打码出来的相对 URL 在回写时认不出是打码值,会被当成用户新填的地址写回补丁。
|
|
123
|
+
const userinfo = /^(?:[a-zA-Z][\w+.-]*:)?\/\/([^/?#]*)@/.exec(text);
|
|
124
|
+
if (userinfo && isRedactedPart(userinfo[1]))
|
|
125
|
+
return true;
|
|
126
|
+
const at = text.indexOf('?');
|
|
127
|
+
if (at >= 0) {
|
|
128
|
+
const hashAt = text.indexOf('#', at);
|
|
129
|
+
if (isRedactedPart(text.slice(at + 1, hashAt < 0 ? undefined : hashAt)))
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
const hashAt = text.indexOf('#');
|
|
133
|
+
if (hashAt >= 0 && isRedactedPart(text.slice(hashAt + 1)))
|
|
134
|
+
return true;
|
|
69
135
|
return false;
|
|
70
136
|
}
|
|
71
137
|
}
|
|
72
138
|
/**
|
|
73
|
-
*
|
|
139
|
+
* 字符串级兜底打码(`new URL` 认不下的相对地址 / 协议相对地址):
|
|
140
|
+
* 按 `[scheme:][//userinfo@host]/path[?query][#fragment]` 的形状,把 userinfo / 查询串 /
|
|
141
|
+
* 片段三处各换成哨兵,路径与主机原样保留(它们是该服务的身份)。
|
|
142
|
+
*/
|
|
143
|
+
function maskUrlPartsByString(url) {
|
|
144
|
+
// ① userinfo:`scheme://` 或 `//` 之后、第一个 `/ ? #` 之前若有 `@`,把 `@` 之前那段换掉。
|
|
145
|
+
// 正则里的 `[^/?#]*` 越不过路径分隔符,所以路径里的 `@`(`/user@example.com`)不会误伤。
|
|
146
|
+
let out = url.replace(/^((?:[a-zA-Z][\w+.-]*:)?\/\/)([^/?#]*@)/, `$1${URL_REDACTED_QUERY}@`);
|
|
147
|
+
// ② 查询串:第一个 `?` 到 `#` 之前整段换哨兵。
|
|
148
|
+
const at = out.indexOf('?');
|
|
149
|
+
if (at >= 0) {
|
|
150
|
+
const hashAt = out.indexOf('#', at);
|
|
151
|
+
out = out.slice(0, at + 1) + URL_REDACTED_QUERY + (hashAt < 0 ? '' : out.slice(hashAt));
|
|
152
|
+
}
|
|
153
|
+
// ③ 片段:第一个 `#` 之后整段换哨兵。
|
|
154
|
+
const hashAt = out.indexOf('#');
|
|
155
|
+
if (hashAt >= 0)
|
|
156
|
+
out = out.slice(0, hashAt + 1) + URL_REDACTED_QUERY;
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 把 URL 里承载凭据的三段(userinfo / 查询串 / 片段)换成 `<redacted>` 哨兵
|
|
161
|
+
* —— 打码的**产出**侧,与上面的 `isMaskedUrl` 配对。
|
|
74
162
|
*
|
|
75
|
-
* 放在这里而不是 manager.ts
|
|
76
|
-
* ——MCP 列表视图(`mcpmRowsWithNotes
|
|
77
|
-
* (卡里要回显"新 URL"
|
|
78
|
-
*
|
|
163
|
+
* 放在这里而不是 manager.ts 里:本模块是这两条打码形态的唯一口径,而使用方已经有三个
|
|
164
|
+
* ——MCP 列表视图(`mcpmRowsWithNotes`)、模型调 `mcp_manager_save` 时的确认卡
|
|
165
|
+
* (卡里要回显"新 URL",但用户批准的是「跑这个地址」,查询串里的凭据不该明文进卡片)、
|
|
166
|
+
* 以及整机迁移的导出(`ops/snapshot.ts`,那份目录是要拷去 U 盘 / 另一台机器的)。
|
|
167
|
+
* 各处各写一份的话,改了一处就会漂成几种打码形态,而 `isMaskedUrl` 只认其中一种。
|
|
168
|
+
*
|
|
169
|
+
* 为什么是**三段**而不是只有查询串(2026-09-30 审查 F5):`new URL()` 把 userinfo 与
|
|
170
|
+
* fragment 存在 `username/password` 与 `hash` 里,`toString()` 会原样带出 ——
|
|
171
|
+
* 只替换 `search` 的话,`https://user:pass@host/mcp` 与 `https://host/mcp#access_token=…`
|
|
172
|
+
* 会**整条原样返回**(后者连 `?` 都没有),而这两条路正是免令牌出口
|
|
173
|
+
* (`mcpm-list` / `mcpm-inspect` / 模型工具 / 快照导出)。Basic-Auth 形态的凭据就此明文可见。
|
|
79
174
|
*/
|
|
80
175
|
export function maskUrlQuery(url) {
|
|
81
176
|
if (!url)
|
|
@@ -84,10 +179,21 @@ export function maskUrlQuery(url) {
|
|
|
84
179
|
const parsed = new URL(url);
|
|
85
180
|
if (parsed.search)
|
|
86
181
|
parsed.search = '?' + URL_REDACTED_QUERY;
|
|
182
|
+
// userinfo 与片段分别置哨兵。`password = ''` 是为了不留下 `user:<redacted>@` 这种半截形态
|
|
183
|
+
// —— 有 userinfo 时两段一起换,读回去就是一个整体。
|
|
184
|
+
if (parsed.username || parsed.password) {
|
|
185
|
+
parsed.username = URL_REDACTED_QUERY;
|
|
186
|
+
parsed.password = '';
|
|
187
|
+
}
|
|
188
|
+
if (parsed.hash)
|
|
189
|
+
parsed.hash = '#' + URL_REDACTED_QUERY;
|
|
87
190
|
return parsed.toString();
|
|
88
191
|
}
|
|
89
192
|
catch {
|
|
90
|
-
return url
|
|
193
|
+
// 不是绝对 URL(`new URL` 抛错)—— 原来这里是 `return url`,即**完全不打码**。
|
|
194
|
+
// 但凭据照样可以挂在这种地址上(`/api?token=…`、`//host/mcp?api_key=…`),而这条函数的
|
|
195
|
+
// 调用方里有「要拷去另一台机器」的导出侧:原样返回等于承诺打码却留明文。
|
|
196
|
+
return maskUrlPartsByString(url);
|
|
91
197
|
}
|
|
92
198
|
}
|
|
93
199
|
/**
|