dsh-mcp-connector 0.2.25 → 0.2.26

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,18 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.26] - 2026-08-27
8
+
9
+ ### Fixed
10
+
11
+ - 升级启动时自动发现并归并历史版本为同 issuer 卡片保存的多份 OAuth Grant;归并前先验证过期 Grant 是否仍可刷新,避免用较新但已失效的凭据覆盖可恢复授权。
12
+ - 多个 DSH 进程并发刷新同一组轮换式 Refresh Token 时,收到 `invalid_grant` 后会短暂重读本机持久化 Grant;发现其他进程已保存新 Token 时自动采用并重试,不再误报“需重新授权”。
13
+ - 页面不再盲信 Update Provider 的 `succeeded`;独立校验更新前、预期与实际版本,检测降级、解析版本偏差或无效结果时拒绝重启,并在 Provider 保留恢复点时自动回滚。
14
+
15
+ ### Verification
16
+
17
+ - 129 项自动测试、lint、npm 发布包白名单与敏感内容扫描通过;新增历史 Grant 安全归并、失效候选回退、跨进程 Token 轮换恢复和 Provider 版本完整性回归测试。
18
+
7
19
  ## [0.2.25] - 2026-08-27
8
20
 
9
21
  ### Added
@@ -414,7 +426,8 @@
414
426
  - 外部 URL 与导入 Header 执行安全校验。
415
427
  - iframe 消息校验同源和消息来源。
416
428
 
417
- [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.25...HEAD
429
+ [Unreleased]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.26...HEAD
430
+ [0.2.26]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.25...v0.2.26
418
431
  [0.2.25]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.24...v0.2.25
419
432
  [0.2.24]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.23...v0.2.24
420
433
  [0.2.23]: https://github.com/duhu2000/dsh-mcp-connector/compare/v0.2.22...v0.2.23
@@ -21,10 +21,11 @@ DSH Market 适配器从 `GET /dsh-market/api/v1/capabilities` 开始,要求 `s
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
 
@@ -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;多个 DSH 进程并发轮换 Refresh Token 时也会重读较新的本机持久化凭据后自动恢复。网络、OAuth 元数据发现或服务端 5xx 等暂时故障只进入“自动重试中”,确认本机也没有其他进程保存的新 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,6 +373,71 @@ 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
443
  * slot 和可访问文本打开插件市场,不依赖构建生成的 CSS 类名。
@@ -451,6 +519,7 @@ window.__ModuleLoader__.load({
451
519
  const [updateProviderState, setUpdateProviderState] = (0, react.useState)("checking");
452
520
  const [updateProviderSelection, setUpdateProviderSelection] = (0, react.useState)(null);
453
521
  const [providerUpdateCheck, setProviderUpdateCheck] = (0, react.useState)(null);
522
+ const [expectedUpdateVersion, setExpectedUpdateVersion] = (0, react.useState)(null);
454
523
  const [updateOperation, setUpdateOperation] = (0, react.useState)(null);
455
524
  const [updateError, setUpdateError] = (0, react.useState)(null);
456
525
  const [updateStarting, setUpdateStarting] = (0, react.useState)(false);
@@ -564,6 +633,34 @@ window.__ModuleLoader__.load({
564
633
  provider.operation(capabilities, operationId).then((operation) => {
565
634
  if (disposed) return;
566
635
  setUpdateError(null);
636
+ const integrityFailure = completedUpdateIntegrityFailure(operation, expectedUpdateVersion);
637
+ if (integrityFailure !== null) {
638
+ const failedOperation = { ...operation, state: "failed", failure: integrityFailure };
639
+ setUpdateOperation(failedOperation);
640
+ const canRollback = capabilities.features?.rollback === true
641
+ && operation.outcome?.rollback?.available === true
642
+ && capabilities.endpoints.rollback !== null;
643
+ if (!canRollback) return;
644
+ setRollbackRunning(true);
645
+ provider.rollback(capabilities, operationId).then((rolledBackOperation) => {
646
+ if (disposed) return;
647
+ setRollbackRunning(false);
648
+ setUpdateOperation({ ...rolledBackOperation, integrityFailure });
649
+ }, (error) => {
650
+ console.error("[mcp-connector] automatic rollback failed:", error);
651
+ if (disposed) return;
652
+ const detail = error instanceof Error ? error.message : String(error);
653
+ setRollbackRunning(false);
654
+ setUpdateOperation({
655
+ ...failedOperation,
656
+ outcome: {
657
+ ...failedOperation.outcome,
658
+ rollback: { available: true, state: "failed", detail }
659
+ }
660
+ });
661
+ });
662
+ return;
663
+ }
567
664
  setUpdateOperation(operation);
568
665
  if (["queued", "running"].includes(operation.state)) {
569
666
  timer = window.setTimeout(poll, UPDATE_PROVIDER_OPERATION_POLL_MS);
@@ -584,7 +681,7 @@ window.__ModuleLoader__.load({
584
681
  disposed = true;
585
682
  window.clearTimeout(timer);
586
683
  };
587
- }, [open, updateOperation?.operationId, updateProviderSelection?.provider?.id]);
684
+ }, [open, updateOperation?.operationId, updateProviderSelection?.provider?.id, expectedUpdateVersion]);
588
685
  (0, react.useEffect)(() => {
589
686
  if (!open) return void 0;
590
687
  closeRef.current?.focus?.();
@@ -600,6 +697,7 @@ window.__ModuleLoader__.load({
600
697
  if (updateRunning || provider === void 0 || capabilities === void 0) return;
601
698
  setUpdateError(null);
602
699
  setUpdateStarting(true);
700
+ setExpectedUpdateVersion(providerUpdateCheck?.latestVersion ?? versionStatus?.latestVersion ?? null);
603
701
  provider.start(capabilities, PLUGIN_PACKAGE_NAME, force).then((operation) => {
604
702
  setUpdateStarting(false);
605
703
  setUpdateOperation(operation);
@@ -687,6 +785,10 @@ window.__ModuleLoader__.load({
687
785
  onClick: () => startUpdate(force),
688
786
  children: force ? "强制重试" : "重试更新"
689
787
  }),
788
+ rollbackRunning && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
789
+ className: "mcpConnectorUpdateStatus",
790
+ children: "检测到异常版本,正在自动回滚…"
791
+ }),
690
792
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
691
793
  type: "button",
692
794
  className: "mcpConnectorUpdateButton mcpConnectorUpdateSecondary",
@@ -707,7 +809,7 @@ window.__ModuleLoader__.load({
707
809
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
708
810
  className: "mcpConnectorUpdateStatus",
709
811
  children: rolledBack
710
- ? "已回滚"
812
+ ? updateOperation.integrityFailure === void 0 ? "已回滚" : "已阻止异常版本并回滚"
711
813
  : `已安装 v${updateOperation.installedVersion ?? versionStatus?.latestVersion}`
712
814
  }),
713
815
  rollbackFailed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
package/lib/index.js CHANGED
@@ -380,12 +380,41 @@ export async function apply(ctx, config) {
380
380
  }
381
381
 
382
382
  async function refreshGrantWithRecovery(grantKey, phase) {
383
+ const observed = state.grants.get(grantKey)?.grant;
383
384
  try {
384
385
  return await refreshGrant(grantKey);
385
386
  } catch (error) {
387
+ let finalError = error;
388
+ const initialFailure = classifyRefreshFailure(error);
389
+ // Refresh tokens are commonly rotated. If another DSH process refreshed
390
+ // the same persisted grant first, this process still holds the old token
391
+ // and receives invalid_grant. Re-read storage before declaring the user
392
+ // logged out; a newer token is authoritative and can be retried once.
393
+ if (config.persistSecrets && observed
394
+ && ['invalid_grant', 'invalid_token'].includes(initialFailure.code)) {
395
+ for (const delayMs of [0, 100, 250]) {
396
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
397
+ const persisted = await grantStore.get(grantKey).catch(() => undefined);
398
+ const entry = state.grants.get(grantKey);
399
+ if (!entry || !persisted
400
+ || persisted.updatedAt <= observed.updatedAt
401
+ || persisted.refreshToken === observed.refreshToken) continue;
402
+ entry.grant = persisted;
403
+ entry.needsReauth = false;
404
+ entry.refreshFailureKind = null;
405
+ entry.lastRefreshError = null;
406
+ logger.info(`OAuth grant ${grantKey} adopted a newer persisted token after a concurrent rotation`);
407
+ try {
408
+ return await refreshGrant(grantKey);
409
+ } catch (retryError) {
410
+ finalError = retryError;
411
+ }
412
+ break;
413
+ }
414
+ }
386
415
  // 多个调用方可能等待同一个 refreshPromise;同一次失败只能记一次并调度一个重试。
387
- if (!error.refreshFailure) error.refreshFailure = recordRefreshFailure(grantKey, error, phase);
388
- throw error;
416
+ if (!finalError.refreshFailure) finalError.refreshFailure = recordRefreshFailure(grantKey, finalError, phase);
417
+ throw finalError;
389
418
  }
390
419
  }
391
420
 
@@ -858,6 +887,86 @@ export async function apply(ctx, config) {
858
887
  }
859
888
  }
860
889
 
890
+ /**
891
+ * v0.2.22 及更早版本会让四张企查查卡片分别保存动态客户端和 Grant。
892
+ * 新版升级时把同 issuer、同资源域且授权范围完整的历史记录归并为一组,
893
+ * 这样启动只刷新一个 refresh token,之后任意一张卡片重新授权也会修复整组。
894
+ */
895
+ async function consolidatePersistedSharedGrants() {
896
+ const groups = new Map();
897
+ for (const record of state.connections.values()) {
898
+ if (record.auth?.mode !== 'oauth' || !record.auth.grantKey) continue;
899
+ const connector = state.merged.find((item) => item.id === record.connectorId);
900
+ if (connector?.auth?.grantSharing !== 'issuer') continue;
901
+ const groupKey = oauthSharingKey(connector);
902
+ const group = groups.get(groupKey) ?? { records: [], grantKeys: new Set() };
903
+ group.records.push(record);
904
+ group.grantKeys.add(record.auth.grantKey);
905
+ groups.set(groupKey, group);
906
+ }
907
+
908
+ let consolidated = 0;
909
+ for (const group of groups.values()) {
910
+ if (group.grantKeys.size <= 1) continue;
911
+ const requiredResources = new Set(group.records.map((record) => record.url).filter(Boolean));
912
+ const candidates = [...group.grantKeys]
913
+ .map((key) => [key, state.grants.get(key)])
914
+ .filter(([, entry]) => entry
915
+ && [...requiredResources].every((resource) => entry.grant.authorizedResources.includes(resource)))
916
+ .sort((left, right) => (
917
+ (right[1].grant.updatedAt ?? 0) - (left[1].grant.updatedAt ?? 0)
918
+ || (right[1].grant.accessTokenExpiresAt ?? 0) - (left[1].grant.accessTokenExpiresAt ?? 0)
919
+ ));
920
+ if (candidates.length === 0) continue;
921
+
922
+ // Do not retire three recoverable historical grants in favour of a newer
923
+ // but already-revoked refresh token. Validate expired candidates in
924
+ // recency order and only consolidate after one grant is demonstrably
925
+ // usable. If none is usable, preserve every grant so one subsequent
926
+ // authorization can replace the group without destroying recovery data.
927
+ let winnerCandidate;
928
+ for (const candidate of candidates) {
929
+ const [candidateKey, candidateEntry] = candidate;
930
+ if (candidateEntry.needsReauth) continue;
931
+ if (candidateEntry.grant.accessTokenExpiresAt <= Date.now()) {
932
+ try {
933
+ await refreshGrantWithRecovery(candidateKey, 'startup-consolidation');
934
+ } catch {
935
+ continue;
936
+ }
937
+ }
938
+ winnerCandidate = candidate;
939
+ break;
940
+ }
941
+ if (winnerCandidate === undefined) {
942
+ logger.warn(`preserved ${group.grantKeys.size} historical issuer-shared OAuth grants because none could be refreshed`);
943
+ continue;
944
+ }
945
+
946
+ const [winnerKey, winnerEntry] = winnerCandidate;
947
+ const connectorIds = [...new Set([
948
+ ...(winnerEntry.grant.connectorIds ?? []),
949
+ ...group.records.map((record) => record.connectorId),
950
+ ])];
951
+ const winner = { ...winnerEntry.grant, connectorIds, updatedAt: Date.now() };
952
+ winnerEntry.grant = winner;
953
+ if (config.persistSecrets) await grantStore.put(winner);
954
+
955
+ for (const record of group.records) {
956
+ if (record.auth.grantKey === winnerKey) continue;
957
+ const next = { ...record, auth: { ...record.auth, grantKey: winnerKey }, updatedAt: Date.now() };
958
+ const persisted = config.persistSecrets ? await connectionStore.put(next) : next;
959
+ state.connections.set(record.key, persisted);
960
+ }
961
+ for (const losingKey of group.grantKeys) {
962
+ if (losingKey !== winnerKey) await retireGrantIfUnused(losingKey);
963
+ }
964
+ consolidated += group.grantKeys.size - 1;
965
+ logger.info(`consolidated ${group.grantKeys.size} issuer-shared OAuth grants into ${winnerKey}`);
966
+ }
967
+ return consolidated;
968
+ }
969
+
861
970
  /* ───────────────────────── api 门面 ───────────────────────── */
862
971
 
863
972
  const api = {
@@ -1612,6 +1721,9 @@ export async function apply(ctx, config) {
1612
1721
  }
1613
1722
  }
1614
1723
 
1724
+ const consolidatedGrantCount = await consolidatePersistedSharedGrants();
1725
+ if (consolidatedGrantCount > 0) logger.info(`consolidated ${consolidatedGrantCount} historical OAuth grant(s) during startup`);
1726
+
1615
1727
  const prunedGrantCount = await pruneOrphanGrants();
1616
1728
  if (prunedGrantCount > 0) logger.info(`removed ${prunedGrantCount} unreferenced OAuth grant(s) from local storage`);
1617
1729
 
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.26",
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",