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/lib/wb-market.js CHANGED
@@ -29,6 +29,11 @@ import { parseInjectionRules, rulesFor, scanTokenSlots } from "./wb-auth-rules.j
29
29
  // T03/R4:连接器源不再硬编码单 URL——从多市场注册表取 sourceFor("connectors"),
30
30
  // 读不到/损坏时回落内置默认(wb-registry.builtinMarketplaces 保证)。
31
31
  import { sourceFor } from "./wb-registry.js";
32
+ // R8:鉴权失败的判定与 OAuth 客户端共用同一份实现(见 mcp-oauth.js 的 mcpAuthFailure)。
33
+ // 两家各写一套「什么算 401」迟早分叉,而分叉的代价是用户看不到授权入口。
34
+ import { mcpAuthFailure } from "./mcp-oauth.js";
35
+ // R8:安装状态账本读侧——wbSkillInstalled 用它给每条已装技能补 installedAt。
36
+ import { listInstalled } from "./wb-install-state.js";
32
37
 
33
38
  const CONNECTOR_ZIP_URL = "https://static.workbuddy.cn/connectors-config-v2/connectors-config.zip";
34
39
 
@@ -219,8 +224,16 @@ export function serverInstallability(serverName, rawServer, authMode = "") {
219
224
  // 早前这里只认 nested.type:command 型条目 transport 落成空串,被文件末尾的
220
225
  // 「未知传输类型」判为拒装——凡是标准 stdio 连接器都装不上。缺 type 但有 command
221
226
  // 时按 stdio 处理,与下面返回处的 `transport || "stdio"` 兜底保持同一判断。
227
+ // 同一条兜底也适用于远程条目:实测索引里有 11 条只写 url 不写 type
228
+ // (qq-mail、lexiang、tencent-docs、tencent-docs-oa、tapd、github、notion…),
229
+ // 一律撞「未知传输类型(缺失)」被判定装不上。有 url 就按 streamable-http 处理,
230
+ // 与 lib/index.js 的 isOAuthCandidate(`spec.transport || "streamable-http"`)同口径。
222
231
  const declaredType = String(nested?.type ?? "").toLowerCase();
223
- const transport = declaredType || (typeof nested?.command === "string" && nested.command.trim() ? "stdio" : "");
232
+ const hasUrl = typeof nested?.url === "string" && nested.url.trim();
233
+ const transport =
234
+ declaredType ||
235
+ (typeof nested?.command === "string" && nested.command.trim() ? "stdio" : "") ||
236
+ (hasUrl ? "streamable-http" : "");
224
237
  // R7:先扫占位符槽位(env/headers 里的 ${...})。这一扫描必须发生在 transport 的
225
238
  // 提前 return 之前——否则 streamable-http 型连接器的 header 占位符会被整段跳过,
226
239
  // 而 80+ 条 auth_mode=token 的连接器正是这个传输形态,正是「装不了」的成因之一。
@@ -381,18 +394,24 @@ export async function wbConnectorIcon(connectorId) {
381
394
 
382
395
  /**
383
396
  * 装前 401 探测:对 streamable-http 型 server 发一次 MCP initialize JSON-RPC,
384
- * 服务器明确回 401/403 就中止安装。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
397
+ * 服务器明确要凭据就回报 needsAuth。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
385
398
  * 索引 auth_mode 也为空,装完连接必 401 "Version negotiation failed")——这是漏标鉴权的
386
- * 最后防线。判定刻意保守:只有 401/403 拦,超时/5xx/网络错误/其他状态一律放行——
387
- * 探测挂了不能误伤正常连接器,宁可让个别服务器在连接时报错。
388
- * 返回 { authorized: true }(放行)或 throw(要求授权,中止整个安装)。
399
+ * 最后防线。判定刻意保守:只有「401/403」或「其它 4xx 且响应体点名是 token/鉴权问题」
400
+ * 才拦,超时/5xx/网络错误/其它状态一律放行——探测挂了不能误伤正常连接器,宁可让个别
401
+ * 服务器在连接时报错。响应体这一路不能省:腾讯 ima 在凭据是坏的时候回的是 HTTP 400 +
402
+ * `msg:token parse failed`(且不带 WWW-Authenticate),只看状态码就会把它判成
403
+ * 「匿名可用」直接写配置,用户既装不上也等不到授权页。
404
+ * 返回 { authorized: true }(放行)或 { authorized:false, needsAuth:true, ... }。
389
405
  */
390
406
  export async function probeServerAuthorization(serverName, rawServer) {
391
407
  const nested = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
392
- const transport = String(nested?.type ?? "").toLowerCase();
408
+ const url = String(nested?.url || "").trim();
409
+ // transport 缺省判定与 serverInstallability 必须同口径:只写 url 不写 type 的条目
410
+ // (索引里实有 11 条)在那边按 streamable-http 放行,这里若按「非 HTTP 型」跳过,
411
+ // 漏标鉴权的最后防线对它们就正好失效。
412
+ const transport = String(nested?.type ?? "").toLowerCase() || (url ? "streamable-http" : "");
393
413
  // 只探测 HTTP 型:stdio 没有可探测的 URL,sse/ws 本来就被准入闸拦下。
394
414
  if (transport !== "streamablehttp" && transport !== "streamable-http" && transport !== "http") return { authorized: true, skipped: true };
395
- const url = String(nested?.url || "").trim();
396
415
  if (!url) return { authorized: true, skipped: true };
397
416
  const body = JSON.stringify({
398
417
  jsonrpc: "2.0",
@@ -422,8 +441,10 @@ export async function probeServerAuthorization(serverName, rawServer) {
422
441
  // 超时 / DNS 失败 / 连接拒绝:探测不了就放行,安装后由用户实测。
423
442
  return { authorized: true, error: "probe-unreachable" };
424
443
  }
425
- if (response.status === 401 || response.status === 403) {
426
- // R8:不再把 401 当致命错误。改回传结构化「需授权」,由上层触发浏览器授权流程:
444
+ // 正文既用于「非 401 的鉴权失败」判定,也是面板里最有信息量的一行提示。
445
+ const bodyText = await response.text().catch(() => "");
446
+ if (mcpAuthFailure(response.status, bodyText)) {
447
+ // R8:不再把鉴权失败当致命错误。改回传结构化「需授权」,由上层触发浏览器授权流程:
427
448
  // 解析 WWW-Authenticate 里的 resource_metadata,交给 lib/mcp-oauth.js 继续走
428
449
  // RFC 9728(资源元数据)→ RFC 8414(AS 元数据)→ RFC 7591(动态注册)→ RFC 7636(PKCE)。
429
450
  const wwwAuthenticate = String(response.headers.get("www-authenticate") || "");
@@ -435,9 +456,10 @@ export async function probeServerAuthorization(serverName, rawServer) {
435
456
  serverName,
436
457
  wwwAuthenticate,
437
458
  resourceMetadata: extractResourceMetadata(wwwAuthenticate),
459
+ bodyHint: String(bodyText || "").replace(/\s+/g, " ").trim().slice(0, 300),
438
460
  };
439
461
  }
440
- // 200/202/4xx 其他/5xx:都视为「没有明确拒绝匿名握手」,放行。
462
+ // 200/202/其它 4xx/5xx:都视为「没有明确拒绝匿名握手」,放行。
441
463
  return { authorized: true, status: response.status };
442
464
  }
443
465
 
@@ -606,17 +628,37 @@ function skillTargetName(namespace, slug) {
606
628
  const safe = (value) => String(value).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "x";
607
629
  return `${safe(namespace)}__${safe(slug)}`;
608
630
  }
631
+ // 并发安装互斥(按目标目录加锁)。双击、卡片+详情同时点、面板重挂载后再点,都会让
632
+ // 同一技能有两个安装任务在飞:两者交错执行同一路径上的 rm(staging)/rm(finalDir)/rename,
633
+ // 轻则留下残包(缺 SKILL.md → 注册表永远发现不了,即「显示安装成功但 Skill 看板里没有」),
634
+ // 重则一个任务删掉另一个刚装好的目录、自己的 rename 又失败 → 「报成功但磁盘上什么都没有」,
635
+ // 之后卸载还会撞「本机没有安装记录」。UI 的 busy 禁用挡不住所有入口(重挂载即重置),
636
+ // 宿主侧串行化才是根修:RPC 可从任意处调用,落盘顺序必须由这里保证。
637
+ const skillInstallLocks = new Map();
609
638
  export async function wbSkillInstall(payload = {}, { onProgress } = {}) {
610
639
  const slug = String(payload?.slug || "").trim();
611
640
  const namespace = String(payload?.namespace || "").trim();
612
- const requestedName = String(payload?.name || "").trim();
613
641
  if (!slug || !namespace) throw new Error("缺少 slug 或 namespace");
642
+ const finalDir = join(homedir(), ".agents", "skills", skillTargetName(namespace, slug));
643
+ const previous = skillInstallLocks.get(finalDir);
644
+ let run;
645
+ // 前序任务失败不该连坐后序:catch 吞掉前序拒绝后再排自己的。
646
+ const queued = previous ? previous.catch(() => {}) : Promise.resolve();
647
+ run = queued
648
+ .then(() => installSkillLocked(payload, { onProgress }, finalDir))
649
+ .finally(() => {
650
+ if (skillInstallLocks.get(finalDir) === run) skillInstallLocks.delete(finalDir);
651
+ });
652
+ skillInstallLocks.set(finalDir, run);
653
+ return run;
654
+ }
655
+ async function installSkillLocked(payload = {}, { onProgress } = {}, finalDir) {
656
+ const slug = String(payload?.slug || "").trim();
657
+ const namespace = String(payload?.namespace || "").trim();
658
+ const requestedName = String(payload?.name || "").trim();
614
659
  const listing = await wbSkillFiles(payload);
615
660
  if (!listing.files.length) throw new Error("该技能没有可下载的文件");
616
661
  if (listing.files.length > 400) throw new Error(`文件数超出上限(${listing.files.length} > 400)`);
617
- const root = join(homedir(), ".agents", "skills");
618
- const targetName = skillTargetName(namespace, slug);
619
- const finalDir = join(root, targetName);
620
662
  const stagingDir = `${finalDir}.installing`;
621
663
  const totalBytes = listing.files.reduce((sum, file) => sum + file.size, 0);
622
664
  if (totalBytes > 32 * 1024 * 1024) throw new Error(`技能总体积超过 32MB 上限(${Math.round(totalBytes / 1024 / 1024)}MB)`);
@@ -651,6 +693,17 @@ export async function wbSkillInstall(payload = {}, { onProgress } = {}) {
651
693
  await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
652
694
  throw error;
653
695
  }
696
+ // 落盘后逐文件复核:清单里的每个路径都必须真实存在才许报成功。缺 SKILL.md 的残包
697
+ // 不会被注册表发现(「显示成功但看板里没有」的假安装),宁可清掉现场报失败,
698
+ // 也不留一个挂着「已安装」却永远不生效的哑目录。
699
+ for (const file of listing.files) {
700
+ try {
701
+ await stat(resolve(finalDir, file.path));
702
+ } catch {
703
+ await rm(finalDir, { recursive: true, force: true }).catch(() => {});
704
+ throw new Error(`安装后校验失败:${file.path} 未落盘(${finalDir}),已清理现场,请重试`);
705
+ }
706
+ }
654
707
  return {
655
708
  note: `已安装到 ${finalDir}(${listing.files.length} 个文件${listing.version ? `,v${listing.version}` : ""}),即刻生效`,
656
709
  dir: finalDir,
@@ -672,7 +725,12 @@ export async function wbSkillUninstall(payload = {}) {
672
725
  await rm(finalDir, { recursive: true, force: true });
673
726
  return { note: `已卸载 ${targetName}` };
674
727
  }
675
- /** 已装的市场技能(安装记录即目录本身),用于卡片上的「已安装」标记与卸载。 */
728
+ /** 已装的市场技能(安装记录即目录本身),用于卡片上的「已安装」标记与卸载。
729
+ * installedAt:安装时间(毫秒时间戳,0 = 完全取不到)。取值优先级:
730
+ * ① 安装状态账本(kind=skill 按 installPath 对齐)登记的首次安装时间;
731
+ * ② 目录创建时间——重装是 rm 后 rename 换入新目录,创建时间即最近一次安装;
732
+ * ③ 目录修改时间兜底(个别文件系统不吐 birthtime)。
733
+ * 账本读失败只降级到 ②③,不影响已装列表本身。 */
676
734
  export async function wbSkillInstalled() {
677
735
  const root = join(homedir(), ".agents", "skills");
678
736
  const rows = [];
@@ -682,6 +740,10 @@ export async function wbSkillInstalled() {
682
740
  } catch {
683
741
  return { installed: [] };
684
742
  }
743
+ const ledger = await listInstalled("skill").catch(() => []);
744
+ const ledgerAt = new Map(
745
+ ledger.filter((row) => row && row.installPath).map((row) => [row.installPath, row.installedAt || 0]),
746
+ );
685
747
  for (const name of names) {
686
748
  if (!name.includes("__")) continue;
687
749
  const [namespace, ...slugParts] = name.split("__");
@@ -689,7 +751,14 @@ export async function wbSkillInstalled() {
689
751
  try {
690
752
  const info = await stat(dir);
691
753
  if (!info.isDirectory()) continue;
692
- rows.push({ dirName: name, namespace, slug: slugParts.join("__"), dir });
754
+ const bornAt = Math.round(Number(info.birthtimeMs) || 0);
755
+ rows.push({
756
+ dirName: name,
757
+ namespace,
758
+ slug: slugParts.join("__"),
759
+ dir,
760
+ installedAt: ledgerAt.get(dir) || bornAt || Math.round(Number(info.mtimeMs) || 0),
761
+ });
693
762
  } catch {
694
763
  // 竞态删除:跳过
695
764
  }
@@ -410,6 +410,30 @@ export const WHATS_NEW_EN = {
410
410
  text: 'Awesome cold start no longer discards data fetched from source 2 when source 1 fails; removed 19 one-off developer patch scripts and two dead exports',
411
411
  },
412
412
  ],
413
+ '2.3.2': [
414
+ {
415
+ text: '**Fixed a whole-panel crash when installing an OAuth connector (Minified React error #62)**: a warning line in the authorization dialog passed its style as a string, so the moment the dialog rendered after the browser failed to open, the error boundary replaced the entire panel. The dialog now renders properly, the client immediately retries opening the browser, and the authorize link with a "reopen browser" button remains as the fallback; the same latent bug in the expert detail dialog is fixed too',
416
+ where: 'Market -> install a connector that needs authorization',
417
+ },
418
+ ],
419
+ '2.4.0': [
420
+ {
421
+ text: '**Every Skill card now shows its install time**: "Added 3 h ago / Added 2026-09-01" sits right on the card (green highlight within 7 days). The install ledger\'s first-install time takes priority, falling back to the directory\'s creation time — the card you just installed is obvious at a glance; the detail panel gains an "Install time" row too',
422
+ where: 'Skill board / Market -> skills',
423
+ },
424
+ {
425
+ text: '**New "Recently installed" filter**: a flat timeline sorted by install time, newest first, with a green highlight within 7 days. Categories are keyword guesses and not always right — the timeline fills that navigation gap; search, source and category filters keep working on top',
426
+ where: 'Skill board -> filter bar',
427
+ },
428
+ {
429
+ text: '**Market-name correspondence**: when a skill\'s board name (the name field in SKILL.md) differs from its market name, the card shows a "From market: …" badge and the detail panel gains a "Market name" row — no more "I installed X from the market but can\'t find X on the board"; matching names stay badge-free to avoid noise',
430
+ where: 'Skill board / Market -> skills',
431
+ },
432
+ {
433
+ text: '**Install-time lookup now uses a host-built name map (skillInstallInfo)**: the host scans the standard skill roots, reads the name field from each SKILL.md header and returns the map keyed by name — no longer depending on the path column that the registry rows may omit entirely (hit in practice: path-based lookups silently returned nothing); when the API is unavailable the board says so and asks for a restart instead of staying silent',
434
+ where: 'Skill board',
435
+ },
436
+ ],
413
437
  };
414
438
 
415
439
  /**
package/lib/whats-new.js CHANGED
@@ -469,4 +469,34 @@ export const WHATS_NEW = [
469
469
  },
470
470
  ],
471
471
  },
472
+ {
473
+ version: '2.3.2',
474
+ items: [
475
+ {
476
+ text: '**修复安装需 OAuth 连接器时的整面板崩溃(Minified React error #62)**:授权弹窗的警示行把 style 写成了字符串,浏览器没能自动打开时弹窗一渲染就把整块面板炸掉——现在弹出授权层正常显示,浏览器打开失败时客户端会立即重试唤起,仍有「授权链接 + 重新打开浏览器」兜底;专家详情的同类隐患一并修复',
477
+ where: '市场 → 安装需授权的连接器',
478
+ },
479
+ ],
480
+ },
481
+ {
482
+ version: '2.4.0',
483
+ items: [
484
+ {
485
+ text: '**每张 Skill 卡片显示安装时间**:「装于 3 小时前 / 装于 2026-09-01」直接标在卡片上(7 天内绿色高亮),安装账本登记的首次安装时间优先、无账本用目录创建时间兜底——刚装的是哪张一眼可见;详情面板同步补「安装时间」行',
486
+ where: 'Skill 看板 / 市场 → 技能',
487
+ },
488
+ {
489
+ text: '**新增「最近安装」筛选**:按安装时间倒序平铺成时间线,刚装的永远排最前,7 天内高亮——分类是关键词猜的、不一定准,时间线正好补上导航缺口;搜索、来源、分类筛选照常叠加',
490
+ where: 'Skill 看板 → 筛选条',
491
+ },
492
+ {
493
+ text: '**市场原名对应**:看板显示名(SKILL.md 的 name)与市场名不一致的技能,卡片出现「来自市场 原名」徽标、详情面板新增「市场原名」行——解决「在市场装了 X,看板里找不到 X」;名字一致的不显示,避免噪音',
494
+ where: 'Skill 看板 / 市场 → 技能',
495
+ },
496
+ {
497
+ text: '**安装时间查询改为宿主自建名字映射(skillInstallInfo)**:宿主扫描标准技能根目录、读 SKILL.md 头部 name 建立映射后按名字返回——不再依赖注册表行可能整列缺失的 path 字段(实测踩过:按 path 查静默落空);接口不可用时看板明确提示重启,不再无声无息',
498
+ where: 'Skill 看板',
499
+ },
500
+ ],
501
+ },
472
502
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
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",