dsh-armory 2.3.1 → 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.
package/README.en.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **The all-in-one control board for DeepSeek Harness**: MCP servers · skills · experts · plugins · tool-usage ledger · four markets — browse, search, install and summon in one place.
6
6
  > Forked from [Imzl-zl/dsh-mcp-manager-ui](https://github.com/Imzl-zl/dsh-mcp-manager-ui) (MIT) and extended into a full armory.
7
7
  >
8
- > **New in v2.3.1**: fixed two dead ends in the market caches (a 304 crash loop on corrupted caches, discarding a good disk cache when offline), network data is now validated and written atomically, and the expert persona path follows the marketplace manifest. See the [changelog](.github/release-notes/v2.3.1.md).
8
+ > **New in v2.3.2**: fixed a whole-panel crash when installing an OAuth connector (a string style in the authorization dialog triggered React error #62); a failed browser open is now retried automatically. See the [changelog](.github/release-notes/v2.3.2.md).
9
9
 
10
10
  <p align="center">
11
11
  <a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **DeepSeek Harness 的一体化管理看板**:MCP · 技能 · 专家 · 插件 · 工具账本 · 四大市场,浏览、搜索、一键安装、一键召唤。
6
6
  > fork 自 [Imzl-zl/dsh-mcp-manager-ui](https://github.com/Imzl-zl/dsh-mcp-manager-ui)(MIT),现已扩展为完整军械库。
7
7
  >
8
- > **最新 v2.3.1**:修复市场缓存的两条死路(缓存损坏遇 304 崩溃循环、断网丢弃完好磁盘缓存),网络数据先校验再原子落盘,专家人设路径以市场清单为准。详见 [更新日志](.github/release-notes/v2.3.1.md)。
8
+ > **最新 v2.3.2**:修复安装需 OAuth 连接器时的整面板崩溃(授权弹窗 style 字符串触发 React #62),浏览器打开失败会自动重试唤起。详见 [更新日志](.github/release-notes/v2.3.2.md)。
9
9
 
10
10
  <p align="center">
11
11
  <a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
package/lib/client.js CHANGED
@@ -372,6 +372,7 @@ const EN_US_MESSAGES = {
372
372
  "安装": "Install",
373
373
  "安装 = 逐文件下载并 sha256 校验后写入 ~/.agents/skills/,即刻被 DSH 发现(无需重启)。已装版本重装即更新。": "Install = download file by file with sha256 verification into ~/.agents/skills/; DSH picks it up immediately (no restart). Reinstalling an installed version updates it.",
374
374
  "安装中…": "Installing…",
375
+ "该技能正在安装中,请等当前任务结束": "This skill is already installing; wait for the current task to finish.",
375
376
  "安装到全局": "Install globally",
376
377
  "安装失败": "Install failed",
377
378
  "安装任务已结束": "Install finished",
@@ -935,6 +936,10 @@ const EN_US_MESSAGES = {
935
936
  "正在启动授权…": "Starting authorization…",
936
937
  "无法启动授权流程": "Unable to start the authorization flow",
937
938
  "没能自动唤起浏览器,请复制下方链接手动打开。": "The browser could not be opened automatically — copy the link below and open it manually.",
939
+ // 服务器不做 OAuth 发现(如腾讯 ima)时授权页根本开不出来,这里把手填令牌那条
940
+ // 兜底路的弹层文案补齐——只报一句「启动失败」等于把用户堵死在门外。
941
+ "该服务器无法自动打开授权页。": "This server can't have an authorization page opened automatically.",
942
+ "该服务器没有提供标准 OAuth 授权发现,无法自动打开授权页。如果你已经从服务方拿到访问令牌,粘贴在下面即可安装。": "This server publishes no standard OAuth authorization-server metadata, so an authorization page can't be opened automatically. If you already have an access token from the provider, paste it below to install.",
938
943
  "授权链接": "Authorization link",
939
944
  "重新打开浏览器": "Reopen browser",
940
945
  "该服务器只接受自定义协议回调,浏览器跳转后页面会打不开。请把地址栏里的完整地址复制到下面。": "This server only accepts a custom-scheme callback, so the redirect page will not load. Copy the full address from the browser's address bar into the field below.",
@@ -1924,6 +1929,10 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
1924
1929
  .dsh-mcp-wb-search-input:hover:not(:disabled){border-color:var(--dsw-alias-border-l3)}
1925
1930
  .dsh-mcp-wb-search-input:focus{border-color:var(--mcp-accent);background:var(--mcp-bg-2);box-shadow:0 0 0 3px color-mix(in srgb,var(--mcp-accent) 16%,transparent)}
1926
1931
  .dsh-mcp-wb-search-input:disabled{opacity:.5;cursor:not-allowed}
1932
+ /* busy=服务端搜索在飞:输入框保持可用(disabled 会收走焦点,见 WbSearchBox 注释),
1933
+ 只让放大镜呼吸提示「在加载」,光标与选区都不受影响。 */
1934
+ .dsh-mcp-wb-search.busy .dsh-mcp-wb-search-ico{color:var(--mcp-accent);animation:dsh-wb-search-busy 1.1s ease-in-out infinite}
1935
+ @keyframes dsh-wb-search-busy{0%,100%{opacity:1}50%{opacity:.3}}
1927
1936
  .dsh-mcp-wb-search-input::-webkit-search-cancel-button{display:none}
1928
1937
  .dsh-mcp-wb-search-clear{position:absolute;right:7px;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border:0;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);color:var(--mcp-mut);cursor:pointer;padding:0;transition:color .12s,background .12s}
1929
1938
  .dsh-mcp-wb-search-clear:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-active)}
@@ -7902,7 +7911,7 @@ const ExpertMarket = ({ call }) => {
7902
7911
  return h('div', null,
7903
7912
  h('div', { className: 'dsh-mcp-wb-toolbar' },
7904
7913
  h(WbSearchBox, {
7905
- value: query, placeholder: t('搜索专家(花名 / 职业 / 描述 / 标签)'), disabled: loading,
7914
+ value: query, placeholder: t('搜索专家(花名 / 职业 / 描述 / 标签)'), busy: loading,
7906
7915
  onChange: (value) => { setQuery(value); setPage(1); },
7907
7916
  }),
7908
7917
  h('span', { className: 'dsh-mcp-builtin-count' },
@@ -8098,7 +8107,7 @@ const ExpertMarket = ({ call }) => {
8098
8107
  // stale 团队行:详情弹层里给出直白解释 + 更新入口(同一条召唤链,upsert 整行重写)。
8099
8108
  // 判定与卡片按钮同源(detailActions 在上方按 expertCardActions 算出)。
8100
8109
  detailActions.update
8101
- ? h('p', { className: 'dsh-mcp-wb-modal-note', style: 'color:var(--dsw-alias-state-warn-primary)' },
8110
+ ? h('p', { className: 'dsh-mcp-wb-modal-note', style: { color: 'var(--dsw-alias-state-warn-primary)' } },
8102
8111
  t('⚠ 这条预设是旧版本召唤写入的:人设缺「DSH 运行时适配」桥接段,用它的新会话主理人只会调不存在的 TeamCreate/Agent,成员子智能体开不出来。点下方「更新预设」重跑召唤即可刷新。'))
8103
8112
  : null,
8104
8113
  ),
@@ -9850,18 +9859,21 @@ const MarketPulse = ({ state, onJump, onClear }) => {
9850
9859
  // 市场搜索框:左内置放大镜、圆角胶囊、聚焦高亮、右一键清空。
9851
9860
  // 两处复用(连接器 / 插件):两者都是纯本地过滤,输入即过滤;
9852
9861
  // 技能走服务端搜索,onCommit 由防抖或回车触发(见 WorkBuddyMarket 内注释)。
9853
- const WbSearchBox = ({ value, onChange, placeholder, disabled, onCommit }) =>
9854
- h('div', { className: 'dsh-mcp-wb-search' },
9862
+ // busy 只做视觉提示,**绝不 disabled 输入框**:disabled 会让浏览器立刻收走焦点,
9863
+ // 技能搜索防抖生效那一刻(含按 Delete 删字后的停顿)输入框被禁用 → 光标丢失,
9864
+ // 用户得重新点一次才能继续输入。加载态改由放大镜呼吸动画表达(见 .busy CSS)。
9865
+ const WbSearchBox = ({ value, onChange, placeholder, busy, onCommit }) =>
9866
+ h('div', { className: 'dsh-mcp-wb-search' + (busy ? ' busy' : '') },
9855
9867
  h('span', { className: 'dsh-mcp-wb-search-ico', 'aria-hidden': true }, h(Icon, { d: ICONS.search, size: 14 })),
9856
9868
  h('input', {
9857
9869
  type: 'search', className: 'dsh-mcp-wb-search-input', placeholder,
9858
- value, disabled, spellCheck: false,
9870
+ value, spellCheck: false, 'aria-busy': busy || undefined,
9859
9871
  onChange: (e) => onChange(e.target.value),
9860
9872
  onKeyDown: onCommit
9861
9873
  ? (e) => { if (e.key === 'Enter') { e.preventDefault(); onCommit(); } }
9862
9874
  : undefined,
9863
9875
  }),
9864
- value && !disabled
9876
+ value
9865
9877
  ? h('button', {
9866
9878
  type: 'button', className: 'dsh-mcp-wb-search-clear', title: t('清空'),
9867
9879
  'aria-label': t('清空搜索'),
@@ -9935,6 +9947,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9935
9947
  const authDialogRefNode = useRef(null);
9936
9948
  useFocusTrap(authDialogRefNode, !!authDialog);
9937
9949
  const [skillOnlyInstalled, setSkillOnlyInstalled] = useState(false);
9950
+ // 「已安装」集合的键是 namespace/slug 双键,不是 slug 单键:SkillHub 上同 slug 不同
9951
+ // namespace 的技能一大把(find-skills 实测三家在架)。单键会让没装过的同名卡片假显示
9952
+ // 「已安装」,在假卡片上点卸载又必撞「本机没有这个市场技能的安装记录」——用户报的
9953
+ // 「假安装」截图正是这一对症状。wbSkillInstalled 行的 namespace/slug 从目录名解析
9954
+ // (与安装时同一套 sanitize 口径),与卡片两字段天然一一对应。
9955
+ const skillInstalledKey = (row) => row.namespace + '/' + row.slug;
9938
9956
  // OPT-21:两个详情弹层此前都没有 ref(焦点陷阱需要容器节点)。
9939
9957
  const connectorDetailRef = useRef(null);
9940
9958
  const skillDetailRef = useRef(null);
@@ -9944,7 +9962,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
9944
9962
 
9945
9963
  const loadInstalled = useCallback(async () => {
9946
9964
  const res = await call('wbSkillInstalled');
9947
- if (res?.ok) setInstalledSkills(new Set((res.installed || []).map((row) => row.slug)));
9965
+ if (res?.ok) setInstalledSkills(new Set((res.installed || []).map((row) => skillInstalledKey(row))));
9948
9966
  }, [call]);
9949
9967
 
9950
9968
  // T04/R8:安装状态账本(三类安装的统一登记,宿主 ~/.dsh/armory/install-state.json)。
@@ -10130,7 +10148,27 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10130
10148
  setBusy(true); setBusyLabel(t('正在启动授权…'));
10131
10149
  try {
10132
10150
  const res = await call('wbOAuthBegin', { connectorId: item.id, serverName });
10133
- if (!res?.ok) throw new Error(res?.error || t('无法启动授权流程'));
10151
+ if (!res?.ok) {
10152
+ // 授权页这条路走不通(服务器不做 OAuth 发现、动态注册被拒、要求客户端认证)时,
10153
+ // 不能只甩一行红字就把用户关在门外:手填令牌那条兜底路还在,把弹层开出来摊给他。
10154
+ if (res?.manualTokenOnly) {
10155
+ setAuthDialog({
10156
+ item,
10157
+ serverName: res.serverName || serverName || item.name,
10158
+ sessionId: '',
10159
+ authorizeUrl: '',
10160
+ mode: 'manual-token',
10161
+ redirectUri: '',
10162
+ hint: '',
10163
+ status: 'manual',
10164
+ error: res.error || t('该服务器无法自动打开授权页。'),
10165
+ callbackUrl: '',
10166
+ manualToken: '',
10167
+ });
10168
+ return;
10169
+ }
10170
+ throw new Error(res?.error || t('无法启动授权流程'));
10171
+ }
10134
10172
  setAuthDialog({
10135
10173
  item,
10136
10174
  serverName: res.serverName || serverName || item.name,
@@ -10144,6 +10182,16 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10144
10182
  callbackUrl: '',
10145
10183
  manualToken: '',
10146
10184
  });
10185
+ // 宿主第一条打开通路(shell / rundll32)失败时立即补一发 wbOAuthOpen 再试一次:
10186
+ // 多数失败是瞬时抖动,同一 URL 重开往往就成功了;补开成功就撤掉"没能唤起浏览器"
10187
+ // 的提示回到纯等待态,仍失败则弹层里的链接与「重新打开浏览器」按钮还在。
10188
+ if (!res.opened && res.authorizeUrl) {
10189
+ call('wbOAuthOpen', { url: res.authorizeUrl }).then((openRes) => {
10190
+ if (openRes?.opened && authDialogRef.current?.sessionId === res.sessionId) {
10191
+ setAuthDialog((cur) => (cur && cur.sessionId === res.sessionId ? { ...cur, error: '' } : cur));
10192
+ }
10193
+ }).catch(() => {});
10194
+ }
10147
10195
  if (res.sessionId) {
10148
10196
  stopAuthPoll();
10149
10197
  authPollRef.current = setTimeout(() => { pollAuthOnce(res.sessionId); }, 1200);
@@ -10238,7 +10286,16 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10238
10286
  }
10239
10287
  installConnector(item);
10240
10288
  };
10289
+ const skillInstallInFlight = useRef(new Set());
10241
10290
  const installSkill = async (skill) => {
10291
+ // 同一技能的上一次安装还在飞时直接挡回:宿主侧已有按目录的互斥锁兜底串行,
10292
+ // 这里再挡一层是为了不给用户制造「点了两次、两次都报成功」的错觉。
10293
+ const flightKey = skillInstalledKey(skill);
10294
+ if (skillInstallInFlight.current.has(flightKey)) {
10295
+ setNote(t('该技能正在安装中,请等当前任务结束'));
10296
+ return;
10297
+ }
10298
+ skillInstallInFlight.current.add(flightKey);
10242
10299
  setBusy(true); setBusyLabel(t('正在下载并校验 {0}…', [skill.name])); setError('');
10243
10300
  try {
10244
10301
  const res = await call('wbSkillInstall', { slug: skill.slug, namespace: skill.namespace, name: skill.name });
@@ -10253,6 +10310,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10253
10310
  } catch (e) {
10254
10311
  setError(String(e?.message || e));
10255
10312
  } finally {
10313
+ skillInstallInFlight.current.delete(flightKey);
10256
10314
  setBusy(false); setBusyLabel('');
10257
10315
  }
10258
10316
  };
@@ -10543,11 +10601,11 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10543
10601
  };
10544
10602
 
10545
10603
  const connectorSearch = h(WbSearchBox, {
10546
- value: connectorQuery, placeholder: t('搜索连接器(名称 / 说明 / ID)'), disabled: loading,
10604
+ value: connectorQuery, placeholder: t('搜索连接器(名称 / 说明 / ID)'), busy: loading,
10547
10605
  onChange: (value) => { setConnectorQuery(value); setConnectorPage(1); },
10548
10606
  });
10549
10607
  const skillSearch = h(WbSearchBox, {
10550
- value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: skillLoading,
10608
+ value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), busy: skillLoading,
10551
10609
  onChange: (value) => setSkillKeywordInput(value),
10552
10610
  // 回车跳过防抖立即生效(防抖本身也会触发,双保险但幂等)。
10553
10611
  onCommit: () => { setSkillPage(1); setSkillKeyword(skillKeywordInput); },
@@ -10561,9 +10619,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10561
10619
  // 已安装状态只在本地知道,所以这层过滤只作用于本页——计数文案里写清楚,
10562
10620
  // 免得用户以为「只看已安装」能翻遍全库。
10563
10621
  const shownSkills = skillOnlyInstalled
10564
- ? skills.filter((skill) => installedSkills.has(skill.slug))
10622
+ ? skills.filter((skill) => installedSkills.has(skillInstalledKey(skill)))
10565
10623
  : skills;
10566
- const installedOnPage = skills.filter((skill) => installedSkills.has(skill.slug)).length;
10624
+ const installedOnPage = skills.filter((skill) => installedSkills.has(skillInstalledKey(skill))).length;
10567
10625
 
10568
10626
  // §6.1 子页签:原来用与顶栏 `.dsh-mcp-cat` 同款的胶囊按钮,两排长得一样,
10569
10627
  // 用户分不清「换整页」还是「换市场内部子页」。改成下划线式页签 + 面包屑,
@@ -10619,7 +10677,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10619
10677
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
10620
10678
  connectorSearch,
10621
10679
  h('label', { className: 'dsh-mcp-check', title: t('默认只显示可直接安装的条目;取消勾选可看到「仅展示」的不兼容条目') },
10622
- h('input', { type: 'checkbox', checked: connectorOnlyReady, disabled: loading, onChange: (e) => { setConnectorOnlyReady(e.target.checked); setConnectorPage(1); } }),
10680
+ h('input', { type: 'checkbox', checked: connectorOnlyReady, onChange: (e) => { setConnectorOnlyReady(e.target.checked); setConnectorPage(1); } }),
10623
10681
  t('只看可直接安装')),
10624
10682
  // T04/R8:已安装筛选是全量本地筛选(跨页生效),不随分页变化。
10625
10683
  h('label', { className: 'dsh-mcp-check', title: t('在全量 {0} 条目录里筛出已安装的连接器(跨页生效,与技能侧「仅本页」不同)', [connectors.length]) },
@@ -10681,12 +10739,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10681
10739
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
10682
10740
  skillSearch,
10683
10741
  h('select', {
10684
- className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort, disabled: skillLoading,
10742
+ className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort,
10685
10743
  title: t('SkillHub 已不再提供全站下载量排序;这一项只在当前页内按下载量排序。'),
10686
10744
  onChange: (e) => { setSkillSort(e.target.value); setSkillPage(1); },
10687
10745
  }, WB_SKILLHUB_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
10688
10746
  h('label', { className: 'dsh-mcp-check', title: t('仅在本页 {0} 条里筛出已安装的(SkillHub 是远程分页,无法跨页筛选)', [skills.length]) },
10689
- h('input', { type: 'checkbox', checked: skillOnlyInstalled, disabled: skillLoading, onChange: (e) => setSkillOnlyInstalled(e.target.checked) }),
10747
+ h('input', { type: 'checkbox', checked: skillOnlyInstalled, onChange: (e) => setSkillOnlyInstalled(e.target.checked) }),
10690
10748
  t('只看已安装')),
10691
10749
  skillOnlyInstalled
10692
10750
  ? h('span', { className: 'dsh-mcp-builtin-count' }, t('本页已安装 {0} / {1}', [installedOnPage, skills.length]))
@@ -10808,7 +10866,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10808
10866
  tab === 'skill' && shownSkills.length
10809
10867
  ? h('div', { className: 'dsh-mcp-wb-grid' },
10810
10868
  shownSkills.map((skill) => {
10811
- const installed = installedSkills.has(skill.slug);
10869
+ const installed = installedSkills.has(skillInstalledKey(skill));
10812
10870
  return h('div', { key: skill.namespace + '/' + skill.slug, className: 'dsh-mcp-card wb' },
10813
10871
  h('div', { className: 'dsh-mcp-card-top' },
10814
10872
  h('div', { className: 'dsh-mcp-card-person' },
@@ -11042,9 +11100,11 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
11042
11100
  h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
11043
11101
  authDialog.status === 'authorized'
11044
11102
  ? t('{0}:授权成功,正在完成安装…', [authDialog.item?.name || authDialog.serverName])
11045
- : t('该连接器要求在浏览器里完成一次登录授权。授权页会尝试自动打开;授权完成后这里会自动继续,无需手动刷新。')),
11103
+ : authDialog.mode === 'manual-token'
11104
+ ? t('该服务器没有提供标准 OAuth 授权发现,无法自动打开授权页。如果你已经从服务方拿到访问令牌,粘贴在下面即可安装。')
11105
+ : t('该连接器要求在浏览器里完成一次登录授权。授权页会尝试自动打开;授权完成后这里会自动继续,无需手动刷新。')),
11046
11106
  authDialog.error
11047
- ? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'alert', style: 'color:var(--dsw-alias-state-warn-primary)' }, authDialog.error)
11107
+ ? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'alert', style: { color: 'var(--dsw-alias-state-warn-primary)' } }, authDialog.error)
11048
11108
  : null,
11049
11109
  authDialog.authorizeUrl
11050
11110
  ? h('label', { className: 'dsh-mcp-wb-tokenfield' },
@@ -11197,7 +11257,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
11197
11257
  ),
11198
11258
  h('div', { className: 'dsh-mcp-modal-actions' },
11199
11259
  h(Btn, { onClick: () => setSkillDetail(null), disabled: busy, children: t('关闭') }),
11200
- installedSkills.has(skillDetail.slug)
11260
+ installedSkills.has(skillInstalledKey(skillDetail))
11201
11261
  ? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => uninstallSkill(skillDetail), children: t('卸载') })
11202
11262
  : h(Btn, { primary: true, icon: 'plus', disabled: busy || skillDetail.loading, onClick: () => installSkill(skillDetail), children: busy ? t('安装中…') : t('安装') }),
11203
11263
  ),
package/lib/index.js CHANGED
@@ -38,6 +38,8 @@ import {
38
38
  cancelAuthorization as oauthCancel,
39
39
  clearTokenRecord as oauthClearToken,
40
40
  ensureAccessToken as oauthEnsureToken,
41
+ isSerializedGarbageCredential as oauthIsGarbageCredential,
42
+ isUsableBearerToken as oauthIsUsableToken,
41
43
  saveManualToken as oauthSaveManualToken,
42
44
  submitCallback as oauthSubmitCallback,
43
45
  } from "./mcp-oauth.js";
@@ -121,6 +123,24 @@ function withBearerHeader(rawServer, token) {
121
123
  return { ...rawServer, headers };
122
124
  }
123
125
 
126
+ /**
127
+ * 摘掉一个已经坏掉的 authorization 头(保留其它头、保留原键名大小写)。
128
+ * 只给「确认是序列化残骸」的配置用:历史版本把 `Bearer [object Object]` 写进了
129
+ * 用户的 patch,那条头不删掉,服务器就从「你没带凭据」(401) 变成「你凭据是坏的」(400),
130
+ * 装前探测因此再也认不出这是鉴权问题,用户既装不上也看不到授权入口。
131
+ */
132
+ function stripBearerHeader(rawServer) {
133
+ const container = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
134
+ const headers = container?.headers;
135
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) return rawServer;
136
+ const next = {};
137
+ for (const [key, value] of Object.entries(headers)) {
138
+ if (String(key).toLowerCase() !== "authorization") next[key] = value;
139
+ }
140
+ const patched = { ...container, headers: next };
141
+ return rawServer && typeof rawServer.transport === "object" ? { ...rawServer, transport: patched } : patched;
142
+ }
143
+
124
144
  /**
125
145
  * 安装前把已落盘的 access_token 注进对应 server 的 headers。
126
146
  * 读不到 / 过期且刷不动就原样返回,交给 probe 去判定「需要授权」——
@@ -131,17 +151,27 @@ async function injectStoredOAuthTokens(mcpServers) {
131
151
  const out = {};
132
152
  for (const [serverName, rawServer] of Object.entries(mcpServers)) {
133
153
  const spec = nestedServerSpec(rawServer);
134
- if (!isOAuthCandidate(spec) || spec.headers.authorization) {
154
+ // 配置里已有的 authorization 一律优先:可能是用户手填的真令牌,也可能是 R7
155
+ // 的 ${...} 占位符(那种要原样保留、由 missingSlots 回报面板补填)。
156
+ const declared = spec.headers.authorization;
157
+ const poisoned = Boolean(declared) && oauthIsGarbageCredential(declared);
158
+ if (!isOAuthCandidate(spec) || (declared && !poisoned)) {
135
159
  out[serverName] = rawServer;
136
160
  continue;
137
161
  }
138
162
  let token = "";
139
163
  try {
140
- token = String((await oauthEnsureToken(spec.url)) || "");
164
+ // ensureAccessToken 回的是 {ok, accessToken, refreshed, error} 对象,不是字符串。
165
+ // 这里曾经直接对那个对象取字符串:得到 "[object Object]",真值、非空,
166
+ // 于是每个 HTTP 连接器都被注进 `authorization: Bearer [object Object]`
167
+ // —— 装上必挂,还把服务器的 401 伪装成 400(见 test/auth-gate-regression.mjs 第 7 节)。
168
+ const result = await oauthEnsureToken(spec.url);
169
+ if (result?.ok && oauthIsUsableToken(result.accessToken)) token = String(result.accessToken).replace(/^Bearer\s+/i, "").trim();
141
170
  } catch {
142
171
  token = "";
143
172
  }
144
- out[serverName] = token ? withBearerHeader(rawServer, token) : rawServer;
173
+ if (token) out[serverName] = withBearerHeader(rawServer, token);
174
+ else out[serverName] = poisoned ? stripBearerHeader(rawServer) : rawServer;
145
175
  }
146
176
  return out;
147
177
  }
@@ -155,7 +185,12 @@ async function resolveOAuthTarget(connectorId, serverName) {
155
185
  if (!rawServer) throw new Error(`连接器 ${connectorId} 里没有 server「${name || "(空)"}」`);
156
186
  const spec = nestedServerSpec(rawServer);
157
187
  if (!spec.url) throw new Error(`server「${name}」没有可授权的 URL(stdio 型不需要 OAuth 授权)`);
158
- return { serverName: name, url: spec.url, headers: spec.headers, transport: spec.transport };
188
+ // 坏掉的 authorization 不能跟着去探测:带着 `Bearer [object Object]` 打过去,服务器
189
+ // 回的是 400「token parse failed」而不是 401「empty access token」,授权发现
190
+ // 那条路就在第一步被判定成「非鉴权错误」而中止。
191
+ const headers = { ...spec.headers };
192
+ if (headers.authorization && oauthIsGarbageCredential(headers.authorization)) delete headers.authorization;
193
+ return { serverName: name, url: spec.url, headers, transport: spec.transport };
159
194
  }
160
195
 
161
196
  /** 宿主 shell 服务的几种可能落点,逐个试;拿不到就返回 undefined 走系统兜底。 */
@@ -1884,6 +1919,10 @@ let McpManagerGateway = (() => {
1884
1919
  error: started?.error || "授权流程启动失败",
1885
1920
  mode: started?.mode || "",
1886
1921
  redirectUri: started?.redirectUri || "",
1922
+ // 授权页这条路走不通(服务器不提供 OAuth 发现、动态注册被拒…)时,
1923
+ // 手填令牌仍然是可用通道:把它交给面板,别只留一行红字。
1924
+ manualTokenOnly: started?.manualTokenOnly === true,
1925
+ serverUrl: started?.serverUrl || target.url,
1887
1926
  };
1888
1927
  }
1889
1928
  // 默认由宿主直接把授权页推给系统浏览器;open:false 留给面板自己渲染链接。
package/lib/mcp-oauth.js CHANGED
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
29
29
  import { join } from "node:path";
30
30
 
31
31
  /** 插件版本:DCR 的 clientInfo 与 UA 共用一处,避免两边各自漂移。 */
32
- const PLUGIN_VERSION = "2.3.1";
32
+ const PLUGIN_VERSION = "2.3.2";
33
33
  const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
34
34
  const NET_TIMEOUT_MS = 15_000;
35
35
  /** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
@@ -59,6 +59,69 @@ function sha256hex(text) {
59
59
  return createHash("sha256").update(String(text)).digest("hex");
60
60
  }
61
61
 
62
+ // ── 凭据形状 ────────────────────────────────────────────────────────────
63
+ // 这一层存在的理由是一条真实事故:`ensureAccessToken()` 返回的是对象
64
+ // `{ok, accessToken, ...}`,调用方写成 `String(await ensureAccessToken(url))` 就得到
65
+ // `"[object Object]"` —— 真值、非空、于是被当成正经令牌注进 `authorization` 头并落盘。
66
+ // 服务器收到后回的不是 401「empty access token」而是 400「token parse failed」,
67
+ // 装前探测只认 401/403,于是「需要授权」被彻底漏判:配置写进去了、授权页永远不弹、
68
+ // 连接永远失败。所以「什么才算一个能用的令牌」必须只写一次、放在这里。
69
+
70
+ /** Bearer 令牌长度上限:正常签发物不会这么长,超长基本是把整坨响应粘回来了。 */
71
+ const BEARER_TOKEN_MAX = 8192;
72
+
73
+ /** 去掉可选的 `Bearer ` 前缀与首尾空白(光杆一个 "Bearer" 等于没有令牌)。 */
74
+ function stripBearerPrefix(value) {
75
+ return String(value ?? "").replace(/^\s*Bearer\b[ \t]*/i, "").trim();
76
+ }
77
+
78
+ /**
79
+ * 一眼假:序列化残骸(`[object Object]` / `undefined` / `NaN`)、空值、光杆 `Bearer`。
80
+ * 未替换的 `${TOKEN}` 占位符**不算**残骸——那是 R7 令牌引导的合法中间态,
81
+ * 由 `missingSlots` 单独回报面板,不能被这里悄悄抹掉。
82
+ */
83
+ export function isSerializedGarbageCredential(value) {
84
+ if (value === null || value === undefined) return true;
85
+ if (typeof value !== "string") return true;
86
+ const text = value.trim();
87
+ if (!text) return true;
88
+ if (/^\[object\s+\w+\]$/i.test(text) || /\[object\s+\w+\]/i.test(text)) return true;
89
+ if (/^(?:undefined|null|NaN)$/i.test(stripBearerPrefix(text))) return true;
90
+ return !stripBearerPrefix(text);
91
+ }
92
+
93
+ /** 是不是一个可以原样发给服务器的 access token。 */
94
+ export function isUsableBearerToken(value) {
95
+ if (isSerializedGarbageCredential(value)) return false;
96
+ const token = stripBearerPrefix(value);
97
+ if (token.length > BEARER_TOKEN_MAX) return false;
98
+ if (/\$\{[^}]*\}/.test(token)) return false;
99
+ // 控制符/换行:HTTP 头里出现就是注入面(fetch 也会拒),一律视为不可用。
100
+ return !/[\u0000-\u001f\u007f\u0080-\u009f]/.test(token);
101
+ }
102
+
103
+ // ── 「这是鉴权失败吗」判定 ───────────────────────────────────────────────
104
+
105
+ /**
106
+ * 各家网关的鉴权失败措辞(JSON-RPC error.message 里常见)。刻意只列鉴权语义,
107
+ * 不收 "not found" / "invalid params" 这类——把服务器故障判成「要授权」会把用户
108
+ * 引到一条根本走不通的授权路上。
109
+ */
110
+ const AUTH_BODY_RE =
111
+ /(empty access token|access token (?:empty|missing|invalid)|token parse|invalid[ _-]?token|missing[ _-]?token|no (?:access )?token|token (?:expired|invalid)|unauthorized|unauthenticated|(?:authentication|authorization) (?:failed|error|required)|(?:需要|请)(?:登录|授权|鉴权))/i;
112
+
113
+ /**
114
+ * 一次 MCP 握手失败是不是「缺凭据」。
115
+ * 401/403 无条件算;其它 4xx 只有在响应体明确说鉴权时才算(腾讯 ima 就是 400 +
116
+ * `msg:token parse failed`,且不带 `WWW-Authenticate`);2xx/3xx/5xx 一律不算。
117
+ */
118
+ export function mcpAuthFailure(status, bodyText = "") {
119
+ const code = Number(status) || 0;
120
+ if (code === 401 || code === 403) return true;
121
+ if (code < 400 || code >= 500) return false;
122
+ return AUTH_BODY_RE.test(String(bodyText || ""));
123
+ }
124
+
62
125
  /** PKCE 授权码流程的 verifier/challenge(S256)。 */
63
126
  export function createPkce() {
64
127
  const verifier = b64url(randomBytes(48));
@@ -114,8 +177,8 @@ async function fetchJson(url, init = {}) {
114
177
  // ── 探测 ────────────────────────────────────────────────────────────────
115
178
 
116
179
  /**
117
- * 打一次 MCP initialize,拿回状态码与 `WWW-Authenticate`。
118
- * 只用于确认「这颗 server 是不是 401 且声明了 OAuth」,不做其它判断。
180
+ * 打一次 MCP initialize,拿回状态码、`WWW-Authenticate` 与响应体摘要。
181
+ * 只用于确认「这颗 server 是不是要凭据」,不做其它判断。
119
182
  */
120
183
  export async function probeServer(serverUrl, headers = {}) {
121
184
  const body = JSON.stringify({
@@ -142,16 +205,18 @@ export async function probeServer(serverUrl, headers = {}) {
142
205
  },
143
206
  body
144
207
  });
145
- // 读掉 body,避免 socket 悬挂
146
- await response.text().catch(() => "");
208
+ // 读掉 body,避免 socket 悬挂;正文还要留给鉴权判定(不少网关 400 也带鉴权语义)。
209
+ const text = await response.text().catch(() => "");
147
210
  return {
148
211
  ok: true,
149
212
  status: response.status,
150
213
  wwwAuthenticate: response.headers.get("www-authenticate") || "",
214
+ body: trimText(text, 400),
215
+ authRequired: mcpAuthFailure(response.status, text),
151
216
  error: ""
152
217
  };
153
218
  } catch (error) {
154
- return { ok: false, status: 0, wwwAuthenticate: "", error: String(error?.message || error) };
219
+ return { ok: false, status: 0, wwwAuthenticate: "", body: "", authRequired: false, error: String(error?.message || error) };
155
220
  }
156
221
  }
157
222
 
@@ -439,7 +504,8 @@ export async function clearTokenRecord(serverUrl) {
439
504
 
440
505
  /** 记录里是否有一个「还没过期」的 access token(留 60s 余量)。 */
441
506
  export function tokenIsFresh(record) {
442
- if (!record || !str(record.accessToken)) return false;
507
+ // 形状先于时效:`[object Object]` 这种残骸哪怕带着未来的 expiresAt 也不是令牌。
508
+ if (!record || !isUsableBearerToken(record.accessToken)) return false;
443
509
  if (!record.expiresAt) return true; // 服务器没给 expires_in:视为长期有效,等 401 再说
444
510
  // 落盘时写的是 ISO 串(见 exchangeCode),但令牌库是纯 JSON、可能被人手改或由
445
511
  // 旧版本写成数字毫秒,两种都认:数字直接当时间戳,字符串才走 Date.parse。
@@ -478,7 +544,7 @@ async function refreshAccessToken(record) {
478
544
  headers: { "content-type": "application/x-www-form-urlencoded" },
479
545
  body: body.toString()
480
546
  });
481
- if (!result.ok || !str(result.json?.access_token)) {
547
+ if (!result.ok || !isUsableBearerToken(result.json?.access_token)) {
482
548
  return { ok: false, accessToken: "", error: `刷新 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}` };
483
549
  }
484
550
  await writeTokenRecord(record.serverUrl, tokenRecordPatch(result.json));
@@ -512,20 +578,35 @@ function tokenRecordPatch(tokenResponse) {
512
578
  export async function beginAuthorization({ serverName, serverUrl, headers = {}, clientName = "DeepSeek Harness" } = {}) {
513
579
  if (!serverUrl) return fail("", "", "", "缺少 server url");
514
580
  const probe = await probeServer(serverUrl, headers);
515
- if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}`);
516
- if (probe.status !== 401 && probe.status !== 403) {
517
- // 有些服务器首次 initialize 回 200,鉴权在 tools/call 才触发;这里仍允许继续试一次。
518
- if (probe.status >= 400 && probe.status !== 401 && probe.status !== 403) {
519
- return fail("", "", "", `服务器返回 HTTP ${probe.status},不是预期的 401/403,无法确认其 OAuth 要求`);
520
- }
581
+ if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}`, { serverUrl });
582
+ if (probe.status >= 400 && !probe.authRequired) {
583
+ // 明确的非鉴权失败(404 路径错、500 服务器挂了…):继续往下走只会把用户
584
+ // 引到一条根本不存在的授权路上,直接说清楚更省时间。
585
+ return fail("", "", "", `服务器返回 HTTP ${probe.status},不是预期的鉴权错误:${probe.body || "(无响应体)"}`, { serverUrl });
521
586
  }
587
+ // 到这里要么服务器点名要凭据(401/403/4xx 鉴权体),要么匿名握手就过了
588
+ // (有些实现把鉴权推迟到 tools/call)——两种都继续试一次发现流程。
522
589
 
523
590
  const resource = await resolveProtectedResource({ serverUrl, wwwAuthenticate: probe.wwwAuthenticate });
524
- if (!resource.ok) return fail("", "", "", resource.error);
591
+ if (!resource.ok) return fail("", "", "", resource.error, { serverUrl, manualTokenOnly: true });
525
592
  const as = await resolveAuthorizationServer(resource.authorizationServers[0]);
526
- if (!as.ok) return fail("", "", "", as.error);
593
+ if (!as.ok) {
594
+ // 现实里很常见:服务器要 Bearer token,但压根不实现 RFC 9728/8414 的 well-known
595
+ // 发现(腾讯 ima 就是这样)。这时没有任何 authorizeUrl 可开,硬报错等于把用户
596
+ // 关在门外——回 manualTokenOnly,让面板把手填令牌那条兜底路摊开给他。
597
+ return fail("", "", "", `${as.error}\n该服务器没有提供标准 OAuth 授权发现,无法自动打开授权页;如果你已有访问令牌,可以直接粘贴安装。`, {
598
+ serverUrl,
599
+ manualTokenOnly: true
600
+ });
601
+ }
527
602
  if (!as.tokenEndpointAuthMethods.includes("none")) {
528
- return fail("", "", "", `该授权服务器要求客户端认证(${as.tokenEndpointAuthMethods.join("/")}),本插件只支持公共客户端(none)`);
603
+ return fail(
604
+ "",
605
+ "",
606
+ "",
607
+ `该授权服务器要求客户端认证(${as.tokenEndpointAuthMethods.join("/")}),本插件只支持公共客户端(none);如果你已有访问令牌,可以直接粘贴安装。`,
608
+ { serverUrl, manualTokenOnly: true }
609
+ );
529
610
  }
530
611
 
531
612
  const scope = as.scopesSupported.length ? as.scopesSupported.join(" ") : "";
@@ -543,13 +624,22 @@ export async function beginAuthorization({ serverName, serverUrl, headers = {},
543
624
  const template = extractExpectedRedirectUri(registration.raw || registration.error);
544
625
  if (!template) {
545
626
  loopback.close();
546
- return fail("", "", "", registration.error);
627
+ return fail("", "", "", `${registration.error}\n动态注册走不通,无法生成授权页;如果你已有访问令牌,可以直接粘贴安装。`, {
628
+ serverUrl,
629
+ manualTokenOnly: true
630
+ });
547
631
  }
548
632
  const expected = substituteRedirectPlaceholder(template, serverName || clientName);
549
633
  const retry = await registerClient(as, { redirectUri: expected, clientName, scope });
550
634
  if (!retry.ok) {
551
635
  loopback.close();
552
- return fail("", "", "", `${registration.error}\n改用服务器指定的回调地址 ${expected} 重试仍失败:${retry.error}`);
636
+ return fail(
637
+ "",
638
+ "",
639
+ "",
640
+ `${registration.error}\n改用服务器指定的回调地址 ${expected} 重试仍失败:${retry.error}\n如果你已有访问令牌,可以直接粘贴安装。`,
641
+ { serverUrl, manualTokenOnly: true }
642
+ );
553
643
  }
554
644
  registration = retry;
555
645
  redirectUri = expected;
@@ -619,8 +709,21 @@ function buildAuthorizeUrl({ authorizationEndpoint, clientId, redirectUri, scope
619
709
  return url.href;
620
710
  }
621
711
 
622
- function fail(sessionId, authorizeUrl, mode, error) {
623
- return { ok: false, sessionId, authorizeUrl, mode, redirectUri: "", hint: "", error };
712
+ function fail(sessionId, authorizeUrl, mode, error, extra = {}) {
713
+ return {
714
+ ok: false,
715
+ sessionId,
716
+ authorizeUrl,
717
+ mode,
718
+ redirectUri: "",
719
+ hint: "",
720
+ // manualTokenOnly:授权页这条路走不通,但「手填访问令牌」仍然可用。面板据此
721
+ // 也要把弹层开出来(而不是只甩一行红字),否则用户看到的是死路。
722
+ manualTokenOnly: false,
723
+ serverUrl: "",
724
+ error,
725
+ ...extra
726
+ };
624
727
  }
625
728
 
626
729
  function pruneSessions() {
@@ -667,7 +770,7 @@ async function exchangeCode(session, code) {
667
770
  headers: { "content-type": "application/x-www-form-urlencoded" },
668
771
  body: body.toString()
669
772
  });
670
- if (!result.ok || !str(result.json?.access_token)) {
773
+ if (!result.ok || !isUsableBearerToken(result.json?.access_token)) {
671
774
  session.status = "failed";
672
775
  session.error = `换取 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}`;
673
776
  return;
@@ -775,8 +878,12 @@ export async function saveManualToken({ serverUrl, serverName = "", token, token
775
878
  const raw = String(token || "").trim();
776
879
  if (!serverUrl) return { ok: false, error: "缺少 server url" };
777
880
  if (!raw) return { ok: false, error: "token 不能为空" };
778
- const accessToken = raw.replace(/^Bearer\s+/i, "").trim();
881
+ const accessToken = stripBearerPrefix(raw);
779
882
  if (!accessToken) return { ok: false, error: "token 不能为空" };
883
+ // 手填这条路最容易粘进残骸(`[object Object]`、没替换的 ${TOKEN}、带换行的整段 curl)。
884
+ if (!isUsableBearerToken(accessToken)) {
885
+ return { ok: false, error: "这不像是个可用的访问令牌(空值、未替换的 ${...} 占位符或序列化残骸),请重新从服务方复制。" };
886
+ }
780
887
  await writeTokenRecord(serverUrl, {
781
888
  accessToken,
782
889
  tokenType: "Bearer",
package/lib/wb-market.js CHANGED
@@ -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
 
@@ -219,8 +222,16 @@ export function serverInstallability(serverName, rawServer, authMode = "") {
219
222
  // 早前这里只认 nested.type:command 型条目 transport 落成空串,被文件末尾的
220
223
  // 「未知传输类型」判为拒装——凡是标准 stdio 连接器都装不上。缺 type 但有 command
221
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"`)同口径。
222
229
  const declaredType = String(nested?.type ?? "").toLowerCase();
223
- 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" : "");
224
235
  // R7:先扫占位符槽位(env/headers 里的 ${...})。这一扫描必须发生在 transport 的
225
236
  // 提前 return 之前——否则 streamable-http 型连接器的 header 占位符会被整段跳过,
226
237
  // 而 80+ 条 auth_mode=token 的连接器正是这个传输形态,正是「装不了」的成因之一。
@@ -381,18 +392,24 @@ export async function wbConnectorIcon(connectorId) {
381
392
 
382
393
  /**
383
394
  * 装前 401 探测:对 streamable-http 型 server 发一次 MCP initialize JSON-RPC,
384
- * 服务器明确回 401/403 就中止安装。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
395
+ * 服务器明确要凭据就回报 needsAuth。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
385
396
  * 索引 auth_mode 也为空,装完连接必 401 "Version negotiation failed")——这是漏标鉴权的
386
- * 最后防线。判定刻意保守:只有 401/403 拦,超时/5xx/网络错误/其他状态一律放行——
387
- * 探测挂了不能误伤正常连接器,宁可让个别服务器在连接时报错。
388
- * 返回 { 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, ... }。
389
403
  */
390
404
  export async function probeServerAuthorization(serverName, rawServer) {
391
405
  const nested = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
392
- 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" : "");
393
411
  // 只探测 HTTP 型:stdio 没有可探测的 URL,sse/ws 本来就被准入闸拦下。
394
412
  if (transport !== "streamablehttp" && transport !== "streamable-http" && transport !== "http") return { authorized: true, skipped: true };
395
- const url = String(nested?.url || "").trim();
396
413
  if (!url) return { authorized: true, skipped: true };
397
414
  const body = JSON.stringify({
398
415
  jsonrpc: "2.0",
@@ -422,8 +439,10 @@ export async function probeServerAuthorization(serverName, rawServer) {
422
439
  // 超时 / DNS 失败 / 连接拒绝:探测不了就放行,安装后由用户实测。
423
440
  return { authorized: true, error: "probe-unreachable" };
424
441
  }
425
- if (response.status === 401 || response.status === 403) {
426
- // R8:不再把 401 当致命错误。改回传结构化「需授权」,由上层触发浏览器授权流程:
442
+ // 正文既用于「非 401 的鉴权失败」判定,也是面板里最有信息量的一行提示。
443
+ const bodyText = await response.text().catch(() => "");
444
+ if (mcpAuthFailure(response.status, bodyText)) {
445
+ // R8:不再把鉴权失败当致命错误。改回传结构化「需授权」,由上层触发浏览器授权流程:
427
446
  // 解析 WWW-Authenticate 里的 resource_metadata,交给 lib/mcp-oauth.js 继续走
428
447
  // RFC 9728(资源元数据)→ RFC 8414(AS 元数据)→ RFC 7591(动态注册)→ RFC 7636(PKCE)。
429
448
  const wwwAuthenticate = String(response.headers.get("www-authenticate") || "");
@@ -435,9 +454,10 @@ export async function probeServerAuthorization(serverName, rawServer) {
435
454
  serverName,
436
455
  wwwAuthenticate,
437
456
  resourceMetadata: extractResourceMetadata(wwwAuthenticate),
457
+ bodyHint: String(bodyText || "").replace(/\s+/g, " ").trim().slice(0, 300),
438
458
  };
439
459
  }
440
- // 200/202/4xx 其他/5xx:都视为「没有明确拒绝匿名握手」,放行。
460
+ // 200/202/其它 4xx/5xx:都视为「没有明确拒绝匿名握手」,放行。
441
461
  return { authorized: true, status: response.status };
442
462
  }
443
463
 
@@ -606,17 +626,37 @@ function skillTargetName(namespace, slug) {
606
626
  const safe = (value) => String(value).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "x";
607
627
  return `${safe(namespace)}__${safe(slug)}`;
608
628
  }
629
+ // 并发安装互斥(按目标目录加锁)。双击、卡片+详情同时点、面板重挂载后再点,都会让
630
+ // 同一技能有两个安装任务在飞:两者交错执行同一路径上的 rm(staging)/rm(finalDir)/rename,
631
+ // 轻则留下残包(缺 SKILL.md → 注册表永远发现不了,即「显示安装成功但 Skill 看板里没有」),
632
+ // 重则一个任务删掉另一个刚装好的目录、自己的 rename 又失败 → 「报成功但磁盘上什么都没有」,
633
+ // 之后卸载还会撞「本机没有安装记录」。UI 的 busy 禁用挡不住所有入口(重挂载即重置),
634
+ // 宿主侧串行化才是根修:RPC 可从任意处调用,落盘顺序必须由这里保证。
635
+ const skillInstallLocks = new Map();
609
636
  export async function wbSkillInstall(payload = {}, { onProgress } = {}) {
610
637
  const slug = String(payload?.slug || "").trim();
611
638
  const namespace = String(payload?.namespace || "").trim();
612
- const requestedName = String(payload?.name || "").trim();
613
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();
614
657
  const listing = await wbSkillFiles(payload);
615
658
  if (!listing.files.length) throw new Error("该技能没有可下载的文件");
616
659
  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
660
  const stagingDir = `${finalDir}.installing`;
621
661
  const totalBytes = listing.files.reduce((sum, file) => sum + file.size, 0);
622
662
  if (totalBytes > 32 * 1024 * 1024) throw new Error(`技能总体积超过 32MB 上限(${Math.round(totalBytes / 1024 / 1024)}MB)`);
@@ -651,6 +691,17 @@ export async function wbSkillInstall(payload = {}, { onProgress } = {}) {
651
691
  await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
652
692
  throw error;
653
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
+ }
654
705
  return {
655
706
  note: `已安装到 ${finalDir}(${listing.files.length} 个文件${listing.version ? `,v${listing.version}` : ""}),即刻生效`,
656
707
  dir: finalDir,
@@ -410,6 +410,12 @@ 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
+ ],
413
419
  };
414
420
 
415
421
  /**
package/lib/whats-new.js CHANGED
@@ -469,4 +469,13 @@ 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
+ },
472
481
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
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",