@leterhong/multichat 1.6.0
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/.agents/plugins/marketplace.json +32 -0
- package/.agents/plugins/plugins/developer-toolkit/.codex-plugin/plugin.json +24 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/code-review/SKILL.md +9 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/code-review/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/release-notes/SKILL.md +9 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/release-notes/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/security-review/SKILL.md +9 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/security-review/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/test-plan/SKILL.md +9 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/test-plan/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/writing-toolkit/.codex-plugin/plugin.json +24 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/meeting-notes/SKILL.md +9 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/meeting-notes/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/resume-tune/SKILL.md +9 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/resume-tune/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/weekly-report/SKILL.md +9 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/weekly-report/agents/openai.yaml +3 -0
- package/.agents/skills/.gitkeep +1 -0
- package/.agents/skills/wanghong-handwritten-ppt/SKILL.md +172 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/HTML-PPT-LICENSE +21 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/NEAT-ANNOTATIONS-LICENSE +21 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/animations.css +138 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/base.css +150 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/deepseek-example.css +893 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/neat-annotations.css +152 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/preview-cover.png +0 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/preview-diagram.png +0 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/preview-summary.png +0 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/preview-table.png +0 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/runtime.js +960 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/template.css +198 -0
- package/.agents/skills/wanghong-handwritten-ppt/evals/evals.json +20 -0
- package/.agents/skills/wanghong-handwritten-ppt/examples/deepseek-v4-flash/index.html +414 -0
- package/.agents/skills/wanghong-handwritten-ppt/examples/deepseek-v4-flash/style.css +897 -0
- package/.agents/skills/wanghong-handwritten-ppt/references/prompts.md +170 -0
- package/.agents/skills/wanghong-handwritten-ppt/references/style-guide.md +133 -0
- package/.agents/skills/wanghong-handwritten-ppt/scripts/check_deck.py +41 -0
- package/.agents/skills/wanghong-handwritten-ppt/scripts/prepare_render_html.py +137 -0
- package/.agents/skills/wanghong-handwritten-ppt/scripts/render.sh +110 -0
- package/.agents/skills/wanghong-handwritten-ppt/templates/deck.html +94 -0
- package/.agents/skills/wanghong-handwritten-ppt/tests/test_font_contract.py +73 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/backend/adapters/README.md +77 -0
- package/backend/adapters/anthropic.ts +115 -0
- package/backend/adapters/base.ts +47 -0
- package/backend/adapters/index.ts +41 -0
- package/backend/adapters/lmstudio.ts +7 -0
- package/backend/adapters/moonshot.ts +7 -0
- package/backend/adapters/ollama.ts +7 -0
- package/backend/adapters/openai-compatible.ts +51 -0
- package/backend/adapters/openai.ts +7 -0
- package/backend/adapters/qwen.ts +112 -0
- package/backend/adapters/wenxin.ts +136 -0
- package/backend/adapters/zhipu.ts +93 -0
- package/backend/dist/adapters/anthropic.js +130 -0
- package/backend/dist/adapters/anthropic.js.map +1 -0
- package/backend/dist/adapters/base.js +38 -0
- package/backend/dist/adapters/base.js.map +1 -0
- package/backend/dist/adapters/index.js +39 -0
- package/backend/dist/adapters/index.js.map +1 -0
- package/backend/dist/adapters/lmstudio.js +8 -0
- package/backend/dist/adapters/lmstudio.js.map +1 -0
- package/backend/dist/adapters/moonshot.js +8 -0
- package/backend/dist/adapters/moonshot.js.map +1 -0
- package/backend/dist/adapters/ollama.js +8 -0
- package/backend/dist/adapters/ollama.js.map +1 -0
- package/backend/dist/adapters/openai-compatible.js +47 -0
- package/backend/dist/adapters/openai-compatible.js.map +1 -0
- package/backend/dist/adapters/openai.js +8 -0
- package/backend/dist/adapters/openai.js.map +1 -0
- package/backend/dist/adapters/qwen.js +103 -0
- package/backend/dist/adapters/qwen.js.map +1 -0
- package/backend/dist/adapters/wenxin.js +114 -0
- package/backend/dist/adapters/wenxin.js.map +1 -0
- package/backend/dist/adapters/zhipu.js +86 -0
- package/backend/dist/adapters/zhipu.js.map +1 -0
- package/backend/dist/extensions/demo-mcp-server.js +75 -0
- package/backend/dist/extensions/demo-mcp-server.js.map +1 -0
- package/backend/dist/extensions/importer.js +828 -0
- package/backend/dist/extensions/importer.js.map +1 -0
- package/backend/dist/extensions/manager.js +1154 -0
- package/backend/dist/extensions/manager.js.map +1 -0
- package/backend/dist/lib/catalog.js +30 -0
- package/backend/dist/lib/catalog.js.map +1 -0
- package/backend/dist/lib/context.js +59 -0
- package/backend/dist/lib/context.js.map +1 -0
- package/backend/dist/lib/errors.js +75 -0
- package/backend/dist/lib/errors.js.map +1 -0
- package/backend/dist/lib/provider-store.js +116 -0
- package/backend/dist/lib/provider-store.js.map +1 -0
- package/backend/dist/lib/redact.js +11 -0
- package/backend/dist/lib/redact.js.map +1 -0
- package/backend/dist/lib/runtime-store.js +28 -0
- package/backend/dist/lib/runtime-store.js.map +1 -0
- package/backend/dist/lib/sqlite-store.js +218 -0
- package/backend/dist/lib/sqlite-store.js.map +1 -0
- package/backend/dist/lib/sse.js +46 -0
- package/backend/dist/lib/sse.js.map +1 -0
- package/backend/dist/lib/ssrf.js +131 -0
- package/backend/dist/lib/ssrf.js.map +1 -0
- package/backend/dist/lib/store.js +84 -0
- package/backend/dist/lib/store.js.map +1 -0
- package/backend/dist/lib/usage.js +219 -0
- package/backend/dist/lib/usage.js.map +1 -0
- package/backend/dist/lib/util.js +38 -0
- package/backend/dist/lib/util.js.map +1 -0
- package/backend/dist/lib/workspace-store.js +152 -0
- package/backend/dist/lib/workspace-store.js.map +1 -0
- package/backend/dist/mcp.js +429 -0
- package/backend/dist/mcp.js.map +1 -0
- package/backend/dist/routes/agents.js +51 -0
- package/backend/dist/routes/agents.js.map +1 -0
- package/backend/dist/routes/assistants.js +32 -0
- package/backend/dist/routes/assistants.js.map +1 -0
- package/backend/dist/routes/chat.js +697 -0
- package/backend/dist/routes/chat.js.map +1 -0
- package/backend/dist/routes/control-plane.js +200 -0
- package/backend/dist/routes/control-plane.js.map +1 -0
- package/backend/dist/routes/conversations.js +66 -0
- package/backend/dist/routes/conversations.js.map +1 -0
- package/backend/dist/routes/extensions-import.js +22 -0
- package/backend/dist/routes/extensions-import.js.map +1 -0
- package/backend/dist/routes/import.js +85 -0
- package/backend/dist/routes/import.js.map +1 -0
- package/backend/dist/routes/mcp-servers.js +56 -0
- package/backend/dist/routes/mcp-servers.js.map +1 -0
- package/backend/dist/routes/meta.js +37 -0
- package/backend/dist/routes/meta.js.map +1 -0
- package/backend/dist/routes/mock.js +28 -0
- package/backend/dist/routes/mock.js.map +1 -0
- package/backend/dist/routes/plugins.js +41 -0
- package/backend/dist/routes/plugins.js.map +1 -0
- package/backend/dist/routes/providers.js +197 -0
- package/backend/dist/routes/providers.js.map +1 -0
- package/backend/dist/routes/runs.js +57 -0
- package/backend/dist/routes/runs.js.map +1 -0
- package/backend/dist/routes/skills.js +73 -0
- package/backend/dist/routes/skills.js.map +1 -0
- package/backend/dist/routes/tools.js +40 -0
- package/backend/dist/routes/tools.js.map +1 -0
- package/backend/dist/routes/usage.js +14 -0
- package/backend/dist/routes/usage.js.map +1 -0
- package/backend/dist/routes/workspaces.js +158 -0
- package/backend/dist/routes/workspaces.js.map +1 -0
- package/backend/dist/runtime/agent.js +518 -0
- package/backend/dist/runtime/agent.js.map +1 -0
- package/backend/dist/server.js +103 -0
- package/backend/dist/server.js.map +1 -0
- package/backend/dist/types.js +3 -0
- package/backend/dist/types.js.map +1 -0
- package/backend/extensions/demo-mcp-server.js +70 -0
- package/backend/extensions/importer.ts +709 -0
- package/backend/extensions/manager.ts +1055 -0
- package/backend/lib/catalog.ts +28 -0
- package/backend/lib/context.ts +61 -0
- package/backend/lib/errors.ts +78 -0
- package/backend/lib/provider-store.ts +116 -0
- package/backend/lib/redact.ts +9 -0
- package/backend/lib/runtime-store.ts +38 -0
- package/backend/lib/sqlite-store.ts +220 -0
- package/backend/lib/sse.ts +42 -0
- package/backend/lib/ssrf.ts +120 -0
- package/backend/lib/store.ts +79 -0
- package/backend/lib/usage.ts +212 -0
- package/backend/lib/util.ts +31 -0
- package/backend/lib/workspace-store.ts +150 -0
- package/backend/mcp.ts +417 -0
- package/backend/package.json +34 -0
- package/backend/prompts.json +82 -0
- package/backend/routes/agents.ts +55 -0
- package/backend/routes/assistants.ts +30 -0
- package/backend/routes/chat.ts +660 -0
- package/backend/routes/control-plane.ts +165 -0
- package/backend/routes/conversations.ts +62 -0
- package/backend/routes/extensions-import.ts +15 -0
- package/backend/routes/import.ts +72 -0
- package/backend/routes/mcp-servers.ts +36 -0
- package/backend/routes/meta.ts +38 -0
- package/backend/routes/mock.ts +25 -0
- package/backend/routes/plugins.ts +27 -0
- package/backend/routes/providers.ts +166 -0
- package/backend/routes/runs.ts +55 -0
- package/backend/routes/skills.ts +52 -0
- package/backend/routes/tools.ts +40 -0
- package/backend/routes/usage.ts +13 -0
- package/backend/routes/workspaces.ts +124 -0
- package/backend/runtime/agent.ts +474 -0
- package/backend/server.ts +106 -0
- package/backend/types.ts +126 -0
- package/bin/multichat.mjs +535 -0
- package/frontend/dist/assets/agentPicker-C39VzmYa.js +13 -0
- package/frontend/dist/assets/compare-BpaGD32J.js +1 -0
- package/frontend/dist/assets/conversations-COuLmg99.js +7 -0
- package/frontend/dist/assets/data-ZH7Dux1K.js +1 -0
- package/frontend/dist/assets/extensionImport-CnTWKtWm.js +28 -0
- package/frontend/dist/assets/importExport-B3mB-9zs.js +70 -0
- package/frontend/dist/assets/index-CASVCOUO.js +34 -0
- package/frontend/dist/assets/index-DSvbjNLc.css +1 -0
- package/frontend/dist/assets/init-ZPK483vB.js +1 -0
- package/frontend/dist/assets/jsx-runtime-ZSsbr0oW.js +9 -0
- package/frontend/dist/assets/modal-CgHocv3n.js +2 -0
- package/frontend/dist/assets/modelPicker-EUmmVnbj.js +14 -0
- package/frontend/dist/assets/pluginsUI-B6jGUQkx.js +16 -0
- package/frontend/dist/assets/render-DgAg1K8A.js +1 -0
- package/frontend/dist/assets/send-BJhZ_5YA.js +14 -0
- package/frontend/dist/assets/settings-DdnYwZl-.js +2 -0
- package/frontend/dist/assets/shell-DFV2khQl.js +1 -0
- package/frontend/dist/assets/workbench-BY2J4vWC.js +41 -0
- package/frontend/dist/icon-maskable.svg +4 -0
- package/frontend/dist/icon.svg +4 -0
- package/frontend/dist/index.html +21 -0
- package/frontend/dist/manifest.webmanifest +26 -0
- package/package.json +58 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Safe, two-phase import pipeline for Agent Skills, MCP server registries and
|
|
4
|
+
// Codex plugins. The browser first calls inspect(), then sends the exact same
|
|
5
|
+
// package plus the returned fingerprint to install(). Imported archives are
|
|
6
|
+
// never executed during inspection or installation.
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const AdmZip = require('adm-zip');
|
|
11
|
+
const extensions = require('./manager');
|
|
12
|
+
const { AppError } = require('../lib/errors');
|
|
13
|
+
import type { JsonRecord, McpServerRecord } from '../types';
|
|
14
|
+
|
|
15
|
+
interface ArchiveFile {
|
|
16
|
+
path: string;
|
|
17
|
+
data: Buffer;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ImportOptions {
|
|
21
|
+
pluginPackage?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_ARCHIVE_BYTES = 20 * 1024 * 1024;
|
|
25
|
+
const MAX_UNPACKED_BYTES = 80 * 1024 * 1024;
|
|
26
|
+
const MAX_SINGLE_FILE_BYTES = 24 * 1024 * 1024;
|
|
27
|
+
const MAX_MCP_CONFIG_BYTES = 2 * 1024 * 1024;
|
|
28
|
+
const MAX_FILES = 600;
|
|
29
|
+
const REPO_SKILLS_ROOT = path.join(extensions.PROJECT_ROOT, '.agents', 'skills');
|
|
30
|
+
const REPO_PLUGIN_ROOT = path.join(extensions.PROJECT_ROOT, '.agents', 'plugins', 'plugins');
|
|
31
|
+
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
32
|
+
const PACKAGE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
33
|
+
const SKILL_EXTENSION_FIELD_RE = /^x-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
34
|
+
const STANDARD_SKILL_FIELDS = new Set(['name', 'description', 'license', 'allowed-tools', 'metadata']);
|
|
35
|
+
const EXECUTABLE_RE = /\.(?:bat|cmd|com|cpl|dll|exe|hta|jar|js|mjs|cjs|node|ps1|py|rb|sh|ts|vbs|wsf)$/i;
|
|
36
|
+
const WINDOWS_RESERVED_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
37
|
+
const FORBIDDEN_PACKAGE_DIRS = new Set(['.git', '.ssh', 'node_modules']);
|
|
38
|
+
const ENV_REFERENCE_RE = /\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
39
|
+
|
|
40
|
+
function packageError(message: string): never {
|
|
41
|
+
throw new AppError('INVALID_PACKAGE', message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function decodeBase64(value: unknown, label: string, maxBytes = MAX_ARCHIVE_BYTES) {
|
|
45
|
+
const text = String(value || '').trim();
|
|
46
|
+
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
|
|
47
|
+
packageError(`${label} 不是有效的 Base64 文件内容`);
|
|
48
|
+
}
|
|
49
|
+
const data = Buffer.from(text, 'base64');
|
|
50
|
+
if (!data.length) packageError(`${label} 不能为空`);
|
|
51
|
+
if (data.length > maxBytes) packageError(`${label} 超过 ${Math.floor(maxBytes / 1024 / 1024)} MB 限制`);
|
|
52
|
+
return data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeArchivePath(rawPath: unknown) {
|
|
56
|
+
let value = String(rawPath || '').normalize('NFC').replace(/\\/g, '/');
|
|
57
|
+
while (value.startsWith('./')) value = value.slice(2);
|
|
58
|
+
value = value.replace(/\/$/, '');
|
|
59
|
+
if (!value || value.includes('\0') || value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:/.test(value)) {
|
|
60
|
+
packageError(`压缩包包含不安全路径:${rawPath}`);
|
|
61
|
+
}
|
|
62
|
+
if (value.length > 512) packageError(`压缩包路径过长:${rawPath}`);
|
|
63
|
+
const parts = value.split('/');
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
if (!part || part === '.' || part === '..' || part.length > 255 || /[<>:"|?*]/.test(part) || /[. ]$/.test(part) || WINDOWS_RESERVED_RE.test(part)) {
|
|
66
|
+
packageError(`压缩包包含不安全路径:${rawPath}`);
|
|
67
|
+
}
|
|
68
|
+
if (FORBIDDEN_PACKAGE_DIRS.has(part.toLocaleLowerCase('en-US'))) packageError(`导入包不能包含 ${part}/ 目录`);
|
|
69
|
+
}
|
|
70
|
+
const base = parts.at(-1).toLocaleLowerCase('en-US');
|
|
71
|
+
if (/^\.env(?:\..+)?$/.test(base) && !/^\.env\.(?:example|sample|template)$/.test(base)) packageError(`导入包不能包含可能泄露密钥的文件:${rawPath}`);
|
|
72
|
+
return parts.join('/');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function shouldIgnorePath(value: string) {
|
|
76
|
+
const parts = value.replace(/\\/g, '/').split('/');
|
|
77
|
+
return parts.includes('__MACOSX') || parts.at(-1) === '.DS_Store' || parts.at(-1) === 'Thumbs.db';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function assertUniqueFiles(files: ArchiveFile[]) {
|
|
81
|
+
if (!files.length) packageError('导入包中没有文件');
|
|
82
|
+
if (files.length > MAX_FILES) packageError(`导入包文件数超过 ${MAX_FILES} 个限制`);
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
let total = 0;
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
const key = file.path.toLocaleLowerCase('en-US');
|
|
87
|
+
if (seen.has(key)) packageError(`导入包包含重复路径:${file.path}`);
|
|
88
|
+
seen.add(key);
|
|
89
|
+
if (!Buffer.isBuffer(file.data)) packageError(`无法读取文件:${file.path}`);
|
|
90
|
+
if (file.data.length > MAX_SINGLE_FILE_BYTES) packageError(`单个文件超过 24 MB:${file.path}`);
|
|
91
|
+
total += file.data.length;
|
|
92
|
+
}
|
|
93
|
+
if (total > MAX_UNPACKED_BYTES) packageError('导入包解压后超过 80 MB 限制');
|
|
94
|
+
return total;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readZip(buffer: Buffer): ArchiveFile[] {
|
|
98
|
+
let zip;
|
|
99
|
+
try { zip = new AdmZip(buffer); }
|
|
100
|
+
catch { packageError('ZIP 文件损坏或格式不受支持'); }
|
|
101
|
+
let entries;
|
|
102
|
+
try { entries = zip.getEntries(); }
|
|
103
|
+
catch { packageError('无法读取 ZIP 目录'); }
|
|
104
|
+
if (entries.length > MAX_FILES) packageError(`ZIP 文件条目超过 ${MAX_FILES} 个限制`);
|
|
105
|
+
let announcedTotal = 0;
|
|
106
|
+
const files: ArchiveFile[] = [];
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const raw = String(entry.entryName || '');
|
|
109
|
+
if (!raw || shouldIgnorePath(raw) || entry.isDirectory) continue;
|
|
110
|
+
const normalized = safeArchivePath(raw);
|
|
111
|
+
const mode = (Number(entry.attr || entry.header?.attr || 0) >>> 16) & 0xffff;
|
|
112
|
+
if ((mode & 0xf000) === 0xa000) packageError(`ZIP 不允许符号链接:${normalized}`);
|
|
113
|
+
const size = Number(entry.header?.size || 0);
|
|
114
|
+
const compressedSize = Number(entry.header?.compressedSize || 0);
|
|
115
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_SINGLE_FILE_BYTES) packageError(`ZIP 文件条目过大:${normalized}`);
|
|
116
|
+
announcedTotal += size;
|
|
117
|
+
if (announcedTotal > MAX_UNPACKED_BYTES) packageError('ZIP 解压后超过 80 MB 限制');
|
|
118
|
+
if (size > 1024 * 1024 && compressedSize > 0 && size / compressedSize > 250) packageError(`ZIP 压缩率异常:${normalized}`);
|
|
119
|
+
let data;
|
|
120
|
+
try { data = entry.getData(); }
|
|
121
|
+
catch { packageError(`ZIP 文件条目无法解压:${normalized}`); }
|
|
122
|
+
if (data.length !== size) packageError(`ZIP 文件条目大小不一致:${normalized}`);
|
|
123
|
+
files.push({ path: normalized, data });
|
|
124
|
+
}
|
|
125
|
+
assertUniqueFiles(files);
|
|
126
|
+
return files;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readFileList(rows: JsonRecord[]): ArchiveFile[] {
|
|
130
|
+
if (!Array.isArray(rows) || !rows.length) packageError('请选择一个文件夹或 ZIP 文件');
|
|
131
|
+
const files = rows.map((row, index) => {
|
|
132
|
+
if (!row || typeof row !== 'object') packageError(`第 ${index + 1} 个文件无效`);
|
|
133
|
+
const rawPath = String(row.path || row.name || '');
|
|
134
|
+
if (shouldIgnorePath(rawPath)) return null;
|
|
135
|
+
return {
|
|
136
|
+
path: safeArchivePath(rawPath),
|
|
137
|
+
data: decodeBase64(row.contentBase64, rawPath || `文件 ${index + 1}`, MAX_SINGLE_FILE_BYTES),
|
|
138
|
+
};
|
|
139
|
+
}).filter(Boolean) as ArchiveFile[];
|
|
140
|
+
assertUniqueFiles(files);
|
|
141
|
+
return files;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function packageFiles(input: JsonRecord, kind: 'skill' | 'plugin'): ArchiveFile[] {
|
|
145
|
+
if (Array.isArray(input?.files)) return readFileList(input.files);
|
|
146
|
+
const fileName = String(input?.fileName || '').trim();
|
|
147
|
+
const data = decodeBase64(input?.contentBase64, fileName || '导入文件');
|
|
148
|
+
if (/\.zip$/i.test(fileName)) return readZip(data);
|
|
149
|
+
if (kind === 'skill' && /(?:^|\.)md$/i.test(fileName)) return [{ path: 'SKILL.md', data }];
|
|
150
|
+
packageError(`${kind === 'plugin' ? '插件' : 'Skill'} 请上传 ZIP,或直接选择整个目录${kind === 'skill' ? ';Skill 也支持单个 SKILL.md' : ''}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function rebaseAtMarker(files: ArchiveFile[], marker: string, label: string) {
|
|
154
|
+
const candidates = files.filter(file => file.path === marker || file.path.endsWith('/' + marker));
|
|
155
|
+
if (!candidates.length) packageError(`${label} 缺少 ${marker}`);
|
|
156
|
+
candidates.sort((a, b) => a.path.split('/').length - b.path.split('/').length);
|
|
157
|
+
const depth = candidates[0].path.split('/').length;
|
|
158
|
+
if (candidates.filter(item => item.path.split('/').length === depth).length > 1) {
|
|
159
|
+
packageError(`${label} 中发现多个并列的 ${marker},请每次只导入一个包`);
|
|
160
|
+
}
|
|
161
|
+
const selected = candidates[0].path;
|
|
162
|
+
const root = selected.slice(0, -marker.length).replace(/\/$/, '');
|
|
163
|
+
const scoped = root ? files.filter(file => file.path.startsWith(root + '/')) : files;
|
|
164
|
+
const rebased = scoped.map(file => ({ path: root ? file.path.slice(root.length + 1) : file.path, data: file.data }));
|
|
165
|
+
assertUniqueFiles(rebased);
|
|
166
|
+
return { files: rebased, ignoredFiles: files.length - scoped.length };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function utf8(file: ArchiveFile, label = file.path) {
|
|
170
|
+
try { return new TextDecoder('utf-8', { fatal: true }).decode(file.data); }
|
|
171
|
+
catch { packageError(`${label} 必须使用 UTF-8 编码`); }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function findFile(files: ArchiveFile[], name: string, required = true) {
|
|
175
|
+
const file = files.find(item => item.path === name);
|
|
176
|
+
if (!file && required) packageError(`缺少 ${name}`);
|
|
177
|
+
return file;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseJsonFile(files: ArchiveFile[], name: string, required = true): JsonRecord | null {
|
|
181
|
+
const file = findFile(files, name, required);
|
|
182
|
+
if (!file) return null;
|
|
183
|
+
let value;
|
|
184
|
+
try { value = JSON.parse(utf8(file, name)); }
|
|
185
|
+
catch { packageError(`${name} 必须是有效的 JSON`); }
|
|
186
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) packageError(`${name} 顶层必须是 JSON 对象`);
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function fingerprint(files: ArchiveFile[]) {
|
|
191
|
+
const hash = crypto.createHash('sha256');
|
|
192
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path, 'en'))) {
|
|
193
|
+
hash.update(file.path); hash.update('\0'); hash.update(file.data); hash.update('\0');
|
|
194
|
+
}
|
|
195
|
+
return hash.digest('hex');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validatePackageName(value, label) {
|
|
199
|
+
const name = String(value || '').trim();
|
|
200
|
+
if (!name || name.length > 64 || !PACKAGE_NAME_RE.test(name)) packageError(`${label} 必须是 1-64 位小写字母、数字或连字符,且不能连续使用连字符`);
|
|
201
|
+
return name;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function validateSkillFiles(files, skillPath = 'SKILL.md') {
|
|
205
|
+
const file = findFile(files, skillPath);
|
|
206
|
+
const text = utf8(file, skillPath);
|
|
207
|
+
const parsed = extensions.parseSkillMarkdown(text, skillPath);
|
|
208
|
+
const name = validatePackageName(parsed.metadata.name, `${skillPath} 的 name`);
|
|
209
|
+
const description = String(parsed.metadata.description || '').trim();
|
|
210
|
+
const fields = Object.keys(parsed.metadata);
|
|
211
|
+
const extensionFields = fields.filter(key => SKILL_EXTENSION_FIELD_RE.test(key));
|
|
212
|
+
const unexpected = fields.filter(key => !STANDARD_SKILL_FIELDS.has(key) && !SKILL_EXTENSION_FIELD_RE.test(key));
|
|
213
|
+
if (unexpected.length) packageError(`${skillPath} frontmatter 包含不支持的字段:${unexpected.join(', ')}。当前支持 name、description、license、allowed-tools、metadata,以及 x- 开头的厂商扩展字段(会原样保留但不执行)`);
|
|
214
|
+
if (!description || description.length > 1024 || /[<>]/.test(description)) packageError(`${skillPath} 的 description 必须为 1-1024 个字符且不能包含尖括号`);
|
|
215
|
+
if (!parsed.instructions.trim()) packageError(`${skillPath} 必须包含正文指令`);
|
|
216
|
+
if (/(?:^|\n)[ \t]{0,3}\[TODO:[^\n]*\][ \t]*(?:\n|$)/.test(parsed.instructions)) packageError(`${skillPath} 仍包含 [TODO: ...] 占位符`);
|
|
217
|
+
return { name, description, instructions: parsed.instructions, extensionFields };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function executableWarnings(files) {
|
|
221
|
+
const paths = files.filter(file => EXECUTABLE_RE.test(file.path)).map(file => file.path);
|
|
222
|
+
if (!paths.length) return [];
|
|
223
|
+
return [`包含 ${paths.length} 个脚本或可执行文件;导入不会执行它们,请在启用前审核:${paths.slice(0, 6).join(', ')}${paths.length > 6 ? '…' : ''}`];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function inspectSkill(input) {
|
|
227
|
+
const rebased = rebaseAtMarker(packageFiles(input, 'skill'), 'SKILL.md', 'Skill 包');
|
|
228
|
+
const metadata = validateSkillFiles(rebased.files);
|
|
229
|
+
const destination = path.join(REPO_SKILLS_ROOT, metadata.name);
|
|
230
|
+
const conflict = fs.existsSync(destination);
|
|
231
|
+
const resources = ['scripts', 'references', 'assets', 'templates'].filter(folder => rebased.files.some(file => file.path.startsWith(folder + '/')));
|
|
232
|
+
const warnings = executableWarnings(rebased.files);
|
|
233
|
+
if (metadata.extensionFields.length) {
|
|
234
|
+
warnings.push(`已保留 ${metadata.extensionFields.length} 个 x-* 扩展元数据字段;MultiChat 不会执行这些字段:${metadata.extensionFields.join(', ')}`);
|
|
235
|
+
}
|
|
236
|
+
if (rebased.ignoredFiles) warnings.push(`包根目录之外的 ${rebased.ignoredFiles} 个文件不会导入`);
|
|
237
|
+
return {
|
|
238
|
+
kind: 'skill',
|
|
239
|
+
name: metadata.name,
|
|
240
|
+
description: metadata.description,
|
|
241
|
+
fingerprint: fingerprint(rebased.files),
|
|
242
|
+
files: rebased.files,
|
|
243
|
+
fileCount: rebased.files.length,
|
|
244
|
+
totalBytes: rebased.files.reduce((sum, file) => sum + file.data.length, 0),
|
|
245
|
+
resources,
|
|
246
|
+
warnings,
|
|
247
|
+
conflicts: conflict ? [{ id: metadata.name, path: destination, replaceable: true }] : [],
|
|
248
|
+
destination,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function jsonClone(value) { return JSON.parse(JSON.stringify(value)); }
|
|
253
|
+
|
|
254
|
+
function hasTodo(value) {
|
|
255
|
+
if (typeof value === 'string') return value.includes('[TODO:');
|
|
256
|
+
if (Array.isArray(value)) return value.some(hasTodo);
|
|
257
|
+
return !!value && typeof value === 'object' && Object.values(value).some(hasTodo);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function requireText(object, field, label = field) {
|
|
261
|
+
if (!object || typeof object[field] !== 'string' || !object[field].trim()) packageError(`plugin.json 字段 ${label} 必须是非空字符串`);
|
|
262
|
+
return object[field].trim();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function contractPath(value) {
|
|
266
|
+
return typeof value === 'string' ? value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '') : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validateAsset(files: ArchiveFile[], rawPath: unknown, label: string) {
|
|
270
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) packageError(`plugin.json 字段 ${label} 必须是相对路径`);
|
|
271
|
+
const normalized = safeArchivePath(rawPath);
|
|
272
|
+
if (!findFile(files, normalized, false)) packageError(`plugin.json 字段 ${label} 指向不存在的文件:${rawPath}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function validatePluginManifest(files: ArchiveFile[]) {
|
|
276
|
+
const manifest = parseJsonFile(files, '.codex-plugin/plugin.json');
|
|
277
|
+
const allowedTop = new Set(['id', 'name', 'version', 'description', 'skills', 'apps', 'mcpServers', 'interface', 'author', 'homepage', 'repository', 'license', 'keywords']);
|
|
278
|
+
const unknownTop = Object.keys(manifest).filter(key => !allowedTop.has(key));
|
|
279
|
+
if (unknownTop.length) packageError(`plugin.json 包含当前 Codex 规范不接受的字段:${unknownTop.join(', ')}`);
|
|
280
|
+
if (hasTodo(manifest)) packageError('plugin.json 仍包含 [TODO: ...] 占位符');
|
|
281
|
+
const name = validatePackageName(requireText(manifest, 'name'), 'plugin.json 的 name');
|
|
282
|
+
const version = requireText(manifest, 'version');
|
|
283
|
+
if (!SEMVER_RE.test(version)) packageError('plugin.json 的 version 必须是严格 semver');
|
|
284
|
+
const description = requireText(manifest, 'description');
|
|
285
|
+
if (!manifest.author || typeof manifest.author !== 'object' || Array.isArray(manifest.author)) packageError('plugin.json 的 author 必须是对象');
|
|
286
|
+
const unknownAuthor = Object.keys(manifest.author).filter(key => !['name', 'email', 'url'].includes(key));
|
|
287
|
+
if (unknownAuthor.length) packageError(`plugin.json 的 author 包含不支持的字段:${unknownAuthor.join(', ')}`);
|
|
288
|
+
requireText(manifest.author, 'name', 'author.name');
|
|
289
|
+
if (manifest.skills !== undefined && contractPath(manifest.skills) !== 'skills') packageError('plugin.json 的 skills 必须指向 ./skills/');
|
|
290
|
+
if (manifest.apps !== undefined && contractPath(manifest.apps) !== '.app.json') packageError('plugin.json 的 apps 必须指向 ./.app.json');
|
|
291
|
+
if (typeof manifest.mcpServers === 'string' && contractPath(manifest.mcpServers) !== '.mcp.json') packageError('plugin.json 的 mcpServers 必须指向 ./.mcp.json');
|
|
292
|
+
if (manifest.mcpServers !== undefined && typeof manifest.mcpServers !== 'string' && (!manifest.mcpServers || typeof manifest.mcpServers !== 'object' || Array.isArray(manifest.mcpServers))) packageError('plugin.json 的 mcpServers 必须是对象或 ./.mcp.json 路径');
|
|
293
|
+
const ui = manifest.interface;
|
|
294
|
+
if (!ui || typeof ui !== 'object' || Array.isArray(ui)) packageError('plugin.json 的 interface 必须是对象');
|
|
295
|
+
const allowedInterface = new Set(['displayName', 'shortDescription', 'longDescription', 'developerName', 'category', 'capabilities', 'websiteURL', 'privacyPolicyURL', 'termsOfServiceURL', 'brandColor', 'composerIcon', 'logo', 'logoDark', 'screenshots', 'defaultPrompt', 'default_prompt']);
|
|
296
|
+
const unknownInterface = Object.keys(ui).filter(key => !allowedInterface.has(key));
|
|
297
|
+
if (unknownInterface.length) packageError(`plugin.json 的 interface 包含不支持的字段:${unknownInterface.join(', ')}`);
|
|
298
|
+
for (const field of ['displayName', 'shortDescription', 'longDescription', 'developerName', 'category']) requireText(ui, field, `interface.${field}`);
|
|
299
|
+
if (!Array.isArray(ui.capabilities) || !ui.capabilities.length || ui.capabilities.some(value => typeof value !== 'string' || !value.trim())) packageError('plugin.json 的 interface.capabilities 必须是非空字符串数组');
|
|
300
|
+
const prompts = ui.defaultPrompt ?? ui.default_prompt;
|
|
301
|
+
if (!Array.isArray(prompts) || !prompts.length || prompts.some(value => typeof value !== 'string' || !value.trim())) packageError('plugin.json 必须提供 interface.defaultPrompt 字符串数组');
|
|
302
|
+
for (const field of ['websiteURL', 'privacyPolicyURL', 'termsOfServiceURL']) {
|
|
303
|
+
if (ui[field] !== undefined && !/^https:\/\/[^/]+/i.test(String(ui[field]))) packageError(`plugin.json 的 interface.${field} 必须是绝对 https:// URL`);
|
|
304
|
+
}
|
|
305
|
+
if (ui.brandColor !== undefined && !/^#[0-9A-F]{6}$/i.test(String(ui.brandColor))) packageError('plugin.json 的 interface.brandColor 必须使用 #RRGGBB');
|
|
306
|
+
for (const field of ['composerIcon', 'logo', 'logoDark']) if (ui[field] !== undefined) validateAsset(files, ui[field], `interface.${field}`);
|
|
307
|
+
if (ui.screenshots !== undefined) {
|
|
308
|
+
if (!Array.isArray(ui.screenshots)) packageError('plugin.json 的 interface.screenshots 必须是数组');
|
|
309
|
+
ui.screenshots.forEach((item, index) => validateAsset(files, item, `interface.screenshots[${index}]`));
|
|
310
|
+
}
|
|
311
|
+
const skillFolders = new Set();
|
|
312
|
+
for (const file of files.filter(item => item.path.startsWith('skills/'))) {
|
|
313
|
+
const parts = file.path.split('/');
|
|
314
|
+
if (parts.length >= 3 && !parts[1].startsWith('.')) skillFolders.add(parts[1]);
|
|
315
|
+
}
|
|
316
|
+
for (const folder of skillFolders) validateSkillFiles(files, `skills/${folder}/SKILL.md`);
|
|
317
|
+
let mcpMap: JsonRecord = {};
|
|
318
|
+
if (typeof manifest.mcpServers === 'object' && manifest.mcpServers) mcpMap = manifest.mcpServers;
|
|
319
|
+
if (typeof manifest.mcpServers === 'string' || (manifest.mcpServers === undefined && findFile(files, '.mcp.json', false))) {
|
|
320
|
+
const companion = parseJsonFile(files, '.mcp.json');
|
|
321
|
+
const unknown = Object.keys(companion).filter(key => key !== 'mcpServers');
|
|
322
|
+
if (unknown.length) packageError(`.mcp.json 包含不支持的字段:${unknown.join(', ')}`);
|
|
323
|
+
mcpMap = companion.mcpServers as JsonRecord;
|
|
324
|
+
}
|
|
325
|
+
if (mcpMap && (typeof mcpMap !== 'object' || Array.isArray(mcpMap))) packageError('插件 MCP 配置必须是 server 对象');
|
|
326
|
+
for (const [serverName, server] of Object.entries(mcpMap || {})) {
|
|
327
|
+
if (!serverName.trim() || !server || typeof server !== 'object' || Array.isArray(server)) packageError(`插件 MCP server ${serverName || '(空名称)'} 配置无效`);
|
|
328
|
+
normalizeImportedMcp(serverName, server as JsonRecord, [], { pluginPackage: true });
|
|
329
|
+
}
|
|
330
|
+
if (manifest.apps !== undefined) {
|
|
331
|
+
const appManifest = parseJsonFile(files, '.app.json');
|
|
332
|
+
const unknown = Object.keys(appManifest).filter(key => key !== 'apps');
|
|
333
|
+
if (unknown.length || !appManifest.apps || typeof appManifest.apps !== 'object' || Array.isArray(appManifest.apps)) packageError('.app.json 必须只包含 apps 对象');
|
|
334
|
+
for (const [appName, app] of Object.entries(appManifest.apps as JsonRecord)) {
|
|
335
|
+
if (!app || typeof app !== 'object' || Array.isArray(app)) packageError(`.app.json 的 app ${appName} 必须是对象`);
|
|
336
|
+
const appUnknown = Object.keys(app).filter(key => !['id', 'category'].includes(key));
|
|
337
|
+
if (appUnknown.length || typeof app.id !== 'string' || !app.id.trim()) packageError(`.app.json 的 app ${appName} 配置无效`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { manifest, name, version, description, skills: skillFolders.size, mcpServers: Object.keys(mcpMap || {}).length };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function inspectPlugin(input: JsonRecord) {
|
|
344
|
+
const rebased = rebaseAtMarker(packageFiles(input, 'plugin'), '.codex-plugin/plugin.json', '插件包');
|
|
345
|
+
const details = validatePluginManifest(rebased.files);
|
|
346
|
+
const destination = path.join(REPO_PLUGIN_ROOT, details.name);
|
|
347
|
+
const existing = extensions.listPlugins().filter(item => item.id === details.name);
|
|
348
|
+
const replaceable = existing.every(item => path.resolve(item.path) === path.resolve(destination));
|
|
349
|
+
const conflicts = existing.length || fs.existsSync(destination) ? [{ id: details.name, path: destination, replaceable }] : [];
|
|
350
|
+
const warnings = executableWarnings(rebased.files);
|
|
351
|
+
if (details.mcpServers) warnings.push(`插件包含 ${details.mcpServers} 个 MCP server;导入后插件默认停用,不会自动启动进程或连接网络`);
|
|
352
|
+
if (rebased.ignoredFiles) warnings.push(`插件根目录之外的 ${rebased.ignoredFiles} 个文件不会导入`);
|
|
353
|
+
return {
|
|
354
|
+
kind: 'plugin',
|
|
355
|
+
name: details.name,
|
|
356
|
+
version: details.version,
|
|
357
|
+
description: details.description,
|
|
358
|
+
fingerprint: fingerprint(rebased.files),
|
|
359
|
+
files: rebased.files,
|
|
360
|
+
fileCount: rebased.files.length,
|
|
361
|
+
totalBytes: rebased.files.reduce((sum, file) => sum + file.data.length, 0),
|
|
362
|
+
components: { skills: details.skills, mcpServers: details.mcpServers },
|
|
363
|
+
warnings,
|
|
364
|
+
conflicts,
|
|
365
|
+
destination,
|
|
366
|
+
manifest: jsonClone(details.manifest),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function mcpSlug(value) {
|
|
371
|
+
const result = String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-{2,}/g, '-');
|
|
372
|
+
if (!result || result.length > 64) packageError(`MCP server 名称无法转换成有效 ID:${value}`);
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function secretInArgs(args) {
|
|
377
|
+
return (args || []).some((value, index) => {
|
|
378
|
+
const text = String(value);
|
|
379
|
+
return /--?(?:api[-_]?key|token|secret|password|authorization)=.+/i.test(text)
|
|
380
|
+
|| (index > 0 && /^--?(?:api[-_]?key|token|secret|password|authorization)$/i.test(String(args[index - 1])));
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function environmentReference(value) {
|
|
385
|
+
const match = /^\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(String(value || '').trim());
|
|
386
|
+
return match && match[1] !== 'PLUGIN_ROOT' ? match[1] : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function looksLikeLiteralCredential(value) {
|
|
390
|
+
const withoutReferences = String(value || '').replace(ENV_REFERENCE_RE, '').trim();
|
|
391
|
+
if (!withoutReferences) return false;
|
|
392
|
+
return /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(withoutReferences)
|
|
393
|
+
|| /\b(?:Bearer|Basic)\s+\S+/i.test(withoutReferences)
|
|
394
|
+
|| /\b(?:sk-[A-Za-z0-9_-]{8,}|AKIA[0-9A-Z]{12,}|ASIA[0-9A-Z]{12,}|ghp_[A-Za-z0-9]{12,}|github_pat_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{8,})\b/.test(withoutReferences)
|
|
395
|
+
|| /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/.test(withoutReferences)
|
|
396
|
+
|| /(?:api[-_]?key|access[-_]?key|token|secret|password|authorization|credential|session)\s*[=:]\s*[^\s;,]{4,}/i.test(withoutReferences);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function validatePluginCredentialMap(serverName: string, values: Record<string, string>, kind: 'env' | 'header') {
|
|
400
|
+
for (const [key, rawValue] of Object.entries(values || {})) {
|
|
401
|
+
const value = String(rawValue);
|
|
402
|
+
if (kind === 'env' && !environmentReference(value)) {
|
|
403
|
+
packageError(`插件 MCP server ${serverName} 的 env.${key} 必须使用 \${ENV_VAR} 环境变量引用,不能在插件包中保存字面量值`);
|
|
404
|
+
}
|
|
405
|
+
if (kind === 'header') {
|
|
406
|
+
const direct = environmentReference(value);
|
|
407
|
+
const authorization = /^(?:Bearer|Basic)\s+(\$\{(?:env:)?[A-Za-z_][A-Za-z0-9_]*\})$/i.exec(value.trim());
|
|
408
|
+
const safeStaticHeader = /^(?:accept|content-type|user-agent)$/i.test(key);
|
|
409
|
+
if (!safeStaticHeader && !direct && (!authorization || !environmentReference(authorization[1]))) {
|
|
410
|
+
packageError(`插件 MCP server ${serverName} 的 header ${key} 必须使用环境变量引用,不能在插件包中保存字面量值`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (looksLikeLiteralCredential(value)) {
|
|
414
|
+
packageError(`插件 MCP server ${serverName} 的 ${kind}.${key} 疑似包含字面量凭据;请改用 \${ENV_VAR} 环境变量引用`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function mcpInput(input: JsonRecord): { map: JsonRecord; fingerprint: string } {
|
|
420
|
+
const fileName = String(input?.fileName || '').trim();
|
|
421
|
+
let data;
|
|
422
|
+
if (Array.isArray(input?.files)) {
|
|
423
|
+
const files = readFileList(input.files);
|
|
424
|
+
if (files.length !== 1) packageError('MCP 导入每次请选择一个 JSON 文件');
|
|
425
|
+
data = files[0].data;
|
|
426
|
+
} else data = decodeBase64(input?.contentBase64, fileName || 'MCP 配置', MAX_MCP_CONFIG_BYTES);
|
|
427
|
+
if (data.length > MAX_MCP_CONFIG_BYTES) packageError('MCP 配置超过 2 MB 限制');
|
|
428
|
+
let parsed;
|
|
429
|
+
try { parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data)); }
|
|
430
|
+
catch { packageError('MCP 配置必须是 UTF-8 JSON 文件'); }
|
|
431
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) packageError('MCP 配置顶层必须是 JSON 对象');
|
|
432
|
+
const map = (parsed.mcpServers || parsed.mcp_servers || parsed.servers || extensions.mcpServerMap(parsed)) as JsonRecord;
|
|
433
|
+
if (!map || typeof map !== 'object' || Array.isArray(map) || !Object.keys(map).length) packageError('没有找到 mcpServers 配置');
|
|
434
|
+
const canonical = Buffer.from(JSON.stringify(map));
|
|
435
|
+
return { map, fingerprint: crypto.createHash('sha256').update(canonical).digest('hex') };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function normalizeImportedMcp(name: string, raw: JsonRecord, warnings: string[], options: ImportOptions = {}): McpServerRecord {
|
|
439
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) packageError(`MCP server ${name} 配置必须是对象`);
|
|
440
|
+
const id = mcpSlug(name);
|
|
441
|
+
const type = String(raw.type || '').toLowerCase();
|
|
442
|
+
if (type === 'sse') packageError(`MCP server ${name} 使用旧式 SSE;请改用 Streamable HTTP`);
|
|
443
|
+
if (raw.url && raw.command) packageError(`MCP server ${name} 不能同时配置 url 和 command`);
|
|
444
|
+
const transport = raw.url || type === 'http' || type === 'streamable-http' ? 'http' : 'stdio';
|
|
445
|
+
if (options.pluginPackage) {
|
|
446
|
+
const allowed = new Set([
|
|
447
|
+
'name', 'description', 'type',
|
|
448
|
+
...(transport === 'stdio'
|
|
449
|
+
? ['command', 'args', 'cwd', 'env']
|
|
450
|
+
: ['url', 'headers', 'http_headers', 'bearer_token_env_var', 'bearerTokenEnvVar']),
|
|
451
|
+
]);
|
|
452
|
+
const unknown = Object.keys(raw).filter(key => !allowed.has(key));
|
|
453
|
+
if (unknown.length) packageError(`插件 MCP server ${name} 包含不支持的字段:${unknown.join(', ')}`);
|
|
454
|
+
}
|
|
455
|
+
const record: McpServerRecord = {
|
|
456
|
+
id,
|
|
457
|
+
name: String(raw.name || name).trim() || id,
|
|
458
|
+
description: String(raw.description || '').trim(),
|
|
459
|
+
transport,
|
|
460
|
+
enabled: false,
|
|
461
|
+
trustLevel: 'untrusted',
|
|
462
|
+
allowPrivate: false,
|
|
463
|
+
targets: ['multichat', 'codex'],
|
|
464
|
+
source: { kind: 'managed', scope: 'project', imported: true },
|
|
465
|
+
};
|
|
466
|
+
if (transport === 'stdio') {
|
|
467
|
+
record.command = String(raw.command || '').trim();
|
|
468
|
+
if (!record.command) packageError(`MCP server ${name} 缺少 command`);
|
|
469
|
+
if (raw.args !== undefined && (!Array.isArray(raw.args) || raw.args.some(value => typeof value !== 'string'))) packageError(`MCP server ${name} 的 args 必须是字符串数组`);
|
|
470
|
+
record.args = raw.args || [];
|
|
471
|
+
if (secretInArgs(record.args)) packageError(`MCP server ${name} 的 args 似乎包含密钥;请改用 env 环境变量后再导入`);
|
|
472
|
+
record.cwd = String(raw.cwd || '').trim();
|
|
473
|
+
if (raw.env !== undefined && (!raw.env || typeof raw.env !== 'object' || Array.isArray(raw.env) || Object.values(raw.env).some(value => typeof value !== 'string'))) packageError(`MCP server ${name} 的 env 必须是字符串映射`);
|
|
474
|
+
record.env = raw.env || {};
|
|
475
|
+
if (options.pluginPackage) validatePluginCredentialMap(name, record.env, 'env');
|
|
476
|
+
} else {
|
|
477
|
+
record.url = String(raw.url || '').trim();
|
|
478
|
+
if (!/^https?:\/\//i.test(record.url)) packageError(`MCP server ${name} 必须提供 http(s) URL`);
|
|
479
|
+
let parsedUrl;
|
|
480
|
+
try { parsedUrl = new URL(record.url); }
|
|
481
|
+
catch { packageError(`MCP server ${name} 的 URL 无效`); }
|
|
482
|
+
if (parsedUrl.username || parsedUrl.password) packageError(`MCP server ${name} 的 URL 不能包含用户名或密码,请改用环境变量`);
|
|
483
|
+
if ([...parsedUrl.searchParams.keys()].some(key => /(?:api[-_]?key|token|secret|password|authorization)/i.test(key))) packageError(`MCP server ${name} 的 URL 查询参数疑似包含密钥,请改用 bearer token 环境变量`);
|
|
484
|
+
if (options.pluginPackage && raw.headers !== undefined && raw.http_headers !== undefined) packageError(`插件 MCP server ${name} 不能同时配置 headers 与 http_headers`);
|
|
485
|
+
record.headers = raw.headers || raw.http_headers || {};
|
|
486
|
+
if (!record.headers || typeof record.headers !== 'object' || Array.isArray(record.headers) || Object.values(record.headers).some(value => typeof value !== 'string')) packageError(`MCP server ${name} 的 headers 必须是字符串映射`);
|
|
487
|
+
record.headers = { ...record.headers };
|
|
488
|
+
if (options.pluginPackage) validatePluginCredentialMap(name, record.headers, 'header');
|
|
489
|
+
record.bearerTokenEnvVar = String(raw.bearer_token_env_var || raw.bearerTokenEnvVar || '').trim();
|
|
490
|
+
if (options.pluginPackage && raw.bearer_token_env_var !== undefined && raw.bearerTokenEnvVar !== undefined) packageError(`插件 MCP server ${name} 不能重复配置 bearer token 环境变量`);
|
|
491
|
+
if (record.bearerTokenEnvVar && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(record.bearerTokenEnvVar)) packageError(`MCP server ${name} 的 bearer token 环境变量名无效`);
|
|
492
|
+
if (options.pluginPackage && record.bearerTokenEnvVar && Object.keys(record.headers).some(key => /^authorization$/i.test(key))) packageError(`插件 MCP server ${name} 不能同时配置 Authorization header 与 bearer token 环境变量`);
|
|
493
|
+
if (Object.keys(record.headers).length) {
|
|
494
|
+
record.targets = ['multichat'];
|
|
495
|
+
warnings.push(`${name} 含静态 HTTP headers,因此不会自动同步到 Codex;如需同步,请改用 bearer token 环境变量`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return record;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function inspectMcp(input) {
|
|
502
|
+
const parsed = mcpInput(input);
|
|
503
|
+
const warnings = ['所有导入的 MCP server 都将保持停用和未信任状态,确认配置后再手动启用'];
|
|
504
|
+
const servers = Object.entries(parsed.map).map(([name, raw]) => normalizeImportedMcp(name, raw as JsonRecord, warnings));
|
|
505
|
+
if (servers.length > 50) packageError('一次最多导入 50 个 MCP server');
|
|
506
|
+
const ids = new Set();
|
|
507
|
+
for (const server of servers) {
|
|
508
|
+
if (ids.has(server.id)) packageError(`多个 MCP server 会生成同一个 ID:${server.id}`);
|
|
509
|
+
ids.add(server.id);
|
|
510
|
+
}
|
|
511
|
+
const existing = new Map<string, McpServerRecord>(extensions.listMcpServers().map((server: McpServerRecord) => [String(server.id), server]));
|
|
512
|
+
const conflicts = servers.filter(server => existing.has(server.id)).map(server => {
|
|
513
|
+
const current = existing.get(server.id);
|
|
514
|
+
return { id: server.id, source: current.source?.kind || 'unknown', replaceable: current.source?.kind === 'managed' };
|
|
515
|
+
});
|
|
516
|
+
return {
|
|
517
|
+
kind: 'mcp',
|
|
518
|
+
name: servers.length === 1 ? servers[0].name : `${servers.length} 个 MCP servers`,
|
|
519
|
+
description: '标准 MCP JSON 配置',
|
|
520
|
+
fingerprint: parsed.fingerprint,
|
|
521
|
+
servers,
|
|
522
|
+
serverCount: servers.length,
|
|
523
|
+
warnings,
|
|
524
|
+
conflicts,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function publicInspection(result: JsonRecord) {
|
|
529
|
+
const safe = { ...result };
|
|
530
|
+
const files = safe.files;
|
|
531
|
+
const servers = safe.servers;
|
|
532
|
+
delete safe.files;
|
|
533
|
+
delete safe.servers;
|
|
534
|
+
delete safe.manifest;
|
|
535
|
+
delete safe.destination;
|
|
536
|
+
if (files) safe.fileTree = files.map(file => file.path).slice(0, 600);
|
|
537
|
+
if (servers) {
|
|
538
|
+
safe.servers = servers.map(server => ({
|
|
539
|
+
id: server.id,
|
|
540
|
+
name: server.name,
|
|
541
|
+
transport: server.transport,
|
|
542
|
+
command: server.command,
|
|
543
|
+
url: (() => {
|
|
544
|
+
if (!server.url) return undefined;
|
|
545
|
+
try {
|
|
546
|
+
const value = new URL(server.url);
|
|
547
|
+
if (value.username) value.username = 'redacted';
|
|
548
|
+
if (value.password) value.password = 'redacted';
|
|
549
|
+
for (const key of [...value.searchParams.keys()]) value.searchParams.set(key, '***');
|
|
550
|
+
return value.toString();
|
|
551
|
+
} catch { return String(server.url).replace(/([?&][^=]+)=([^&]+)/g, '$1=***'); }
|
|
552
|
+
})(),
|
|
553
|
+
targets: server.targets,
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
return safe;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function inspect(kind: string, input: JsonRecord) {
|
|
560
|
+
if (kind === 'skill') return publicInspection(inspectSkill(input));
|
|
561
|
+
if (kind === 'mcp') return publicInspection(inspectMcp(input));
|
|
562
|
+
if (kind === 'plugin') return publicInspection(inspectPlugin(input));
|
|
563
|
+
throw new AppError('NOT_FOUND', '不支持的导入类型');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function assertInstallRequest(result, input) {
|
|
567
|
+
if (!input?.expectedFingerprint || input.expectedFingerprint !== result.fingerprint) {
|
|
568
|
+
throw new AppError('CONFLICT', '导入文件在预检后发生了变化,请重新预检');
|
|
569
|
+
}
|
|
570
|
+
const policy = input.conflictPolicy === 'replace' ? 'replace' : 'reject';
|
|
571
|
+
if (result.conflicts.length && policy !== 'replace') throw new AppError('CONFLICT', '存在同名项目,请在预检页明确选择覆盖');
|
|
572
|
+
if (policy === 'replace' && result.conflicts.some(item => item.replaceable === false)) throw new AppError('PERMISSION_DENIED', '冲突项来自其他来源,不能由本次导入覆盖');
|
|
573
|
+
return policy;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function writePackageAtomically(files, destination, policy) {
|
|
577
|
+
const root = path.dirname(destination);
|
|
578
|
+
const resolvedRoot = path.resolve(root);
|
|
579
|
+
const resolvedDestination = path.resolve(destination);
|
|
580
|
+
if (resolvedDestination === resolvedRoot || !resolvedDestination.startsWith(resolvedRoot + path.sep)) throw new AppError('PERMISSION_DENIED', '导入目标越过受管目录');
|
|
581
|
+
fs.mkdirSync(root, { recursive: true });
|
|
582
|
+
const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(5).toString('hex')}`;
|
|
583
|
+
const stage = path.join(root, `.multichat-import-${nonce}`);
|
|
584
|
+
const backup = path.join(root, `.multichat-backup-${nonce}`);
|
|
585
|
+
try {
|
|
586
|
+
fs.mkdirSync(stage, { recursive: false });
|
|
587
|
+
for (const file of files) {
|
|
588
|
+
const target = path.resolve(stage, ...file.path.split('/'));
|
|
589
|
+
if (target === path.resolve(stage) || !target.startsWith(path.resolve(stage) + path.sep)) throw new AppError('PERMISSION_DENIED', `文件路径越界:${file.path}`);
|
|
590
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
591
|
+
fs.writeFileSync(target, file.data, { flag: 'wx' });
|
|
592
|
+
}
|
|
593
|
+
if (fs.existsSync(destination)) {
|
|
594
|
+
if (policy !== 'replace') throw new AppError('CONFLICT', '目标已存在');
|
|
595
|
+
fs.renameSync(destination, backup);
|
|
596
|
+
}
|
|
597
|
+
fs.renameSync(stage, destination);
|
|
598
|
+
return {
|
|
599
|
+
rollback() {
|
|
600
|
+
if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
601
|
+
if (fs.existsSync(backup)) fs.renameSync(backup, destination);
|
|
602
|
+
},
|
|
603
|
+
commit() {
|
|
604
|
+
if (fs.existsSync(backup)) {
|
|
605
|
+
try { fs.rmSync(backup, { recursive: true, force: true }); }
|
|
606
|
+
catch {
|
|
607
|
+
// The active destination is already committed. A stale hidden
|
|
608
|
+
// backup is safer than reporting failure after a successful swap.
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (fs.existsSync(stage)) fs.rmSync(stage, { recursive: true, force: true });
|
|
615
|
+
if (!fs.existsSync(destination) && fs.existsSync(backup)) fs.renameSync(backup, destination);
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function installSkill(input) {
|
|
621
|
+
const result = inspectSkill(input);
|
|
622
|
+
const policy = assertInstallRequest(result, input);
|
|
623
|
+
const transaction = writePackageAtomically(result.files, result.destination, policy);
|
|
624
|
+
try {
|
|
625
|
+
const written = extensions.listSkills().find(skill => path.resolve(skill.directory) === path.resolve(result.destination));
|
|
626
|
+
if (!written) throw new AppError('INVALID_PACKAGE', 'Skill 写入后无法被项目发现');
|
|
627
|
+
extensions.setSkillEnabled(written.key, false);
|
|
628
|
+
transaction.commit();
|
|
629
|
+
} catch (error) {
|
|
630
|
+
transaction.rollback();
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
const item = extensions.listSkills().find(skill => path.resolve(skill.directory) === path.resolve(result.destination));
|
|
634
|
+
return { ok: true, kind: 'skill', item, fingerprint: result.fingerprint };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function marketplaceForPlugin(result) {
|
|
638
|
+
const file = extensions.REPO_MARKETPLACE;
|
|
639
|
+
let marketplace = { name: 'multichat-project', interface: { displayName: 'MultiChat 项目插件' }, plugins: [] };
|
|
640
|
+
if (fs.existsSync(file)) {
|
|
641
|
+
try { marketplace = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
642
|
+
catch { packageError('项目 marketplace.json 已损坏,无法安全更新'); }
|
|
643
|
+
}
|
|
644
|
+
if (!marketplace || typeof marketplace !== 'object' || !Array.isArray(marketplace.plugins)) packageError('项目 marketplace.json 格式无效');
|
|
645
|
+
const entry = {
|
|
646
|
+
name: result.name,
|
|
647
|
+
source: { source: 'local', path: `./plugins/${result.name}` },
|
|
648
|
+
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
|
|
649
|
+
category: String(result.manifest.interface?.category || 'Other'),
|
|
650
|
+
};
|
|
651
|
+
const index = marketplace.plugins.findIndex(item => item?.name === result.name);
|
|
652
|
+
if (index >= 0) marketplace.plugins[index] = entry;
|
|
653
|
+
else marketplace.plugins.push(entry);
|
|
654
|
+
return { file, marketplace };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function atomicJson(file, value) {
|
|
658
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
659
|
+
const temp = `${file}.multichat-${process.pid}-${crypto.randomBytes(5).toString('hex')}.tmp`;
|
|
660
|
+
try {
|
|
661
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf8', flag: 'wx' });
|
|
662
|
+
fs.renameSync(temp, file);
|
|
663
|
+
} finally {
|
|
664
|
+
if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function installPlugin(input) {
|
|
669
|
+
const result = inspectPlugin(input);
|
|
670
|
+
const policy = assertInstallRequest(result, input);
|
|
671
|
+
const marketplace = marketplaceForPlugin(result);
|
|
672
|
+
const previousMarketplace = fs.existsSync(marketplace.file) ? fs.readFileSync(marketplace.file) : null;
|
|
673
|
+
const transaction = writePackageAtomically(result.files, result.destination, policy);
|
|
674
|
+
let item;
|
|
675
|
+
try {
|
|
676
|
+
atomicJson(marketplace.file, marketplace.marketplace);
|
|
677
|
+
item = extensions.listPlugins().find(plugin => plugin.id === result.name && path.resolve(plugin.path) === path.resolve(result.destination));
|
|
678
|
+
if (!item) throw new AppError('INVALID_PACKAGE', '插件写入后无法被项目 marketplace 发现');
|
|
679
|
+
extensions.markPluginImported(item.key || item.id);
|
|
680
|
+
transaction.commit();
|
|
681
|
+
} catch (error) {
|
|
682
|
+
transaction.rollback();
|
|
683
|
+
if (previousMarketplace) fs.writeFileSync(marketplace.file, previousMarketplace);
|
|
684
|
+
else if (fs.existsSync(marketplace.file)) fs.rmSync(marketplace.file, { force: true });
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
return { ok: true, kind: 'plugin', item: extensions.listPlugins().find(plugin => plugin.id === result.name && path.resolve(plugin.path) === path.resolve(result.destination)), fingerprint: result.fingerprint };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function installMcp(input) {
|
|
691
|
+
const result = inspectMcp(input);
|
|
692
|
+
const policy = assertInstallRequest(result, input);
|
|
693
|
+
const items = extensions.importMcpServers(result.servers, policy);
|
|
694
|
+
return { ok: true, kind: 'mcp', items, fingerprint: result.fingerprint };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function install(kind, input) {
|
|
698
|
+
if (kind === 'skill') return installSkill(input);
|
|
699
|
+
if (kind === 'mcp') return installMcp(input);
|
|
700
|
+
if (kind === 'plugin') return installPlugin(input);
|
|
701
|
+
throw new AppError('NOT_FOUND', '不支持的导入类型');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
module.exports = {
|
|
705
|
+
MAX_ARCHIVE_BYTES,
|
|
706
|
+
inspect,
|
|
707
|
+
install,
|
|
708
|
+
_test: { safeArchivePath, readZip, inspectSkill, inspectMcp, inspectPlugin, fingerprint },
|
|
709
|
+
};
|