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.
- package/README.md +17 -3
- package/client.js +294 -37
- package/index.js +508 -16
- 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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
h(Field, { label: t('skillName') },
|
|
532
|
-
|
|
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' },
|
|
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' &&
|
|
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' &&
|
|
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) =>
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
h('div', { className: 'mc-card
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|