dsh-armory 2.4.3 → 2.4.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.
@@ -0,0 +1,113 @@
1
+ // 系统级「打开链接 / 定位文件」的唯一出口。
2
+ //
3
+ // 为什么单独成模块(issue #7):headless 主机(NAS / 容器里的 `dsh web`)没有 xdg-open。
4
+ // spawn 的 ENOENT 不是同步 throw,而是异步 'error' 事件;没人监听就升级成
5
+ // uncaughtException,直接把宿主进程带走——用户看到的是「点一下安装,所有 API 502」。
6
+ // 这里等到 spawn / error 任意一个落地再返回:调用方拿到可判定的结果,也不会再有
7
+ // 「spawn 完立刻 unref、错误没人接」的写法漏进业务代码。
8
+ import { spawn } from 'node:child_process';
9
+
10
+ // 极少数平台既不回 spawn 也不回 error。不能为了一次「打开链接」卡住 RPC,按成功放行。
11
+ const LAUNCH_TIMEOUT_MS = 5000;
12
+
13
+ // 允许交给系统浏览器打开的 URL。rundll32 / open / xdg-open 收到本地路径时会直接用
14
+ // 关联程序执行(.exe / .command / .app),所以这里必须先做协议白名单:只放 https,
15
+ // http 仅限本机回环(本地开发服务)。javascript: / file: / data: 一律拒绝。
16
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
17
+
18
+ /** 是否是允许交给系统浏览器的 URL(协议白名单)。 */
19
+ export function isAllowedExternalUrl(value) {
20
+ const text = String(value ?? '').trim();
21
+ if (!text) return false;
22
+ let parsed;
23
+ try {
24
+ parsed = new URL(text);
25
+ } catch {
26
+ return false;
27
+ }
28
+ if (parsed.protocol === 'https:') return true;
29
+ return parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase());
30
+ }
31
+
32
+ /** 打开 URL 的平台命令。 */
33
+ export function urlOpenCommand(target, platform = process.platform) {
34
+ if (platform === 'win32') {
35
+ // 绝不能用 `cmd /c start "$url"`:Node 只在参数含空格时才给参数加引号,而 URL 不含空格,
36
+ // cmd 于是把 URL 里的 & 当成命令分隔符,授权地址被截断成 `?response_type=code`,
37
+ // 服务器随后回 `invalid_request: client_id is required`。
38
+ // rundll32 走 ShellExecute 且参数直传,不经 cmd 解析。
39
+ return { command: 'rundll32', args: ['url.dll,FileProtocolHandler', target] };
40
+ }
41
+ return { command: platform === 'darwin' ? 'open' : 'xdg-open', args: [target] };
42
+ }
43
+
44
+ /** 在系统文件管理器中定位路径的平台命令。 */
45
+ export function pathRevealCommand(target, platform = process.platform) {
46
+ if (platform === 'win32') return { command: 'explorer', args: ['/select,', target] };
47
+ return { command: platform === 'darwin' ? 'open' : 'xdg-open', args: [target] };
48
+ }
49
+
50
+ function failedOutcome(command, error, logger) {
51
+ const reason =
52
+ error?.code === 'ENOENT'
53
+ ? `找不到系统命令 ${command}(headless 主机通常没有桌面打开器)`
54
+ : String(error?.message || error);
55
+ try {
56
+ logger?.warn?.(`[dsh-armory] 系统打开器不可用:${reason}`);
57
+ } catch {
58
+ /* 日志本身不该再制造一次失败 */
59
+ }
60
+ return { ok: false, reason, code: error?.code || '' };
61
+ }
62
+
63
+ /**
64
+ * 启动一个分离进程,并等「确实起来了」或「确实失败了」再返回。永不 reject。
65
+ * 成功才 unref;失败时进程根本没起来,unref 无意义。
66
+ */
67
+ export async function launchDetached(command, args, { logger } = {}) {
68
+ let child;
69
+ try {
70
+ child = spawn(command, args, { detached: true, stdio: 'ignore' });
71
+ } catch (error) {
72
+ return failedOutcome(command, error, logger);
73
+ }
74
+ const outcome = await new Promise((resolve) => {
75
+ let settled = false;
76
+ let timer = null;
77
+ const finish = (result) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ if (timer) clearTimeout(timer);
81
+ resolve(result);
82
+ };
83
+ // 监听器常驻:成功之后才冒出来的 error 同样必须被吃掉,不能再回到 uncaughtException。
84
+ child.on('error', (error) => finish(failedOutcome(command, error, logger)));
85
+ child.once('spawn', () => finish({ ok: true }));
86
+ timer = setTimeout(() => finish({ ok: true, timedOut: true }), LAUNCH_TIMEOUT_MS);
87
+ timer.unref?.();
88
+ });
89
+ if (outcome.ok) child.unref();
90
+ return outcome;
91
+ }
92
+
93
+ /** 用系统默认浏览器打开 URL。非白名单协议直接拒绝,不启动任何进程。 */
94
+ export function openSystemUrl(url, { logger, platform = process.platform } = {}) {
95
+ if (!isAllowedExternalUrl(url)) {
96
+ const reason = '只允许用系统浏览器打开 https 链接(http 仅限本机回环)';
97
+ try {
98
+ logger?.warn?.('[dsh-armory] 已拦截非白名单协议的打开请求');
99
+ } catch {
100
+ /* 日志本身不该再制造一次失败 */
101
+ }
102
+ return Promise.resolve({ ok: false, reason, code: 'EBADURL' });
103
+ }
104
+ const target = String(url).trim();
105
+ const { command, args } = urlOpenCommand(target, platform);
106
+ return launchDetached(command, args, { logger });
107
+ }
108
+
109
+ /** 用系统文件管理器定位路径。 */
110
+ export function revealSystemPath(path, { logger, platform = process.platform } = {}) {
111
+ const { command, args } = pathRevealCommand(path, platform);
112
+ return launchDetached(command, args, { logger });
113
+ }
package/lib/typert.js CHANGED
@@ -77,6 +77,10 @@ export const TYPERT = {
77
77
  // 卸载已安装 skill:永久删除用户级/项目级 skills 根目录内的条目。三处契约缺一即 404,
78
78
  // 删除规则全在 lib/skill-remove.js(来源白名单 / 根目录包含性 / 符号链接只删链接)。
79
79
  mv('removeSkill', [payloadParam], 'NoteResult'),
80
+ // 禁用 / 释放 skill(工具裁缝技能账本行内按钮):写 SKILL.md frontmatter 的
81
+ // disable-model-invocation 标志(宿主官方键),写盘规则全在 lib/skill-disable.js。
82
+ mv('disableSkill', [payloadParam], 'NoteResult'),
83
+ mv('enableSkill', [payloadParam], 'NoteResult'),
80
84
  // 安装时间 + 市场原名(Skill 看板卡片徽标 /「最近安装」排序的数据源):宿主自扫
81
85
  // 标准根目录按 SKILL.md name 建映射,出 { ok, info: { [name]: { installedAt, ... } } }。
82
86
  // 不按 listSkills 行的 path 查——注册表行可能整列缺 path(实测)。形状由
@@ -115,6 +119,12 @@ export const TYPERT = {
115
119
  mv('wbInstallStateForget', [payloadParam], 'NoteResult'),
116
120
  // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins,数据层 lib/wb-awesome-market.js)。
117
121
  mv('wbAwesomeCatalog', [payloadParam], 'AwesomeCatalogResult'),
122
+ // 社区 MCP:官方 MCP Registry v0 只读列表(数据层 lib/mcp-community.js)。
123
+ // 与上面 wb* 系列同一宽容度:payload 形状由数据层校验,契约层 $any。
124
+ mv('wbMcpCommunityList', [payloadParam], 'WbMcpCommunityResult'),
125
+ // 社区目录全库总数(有界、可续爬的分片统计):Registry 不返回总数,面板徽标要常驻
126
+ // 显示"到底有多少条"只能顺游标数。payload $any({cursor, pages}),结果 {counted, nextCursor, done}。
127
+ mv('wbMcpCommunityCount', [payloadParam], 'WbMcpCommunityCountResult'),
118
128
  mv('wbAwesomeDetail', [payloadParam], 'AwesomeDetailResult'),
119
129
  // README 预览:点击卡片按需取一次(npm registry / GitHub raw),落盘缓存 7 天。
120
130
  mv('wbAwesomeReadme', [payloadParam], 'AwesomeReadmeResult'),
@@ -150,6 +160,9 @@ export const TYPERT = {
150
160
  mv('wbOAuthCancel', [payloadParam], 'NoteResult'),
151
161
  mv('wbOAuthToken', [payloadParam], 'NoteResult'),
152
162
  mv('wbOAuthOverview', [payloadParam], 'NoteResult'),
163
+ // 强制刷新已存的 OAuth token(refresh_token 换新)。面板「刷新授权」用它,
164
+ // 刷新后再走一次 wbConnectorInstall 把新凭据写回 headers。
165
+ mv('wbOAuthRefresh', [payloadParam], 'NoteResult'),
153
166
  mv('wbOAuthOpen', [payloadParam], 'NoteResult'),
154
167
  ],
155
168
  model: { services: [], events: [], objects: [] },
@@ -37,20 +37,24 @@ export function rulesFor(rules, connectorId) {
37
37
  }
38
38
 
39
39
  /** 一个待填 token 槽位:占位符在哪个 server 的哪个容器字段里。 */
40
- // 形状:{ server, container: "env"|"header", key, template, placeholder, value }
40
+ // 形状:{ server, container: "env"|"header"|"url", key, template, placeholder, value }
41
+ // url 槽位是「凭据写在连接地址里」的形态:`https://api.x/mcp/?token=${TOKEN}`。实测
42
+ // 连接器市场里有 12 条 auth_mode=token 只把占位符写在 url(tushare / 百度地图 / 快递100…),
43
+ // 只扫 env/headers 会让它们既没有可填槽位、又被判「未声明可替换」,安装入口整个消失。
41
44
 
42
45
  const PLACEHOLDER_PATTERN = /\$\{([^}]+)\}/g;
43
46
  // 非全局版:.test() 不会推进 lastIndex(全局正则连续 test 会漏判残留占位符)。
44
47
  const HAS_PLACEHOLDER = /\$\{[^}]+\}/;
45
48
 
46
49
  /**
47
- * 扫描 mcpServers 的 env 与 headers 值里的 `${...}` 模板占位符。
48
- * 返回槽位清单:[{ server, container: "env"|"header", key, template, placeholder, value }]
49
- * - container 字段容器:env → env 对象;header → headers 对象(对外统一命名)
50
- * - template 原始模板串(如 "Bearer ${token}")
50
+ * 扫描 mcpServers 的 env / headers / url 里的 `${...}` 模板占位符。
51
+ * 返回槽位清单:[{ server, container: "env"|"header"|"url", key, template, placeholder, value }]
52
+ * - container 字段容器:env → env 对象;header → headers 对象(对外统一命名);
53
+ * url → server 的连接地址(key 固定为 "url")
54
+ * - template 原始模板串(如 "Bearer ${token}"、"?token=${TOKEN}")
51
55
  * - placeholder 第一个占位符(如 "token");模板无占位符的条目不进清单
52
56
  * - value 原值(供 applyTokens 做精确字符串替换与回显)
53
- * mcpServers / env / headers 畸形(null、数组、值非字符串)一律跳过,不抛错。
57
+ * mcpServers / env / headers / url 畸形(null、数组、值非字符串)一律跳过,不抛错。
54
58
  */
55
59
  export function scanTokenSlots(mcpServers) {
56
60
  if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) return [];
@@ -79,10 +83,36 @@ export function scanTokenSlots(mcpServers) {
79
83
  });
80
84
  }
81
85
  }
86
+ // url 槽位:凭据写在连接地址里(?token=${X} / /mcp/${X})。与 env/headers 分开处理,
87
+ // 因为它在 server 本体的字符串字段上,applyTokens 也要按字符串整体替换。
88
+ const urlValue = typeof nested.url === "string" ? nested.url : "";
89
+ const urlMatches = urlValue ? [...urlValue.matchAll(PLACEHOLDER_PATTERN)] : [];
90
+ if (urlMatches.length) {
91
+ slots.push({
92
+ server,
93
+ container: "url",
94
+ key: "url",
95
+ template: urlValue,
96
+ placeholder: urlMatches[0][1].trim(),
97
+ value: urlValue,
98
+ });
99
+ }
82
100
  }
83
101
  return slots;
84
102
  }
85
103
 
104
+ /** 把模板串里所有有值 token 的占位符替换掉;无值占位符原样保留。 */
105
+ function fillPlaceholders(template, tokenMap) {
106
+ let filled = String(template);
107
+ for (const match of filled.matchAll(PLACEHOLDER_PATTERN)) {
108
+ const token = tokenMap[match[1].trim()];
109
+ if (typeof token === "string" && token.length) {
110
+ filled = filled.split(match[0]).join(token);
111
+ }
112
+ }
113
+ return filled;
114
+ }
115
+
86
116
  /**
87
117
  * 按槽位把占位符替换为用户 token,产出「可安全写盘」的 mcpServers 副本。
88
118
  * - tokens: Record<placeholder, value>,键为占位符名(如 { access_token: "xxx" })。
@@ -92,6 +122,7 @@ export function scanTokenSlots(mcpServers) {
92
122
  * missingSlots [{ server, container, key, placeholder, template }] 未填槽位清单
93
123
  * 输入畸形时返回 { mcpServers: {}, missingSlots: [] };对每个槽位同时应用模板内**所有**
94
124
  * 占位符("Bearer ${a}/${b}" 两处都能换),全替换后仍含 `${` 的值按未填槽位上报。
125
+ * url 槽位整串替换(`?token=${TOKEN}` → `?token=xxx`),写回 server.url / transport.url。
95
126
  */
96
127
  export function applyTokens(mcpServers, tokens) {
97
128
  const source = mcpServers && typeof mcpServers === "object" && !Array.isArray(mcpServers) ? mcpServers : {};
@@ -100,17 +131,17 @@ export function applyTokens(mcpServers, tokens) {
100
131
  const result = structuredClone(source);
101
132
  const slots = scanTokenSlots(result);
102
133
  for (const slot of slots) {
103
- const target = slot.container === "header"
104
- ? result[slot.server]?.transport?.headers ?? result[slot.server]?.headers
105
- : result[slot.server]?.transport?.env ?? result[slot.server]?.env;
134
+ // url 槽位落在 server 本体的字符串字段上;transport 嵌套形态则落在 transport.url。
135
+ const urlHolder = result[slot.server]?.transport && typeof result[slot.server].transport === "object"
136
+ ? result[slot.server].transport
137
+ : result[slot.server];
138
+ const target = slot.container === "url"
139
+ ? (urlHolder && typeof urlHolder.url === "string" ? urlHolder : null)
140
+ : slot.container === "header"
141
+ ? result[slot.server]?.transport?.headers ?? result[slot.server]?.headers
142
+ : result[slot.server]?.transport?.env ?? result[slot.server]?.env;
106
143
  if (!target || typeof target !== "object") continue;
107
- let filled = String(target[slot.key]);
108
- for (const match of filled.matchAll(PLACEHOLDER_PATTERN)) {
109
- const token = tokenMap[match[1].trim()];
110
- if (typeof token === "string" && token.length) {
111
- filled = filled.split(match[0]).join(token);
112
- }
113
- }
144
+ const filled = fillPlaceholders(slot.container === "url" ? target.url : target[slot.key], tokenMap);
114
145
  if (HAS_PLACEHOLDER.test(filled)) {
115
146
  // 仍有残留占位符 → 该槽位缺 token:保留原样并上报。
116
147
  missingSlots.push({
package/lib/wb-market.js CHANGED
@@ -277,6 +277,79 @@ export function serverInstallability(serverName, rawServer, authMode = "") {
277
277
  return { ok: reasons.length === 0, transport: transport || "stdio", reasons, tokenSlots, needsTokens, needsOAuth };
278
278
  }
279
279
 
280
+ // ── 本机固定依赖(本地 stdio 连接器)────────────────────────────────────
281
+ // 少数连接器不是「远程 URL + 凭据」,而是**驱动本机已安装的客户端**:mcp.json 把固定绝对
282
+ // 路径(或 `cmd /d /c <path>` 包装)写死,能不能连上取决于用户机器上有没有那个程序、有没有
283
+ // 开启它的 MCP/集成开关。不做检查时安装会「成功」,随后宿主按 mcp-client 的重连策略失败 10
284
+ // 次才注销工具(issue #3:NVIDIA App 本地助手)。这里只做**静态**检查:认显式绝对路径、在
285
+ // 对应平台 stat 一次;本插件不 spawn 任何 MCP 子进程,也不读取目标程序的配置或凭据。
286
+ const WINDOWS_ABS_PATH = /^[A-Za-z]:[\\/]/;
287
+ const WINDOWS_UNC_PATH = /^\\\\[^\\]/;
288
+ const SHELL_COMMANDS = new Set(["cmd", "cmd.exe", "sh", "bash", "zsh", "powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
289
+
290
+ function unquotePath(value) {
291
+ const text = String(value ?? "").trim();
292
+ if (text.length >= 2 && ((text[0] === '"' && text.at(-1) === '"') || (text[0] === "'" && text.at(-1) === "'"))) {
293
+ return text.slice(1, -1);
294
+ }
295
+ return text;
296
+ }
297
+
298
+ function isFixedAbsolutePath(value) {
299
+ const text = unquotePath(value);
300
+ // `${...}` / `!!js` 是运行时才求值的表达式,静态检查无法判断,交给宿主连接时的真实报错。
301
+ if (!text || text.includes("${") || text.startsWith("!!js ")) return false;
302
+ return WINDOWS_ABS_PATH.test(text) || WINDOWS_UNC_PATH.test(text) || (text.startsWith("/") && !text.startsWith("//"));
303
+ }
304
+
305
+ /** 一条 stdio server 固定依赖的本机可执行文件(只认显式绝对路径,不解析 PATH)。 */
306
+ export function localExecutablePathsOf(rawServer) {
307
+ const nested = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
308
+ const transport =
309
+ String(nested?.type ?? "").toLowerCase() ||
310
+ (typeof nested?.command === "string" && nested.command.trim() ? "stdio" : "");
311
+ if (transport !== "stdio") return [];
312
+ const command = unquotePath(nested?.command);
313
+ if (!command) return [];
314
+ const base = command.split(/[\\/]/).pop().toLowerCase();
315
+ if (SHELL_COMMANDS.has(base)) {
316
+ // cmd /d /c <path>、sh -c <path>:只取执行开关后面的第一个绝对路径参数;
317
+ // `-y`、包名、数据文件之类的一律不当成依赖。
318
+ const args = Array.isArray(nested?.args) ? nested.args.map(unquotePath) : [];
319
+ const flagIndex = args.findIndex((arg) => /^(\/c|-c)$/i.test(arg));
320
+ const target = flagIndex >= 0 ? args[flagIndex + 1] : "";
321
+ return isFixedAbsolutePath(target) ? [target] : [];
322
+ }
323
+ return isFixedAbsolutePath(command) ? [command] : [];
324
+ }
325
+
326
+ /** 固定依赖的静态可用性:found / missing / platform(Windows 路径在别的系统上无意义)。 */
327
+ async function localDependencyStatus(path) {
328
+ const windowsPath = WINDOWS_ABS_PATH.test(path) || WINDOWS_UNC_PATH.test(path);
329
+ const posixPath = path.startsWith("/") && !path.startsWith("//");
330
+ if ((windowsPath && process.platform !== "win32") || (posixPath && process.platform === "win32")) return "platform";
331
+ try {
332
+ return (await stat(path)).isFile() ? "found" : "missing";
333
+ } catch {
334
+ return "missing";
335
+ }
336
+ }
337
+
338
+ /** 整份 mcpServers 的本机固定依赖(按路径去重;没有固定依赖时返回空数组)。 */
339
+ export async function connectorLocalDeps(mcpServers) {
340
+ const paths = new Set();
341
+ const servers = mcpServers && typeof mcpServers === "object" ? Object.values(mcpServers) : [];
342
+ for (const rawServer of servers) for (const path of localExecutablePathsOf(rawServer)) paths.add(path);
343
+ const deps = [];
344
+ for (const path of paths) deps.push({ path, status: await localDependencyStatus(path) });
345
+ return deps;
346
+ }
347
+
348
+ // P1-6:10 分钟模块级缓存。顶栏 chip 与看板都会在每次挂载时问一次,解 24MB ZIP 只为
349
+ // 数一个数字;wbConnectorCatalog 成功后回写,保证刚开过市场时两边同源。
350
+ // 解不出(没有 ZIP)时不缓存 null,下次挂载还会再试——缓存文件可能刚被下载落盘。
351
+ const COUNT_CACHE_TTL = 10 * 60 * 1000;
352
+ let countCache = null;
280
353
  /** connectors.json 索引 + 每条的可装性标注。UI 列表唯一数据源。 */
281
354
  export async function wbConnectorCatalog() {
282
355
  const { zip, loadedFrom } = await loadConnectorZip();
@@ -302,6 +375,9 @@ export async function wbConnectorCatalog() {
302
375
  const servers = mcpJson?.mcpServers && typeof mcpJson.mcpServers === "object" ? Object.entries(mcpJson.mcpServers) : [];
303
376
  const checks = servers.map(([serverName, rawServer]) => ({ serverName, rawServer, ...serverInstallability(serverName, rawServer, entry.auth_mode) }));
304
377
  const blocked = checks.filter((check) => !check.ok);
378
+ // 本机固定依赖:绝大多数连接器是远程 URL / npx 型,这里为空;本地客户端型(如
379
+ // nvapp-windows-local)会带上路径与 found/missing/platform,供详情页与安装闸使用。
380
+ const localDeps = await connectorLocalDeps(mcpJson?.mcpServers);
305
381
  // R3:examples 优先(zh → en 回落),description 只作兜底正文。
306
382
  const copy = displayCopyOf(entry);
307
383
  rows.push({
@@ -323,6 +399,8 @@ export async function wbConnectorCatalog() {
323
399
  // R8:needsOAuth 为 true 时 UI 应走「浏览器授权」引导(而不是拒装/令牌框)。
324
400
  needsOAuth: checks.some((check) => check.needsOAuth),
325
401
  tokenSlots: checks.flatMap((check) => check.tokenSlots || []),
402
+ // 本机固定依赖(没有固定依赖时为空数组)。缺失时安装会被拦住,详情页也会明示路径。
403
+ localDeps,
326
404
  authMode: entry.auth_mode || "",
327
405
  version: entry.version || "",
328
406
  type: entry.type || "",
@@ -337,6 +415,8 @@ export async function wbConnectorCatalog() {
337
415
  : blocked[0]?.reasons.join(";") || "",
338
416
  });
339
417
  }
418
+ // P1-6:列表刚解过的 ZIP 顺便把条目数记下来,顶栏 chip 的计数查询不必再解一次。
419
+ countCache = { at: Date.now(), total: rows.length };
340
420
  return { loadedFrom, total: rows.length, connectors: rows };
341
421
  }
342
422
 
@@ -488,13 +568,17 @@ export async function probeMcpServersAuthorization(mcpServers) {
488
568
  * 单独存在的意义:wbConnectorCatalog 即使热缓存也会做一次 304 网络探测,
489
569
  * 冷缓存更是 24MB 下载——为顶栏一个数字触发这两者都不值得。
490
570
  */
571
+ // P1-6:10 分钟模块级缓存(见上方 countCache 声明)。
491
572
  export async function wbConnectorCachedCount() {
573
+ if (countCache && Date.now() - countCache.at < COUNT_CACHE_TTL) return { total: countCache.total };
492
574
  try {
493
575
  const local = await readFile(zipPath());
494
576
  const zip = new ZipReader(local);
495
577
  const index = JSON.parse(zip.extract(".codebuddy-connector/connectors.json").toString("utf8"));
496
578
  const connectors = Array.isArray(index.connectors) ? index.connectors : [];
497
- return { total: connectors.filter((entry) => String(entry.id || entry.source || "").trim()).length };
579
+ const total = connectors.filter((entry) => String(entry.id || entry.source || "").trim()).length;
580
+ countCache = { at: Date.now(), total };
581
+ return { total };
498
582
  } catch {
499
583
  return { total: null };
500
584
  }
@@ -476,6 +476,50 @@ export const WHATS_NEW_EN = {
476
476
  where: 'README -> Experts / FAQ',
477
477
  },
478
478
  ],
479
+ '2.4.4': [
480
+ {
481
+ text: '**A new Community MCP source on the Connectors tab**: reads the official MCP Registry directly and needs no WorkBuddy account; search by name or description, cursor pagination, an "only directly runnable (npx / uvx)" filter, details with the exact command, args and environment variables, and "Add MCP" only prefills the form — nothing is written until you confirm. Community entries are not reviewed by the Armory, and a permanent notice asks you to verify the command and environment variables yourself',
482
+ where: 'Market -> Connectors -> Source switch',
483
+ },
484
+ {
485
+ text: '**Disable or release a skill from the Tool Tailor skill ledger**: the inline Disable button writes the official `disable-model-invocation` flag into SKILL.md, so the model no longer loads that skill or pays its context tax, while manual `/` references keep working; Release removes the flag. Only user-level and project-level `.agents/skills` and `.dsh/skills` are accepted, after a source allowlist, strict root containment and a realpath check',
486
+ where: 'Tool Tailor -> Skills (skill tool)',
487
+ },
488
+ {
489
+ text: '**Connector cards now say whether the connector is actually connected**: an installed connector with zero tools is labelled "not connected" or "needs authorization" (401/403 failures that reconnecting cannot fix), with Reconnect or Re-authorize offered right there. Details show the authorization expiry; when the server issued a refresh_token, Refresh authorization renews it and writes the new credential back to the config, falling back to browser authorization if that fails. CLI-based connectors also get a local-dependency check',
490
+ where: 'Market -> Connectors',
491
+ },
492
+ {
493
+ text: '**Your expert choice is remembered per session**: an attached expert is recorded in `~/.dsh/wb-market/experts/session-persona.json` (only which session has which expert), so switching sessions, restarting the host, or an agent rebuild after context compaction brings it back automatically; only the × button cancels it for good. Stand-in personas are never persisted, so generated text is not remembered as the original',
494
+ where: 'Summon menu -> Experts / session header badge',
495
+ },
496
+ {
497
+ text: '**Two lifecycle fixes**: a project `.dsh/mcp.json` added while a session is already open is registered and reconciled immediately, with no need to start a new session (issue #6); on a headless host with no `xdg-open`, clicking Authorize no longer takes the host process down with it (issue #7)',
498
+ where: 'Project MCP / Connector authorization',
499
+ },
500
+ {
501
+ text: '**Security hardening and a faster market**: external links are https-only (http only for loopback), skill paths are trusted only from the host registry, community repository links filter out pseudo-protocols, and skill-disable writes are pinned to the four skills roots; the host market modules load on demand, the first paint prefetches only the current tab, snapshots are written on an idle frame after a signature comparison, the skill list uses stale-while-revalidate, and hovering a card prefetches its details — switching tabs no longer reparses and rewrites the whole directory',
502
+ where: 'Market / Security',
503
+ },
504
+ ],
505
+ '2.4.5': [
506
+ {
507
+ text: '**The Community MCP badge now shows the size of the whole catalog, and stays on screen**: it used to show how many rows this query had read, which reads as "this is all there is". The official Registry returns no total, so the panel counts the catalog page by page in the background (up to 12 pages per session, progress kept locally so the next visit resumes): an exact number once the crawl finishes, a "N+" lower bound while it is still running, and "..." before counting starts — the badge never disappears again. The tooltip says which of the two you are looking at, and the toolbar keeps showing the rows read by the current query',
508
+ where: 'Market -> Connectors -> Community MCP',
509
+ },
510
+ {
511
+ text: '**MCP servers added from the market or the community now start on Windows**: the host spawns stdio servers without a shell, but `npx` / `npm` on Windows are `.cmd` wrappers, so a bare spawn dies with `ENOENT` in milliseconds and the panel is left with "Connection closed" until the reconnect budget runs out. The single write boundary now rewrites a bare `npx` / `npm` into the absolute `node.exe` on this machine plus the CLI entry script, and injects a writable npm cache directory (an existing `npm_config_cache` setting is never overwritten, in any casing). Non-Windows, absolute paths, `uvx`, `${VAR}` and `!!js` are passed through untouched, saving twice is idempotent, and every rewrite is reported in the install notice. Know the trade-off: the rewritten command is specific to this machine, so moving the config to another machine means adding the connector again',
512
+ where: 'Market -> Connectors -> Add MCP',
513
+ },
514
+ {
515
+ text: '**Community cards now mark what you already added**: the list name carries a collision suffix (installing a second entry with the same id shows up as `xxx-2`), so comparing it against the installed command never matched — the badge stayed dark, the button stayed enabled, and clicking again silently created a duplicate. The comparison now uses the name with the suffix removed',
516
+ where: 'Market -> Connectors -> Community MCP',
517
+ },
518
+ {
519
+ text: '**Lightweight feedback when installing or reconnecting, and the dark-blue notice is finally readable**: adding or reconnecting a community MCP opens a small loading toast, and progress and result now travel through the same path. The old notice paired dark text with a deep blue background at a measured 1.49:1 contrast (WCAG AA asks 4.5:1 for body text); it is now 11.6:1 on light and 16.1:1 on dark, with both palettes carried by the overlay itself instead of drifting with the host background',
520
+ where: 'Market -> Connectors / Skills',
521
+ },
522
+ ],
479
523
  };
480
524
 
481
525
  /**
package/lib/whats-new.js CHANGED
@@ -550,4 +550,54 @@ export const WHATS_NEW = [
550
550
  },
551
551
  ],
552
552
  },
553
+ {
554
+ version: '2.4.4',
555
+ items: [
556
+ {
557
+ text: '**连接器页新增「社区 MCP」来源**:直读官方 MCP Registry,不依赖 WorkBuddy 账号;支持关键词搜索、游标翻页、只看 npx / uvx 可直接运行,详情给出启动命令、参数与环境变量,点「加入 MCP」只预填表单,确认后才写配置。社区条目未经军械库审核,面板常驻声明提醒自行确认命令与环境变量',
558
+ where: '市场 → 连接器 → 来源切换',
559
+ },
560
+ {
561
+ text: '**工具裁缝的技能账本可禁用 / 释放技能**:行内「禁用」写 SKILL.md 的官方键 `disable-model-invocation`,模型不再加载该技能、不再缴它的上下文税,手动 `/` 引用不受影响;点「释放」恢复。只对用户级 / 项目级的 `.agents/skills` 与 `.dsh/skills` 开放,写入前过来源白名单、根目录包含性与 realpath 校验',
562
+ where: '工具裁缝 → 技能(skill 工具)',
563
+ },
564
+ {
565
+ text: '**连接器卡片先说清「到底连没连上」**:已安装但工具数为 0 时区分「未连接」与「需授权」(401/403 类失败,重连不会恢复),并就地给出「重连」或「重新授权」;详情显示授权有效期,有 refresh_token 的可直接「刷新授权」把新凭据写回配置,刷不动自动回到浏览器授权;CLI 型连接器另给「本机依赖」检查',
566
+ where: '市场 → 连接器',
567
+ },
568
+ {
569
+ text: '**专家选择随会话记住**:挂上的专家写进 `~/.dsh/wb-market/experts/session-persona.json`(只记哪个会话挂了谁),切会话、宿主重启、上下文压缩后 agent 重建都会自动补回;点 × 才是永久取消。替身人设不落盘,避免把生成的正文当成原文记住',
570
+ where: '召唤菜单 → 专家 / 会话标题栏徽章',
571
+ },
572
+ {
573
+ text: '**项目 MCP 与会话生命周期的两处修正**:项目还没有 `.dsh/mcp.json` 时会话也会先登记,之后新加的配置立刻对齐到已开着的会话,不必等新会话(issue #6);headless 主机没有 `xdg-open` 时,点「安装」触发授权不再把宿主进程一起带走(issue #7)',
574
+ where: '项目 MCP / 连接器授权',
575
+ },
576
+ {
577
+ text: '**安全加固与市场加载提速**:外链只允许 https(http 仅本机回环),技能路径只信宿主注册表,社区仓库链接过滤伪协议,技能禁用的写入面钉死在四类 skills 根;宿主市场数据层改为按需加载,首屏只预取当前页签,快照先比签名再在空闲帧写盘,技能列表走 SWR、卡片 hover 预取详情,切页不再重付整份目录的解析与写盘',
578
+ where: '市场 / 安全',
579
+ },
580
+ ],
581
+ },
582
+ {
583
+ version: '2.4.5',
584
+ items: [
585
+ {
586
+ text: '**社区 MCP 角标改成「全库总数」并常驻**:角标原本显示本次已读取的条数,很容易被读成「社区目录一共只有这么多」。官方 Registry 不返回总数,插件改为在后台逐页统计全库条目(每次会话最多 12 页,进度记在本机、下次打开接着数):数到底显示精确值,没到底显示「N+」下界,还没开始数显示「…」,角标不再随列表消失;悬浮说明写清当前是精确值还是下界,工具条继续显示本次查询的「已读取 N 条」',
587
+ where: '市场 → 连接器 → 社区 MCP',
588
+ },
589
+ {
590
+ text: '**Windows 上从市场 / 社区加入的 MCP 不再必然连不上**:宿主起 stdio 服务时不走 shell,而 `npx` / `npm` 在 Windows 上是 `.cmd` 壳——直接 spawn 必然 `ENOENT` 秒退,面板只剩「连接已关闭」,重连次数耗尽后永久失败。写入配置的唯一边界现在把裸 `npx` / `npm` 归成本机绝对路径的 `node.exe` + 入口 JS,并注入一个可写的 npm 缓存目录(你配过 `npm_config_cache` 就不动,任意大小写都认);非 Windows、已是绝对路径、`uvx`、`${VAR}`、`!!js` 原样放行,重复保存幂等,改写内容会如实写进安装提示。副作用要清楚:归一后的命令是本机专属路径,换机导入需重新「加入 MCP」',
591
+ where: '市场 → 连接器 → 加入 MCP',
592
+ },
593
+ {
594
+ text: '**社区条目的「已加入」会亮了**:列表名带着防撞名的后缀(同名再装会显示成 `xxx-2`),拿它比已装命令名永远不相等——徽标不亮、按钮不禁用、再点一次装出第二条。现在按去掉后缀的原始名比较',
595
+ where: '市场 → 连接器 → 社区 MCP',
596
+ },
597
+ {
598
+ text: '**安装与重连有轻提示,深蓝提示窗文字改到能读**:加入 / 重连社区 MCP 时弹出小 loading 浮层,进度与结果走同一条链;原先深蓝底配深色文字实测对比度只有 1.49:1(WCAG AA 要求正文 4.5:1),现在浅底 11.6:1、深底 16.1:1,深浅两套由浮层自带配色决定,不再随宿主背景漂移',
599
+ where: '市场 → 连接器 / 技能',
600
+ },
601
+ ],
602
+ },
553
603
  ]