dsh-armory 2.4.3 → 2.4.4
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/README.en.md +8 -3
- package/README.md +15 -5
- package/docs/perf-market-load.md +469 -0
- package/docs/project-mcp-lifecycle.md +2 -0
- package/lib/client.js +1407 -114
- package/lib/expert-market.js +17 -1
- package/lib/index.js +314 -62
- package/lib/mcp-community.js +353 -0
- package/lib/session-persona.js +302 -48
- package/lib/skill-disable.js +178 -0
- package/lib/system-open.js +113 -0
- package/lib/typert.js +10 -0
- package/lib/wb-auth-rules.js +47 -16
- package/lib/wb-market.js +85 -1
- package/lib/whats-new-en.js +26 -0
- package/lib/whats-new.js +29 -0
- package/lib/workspace-runtime.js +37 -3
- package/package.json +1 -1
|
@@ -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,9 @@ 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'),
|
|
118
125
|
mv('wbAwesomeDetail', [payloadParam], 'AwesomeDetailResult'),
|
|
119
126
|
// README 预览:点击卡片按需取一次(npm registry / GitHub raw),落盘缓存 7 天。
|
|
120
127
|
mv('wbAwesomeReadme', [payloadParam], 'AwesomeReadmeResult'),
|
|
@@ -150,6 +157,9 @@ export const TYPERT = {
|
|
|
150
157
|
mv('wbOAuthCancel', [payloadParam], 'NoteResult'),
|
|
151
158
|
mv('wbOAuthToken', [payloadParam], 'NoteResult'),
|
|
152
159
|
mv('wbOAuthOverview', [payloadParam], 'NoteResult'),
|
|
160
|
+
// 强制刷新已存的 OAuth token(refresh_token 换新)。面板「刷新授权」用它,
|
|
161
|
+
// 刷新后再走一次 wbConnectorInstall 把新凭据写回 headers。
|
|
162
|
+
mv('wbOAuthRefresh', [payloadParam], 'NoteResult'),
|
|
153
163
|
mv('wbOAuthOpen', [payloadParam], 'NoteResult'),
|
|
154
164
|
],
|
|
155
165
|
model: { services: [], events: [], objects: [] },
|
package/lib/wb-auth-rules.js
CHANGED
|
@@ -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
|
|
48
|
-
* 返回槽位清单:[{ server, container: "env"|"header", key, template, placeholder, value }]
|
|
49
|
-
* - container 字段容器:env → env 对象;header → headers
|
|
50
|
-
*
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/whats-new-en.js
CHANGED
|
@@ -476,6 +476,32 @@ 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
|
+
],
|
|
479
505
|
};
|
|
480
506
|
|
|
481
507
|
/**
|
package/lib/whats-new.js
CHANGED
|
@@ -550,4 +550,33 @@ 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
|
+
},
|
|
553
582
|
]
|
package/lib/workspace-runtime.js
CHANGED
|
@@ -508,6 +508,25 @@ function repointConnectionSlots(ctx, previous, entry) {
|
|
|
508
508
|
}
|
|
509
509
|
return repointed;
|
|
510
510
|
}
|
|
511
|
+
// 宿主 agents 服务里的存活会话,作为 agent/created 之外的兜底来源。
|
|
512
|
+
// 为什么需要兜底:本代挂载只覆盖「本代上线之后创建的会话」。插件被宿主热加载
|
|
513
|
+
// (改 profile patch 触发重载)时,旧代登记过的会话仍在跑,却不在新代的
|
|
514
|
+
// agentWorkspaceStates 里;只靠事件的话,它们要等下一次创建才重新进入管理,
|
|
515
|
+
// 期间新增的项目 MCP 对它们无效。写盘对齐时扫一遍活会话把它们并进来,
|
|
516
|
+
// 代价只是一次 list();拿不到服务(旧宿主/作用域外调用)时退化为空表。
|
|
517
|
+
function liveAgentsOf(ctx) {
|
|
518
|
+
const get = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
|
|
519
|
+
const agents = get && get("agents");
|
|
520
|
+
if (!agents || typeof agents.list !== "function") return [];
|
|
521
|
+
try {
|
|
522
|
+
return agents.list() || [];
|
|
523
|
+
} catch (error) {
|
|
524
|
+
// 不能静默吞:兜底来源失效时用户只会看到「写盘后历史会话没吃到新配置」。
|
|
525
|
+
// 同一条故障只打一次(reconcile 每 5s 会走到这里)。
|
|
526
|
+
warnDiagnosticOnce(ctx?.logger, "live-agents", `armory: 读取存活会话失败(项目 MCP 兜底同步暂停):${error?.message || error}`);
|
|
527
|
+
return [];
|
|
528
|
+
}
|
|
529
|
+
}
|
|
511
530
|
// 配置 → 连接集的唯一同步点:**写盘之后**调用,把该项目对齐到新配置。
|
|
512
531
|
// 1. 配置里已不存在、或配置已变化的连接立刻 retire(不做空闲等待:它已经"不再有效");
|
|
513
532
|
// 2. 对每个存活会话重跑一次挂载(幂等):缺的连上、换掉的重新投射、已有的当场归还多余引用。
|
|
@@ -548,6 +567,16 @@ async function reconcileWorkspaceConnections(ctx, wsPath) {
|
|
|
548
567
|
await mountProjectMcpForAgent(ctx, agent, generation, { awaitReady: false });
|
|
549
568
|
remounted += 1;
|
|
550
569
|
}
|
|
570
|
+
// 兜底:并入本代尚未登记、但确实活在这个项目里的会话(见 liveAgentsOf)。
|
|
571
|
+
// 正常情况下这一步是空转;热加载换代后它负责让「历史会话」也吃到本次写盘。
|
|
572
|
+
for (const agent of liveAgentsOf(ctx)) {
|
|
573
|
+
if (!agent || agentWorkspaceStates.has(agent)) continue;
|
|
574
|
+
const cwd = agent.session?.header?.cwd;
|
|
575
|
+
if (typeof cwd !== "string" || cwd.length === 0) continue;
|
|
576
|
+
if (canonicalWorkspacePath(cwd) !== wsPath) continue;
|
|
577
|
+
await mountProjectMcpForAgent(ctx, agent, generation, { awaitReady: false });
|
|
578
|
+
remounted += 1;
|
|
579
|
+
}
|
|
551
580
|
return { retired, remounted };
|
|
552
581
|
}
|
|
553
582
|
function agentSlotIndex(ctx, agent) {
|
|
@@ -1155,11 +1184,16 @@ async function mountProjectMcpForAgent(ctx, agent, generation, options = {}) {
|
|
|
1155
1184
|
if (typeof cwd === "string" && cwd.length > 0) {
|
|
1156
1185
|
const wsPath = canonicalWorkspacePath(cwd);
|
|
1157
1186
|
try {
|
|
1187
|
+
// **先登记、再读配置**:项目 MCP 最常见的添加时机,正是这个项目已经开着会话、
|
|
1188
|
+
// 但还没有 `.dsh/mcp.json` 的时候(第一次加)。若这里因为 config.missing 跳过登记,
|
|
1189
|
+
// 之后面板写盘时的 reconcileWorkspaceConnections 就遍历不到这个会话(它只认
|
|
1190
|
+
// agentWorkspaceStates),新增的 server 只能等用户新开会话——正是 issue #6 的现象。
|
|
1191
|
+
// 登记只是记账,不建连、不注册工具;没有配置时它没有任何可观察副作用。
|
|
1192
|
+
// 会话记录只承载「本会话属于哪个项目 + restrict 展开到哪一步」。它不再是任何
|
|
1193
|
+
// 存活性凭据:共享连接的引用由 commitSharedConnection 交给 cordis 的 effect 持有。
|
|
1194
|
+
ensureAgentWorkspaceState(ctx, agentCtx, agent, wsPath);
|
|
1158
1195
|
const config = await readWorkspaceConfigCached(ctx, wsPath);
|
|
1159
1196
|
if (!config.error && !config.missing) {
|
|
1160
|
-
// 会话记录只承载「本会话属于哪个项目 + restrict 展开到哪一步」。它不再是任何
|
|
1161
|
-
// 存活性凭据:共享连接的引用由 commitSharedConnection 交给 cordis 的 effect 持有。
|
|
1162
|
-
ensureAgentWorkspaceState(ctx, agentCtx, agent, wsPath);
|
|
1163
1197
|
// 本次挂载的共享就绪预算(见 waitForConnectionReady):取各 server 上限里的最大值,
|
|
1164
1198
|
// 而不是逐个相加 —— 预算是给「这次会话创建」的,不是给每个 server 各发一份。
|
|
1165
1199
|
let readyDeadline = Date.now();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-armory",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.4",
|
|
4
4
|
"description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — custom skill suites, skill install/uninstall with live refresh, global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & DSH community plugin market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|