dsh-my-config 1.7.2 → 1.8.1

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.
Files changed (4) hide show
  1. package/README.md +17 -3
  2. package/client.js +294 -37
  3. package/index.js +508 -16
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -4,12 +4,26 @@ DeepSeek Harness 插件:在 Web 侧边栏提供「我的技能 / 我的连接
4
4
 
5
5
  | 模块 | 能力 |
6
6
  |---|---|
7
- | 我的技能 | 创建 / 编辑 / 删除 / 导入技能;**ZIP 技能包导入**(多文件包);**外部技能市场**(内置精选市场 + 任意 `github://owner/repo` 仓库 + JSON 索引市场);分组管理 |
8
- | 我的连接器 | 创建 / 编辑(配置)/ 启停 / 删除 MCP 连接器(streamable-http 与 stdio),通过 `ctx.loader` 动态下发 `@deepseek-ai/dsh-mcp-client` 条目;分组管理 |
9
- | 我的智能体 | 创建 / 编辑 / 删除自定义 Agent 预设;**关联技能**(独立技能目录,精确限定该智能体可见的技能)与**关联连接器**(工具清单注入系统提示词);分组管理 |
7
+ | 我的技能 | 创建 / 编辑 / 删除 / 导入技能;**ZIP 技能包导入**(多文件包);**外部技能市场**(内置精选市场 + 任意 `github://owner/repo` 仓库 + JSON 索引市场);**全字段编辑**(license / compatibility / metadata / allowed-tools,对齐 Agent Skills 开放规范);**规范校验**(保存/导入时逐字段拦截并定位错误);分组管理 |
8
+ | 我的连接器 | 创建 / 编辑(配置)/ 启停 / 删除 MCP 连接器(streamable-http 与 stdio),通过 `ctx.loader` 动态下发 `@deepseek-ai/dsh-mcp-client` 条目;**连接测试**(MCP 握手 + 工具清单预览);**工具级授权**(白名单拦截 + 自动执行放行,经审批 answerer 实现);**凭据脱敏**(env / headers 掩码显示,防误覆盖);分组管理 |
9
+ | 我的智能体 | 创建 / 编辑 / 删除自定义 Agent 预设;**关联技能**(独立技能目录,精确限定该智能体可见的技能)与**关联连接器**(工具清单注入系统提示词);**模型绑定**(provider/model/reasoningEffort 记录与展示);**工具组白名单**(文件 / 联网 / 终端 / 技能 四组粒度过滤预设组合);分组管理 |
10
10
 
11
11
  三个页面统一布局:搜索框 + 分组筛选 + 刷新 + 新建。
12
12
 
13
+ ## 更新日志
14
+
15
+ ### v1.8.1
16
+
17
+ - **修复**:streamable-http「测试连接」适配 stateful 服务端——带回 `mcp-session-id`(TS SDK 服务端强校验,缺失返回 400);响应支持 gzip / deflate / br 解码(部分 CDN 强制压缩导致解析失败);initialize 协议版本不匹配时自动降级重试。
18
+ - **修复**:Windows 下 stdio 探测无法启动 `npx` / `uvx` 等 `.cmd` shim 的问题(改经 cmd.exe 启动,与内核 MCP SDK 行为一致)。
19
+
20
+ ### v1.8.0
21
+
22
+ - **技能**:编辑器支持 license / compatibility / metadata / allowed-tools 全字段(对齐 agentskills.io 规范);保存与导入时执行规范校验,错误逐字段定位展示。
23
+ - **连接器**:新增「测试连接」(stdio / streamable-http 真实 MCP 握手,返回工具清单与延迟);工具级授权(允许使用白名单 + 自动执行列表,经 `approval/request` answerer 拦截/放行);env / headers 密钥掩码显示与防误覆盖。
24
+ - **智能体**:预设新增模型绑定(provider / model / reasoningEffort)与工具组白名单(文件 / 联网 / 终端 / 技能)。
25
+ - 已知限制:审批拒绝原因经审计日志留痕(内核 outcome 词表无原因通道);连接器密钥存储仍为本地明文(data.json),请妥善保管该文件。
26
+
13
27
  ## 安装
14
28
 
15
29
  ```bash
package/client.js CHANGED
@@ -102,6 +102,18 @@ window.__ModuleLoader__.load({
102
102
  skillReadonly: '只读',
103
103
  skillInvalid: 'frontmatter 无效',
104
104
  skillRoot: '存储目录',
105
+ skillLicense: '许可证(可选)',
106
+ skillCompatibility: '兼容性(可选)',
107
+ skillMetadata: '元数据(键值对,可选)',
108
+ skillMetadataKey: '键',
109
+ skillMetadataValue: '值',
110
+ skillMetadataAdd: '添加一行',
111
+ skillAllowedTools: '允许的工具(可选)',
112
+ skillAllowedToolsHint: '实验性功能:写入 frontmatter 的 allowed-tools,空格分隔的工具名',
113
+ skillErrName: '名称须为小写 kebab-case(字母/数字/连字符),不超过 64 字符',
114
+ skillErrDescEmpty: '描述不能为空',
115
+ skillErrDescLong: '描述不能超过 1024 字符',
116
+ skillErrCompatLong: '兼容性说明不能超过 500 字符',
105
117
  marketAdd: '添加市场',
106
118
  marketName: '市场名称',
107
119
  marketUrl: '市场索引地址(JSON)',
@@ -127,6 +139,16 @@ window.__ModuleLoader__.load({
127
139
  connectorLive: '运行中',
128
140
  connectorStopped: '未加载',
129
141
  connectorToolsHint: '连接后工具以 mcp__{serverName}__ 前缀注册',
142
+ connectorTest: '测试连接',
143
+ connectorUntested: '未测试',
144
+ connectorConnected: '已连接 {n} 个工具',
145
+ connectorTestFailed: '失败:{reason}',
146
+ connectorTools: '工具',
147
+ connectorToolsEmpty: '暂无工具清单,请先点击「测试连接」获取。',
148
+ connectorToolAllowed: '允许使用',
149
+ connectorToolAuto: '自动执行',
150
+ connectorApprovalNever: '当前审批策略为 never,自动执行不生效。',
151
+ connectorSecretHidden: '敏感值不可查看,如需修改请重新填写。',
130
152
  // 智能体
131
153
  expertCreate: '创建智能体',
132
154
  expertEdit: '编辑智能体',
@@ -139,6 +161,19 @@ window.__ModuleLoader__.load({
139
161
  expertDefault: '默认',
140
162
  expertBroken: '加载失败',
141
163
  expertHint: '自定义智能体注册为 Agent 预设,新会话可在预设选择器中选用。',
164
+ expertModel: '模型绑定(可选)',
165
+ expertModelProvider: '提供商(如 anthropic)',
166
+ expertModelName: '模型名(如 claude-sonnet-4)',
167
+ expertModelDefault: '跟随默认',
168
+ expertModelHint: '提供商与模型名均填写才生效;留空 = 跟随默认模型。',
169
+ expertModelPartial: '模型绑定须同时填写提供商与模型名,或留空。',
170
+ expertTools: '工具组(可选)',
171
+ expertToolFs: '文件读写检索',
172
+ expertToolWeb: '联网',
173
+ expertToolShell: '终端',
174
+ expertToolSkills: '技能',
175
+ expertToolsHint: '留空 = 全部可用。',
176
+ expertToolsSummary: '工具组',
142
177
  // 插件
143
178
  pluginInstall: '安装插件',
144
179
  pluginSpec: '包名 / Git 地址 / 本地目录',
@@ -242,6 +277,18 @@ window.__ModuleLoader__.load({
242
277
  skillReadonly: 'Read-only',
243
278
  skillInvalid: 'Invalid frontmatter',
244
279
  skillRoot: 'Storage directory',
280
+ skillLicense: 'License (optional)',
281
+ skillCompatibility: 'Compatibility (optional)',
282
+ skillMetadata: 'Metadata (key-value pairs, optional)',
283
+ skillMetadataKey: 'Key',
284
+ skillMetadataValue: 'Value',
285
+ skillMetadataAdd: 'Add row',
286
+ skillAllowedTools: 'Allowed tools (optional)',
287
+ skillAllowedToolsHint: 'Experimental: written to frontmatter as allowed-tools, space-separated tool names',
288
+ skillErrName: 'Name must be lowercase kebab-case (letters/digits/hyphens), max 64 characters',
289
+ skillErrDescEmpty: 'Description is required',
290
+ skillErrDescLong: 'Description must be at most 1024 characters',
291
+ skillErrCompatLong: 'Compatibility must be at most 500 characters',
245
292
  marketAdd: 'Add market',
246
293
  marketName: 'Market name',
247
294
  marketUrl: 'Market index URL (JSON)',
@@ -266,6 +313,16 @@ window.__ModuleLoader__.load({
266
313
  connectorLive: 'Live',
267
314
  connectorStopped: 'Not loaded',
268
315
  connectorToolsHint: 'Tools register with the mcp__{serverName}__ prefix once connected',
316
+ connectorTest: 'Test connection',
317
+ connectorUntested: 'Not tested',
318
+ connectorConnected: 'Connected: {n} tools',
319
+ connectorTestFailed: 'Failed: {reason}',
320
+ connectorTools: 'Tools',
321
+ connectorToolsEmpty: 'No tool list yet. Click "Test connection" to fetch it.',
322
+ connectorToolAllowed: 'Allow',
323
+ connectorToolAuto: 'Auto-run',
324
+ connectorApprovalNever: 'Current approval policy is "never"; auto-run has no effect.',
325
+ connectorSecretHidden: 'Secret values cannot be viewed. Re-enter to change them.',
269
326
  expertCreate: 'New agent',
270
327
  expertEdit: 'Edit agent',
271
328
  expertId: 'ID (lowercase letters/digits/hyphens)',
@@ -277,6 +334,19 @@ window.__ModuleLoader__.load({
277
334
  expertDefault: 'Default',
278
335
  expertBroken: 'Broken',
279
336
  expertHint: 'Custom agents register as agent presets and can be selected for new sessions.',
337
+ expertModel: 'Model binding (optional)',
338
+ expertModelProvider: 'Provider (e.g. anthropic)',
339
+ expertModelName: 'Model (e.g. claude-sonnet-4)',
340
+ expertModelDefault: 'Follow default',
341
+ expertModelHint: 'Both provider and model are required to take effect; empty = follow the default model.',
342
+ expertModelPartial: 'Model binding requires both provider and model, or leave empty.',
343
+ expertTools: 'Tool groups (optional)',
344
+ expertToolFs: 'File read/write & search',
345
+ expertToolWeb: 'Web access',
346
+ expertToolShell: 'Terminal',
347
+ expertToolSkills: 'Skills',
348
+ expertToolsHint: 'Empty = all available.',
349
+ expertToolsSummary: 'Tool groups',
280
350
  pluginInstall: 'Install plugin',
281
351
  pluginSpec: 'Package name / Git URL / local directory',
282
352
  pluginUninstall: 'Uninstall',
@@ -350,6 +420,7 @@ window.__ModuleLoader__.load({
350
420
  .mc-empty { color: var(--dsw-alias-label-secondary); padding: 48px 0; text-align: center; }
351
421
  .mc-error { color: var(--dsw-alias-state-error-primary); padding: 24px 0; text-align: center; display: flex; flex-direction: column; gap: 8px; align-items: center; }
352
422
  .mc-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); line-height: 1.6; }
423
+ .mc-field-error { color: var(--dsw-alias-state-error-primary); font-size: 12px; }
353
424
  .mc-section { margin-bottom: 20px; }
354
425
  .mc-section-title { font-size: 13px; font-weight: 600; margin: 0 0 10px; color: var(--dsw-alias-label-secondary); }
355
426
  .mc-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; margin-bottom: 8px; }
@@ -450,7 +521,11 @@ window.__ModuleLoader__.load({
450
521
  clearTimeout(timer);
451
522
  }
452
523
  const data = await res.json().catch(() => ({ ok: false, message: `HTTP ${res.status} [${url}]` }));
453
- if (!data.ok) throw new Error(data.message || `HTTP ${res.status}`);
524
+ if (!data.ok) {
525
+ const err = new Error(data.message || `HTTP ${res.status}`);
526
+ if (Array.isArray(data.errors)) err.fields = data.errors;
527
+ throw err;
528
+ }
454
529
  return data;
455
530
  }
456
531
 
@@ -509,17 +584,51 @@ window.__ModuleLoader__.load({
509
584
  group: skill?.group ?? '',
510
585
  userInvocable: skill?.userInvocable !== false,
511
586
  disableModelInvocation: skill?.disableModelInvocation === true,
587
+ license: skill?.license ?? '',
588
+ compatibility: skill?.compatibility ?? '',
589
+ metadata: Object.entries(skill?.metadata ?? {}).map(([key, value]) => ({ key, value: String(value ?? '') })),
590
+ allowedTools: skill?.allowedTools ?? '',
512
591
  });
513
592
  const [busy, setBusy] = useState(false);
514
- const [error, setError] = useState('');
515
- const set = (key) => (e) => setForm({ ...form, [key]: e.target.value });
593
+ const [error, setError] = useState(undefined); // { message, fields? }
594
+ const [errors, setErrors] = useState({}); // 本地校验:字段 → 错误文案
595
+ const validateForm = (f) => {
596
+ const errs = {};
597
+ if (f.name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(f.name)) errs.name = t('skillErrName');
598
+ if (!f.description.trim()) errs.description = t('skillErrDescEmpty');
599
+ else if (f.description.length > 1024) errs.description = t('skillErrDescLong');
600
+ if (f.compatibility.length > 500) errs.compatibility = t('skillErrCompatLong');
601
+ return errs;
602
+ };
603
+ const set = (key) => (e) => {
604
+ const next = { ...form, [key]: e.target.value };
605
+ setForm(next);
606
+ if (Object.keys(errors).length > 0) setErrors(validateForm(next));
607
+ };
608
+ const setMetadata = (i, key) => (e) => {
609
+ const metadata = form.metadata.map((row, idx) => (idx === i ? { ...row, [key]: e.target.value } : row));
610
+ setForm({ ...form, metadata });
611
+ };
516
612
  const submit = async () => {
517
- setBusy(true); setError('');
613
+ const errs = validateForm(form);
614
+ setErrors(errs);
615
+ if (Object.keys(errs).length > 0) return;
616
+ setBusy(true); setError(undefined);
518
617
  try {
519
- await api(isEdit ? 'skills.update' : 'skills.create', form);
618
+ const metadata = {};
619
+ for (const row of form.metadata) {
620
+ const k = row.key.trim();
621
+ if (k) metadata[k] = row.value;
622
+ }
623
+ await api(isEdit ? 'skills.update' : 'skills.create', { ...form, metadata });
520
624
  onSaved();
521
- } catch (err) { setError(err.message); } finally { setBusy(false); }
625
+ } catch (err) { setError({ message: err.message, fields: err.fields }); } finally { setBusy(false); }
522
626
  };
627
+ const renderServerError = (err) => err && h('div', { className: 'mc-diag' },
628
+ err.message,
629
+ (err.fields ?? []).length > 0 && h('ul', { style: { margin: '6px 0 0', paddingLeft: 18 } },
630
+ err.fields.map((f, i) => h('li', { key: i }, (f.field ? f.field + ': ' : '') + (f.message ?? '')))),
631
+ );
523
632
  return h(Modal, {
524
633
  title: isEdit ? t('skillEdit') : t('skillCreate'), onClose,
525
634
  footer: [
@@ -527,10 +636,28 @@ window.__ModuleLoader__.load({
527
636
  h(Button, { key: 's', kind: 'primary', disabled: busy || !form.name || !form.description, onClick: submit }, busy ? '…' : t('save')),
528
637
  ],
529
638
  },
530
- error && h('div', { className: 'mc-diag' }, error),
531
- h(Field, { label: t('skillName') }, h('input', { className: 'mc-input', value: form.name, disabled: isEdit, onChange: set('name'), placeholder: 'my-skill', autoFocus: !isEdit })),
532
- h(Field, { label: t('skillDescription') }, h('input', { className: 'mc-input', value: form.description, onChange: set('description') })),
639
+ renderServerError(error),
640
+ h(Field, { label: t('skillName') },
641
+ h('input', { className: 'mc-input', value: form.name, disabled: isEdit, onChange: set('name'), placeholder: 'my-skill', autoFocus: !isEdit }),
642
+ errors.name && h('span', { className: 'mc-field-error' }, errors.name)),
643
+ h(Field, { label: t('skillDescription') },
644
+ h('input', { className: 'mc-input', value: form.description, onChange: set('description') }),
645
+ errors.description && h('span', { className: 'mc-field-error' }, errors.description)),
533
646
  h(Field, { label: t('skillWhenToUse') }, h('input', { className: 'mc-input', value: form.whenToUse, onChange: set('whenToUse') })),
647
+ h(Field, { label: t('skillCompatibility') },
648
+ h('input', { className: 'mc-input', value: form.compatibility, onChange: set('compatibility'), placeholder: 'node >= 18, macos' }),
649
+ errors.compatibility && h('span', { className: 'mc-field-error' }, errors.compatibility)),
650
+ h(Field, { label: t('skillLicense') }, h('input', { className: 'mc-input', value: form.license, onChange: set('license'), placeholder: 'MIT' })),
651
+ h(Field, { label: t('skillMetadata') },
652
+ form.metadata.map((row, i) => h('div', { key: i, style: { display: 'flex', gap: 6, alignItems: 'center' } },
653
+ h('input', { className: 'mc-input', style: { flex: 1 }, placeholder: t('skillMetadataKey'), value: row.key, onChange: setMetadata(i, 'key') }),
654
+ h('input', { className: 'mc-input', style: { flex: 2 }, placeholder: t('skillMetadataValue'), value: row.value, onChange: setMetadata(i, 'value') }),
655
+ h(Button, { className: 'link danger', kind: undefined, 'aria-label': t('delete'), onClick: () => setForm({ ...form, metadata: form.metadata.filter((_, idx) => idx !== i) }) }, '✕'),
656
+ )),
657
+ h(Button, { className: 'link', kind: undefined, onClick: () => setForm({ ...form, metadata: [...form.metadata, { key: '', value: '' }] }) }, '+ ' + t('skillMetadataAdd'))),
658
+ h(Field, { label: t('skillAllowedTools') },
659
+ h('input', { className: 'mc-input', value: form.allowedTools, onChange: set('allowedTools'), placeholder: 'tool-a tool-b' }),
660
+ h('span', { className: 'mc-hint' }, t('skillAllowedToolsHint'))),
534
661
  h(Field, { label: t('group') }, h('input', { className: 'mc-input', value: form.group, onChange: set('group'), placeholder: t('groupPlaceholder') })),
535
662
  h(Field, { label: t('skillContent') }, h('textarea', { className: 'mc-textarea', value: form.content, onChange: set('content'), placeholder: '# ...' })),
536
663
  h('label', { className: 'mc-checkbox' }, h('input', { type: 'checkbox', checked: form.userInvocable, onChange: (e) => setForm({ ...form, userInvocable: e.target.checked }) }), t('skillUserInvocable')),
@@ -636,7 +763,7 @@ window.__ModuleLoader__.load({
636
763
  setError(undefined);
637
764
  if (file.size > 30 * 1024 * 1024) {
638
765
  setFileName(''); setContent(''); setZipBase64('');
639
- setError(t('importTooLarge'));
766
+ setError({ message: t('importTooLarge') });
640
767
  return;
641
768
  }
642
769
  setFileName(file.name);
@@ -647,12 +774,12 @@ window.__ModuleLoader__.load({
647
774
  let bin = '';
648
775
  for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
649
776
  setZipBase64(btoa(bin));
650
- } catch { setZipBase64(''); setError(t('importReadFailed')); }
777
+ } catch { setZipBase64(''); setError({ message: t('importReadFailed') }); }
651
778
  } else {
652
779
  setZipBase64('');
653
780
  const reader = new FileReader();
654
781
  reader.onload = () => setContent(String(reader.result ?? ''));
655
- reader.onerror = () => setError(t('importReadFailed'));
782
+ reader.onerror = () => setError({ message: t('importReadFailed') });
656
783
  reader.readAsText(file);
657
784
  }
658
785
  };
@@ -682,7 +809,7 @@ window.__ModuleLoader__.load({
682
809
  await api('skills.import', params);
683
810
  onImported();
684
811
  } catch (err) {
685
- setError(err.message);
812
+ setError({ message: err.message, fields: err.fields });
686
813
  } finally {
687
814
  setBusy(false);
688
815
  }
@@ -713,7 +840,10 @@ window.__ModuleLoader__.load({
713
840
  h('label', { style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
714
841
  h('input', { type: 'checkbox', checked: overwrite, onChange: (e) => setOverwrite(e.target.checked) }),
715
842
  t('importOverwrite')),
716
- error && h('div', { className: 'mc-error' }, error),
843
+ error && h('div', { className: 'mc-error' },
844
+ error.message,
845
+ (error.fields ?? []).length > 0 && h('ul', { style: { margin: '6px 0 0', paddingLeft: 18, textAlign: 'left' } },
846
+ error.fields.map((f, i) => h('li', { key: i }, (f.field ? f.field + ': ' : '') + (f.message ?? ''))))),
717
847
  );
718
848
  }
719
849
 
@@ -803,6 +933,8 @@ window.__ModuleLoader__.load({
803
933
  });
804
934
  const [busy, setBusy] = useState(false);
805
935
  const [error, setError] = useState('');
936
+ // 掩码值('••••••')不可查看明文;点击眼睛图标仅提示重新填写
937
+ const [secretHint, setSecretHint] = useState('');
806
938
  const set = (key) => (e) => setForm({ ...form, [key]: e.target.value });
807
939
  const submit = async () => {
808
940
  setBusy(true); setError('');
@@ -811,6 +943,14 @@ window.__ModuleLoader__.load({
811
943
  onSaved();
812
944
  } catch (err) { setError(err.message); } finally { setBusy(false); }
813
945
  };
946
+ const secretField = (label, value, key, placeholder) => h(Field, { label },
947
+ h('textarea', { className: 'mc-textarea', style: { minHeight: 72 }, value, onChange: set(key), placeholder }),
948
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
949
+ h(Button, {
950
+ className: 'link', kind: undefined, 'aria-label': label,
951
+ onClick: () => setSecretHint(secretHint === key ? '' : key),
952
+ }, '👁'),
953
+ secretHint === key && h('span', { className: 'mc-hint' }, t('connectorSecretHidden'))));
814
954
  return h(Modal, {
815
955
  title: isEdit ? t('connectorEdit') : t('connectorCreate'), onClose,
816
956
  footer: [
@@ -829,10 +969,10 @@ window.__ModuleLoader__.load({
829
969
  ),
830
970
  ),
831
971
  form.transport === 'streamable-http' && h(Field, { label: t('connectorUrl') }, h('input', { className: 'mc-input', value: form.url, onChange: set('url'), placeholder: 'https://example.com/mcp' })),
832
- form.transport === 'streamable-http' && h(Field, { label: t('connectorHeaders') }, h('textarea', { className: 'mc-textarea', style: { minHeight: 72 }, value: form.headers, onChange: set('headers'), placeholder: 'Authorization=Bearer …' })),
972
+ form.transport === 'streamable-http' && secretField(t('connectorHeaders'), form.headers, 'headers', 'Authorization=Bearer …'),
833
973
  form.transport === 'stdio' && h(Field, { label: t('connectorCommand') }, h('input', { className: 'mc-input', value: form.command, onChange: set('command'), placeholder: 'npx' })),
834
974
  form.transport === 'stdio' && h(Field, { label: t('connectorArgs') }, h('textarea', { className: 'mc-textarea', style: { minHeight: 72 }, value: form.args, onChange: set('args'), placeholder: '-y\n@modelcontextprotocol/server-github' })),
835
- form.transport === 'stdio' && h(Field, { label: t('connectorEnv') }, h('textarea', { className: 'mc-textarea', style: { minHeight: 72 }, value: form.env, onChange: set('env'), placeholder: 'GITHUB_TOKEN=…' })),
975
+ form.transport === 'stdio' && secretField(t('connectorEnv'), form.env, 'env', 'GITHUB_TOKEN=…'),
836
976
  form.transport === 'stdio' && h(Field, { label: t('connectorCwd') }, h('input', { className: 'mc-input', value: form.cwd, onChange: set('cwd') })),
837
977
  h(Field, { label: t('group') }, h('input', { className: 'mc-input', value: form.group, onChange: set('group'), placeholder: t('groupPlaceholder') })),
838
978
  );
@@ -846,11 +986,33 @@ window.__ModuleLoader__.load({
846
986
  const [busyDelete, setBusyDelete] = useState(false);
847
987
  const [groupFilter, setGroupFilter] = useState('');
848
988
  const [q, setQ] = useState('');
989
+ const [expanded, setExpanded] = useState({});
990
+ // 测试连接结果(易失,不持久):key → { status:'testing' } | { ok:true, latencyMs, tools } | { ok:false, error }
991
+ const [testResults, setTestResults] = useState({});
849
992
 
850
993
  const toggle = async (connector, enabled) => {
851
994
  try { await api('connectors.setEnabled', { key: connector.key, enabled }); reload(); }
852
995
  catch (err) { toast(err.message, true); }
853
996
  };
997
+ const test = async (connector) => {
998
+ const key = connector.key;
999
+ setTestResults((prev) => ({ ...prev, [key]: { status: 'testing' } }));
1000
+ try {
1001
+ const result = await api('connectors.test', { key });
1002
+ setTestResults((prev) => ({ ...prev, [key]: { ok: true, latencyMs: result.latencyMs, tools: result.tools ?? [] } }));
1003
+ } catch (err) {
1004
+ setTestResults((prev) => ({ ...prev, [key]: { ok: false, error: err.message } }));
1005
+ }
1006
+ };
1007
+ const setTools = async (connector, allowed, autoApprove) => {
1008
+ const known = testResults[connector.key]?.tools ?? [];
1009
+ const names = known.map((tool) => tool.name);
1010
+ const mode = allowed.length >= names.length && names.length > 0 ? 'all' : 'whitelist';
1011
+ try {
1012
+ await api('connectors.setTools', { key: connector.key, mode, tools: allowed, autoApprove });
1013
+ reload();
1014
+ } catch (err) { toast(err.message, true); }
1015
+ };
854
1016
  const doDelete = async () => {
855
1017
  setBusyDelete(true);
856
1018
  try { await api('connectors.delete', { key: deleting.key }); toast(t('deleted')); setDeleting(undefined); reload(); }
@@ -863,6 +1025,8 @@ window.__ModuleLoader__.load({
863
1025
  const connectors = allConnectors.filter((c) =>
864
1026
  (!groupFilter || c.group === groupFilter) &&
865
1027
  (!needle || (c.label + ' ' + c.serverName + ' ' + c.key + ' ' + (c.transport ?? '') + ' ' + (c.url || '') + ' ' + (c.command || '')).toLowerCase().includes(needle)));
1028
+ // 审批策略:Host 若暴露则读取(列表级或行级),缺省视为非 never
1029
+ const approvalNever = (state.data?.approvalPolicy ?? '') === 'never';
866
1030
  return h('div', null,
867
1031
  h('div', { className: 'mc-toolbar' },
868
1032
  h('span', { className: 'mc-spacer' }),
@@ -877,26 +1041,80 @@ window.__ModuleLoader__.load({
877
1041
  state.status === 'error' && h('div', { className: 'mc-error' }, t('loadFailed') + ': ' + state.error.message, h(Button, { onClick: reload }, t('retry'))),
878
1042
  state.status !== 'error' && h('div', { className: 'mc-grid' },
879
1043
  connectors.length === 0 && state.status === 'done' && h('div', { className: 'mc-empty' }, t('empty')),
880
- connectors.map((connector) => h('div', { key: connector.key, className: 'mc-card' },
881
- h('div', { className: 'mc-card-title' }, connector.label || connector.serverName,
882
- connector.group && h(Badge, { tone: 'info' }, connector.group),
883
- h(Badge, { tone: 'brand' }, connector.transport),
884
- connector.external
885
- ? h(Badge, null, t('connectorExternal'))
886
- : connector.live
887
- ? h(Badge, { tone: connector.enabled === false ? undefined : 'success' }, connector.enabled === false ? t('disabled') : t('connectorLive'))
888
- : h(Badge, { tone: 'warn' }, t('connectorStopped')),
889
- ),
890
- h('div', { className: 'mc-card-meta' }, 'mcp__' + connector.serverName + '__*'),
891
- h('div', { className: 'mc-card-desc' }, connector.transport === 'stdio'
892
- ? [connector.command, ...(connector.args ?? [])].join(' ')
893
- : connector.url),
894
- !connector.external && h('div', { className: 'mc-card-actions' },
895
- h(Switch, { checked: connector.enabled !== false, onChange: (v) => toggle(connector, v), title: connector.enabled !== false ? t('disable') : t('enable') }),
896
- h(Button, { className: 'link', onClick: () => setEditing(connector) }, t('edit')),
897
- h(Button, { className: 'link danger', onClick: () => setDeleting(connector) }, t('delete')),
898
- ),
899
- )),
1044
+ connectors.map((connector) => {
1045
+ const result = testResults[connector.key];
1046
+ const testing = result?.status === 'testing';
1047
+ const tools = result?.ok ? (result.tools ?? []) : [];
1048
+ const policy = connector.toolPolicy ?? { mode: 'all', tools: [], autoApprove: [] };
1049
+ const allowedSet = new Set(policy.mode === 'all' ? tools.map((tool) => tool.name) : (policy.tools ?? []));
1050
+ const autoSet = new Set(policy.autoApprove ?? []);
1051
+ const open = expanded[connector.key] === true;
1052
+ const saveTools = (nextAllowed, nextAuto) =>
1053
+ setTools(connector, tools.map((tool) => tool.name).filter((name) => nextAllowed.has(name)), tools.map((tool) => tool.name).filter((name) => nextAuto.has(name)));
1054
+ return h('div', { key: connector.key, className: 'mc-card' },
1055
+ h('div', { className: 'mc-card-title' }, connector.label || connector.serverName,
1056
+ connector.group && h(Badge, { tone: 'info' }, connector.group),
1057
+ h(Badge, { tone: 'brand' }, connector.transport),
1058
+ connector.external
1059
+ ? h(Badge, null, t('connectorExternal'))
1060
+ : connector.live
1061
+ ? h(Badge, { tone: connector.enabled === false ? undefined : 'success' }, connector.enabled === false ? t('disabled') : t('connectorLive'))
1062
+ : h(Badge, { tone: 'warn' }, t('connectorStopped')),
1063
+ ),
1064
+ h('div', { className: 'mc-card-meta' }, 'mcp__' + connector.serverName + '__*'),
1065
+ h('div', { className: 'mc-card-desc' }, connector.transport === 'stdio'
1066
+ ? [connector.command, ...(connector.args ?? [])].join(' ')
1067
+ : connector.url),
1068
+ !connector.external && h('div', { className: 'mc-card-actions' },
1069
+ h(Switch, { checked: connector.enabled !== false, onChange: (v) => toggle(connector, v), title: connector.enabled !== false ? t('disable') : t('enable') }),
1070
+ h(Button, { className: 'link', disabled: testing, onClick: () => test(connector) }, testing ? '…' : t('connectorTest')),
1071
+ h(Badge, {
1072
+ tone: result?.ok ? 'success' : result?.ok === false ? 'error' : undefined,
1073
+ title: result?.ok ? result.latencyMs + ' ms' : (result?.error ?? ''),
1074
+ }, testing
1075
+ ? '…'
1076
+ : result?.ok
1077
+ ? t('connectorConnected').replace('{n}', String(result.tools?.length ?? 0))
1078
+ : result?.ok === false
1079
+ ? t('connectorTestFailed').replace('{reason}', (result.error || '').slice(0, 60))
1080
+ : t('connectorUntested')),
1081
+ h('span', { className: 'mc-spacer' }),
1082
+ h(Button, { className: 'link', onClick: () => setExpanded({ ...expanded, [connector.key]: !open }) }, (open ? '▾ ' : '▸ ') + t('connectorTools')),
1083
+ h(Button, { className: 'link', onClick: () => setEditing(connector) }, t('edit')),
1084
+ h(Button, { className: 'link danger', onClick: () => setDeleting(connector) }, t('delete')),
1085
+ ),
1086
+ !connector.external && open && h('div', null,
1087
+ tools.length === 0 && h('div', { className: 'mc-row-sub', style: { marginBottom: 6 } }, t('connectorToolsEmpty')),
1088
+ tools.map((tool) => h('div', { key: tool.name, className: 'mc-row', style: { padding: '6px 10px', marginBottom: 6 } },
1089
+ h('div', { className: 'mc-row-main' },
1090
+ h('div', { className: 'mc-row-title' }, tool.name),
1091
+ tool.description && h('div', { className: 'mc-row-sub' }, tool.description),
1092
+ ),
1093
+ h('label', { className: 'mc-checkbox', style: { fontSize: 12 } },
1094
+ h('input', {
1095
+ type: 'checkbox', checked: allowedSet.has(tool.name),
1096
+ onChange: (e) => {
1097
+ const nextAllowed = new Set(allowedSet);
1098
+ const nextAuto = new Set(autoSet);
1099
+ if (e.target.checked) nextAllowed.add(tool.name);
1100
+ else { nextAllowed.delete(tool.name); nextAuto.delete(tool.name); }
1101
+ saveTools(nextAllowed, nextAuto);
1102
+ },
1103
+ }), t('connectorToolAllowed')),
1104
+ h('label', { className: 'mc-checkbox', style: { fontSize: 12 } },
1105
+ h('input', {
1106
+ type: 'checkbox', checked: autoSet.has(tool.name), disabled: !allowedSet.has(tool.name),
1107
+ onChange: (e) => {
1108
+ const nextAuto = new Set(autoSet);
1109
+ if (e.target.checked) nextAuto.add(tool.name); else nextAuto.delete(tool.name);
1110
+ saveTools(allowedSet, nextAuto);
1111
+ },
1112
+ }), t('connectorToolAuto')),
1113
+ )),
1114
+ approvalNever && h('div', { className: 'mc-hint' }, t('connectorApprovalNever')),
1115
+ ),
1116
+ );
1117
+ }),
900
1118
  ),
901
1119
  editing && h(ConnectorEditModal, { t, connector: editing.key ? editing : undefined, onClose: () => setEditing(undefined), onSaved: () => { setEditing(undefined); toast(t('saved')); reload(); } }),
902
1120
  deleting && h(ConfirmModal, { t, title: t('confirmDelete'), message: t('confirmDeleteBody').replace('{name}', deleting.label || deleting.serverName), busy: busyDelete, onClose: () => setDeleting(undefined), onConfirm: doDelete }),
@@ -957,14 +1175,29 @@ window.__ModuleLoader__.load({
957
1175
  // undefined(旧数据)= 全部技能;编辑时显式化为全选列表
958
1176
  skills: expert?.skills ?? (expert ? allSkills.map((s) => s.name) : []),
959
1177
  connectors: expert?.connectors ?? [],
1178
+ // 模型绑定:provider+model 均填写才提交;否则缺省不传(跟随默认)
1179
+ modelProvider: expert?.model?.provider ?? '',
1180
+ modelModel: expert?.model?.model ?? '',
1181
+ modelReasoning: expert?.model?.reasoningEffort ?? '',
1182
+ // 工具组白名单:空数组/缺省 = 全部可用
1183
+ tools: expert?.tools ?? [],
960
1184
  });
961
1185
  const [busy, setBusy] = useState(false);
962
1186
  const [error, setError] = useState('');
1187
+ const modelError = (form.modelProvider && !form.modelModel) || (!form.modelProvider && form.modelModel)
1188
+ ? t('expertModelPartial') : '';
963
1189
  const set = (key) => (e) => setForm({ ...form, [key]: e.target.value });
964
1190
  const submit = async () => {
1191
+ if (modelError) return;
965
1192
  setBusy(true); setError('');
966
1193
  try {
967
- await api(isEdit ? 'experts.update' : 'experts.create', form);
1194
+ const { modelProvider, modelModel, modelReasoning, ...rest } = form;
1195
+ const payload = { ...rest };
1196
+ if (modelProvider && modelModel) {
1197
+ payload.model = { provider: modelProvider, model: modelModel };
1198
+ if (modelReasoning) payload.model.reasoningEffort = modelReasoning;
1199
+ }
1200
+ await api(isEdit ? 'experts.update' : 'experts.create', payload);
968
1201
  onSaved();
969
1202
  } catch (err) { setError(err.message); } finally { setBusy(false); }
970
1203
  };
@@ -981,6 +1214,25 @@ window.__ModuleLoader__.load({
981
1214
  h(Field, { label: t('expertDescription') }, h('input', { className: 'mc-input', value: form.description, onChange: set('description') })),
982
1215
  h(Field, { label: t('group') }, h('input', { className: 'mc-input', value: form.group, onChange: set('group'), placeholder: t('groupPlaceholder') })),
983
1216
  h(Field, { label: t('expertPrefix') }, h('textarea', { className: 'mc-textarea', value: form.prefix, onChange: set('prefix'), placeholder: 'You are …' })),
1217
+ h(Field, { label: t('expertModel') },
1218
+ h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1219
+ h('input', { className: 'mc-input', style: { flex: 1, minWidth: 140 }, placeholder: t('expertModelProvider'), value: form.modelProvider, onChange: set('modelProvider') }),
1220
+ h('input', { className: 'mc-input', style: { flex: 1, minWidth: 140 }, placeholder: t('expertModelName'), value: form.modelModel, onChange: set('modelModel') }),
1221
+ h('select', { className: 'mc-select', value: form.modelReasoning, onChange: set('modelReasoning'), title: 'reasoningEffort' },
1222
+ h('option', { value: '' }, t('expertModelDefault')),
1223
+ ['low', 'medium', 'high'].map((v) => h('option', { key: v, value: v }, v)))),
1224
+ h('span', { className: 'mc-hint' }, t('expertModelHint')),
1225
+ modelError && h('span', { className: 'mc-field-error' }, modelError)),
1226
+ h(Field, { label: t('expertTools') },
1227
+ h('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
1228
+ [['fs', t('expertToolFs')], ['web', t('expertToolWeb')], ['shell', t('expertToolShell')], ['skills', t('expertToolSkills')]].map(([value, labelText]) =>
1229
+ h('label', { key: value, className: 'mc-checkbox', style: { fontSize: 12 } },
1230
+ h('input', {
1231
+ type: 'checkbox', checked: form.tools.includes(value),
1232
+ onChange: (e) => setForm({ ...form, tools: e.target.checked ? [...form.tools, value] : form.tools.filter((v) => v !== value) }),
1233
+ }),
1234
+ labelText))),
1235
+ h('span', { className: 'mc-hint' }, t('expertToolsHint'))),
984
1236
  h(LinkPicker, {
985
1237
  t, title: t('expertSkills'), items: allSkills,
986
1238
  selected: form.skills.filter((n) => allSkills.some((s) => s.name === n)),
@@ -1040,9 +1292,14 @@ window.__ModuleLoader__.load({
1040
1292
  expert.custom ? h(Badge, { tone: 'brand' }, t('expertCustom')) : h(Badge, null, t('expertBuiltin')),
1041
1293
  expert.isDefault && h(Badge, { tone: 'success' }, t('expertDefault')),
1042
1294
  expert.broken && h(Badge, { tone: 'error' }, t('expertBroken')),
1295
+ expert.model && h(Badge, { tone: 'brand' }, expert.model.provider + '/' + expert.model.model),
1043
1296
  ),
1044
1297
  h('div', { className: 'mc-card-desc' }, expert.description || '—'),
1045
1298
  h('div', { className: 'mc-card-meta' }, expert.id + (expert.broken ? ' · ' + expert.broken : '')),
1299
+ expert.custom && (expert.tools ?? []).length > 0 && h('div', { className: 'mc-card-meta' },
1300
+ t('expertToolsSummary') + ': ' + expert.tools
1301
+ .map((v) => ({ fs: t('expertToolFs'), web: t('expertToolWeb'), shell: t('expertToolShell'), skills: t('expertToolSkills') }[v] ?? v))
1302
+ .join(', ')),
1046
1303
  expert.custom && (expert.skills !== undefined || expert.connectors !== undefined) && h('div', { className: 'mc-card-meta' },
1047
1304
  t('expertLinkedMeta')
1048
1305
  .replace('{skills}', expert.skills === undefined ? t('expertSkillsAll') : expert.skills.length)
package/index.js CHANGED
@@ -13,7 +13,10 @@
13
13
  import { promises as fs } from 'node:fs';
14
14
  import os from 'node:os';
15
15
  import { join, resolve, sep, basename } from 'node:path';
16
- import { inflateRawSync } from 'node:zlib';
16
+ import { inflateRawSync, gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib';
17
+ import { spawn } from 'node:child_process';
18
+ import http from 'node:http';
19
+ import https from 'node:https';
17
20
 
18
21
  const DSH_HOME = process.env.DSH_HOME || join(os.homedir(), '.dsh');
19
22
  const DATA_DIR = join(DSH_HOME, 'manager-center');
@@ -24,6 +27,8 @@ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
24
27
  const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/;
25
28
  const MCP_PACKAGE = '@deepseek-ai/dsh-mcp-client';
26
29
  const CONNECTOR_PREFIX = 'mcm-';
30
+ const MASKED_VALUE = '••••••'; // FR-C3:脱敏掩码(前后端约定字面量)
31
+ const CONNECTOR_TEST_TIMEOUT_MS = 8000; // FR-C1:testConnector 单项总超时
27
32
 
28
33
  /* ───────────────────────── 内置技能市场 ───────────────────────── */
29
34
 
@@ -282,9 +287,23 @@ function parseSkillFile(text) {
282
287
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(source);
283
288
  if (!m) return { fields: {}, body: source };
284
289
  const fields = {};
285
- for (const line of m[1].split(/\r?\n/)) {
290
+ const lines = m[1].split(/\r?\n/);
291
+ for (let i = 0; i < lines.length; i++) {
292
+ const line = lines[i];
286
293
  const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
287
- if (kv) fields[kv[1]] = parseYamlScalar(kv[2]);
294
+ if (!kv) continue;
295
+ // metadata 为嵌套键值块:收集其后缩进的子行
296
+ if (kv[1] === 'metadata' && kv[2] === '') {
297
+ const meta = {};
298
+ while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
299
+ i++;
300
+ const mk = /^\s+([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
301
+ if (mk) meta[mk[1]] = parseYamlScalar(mk[2]);
302
+ }
303
+ fields.metadata = meta;
304
+ continue;
305
+ }
306
+ fields[kv[1]] = parseYamlScalar(kv[2]);
288
307
  }
289
308
  return { fields, body: m[2] };
290
309
  }
@@ -294,12 +313,65 @@ function buildSkillFile(input) {
294
313
  lines.push(`name: ${yamlScalar(input.name)}`);
295
314
  lines.push(`description: ${yamlScalar(input.description)}`);
296
315
  if (input.whenToUse) lines.push(`whenToUse: ${yamlScalar(input.whenToUse)}`);
316
+ // 以下四个字段遵循 agentskills.io 规范,有值才写(旧技能缺省字段读为空、不回写)
317
+ if (input.license) lines.push(`license: ${yamlScalar(input.license)}`);
318
+ if (input.compatibility) lines.push(`compatibility: ${yamlScalar(input.compatibility)}`);
319
+ if (input.metadata && typeof input.metadata === 'object' && !Array.isArray(input.metadata)) {
320
+ const keys = Object.keys(input.metadata);
321
+ if (keys.length) {
322
+ lines.push('metadata:');
323
+ for (const key of keys) lines.push(` ${key}: ${yamlScalar(input.metadata[key])}`);
324
+ }
325
+ }
326
+ if (input.allowedTools) lines.push(`allowed-tools: ${yamlScalar(input.allowedTools)}`);
297
327
  if (input.disableModelInvocation === true) lines.push('disable-model-invocation: true');
298
328
  if (input.userInvocable === false) lines.push('user-invocable: false');
299
329
  lines.push('---', '');
300
330
  return lines.join('\n') + String(input.content ?? '').replace(/^\s+/, '');
301
331
  }
302
332
 
333
+ /* ───────────────────────── 技能规范校验(FR-S2) ───────────────────────── */
334
+
335
+ /**
336
+ * 按 agentskills.io 规范校验技能字段。不抛异常,返回 { errors: [{ field, message }] }。
337
+ * field 取值:name / description / compatibility / license / metadata。
338
+ */
339
+ function validateSkillSpec(p) {
340
+ const errors = [];
341
+ const name = String(p?.name ?? '');
342
+ if (!KEBAB.test(name)) errors.push({ field: 'name', message: '技能名须为小写 kebab-case(如 my-skill)' });
343
+ else if (name.length > 64) errors.push({ field: 'name', message: '技能名长度不能超过 64 个字符' });
344
+ const description = String(p?.description ?? '');
345
+ if (!description) errors.push({ field: 'description', message: 'description 必填' });
346
+ else if (description.length > 1024) errors.push({ field: 'description', message: 'description 长度不能超过 1024 个字符' });
347
+ const compatibility = p?.compatibility == null ? '' : String(p.compatibility);
348
+ if (compatibility.length > 500) errors.push({ field: 'compatibility', message: 'compatibility 长度不能超过 500 个字符' });
349
+ const license = p?.license == null ? '' : String(p.license);
350
+ if (license.length > 128) errors.push({ field: 'license', message: 'license 长度不能超过 128 个字符' });
351
+ if (p?.metadata != null) {
352
+ if (typeof p.metadata !== 'object' || Array.isArray(p.metadata)) {
353
+ errors.push({ field: 'metadata', message: 'metadata 须为键值对对象(map)' });
354
+ } else {
355
+ for (const [key, value] of Object.entries(p.metadata)) {
356
+ if (typeof value !== 'string') {
357
+ errors.push({ field: 'metadata', message: `metadata 键 "${key}" 的值须为字符串` });
358
+ }
359
+ }
360
+ }
361
+ }
362
+ return { errors };
363
+ }
364
+
365
+ /** 校验不过则抛出汇总错误(逐字段列出,供前端定位展示)。error.errors 供路由层透传字段级错误。 */
366
+ function assertSkillSpec(p) {
367
+ const { errors } = validateSkillSpec(p);
368
+ if (errors.length) {
369
+ const error = new Error('技能校验失败:\n' + errors.map((e) => `- ${e.field}:${e.message}`).join('\n'));
370
+ error.errors = errors;
371
+ throw error;
372
+ }
373
+ }
374
+
303
375
  function underSkillRoot(path) {
304
376
  if (typeof path !== 'string' || path === '') return false;
305
377
  const rel = resolve(path).toLowerCase();
@@ -516,6 +588,298 @@ async function removeConnectorEntry(ctx, key) {
516
588
  if (loaderHas(ctx, id)) await ctx.loader.remove(id);
517
589
  }
518
590
 
591
+ /* ──────────────── FR-C1/C2/C3:脱敏、状态缓存、工具探测、审批策略 ──────────────── */
592
+
593
+ /** 易失状态缓存:testConnector 结果(含失败原因),不持久化,重启即失。 */
594
+ const connectorStatus = new Map();
595
+
596
+ /** 脱敏输出:env/headers 的值一律替换为掩码,并附被掩码的键名列表。结构其余部分不变。 */
597
+ function maskConnectorSecrets(record) {
598
+ const out = { ...record };
599
+ const maskedKeys = [];
600
+ for (const field of ['env', 'headers']) {
601
+ if (out[field] && typeof out[field] === 'object') {
602
+ out[field] = Object.fromEntries(Object.entries(out[field]).map(([k, v]) => {
603
+ maskedKeys.push(k);
604
+ return [k, MASKED_VALUE];
605
+ }));
606
+ }
607
+ }
608
+ out.maskedKeys = maskedKeys;
609
+ return out;
610
+ }
611
+
612
+ /** 入参脱敏回填:env/headers 中值为掩码的键表示「保持原值」,从旧记录回填明文,不得落盘掩码。 */
613
+ function unmaskConnectorSecrets(input, old) {
614
+ if (!input || typeof input !== 'object') return input;
615
+ const out = { ...input };
616
+ for (const field of ['env', 'headers']) {
617
+ const values = out[field];
618
+ if (!values || typeof values !== 'object') continue;
619
+ const merged = { ...values };
620
+ for (const k of Object.keys(merged)) {
621
+ if (merged[k] === MASKED_VALUE && old?.[field] && old[field][k] !== undefined) {
622
+ merged[k] = old[field][k];
623
+ }
624
+ }
625
+ out[field] = merged;
626
+ }
627
+ return out;
628
+ }
629
+
630
+ /** 统一 tools/list 结果整形:仅保留 name/description,名称为原始名(不含 mcp__ 前缀)。 */
631
+ function normalizeToolList(list) {
632
+ return (Array.isArray(list) ? list : []).map((t) => ({
633
+ name: String(t?.name ?? ''),
634
+ description: String(t?.description ?? ''),
635
+ })).filter((t) => t.name);
636
+ }
637
+
638
+ /**
639
+ * streamable-http 单次 JSON-RPC POST;兼容 text/event-stream 与 gzip/deflate 响应。
640
+ * 返回 { msg, sessionId }:msg 为解析后的 JSON-RPC 消息;sessionId 取响应 mcp-session-id
641
+ * 头(stateful 服务端在 initialize 后要求后续请求带回,缺失会被 400 拒绝)。
642
+ */
643
+ function httpJsonRpcPost(urlString, headers, payload, timeoutMs, sessionId) {
644
+ return new Promise((resolvePromise, rejectPromise) => {
645
+ let target;
646
+ try { target = new URL(urlString); } catch { rejectPromise(new Error('url 不是合法地址')); return; }
647
+ const lib = target.protocol === 'https:' ? https : http;
648
+ const body = JSON.stringify(payload);
649
+ const req = lib.request(target, {
650
+ method: 'POST',
651
+ headers: {
652
+ 'content-type': 'application/json',
653
+ accept: 'application/json, text/event-stream',
654
+ 'accept-encoding': 'identity', // 明示不压缩;部分 CDN 仍强推 gzip,响应侧再做兼容解码
655
+ ...(sessionId ? { 'mcp-session-id': sessionId } : {}),
656
+ ...(headers && typeof headers === 'object' ? headers : {}),
657
+ 'content-length': Buffer.byteLength(body),
658
+ },
659
+ timeout: timeoutMs,
660
+ }, (res) => {
661
+ const chunks = [];
662
+ res.on('data', (chunk) => chunks.push(chunk));
663
+ res.on('end', () => {
664
+ let raw = Buffer.concat(chunks);
665
+ try {
666
+ const enc = String(res.headers['content-encoding'] ?? '').toLowerCase();
667
+ if (enc === 'gzip') raw = gunzipSync(raw);
668
+ else if (enc === 'deflate') raw = inflateSync(raw);
669
+ else if (enc === 'br') raw = brotliDecompressSync(raw);
670
+ } catch (err) {
671
+ rejectPromise(new Error(`响应压缩解码失败(${String(res.headers['content-encoding'])}): ${err?.message ?? err}`));
672
+ return;
673
+ }
674
+ const text = raw.toString('utf8');
675
+ if (res.statusCode < 200 || res.statusCode >= 300) {
676
+ rejectPromise(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
677
+ return;
678
+ }
679
+ const nextSid = res.headers['mcp-session-id'] ?? sessionId;
680
+ try {
681
+ if (String(res.headers['content-type'] ?? '').includes('text/event-stream')) {
682
+ const dataLines = text.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).filter(Boolean);
683
+ let msg;
684
+ for (let i = dataLines.length - 1; i >= 0; i--) {
685
+ try { msg = JSON.parse(dataLines[i]); break; } catch { /* 继续往前找 */ }
686
+ }
687
+ if (msg === undefined) throw new Error('no-json');
688
+ resolvePromise({ msg, sessionId: nextSid });
689
+ } else {
690
+ resolvePromise({ msg: JSON.parse(text), sessionId: nextSid });
691
+ }
692
+ } catch {
693
+ rejectPromise(new Error(`响应不是合法 JSON:${text.slice(0, 120)}`));
694
+ }
695
+ });
696
+ });
697
+ req.on('timeout', () => req.destroy(new Error('请求超时')));
698
+ req.on('error', (err) => rejectPromise(err instanceof Error ? err : new Error(String(err))));
699
+ req.write(body);
700
+ req.end();
701
+ });
702
+ }
703
+
704
+ function jsonRpcErrorMessage(msg) {
705
+ return msg?.error?.message ? String(msg.error.message) : 'JSON-RPC 返回错误';
706
+ }
707
+
708
+ /** streamable-http 探测:initialize → notifications/initialized → tools/list。
709
+ * 带回 mcp-session-id(stateful 服务端强校验);initialize 报协议版本错误时降级重试旧版本。 */
710
+ async function probeHttpConnector(record, timeoutMs) {
711
+ const tryInit = async (protocolVersion) => {
712
+ const init = await httpJsonRpcPost(record.url, record.headers, {
713
+ jsonrpc: '2.0', id: 1, method: 'initialize',
714
+ params: {
715
+ protocolVersion,
716
+ capabilities: {},
717
+ clientInfo: { name: 'dsh-manager-center', version: '1.8.1' },
718
+ },
719
+ }, timeoutMs);
720
+ return init;
721
+ };
722
+ let init = await tryInit('2025-06-18');
723
+ if (init.msg?.error && /protocol/i.test(String(init.msg.error.message ?? ''))) {
724
+ init = await tryInit('2024-11-05');
725
+ }
726
+ if (init.msg?.error) throw new Error(jsonRpcErrorMessage(init.msg));
727
+ const sessionId = init.sessionId; // stateful 服务端在 initialize 响应头发 mcp-session-id
728
+ try {
729
+ await httpJsonRpcPost(record.url, record.headers, { jsonrpc: '2.0', method: 'notifications/initialized' }, timeoutMs, sessionId);
730
+ } catch { /* initialized 通知失败不阻断(部分服务端不要求) */ }
731
+ const list = await httpJsonRpcPost(record.url, record.headers, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, timeoutMs, sessionId);
732
+ if (list.msg?.error) throw new Error(jsonRpcErrorMessage(list.msg));
733
+ return normalizeToolList(list.msg?.result?.tools);
734
+ }
735
+
736
+ /**
737
+ * stdio 传输层探测:对给定子进程跑 initialize → notifications/initialized → tools/list。
738
+ * 读 stdout 按行解析 JSON-RPC 响应;超时/进程退出即失败;结束务必 kill 防泄漏。
739
+ */
740
+ function probeStdioTransport(child, timeoutMs) {
741
+ return new Promise((resolvePromise, rejectPromise) => {
742
+ const started = Date.now();
743
+ let settled = false;
744
+ let stdoutBuf = '';
745
+ let forceKillTimer;
746
+ const cleanup = () => {
747
+ clearTimeout(forceKillTimer);
748
+ try { child.stdin.end(); } catch { /* 进程可能已退出 */ }
749
+ child.kill();
750
+ // Windows 上 SIGTERM 可能杀不掉:宽限 500ms 后强制结束,防泄漏
751
+ forceKillTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* ignore */ } }, 500);
752
+ forceKillTimer.unref?.();
753
+ };
754
+ const finish = (err, tools) => {
755
+ if (settled) return;
756
+ settled = true;
757
+ clearTimeout(timer);
758
+ cleanup();
759
+ if (err) rejectPromise(err);
760
+ else resolvePromise({ latencyMs: Date.now() - started, tools });
761
+ };
762
+ const timer = setTimeout(() => finish(new Error('连接超时')), timeoutMs);
763
+ child.on('error', (err) => finish(new Error(`无法启动进程: ${err?.message ?? err}`)));
764
+ child.on('exit', (code, signal) => {
765
+ finish(new Error(`进程已退出(code=${code ?? 'null'}${signal ? `, signal=${signal}` : ''}),未返回工具清单`));
766
+ });
767
+ child.stderr.resume(); // 丢弃 stderr,防管道缓冲顶死
768
+ child.stdout.on('data', (chunk) => {
769
+ stdoutBuf += chunk.toString('utf8');
770
+ let idx;
771
+ while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
772
+ const line = stdoutBuf.slice(0, idx).trim();
773
+ stdoutBuf = stdoutBuf.slice(idx + 1);
774
+ if (!line) continue;
775
+ let msg;
776
+ try { msg = JSON.parse(line); } catch { continue; } // 忽略非 JSON 日志行
777
+ if (msg?.id !== 2 || settled) continue;
778
+ if (msg.error) finish(new Error(jsonRpcErrorMessage(msg)));
779
+ else finish(null, normalizeToolList(msg?.result?.tools));
780
+ }
781
+ });
782
+ const send = (msg) => {
783
+ try { child.stdin.write(JSON.stringify(msg) + '\n'); } catch { /* 进程可能已退出,由 exit 事件收尾 */ }
784
+ };
785
+ send({
786
+ jsonrpc: '2.0', id: 1, method: 'initialize',
787
+ params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'dsh-manager-center', version: '1.8.0' } },
788
+ });
789
+ send({ jsonrpc: '2.0', method: 'notifications/initialized' });
790
+ send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
791
+ });
792
+ }
793
+
794
+ /** stdio 连接器探测:spawn 子进程(env 合并 process.env),再走统一传输层。绝不同步抛出。
795
+ * Windows 兼容:npx/uvx 等是 .cmd 脚本 shim,CreateProcess 无法直接执行,
796
+ * 无扩展名命令统一经 shell(cmd.exe /d /s /c)启动,与内核 MCP SDK 行为一致。 */
797
+ function probeStdioConnector(record, timeoutMs) {
798
+ let child;
799
+ try {
800
+ const command = String(record.command ?? '');
801
+ child = spawn(command, Array.isArray(record.args) ? record.args : [], {
802
+ env: { ...process.env, ...(record.env && typeof record.env === 'object' ? record.env : {}) },
803
+ ...(record.cwd ? { cwd: record.cwd } : {}),
804
+ ...(process.platform === 'win32' && !/\.[a-z0-9]+$/i.test(command) ? { shell: true } : {}),
805
+ stdio: ['pipe', 'pipe', 'pipe'],
806
+ });
807
+ } catch (err) {
808
+ return Promise.reject(new Error(`无法启动进程: ${err?.message ?? err}`));
809
+ }
810
+ return probeStdioTransport(child, timeoutMs);
811
+ }
812
+
813
+ /* ── FR-C2 审批 answerer:serverName → 工具策略(mode/tools/autoApprove),连接器增删改/启停时刷新 ── */
814
+
815
+ let approvalPolicy = new Map();
816
+
817
+ function refreshApprovalPolicy() {
818
+ const next = new Map();
819
+ for (const c of store.connectors) {
820
+ next.set(c.serverName, {
821
+ enabled: c.enabled !== false,
822
+ mode: c.toolPolicy?.mode === 'whitelist' ? 'whitelist' : 'all',
823
+ tools: Array.isArray(c.toolPolicy?.tools) ? c.toolPolicy.tools.map(String) : [],
824
+ autoApprove: Array.isArray(c.toolPolicy?.autoApprove) ? c.toolPolicy.autoApprove.map(String) : [],
825
+ });
826
+ }
827
+ approvalPolicy = next;
828
+ }
829
+
830
+ /** 工具名匹配 mcp__<serverName>__<tool>:返回命中策略与原始工具名。 */
831
+ function matchConnectorTool(toolName) {
832
+ for (const [serverName, policy] of approvalPolicy) {
833
+ const prefix = `mcp__${serverName}__`;
834
+ if (toolName.startsWith(prefix)) return { policy, tool: toolName.slice(prefix.length) };
835
+ }
836
+ return undefined;
837
+ }
838
+
839
+ /**
840
+ * approval/request 瀑布监听(形态抄内核 dsh-acp:ctx.on('approval/request', (request, next) => ...))。
841
+ * 仅裁决本插件创建的连接器工具;其余请求 next() 透传给后续 answerer(如人工审批面板)。
842
+ * outcome 词表(allowed-once/rejected/cancelled/unavailable)无原因通道,
843
+ * 白名单拒绝时把中文原因写入 request.reason,随 approval/asked 审计事件留痕。
844
+ */
845
+ function managerApprovalAnswerer(request, next) {
846
+ try {
847
+ const toolName = String(request?.toolName ?? '');
848
+ const hit = matchConnectorTool(toolName);
849
+ if (!hit || !hit.policy.enabled) return next();
850
+ const { policy, tool } = hit;
851
+ if (policy.autoApprove.includes(tool)) return 'allowed-once';
852
+ if (policy.mode === 'whitelist' && !policy.tools.includes(tool)) {
853
+ try {
854
+ request.reason = `连接器工具白名单已拦截:「${toolName}」不在允许列表(当前为白名单模式,仅放行 ${policy.tools.length} 个工具)`;
855
+ } catch { /* request 只读时忽略,不影响拒绝语义 */ }
856
+ return 'rejected';
857
+ }
858
+ return next(); // 其余情况交人工审批
859
+ } catch {
860
+ return next(); // answerer 自身故障不得阻断审批链
861
+ }
862
+ }
863
+
864
+ /**
865
+ * 读取部署级审批策略默认值(dsh-user-approval 的 config.policy,ask|never)。
866
+ * client 据 listConnectors 根部的 approvalPolicy 显示「自动执行不生效」提示;
867
+ * 会话级 override 不影响此提示(提示针对部署默认口径)。
868
+ */
869
+ function approvalPolicyDefault(ctx) {
870
+ try {
871
+ const configEditor = ctx.get('configEditor');
872
+ if (configEditor) {
873
+ for (const row of configEditor.configuration()) {
874
+ if (row?.entry?.name !== '@deepseek-ai/dsh-user-approval') continue;
875
+ const config = { ...(row?.inherited ?? {}), ...(row?.override ?? {}) };
876
+ if (config.policy === 'ask' || config.policy === 'never') return config.policy;
877
+ }
878
+ }
879
+ } catch { /* configEditor 不可用时按 ask 处理 */ }
880
+ return 'ask';
881
+ }
882
+
519
883
  /* ───────────────────────── 智能体(专家)预设 ───────────────────────── */
520
884
 
521
885
  const EXPERT_SKILL_ROOT = join(DATA_DIR, 'expert-skills');
@@ -533,6 +897,35 @@ function normalizeExpertConnectors(value) {
533
897
  return [...new Set(value.map((v) => String(v)).filter((k) => valid.has(k)))];
534
898
  }
535
899
 
900
+ /* FR-A1:模型绑定落地 = 存储 + 展示(预设组合内挂 dsh-agent-default-model 不生效,入口点 selectionFor() 读自身 scope)。 */
901
+ /** 规范化模型绑定:provider/model 为非空字符串才生效;否则 undefined(跟随默认)。 */
902
+ function normalizeExpertModel(value) {
903
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
904
+ const provider = String(value.provider ?? '').trim();
905
+ const model = String(value.model ?? '').trim();
906
+ if (!provider || !model) return undefined;
907
+ const out = { provider, model };
908
+ const reasoningEffort = String(value.reasoningEffort ?? '').trim();
909
+ if (reasoningEffort) out.reasoningEffort = reasoningEffort;
910
+ return out;
911
+ }
912
+
913
+ /* FR-A2:工具组白名单(组粒度)。 */
914
+ const EXPERT_TOOL_GROUPS = ['fs', 'web', 'shell', 'skills'];
915
+ /** 工具组 → expertPlugins 行 id 映射;persona/compaction 为基础设施,恒保留。 */
916
+ const EXPERT_TOOL_GROUP_ROWS = {
917
+ fs: new Set(['tool-fs', 'tool-fs-search']),
918
+ web: new Set(['tool-web']),
919
+ shell: new Set(['persistent-shell']),
920
+ skills: new Set(['skill-fs', 'tool-skill']),
921
+ };
922
+
923
+ /** 规范化工具组:仅保留合法组名去重;非数组 → undefined(全部可用)。空数组语义 = 全部。 */
924
+ function normalizeExpertTools(value) {
925
+ if (!Array.isArray(value)) return undefined;
926
+ return [...new Set(value.map((v) => String(v)).filter((g) => EXPERT_TOOL_GROUPS.includes(g)))];
927
+ }
928
+
536
929
  /** 为智能体重建专属技能链接目录:仅包含所选技能(junction/符号链接,失败退化为复制)。 */
537
930
  async function rebuildExpertSkillLinks(expert) {
538
931
  const dir = join(EXPERT_SKILL_ROOT, expert.id);
@@ -631,6 +1024,17 @@ function expertPlugins(expert, skillScope) {
631
1024
  { id: 'tool-skill', name: '@deepseek-ai/dsh-tool-skill' },
632
1025
  );
633
1026
  }
1027
+ // FR-A2 工具组白名单:未选中的组对应行不输出;persona/compaction 恒保留;空数组/缺省 = 保持现状全部
1028
+ const groups = Array.isArray(expert.tools) ? expert.tools.filter((g) => EXPERT_TOOL_GROUPS.includes(g)) : [];
1029
+ if (groups.length > 0) {
1030
+ const allowed = new Set(groups.flatMap((g) => [...EXPERT_TOOL_GROUP_ROWS[g]]));
1031
+ return plugins.filter((plugin) => {
1032
+ for (const rows of Object.values(EXPERT_TOOL_GROUP_ROWS)) {
1033
+ if (rows.has(plugin.id)) return allowed.has(plugin.id);
1034
+ }
1035
+ return true; // 基础设施行(persona/compaction)不受过滤
1036
+ });
1037
+ }
634
1038
  return plugins;
635
1039
  }
636
1040
 
@@ -668,6 +1072,7 @@ function createApi(ctx, diagnostics) {
668
1072
  }
669
1073
 
670
1074
  async function provisionAll() {
1075
+ refreshApprovalPolicy(); // 启动恢复持久化记录后重建审批策略表
671
1076
  for (const record of store.connectors) {
672
1077
  try { await provisionConnector(ctx, record); }
673
1078
  catch (error) { diagnostics.push(`连接器 ${record.serverName}: ${error?.message ?? error}`); }
@@ -727,6 +1132,11 @@ function createApi(ctx, diagnostics) {
727
1132
  return { skills: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)), skillRoot: SKILL_ROOT };
728
1133
  },
729
1134
 
1135
+ /* FR-S2:规范校验(不抛异常,供前端提交前预检;field 定位供 UI 逐字段展示) */
1136
+ async validateSkillSpec(p) {
1137
+ return validateSkillSpec(p);
1138
+ },
1139
+
730
1140
  async getSkill(p) {
731
1141
  const name = String(p?.name ?? '');
732
1142
  if (!KEBAB.test(name)) throw new Error('技能名须为小写 kebab-case');
@@ -737,6 +1147,10 @@ function createApi(ctx, diagnostics) {
737
1147
  name,
738
1148
  description: typeof fields.description === 'string' ? fields.description : '',
739
1149
  whenToUse: typeof fields.whenToUse === 'string' ? fields.whenToUse : '',
1150
+ license: typeof fields.license === 'string' ? fields.license : '',
1151
+ compatibility: typeof fields.compatibility === 'string' ? fields.compatibility : '',
1152
+ metadata: fields.metadata && typeof fields.metadata === 'object' && !Array.isArray(fields.metadata) ? fields.metadata : {},
1153
+ allowedTools: typeof fields['allowed-tools'] === 'string' ? fields['allowed-tools'] : '',
740
1154
  userInvocable: fields['user-invocable'] !== false,
741
1155
  disableModelInvocation: fields['disable-model-invocation'] === true,
742
1156
  content: body.replace(/^\r?\n/, ''),
@@ -747,8 +1161,7 @@ function createApi(ctx, diagnostics) {
747
1161
 
748
1162
  async createSkill(p) {
749
1163
  const name = String(p?.name ?? '');
750
- if (!KEBAB.test(name)) throw new Error('技能名须为小写 kebab-case,如 my-skill');
751
- if (!p?.description) throw new Error('description 必填');
1164
+ assertSkillSpec({ ...p, name });
752
1165
  if (await skillFileFor(name)) throw new Error(`技能 "${name}" 已存在`);
753
1166
  const dir = join(SKILL_ROOT, name);
754
1167
  await fs.mkdir(dir, { recursive: true });
@@ -760,8 +1173,7 @@ function createApi(ctx, diagnostics) {
760
1173
 
761
1174
  async updateSkill(p) {
762
1175
  const name = String(p?.name ?? '');
763
- if (!KEBAB.test(name)) throw new Error('技能名须为小写 kebab-case');
764
- if (!p?.description) throw new Error('description 必填');
1176
+ assertSkillSpec({ ...p, name });
765
1177
  const file = await skillFileFor(name);
766
1178
  if (!file) throw new Error(`技能 "${name}" 不存在或不可编辑`);
767
1179
  await fs.writeFile(file, buildSkillFile({ ...p, name }));
@@ -809,7 +1221,19 @@ function createApi(ctx, diagnostics) {
809
1221
  const ensureAbsent = async (target) => {
810
1222
  if (!overwrite && await skillFileFor(target)) throw new Error(`技能 "${target}" 已存在(勾选"覆盖同名技能"可替换)`);
811
1223
  };
1224
+ /** 安装路径统一复用规范校验:以 frontmatter 解析字段 + 最终技能名校验,失败抛逐字段汇总错误。 */
1225
+ const assertImportedSkill = (text, target) => {
1226
+ const { fields } = parseSkillFile(text);
1227
+ assertSkillSpec({
1228
+ name: target,
1229
+ description: typeof fields.description === 'string' ? fields.description : '',
1230
+ compatibility: fields.compatibility,
1231
+ license: fields.license,
1232
+ metadata: fields.metadata,
1233
+ });
1234
+ };
812
1235
  const writeContent = async (target, text) => {
1236
+ assertImportedSkill(text, target);
813
1237
  await ensureAbsent(target);
814
1238
  const dir = join(SKILL_ROOT, target);
815
1239
  await fs.mkdir(dir, { recursive: true });
@@ -826,6 +1250,7 @@ function createApi(ctx, diagnostics) {
826
1250
  const skillMd = files.find((f) => f.rel.toLowerCase() === (root.prefix + 'skill.md').toLowerCase());
827
1251
  const text = skillMd.data.toString('utf8');
828
1252
  const target = resolveName(text, root.prefix ? root.prefix.slice(0, -1) : fallbackName);
1253
+ assertImportedSkill(text, target);
829
1254
  await ensureAbsent(target);
830
1255
  const dir = join(SKILL_ROOT, target);
831
1256
  if (overwrite) await fs.rm(dir, { recursive: true, force: true }); // 覆盖时清空重建,避免残留旧文件
@@ -852,7 +1277,9 @@ function createApi(ctx, diagnostics) {
852
1277
  if (st.isDirectory()) {
853
1278
  const f = join(src, 'SKILL.md');
854
1279
  if (!await fs.access(f).then(() => true, () => false)) throw new Error('目录中缺少 SKILL.md');
855
- const target = resolveName(await fs.readFile(f, 'utf8'), basename(src));
1280
+ const text = await fs.readFile(f, 'utf8');
1281
+ const target = resolveName(text, basename(src));
1282
+ assertImportedSkill(text, target);
856
1283
  await ensureAbsent(target);
857
1284
  await fs.cp(src, join(SKILL_ROOT, target), { recursive: true });
858
1285
  if (typeof p?.group === 'string') await applyGroup('skills', target, p.group);
@@ -957,7 +1384,8 @@ function createApi(ctx, diagnostics) {
957
1384
  /* ---- 连接器 ---- */
958
1385
  async listConnectors() {
959
1386
  const ours = store.connectors.map((record) => ({
960
- ...record,
1387
+ ...maskConnectorSecrets(record),
1388
+ status: connectorStatus.get(record.key), // FR-C1:易失测试状态(未测试为 undefined)
961
1389
  group: groupOf('connectors', record.key),
962
1390
  entryId: connectorEntryId(record),
963
1391
  live: loaderHas(ctx, connectorEntryId(record)),
@@ -984,7 +1412,7 @@ function createApi(ctx, diagnostics) {
984
1412
  }
985
1413
  } catch { /* configEditor 不可用时忽略外部条目 */ }
986
1414
  }
987
- return { connectors: [...ours, ...external] };
1415
+ return { connectors: [...ours, ...external], approvalPolicy: approvalPolicyDefault(ctx) };
988
1416
  },
989
1417
 
990
1418
  async createConnector(p) {
@@ -997,22 +1425,25 @@ function createApi(ctx, diagnostics) {
997
1425
  if (typeof p?.group === 'string') store.groups.connectors[record.key] = String(p.group).trim() || undefined;
998
1426
  if (store.groups.connectors[record.key] === undefined) delete store.groups.connectors[record.key];
999
1427
  await save();
1000
- return { connector: { ...record, entryId: connectorEntryId(record), group: groupOf('connectors', record.key) } };
1428
+ refreshApprovalPolicy();
1429
+ return { connector: { ...maskConnectorSecrets(record), entryId: connectorEntryId(record), group: groupOf('connectors', record.key) } };
1001
1430
  },
1002
1431
 
1003
1432
  async updateConnector(p) {
1004
1433
  const key = String(p?.key ?? '');
1005
1434
  const index = store.connectors.findIndex((c) => c.key === key);
1006
1435
  if (index < 0) throw new Error('连接器不存在');
1007
- const record = normalizeConnector({ ...store.connectors[index], ...p, key });
1436
+ // FR-C3:掩码值表示「保持原值」,先从旧记录回填明文再规范化
1437
+ const record = normalizeConnector({ ...store.connectors[index], ...unmaskConnectorSecrets(p, store.connectors[index]), key });
1008
1438
  if (store.connectors.some((c) => c.key !== key && c.serverName === record.serverName)) {
1009
1439
  throw new Error(`serverName "${record.serverName}" 已被占用`);
1010
1440
  }
1011
1441
  await provisionConnector(ctx, record);
1012
1442
  store.connectors[index] = record;
1443
+ refreshApprovalPolicy();
1013
1444
  if (typeof p?.group === 'string') await applyGroup('connectors', key, p.group);
1014
1445
  else await save();
1015
- return { connector: { ...record, group: groupOf('connectors', key) } };
1446
+ return { connector: { ...maskConnectorSecrets(record), group: groupOf('connectors', key) } };
1016
1447
  },
1017
1448
 
1018
1449
  async deleteConnector(p) {
@@ -1022,7 +1453,9 @@ function createApi(ctx, diagnostics) {
1022
1453
  await removeConnectorEntry(ctx, key);
1023
1454
  store.connectors.splice(index, 1);
1024
1455
  clearGroup('connectors', key);
1456
+ connectorStatus.delete(key);
1025
1457
  await save();
1458
+ refreshApprovalPolicy();
1026
1459
  return { key };
1027
1460
  },
1028
1461
 
@@ -1033,7 +1466,41 @@ function createApi(ctx, diagnostics) {
1033
1466
  record.enabled = p?.enabled !== false;
1034
1467
  await provisionConnector(ctx, record);
1035
1468
  await save();
1036
- return { connector: record };
1469
+ refreshApprovalPolicy();
1470
+ return { connector: maskConnectorSecrets(record) };
1471
+ },
1472
+
1473
+ /* FR-C1:测试连接(streamable-http / stdio),结果只进易失缓存,不持久化。 */
1474
+ async testConnector(p) {
1475
+ const key = String(p?.key ?? '');
1476
+ const record = store.connectors.find((c) => c.key === key);
1477
+ if (!record) throw new Error('连接器不存在');
1478
+ const started = Date.now();
1479
+ let result;
1480
+ try {
1481
+ const probed = record.transport === 'stdio'
1482
+ ? await probeStdioConnector(record, CONNECTOR_TEST_TIMEOUT_MS)
1483
+ : { latencyMs: Date.now() - started, tools: await probeHttpConnector(record, CONNECTOR_TEST_TIMEOUT_MS) };
1484
+ result = { ok: true, latencyMs: probed.latencyMs, tools: probed.tools };
1485
+ } catch (error) {
1486
+ result = { ok: false, latencyMs: Date.now() - started, tools: [], error: error?.message ?? String(error) };
1487
+ }
1488
+ connectorStatus.set(key, { ...result, testedAt: Date.now() });
1489
+ return result;
1490
+ },
1491
+
1492
+ /* FR-C2:持久化工具策略(白名单/自动执行);旧记录无 toolPolicy 字段 = 默认 all。 */
1493
+ async setConnectorTools(p) {
1494
+ const key = String(p?.key ?? '');
1495
+ const record = store.connectors.find((c) => c.key === key);
1496
+ if (!record) throw new Error('连接器不存在');
1497
+ const mode = p?.mode === 'whitelist' ? 'whitelist' : 'all';
1498
+ const tools = Array.isArray(p?.tools) ? [...new Set(p.tools.map(String))] : [];
1499
+ const autoApprove = Array.isArray(p?.autoApprove) ? [...new Set(p.autoApprove.map(String))] : [];
1500
+ record.toolPolicy = { mode, tools, autoApprove };
1501
+ await save();
1502
+ refreshApprovalPolicy();
1503
+ return { connector: { ...maskConnectorSecrets(record), group: groupOf('connectors', key) } };
1037
1504
  },
1038
1505
 
1039
1506
  /* ---- 智能体(专家) ---- */
@@ -1053,11 +1520,13 @@ function createApi(ctx, diagnostics) {
1053
1520
  group: groupOf('experts', preset.id),
1054
1521
  skills: custom?.skills,
1055
1522
  connectors: custom?.connectors,
1523
+ model: custom?.model, // FR-A1:旧记录无 = 跟随默认
1524
+ tools: custom?.tools,
1056
1525
  };
1057
1526
  });
1058
1527
  for (const expert of store.experts) {
1059
1528
  if (!rows.some((r) => r.id === expert.id)) {
1060
- rows.push({ id: expert.id, name: expert.name, description: expert.description, isDefault: false, custom: true, broken: '注册失败或未注册', group: groupOf('experts', expert.id), skills: expert.skills, connectors: expert.connectors });
1529
+ rows.push({ id: expert.id, name: expert.name, description: expert.description, isDefault: false, custom: true, broken: '注册失败或未注册', group: groupOf('experts', expert.id), skills: expert.skills, connectors: expert.connectors, model: expert.model, tools: expert.tools });
1061
1530
  }
1062
1531
  }
1063
1532
  return { experts: rows };
@@ -1072,6 +1541,8 @@ function createApi(ctx, diagnostics) {
1072
1541
  if (!prefix) throw new Error('系统提示词必填');
1073
1542
  const skills = normalizeExpertSkills(p?.skills);
1074
1543
  const connectors = normalizeExpertConnectors(p?.connectors);
1544
+ const model = normalizeExpertModel(p?.model);
1545
+ const tools = normalizeExpertTools(p?.tools);
1075
1546
  const agentPresets = ctx.get('agentPresets');
1076
1547
  if (agentPresets) {
1077
1548
  const roster = await agentPresets.list().catch(() => []);
@@ -1081,6 +1552,8 @@ function createApi(ctx, diagnostics) {
1081
1552
  const expert = { id, name, description: String(p?.description ?? ''), prefix, createdAt: Date.now() };
1082
1553
  if (skills !== undefined) expert.skills = skills;
1083
1554
  if (connectors !== undefined) expert.connectors = connectors;
1555
+ if (model !== undefined) expert.model = model;
1556
+ if (tools !== undefined) expert.tools = tools;
1084
1557
  await registerExpert(expert);
1085
1558
  store.experts.push(expert);
1086
1559
  if (typeof p?.group === 'string' && String(p.group).trim()) store.groups.experts[id] = String(p.group).trim();
@@ -1103,6 +1576,17 @@ function createApi(ctx, diagnostics) {
1103
1576
  };
1104
1577
  if (skills !== undefined) expert.skills = skills;
1105
1578
  if (connectors !== undefined) expert.connectors = connectors;
1579
+ // FR-A1/A2:入参显式携带 model/tools 键时更新;model 传 null 或 tools 传非数组 = 清除(跟随默认/全部)
1580
+ if (p && 'model' in p) {
1581
+ const model = normalizeExpertModel(p.model);
1582
+ if (model) expert.model = model;
1583
+ else delete expert.model;
1584
+ }
1585
+ if (p && 'tools' in p) {
1586
+ const tools = normalizeExpertTools(p.tools);
1587
+ if (tools) expert.tools = tools; // 空数组也落盘,语义 = 全部可用
1588
+ else delete expert.tools;
1589
+ }
1106
1590
  await registerExpert(expert);
1107
1591
  store.experts[index] = expert;
1108
1592
  if (typeof p?.group === 'string') await applyGroup('experts', id, p.group);
@@ -1285,6 +1769,7 @@ const API_METHODS = new Map([
1285
1769
  ['skills.get', (api, p) => api.getSkill(p)],
1286
1770
  ['skills.create', (api, p) => api.createSkill(p)],
1287
1771
  ['skills.update', (api, p) => api.updateSkill(p)],
1772
+ ['skills.validate', (api, p) => api.validateSkillSpec(p)],
1288
1773
  ['skills.delete', (api, p) => api.deleteSkill(p)],
1289
1774
  ['skills.import', (api, p) => api.importSkill(p)],
1290
1775
  ['markets.list', (api) => api.listMarkets()],
@@ -1297,6 +1782,8 @@ const API_METHODS = new Map([
1297
1782
  ['connectors.update', (api, p) => api.updateConnector(p)],
1298
1783
  ['connectors.delete', (api, p) => api.deleteConnector(p)],
1299
1784
  ['connectors.setEnabled', (api, p) => api.setConnectorEnabled(p)],
1785
+ ['connectors.test', (api, p) => api.testConnector(p)],
1786
+ ['connectors.setTools', (api, p) => api.setConnectorTools(p)],
1300
1787
  ['experts.list', (api) => api.listExperts()],
1301
1788
  ['experts.create', (api, p) => api.createExpert(p)],
1302
1789
  ['experts.update', (api, p) => api.updateExpert(p)],
@@ -1338,7 +1825,9 @@ function mountRoutes(wctx, api, trustedHosts) {
1338
1825
  await ensureLoaded();
1339
1826
  writeJson(res, 200, { ok: true, ...(await dispatch(api, body?.params ?? {})) }, cors);
1340
1827
  } catch (error) {
1341
- writeJson(res, 400, { ok: false, message: error instanceof Error ? error.message : String(error) }, cors);
1828
+ const message = error instanceof Error ? error.message : String(error);
1829
+ // FR-S2:校验类错误附带字段级 errors,供 client 逐字段红字展示(api() 失败信封透传)
1830
+ writeJson(res, 400, { ok: false, message, ...(Array.isArray(error?.errors) ? { errors: error.errors } : {}) }, cors);
1342
1831
  }
1343
1832
  },
1344
1833
  });
@@ -1362,6 +1851,9 @@ export function apply(ctx) {
1362
1851
 
1363
1852
  ctx.effect(() => () => { void api.disposeAll(); }, 'manager-center: dispose experts');
1364
1853
 
1854
+ // FR-C2:审批 answerer——本插件连接器的工具自动执行/白名单拦截,其余请求透传给后续 answerer。
1855
+ ctx.effect(() => ctx.on('approval/request', managerApprovalAnswerer), 'manager-center: approval answerer');
1856
+
1365
1857
  // Web 路由:优先 webStartup,兼容旧版 webRuntime;非 web 部署不挂载。
1366
1858
  // 路由注册挂在「本插件 ctx 的 effect」上:插件卸载/重载时旧路由一定随之下线,
1367
1859
  // 避免在 webStartup ctx 上注册导致重复激活后路由泄漏、多个 handler 争抢同一请求。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-my-config",
3
- "version": "1.7.2",
3
+ "version": "1.8.1",
4
4
  "description": "My Config (我的配置): manage skills (with external skill markets and zip import), MCP connectors and agent experts from dedicated Web sidebar panels.",
5
5
  "type": "module",
6
6
  "main": "index.js",