dsh-armory 2.3.1 → 2.4.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/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/client.js +194 -28
- package/lib/index.js +51 -4
- package/lib/mcp-oauth.js +130 -23
- package/lib/skill-dir-times.js +111 -0
- package/lib/typert.js +5 -0
- package/lib/wb-market.js +85 -16
- package/lib/whats-new-en.js +24 -0
- package/lib/whats-new.js +30 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -14,6 +14,8 @@ import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observabilit
|
|
|
14
14
|
import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInventory } from "./mcp-registry.js";
|
|
15
15
|
// Skill 看板的卸载落盘点:来源白名单 + 根目录包含性 + 符号链接/根目录校验(lib/skill-remove.js)。
|
|
16
16
|
import { removeSkillFromDisk } from "./skill-remove.js";
|
|
17
|
+
// Skill 看板的安装时间 + 市场原名投影(名字自建映射,lib/skill-dir-times.js)。
|
|
18
|
+
import { skillInstallInfo } from "./skill-dir-times.js";
|
|
17
19
|
import { WHATS_NEW } from "./whats-new.js";
|
|
18
20
|
import { attachWhatsNewEnglish } from "./whats-new-en.js";
|
|
19
21
|
import { probeMcpServersAuthorization, wbConnectorCatalog, wbConnectorCachedCount, wbConnectorIcon, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
|
|
@@ -38,6 +40,8 @@ import {
|
|
|
38
40
|
cancelAuthorization as oauthCancel,
|
|
39
41
|
clearTokenRecord as oauthClearToken,
|
|
40
42
|
ensureAccessToken as oauthEnsureToken,
|
|
43
|
+
isSerializedGarbageCredential as oauthIsGarbageCredential,
|
|
44
|
+
isUsableBearerToken as oauthIsUsableToken,
|
|
41
45
|
saveManualToken as oauthSaveManualToken,
|
|
42
46
|
submitCallback as oauthSubmitCallback,
|
|
43
47
|
} from "./mcp-oauth.js";
|
|
@@ -121,6 +125,24 @@ function withBearerHeader(rawServer, token) {
|
|
|
121
125
|
return { ...rawServer, headers };
|
|
122
126
|
}
|
|
123
127
|
|
|
128
|
+
/**
|
|
129
|
+
* 摘掉一个已经坏掉的 authorization 头(保留其它头、保留原键名大小写)。
|
|
130
|
+
* 只给「确认是序列化残骸」的配置用:历史版本把 `Bearer [object Object]` 写进了
|
|
131
|
+
* 用户的 patch,那条头不删掉,服务器就从「你没带凭据」(401) 变成「你凭据是坏的」(400),
|
|
132
|
+
* 装前探测因此再也认不出这是鉴权问题,用户既装不上也看不到授权入口。
|
|
133
|
+
*/
|
|
134
|
+
function stripBearerHeader(rawServer) {
|
|
135
|
+
const container = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
|
|
136
|
+
const headers = container?.headers;
|
|
137
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return rawServer;
|
|
138
|
+
const next = {};
|
|
139
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
140
|
+
if (String(key).toLowerCase() !== "authorization") next[key] = value;
|
|
141
|
+
}
|
|
142
|
+
const patched = { ...container, headers: next };
|
|
143
|
+
return rawServer && typeof rawServer.transport === "object" ? { ...rawServer, transport: patched } : patched;
|
|
144
|
+
}
|
|
145
|
+
|
|
124
146
|
/**
|
|
125
147
|
* 安装前把已落盘的 access_token 注进对应 server 的 headers。
|
|
126
148
|
* 读不到 / 过期且刷不动就原样返回,交给 probe 去判定「需要授权」——
|
|
@@ -131,17 +153,27 @@ async function injectStoredOAuthTokens(mcpServers) {
|
|
|
131
153
|
const out = {};
|
|
132
154
|
for (const [serverName, rawServer] of Object.entries(mcpServers)) {
|
|
133
155
|
const spec = nestedServerSpec(rawServer);
|
|
134
|
-
|
|
156
|
+
// 配置里已有的 authorization 一律优先:可能是用户手填的真令牌,也可能是 R7
|
|
157
|
+
// 的 ${...} 占位符(那种要原样保留、由 missingSlots 回报面板补填)。
|
|
158
|
+
const declared = spec.headers.authorization;
|
|
159
|
+
const poisoned = Boolean(declared) && oauthIsGarbageCredential(declared);
|
|
160
|
+
if (!isOAuthCandidate(spec) || (declared && !poisoned)) {
|
|
135
161
|
out[serverName] = rawServer;
|
|
136
162
|
continue;
|
|
137
163
|
}
|
|
138
164
|
let token = "";
|
|
139
165
|
try {
|
|
140
|
-
|
|
166
|
+
// ensureAccessToken 回的是 {ok, accessToken, refreshed, error} 对象,不是字符串。
|
|
167
|
+
// 这里曾经直接对那个对象取字符串:得到 "[object Object]",真值、非空,
|
|
168
|
+
// 于是每个 HTTP 连接器都被注进 `authorization: Bearer [object Object]`
|
|
169
|
+
// —— 装上必挂,还把服务器的 401 伪装成 400(见 test/auth-gate-regression.mjs 第 7 节)。
|
|
170
|
+
const result = await oauthEnsureToken(spec.url);
|
|
171
|
+
if (result?.ok && oauthIsUsableToken(result.accessToken)) token = String(result.accessToken).replace(/^Bearer\s+/i, "").trim();
|
|
141
172
|
} catch {
|
|
142
173
|
token = "";
|
|
143
174
|
}
|
|
144
|
-
out[serverName] =
|
|
175
|
+
if (token) out[serverName] = withBearerHeader(rawServer, token);
|
|
176
|
+
else out[serverName] = poisoned ? stripBearerHeader(rawServer) : rawServer;
|
|
145
177
|
}
|
|
146
178
|
return out;
|
|
147
179
|
}
|
|
@@ -155,7 +187,12 @@ async function resolveOAuthTarget(connectorId, serverName) {
|
|
|
155
187
|
if (!rawServer) throw new Error(`连接器 ${connectorId} 里没有 server「${name || "(空)"}」`);
|
|
156
188
|
const spec = nestedServerSpec(rawServer);
|
|
157
189
|
if (!spec.url) throw new Error(`server「${name}」没有可授权的 URL(stdio 型不需要 OAuth 授权)`);
|
|
158
|
-
|
|
190
|
+
// 坏掉的 authorization 不能跟着去探测:带着 `Bearer [object Object]` 打过去,服务器
|
|
191
|
+
// 回的是 400「token parse failed」而不是 401「empty access token」,授权发现
|
|
192
|
+
// 那条路就在第一步被判定成「非鉴权错误」而中止。
|
|
193
|
+
const headers = { ...spec.headers };
|
|
194
|
+
if (headers.authorization && oauthIsGarbageCredential(headers.authorization)) delete headers.authorization;
|
|
195
|
+
return { serverName: name, url: spec.url, headers, transport: spec.transport };
|
|
159
196
|
}
|
|
160
197
|
|
|
161
198
|
/** 宿主 shell 服务的几种可能落点,逐个试;拿不到就返回 undefined 走系统兜底。 */
|
|
@@ -1780,6 +1817,12 @@ let McpManagerGateway = (() => {
|
|
|
1780
1817
|
if (result === undefined) throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法卸载");
|
|
1781
1818
|
return { note: `已卸载 ${name}(${result.kind === "symlink" ? "符号链接" : "目录"}:${result.path})` };
|
|
1782
1819
|
}
|
|
1820
|
+
// 安装时间 + 市场原名(按 skill 名字索引):宿主自扫标准技能根目录、读 SKILL.md
|
|
1821
|
+
// 头部 name 建立映射——不依赖 listSkills 行里的 path(注册表可能整列不给)。
|
|
1822
|
+
// 永不抛错——时间与原名是辅助信息,单个目录读失败只是没有该条目,不该拖垮看板。
|
|
1823
|
+
async skillInstallInfo(payload) {
|
|
1824
|
+
return skillInstallInfo(payload || {});
|
|
1825
|
+
}
|
|
1783
1826
|
// ── 自定义套件(Skill 看板)─────────────────────────────────────────
|
|
1784
1827
|
// 数据层在 lib/suites-registry.js(~/.dsh/armory/suites.json);这里只做 RPC 转发。
|
|
1785
1828
|
// listSkillSuites —— 永不抛错:文件缺失 / JSON 损坏 / 结构不对都回落空集合,
|
|
@@ -1884,6 +1927,10 @@ let McpManagerGateway = (() => {
|
|
|
1884
1927
|
error: started?.error || "授权流程启动失败",
|
|
1885
1928
|
mode: started?.mode || "",
|
|
1886
1929
|
redirectUri: started?.redirectUri || "",
|
|
1930
|
+
// 授权页这条路走不通(服务器不提供 OAuth 发现、动态注册被拒…)时,
|
|
1931
|
+
// 手填令牌仍然是可用通道:把它交给面板,别只留一行红字。
|
|
1932
|
+
manualTokenOnly: started?.manualTokenOnly === true,
|
|
1933
|
+
serverUrl: started?.serverUrl || target.url,
|
|
1887
1934
|
};
|
|
1888
1935
|
}
|
|
1889
1936
|
// 默认由宿主直接把授权页推给系统浏览器;open:false 留给面板自己渲染链接。
|
package/lib/mcp-oauth.js
CHANGED
|
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
|
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
|
|
31
31
|
/** 插件版本:DCR 的 clientInfo 与 UA 共用一处,避免两边各自漂移。 */
|
|
32
|
-
const PLUGIN_VERSION = "2.3.
|
|
32
|
+
const PLUGIN_VERSION = "2.3.2";
|
|
33
33
|
const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
|
|
34
34
|
const NET_TIMEOUT_MS = 15_000;
|
|
35
35
|
/** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
|
|
@@ -59,6 +59,69 @@ function sha256hex(text) {
|
|
|
59
59
|
return createHash("sha256").update(String(text)).digest("hex");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// ── 凭据形状 ────────────────────────────────────────────────────────────
|
|
63
|
+
// 这一层存在的理由是一条真实事故:`ensureAccessToken()` 返回的是对象
|
|
64
|
+
// `{ok, accessToken, ...}`,调用方写成 `String(await ensureAccessToken(url))` 就得到
|
|
65
|
+
// `"[object Object]"` —— 真值、非空、于是被当成正经令牌注进 `authorization` 头并落盘。
|
|
66
|
+
// 服务器收到后回的不是 401「empty access token」而是 400「token parse failed」,
|
|
67
|
+
// 装前探测只认 401/403,于是「需要授权」被彻底漏判:配置写进去了、授权页永远不弹、
|
|
68
|
+
// 连接永远失败。所以「什么才算一个能用的令牌」必须只写一次、放在这里。
|
|
69
|
+
|
|
70
|
+
/** Bearer 令牌长度上限:正常签发物不会这么长,超长基本是把整坨响应粘回来了。 */
|
|
71
|
+
const BEARER_TOKEN_MAX = 8192;
|
|
72
|
+
|
|
73
|
+
/** 去掉可选的 `Bearer ` 前缀与首尾空白(光杆一个 "Bearer" 等于没有令牌)。 */
|
|
74
|
+
function stripBearerPrefix(value) {
|
|
75
|
+
return String(value ?? "").replace(/^\s*Bearer\b[ \t]*/i, "").trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 一眼假:序列化残骸(`[object Object]` / `undefined` / `NaN`)、空值、光杆 `Bearer`。
|
|
80
|
+
* 未替换的 `${TOKEN}` 占位符**不算**残骸——那是 R7 令牌引导的合法中间态,
|
|
81
|
+
* 由 `missingSlots` 单独回报面板,不能被这里悄悄抹掉。
|
|
82
|
+
*/
|
|
83
|
+
export function isSerializedGarbageCredential(value) {
|
|
84
|
+
if (value === null || value === undefined) return true;
|
|
85
|
+
if (typeof value !== "string") return true;
|
|
86
|
+
const text = value.trim();
|
|
87
|
+
if (!text) return true;
|
|
88
|
+
if (/^\[object\s+\w+\]$/i.test(text) || /\[object\s+\w+\]/i.test(text)) return true;
|
|
89
|
+
if (/^(?:undefined|null|NaN)$/i.test(stripBearerPrefix(text))) return true;
|
|
90
|
+
return !stripBearerPrefix(text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 是不是一个可以原样发给服务器的 access token。 */
|
|
94
|
+
export function isUsableBearerToken(value) {
|
|
95
|
+
if (isSerializedGarbageCredential(value)) return false;
|
|
96
|
+
const token = stripBearerPrefix(value);
|
|
97
|
+
if (token.length > BEARER_TOKEN_MAX) return false;
|
|
98
|
+
if (/\$\{[^}]*\}/.test(token)) return false;
|
|
99
|
+
// 控制符/换行:HTTP 头里出现就是注入面(fetch 也会拒),一律视为不可用。
|
|
100
|
+
return !/[\u0000-\u001f\u007f\u0080-\u009f]/.test(token);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 「这是鉴权失败吗」判定 ───────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 各家网关的鉴权失败措辞(JSON-RPC error.message 里常见)。刻意只列鉴权语义,
|
|
107
|
+
* 不收 "not found" / "invalid params" 这类——把服务器故障判成「要授权」会把用户
|
|
108
|
+
* 引到一条根本走不通的授权路上。
|
|
109
|
+
*/
|
|
110
|
+
const AUTH_BODY_RE =
|
|
111
|
+
/(empty access token|access token (?:empty|missing|invalid)|token parse|invalid[ _-]?token|missing[ _-]?token|no (?:access )?token|token (?:expired|invalid)|unauthorized|unauthenticated|(?:authentication|authorization) (?:failed|error|required)|(?:需要|请)(?:登录|授权|鉴权))/i;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 一次 MCP 握手失败是不是「缺凭据」。
|
|
115
|
+
* 401/403 无条件算;其它 4xx 只有在响应体明确说鉴权时才算(腾讯 ima 就是 400 +
|
|
116
|
+
* `msg:token parse failed`,且不带 `WWW-Authenticate`);2xx/3xx/5xx 一律不算。
|
|
117
|
+
*/
|
|
118
|
+
export function mcpAuthFailure(status, bodyText = "") {
|
|
119
|
+
const code = Number(status) || 0;
|
|
120
|
+
if (code === 401 || code === 403) return true;
|
|
121
|
+
if (code < 400 || code >= 500) return false;
|
|
122
|
+
return AUTH_BODY_RE.test(String(bodyText || ""));
|
|
123
|
+
}
|
|
124
|
+
|
|
62
125
|
/** PKCE 授权码流程的 verifier/challenge(S256)。 */
|
|
63
126
|
export function createPkce() {
|
|
64
127
|
const verifier = b64url(randomBytes(48));
|
|
@@ -114,8 +177,8 @@ async function fetchJson(url, init = {}) {
|
|
|
114
177
|
// ── 探测 ────────────────────────────────────────────────────────────────
|
|
115
178
|
|
|
116
179
|
/**
|
|
117
|
-
* 打一次 MCP initialize
|
|
118
|
-
* 只用于确认「这颗 server
|
|
180
|
+
* 打一次 MCP initialize,拿回状态码、`WWW-Authenticate` 与响应体摘要。
|
|
181
|
+
* 只用于确认「这颗 server 是不是要凭据」,不做其它判断。
|
|
119
182
|
*/
|
|
120
183
|
export async function probeServer(serverUrl, headers = {}) {
|
|
121
184
|
const body = JSON.stringify({
|
|
@@ -142,16 +205,18 @@ export async function probeServer(serverUrl, headers = {}) {
|
|
|
142
205
|
},
|
|
143
206
|
body
|
|
144
207
|
});
|
|
145
|
-
// 读掉 body,避免 socket
|
|
146
|
-
await response.text().catch(() => "");
|
|
208
|
+
// 读掉 body,避免 socket 悬挂;正文还要留给鉴权判定(不少网关 400 也带鉴权语义)。
|
|
209
|
+
const text = await response.text().catch(() => "");
|
|
147
210
|
return {
|
|
148
211
|
ok: true,
|
|
149
212
|
status: response.status,
|
|
150
213
|
wwwAuthenticate: response.headers.get("www-authenticate") || "",
|
|
214
|
+
body: trimText(text, 400),
|
|
215
|
+
authRequired: mcpAuthFailure(response.status, text),
|
|
151
216
|
error: ""
|
|
152
217
|
};
|
|
153
218
|
} catch (error) {
|
|
154
|
-
return { ok: false, status: 0, wwwAuthenticate: "", error: String(error?.message || error) };
|
|
219
|
+
return { ok: false, status: 0, wwwAuthenticate: "", body: "", authRequired: false, error: String(error?.message || error) };
|
|
155
220
|
}
|
|
156
221
|
}
|
|
157
222
|
|
|
@@ -439,7 +504,8 @@ export async function clearTokenRecord(serverUrl) {
|
|
|
439
504
|
|
|
440
505
|
/** 记录里是否有一个「还没过期」的 access token(留 60s 余量)。 */
|
|
441
506
|
export function tokenIsFresh(record) {
|
|
442
|
-
|
|
507
|
+
// 形状先于时效:`[object Object]` 这种残骸哪怕带着未来的 expiresAt 也不是令牌。
|
|
508
|
+
if (!record || !isUsableBearerToken(record.accessToken)) return false;
|
|
443
509
|
if (!record.expiresAt) return true; // 服务器没给 expires_in:视为长期有效,等 401 再说
|
|
444
510
|
// 落盘时写的是 ISO 串(见 exchangeCode),但令牌库是纯 JSON、可能被人手改或由
|
|
445
511
|
// 旧版本写成数字毫秒,两种都认:数字直接当时间戳,字符串才走 Date.parse。
|
|
@@ -478,7 +544,7 @@ async function refreshAccessToken(record) {
|
|
|
478
544
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
479
545
|
body: body.toString()
|
|
480
546
|
});
|
|
481
|
-
if (!result.ok || !
|
|
547
|
+
if (!result.ok || !isUsableBearerToken(result.json?.access_token)) {
|
|
482
548
|
return { ok: false, accessToken: "", error: `刷新 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}` };
|
|
483
549
|
}
|
|
484
550
|
await writeTokenRecord(record.serverUrl, tokenRecordPatch(result.json));
|
|
@@ -512,20 +578,35 @@ function tokenRecordPatch(tokenResponse) {
|
|
|
512
578
|
export async function beginAuthorization({ serverName, serverUrl, headers = {}, clientName = "DeepSeek Harness" } = {}) {
|
|
513
579
|
if (!serverUrl) return fail("", "", "", "缺少 server url");
|
|
514
580
|
const probe = await probeServer(serverUrl, headers);
|
|
515
|
-
if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}
|
|
516
|
-
if (probe.status
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
}
|
|
581
|
+
if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}`, { serverUrl });
|
|
582
|
+
if (probe.status >= 400 && !probe.authRequired) {
|
|
583
|
+
// 明确的非鉴权失败(404 路径错、500 服务器挂了…):继续往下走只会把用户
|
|
584
|
+
// 引到一条根本不存在的授权路上,直接说清楚更省时间。
|
|
585
|
+
return fail("", "", "", `服务器返回 HTTP ${probe.status},不是预期的鉴权错误:${probe.body || "(无响应体)"}`, { serverUrl });
|
|
521
586
|
}
|
|
587
|
+
// 到这里要么服务器点名要凭据(401/403/4xx 鉴权体),要么匿名握手就过了
|
|
588
|
+
// (有些实现把鉴权推迟到 tools/call)——两种都继续试一次发现流程。
|
|
522
589
|
|
|
523
590
|
const resource = await resolveProtectedResource({ serverUrl, wwwAuthenticate: probe.wwwAuthenticate });
|
|
524
|
-
if (!resource.ok) return fail("", "", "", resource.error);
|
|
591
|
+
if (!resource.ok) return fail("", "", "", resource.error, { serverUrl, manualTokenOnly: true });
|
|
525
592
|
const as = await resolveAuthorizationServer(resource.authorizationServers[0]);
|
|
526
|
-
if (!as.ok)
|
|
593
|
+
if (!as.ok) {
|
|
594
|
+
// 现实里很常见:服务器要 Bearer token,但压根不实现 RFC 9728/8414 的 well-known
|
|
595
|
+
// 发现(腾讯 ima 就是这样)。这时没有任何 authorizeUrl 可开,硬报错等于把用户
|
|
596
|
+
// 关在门外——回 manualTokenOnly,让面板把手填令牌那条兜底路摊开给他。
|
|
597
|
+
return fail("", "", "", `${as.error}\n该服务器没有提供标准 OAuth 授权发现,无法自动打开授权页;如果你已有访问令牌,可以直接粘贴安装。`, {
|
|
598
|
+
serverUrl,
|
|
599
|
+
manualTokenOnly: true
|
|
600
|
+
});
|
|
601
|
+
}
|
|
527
602
|
if (!as.tokenEndpointAuthMethods.includes("none")) {
|
|
528
|
-
return fail(
|
|
603
|
+
return fail(
|
|
604
|
+
"",
|
|
605
|
+
"",
|
|
606
|
+
"",
|
|
607
|
+
`该授权服务器要求客户端认证(${as.tokenEndpointAuthMethods.join("/")}),本插件只支持公共客户端(none);如果你已有访问令牌,可以直接粘贴安装。`,
|
|
608
|
+
{ serverUrl, manualTokenOnly: true }
|
|
609
|
+
);
|
|
529
610
|
}
|
|
530
611
|
|
|
531
612
|
const scope = as.scopesSupported.length ? as.scopesSupported.join(" ") : "";
|
|
@@ -543,13 +624,22 @@ export async function beginAuthorization({ serverName, serverUrl, headers = {},
|
|
|
543
624
|
const template = extractExpectedRedirectUri(registration.raw || registration.error);
|
|
544
625
|
if (!template) {
|
|
545
626
|
loopback.close();
|
|
546
|
-
return fail("", "", "", registration.error
|
|
627
|
+
return fail("", "", "", `${registration.error}\n动态注册走不通,无法生成授权页;如果你已有访问令牌,可以直接粘贴安装。`, {
|
|
628
|
+
serverUrl,
|
|
629
|
+
manualTokenOnly: true
|
|
630
|
+
});
|
|
547
631
|
}
|
|
548
632
|
const expected = substituteRedirectPlaceholder(template, serverName || clientName);
|
|
549
633
|
const retry = await registerClient(as, { redirectUri: expected, clientName, scope });
|
|
550
634
|
if (!retry.ok) {
|
|
551
635
|
loopback.close();
|
|
552
|
-
return fail(
|
|
636
|
+
return fail(
|
|
637
|
+
"",
|
|
638
|
+
"",
|
|
639
|
+
"",
|
|
640
|
+
`${registration.error}\n改用服务器指定的回调地址 ${expected} 重试仍失败:${retry.error}\n如果你已有访问令牌,可以直接粘贴安装。`,
|
|
641
|
+
{ serverUrl, manualTokenOnly: true }
|
|
642
|
+
);
|
|
553
643
|
}
|
|
554
644
|
registration = retry;
|
|
555
645
|
redirectUri = expected;
|
|
@@ -619,8 +709,21 @@ function buildAuthorizeUrl({ authorizationEndpoint, clientId, redirectUri, scope
|
|
|
619
709
|
return url.href;
|
|
620
710
|
}
|
|
621
711
|
|
|
622
|
-
function fail(sessionId, authorizeUrl, mode, error) {
|
|
623
|
-
return {
|
|
712
|
+
function fail(sessionId, authorizeUrl, mode, error, extra = {}) {
|
|
713
|
+
return {
|
|
714
|
+
ok: false,
|
|
715
|
+
sessionId,
|
|
716
|
+
authorizeUrl,
|
|
717
|
+
mode,
|
|
718
|
+
redirectUri: "",
|
|
719
|
+
hint: "",
|
|
720
|
+
// manualTokenOnly:授权页这条路走不通,但「手填访问令牌」仍然可用。面板据此
|
|
721
|
+
// 也要把弹层开出来(而不是只甩一行红字),否则用户看到的是死路。
|
|
722
|
+
manualTokenOnly: false,
|
|
723
|
+
serverUrl: "",
|
|
724
|
+
error,
|
|
725
|
+
...extra
|
|
726
|
+
};
|
|
624
727
|
}
|
|
625
728
|
|
|
626
729
|
function pruneSessions() {
|
|
@@ -667,7 +770,7 @@ async function exchangeCode(session, code) {
|
|
|
667
770
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
668
771
|
body: body.toString()
|
|
669
772
|
});
|
|
670
|
-
if (!result.ok || !
|
|
773
|
+
if (!result.ok || !isUsableBearerToken(result.json?.access_token)) {
|
|
671
774
|
session.status = "failed";
|
|
672
775
|
session.error = `换取 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}`;
|
|
673
776
|
return;
|
|
@@ -775,8 +878,12 @@ export async function saveManualToken({ serverUrl, serverName = "", token, token
|
|
|
775
878
|
const raw = String(token || "").trim();
|
|
776
879
|
if (!serverUrl) return { ok: false, error: "缺少 server url" };
|
|
777
880
|
if (!raw) return { ok: false, error: "token 不能为空" };
|
|
778
|
-
const accessToken = raw
|
|
881
|
+
const accessToken = stripBearerPrefix(raw);
|
|
779
882
|
if (!accessToken) return { ok: false, error: "token 不能为空" };
|
|
883
|
+
// 手填这条路最容易粘进残骸(`[object Object]`、没替换的 ${TOKEN}、带换行的整段 curl)。
|
|
884
|
+
if (!isUsableBearerToken(accessToken)) {
|
|
885
|
+
return { ok: false, error: "这不像是个可用的访问令牌(空值、未替换的 ${...} 占位符或序列化残骸),请重新从服务方复制。" };
|
|
886
|
+
}
|
|
780
887
|
await writeTokenRecord(serverUrl, {
|
|
781
888
|
accessToken,
|
|
782
889
|
tokenType: "Bearer",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Skill 看板的「安装时间 + 市场原名」投影(按 skill 名字索引)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么不按 listSkills 行里的 path 查:宿主注册表的行**不一定**带 path(字段是条件
|
|
4
|
+
// 展开,lib/index.js 只透传注册表给的),实测有的版本整列都缺——按 path 查会整表静默
|
|
5
|
+
// 落空。这里改为宿主自建映射:扫描标准技能根目录,读每个目录 SKILL.md 头部的
|
|
6
|
+
// `name:` 字段得到「看板显示名」,建立 名字 → 目录 → 时间/原名 的映射,按名字返回。
|
|
7
|
+
// 面板的每一行必有 name(看板主键),join 永不落空。
|
|
8
|
+
//
|
|
9
|
+
// installedAt 取值优先级(与 wb-market.wbSkillInstalled 同一口径):
|
|
10
|
+
// ① 安装状态账本(kind=skill,按 installPath 归一对齐)登记的首次安装时间;
|
|
11
|
+
// ② 目录创建时间——重装 = rm 后 rename 换入新目录,创建时间即最近一次落盘;
|
|
12
|
+
// ③ 目录修改时间兜底(个别文件系统不吐 birthtime)。
|
|
13
|
+
// 没有 SKILL.md 的目录不会被注册表发现,直接跳过;同名目录按扫描序首个优先
|
|
14
|
+
// (agents 根在 dsh 根之前,与注册表的来源优先级同向)。
|
|
15
|
+
// 账本命中的条目附带 marketName / marketplace / marketVersion:账本登记的是市场里的
|
|
16
|
+
// 名字,看板显示的是 SKILL.md 的 name 字段——两者可以不同,投影出去供面板对应。
|
|
17
|
+
//
|
|
18
|
+
// 纯只读(readdir / stat / 只读 SKILL.md 头部 4KB);账本读失败只降级到 ②③。
|
|
19
|
+
// 存储根目录可注入(options.rootDir / DSH_ARMORY_HOME),home 可注入(options.home,测试用)。
|
|
20
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { listInstalled } from "./wb-install-state.js";
|
|
24
|
+
|
|
25
|
+
/** join 键归一:分隔符统一 /、去尾斜杠。账本 installPath 与实际扫描目录是两套来源,
|
|
26
|
+
* Windows 下分隔符与结尾斜杠可能有差异,对齐前先拉齐形态。 */
|
|
27
|
+
const normPath = (value) => String(value || "").trim().replace(/[\\/]+/g, "/").replace(/\/+$/, "");
|
|
28
|
+
|
|
29
|
+
/** 只读文件头部 4KB——frontmatter 永远在最前面,整读大 SKILL.md 不值得。 */
|
|
30
|
+
async function head(path, bytes = 4096) {
|
|
31
|
+
const handle = await open(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
const buffer = Buffer.alloc(bytes);
|
|
34
|
+
const { bytesRead } = await handle.read(buffer, 0, bytes, 0);
|
|
35
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
36
|
+
} finally {
|
|
37
|
+
await handle.close().catch(() => {});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** SKILL.md 头部 frontmatter 的 name 字段;没有 frontmatter / 没写 name 返回空串。 */
|
|
42
|
+
export function frontmatterName(text) {
|
|
43
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ""));
|
|
44
|
+
if (!block) return "";
|
|
45
|
+
const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
|
|
46
|
+
return line ? line[1].replace(/^["']|["']$/g, "") : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 扫描技能根目录,返回「看板名字 → 安装信息」映射。
|
|
51
|
+
* @param {object} payload 客户端信封:{ cwd? }(项目作用域时带项目根)
|
|
52
|
+
* @returns {Promise<{info: Record<string, {installedAt: number, dir: string,
|
|
53
|
+
* marketName?: string, marketplace?: string, marketVersion?: string}>}>}
|
|
54
|
+
*/
|
|
55
|
+
export async function skillInstallInfo(payload = {}, options = {}) {
|
|
56
|
+
const home = options.home || homedir();
|
|
57
|
+
const roots = [
|
|
58
|
+
join(home, ".agents", "skills"),
|
|
59
|
+
join(home, ".dsh", "skills"),
|
|
60
|
+
];
|
|
61
|
+
const cwd = String(payload?.cwd || "").trim();
|
|
62
|
+
if (cwd) {
|
|
63
|
+
roots.push(join(cwd, ".agents", "skills"));
|
|
64
|
+
roots.push(join(cwd, ".dsh", "skills"));
|
|
65
|
+
}
|
|
66
|
+
const ledger = await listInstalled("skill", options).catch(() => []);
|
|
67
|
+
const byPath = new Map(
|
|
68
|
+
ledger.filter((row) => row && row.installPath).map((row) => [normPath(row.installPath), row]),
|
|
69
|
+
);
|
|
70
|
+
const info = {};
|
|
71
|
+
for (const root of roots) {
|
|
72
|
+
let entries = [];
|
|
73
|
+
try {
|
|
74
|
+
entries = await readdir(root);
|
|
75
|
+
} catch {
|
|
76
|
+
continue; // 根目录不存在(没装过任何市场技能 / 没有 ~/.dsh/skills):跳过
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.startsWith(".")) continue;
|
|
80
|
+
const dir = join(root, entry);
|
|
81
|
+
let dirStat;
|
|
82
|
+
try {
|
|
83
|
+
dirStat = await stat(dir);
|
|
84
|
+
} catch {
|
|
85
|
+
continue; // 竞态删除等:跳过
|
|
86
|
+
}
|
|
87
|
+
if (!dirStat.isDirectory()) continue;
|
|
88
|
+
let name = "";
|
|
89
|
+
try {
|
|
90
|
+
name = frontmatterName(await head(join(dir, "SKILL.md")));
|
|
91
|
+
} catch {
|
|
92
|
+
continue; // 没有 SKILL.md:注册表发现不了,不进映射
|
|
93
|
+
}
|
|
94
|
+
if (!name) name = entry;
|
|
95
|
+
if (info[name]) continue; // 同名:首个根/首个目录优先
|
|
96
|
+
const hit = byPath.get(normPath(dir));
|
|
97
|
+
const bornAt = Math.round(Number(dirStat.birthtimeMs) || 0);
|
|
98
|
+
const row = {
|
|
99
|
+
installedAt: (hit?.installedAt || 0) || bornAt || Math.round(Number(dirStat.mtimeMs) || 0),
|
|
100
|
+
dir,
|
|
101
|
+
};
|
|
102
|
+
if (hit?.name) {
|
|
103
|
+
row.marketName = hit.name;
|
|
104
|
+
row.marketplace = hit.source?.marketplace || "";
|
|
105
|
+
row.marketVersion = hit.version || "";
|
|
106
|
+
}
|
|
107
|
+
info[name] = row;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { info };
|
|
111
|
+
}
|
package/lib/typert.js
CHANGED
|
@@ -77,6 +77,11 @@ export const TYPERT = {
|
|
|
77
77
|
// 卸载已安装 skill:永久删除用户级/项目级 skills 根目录内的条目。三处契约缺一即 404,
|
|
78
78
|
// 删除规则全在 lib/skill-remove.js(来源白名单 / 根目录包含性 / 符号链接只删链接)。
|
|
79
79
|
mv('removeSkill', [payloadParam], 'NoteResult'),
|
|
80
|
+
// 安装时间 + 市场原名(Skill 看板卡片徽标 /「最近安装」排序的数据源):宿主自扫
|
|
81
|
+
// 标准根目录按 SKILL.md name 建映射,出 { ok, info: { [name]: { installedAt, ... } } }。
|
|
82
|
+
// 不按 listSkills 行的 path 查——注册表行可能整列缺 path(实测)。形状由
|
|
83
|
+
// lib/skill-dir-times.js 校验,与 wb* 系列同一宽容度(payload $any)。
|
|
84
|
+
mv('skillInstallInfo', [payloadParam], 'SkillInstallInfoResult'),
|
|
80
85
|
// 自定义套件(Skill 看板的「一组相关 skill 打包引用」):读 = 全量集合,
|
|
81
86
|
// 存 = 整集合覆盖(UI 一次提交右侧全部套件,不做增量 upsert)。数据层在
|
|
82
87
|
// lib/suites-registry.js,id 分配 / 成员去重保序 / 脏数据拒绝都在那边,
|