dsh-mcp-connector 0.2.25 → 0.2.27

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/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.27] - 2026-08-28
8
+
9
+ ### Fixed
10
+
11
+ - OAuth Refresh Token 轮换不再依赖 DSH JSON storage 的进程内缓存;新增每 Grant 独立原子日志和跨进程锁,Desktop 与 `dsh web` 同时运行时只轮换一次 Token,等待方直接采用最新凭据。
12
+ - DSH Desktop 没有插件市场设置分区时,更新入口不再停留在普通设置页;改为中性的“查看更新方式”,并在确认无市场分区后打开 npm 安装说明。
13
+
14
+ ### Security
15
+
16
+ - 跨进程 Grant journal 位于 `$DSH_HOME/storages/mcp_connector_grants_v1`,采用 0700 目录、0600 文件和同目录原子替换;Token 不进入运行日志、页面或 Git 仓库。
17
+
18
+ ## [0.2.26] - 2026-08-27
19
+
20
+ ### Fixed
21
+
22
+ - 升级启动时自动发现并归并历史版本为同 issuer 卡片保存的多份 OAuth Grant;归并前先验证过期 Grant 是否仍可刷新,避免用较新但已失效的凭据覆盖可恢复授权。
23
+ - 多个 DSH 进程并发刷新同一组轮换式 Refresh Token 时,收到 `invalid_grant` 后会短暂重读本机持久化 Grant;发现其他进程已保存新 Token 时自动采用并重试,不再误报“需重新授权”。
24
+ - 页面不再盲信 Update Provider 的 `succeeded`;独立校验更新前、预期与实际版本,检测降级、解析版本偏差或无效结果时拒绝重启,并在 Provider 保留恢复点时自动回滚。
25
+
26
+ ### Verification
27
+
28
+ - 129 项自动测试、lint、npm 发布包白名单与敏感内容扫描通过;新增历史 Grant 安全归并、失效候选回退、跨进程 Token 轮换恢复和 Provider 版本完整性回归测试。
29
+
7
30
  ## [0.2.25] - 2026-08-27
8
31
 
9
32
  ### Added
@@ -414,7 +437,9 @@
414
437
  - 外部 URL 与导入 Header 执行安全校验。
415
438
  - iframe 消息校验同源和消息来源。
416
439
 
417
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.25...HEAD
440
+ [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.27...HEAD
441
+ [0.2.27]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.26...v0.2.27
442
+ [0.2.26]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.25...v0.2.26
418
443
  [0.2.25]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.24...v0.2.25
419
444
  [0.2.24]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...v0.2.24
420
445
  [0.2.23]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...v0.2.23
package/README.en.md CHANGED
@@ -25,14 +25,14 @@ Browse and install MCP connectors from different providers in DeepSeek Harness D
25
25
  - Credential and MCP initialize validation before HTTP API-key connectors are saved as installed, plus declarative multi-field credential-to-env bindings for marketplace stdio connectors.
26
26
  - Dynamic tool discovery grouped by MCP server, including descriptions, search, batched rendering, and an independent scroll region.
27
27
  - Curated prompt templates that can open a DSH conversation and prefill its draft; missing variables are requested before the prompt is sent.
28
- - Persistent connection lifecycle management: restore on restart, enable/disable, disconnect, retry transient OAuth refresh failures with bounded backoff, and revoke authorization. Cards declaring issuer-level sharing reuse one grant; DCR client secrets remain local to that grant.
28
+ - Persistent connection lifecycle management: restore on restart, enable/disable, disconnect, retry transient OAuth refresh failures with bounded backoff, and revoke authorization. Cards declaring issuer-level sharing reuse one grant; a cross-process lock and per-grant atomic journal prevent Desktop and Web hosts from consuming or overwriting the same rotating refresh token.
29
29
  - Built-in, remote, and local catalogs with `published` and `featured` controls.
30
30
  - A standalone remote Registry, allowing new marketplace cards to appear after refresh without publishing a new npm version.
31
- - Plugin version and one-click updates: version discovery is independent of the installation source, while an Update Provider adapter layer negotiates safe mutation capabilities. DSH Market API v1 is the first adapter and supports progress, normalized failures, rollback, and capability-gated restart/refresh actions; without a compatible provider, the UI falls back to the current plugin marketplace or npm.
31
+ - Plugin version and one-click updates: version discovery is independent of the installation source, while an Update Provider adapter layer negotiates safe mutation capabilities. DSH Market API v1 is the first adapter and supports progress, normalized failures, rollback, and capability-gated restart/refresh actions. Without a compatible provider the UI offers update instructions; if Desktop has no plugin-market section, it opens the npm package page instead of leaving the user on generic Settings.
32
32
  - Explicit, non-destructive migration from the two earlier Qichacha OAuth plugins, plus active-plugin conflict detection that blocks duplicate server management and credential overwrites.
33
33
 
34
34
  <!-- catalog-stats:start -->
35
- As of 2026-08-25, the public Registry publishes 78 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 82 cards across 9 business categories. Recommendations remain limited to the four Qichacha cards, PKULaw, and Wind, for 6 featured cards in total. The Registry evolves independently; the badge shown after a client refresh and the live badges above are the authoritative current counts.
35
+ As of 2026-08-27, the public Registry publishes 79 connector descriptors. After merging and deduplicating them with the 4 bundled Qichacha cards, the Marketplace exposes 83 cards across 9 business categories. Recommendations remain limited to the four Qichacha cards, PKULaw, and Wind, for 6 featured cards in total. The Registry evolves independently; the badge shown after a client refresh and the live badges above are the authoritative current counts.
36
36
  <!-- catalog-stats:end -->
37
37
 
38
38
  ## Interface and demo
@@ -118,7 +118,7 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](d
118
118
 
119
119
  ## Security and limitations
120
120
 
121
- - Credentials are stored only in the DSH storage domain and are not written to the catalog, Git repository, or conversation history.
121
+ - Credentials stay inside the local DSH storage boundary: connection records use the storage domain, while rotating OAuth credentials are also saved in `$DSH_HOME/storages/mcp_connector_grants_v1` so stale host snapshots cannot overwrite them. The directory is mode 0700 and records are mode 0600; credentials are never written to the catalog, Git repository, page, logs, or conversation history.
122
122
  - Failed API key/token validation is not persisted; authentication, timeout, DNS, and TLS/network errors are reported separately.
123
123
  - External URLs must use HTTPS; HTTP is allowed only for loopback development.
124
124
  - Remote descriptors and catalogs are limited to 2 MiB, Web API requests to 1 MiB, and imported JSON is scanned for credential fields before normalization.
package/README.md CHANGED
@@ -25,7 +25,7 @@
25
25
  - Prompt 模板:使用 `{{company}}` 等变量,发送前填写真实查询主体。
26
26
  - 三种接入:OAuth 2.0 PKCE、自定义 HTTP/stdio、导入 `mcpServers` JSON;也支持从连接器描述 URL 安装。OAuth 动态注册兼容公共客户端以及 `client_secret_post` / `client_secret_basic` 机密客户端。
27
27
  - 市场 Bearer/API Key 连接器先执行 MCP initialize 连通性与凭据校验,全部 HTTP Server 通过后才持久化凭据并进入“已安装”;stdio 卡片可声明多个本机凭据字段及其环境变量映射。
28
- - 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 自动刷新/退避恢复与撤销;同 issuer 卡片可共享一次授权,DCR 返回的客户端密钥与 Token 一同只保存在本机。
28
+ - 生命周期管理:连接持久化、重启恢复、启停、断开、OAuth 自动刷新/退避恢复与撤销;同 issuer 卡片共享一次授权,跨进程锁与独立原子 Grant journal 防止 Desktop/Web 并行时重复消耗 Refresh Token。
29
29
  - 目录运营:内置目录、远程 registry、本地覆盖,支持 `published` 上下架与 `featured` 精选。
30
30
  - 独立远程 Registry:新市场卡片合并后客户端刷新即可见,无需重新发布 npm;远程不可用时自动回退内置目录。
31
31
  - 插件版本与一键更新:版本发现独立于安装来源;页面通过 Update Provider 适配层探测安全更新能力。DSH Market API v1 是首个适配器,支持进度、稳定失败码、回滚及按宿主能力提供的重启/刷新操作;无可用 Provider 时回退到当前插件市场或 npm。
@@ -34,7 +34,7 @@
34
34
  - 对话工具:`mcp_connector_catalog`、`connect`、`configure`、`import_json`、`install_from_url`、`status`、`health_check`、`set_enabled`、`disconnect`、`refresh_catalog`、`publish`、`tools_list`。
35
35
 
36
36
  <!-- catalog-stats:start -->
37
- 截至 2026-08-25,公共 Registry 已发布 78 条连接器描述;与随包的 4 张企查查卡片合并去重后,市场页可浏览 82 张卡片,覆盖企业数据、金融投资、法律合规、开发工具、办公协作、调研分析、设计创意、效率工具、其他 9 类。推荐位严格保留 4 张企查查卡片、北大法宝和 Wind,共 6 张;其他连接器按业务分类展示。Registry 可独立持续更新,实际数量以客户端刷新后的市场页签徽标和上方实时统计徽标为准。
37
+ 截至 2026-08-27,公共 Registry 已发布 79 条连接器描述;与随包的 4 张企查查卡片合并去重后,市场页可浏览 83 张卡片,覆盖企业数据、金融投资、法律合规、开发工具、办公协作、调研分析、设计创意、效率工具、其他 9 类。推荐位严格保留 4 张企查查卡片、北大法宝和 Wind,共 6 张;其他连接器按业务分类展示。Registry 可独立持续更新,实际数量以客户端刷新后的市场页签徽标和上方实时统计徽标为准。
38
38
  <!-- catalog-stats:end -->
39
39
 
40
40
  ## 界面与演示
@@ -117,7 +117,7 @@ stdio 传输的架构、透传边界与安全约束见 [docs/STDIO-SUPPORT.md](d
117
117
 
118
118
  ## 安全与限制
119
119
 
120
- - 凭证只持久化在 DSH storage domain,不进入目录、Git 仓库或对话历史。
120
+ - 凭证只持久化在本机 DSH storage 边界:连接记录使用 storage domain,OAuth 轮换凭据同步保存到 `$DSH_HOME/storages/mcp_connector_grants_v1`。该目录为 0700、文件为 0600;凭证不进入市场目录、Git 仓库、页面、日志或对话历史。
121
121
  - 市场 Key/Token 校验失败时不写入 storage domain;鉴权、超时、DNS、TLS/网络错误会分类提示。
122
122
  - 外部 URL 仅允许 HTTPS,HTTP 仅允许回环地址;导入配置会校验 URL 与 Header。
123
123
  - 远程目录/描述响应限制 2 MiB,Web API 请求限制 1 MiB;原始 JSON 在归一化前扫描凭据字段。
@@ -14,17 +14,18 @@ MCP连接器把“刷新连接器目录”和“升级插件代码”视为两
14
14
 
15
15
  每个 Provider 适配器只硬编码自己的 capabilities 入口,其余更新、任务、回滚和重启端点均从能力响应中读取。客户端只接受当前 DSH 页面的相对同源端点,拒绝跨域 URL、协议不匹配、缺少必要端点或未声明 `features.update` 的 Provider。
16
16
 
17
- DSH Market 适配器从 `GET /dsh-market/api/v1/capabilities` 开始,要求 `schema: dsh-market/update-api/v1` 与 `apiVersion: 1`。未安装任何可用 Provider、接口返回 404、协议不兼容或检查失败时,页面不会尝试私有更新路由,而是回退到“前往插件市场”;设置入口不可用时再打开 npm 包页面。
17
+ DSH Market 适配器从 `GET /dsh-market/api/v1/capabilities` 开始,要求 `schema: dsh-market/update-api/v1` 与 `apiVersion: 1`。未安装任何可用 Provider、接口返回 404、协议不兼容或检查失败时,页面不会尝试私有更新路由,而是显示“查看更新方式”。页面会先尝试打开当前宿主的插件市场;设置入口不可用,或 DSH Desktop 设置中实际没有插件市场分区时,打开 npm 包页面与安装命令说明。
18
18
 
19
19
  ## 更新流程
20
20
 
21
21
  1. 插件自己的服务端缓存检查 npm `latest` 与 GitHub Release,只在 npm 已有可安装新版本时提示升级。
22
22
  2. 选中的 Provider 再检查本 profile 中 `dsh-mcp-connector` 的实际安装版本和目标版本。
23
23
  3. 用户点击“一键更新”后,Provider 立即返回 `operationId`;页面通过适配器轮询任务状态,展示排队、解析、下载和安装进度。
24
- 4. 成功后按 Provider 返回的激活结论展示“刷新生效”或重启提示。Web 且 Provider 明确允许进程重启时才展示“立即重启”;Desktop 等由外壳管理生命周期的宿主只提示用户重启,不自行杀进程。
25
- 5. Provider 保留恢复点时展示“回滚”。回滚仍由 Provider 执行,MCP连接器不直接修改 profile、lockfile 或 `node_modules`。
24
+ 4. Provider 报告成功后,页面仍会独立校验 `beforeVersion`、点击时的预期版本与 `installedVersion`。降级、目标不一致或无效结果会被拒绝;Provider 保留恢复点时自动回滚,不向用户展示重启。
25
+ 5. 完整性校验通过后,按 Provider 返回的激活结论展示“刷新生效”或重启提示。Web 且 Provider 明确允许进程重启时才展示“立即重启”;Desktop 等由外壳管理生命周期的宿主只提示用户重启,不自行杀进程。
26
+ 6. Provider 保留恢复点时展示“回滚”。回滚仍由 Provider 执行,MCP连接器不直接修改 profile、lockfile 或 `node_modules`。
26
27
 
27
- 失败结果使用稳定代码区分正在运行的 Agent、其他安装任务占用、镜像同步、版本未变化、超时和权限拒绝。可重试失败保留重试操作;Registry 刚发布但镜像仍未同步时,用户可在看到明确提示后选择强制重试。
28
+ 失败结果使用稳定代码区分正在运行的 Agent、其他安装任务占用、镜像同步、版本未变化、超时和权限拒绝,以及 `DOWNGRADE_DETECTED`、`RESOLVED_VERSION_MISMATCH`、`INVALID_UPDATE_RESULT` 三类完整性故障。可重试失败保留重试操作;Registry 刚发布但镜像仍未同步时,用户可在看到明确提示后选择强制重试。
28
29
 
29
30
  ## 安全边界
30
31
 
@@ -42,7 +42,7 @@ dsh web
42
42
  - **已安装**:查看已经保存到本机的连接,页签徽标显示连接数。
43
43
  - **搜索**:按名称、服务商、简介和标签查找。
44
44
  - **添加连接**:导入 JSON、手动配置 HTTP/stdio,或从市场描述 URL 安装。
45
- - **版本与更新提示**:标题旁显示当前插件版本,版本检查不依赖安装所用的插件市场。检测到 npm 有新版本且当前宿主存在兼容 Update Provider 时,可在当前页面一键更新并查看进度、失败原因和可用回滚。DSH Market API v1 是当前首个 Provider;无可用 Provider 时自动回退为“前往插件市场”或 npm 页面。
45
+ - **版本与更新提示**:标题旁显示当前插件版本,版本检查不依赖安装所用的插件市场。检测到 npm 有新版本且当前宿主存在兼容 Update Provider 时,可在当前页面一键更新并查看进度、失败原因和可用回滚。DSH Market API v1 是当前首个 Provider;无可用 Provider 时显示“查看更新方式”,宿主没有插件市场分区时会打开 npm 安装说明。
46
46
  - **刷新连接器目录**:重新拉取 Registry 卡片,并更新连接健康状态;该操作不会升级 MCP连接器插件本身。
47
47
 
48
48
  ## 3. 浏览市场与分类
@@ -75,7 +75,7 @@ stdio 市场卡片也可能显示一个或多个凭据字段,例如 API Token
75
75
 
76
76
  OAuth 一键连接要求服务商支持标准 OAuth 2.1/PKCE 和公开元数据发现。动态客户端注册既支持无需客户端密钥的 `none`,也支持服务商签发密钥的 `client_secret_post` 与 `client_secret_basic`。客户端密钥仅与 OAuth Grant 一同保存在 DSH 本机,用于换取、刷新和撤销 Token;插件不会要求用户把 OAuth Token 或客户端密钥复制到聊天中。
77
77
 
78
- 当市场描述声明 `grantSharing: "issuer"` 时,同一账号下相同 issuer、scope 和客户端鉴权方式的卡片共享一组 Grant。首次授权仍只启用用户点击的卡片;之后连接同组卡片会直接复用现有授权,不再重复打开 OAuth 页面。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”,明确收到 Refresh Token/客户端失效错误时才进入“需重新授权”。
78
+ 当市场描述声明 `grantSharing: "issuer"` 时,同一账号下相同 issuer、scope 和客户端鉴权方式的卡片共享一组 Grant。首次授权仍只启用用户点击的卡片;之后连接同组卡片会直接复用现有授权,不再重复打开 OAuth 页面。升级时会验证并自动归并旧版本留下的同 issuer 多份 Grant;Desktop 与 `dsh web` 并发时,插件使用跨进程锁串行轮换,并从每 Grant 独立原子日志读取最新 Token,避免 DSH 整文件 storage 的旧进程快照覆盖新凭据。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”;确认 journal 中也没有更新 Token,且明确收到 Refresh Token/客户端失效错误时,才进入“需重新授权”。
79
79
 
80
80
  ## 5. 查看详情、Prompt 与工具
81
81
 
package/lib/client.js CHANGED
@@ -363,6 +363,9 @@ window.__ModuleLoader__.load({
363
363
  OPERATION_BUSY: "插件市场正在处理其他任务",
364
364
  RELEASE_TOO_FRESH: "新版本仍在镜像同步",
365
365
  VERSION_UNCHANGED: "下载后版本未变化",
366
+ DOWNGRADE_DETECTED: "已阻止版本降级",
367
+ RESOLVED_VERSION_MISMATCH: "下载版本与预期不一致",
368
+ INVALID_UPDATE_RESULT: "更新服务未返回有效版本",
366
369
  UPDATE_TIMEOUT: "更新超时",
367
370
  UPDATE_FORBIDDEN: "更新请求被拒绝",
368
371
  UPDATE_REJECTED: "无法执行本次更新"
@@ -370,9 +373,75 @@ window.__ModuleLoader__.load({
370
373
  return labels[failure?.code] ?? failure?.message ?? "更新失败";
371
374
  }
372
375
 
376
+ function parseClientVersion(value) {
377
+ if (typeof value !== "string") return null;
378
+ const normalized = value.trim().replace(/^v/i, "");
379
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(normalized)) return null;
380
+ const [withoutBuild] = normalized.split("+");
381
+ const [core, prerelease] = withoutBuild.split("-", 2);
382
+ return { normalized, core: core.split(".").map(Number), prerelease: prerelease?.split(".") ?? [] };
383
+ }
384
+
385
+ function compareClientVersions(left, right) {
386
+ const a = parseClientVersion(left);
387
+ const b = parseClientVersion(right);
388
+ if (a === null || b === null) return null;
389
+ for (let index = 0; index < 3; index += 1) {
390
+ if (a.core[index] !== b.core[index]) return a.core[index] - b.core[index];
391
+ }
392
+ if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1;
393
+ if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1;
394
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
395
+ for (let index = 0; index < length; index += 1) {
396
+ const aPart = a.prerelease[index];
397
+ const bPart = b.prerelease[index];
398
+ if (aPart === void 0) return -1;
399
+ if (bPart === void 0) return 1;
400
+ if (aPart === bPart) continue;
401
+ const aNumeric = /^\d+$/.test(aPart);
402
+ const bNumeric = /^\d+$/.test(bPart);
403
+ if (aNumeric && bNumeric) return Number(aPart) - Number(bPart);
404
+ if (aNumeric) return -1;
405
+ if (bNumeric) return 1;
406
+ return aPart.localeCompare(bPart);
407
+ }
408
+ return 0;
409
+ }
410
+
411
+ /** Independently verify a provider's terminal success before asking the user to restart. */
412
+ function completedUpdateIntegrityFailure(operation, expectedVersion) {
413
+ if (operation?.state !== "succeeded") return null;
414
+ const installed = parseClientVersion(operation.installedVersion);
415
+ if (installed === null) {
416
+ return {
417
+ code: "INVALID_UPDATE_RESULT",
418
+ message: "更新服务报告成功,但未返回有效的已安装版本",
419
+ retryable: false
420
+ };
421
+ }
422
+ const before = parseClientVersion(operation.beforeVersion);
423
+ if (before !== null && compareClientVersions(installed.normalized, before.normalized) < 0) {
424
+ return {
425
+ code: "DOWNGRADE_DETECTED",
426
+ message: `更新服务将插件从 v${before.normalized} 降级到 v${installed.normalized}`,
427
+ retryable: false
428
+ };
429
+ }
430
+ const expected = parseClientVersion(expectedVersion);
431
+ if (expected !== null && compareClientVersions(installed.normalized, expected.normalized) !== 0) {
432
+ return {
433
+ code: "RESOLVED_VERSION_MISMATCH",
434
+ message: `预期安装 v${expected.normalized},更新服务实际安装了 v${installed.normalized}`,
435
+ retryable: true
436
+ };
437
+ }
438
+ return null;
439
+ }
440
+
373
441
  /**
374
442
  * DSH 暂未公开“打开指定设置分区”服务:只依赖稳定的 sidebar.settings
375
- * slot 和可访问文本打开插件市场,不依赖构建生成的 CSS 类名。
443
+ * slot 和可访问文本打开插件市场。Desktop 可能根本没有市场分区,
444
+ * 轮询结束后必须回退到 npm 安装说明,不能把用户留在普通设置页。
376
445
  */
377
446
  function openDshPluginMarket(actions) {
378
447
  const settingsHost = document.querySelector('[data-slot="sidebar.settings"]');
@@ -395,7 +464,11 @@ window.__ModuleLoader__.load({
395
464
  return;
396
465
  }
397
466
  attempts += 1;
398
- if (attempts < 20) window.setTimeout(selectMarket, 50);
467
+ if (attempts < 20) {
468
+ window.setTimeout(selectMarket, 50);
469
+ return;
470
+ }
471
+ window.open(NPM_PACKAGE_URL, "_blank", "noopener,noreferrer");
399
472
  };
400
473
  window.requestAnimationFrame(selectMarket);
401
474
  }, 0);
@@ -451,6 +524,7 @@ window.__ModuleLoader__.load({
451
524
  const [updateProviderState, setUpdateProviderState] = (0, react.useState)("checking");
452
525
  const [updateProviderSelection, setUpdateProviderSelection] = (0, react.useState)(null);
453
526
  const [providerUpdateCheck, setProviderUpdateCheck] = (0, react.useState)(null);
527
+ const [expectedUpdateVersion, setExpectedUpdateVersion] = (0, react.useState)(null);
454
528
  const [updateOperation, setUpdateOperation] = (0, react.useState)(null);
455
529
  const [updateError, setUpdateError] = (0, react.useState)(null);
456
530
  const [updateStarting, setUpdateStarting] = (0, react.useState)(false);
@@ -564,6 +638,34 @@ window.__ModuleLoader__.load({
564
638
  provider.operation(capabilities, operationId).then((operation) => {
565
639
  if (disposed) return;
566
640
  setUpdateError(null);
641
+ const integrityFailure = completedUpdateIntegrityFailure(operation, expectedUpdateVersion);
642
+ if (integrityFailure !== null) {
643
+ const failedOperation = { ...operation, state: "failed", failure: integrityFailure };
644
+ setUpdateOperation(failedOperation);
645
+ const canRollback = capabilities.features?.rollback === true
646
+ && operation.outcome?.rollback?.available === true
647
+ && capabilities.endpoints.rollback !== null;
648
+ if (!canRollback) return;
649
+ setRollbackRunning(true);
650
+ provider.rollback(capabilities, operationId).then((rolledBackOperation) => {
651
+ if (disposed) return;
652
+ setRollbackRunning(false);
653
+ setUpdateOperation({ ...rolledBackOperation, integrityFailure });
654
+ }, (error) => {
655
+ console.error("[mcp-connector] automatic rollback failed:", error);
656
+ if (disposed) return;
657
+ const detail = error instanceof Error ? error.message : String(error);
658
+ setRollbackRunning(false);
659
+ setUpdateOperation({
660
+ ...failedOperation,
661
+ outcome: {
662
+ ...failedOperation.outcome,
663
+ rollback: { available: true, state: "failed", detail }
664
+ }
665
+ });
666
+ });
667
+ return;
668
+ }
567
669
  setUpdateOperation(operation);
568
670
  if (["queued", "running"].includes(operation.state)) {
569
671
  timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS);
@@ -584,7 +686,7 @@ window.__ModuleLoader__.load({
584
686
  disposed = true;
585
687
  window.clearTimeout(timer);
586
688
  };
587
- }, [open, updateOperation?.operationId, updateProviderSelection?.provider?.id]);
689
+ }, [open, updateOperation?.operationId, updateProviderSelection?.provider?.id, expectedUpdateVersion]);
588
690
  (0, react.useEffect)(() => {
589
691
  if (!open) return void 0;
590
692
  closeRef.current?.focus?.();
@@ -600,6 +702,7 @@ window.__ModuleLoader__.load({
600
702
  if (updateRunning || provider === void 0 || capabilities === void 0) return;
601
703
  setUpdateError(null);
602
704
  setUpdateStarting(true);
705
+ setExpectedUpdateVersion(providerUpdateCheck?.latestVersion ?? versionStatus?.latestVersion ?? null);
603
706
  provider.start(capabilities, PLUGIN_PACKAGE_NAME, force).then((operation) => {
604
707
  setUpdateStarting(false);
605
708
  setUpdateOperation(operation);
@@ -687,11 +790,15 @@ window.__ModuleLoader__.load({
687
790
  onClick: () => startUpdate(force),
688
791
  children: force ? "强制重试" : "重试更新"
689
792
  }),
793
+ rollbackRunning && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
794
+ className: "mcpConnectorUpdateStatus",
795
+ children: "检测到异常版本,正在自动回滚…"
796
+ }),
690
797
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
691
798
  type: "button",
692
799
  className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
693
800
  onClick: () => openDshPluginMarket(actions),
694
- children: "打开插件市场"
801
+ children: "查看更新方式"
695
802
  })
696
803
  ]
697
804
  });
@@ -707,7 +814,7 @@ window.__ModuleLoader__.load({
707
814
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
708
815
  className: "mcpConnectorUpdateStatus",
709
816
  children: rolledBack
710
- ? "已回滚"
817
+ ? updateOperation.integrityFailure === void 0 ? "已回滚" : "已阻止异常版本并回滚"
711
818
  : `已安装 v${updateOperation.installedVersion ?? versionStatus?.latestVersion}`
712
819
  }),
713
820
  rollbackFailed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -758,7 +865,7 @@ window.__ModuleLoader__.load({
758
865
  type: "button",
759
866
  className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
760
867
  onClick: () => openDshPluginMarket(actions),
761
- children: "打开插件市场"
868
+ children: "查看更新方式"
762
869
  })
763
870
  ]
764
871
  });
@@ -785,7 +892,7 @@ window.__ModuleLoader__.load({
785
892
  className: "mcpConnectorUpdateButton",
786
893
  title: `npm 已发布 v${versionStatus?.latestVersion}`,
787
894
  onClick: () => openDshPluginMarket(actions),
788
- children: `前往插件市场更新到 v${versionStatus?.latestVersion}`
895
+ children: `查看 v${versionStatus?.latestVersion} 更新方式`
789
896
  });
790
897
  };
791
898
  const src = window.location.origin + "/mcp-connector/ui/";
@@ -0,0 +1,192 @@
1
+ /**
2
+ * OAuth Grant 跨进程轮换日志。
3
+ *
4
+ * DSH JSON storage 以“每进程内存状态 + 整文件原子替换”实现,并不保证两个
5
+ * DSH Host 同时打开同一 domain 时的写入一致性。Refresh Token 又通常每次刷新都
6
+ * 会轮换,所以 Desktop 与 dsh web 并行时必须额外做跨进程串行化。
7
+ *
8
+ * 每个 Grant 独立文件,避免一个过期进程覆盖其他 Grant;文件与锁均位于
9
+ * $DSH_HOME/storages/mcp_connector_grants_v1,目录 0700、文件 0600。
10
+ */
11
+ import { createHash, randomUUID } from 'node:crypto';
12
+ import { chmod, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { grantRecordSchema } from './schema.js';
16
+
17
+ const JOURNAL_VERSION = 1;
18
+ const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
19
+ const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
20
+
21
+ function sleep(ms) {
22
+ return new Promise((done) => setTimeout(done, ms));
23
+ }
24
+
25
+ function fileId(grantKey) {
26
+ return createHash('sha256').update(grantKey).digest('hex');
27
+ }
28
+
29
+ export function defaultGrantJournalDir() {
30
+ if (process.env.NODE_TEST_CONTEXT) return null;
31
+ const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
32
+ return resolve(dshHome, 'storages', 'mcp_connector_grants_v1');
33
+ }
34
+
35
+ async function writeAtomic(path, document) {
36
+ const temp = join(dirname(path), `.${randomUUID()}.tmp`);
37
+ try {
38
+ const handle = await open(temp, 'wx', 0o600);
39
+ try {
40
+ await handle.writeFile(`${JSON.stringify(document, null, 2)}\n`, 'utf8');
41
+ await handle.sync();
42
+ } finally {
43
+ await handle.close();
44
+ }
45
+ await rename(temp, path);
46
+ await chmod(path, 0o600);
47
+ } catch (error) {
48
+ await rm(temp, { force: true }).catch(() => {});
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ function parseDocument(text, path) {
54
+ let document;
55
+ try {
56
+ document = JSON.parse(text);
57
+ } catch (error) {
58
+ throw new Error(`OAuth grant journal is malformed: ${path}`, { cause: error });
59
+ }
60
+ if (document?.version !== JOURNAL_VERSION) {
61
+ throw new Error(`OAuth grant journal version is unsupported: ${path}`);
62
+ }
63
+ const parsed = grantRecordSchema.safeParse(document.grant);
64
+ if (!parsed.success) {
65
+ throw new Error(`OAuth grant journal record is invalid: ${path}`);
66
+ }
67
+ return parsed.data;
68
+ }
69
+
70
+ export class GrantJournal {
71
+ constructor({ rootDir = defaultGrantJournalDir(), logger } = {}) {
72
+ this.rootDir = rootDir ? resolve(rootDir) : null;
73
+ this.logger = logger;
74
+ }
75
+
76
+ get enabled() {
77
+ return this.rootDir !== null;
78
+ }
79
+
80
+ async ensureRoot() {
81
+ if (!this.rootDir) return;
82
+ await mkdir(this.rootDir, { recursive: true, mode: 0o700 });
83
+ await chmod(this.rootDir, 0o700);
84
+ }
85
+
86
+ recordPath(grantKey) {
87
+ return join(this.rootDir, `${fileId(grantKey)}.json`);
88
+ }
89
+
90
+ lockPath(grantKey) {
91
+ return join(this.rootDir, `${fileId(grantKey)}.lock`);
92
+ }
93
+
94
+ async get(grantKey) {
95
+ if (!this.rootDir) return undefined;
96
+ try {
97
+ return parseDocument(await readFile(this.recordPath(grantKey), 'utf8'), this.recordPath(grantKey));
98
+ } catch (error) {
99
+ if (error?.code === 'ENOENT') return undefined;
100
+ this.logger?.warn?.(`skip unreadable OAuth grant journal record ${fileId(grantKey)}: ${error.message}`);
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ async entries() {
106
+ if (!this.rootDir) return [];
107
+ await this.ensureRoot();
108
+ const names = await readdir(this.rootDir).catch((error) => {
109
+ if (error?.code === 'ENOENT') return [];
110
+ throw error;
111
+ });
112
+ const out = [];
113
+ for (const name of names.filter((item) => item.endsWith('.json'))) {
114
+ const path = join(this.rootDir, name);
115
+ try {
116
+ const grant = parseDocument(await readFile(path, 'utf8'), path);
117
+ out.push([grant.key, grant]);
118
+ } catch (error) {
119
+ this.logger?.warn?.(`skip unreadable OAuth grant journal record ${name}: ${error.message}`);
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+
125
+ async put(grant) {
126
+ if (!this.rootDir) return grant;
127
+ await this.ensureRoot();
128
+ const parsed = grantRecordSchema.parse(grant);
129
+ await writeAtomic(this.recordPath(parsed.key), { version: JOURNAL_VERSION, grant: parsed });
130
+ return parsed;
131
+ }
132
+
133
+ async delete(grantKey) {
134
+ if (!this.rootDir) return false;
135
+ try {
136
+ await rm(this.recordPath(grantKey));
137
+ return true;
138
+ } catch (error) {
139
+ if (error?.code === 'ENOENT') return false;
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ async withLock(grantKey, task, {
145
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
146
+ staleLockMs = DEFAULT_STALE_LOCK_MS,
147
+ } = {}) {
148
+ if (!this.rootDir) return task();
149
+ await this.ensureRoot();
150
+ const path = this.lockPath(grantKey);
151
+ const startedAt = Date.now();
152
+ const owner = randomUUID();
153
+ let handle;
154
+ for (;;) {
155
+ try {
156
+ handle = await open(path, 'wx', 0o600);
157
+ try {
158
+ await handle.writeFile(JSON.stringify({ owner, pid: process.pid, createdAt: Date.now() }), 'utf8');
159
+ await handle.sync();
160
+ } catch (error) {
161
+ await handle.close().catch(() => {});
162
+ await rm(path, { force: true }).catch(() => {});
163
+ throw error;
164
+ }
165
+ break;
166
+ } catch (error) {
167
+ if (error?.code !== 'EEXIST') throw error;
168
+ const lockStat = await stat(path).catch(() => null);
169
+ if (lockStat && Date.now() - lockStat.mtimeMs > staleLockMs) {
170
+ await rm(path, { force: true });
171
+ continue;
172
+ }
173
+ if (Date.now() - startedAt >= timeoutMs) {
174
+ const timeout = new Error('timed out waiting for another DSH process to finish OAuth refresh');
175
+ timeout.code = 'refresh_lock_timeout';
176
+ throw timeout;
177
+ }
178
+ await sleep(100);
179
+ }
180
+ }
181
+ try {
182
+ return await task();
183
+ } finally {
184
+ await handle?.close().catch(() => {});
185
+ // 避免极端超时下旧持有者删掉已被新进程接管的锁(ABA)。
186
+ const current = await readFile(path, 'utf8').catch(() => '');
187
+ if (current.includes(`"owner":"${owner}"`)) {
188
+ await rm(path, { force: true }).catch(() => {});
189
+ }
190
+ }
191
+ }
192
+ }
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * 能力:
5
5
  * - 连接器目录(内置 + 远程 registry + config 注入 + 本地上下架覆盖)
6
6
  * - 三通道连接:通用 OAuth(PKCE) / 自定义配置 / 粘贴 JSON / URL 安装
7
- * - 连接记录与 OAuth grant 持久化(ctx.storageDomain),重启恢复
7
+ * - 连接记录与 OAuth grant 持久化,重启恢复与跨 Host Token 轮换
8
8
  * - 通过 ctx.loader 动态配置 @deepseek-ai/dsh-mcp-client 条目
9
9
  * - 对话工具:mcp_connector_catalog / connect / configure / import_json /
10
10
  * install_from_url / status / set_enabled / disconnect / refresh_catalog / publish
@@ -95,7 +95,12 @@ export async function apply(ctx, config) {
95
95
  });
96
96
 
97
97
  const connectionStore = new ConnectionStore(domain);
98
- const grantStore = new GrantStore(domain);
98
+ const grantStore = new GrantStore(domain, {
99
+ // 只有真实持久化模式才启用跨进程 Grant journal。测试可通过内部字段
100
+ // 传入临时目录,不让 fake storage 触碰用户真实 $DSH_HOME。
101
+ journalDir: config.persistSecrets ? config.__grantJournalDir : null,
102
+ logger,
103
+ });
99
104
  const catalogStore = new CatalogStore(domain);
100
105
 
101
106
  const state = {
@@ -380,12 +385,67 @@ export async function apply(ctx, config) {
380
385
  }
381
386
 
382
387
  async function refreshGrantWithRecovery(grantKey, phase) {
388
+ const observed = state.grants.get(grantKey)?.grant;
383
389
  try {
384
- return await refreshGrant(grantKey);
390
+ return await grantStore.withRefreshLock(grantKey, async () => {
391
+ // 锁内必须从独立 journal 重读,不能依赖 DSH domain 的进程内缓存。
392
+ // 如果另一 Host 已完成轮换,直接采用新 Grant,避免再次消耗
393
+ // 一次性 Refresh Token。
394
+ const persisted = await grantStore.get(grantKey).catch(() => undefined);
395
+ const entry = state.grants.get(grantKey);
396
+ if (entry && persisted
397
+ && persisted.updatedAt > entry.grant.updatedAt
398
+ && persisted.refreshToken !== entry.grant.refreshToken) {
399
+ entry.grant = persisted;
400
+ entry.needsReauth = false;
401
+ entry.refreshFailureKind = null;
402
+ entry.lastRefreshError = null;
403
+ logger.info(`OAuth grant ${grantKey} adopted a newer cross-process journal token`);
404
+ if (persisted.accessTokenExpiresAt > Date.now()) {
405
+ scheduleRefresh(grantKey);
406
+ return persisted;
407
+ }
408
+ }
409
+ return refreshGrant(grantKey);
410
+ }, { timeoutMs: Math.max(30_000, config.requestTimeoutMs * 2) });
385
411
  } catch (error) {
412
+ let finalError = error;
413
+ const initialFailure = classifyRefreshFailure(error);
414
+ // Refresh tokens are commonly rotated. If another DSH process refreshed
415
+ // the same persisted grant first, this process still holds the old token
416
+ // and receives invalid_grant. Re-read storage before declaring the user
417
+ // logged out; a newer token is authoritative and can be retried once.
418
+ if (config.persistSecrets && observed
419
+ && ['invalid_grant', 'invalid_token'].includes(initialFailure.code)) {
420
+ for (const delayMs of [0, 100, 250]) {
421
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
422
+ const persisted = await grantStore.get(grantKey).catch(() => undefined);
423
+ const entry = state.grants.get(grantKey);
424
+ if (!entry || !persisted
425
+ || persisted.updatedAt <= observed.updatedAt
426
+ || persisted.refreshToken === observed.refreshToken) continue;
427
+ entry.grant = persisted;
428
+ entry.needsReauth = false;
429
+ entry.refreshFailureKind = null;
430
+ entry.lastRefreshError = null;
431
+ logger.info(`OAuth grant ${grantKey} adopted a newer persisted token after a concurrent rotation`);
432
+ try {
433
+ if (persisted.accessTokenExpiresAt > Date.now()) {
434
+ scheduleRefresh(grantKey);
435
+ return persisted;
436
+ }
437
+ return await grantStore.withRefreshLock(grantKey, () => refreshGrant(grantKey), {
438
+ timeoutMs: Math.max(30_000, config.requestTimeoutMs * 2),
439
+ });
440
+ } catch (retryError) {
441
+ finalError = retryError;
442
+ }
443
+ break;
444
+ }
445
+ }
386
446
  // 多个调用方可能等待同一个 refreshPromise;同一次失败只能记一次并调度一个重试。
387
- if (!error.refreshFailure) error.refreshFailure = recordRefreshFailure(grantKey, error, phase);
388
- throw error;
447
+ if (!finalError.refreshFailure) finalError.refreshFailure = recordRefreshFailure(grantKey, finalError, phase);
448
+ throw finalError;
389
449
  }
390
450
  }
391
451
 
@@ -858,6 +918,86 @@ export async function apply(ctx, config) {
858
918
  }
859
919
  }
860
920
 
921
+ /**
922
+ * v0.2.22 及更早版本会让四张企查查卡片分别保存动态客户端和 Grant。
923
+ * 新版升级时把同 issuer、同资源域且授权范围完整的历史记录归并为一组,
924
+ * 这样启动只刷新一个 refresh token,之后任意一张卡片重新授权也会修复整组。
925
+ */
926
+ async function consolidatePersistedSharedGrants() {
927
+ const groups = new Map();
928
+ for (const record of state.connections.values()) {
929
+ if (record.auth?.mode !== 'oauth' || !record.auth.grantKey) continue;
930
+ const connector = state.merged.find((item) => item.id === record.connectorId);
931
+ if (connector?.auth?.grantSharing !== 'issuer') continue;
932
+ const groupKey = oauthSharingKey(connector);
933
+ const group = groups.get(groupKey) ?? { records: [], grantKeys: new Set() };
934
+ group.records.push(record);
935
+ group.grantKeys.add(record.auth.grantKey);
936
+ groups.set(groupKey, group);
937
+ }
938
+
939
+ let consolidated = 0;
940
+ for (const group of groups.values()) {
941
+ if (group.grantKeys.size <= 1) continue;
942
+ const requiredResources = new Set(group.records.map((record) => record.url).filter(Boolean));
943
+ const candidates = [...group.grantKeys]
944
+ .map((key) => [key, state.grants.get(key)])
945
+ .filter(([, entry]) => entry
946
+ && [...requiredResources].every((resource) => entry.grant.authorizedResources.includes(resource)))
947
+ .sort((left, right) => (
948
+ (right[1].grant.updatedAt ?? 0) - (left[1].grant.updatedAt ?? 0)
949
+ || (right[1].grant.accessTokenExpiresAt ?? 0) - (left[1].grant.accessTokenExpiresAt ?? 0)
950
+ ));
951
+ if (candidates.length === 0) continue;
952
+
953
+ // Do not retire three recoverable historical grants in favour of a newer
954
+ // but already-revoked refresh token. Validate expired candidates in
955
+ // recency order and only consolidate after one grant is demonstrably
956
+ // usable. If none is usable, preserve every grant so one subsequent
957
+ // authorization can replace the group without destroying recovery data.
958
+ let winnerCandidate;
959
+ for (const candidate of candidates) {
960
+ const [candidateKey, candidateEntry] = candidate;
961
+ if (candidateEntry.needsReauth) continue;
962
+ if (candidateEntry.grant.accessTokenExpiresAt <= Date.now()) {
963
+ try {
964
+ await refreshGrantWithRecovery(candidateKey, 'startup-consolidation');
965
+ } catch {
966
+ continue;
967
+ }
968
+ }
969
+ winnerCandidate = candidate;
970
+ break;
971
+ }
972
+ if (winnerCandidate === undefined) {
973
+ logger.warn(`preserved ${group.grantKeys.size} historical issuer-shared OAuth grants because none could be refreshed`);
974
+ continue;
975
+ }
976
+
977
+ const [winnerKey, winnerEntry] = winnerCandidate;
978
+ const connectorIds = [...new Set([
979
+ ...(winnerEntry.grant.connectorIds ?? []),
980
+ ...group.records.map((record) => record.connectorId),
981
+ ])];
982
+ const winner = { ...winnerEntry.grant, connectorIds, updatedAt: Date.now() };
983
+ winnerEntry.grant = winner;
984
+ if (config.persistSecrets) await grantStore.put(winner);
985
+
986
+ for (const record of group.records) {
987
+ if (record.auth.grantKey === winnerKey) continue;
988
+ const next = { ...record, auth: { ...record.auth, grantKey: winnerKey }, updatedAt: Date.now() };
989
+ const persisted = config.persistSecrets ? await connectionStore.put(next) : next;
990
+ state.connections.set(record.key, persisted);
991
+ }
992
+ for (const losingKey of group.grantKeys) {
993
+ if (losingKey !== winnerKey) await retireGrantIfUnused(losingKey);
994
+ }
995
+ consolidated += group.grantKeys.size - 1;
996
+ logger.info(`consolidated ${group.grantKeys.size} issuer-shared OAuth grants into ${winnerKey}`);
997
+ }
998
+ return consolidated;
999
+ }
1000
+
861
1001
  /* ───────────────────────── api 门面 ───────────────────────── */
862
1002
 
863
1003
  const api = {
@@ -1612,6 +1752,9 @@ export async function apply(ctx, config) {
1612
1752
  }
1613
1753
  }
1614
1754
 
1755
+ const consolidatedGrantCount = await consolidatePersistedSharedGrants();
1756
+ if (consolidatedGrantCount > 0) logger.info(`consolidated ${consolidatedGrantCount} historical OAuth grant(s) during startup`);
1757
+
1615
1758
  const prunedGrantCount = await pruneOrphanGrants();
1616
1759
  if (prunedGrantCount > 0) logger.info(`removed ${prunedGrantCount} unreferenced OAuth grant(s) from local storage`);
1617
1760
 
package/lib/stores.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * 存储域封装:一个 domain 三张表(connections / grants / catalog)。
3
- * 落盘 ~/.dsh/storages/(0700),凭证不进入 loader 配置树以外的任何地方。
3
+ * OAuth grant 同步写入本机受限权限 journal,防止多 Host 的整文件
4
+ * last-write-wins 覆盖新轮换 Token;凭证不进入 loader 配置树或日志。
4
5
  */
5
6
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
6
7
  import { connectionRecordSchema, grantRecordSchema, catalogRecordSchema } from './schema.js';
7
8
  import { DEFAULT_ACCOUNT } from './constants.js';
8
9
  import { shortHash } from './util.js';
10
+ import { GrantJournal } from './grant-journal.js';
9
11
 
10
12
  export function defineConnectorDomain() {
11
13
  return defineDomain({
@@ -49,9 +51,10 @@ export class ConnectionStore {
49
51
 
50
52
  /** OAuth 授权表(通用多 issuer / 多账号) */
51
53
  export class GrantStore {
52
- constructor(domain) {
54
+ constructor(domain, { journalDir, journal, logger } = {}) {
53
55
  this.table = domain.tables.get('grants');
54
56
  if (!this.table) throw new Error('mcp-connector: domain table "grants" not found');
57
+ this.journal = journal ?? new GrantJournal({ rootDir: journalDir, logger });
55
58
  }
56
59
 
57
60
  keyFor(account, issuer, clientId, scope) {
@@ -59,23 +62,42 @@ export class GrantStore {
59
62
  }
60
63
 
61
64
  async get(key) {
62
- return this.table.get(key);
65
+ const legacy = await this.table.get(key);
66
+ const journal = await this.journal.get(key);
67
+ if (!legacy) return journal;
68
+ if (!journal) return legacy;
69
+ return journal.updatedAt >= legacy.updatedAt ? journal : legacy;
63
70
  }
64
71
 
65
72
  async put(grant) {
66
73
  const next = { ...grant, updatedAt: Date.now() };
74
+ // Journal 先落盘:即使 DSH JSON domain 被另一进程的旧内存整文件覆盖,
75
+ // 下次刷新仍能从独立记录取回最新的轮换 Token。
76
+ await this.journal.put(next);
67
77
  await this.table.put(grant.key, next);
68
78
  return next;
69
79
  }
70
80
 
71
81
  async delete(key) {
72
- return this.table.delete(key);
82
+ const [journalDeleted, tableDeleted] = await Promise.all([
83
+ this.journal.delete(key),
84
+ this.table.delete(key),
85
+ ]);
86
+ return journalDeleted || tableDeleted;
73
87
  }
74
88
 
75
89
  async entries() {
76
- const out = [];
77
- for (const [key, value] of this.table.entries()) out.push([key, value]);
78
- return out;
90
+ const merged = new Map();
91
+ for (const [key, value] of this.table.entries()) merged.set(key, value);
92
+ for (const [key, value] of await this.journal.entries()) {
93
+ const current = merged.get(key);
94
+ if (!current || value.updatedAt >= current.updatedAt) merged.set(key, value);
95
+ }
96
+ return [...merged.entries()];
97
+ }
98
+
99
+ async withRefreshLock(key, task, options) {
100
+ return this.journal.withLock(key, task, options);
79
101
  }
80
102
  }
81
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-connector",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "description": "General-purpose MCP connector, connection manager, plugin extension, and integration marketplace for DeepSeek Harness, initiated and maintained by Qichacha/QCC.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -34,7 +34,7 @@
34
34
  ],
35
35
  "scripts": {
36
36
  "test": "node --test",
37
- "lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/version-status.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/oauth.js && node --check lib/grant-lifecycle.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/sync-registry-stats.mjs",
37
+ "lint": "node --check lib/index.js && node --check lib/web.js && node --check lib/client.js && node --check lib/version-status.js && node --check lib/schema.js && node --check lib/catalog.js && node --check lib/prompts.js && node --check lib/probe.js && node --check lib/mcp-validation.js && node --check lib/mcp-http.js && node --check lib/migration.js && node --check lib/stores.js && node --check lib/grant-journal.js && node --check lib/oauth.js && node --check lib/grant-lifecycle.js && node --check lib/callback-server.js && node --check lib/mcp-provision.js && node --check lib/util.js && node --check lib/constants.js && node --check lib/tools.js && node --check lib/connectors/oauth-connector.js && node --check lib/connectors/manual-connector.js && node --check lib/connectors/json-connector.js && node --check scripts/probe-connector.mjs && node --check scripts/build-registry.mjs && node --check scripts/ui-harness.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/sync-registry-stats.mjs",
38
38
  "registry:build": "node scripts/build-registry.mjs",
39
39
  "registry:validate": "node scripts/probe-connector.mjs catalog/catalog.json --validate-only",
40
40
  "dev:ui": "node scripts/ui-harness.mjs",