dsh-armory 2.3.0 → 2.3.2

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.
@@ -82,12 +82,18 @@ function dirnameOf(path) {
82
82
  return cut > 0 ? path.slice(0, cut) : path;
83
83
  }
84
84
 
85
- /** staging + rename 原子写(与 wb-registry.writeAtomic 同一模式)。 */
85
+ /** 唯一临时名 + rename 原子写(与 wb-registry.writeAtomic 同一模式):固定 `.staging`
86
+ * 在并发登记时会互相消费对方的临时文件,rename 覆盖已存在文件所以无需先 rm。 */
86
87
  async function writeAtomic(path, doc) {
87
88
  await mkdir(dirnameOf(path), { recursive: true });
88
- await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
89
- await rm(path, { force: true });
90
- await rename(`${path}.staging`, path);
89
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
90
+ try {
91
+ await writeFile(tmp, JSON.stringify(doc, null, 2));
92
+ await rename(tmp, path);
93
+ } catch (error) {
94
+ await rm(tmp, { force: true }).catch(() => {});
95
+ throw error;
96
+ }
91
97
  }
92
98
 
93
99
  /** 读全部条目(合并投影:文件缺失/损坏 = 空状态)。永不抛错。 */
package/lib/wb-market.js CHANGED
@@ -14,7 +14,7 @@
14
14
  // - 网络只发生在 Host 侧(浏览器不直连腾讯/SkillHub 域名):面板经由 mcpManager Remote
15
15
  // 命名空间调用本文件提供的方法,与既有预览/导入同一信封。
16
16
  // - 「能装」的标准从严:MCP 只收 streamable-http 与无 preAuth 的 stdio;CLI 型(cli.json)
17
- // 与 server-side 鉴权只展示不装——宁可少,不可错(与 mcp-catalog.js 同一纪律)。
17
+ // 与 server-side 鉴权只展示不装——宁可少,不可错。
18
18
  // - 技能安装逐文件校验 sha256(清单自带哈希)后再落盘,目标目录按 SkillHub 的
19
19
  // canonicalName 命名(`@tencent-adm/tencent-docs` → `tencent-adm__tencent-docs`),
20
20
  // 与 dsh-skill-market 的命名并存不冲突(本插件不带 @ 前缀保留字)。
@@ -29,6 +29,9 @@ 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";
32
35
 
33
36
  const CONNECTOR_ZIP_URL = "https://static.workbuddy.cn/connectors-config-v2/connectors-config.zip";
34
37
 
@@ -44,7 +47,8 @@ const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-a
44
47
  const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
45
48
  // ZIP 落盘缓存目录(~/.dsh 由 DSH 自己管理,这里只在它下面开一个子目录)。
46
49
  function cacheDir() {
47
- return join(homedir(), ".dsh", "wb-market");
50
+ // 测试隔离口(惯例同 wb-registry 的 DSH_ARMORY_HOME):设置时缓存根指到临时目录。
51
+ return process.env.DSH_ARMORY_MARKET_HOME || join(homedir(), ".dsh", "wb-market");
48
52
  }
49
53
  function zipPath() {
50
54
  return join(cacheDir(), "connectors-config.zip");
@@ -76,6 +80,20 @@ async function fetchWithLimits(url, { timeoutMs = 30_000 } = {}) {
76
80
  // ── 连接器 ZIP:下载 + 缓存 ─────────────────────────────────────────────
77
81
  // meta.json 记录远端 ETag;命中就不重复下 24MB。ZIP 本体损坏时丢弃缓存重新拉一次。
78
82
  let cachedZip = null; // { zip: ZipReader, loadedFrom: "memory"|"disk"|"network" }
83
+ // 连接器图标的进程内缓存(id → data URL,null = 探过且没有)。换新 ZIP 时整体作废,
84
+ // 清空点在 loadConnectorZip 安装新包的两处。
85
+ const connectorIconCache = new Map();
86
+ /** 缓存正文用临时文件 + rename 落盘:写一半被杀只会留下无人读的 .tmp,不会留下截断的 ZIP。 */
87
+ async function writeCacheFile(path, data) {
88
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
89
+ try {
90
+ await writeFile(tmp, data);
91
+ await rename(tmp, path);
92
+ } catch (error) {
93
+ await rm(tmp, { force: true }).catch(() => {});
94
+ throw error;
95
+ }
96
+ }
79
97
  async function loadConnectorZip() {
80
98
  if (cachedZip) return cachedZip;
81
99
  await mkdir(cacheDir(), { recursive: true });
@@ -94,32 +112,39 @@ async function loadConnectorZip() {
94
112
  if (buffer.length > 64 * 1024 * 1024) throw new Error("连接器市场包超过 64MB 上限,疑似异常响应");
95
113
  return { buffer, etag: response.headers.get("etag") || "" };
96
114
  };
97
- let zip = null;
98
- if (meta.etag) {
99
- // 有缓存:先验证磁盘上的 ZIP 还能用,能用就 304 探测上游;网络挂了就继续用旧缓存。
100
- try {
101
- const local = await readFile(zipPath());
102
- zip = new ZipReader(local);
103
- const fresh = await download(meta.etag);
104
- if (fresh === null) {
105
- cachedZip = { zip, loadedFrom: "disk" };
106
- return cachedZip;
107
- }
108
- zip = new ZipReader(fresh.buffer);
109
- await writeFile(zipPath(), fresh.buffer);
110
- await writeFile(metaPath(), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
111
- cachedZip = { zip, loadedFrom: "network" };
112
- return cachedZip;
113
- } catch {
114
- zip = null;
115
- }
115
+ const persistNetwork = async (fresh) => {
116
+ // ZipReader 构造即校验 central directory:坏包在这里抛,不会污染缓存。
117
+ const zip = new ZipReader(fresh.buffer);
118
+ await writeCacheFile(zipPath(), fresh.buffer);
119
+ await writeCacheFile(metaPath(), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
120
+ connectorIconCache.clear();
121
+ cachedZip = { zip, loadedFrom: "network" };
122
+ return cachedZip;
123
+ };
124
+ // 磁盘 ZIP 先验证好备用:它是 304(内容未变)与断网两条路的共同回落——
125
+ // 「网络挂了就继续用旧缓存」靠它兑现。验证失败只回落到全量下载,不抛出去。
126
+ let diskZip = null;
127
+ try {
128
+ diskZip = new ZipReader(await readFile(zipPath()));
129
+ } catch {
130
+ diskZip = null;
131
+ }
132
+ try {
133
+ // 只在磁盘 ZIP 有效时才带 etag:本地包损坏/缺失而 etag 命中 304 的话,
134
+ // 手上没有任何正文可用(旧实现此时会拿 null 去取 buffer,崩成 TypeError 且无自愈路径)。
135
+ const fresh = await download(diskZip && meta.etag ? meta.etag : "");
136
+ if (fresh) return await persistNetwork(fresh);
137
+ // 304 = 服务器内容与 etag 一致 = 磁盘缓存就是权威副本。
138
+ cachedZip = { zip: diskZip, loadedFrom: "disk" };
139
+ return cachedZip;
140
+ } catch {
141
+ // 网络失败 / 上游包异常:有磁盘缓存就回落到它,绝不因为刷新失败把市场打空。
116
142
  }
117
- const fresh = await download(meta.etag || "");
118
- zip = new ZipReader(fresh.buffer);
119
- await writeFile(zipPath(), fresh.buffer);
120
- await writeFile(metaPath(), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
121
- cachedZip = { zip, loadedFrom: "network" };
122
- return cachedZip;
143
+ if (diskZip) {
144
+ cachedZip = { zip: diskZip, loadedFrom: "disk" };
145
+ return cachedZip;
146
+ }
147
+ throw new Error("连接器市场不可用:下载失败且本地没有有效的缓存副本");
123
148
  }
124
149
 
125
150
  // ── 连接器目录投影 ─────────────────────────────────────────────────────
@@ -197,8 +222,16 @@ export function serverInstallability(serverName, rawServer, authMode = "") {
197
222
  // 早前这里只认 nested.type:command 型条目 transport 落成空串,被文件末尾的
198
223
  // 「未知传输类型」判为拒装——凡是标准 stdio 连接器都装不上。缺 type 但有 command
199
224
  // 时按 stdio 处理,与下面返回处的 `transport || "stdio"` 兜底保持同一判断。
225
+ // 同一条兜底也适用于远程条目:实测索引里有 11 条只写 url 不写 type
226
+ // (qq-mail、lexiang、tencent-docs、tencent-docs-oa、tapd、github、notion…),
227
+ // 一律撞「未知传输类型(缺失)」被判定装不上。有 url 就按 streamable-http 处理,
228
+ // 与 lib/index.js 的 isOAuthCandidate(`spec.transport || "streamable-http"`)同口径。
200
229
  const declaredType = String(nested?.type ?? "").toLowerCase();
201
- const transport = declaredType || (typeof nested?.command === "string" && nested.command.trim() ? "stdio" : "");
230
+ const hasUrl = typeof nested?.url === "string" && nested.url.trim();
231
+ const transport =
232
+ declaredType ||
233
+ (typeof nested?.command === "string" && nested.command.trim() ? "stdio" : "") ||
234
+ (hasUrl ? "streamable-http" : "");
202
235
  // R7:先扫占位符槽位(env/headers 里的 ${...})。这一扫描必须发生在 transport 的
203
236
  // 提前 return 之前——否则 streamable-http 型连接器的 header 占位符会被整段跳过,
204
237
  // 而 80+ 条 auth_mode=token 的连接器正是这个传输形态,正是「装不了」的成因之一。
@@ -315,20 +348,68 @@ export async function wbConnectorPreview(connectorId) {
315
348
  return { connectorId: id, mcpServers: servers };
316
349
  }
317
350
 
351
+ // ── 连接器图标 ─────────────────────────────────────────────────────────
352
+ // 索引 connectors.json 里没有 icon 字段,但 ZIP 顶层有 icons/ 目录,文件名就是
353
+ // 连接器 id(2026-10-24 实测 443/443 全覆盖),所以按 id 猜扩展名,svg 优先于位图
354
+ // (矢量在 30px 头像位最清晰)。图标只在卡片进视口时被调到这里,单条平均 27KB;
355
+ // 走 data URL 内联而不是宿主文件路径——面板是 webview,读不到本机文件。
356
+ const CONNECTOR_ICON_EXTS = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
357
+ const CONNECTOR_ICON_MIME = {
358
+ ".svg": "image/svg+xml",
359
+ ".png": "image/png",
360
+ ".jpg": "image/jpeg",
361
+ ".jpeg": "image/jpeg",
362
+ ".webp": "image/webp",
363
+ ".gif": "image/gif",
364
+ };
365
+
366
+ /** 某条连接器的图标(data URL 形式)。没有/包没就绪都返回 ok:false,UI 回落首字母。 */
367
+ export async function wbConnectorIcon(connectorId) {
368
+ const id = String(connectorId || "").trim();
369
+ const missing = { ok: false, connectorId: id, dataUrl: "" };
370
+ // id 来自我们自己的目录投影,仍防一层路径注入(query 由 RPC 进来,不信任输入)。
371
+ if (!id || /[\\/\s]/.test(id) || id.includes("..")) return missing;
372
+ if (connectorIconCache.has(id)) {
373
+ const dataUrl = connectorIconCache.get(id);
374
+ return dataUrl ? { ok: true, connectorId: id, dataUrl } : missing;
375
+ }
376
+ let zip;
377
+ try {
378
+ ({ zip } = await loadConnectorZip());
379
+ } catch {
380
+ // 包没就绪(如首次下载失败):不缓存这条「没有」,卡片下次进视口还能再试。
381
+ return missing;
382
+ }
383
+ const ext = CONNECTOR_ICON_EXTS.find((candidate) => zip.has(`icons/${id}${candidate}`));
384
+ if (!ext) {
385
+ connectorIconCache.set(id, null);
386
+ return missing;
387
+ }
388
+ const dataUrl = `data:${CONNECTOR_ICON_MIME[ext]};base64,${zip.extract(`icons/${id}${ext}`).toString("base64")}`;
389
+ connectorIconCache.set(id, dataUrl);
390
+ return { ok: true, connectorId: id, dataUrl };
391
+ }
392
+
318
393
  /**
319
394
  * 装前 401 探测:对 streamable-http 型 server 发一次 MCP initialize JSON-RPC,
320
- * 服务器明确回 401/403 就中止安装。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
395
+ * 服务器明确要凭据就回报 needsAuth。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
321
396
  * 索引 auth_mode 也为空,装完连接必 401 "Version negotiation failed")——这是漏标鉴权的
322
- * 最后防线。判定刻意保守:只有 401/403 拦,超时/5xx/网络错误/其他状态一律放行——
323
- * 探测挂了不能误伤正常连接器,宁可让个别服务器在连接时报错。
324
- * 返回 { authorized: true }(放行)或 throw(要求授权,中止整个安装)。
397
+ * 最后防线。判定刻意保守:只有「401/403」或「其它 4xx 且响应体点名是 token/鉴权问题」
398
+ * 才拦,超时/5xx/网络错误/其它状态一律放行——探测挂了不能误伤正常连接器,宁可让个别
399
+ * 服务器在连接时报错。响应体这一路不能省:腾讯 ima 在凭据是坏的时候回的是 HTTP 400 +
400
+ * `msg:token parse failed`(且不带 WWW-Authenticate),只看状态码就会把它判成
401
+ * 「匿名可用」直接写配置,用户既装不上也等不到授权页。
402
+ * 返回 { authorized: true }(放行)或 { authorized:false, needsAuth:true, ... }。
325
403
  */
326
404
  export async function probeServerAuthorization(serverName, rawServer) {
327
405
  const nested = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
328
- const transport = String(nested?.type ?? "").toLowerCase();
406
+ const url = String(nested?.url || "").trim();
407
+ // transport 缺省判定与 serverInstallability 必须同口径:只写 url 不写 type 的条目
408
+ // (索引里实有 11 条)在那边按 streamable-http 放行,这里若按「非 HTTP 型」跳过,
409
+ // 漏标鉴权的最后防线对它们就正好失效。
410
+ const transport = String(nested?.type ?? "").toLowerCase() || (url ? "streamable-http" : "");
329
411
  // 只探测 HTTP 型:stdio 没有可探测的 URL,sse/ws 本来就被准入闸拦下。
330
412
  if (transport !== "streamablehttp" && transport !== "streamable-http" && transport !== "http") return { authorized: true, skipped: true };
331
- const url = String(nested?.url || "").trim();
332
413
  if (!url) return { authorized: true, skipped: true };
333
414
  const body = JSON.stringify({
334
415
  jsonrpc: "2.0",
@@ -358,8 +439,10 @@ export async function probeServerAuthorization(serverName, rawServer) {
358
439
  // 超时 / DNS 失败 / 连接拒绝:探测不了就放行,安装后由用户实测。
359
440
  return { authorized: true, error: "probe-unreachable" };
360
441
  }
361
- if (response.status === 401 || response.status === 403) {
362
- // R8:不再把 401 当致命错误。改回传结构化「需授权」,由上层触发浏览器授权流程:
442
+ // 正文既用于「非 401 的鉴权失败」判定,也是面板里最有信息量的一行提示。
443
+ const bodyText = await response.text().catch(() => "");
444
+ if (mcpAuthFailure(response.status, bodyText)) {
445
+ // R8:不再把鉴权失败当致命错误。改回传结构化「需授权」,由上层触发浏览器授权流程:
363
446
  // 解析 WWW-Authenticate 里的 resource_metadata,交给 lib/mcp-oauth.js 继续走
364
447
  // RFC 9728(资源元数据)→ RFC 8414(AS 元数据)→ RFC 7591(动态注册)→ RFC 7636(PKCE)。
365
448
  const wwwAuthenticate = String(response.headers.get("www-authenticate") || "");
@@ -371,9 +454,10 @@ export async function probeServerAuthorization(serverName, rawServer) {
371
454
  serverName,
372
455
  wwwAuthenticate,
373
456
  resourceMetadata: extractResourceMetadata(wwwAuthenticate),
457
+ bodyHint: String(bodyText || "").replace(/\s+/g, " ").trim().slice(0, 300),
374
458
  };
375
459
  }
376
- // 200/202/4xx 其他/5xx:都视为「没有明确拒绝匿名握手」,放行。
460
+ // 200/202/其它 4xx/5xx:都视为「没有明确拒绝匿名握手」,放行。
377
461
  return { authorized: true, status: response.status };
378
462
  }
379
463
 
@@ -462,6 +546,14 @@ export async function wbSkillCatalog(payload = {}) {
462
546
  name: String(skill.name || skill.slug || ""),
463
547
  namespace: String(skill.namespace?.handle || ""),
464
548
  canonical: String(skill.namespace?.canonicalName || ""),
549
+ // 「人」的字段:发布方显示名、作品图标、公开详情页、上架时间与认领状态。
550
+ // 这些字段列表接口本来就有,不额外发请求——卡片上的人味信号零成本。
551
+ ownerName: String(skill.ownerName || skill.namespace?.displayName || skill.namespace?.handle || ""),
552
+ iconUrl: String(skill.iconUrl || ""),
553
+ homepage: String(skill.homepage || ""),
554
+ createdAt: Number(skill.created_at) || 0,
555
+ claimState: String(skill.claim_state || ""),
556
+ source: String(skill.source || ""),
465
557
  description: String(skill.description_zh || skill.description || ""),
466
558
  descriptionEn: String(skill.description_en || ""),
467
559
  downloads: Number(skill.downloads) || 0,
@@ -534,17 +626,37 @@ function skillTargetName(namespace, slug) {
534
626
  const safe = (value) => String(value).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "x";
535
627
  return `${safe(namespace)}__${safe(slug)}`;
536
628
  }
629
+ // 并发安装互斥(按目标目录加锁)。双击、卡片+详情同时点、面板重挂载后再点,都会让
630
+ // 同一技能有两个安装任务在飞:两者交错执行同一路径上的 rm(staging)/rm(finalDir)/rename,
631
+ // 轻则留下残包(缺 SKILL.md → 注册表永远发现不了,即「显示安装成功但 Skill 看板里没有」),
632
+ // 重则一个任务删掉另一个刚装好的目录、自己的 rename 又失败 → 「报成功但磁盘上什么都没有」,
633
+ // 之后卸载还会撞「本机没有安装记录」。UI 的 busy 禁用挡不住所有入口(重挂载即重置),
634
+ // 宿主侧串行化才是根修:RPC 可从任意处调用,落盘顺序必须由这里保证。
635
+ const skillInstallLocks = new Map();
537
636
  export async function wbSkillInstall(payload = {}, { onProgress } = {}) {
538
637
  const slug = String(payload?.slug || "").trim();
539
638
  const namespace = String(payload?.namespace || "").trim();
540
- const requestedName = String(payload?.name || "").trim();
541
639
  if (!slug || !namespace) throw new Error("缺少 slug 或 namespace");
640
+ const finalDir = join(homedir(), ".agents", "skills", skillTargetName(namespace, slug));
641
+ const previous = skillInstallLocks.get(finalDir);
642
+ let run;
643
+ // 前序任务失败不该连坐后序:catch 吞掉前序拒绝后再排自己的。
644
+ const queued = previous ? previous.catch(() => {}) : Promise.resolve();
645
+ run = queued
646
+ .then(() => installSkillLocked(payload, { onProgress }, finalDir))
647
+ .finally(() => {
648
+ if (skillInstallLocks.get(finalDir) === run) skillInstallLocks.delete(finalDir);
649
+ });
650
+ skillInstallLocks.set(finalDir, run);
651
+ return run;
652
+ }
653
+ async function installSkillLocked(payload = {}, { onProgress } = {}, finalDir) {
654
+ const slug = String(payload?.slug || "").trim();
655
+ const namespace = String(payload?.namespace || "").trim();
656
+ const requestedName = String(payload?.name || "").trim();
542
657
  const listing = await wbSkillFiles(payload);
543
658
  if (!listing.files.length) throw new Error("该技能没有可下载的文件");
544
659
  if (listing.files.length > 400) throw new Error(`文件数超出上限(${listing.files.length} > 400)`);
545
- const root = join(homedir(), ".agents", "skills");
546
- const targetName = skillTargetName(namespace, slug);
547
- const finalDir = join(root, targetName);
548
660
  const stagingDir = `${finalDir}.installing`;
549
661
  const totalBytes = listing.files.reduce((sum, file) => sum + file.size, 0);
550
662
  if (totalBytes > 32 * 1024 * 1024) throw new Error(`技能总体积超过 32MB 上限(${Math.round(totalBytes / 1024 / 1024)}MB)`);
@@ -579,6 +691,17 @@ export async function wbSkillInstall(payload = {}, { onProgress } = {}) {
579
691
  await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
580
692
  throw error;
581
693
  }
694
+ // 落盘后逐文件复核:清单里的每个路径都必须真实存在才许报成功。缺 SKILL.md 的残包
695
+ // 不会被注册表发现(「显示成功但看板里没有」的假安装),宁可清掉现场报失败,
696
+ // 也不留一个挂着「已安装」却永远不生效的哑目录。
697
+ for (const file of listing.files) {
698
+ try {
699
+ await stat(resolve(finalDir, file.path));
700
+ } catch {
701
+ await rm(finalDir, { recursive: true, force: true }).catch(() => {});
702
+ throw new Error(`安装后校验失败:${file.path} 未落盘(${finalDir}),已清理现场,请重试`);
703
+ }
704
+ }
582
705
  return {
583
706
  note: `已安装到 ${finalDir}(${listing.files.length} 个文件${listing.version ? `,v${listing.version}` : ""}),即刻生效`,
584
707
  dir: finalDir,
@@ -1,12 +1,16 @@
1
1
  // 多市场注册表(R4)—— ~/.dsh/armory/marketplaces.json 的读写层。
2
2
  //
3
3
  // 职责(对应设计 §8.3,类图 MarketRegistry):
4
- // - 注册表文件不存在或损坏时,回落到**内置默认两条**:连接器源(与 lib/wb-market.js:27
5
- // 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相)与
6
- // codebuddy-plugins-official 插件源;首读时把默认项落盘。
4
+ // - 注册表文件不存在或损坏时,回落到**内置默认一条**:连接器源(与
5
+ // lib/wb-market.js:27 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相);
6
+ // 首读时把默认项落盘。
7
7
  // - 合并投影:内置默认永远在场(文件内同 id 条目可覆盖其字段),用户自定义条目追加其后。
8
8
  // - 增删改一律 staging + rename 原子替换(沿用 wbSkillInstall 的 staging 模式)。
9
9
  //
10
+ // 插件市场从 2.3.0 起只保留 DSH 社区源(awesome-dsh-plugins),CodeBuddy 官方 ZIP
11
+ // 插件源已整体移除;旧 marketplaces.json 里的 kind="plugins" 条目读侧直接丢弃,
12
+ // 写侧也顺手清掉,避免它们借 normalize 回落成一条假的连接器源。
13
+ //
10
14
  // 纯数据层:不做任何网络请求,不依赖 Remote/UI。测试可通过 options.rootDir 或
11
15
  // 环境变量 DSH_ARMORY_HOME 把存储根目录指到临时目录,绝不碰真实 ~/.dsh。
12
16
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
@@ -30,9 +34,9 @@ export function registryPath(options = {}) {
30
34
  }
31
35
 
32
36
  /**
33
- * 内置默认市场条目(代码内声明,首读落盘)。顺序即 sourceFor 的优先序:
34
- * 1. connectors → 现有 connectors-config.zip(WorkBuddy 连接器市场分发地址)
35
- * 2. plugins → codebuddy-plugins-official(清单实测在 .codebuddy-plugin/marketplace.json)
37
+ * 内置默认市场条目(代码内声明,首读落盘):
38
+ * connectors → 现有 connectors-config.zip(WorkBuddy 连接器市场分发地址)
39
+ * 插件市场不在这里登记:它只有 DSH 社区一个源,由 lib/wb-awesome-market.js 直连。
36
40
  */
37
41
  export function builtinMarketplaces() {
38
42
  return [
@@ -49,26 +53,13 @@ export function builtinMarketplaces() {
49
53
  addedAt: 0,
50
54
  lastUpdated: 0,
51
55
  },
52
- {
53
- id: "codebuddy-plugins-official",
54
- kind: "plugins",
55
- manifestName: "codebuddy-plugins-official",
56
- type: "zip",
57
- source: {
58
- url: "https://download.codebuddy.cn/plugin-marketplace/codebuddy-plugins-official-4b9366ac-f7e5-4cef-b60a-99a5229ce2b2.zip",
59
- path: "",
60
- },
61
- manifestPath: ".codebuddy-plugin/marketplace.json",
62
- autoUpdate: false,
63
- isBuiltIn: true,
64
- description: "CodeBuddy 官方插件市场",
65
- addedAt: 0,
66
- lastUpdated: 0,
67
- },
68
56
  ];
69
57
  }
70
58
 
71
- const KINDS = new Set(["connectors", "plugins"]);
59
+ const KINDS = new Set(["connectors"]);
60
+ // 已下线的市场类型:读侧丢弃、写侧清除。绝不能让它回落到 connectors——
61
+ // 那会把一条 CodeBuddy 插件源伪装成连接器源。
62
+ const REMOVED_KINDS = new Set(["plugins"]);
72
63
 
73
64
  /** 字符串字段防御读取:非字符串一律回落默认值。 */
74
65
  function asString(value, fallback = "") {
@@ -115,12 +106,19 @@ async function readRawDoc(path) {
115
106
  }
116
107
  }
117
108
 
118
- /** staging + rename 原子写:先写 `<file>.staging`,再删旧文件换入(Windows rename 不覆盖)。 */
109
+ /** 唯一临时名 + rename 原子写(Windows 的 rename 覆盖已存在文件,无需先 rm)。固定
110
+ * `<file>.staging` 在并发写时会互相踩踏——后写者会消费掉先写者的 staging,极端时序
111
+ * 甚至把先写者刚提交的正本删掉,注册表条目就此丢失。 */
119
112
  async function writeAtomic(path, doc) {
120
113
  await mkdir(armoryPathDirname(path), { recursive: true });
121
- await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
122
- await rm(path, { force: true });
123
- await rename(`${path}.staging`, path);
114
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
115
+ try {
116
+ await writeFile(tmp, JSON.stringify(doc, null, 2));
117
+ await rename(tmp, path);
118
+ } catch (error) {
119
+ await rm(tmp, { force: true }).catch(() => {});
120
+ throw error;
121
+ }
124
122
  }
125
123
 
126
124
  /** 拆出目录部分(避免为 dirname 多引一个依赖,路径既有 / 也有 \ 的 Windows 形态)。 */
@@ -131,7 +129,7 @@ function armoryPathDirname(path) {
131
129
 
132
130
  /**
133
131
  * 读注册表并返回合并投影:
134
- * - 文件不存在 / 解析失败 / marketplaces 非数组 → 内置默认两条(并尽力落盘);
132
+ * - 文件不存在 / 解析失败 / marketplaces 非数组 → 内置默认一条(并尽力落盘);
135
133
  * - 文件内与内置默认同 id 的条目**覆盖**默认项字段;
136
134
  * - 其余文件条目按原顺序追加在默认项之后。
137
135
  * 返回 { version, marketplaces },永不抛错。
@@ -140,7 +138,7 @@ export async function readRegistry(options = {}) {
140
138
  const path = registryPath(options);
141
139
  const raw = await readRawDoc(path);
142
140
  const fileEntries = Array.isArray(raw?.marketplaces)
143
- ? raw.marketplaces.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
141
+ ? raw.marketplaces.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry) && !REMOVED_KINDS.has(entry.kind))
144
142
  : null;
145
143
 
146
144
  if (fileEntries === null) {
@@ -173,12 +171,15 @@ export async function readRegistry(options = {}) {
173
171
  * 返回写入后的合并投影。
174
172
  */
175
173
  export async function upsertMarketplace(entry, options = {}) {
174
+ if (REMOVED_KINDS.has(entry?.kind)) {
175
+ throw new Error("CodeBuddy 插件市场已移除,注册表不再接受 plugins 类型的市场源");
176
+ }
176
177
  const normalized = normalizeEntry(entry);
177
178
  if (!normalized.id) throw new Error("市场条目缺少 id,无法登记");
178
179
  const path = registryPath(options);
179
180
  const raw = await readRawDoc(path);
180
181
  const list = Array.isArray(raw?.marketplaces)
181
- ? raw.marketplaces.filter((item) => item && typeof item === "object" && !Array.isArray(item))
182
+ ? raw.marketplaces.filter((item) => item && typeof item === "object" && !Array.isArray(item) && !REMOVED_KINDS.has(item.kind))
182
183
  : [];
183
184
  const now = Date.now();
184
185
  const existingIndex = list.findIndex((item) => normalizeEntry(item).id === normalized.id);
@@ -210,7 +211,7 @@ export async function removeMarketplace(id, options = {}) {
210
211
  return readRegistry(options); // 文件本就没有用户条目,直接回落投影
211
212
  }
212
213
  const list = raw.marketplaces.filter(
213
- (item) => !(item && typeof item === "object" && normalizeEntry(item).id === target),
214
+ (item) => !(item && typeof item === "object" && (normalizeEntry(item).id === target || REMOVED_KINDS.has(item.kind))),
214
215
  );
215
216
  await writeAtomic(path, { version: REGISTRY_VERSION, marketplaces: list });
216
217
  return readRegistry(options);
@@ -389,6 +389,33 @@ export const WHATS_NEW_EN = {
389
389
  where: 'Tool Tailor -> note under the skill table',
390
390
  },
391
391
  ],
392
+ '2.3.1': [
393
+ {
394
+ text: '**Fixed two dead ends in the market caches; offline degradation now keeps its promise**: with a corrupted or cleaned local cache and an unchanged upstream, the expert catalog and connector market crashed on an empty 304 response and failed on every call; offline, a perfectly good disk cache was thrown away and the panel went empty. Both cases now degrade correctly: a broken cache triggers a full re-download without the stale ETag, and a network outage keeps serving the disk cache (loadedFrom=disk)',
395
+ where: 'Market / Experts',
396
+ },
397
+ {
398
+ text: '**Network data is validated before it is written**: catalog / ZIP responses must pass a structure check first (a missing experts array or a corrupt ZIP directory counts as a download failure), so a proxy interstitial that parses as JSON can no longer overwrite a good cache; writes go through a unique temp file + rename, so an interrupted run never leaves a truncated cache body',
399
+ where: 'Market / Experts',
400
+ },
401
+ {
402
+ text: '**Expert persona path now follows the marketplace manifest**: the manifest-provided promptFile was dropped during projection, so experts whose plugin.json could not be fetched failed with "no persona file path" or installed the wrong persona file; the authoritative path takes effect directly now',
403
+ where: 'Experts -> Detail / Summon',
404
+ },
405
+ {
406
+ text: '**Fixed a write race in local caches**: the multi-market registry, custom suites, install state, community meta and npm downloads now write through a unique temp file, so concurrently returning detail cards no longer trample each other into corrupt files - the worst case previously lost user-defined marketplace entries',
407
+ where: 'Global panel',
408
+ },
409
+ {
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
+ },
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
+ ],
392
419
  };
393
420
 
394
421
  /**
package/lib/whats-new.js CHANGED
@@ -445,4 +445,37 @@ export const WHATS_NEW = [
445
445
  },
446
446
  ],
447
447
  },
448
+ {
449
+ version: '2.3.1',
450
+ items: [
451
+ {
452
+ text: '**修复市场缓存的两条死路,离线降级恢复承诺行为**:本地缓存损坏或被清理而上游未更新时,专家目录与连接器市场会对 304 空响应崩溃并每次调用都失败;断网时又会把完好的磁盘缓存丢弃、整块打空。现在两类场景都正确回落:缓存损坏即不带旧 ETag 全量重下,断网继续用磁盘缓存(loadedFrom=disk)',
453
+ where: '市场 / 专家',
454
+ },
455
+ {
456
+ text: '**网络数据先校验再落盘**:目录 / ZIP 响应先过结构校验(缺 experts 数组、ZIP 目录损坏一律按下载失败处理),代理拦截页之类「可解析但不是清单」的响应不再覆盖好缓存;落盘改为唯一临时文件 + rename 原子写,中断不再留下截断的缓存正文',
457
+ where: '市场 / 专家',
458
+ },
459
+ {
460
+ text: '**专家人设路径以市场清单为准**:目录条目自带的 promptFile 此前被投影丢弃,plugin.json 拉不到的专家会硬报「没有人设文件路径」甚至装错人设文件;现在清单给的权威路径直接生效',
461
+ where: '专家 → 详情 / 召唤',
462
+ },
463
+ {
464
+ text: '**修复本地缓存并发写的竞态**:多市场注册表、自定义套件、安装状态、社区元信息、npm 下载量的写盘改用唯一临时名,多张详情卡同时回来不再互相踩出坏文件——此前极端时序可丢用户自定义市场条目',
465
+ where: '全局面板',
466
+ },
467
+ {
468
+ text: 'awesome 冷启动遇源 1 全链失败不再丢弃源 2 已拉到的数据;清理 19 个开发期一次性补丁脚本与两处已失效的死导出',
469
+ },
470
+ ],
471
+ },
472
+ {
473
+ version: '2.3.2',
474
+ items: [
475
+ {
476
+ text: '**修复安装需 OAuth 连接器时的整面板崩溃(Minified React error #62)**:授权弹窗的警示行把 style 写成了字符串,浏览器没能自动打开时弹窗一渲染就把整块面板炸掉——现在弹出授权层正常显示,浏览器打开失败时客户端会立即重试唤起,仍有「授权链接 + 重新打开浏览器」兜底;专家详情的同类隐患一并修复',
477
+ where: '市场 → 安装需授权的连接器',
478
+ },
479
+ ],
480
+ },
448
481
  ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "2.3.0",
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 & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
3
+ "version": "2.3.2",
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",
7
7
  "url": "git+https://github.com/2026heshao/dsh-armory.git"