dsh-plugin-tool-management 0.9.1 → 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 (79) hide show
  1. package/CHANGELOG.md +119 -1
  2. package/README.md +227 -201
  3. package/README_EN.md +227 -199
  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 +144 -12
  21. package/lib/client.js +8437 -5929
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -25
  24. package/lib/context-inject.js +396 -53
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +31 -3
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +940 -3765
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +20 -9
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/mcp/state-section.js +64 -21
  37. package/lib/{rules → memories}/archive-engine.js +65 -10
  38. package/lib/{rules → memories}/archive.js +6 -7
  39. package/lib/memories/constants.js +128 -0
  40. package/lib/memories/index-io.js +330 -0
  41. package/lib/memories/projection.js +280 -0
  42. package/lib/memories/service.js +686 -0
  43. package/lib/memories/snapshot.js +672 -0
  44. package/lib/ops/candidates.js +64 -0
  45. package/lib/ops/compat.js +136 -0
  46. package/lib/ops/ctx.js +9 -0
  47. package/lib/ops/memory.js +678 -0
  48. package/lib/ops/prompts.js +107 -0
  49. package/lib/ops/scene-records.js +460 -0
  50. package/lib/ops/scene-sync.js +17 -0
  51. package/lib/ops/sessions.js +603 -0
  52. package/lib/ops/trash.js +140 -0
  53. package/lib/paths.js +103 -0
  54. package/lib/prompts/preset-id.js +49 -0
  55. package/lib/{agents-md → prompts}/service.js +1 -1
  56. package/lib/request-gate.js +320 -0
  57. package/lib/scene-prompt-sync.js +4 -4
  58. package/lib/scenes/candidates.js +344 -0
  59. package/lib/{history → sessions}/bridge.js +22 -5
  60. package/lib/sessions/history.js +323 -0
  61. package/lib/{history → sessions}/tombstone.js +1 -2
  62. package/lib/{history → sessions}/workspace.js +92 -24
  63. package/lib/skills/catalog.js +6 -9
  64. package/lib/skills/core.js +79 -43
  65. package/lib/skills/readonly-discovery.js +4 -1
  66. package/lib/skills/service.js +98 -13
  67. package/lib/subagents/catalog.js +31 -15
  68. package/lib/subagents/service.js +506 -89
  69. package/lib/subagents/tools.js +29 -4
  70. package/lib/tools/deps.js +8 -0
  71. package/lib/tools/mcp.js +110 -0
  72. package/lib/tools/memory.js +87 -0
  73. package/lib/tools/prompt.js +70 -0
  74. package/lib/tools/skills.js +139 -0
  75. package/lib/tools/subagent.js +40 -0
  76. package/package.json +105 -102
  77. package/lib/agents-md/preset-id.js +0 -49
  78. package/lib/history/projcache.js +0 -335
  79. package/lib/rules/service.js +0 -2971
@@ -46,9 +46,9 @@ export function scanBlockRanges(lines) {
46
46
  /**
47
47
  * 读一个覆盖块三件事:id、disabled、是不是"纯块"。
48
48
  *
49
- * 只看**顶层/一层缩进**的锚定写法(`- id:`、`name:`、`disabled:`):生成器写出来的
50
- * 就是这几行,而 config 里的同名键缩进更深 —— 万一手工把 `disabled` 藏进 config,
51
- * 这里会判成"非纯块"(保留不删),保守方向是对的。
49
+ * 只认**精确列位**的写法(顶层 `- id:`,生成器的 2 空格 `name:` / `disabled:`):
50
+ * config 里的同名键缩进更深,正则不匹配,于是落入下面的 `pure = false` ——
51
+ * 手工把 `disabled` 藏进 config 的块会被判成"非纯块"(保留不删),保守方向是对的。
52
52
  *
53
53
  * 不筛 `name:` —— 补丁是按 **loader id** 生效的,别的模块名写同一个 id 时改的仍是
54
54
  * 那个条目;真正决定"能不能删"的是 pure 与 decider 两条规则,不是名字。
@@ -66,9 +66,9 @@ export function readToggleEntry(lines) {
66
66
  id = head[1];
67
67
  continue;
68
68
  }
69
- if (/^\s{2,}name:\s*\S+\s*$/.test(line))
69
+ if (/^ {2}name:\s*\S+\s*$/.test(line))
70
70
  continue;
71
- const flag = line.match(/^\s{2,}disabled:\s*(true|false)\s*$/);
71
+ const flag = line.match(/^ {2}disabled:\s*(true|false)\s*$/);
72
72
  if (flag) {
73
73
  disabled = flag[1] === 'true';
74
74
  continue;
@@ -101,8 +101,17 @@ export function scanOverrideBlocks(content) {
101
101
  /**
102
102
  * **insert 行自带的** 启停基准(`- insert:` 里每个子条目的 `disabled`,缺失 = 启用)。
103
103
  *
104
+ * 子条目键只认 6 空格列位(生成器 ` - id:` 之下的 ` disabled:`)—— config 内嵌的
105
+ * 同名键缩进更深,匹配不上,该 id 就按"基准未知"处理(调用方只删被 decider 盖住的块,
106
+ * 绝不删 decider),少删不会错删。
107
+ *
104
108
  * 这是收敛判定的唯一合法基准来源:绝不能用 `parseRows()` 那种"合并覆盖块之后的生效值",
105
109
  * 因为生效值就等于最后一条覆盖块的值,"覆盖块的值 == 基准"会恒成立,decider 会被全删。
110
+ *
111
+ * 「无 `disabled` 键」与「有键但值读不出来」是**两件事**(本仓自己的 loader 行就写着
112
+ * `disabled: !!js "..."`,那个值是启动期算出来的):前者 = 基准未知(不删 decider),
113
+ * 后者同样 = 基准未知。把它们都记成 `false` 会让"基准已知"成立,于是 decider 被当 no-op
114
+ * 删掉 —— 在 `!!js` 那条上就是**按启动期的值做了一次启停翻转判断**(审计 C-15)。
106
115
  */
107
116
  export function insertBaseRows(content) {
108
117
  const lines = content.split(/\r?\n/);
@@ -124,7 +133,7 @@ export function insertBaseRows(content) {
124
133
  }
125
134
  if (!id)
126
135
  continue;
127
- const flag = line.match(/^\s+disabled:\s*(true|false)\s*$/);
136
+ const flag = line.match(/^ {6}disabled:\s*(true|false)\s*$/);
128
137
  if (flag)
129
138
  disabled = flag[1] === 'true';
130
139
  }
@@ -163,12 +172,14 @@ export function planOverrideCompaction(content, otherFileContents = []) {
163
172
  byId.set(block.id, [block]);
164
173
  }
165
174
  const baseOf = new Map();
175
+ // 只有**读得出 true/false** 才算基准已知;`disabled` 缺失或值不可解析(`!!js`)一律不记。
166
176
  for (const row of insertBaseRows(content))
167
- baseOf.set(row.id, row.disabled === true);
177
+ if (row.disabled !== undefined)
178
+ baseOf.set(row.id, row.disabled);
168
179
  for (const other of otherFileContents) {
169
180
  for (const row of insertBaseRows(other))
170
- if (!baseOf.has(row.id))
171
- baseOf.set(row.id, row.disabled === true);
181
+ if (!baseOf.has(row.id) && row.disabled !== undefined)
182
+ baseOf.set(row.id, row.disabled);
172
183
  }
173
184
  const ranges = [];
174
185
  const ids = {};
@@ -0,0 +1,351 @@
1
+ // cordis.patch.yml 的「受管 loader 行」读写层 —— 2026-09-19 从 index.ts 的 apply 闭包
2
+ // 原样抽出(07 审查五档问题 3:巨型单文件)。这里是所有 MCP 写路径的**唯一** YAML 出口:
3
+ // 生成(buildInsertBlock / buildDisableBlock)、解析(parseRows,供界面读生效值)、
4
+ // 行级块编辑(removeEntryAll / removeMarked / appendBlock / spliceRanges)。
5
+ // 纯字符串运算,不碰 ctx / 文件系统 —— 读写盘由调用方负责。
6
+ import { MCP_CLIENT_MODULE } from '../host-names.js';
7
+ import { maskedKeysIn } from './secret-guard.js';
8
+ // ---------- YAML generation ----------
9
+ function yq(v) { return typeof v === 'string' ? JSON.stringify(v) : String(v); }
10
+ function yplain(v) { return /^[A-Za-z0-9_.:@%+=/-]+$/.test(v) ? v : yq(v); }
11
+ export function buildInsertBlock(row) {
12
+ const lines = [
13
+ '# dsh-plugin-tool-management:server:' + row.id,
14
+ '- insert:',
15
+ ' - id: ' + yplain(row.id),
16
+ " name: '" + MCP_CLIENT_MODULE + "'",
17
+ ' config:',
18
+ ' serverName: ' + yq(row.serverName),
19
+ ' transport: ' + yq(row.transport),
20
+ ];
21
+ if (row.transport === 'streamable-http') {
22
+ lines.push(' url: ' + yq(row.url || ''));
23
+ const headers = row.headers || {};
24
+ const hk = Object.keys(headers);
25
+ if (hk.length) {
26
+ lines.push(' headers:');
27
+ for (const k of hk)
28
+ lines.push(' ' + yq(k) + ': ' + yq(headers[k]));
29
+ }
30
+ }
31
+ else {
32
+ lines.push(' command: ' + yq(row.command || ''));
33
+ const args = row.args || [];
34
+ if (args.length) {
35
+ lines.push(' args:');
36
+ for (const a of args)
37
+ lines.push(' - ' + yq(a));
38
+ }
39
+ const env = row.env || {};
40
+ const ek = Object.keys(env);
41
+ if (ek.length) {
42
+ lines.push(' env:');
43
+ for (const k of ek)
44
+ lines.push(' ' + yq(k) + ': ' + yq(env[k]));
45
+ }
46
+ }
47
+ if (row.toolCallTimeoutMs)
48
+ lines.push(' toolCallTimeoutMs: ' + Number(row.toolCallTimeoutMs));
49
+ // 结构性兜底:这里是所有 MCP 写路径的**唯一** YAML 出口。打码值到这一步还没被拦下,
50
+ // 说明某个调用点漏过了 ./secret-guard.js —— 那是代码缺陷,宁可整次写入失败,
51
+ // 也不能把 `••••••` 写进补丁文件(真密钥一旦被覆盖就找不回来了:本机 2026-09-18
52
+ // `TAVILY_API_KEY` 就是这样丢的)。所以这里**抛错**而不是静默剔除。
53
+ //
54
+ // 覆盖范围的边界(别读成"所有字段都查了"):断言只查 env / headers 的整串 `•` 形态。
55
+ // URL 的打码是另一套(查询串换成 `<redacted>`),由调用方经 `resolveMaskedUrl` 收敛,
56
+ // 不在这里 —— 它需要"没有原值就拒绝"这种带上下文的裁决,不是一句断言能表达的。
57
+ const leaked = [...maskedKeysIn(row.env), ...maskedKeysIn(row.headers)];
58
+ if (leaked.length) {
59
+ throw new Error('拒绝写入:' + leaked.join('、') + ' 的值仍是打码占位符(调用方应先经 resolveMaskedKv 收敛)');
60
+ }
61
+ return lines.join('\n');
62
+ }
63
+ export function buildDisableBlock(id, disabled) {
64
+ return [
65
+ '# dsh-plugin-tool-management:' + (disabled ? 'disable' : 'enable') + ':' + id,
66
+ '- id: ' + yplain(id),
67
+ " name: '" + MCP_CLIENT_MODULE + "'",
68
+ ' disabled: ' + (disabled ? 'true' : 'false'),
69
+ ].join('\n');
70
+ }
71
+ // ---------- YAML parsing (mini parser) ----------
72
+ // Known limitation: this hand-rolled parser assumes the exact indentation
73
+ // style that buildInsertBlock emits (config at 6 spaces, children at 8,
74
+ // nested maps/lists at 10+). Hand-edited patch files using different
75
+ // indentation may parse incorrectly — DSH itself only cares about the
76
+ // effective YAML it reads, and this parser exists purely for the UI.
77
+ function splitKV(text) {
78
+ const m = text.match(/^("(?:\\.|[^"])*"|'[^']*'|[^:]+?)\s*:\s*(.*)$/);
79
+ if (!m)
80
+ return null;
81
+ return { key: unquote(m[1]), value: m[2] };
82
+ }
83
+ function unquote(v) {
84
+ if (v === undefined || v === null)
85
+ return v;
86
+ const s = String(v).trim();
87
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
88
+ try {
89
+ return JSON.parse(s);
90
+ }
91
+ catch (e) {
92
+ return s.slice(1, -1);
93
+ }
94
+ }
95
+ if (s.length >= 2 && s.startsWith("'") && s.endsWith("'"))
96
+ return s.slice(1, -1).replace(/''/g, "'");
97
+ if (/^\[.*\]$/.test(s))
98
+ return s.slice(1, -1).split(',').map((x) => unquote(x.trim())).filter((x) => x !== '');
99
+ if (s === 'true')
100
+ return true;
101
+ if (s === 'false')
102
+ return false;
103
+ if (/^-?\d+$/.test(s))
104
+ return Number(s);
105
+ return s;
106
+ }
107
+ function parseEntry(lines) {
108
+ const entry = { config: {} };
109
+ let inConfig = false;
110
+ let configIndent = 0;
111
+ let nested = null;
112
+ for (const line of lines) {
113
+ const trimmed = line.trim();
114
+ if (!trimmed || trimmed.startsWith('#'))
115
+ continue;
116
+ const indent = line.match(/^\s*/)[0].length;
117
+ let t = trimmed;
118
+ if (t.startsWith('- '))
119
+ t = t.slice(2).trim();
120
+ const kv = splitKV(t);
121
+ if (!kv) {
122
+ if (inConfig && nested && nested.type === 'list')
123
+ nested.current.push(unquote(t));
124
+ continue;
125
+ }
126
+ if (!inConfig) {
127
+ if (kv.key === 'config' && kv.value === '') {
128
+ inConfig = true;
129
+ configIndent = indent;
130
+ continue;
131
+ }
132
+ if (kv.key === 'id')
133
+ entry.id = unquote(kv.value);
134
+ else if (kv.key === 'name')
135
+ entry.name = unquote(kv.value);
136
+ else if (kv.key === 'disabled')
137
+ entry.disabled = kv.value === 'true';
138
+ continue;
139
+ }
140
+ if (indent <= configIndent) {
141
+ inConfig = false;
142
+ nested = null;
143
+ continue;
144
+ }
145
+ if (kv.value === '' && (kv.key === 'headers' || kv.key === 'env')) {
146
+ nested = { key: kv.key, indent, type: 'map', current: {} };
147
+ entry.config[kv.key] = nested.current;
148
+ continue;
149
+ }
150
+ if (kv.value === '' && kv.key === 'args') {
151
+ nested = { key: kv.key, indent, type: 'list', current: [] };
152
+ entry.config[kv.key] = nested.current;
153
+ continue;
154
+ }
155
+ if (nested && indent > nested.indent) {
156
+ if (nested.type === 'map')
157
+ nested.current[kv.key] = unquote(kv.value);
158
+ else if (nested.type === 'list')
159
+ nested.current.push(unquote(kv.value));
160
+ continue;
161
+ }
162
+ nested = null;
163
+ entry.config[kv.key] = unquote(kv.value);
164
+ }
165
+ return entry;
166
+ }
167
+ export function parseRows(content) {
168
+ const lines = content.split(/\r?\n/);
169
+ const managedIds = new Set();
170
+ for (const line of lines) {
171
+ // Markers written by either this plugin or the template upstream count
172
+ // as managed (coexistence: both can edit the same patch file).
173
+ const m = line.match(/^# (?:dsh-plugin-tool-management|dsh-mcp-manager):server:(.+)$/);
174
+ if (m)
175
+ managedIds.add(m[1].trim());
176
+ }
177
+ const rows = [];
178
+ const blocks = [];
179
+ let current = null;
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];
191
+ if (/^- insert:/.test(head)) {
192
+ const parts = block.text.split('\n');
193
+ const children = [];
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);
210
+ if (entry && entry.name === MCP_CLIENT_MODULE) {
211
+ rows.push({ id: entry.id, name: entry.name, disabled: entry.disabled, config: entry.config, managed: managedIds.has(entry.id) });
212
+ }
213
+ }
214
+ }
215
+ else {
216
+ // 覆盖块按**文件顺序**生效,不是"收集后统一回填":官方
217
+ // `@deepseek-ai/dsh-app-boot` 的 applyEntryPatches 只给此刻已入索引的 id 打补丁
218
+ //(insert 是插入时立即入索引),命中不到就 warn + skip。所以写在 insert 之前的
219
+ // 覆盖块在宿主侧是 no-op —— 这里同样丢弃,界面才和生效值一致。
220
+ const entry = parseEntry(block.text.split('\n'));
221
+ if (entry && entry.name === MCP_CLIENT_MODULE && entry.disabled !== undefined) {
222
+ const row = rows.find((r) => r.id === entry.id);
223
+ if (row)
224
+ row.disabled = entry.disabled;
225
+ }
226
+ }
227
+ }
228
+ return { rows };
229
+ }
230
+ // ---------- line-based block editing ----------
231
+ export function splitLines(content) { return content.split(/\r?\n/); }
232
+ function joinLines(lines) {
233
+ let res = lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '\n');
234
+ if (!res.trim()) {
235
+ res = '[]\n';
236
+ }
237
+ else if (!/^- /m.test(res) && !/^\[\]\s*$/m.test(res)) {
238
+ // A patch file must stay a top-level YAML array: after removing the last
239
+ // entry, emit [] so loadOptionalPatches never throws on a comments-only file.
240
+ res = res.replace(/\n*$/, '\n[]\n');
241
+ }
242
+ return res;
243
+ }
244
+ function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
245
+ function markerRanges(lines, id, ops) {
246
+ const n = lines.length;
247
+ // Match this plugin's markers AND the template upstream's
248
+ // (`# dsh-mcp-manager:server|disable|enable:<id>`) so an entry written by
249
+ // either manager can be located and cleaned up without orphan blocks —
250
+ // the two plugins are designed to coexist.
251
+ const re = new RegExp('^# (?:dsh-plugin-tool-management|dsh-mcp-manager):(' + ops + '):' + escRe(id) + '$');
252
+ const ranges = [];
253
+ for (let i = 0; i < n; i++) {
254
+ if (!re.test(lines[i]))
255
+ continue;
256
+ let j = i + 1;
257
+ while (j < n && !/^- /.test(lines[j]))
258
+ j++;
259
+ let end = j;
260
+ if (j < n && /^- /.test(lines[j])) {
261
+ let k = j + 1;
262
+ while (k < n && !/^- /.test(lines[k]))
263
+ k++;
264
+ end = k;
265
+ }
266
+ ranges.push([i, end]);
267
+ }
268
+ return ranges;
269
+ }
270
+ /**
271
+ * 命中 id 的 `- insert:` 区间。
272
+ *
273
+ * 一个 `- insert:` 下**可以挂多个子条目**(`parseRows` 就是这么读的:它按 ` - `
274
+ * 切子条目逐个解析)。此前只要块内有任一子条目命中就返回**整块**,于是
275
+ * `mcpm-edit` / `mcpm-remove` 会顺手删掉同块兄弟服务器 —— 插件自己写的是"一块一条",
276
+ * 出事的都是手工编辑出来的多子条目块,而那正是最需要保住的形状(用户的手写意图)。
277
+ *
278
+ * 所以按子条目粒度切:只删命中的那一条,兄弟留下;整块只剩这一条时连 `- insert:`
279
+ * 一起删,不留一个空壳块(`- insert:` 下没有子条目在 YAML 里是 null,宿主会跳过)。
280
+ */
281
+ function insertBlockRange(lines, id) {
282
+ const n = lines.length;
283
+ const entryRe = new RegExp('^\\s*- id: ' + escRe(id) + '\\s*$');
284
+ for (let i = 0; i < n; i++) {
285
+ if (!/^- insert:/.test(lines[i]))
286
+ continue;
287
+ let end = i + 1;
288
+ while (end < n && !/^- /.test(lines[end]))
289
+ end++;
290
+ const starts = [];
291
+ for (let k = i + 1; k < end; k++)
292
+ if (/^ {4}- /.test(lines[k]))
293
+ starts.push(k);
294
+ // 切不出子条目(缩进不是生成器那套的手写块):退回"整块",与改动前一致 ——
295
+ // 认不出形状时宁可照旧删整块,也好过认不出就跳过、让 edit 追加出一条重复条目。
296
+ if (!starts.length) {
297
+ if (lines.slice(i, end).some((l) => entryRe.test(l)))
298
+ return [i, end];
299
+ continue;
300
+ }
301
+ for (let s = 0; s < starts.length; s++) {
302
+ const from = starts[s];
303
+ const to = s + 1 < starts.length ? starts[s + 1] : end;
304
+ if (!lines.slice(from, to).some((l) => entryRe.test(l)))
305
+ continue;
306
+ return starts.length === 1 ? [i, end] : [from, to];
307
+ }
308
+ }
309
+ return null;
310
+ }
311
+ function bareOverrideRanges(lines, id) {
312
+ const n = lines.length;
313
+ const re = new RegExp('^- id: ' + escRe(id) + '\\s*$');
314
+ const ranges = [];
315
+ for (let i = 0; i < n; i++) {
316
+ if (!re.test(lines[i]))
317
+ continue;
318
+ let end = i + 1;
319
+ while (end < n && !/^- /.test(lines[end]))
320
+ end++;
321
+ ranges.push([i, end]);
322
+ }
323
+ return ranges;
324
+ }
325
+ export function spliceRanges(lines, ranges) {
326
+ const remove = new Set();
327
+ for (const r of ranges)
328
+ for (let i = r[0]; i < r[1]; i++)
329
+ remove.add(i);
330
+ return joinLines(lines.filter((_, i) => !remove.has(i)));
331
+ }
332
+ export function removeEntryAll(content, id) {
333
+ const lines = splitLines(content);
334
+ const ranges = markerRanges(lines, id, 'server|disable|enable');
335
+ const ib = insertBlockRange(lines, id);
336
+ if (ib)
337
+ ranges.push(ib);
338
+ ranges.push(...bareOverrideRanges(lines, id));
339
+ return spliceRanges(lines, ranges);
340
+ }
341
+ export function removeMarked(content, id, op) {
342
+ return spliceRanges(splitLines(content), markerRanges(splitLines(content), id, op));
343
+ }
344
+ export function appendBlock(content, block) {
345
+ let c = content;
346
+ if (/^\[\]\s*$/m.test(c))
347
+ c = c.replace(/^\[\]\s*$/m, block + '\n');
348
+ else
349
+ c = c.replace(/\s*$/, '\n' + block + '\n');
350
+ return c;
351
+ }
@@ -0,0 +1,145 @@
1
+ // 打码值不得入库 —— env / headers 与 URL 查询串两条形态的唯一口径。
2
+ //
3
+ // 为什么需要它(2026-09-18,本机真实事故):MCP 列表默认把 env / headers 里的凭据打码成
4
+ // `••••••`,而「编辑」弹窗的密钥框**预填的就是这个打码值** —— 界面拿到的行本身就是打码的
5
+ // (列表视图走 `mcpmRowsWithNotes(false)`)。于是"打开编辑 → 改个别的字段 → 保存"这条
6
+ // 最普通的操作,会把打码值当成真值写回补丁文件,**真密钥永久丢失、且无法找回**
7
+ // (本机 `TAVILY_API_KEY` 就是这样变成 `••••••` 的)。
8
+ //
9
+ // URL 是**第二种形态**,别把它漏掉(2026-09-19 审计 T-23):`maskUrlQuery` 不产出 `••••••`,
10
+ // 而是把整个查询串换成 `<redacted>`(经 URL 序列化后是 `?<redacted>` 的百分号编码)。
11
+ // 打码值判据只认整串 `•` 时它永远命中不了,于是 `mcpmEdit` 会把 `?<redacted>` 当成真 URL
12
+ // 写回补丁 —— 一次普通「编辑保存」即把 URL 里的凭据**永久**改成占位串。所以本模块同时
13
+ // 提供 `isMaskedUrl` / `resolveMaskedUrl`,与下面的 env / headers 判据并列。
14
+ //
15
+ // 判据为什么可以这么硬:打码值只可能表示"用户没改这一项",绝不可能表示"用户想把它设成这个"。
16
+ // 所以三道闸的方向都是「往旧值收」:
17
+ // ① 有旧真值 → 用旧真值顶替(用户意图 = 不改这一项);
18
+ // ② 没有旧真值 → **丢弃该键**(宁可少一个键,也不写一个假值进补丁);
19
+ // ③ 旧值本身已是打码值 → 额外报 `alreadyBroken`,界面据此提示"原值已丢失,请重新填写"。
20
+ //
21
+ // 为什么是"顶替 + warning"而不是"报错拒绝":拒绝会让每一次编辑都失败(预填的本来就是打码值),
22
+ // 而用户的意图显然是改别的字段。顶替才是对意图的如实翻译;**报错只留给最后一道结构性兜底**
23
+ // (`buildInsertBlock` 出口的 `maskedKeysIn` 断言)—— 那里一旦命中就说明某个调用点漏过了本模块,
24
+ // 属于代码缺陷,必须吵。
25
+ //
26
+ // 与界面侧的关系:取消打码(点「显示密钥」+ 对的访问令牌)走 `mcpm-reveal`,那条路上
27
+ // 拿到的是**真值**,本模块不参与;本模块只处理"表单里出现打码值"这一种输入。
28
+ /** 打码占位符的字面值。宿主 `maskSecretValue()` 产出的就是它,两处必须同源。 */
29
+ export const MASK_PLACEHOLDER = '••••••';
30
+ /**
31
+ * 一个值是不是打码占位符。
32
+ *
33
+ * 只认「去空白后全是 `•`」:`maskSecretValue` 是**整值替换**(不保留前缀),所以这条规则
34
+ * 既不需要知道原值长度,也不会把真值误判进来 —— 真密钥恰好整串都是 `•` 的概率不存在。
35
+ * 这正是"统一为整值替换"而不是"保留前 4 个字符"的理由:后者的判据得写成 `.+?\*{4}$`,
36
+ * 真值以 `****` 结尾时就会误判成打码值,判错的方向是**丢掉真密钥**。
37
+ */
38
+ export function isMaskedValue(value) {
39
+ const text = String(value == null ? '' : value).trim();
40
+ return text.length > 0 && /^•+$/.test(text);
41
+ }
42
+ /** `map` 里值是打码占位符的键(原序)。空/非对象一律当"没有"。 */
43
+ export function maskedKeysIn(map) {
44
+ if (!map || typeof map !== 'object')
45
+ return [];
46
+ return Object.keys(map).filter((key) => isMaskedValue(map[key]));
47
+ }
48
+ /** `maskUrlQuery` 写进查询串的哨兵(去掉 `?` 之后那一段)。 */
49
+ const URL_REDACTED_QUERY = '<redacted>';
50
+ /**
51
+ * 一个 URL 的查询串是不是被 `maskUrlQuery` 打过码。
52
+ *
53
+ * 只判**查询串**:路径与主机是该服务的身份,打码不会碰它们,把它们算进来会把真 URL 误判成
54
+ * 打码值(判错的方向是丢掉真配置)。经 `new URL()` 一过,`<` `>` 会被百分号编码,
55
+ * 所以比较前先解码 —— 手写补丁里直接写了 `?<redacted>` 也能认出来。
56
+ */
57
+ export function isMaskedUrl(value) {
58
+ const text = String(value == null ? '' : value).trim();
59
+ if (!text)
60
+ return false;
61
+ try {
62
+ const parsed = new URL(text);
63
+ if (!parsed.search)
64
+ return false;
65
+ const query = parsed.search.replace(/^\?/, '');
66
+ return query === URL_REDACTED_QUERY || decodeURIComponent(query) === URL_REDACTED_QUERY;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /**
73
+ * 把表单来的 URL 收敛成"可以安全写盘"的一个(与 `resolveMaskedKv` 同方向:往旧值收)。
74
+ *
75
+ * 为什么"没有旧值"时不学 env / headers 那样丢弃键:URL 不是可选的键值对 —— 丢掉查询串
76
+ * 等于写下一个**能连上但鉴权失败**的地址,界面与用户都会以为配置还完好。宁可整次保存失败
77
+ * 并让用户重填,也不留一个假的可写值。
78
+ *
79
+ * @param incoming - 表单里的 URL。
80
+ * @param current - 该条目**在补丁文件里的旧 URL**;新增条目传 `null`。
81
+ */
82
+ export function resolveMaskedUrl(incoming, current) {
83
+ const value = String(incoming == null ? '' : incoming).trim();
84
+ if (!isMaskedUrl(value))
85
+ return { value, restored: false, unrecoverable: false };
86
+ const previous = String(current == null ? '' : current).trim();
87
+ if (previous && !isMaskedUrl(previous))
88
+ return { value: previous, restored: true, unrecoverable: false };
89
+ return { value, restored: false, unrecoverable: true };
90
+ }
91
+ /**
92
+ * 把表单来的键值对收敛成"可以安全写盘"的一份。
93
+ *
94
+ * @param incoming - 表单解析出来的映射(可能整体为空)。
95
+ * @param current - 该条目**在补丁文件里的旧值**;新增条目传 `null`。
96
+ * @returns 收敛后的映射 + 三类命中键,供调用方回一条 warning。
97
+ */
98
+ export function resolveMaskedKv(incoming, current) {
99
+ const out = {};
100
+ const restored = [];
101
+ const dropped = [];
102
+ const alreadyBroken = [];
103
+ const incomingMap = incoming && typeof incoming === 'object' ? incoming : {};
104
+ const currentMap = current && typeof current === 'object' ? current : {};
105
+ for (const key of Object.keys(incomingMap)) {
106
+ const value = String(incomingMap[key]);
107
+ if (!isMaskedValue(value)) {
108
+ out[key] = value;
109
+ continue;
110
+ }
111
+ const previous = currentMap[key];
112
+ // 旧值存在且不是打码值 ⇒ 只有一种可能:表单里的是打码值,用户的意图是「不改」。
113
+ if (previous != null && previous !== '' && !isMaskedValue(previous)) {
114
+ out[key] = String(previous);
115
+ restored.push(key);
116
+ continue;
117
+ }
118
+ dropped.push(key);
119
+ if (previous != null && isMaskedValue(previous))
120
+ alreadyBroken.push(key);
121
+ }
122
+ return { value: out, restored, dropped, alreadyBroken };
123
+ }
124
+ /**
125
+ * 把命中情况拼成一句给用户看的中文说明;没命中返回 `''`。
126
+ *
127
+ * 措辞刻意区分三件事:`alreadyBroken` 是**已经发生的损失**(要重填),`restored` 是
128
+ * **本次没造成损失**(值保持原样),`dropped` 是**本次丢弃了输入**(表单里凭空出现的打码值)。
129
+ * 三者混成一句的话,用户无法判断自己要不要动手。
130
+ */
131
+ export function describeMaskedOutcome(outcome) {
132
+ const parts = [];
133
+ if (outcome.alreadyBroken.length) {
134
+ parts.push('这些密钥在补丁文件里已经是打码占位符,真实值已丢失,请重新填写:' + outcome.alreadyBroken.join('、'));
135
+ }
136
+ if (outcome.restored.length) {
137
+ parts.push('这些密钥未改动,已保留原值:' + outcome.restored.join('、'));
138
+ }
139
+ if (outcome.dropped.length > outcome.alreadyBroken.length) {
140
+ const plain = outcome.dropped.filter((key) => !outcome.alreadyBroken.includes(key));
141
+ if (plain.length)
142
+ parts.push('这些键的值是打码占位符且没有原值可保留,已跳过:' + plain.join('、'));
143
+ }
144
+ return parts.join(';');
145
+ }