@zshuangmu/agenthub 0.1.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/LICENSE +21 -0
- package/README.md +176 -0
- package/package.json +41 -0
- package/src/api-server.js +195 -0
- package/src/cli.js +343 -0
- package/src/commands/api.js +8 -0
- package/src/commands/info.js +6 -0
- package/src/commands/install.js +10 -0
- package/src/commands/list.js +91 -0
- package/src/commands/pack.js +151 -0
- package/src/commands/publish-remote.js +9 -0
- package/src/commands/publish.js +7 -0
- package/src/commands/rollback.js +64 -0
- package/src/commands/search.js +7 -0
- package/src/commands/serve.js +9 -0
- package/src/commands/stats.js +90 -0
- package/src/commands/update.js +68 -0
- package/src/commands/versions.js +63 -0
- package/src/commands/web.js +8 -0
- package/src/index.js +14 -0
- package/src/lib/bundle-transfer.js +58 -0
- package/src/lib/database.js +244 -0
- package/src/lib/download-stats.js +77 -0
- package/src/lib/fs-utils.js +46 -0
- package/src/lib/html.js +1730 -0
- package/src/lib/http.js +24 -0
- package/src/lib/install.js +14 -0
- package/src/lib/manifest.js +123 -0
- package/src/lib/openclaw-config.js +40 -0
- package/src/lib/registry.js +64 -0
- package/src/lib/security-scanner.js +233 -0
- package/src/lib/skill-md.js +17 -0
- package/src/server.js +158 -0
- package/src/web-server.js +138 -0
package/src/lib/http.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function sendJson(response, statusCode, payload, extraHeaders = {}) {
|
|
2
|
+
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", ...extraHeaders });
|
|
3
|
+
response.end(`${JSON.stringify(payload, null, 2)}\n`);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function sendHtml(response, statusCode, html, extraHeaders = {}) {
|
|
7
|
+
response.writeHead(statusCode, { "content-type": "text/html; charset=utf-8", ...extraHeaders });
|
|
8
|
+
response.end(html);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function notFound(response, extraHeaders = {}) {
|
|
12
|
+
sendJson(response, 404, { error: "Not Found" }, extraHeaders);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readJsonBody(request) {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
for await (const chunk of request) {
|
|
18
|
+
chunks.push(Buffer.from(chunk));
|
|
19
|
+
}
|
|
20
|
+
if (chunks.length === 0) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { copyDir, ensureDir, readJson, writeJson } from "./fs-utils.js";
|
|
3
|
+
import { readAgentInfo } from "./registry.js";
|
|
4
|
+
|
|
5
|
+
export async function installBundle({ registryDir, agentSpec, targetWorkspace }) {
|
|
6
|
+
const manifest = await readAgentInfo(registryDir, agentSpec);
|
|
7
|
+
const bundleDir = path.join(registryDir, "agents", manifest.slug, manifest.version);
|
|
8
|
+
await ensureDir(targetWorkspace);
|
|
9
|
+
await copyDir(path.join(bundleDir, "WORKSPACE"), targetWorkspace);
|
|
10
|
+
const template = await readJson(path.join(bundleDir, "OPENCLAW.template.json"));
|
|
11
|
+
const appliedPath = path.join(targetWorkspace, ".agenthub", "OPENCLAW.applied.json");
|
|
12
|
+
await writeJson(appliedPath, template);
|
|
13
|
+
return { manifest, appliedPath };
|
|
14
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MANIFEST Generator
|
|
3
|
+
* 根据 PRD v1.1 规范生成完整的 MANIFEST.json
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const WORKSPACE_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "IDENTITY.md", "TOOLS.md", "HEARTBEAT.md", "BOOTSTRAP.md"];
|
|
7
|
+
|
|
8
|
+
export function createManifest({ slug, name, description, author, memoryCounts, openclawTemplate, skills = [], prompts = [], tags = [], category, language = "zh-CN" }) {
|
|
9
|
+
const hasModel = openclawTemplate?.agents?.defaults?.model?.primary;
|
|
10
|
+
const totalMemory = memoryCounts.public + memoryCounts.portable + memoryCounts.private;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
// 基本信息
|
|
14
|
+
name: name || slug,
|
|
15
|
+
slug,
|
|
16
|
+
version: "1.0.0",
|
|
17
|
+
description: description || `OpenClaw agent bundle for ${name || slug}`,
|
|
18
|
+
author: author || "anonymous",
|
|
19
|
+
icon: undefined,
|
|
20
|
+
bundleVersion: "1.0",
|
|
21
|
+
|
|
22
|
+
// 运行时
|
|
23
|
+
runtime: {
|
|
24
|
+
type: "openclaw",
|
|
25
|
+
version: ">=0.5.0",
|
|
26
|
+
compatibility: "openclaw-only",
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
// 性格
|
|
30
|
+
persona: {
|
|
31
|
+
summary: `Imported from OpenClaw workspace: ${name || slug}`,
|
|
32
|
+
traits: [],
|
|
33
|
+
expertise: [],
|
|
34
|
+
communication_style: undefined,
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// 包含内容
|
|
38
|
+
includes: {
|
|
39
|
+
memory: {
|
|
40
|
+
count: totalMemory,
|
|
41
|
+
public: memoryCounts.public,
|
|
42
|
+
portable: memoryCounts.portable,
|
|
43
|
+
private: memoryCounts.private,
|
|
44
|
+
},
|
|
45
|
+
workspaceFiles: WORKSPACE_FILES,
|
|
46
|
+
skills: skills,
|
|
47
|
+
prompts: prompts.length,
|
|
48
|
+
configTemplates: ["OPENCLAW.template.json"],
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// 依赖
|
|
52
|
+
requirements: {
|
|
53
|
+
env: [],
|
|
54
|
+
model: hasModel ?? undefined,
|
|
55
|
+
openclaw: ">=0.5.0",
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// 分享策略
|
|
59
|
+
sharingPolicy: {
|
|
60
|
+
memoryMode: "layered",
|
|
61
|
+
allowedMemoryLayers: ["public", "portable"],
|
|
62
|
+
blockedMemoryLayers: ["private"],
|
|
63
|
+
openclawConfigMode: "template-only",
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// 元数据
|
|
67
|
+
metadata: {
|
|
68
|
+
tags: tags.length > 0 ? tags : ["openclaw"],
|
|
69
|
+
category: category || "General",
|
|
70
|
+
language: language,
|
|
71
|
+
license: "MIT",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 验证 MANIFEST 完整性
|
|
78
|
+
*/
|
|
79
|
+
export function validateManifest(manifest) {
|
|
80
|
+
const errors = [];
|
|
81
|
+
const warnings = [];
|
|
82
|
+
|
|
83
|
+
// 必需字段检查
|
|
84
|
+
if (!manifest.name) errors.push("name is required");
|
|
85
|
+
if (!manifest.slug) errors.push("slug is required");
|
|
86
|
+
if (!manifest.version) errors.push("version is required");
|
|
87
|
+
if (!manifest.description) errors.push("description is required");
|
|
88
|
+
|
|
89
|
+
// 运行时检查
|
|
90
|
+
if (manifest.runtime?.type !== "openclaw") {
|
|
91
|
+
errors.push("runtime.type must be 'openclaw'");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 私有记忆检查
|
|
95
|
+
if (manifest.includes?.memory?.private > 0) {
|
|
96
|
+
warnings.push("Bundle contains private memory, cannot be published publicly");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
valid: errors.length === 0,
|
|
101
|
+
canPublish: errors.length === 0 && manifest.includes?.memory?.private === 0,
|
|
102
|
+
errors,
|
|
103
|
+
warnings,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 生成 Bundle ID
|
|
109
|
+
*/
|
|
110
|
+
export function generateBundleId(slug, version) {
|
|
111
|
+
return `agenthub://${slug}@${version}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 解析 Bundle ID
|
|
116
|
+
*/
|
|
117
|
+
export function parseBundleId(bundleId) {
|
|
118
|
+
const match = bundleId.match(/^agenthub:\/\/([^@]+)@(.+)$/);
|
|
119
|
+
if (!match) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return { slug: match[1], version: match[2] };
|
|
123
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const ALLOWED_PATHS = [
|
|
2
|
+
["agents", "defaults", "model"],
|
|
3
|
+
["agents", "defaults", "compaction"],
|
|
4
|
+
["agents", "defaults", "memorySearch"],
|
|
5
|
+
["agents", "defaults", "sandbox"],
|
|
6
|
+
["plugins", "slots"],
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
function cloneJson(value) {
|
|
10
|
+
return JSON.parse(JSON.stringify(value));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setDeep(target, keys, value) {
|
|
14
|
+
let cursor = target;
|
|
15
|
+
for (let index = 0; index < keys.length - 1; index += 1) {
|
|
16
|
+
const key = keys[index];
|
|
17
|
+
cursor[key] ??= {};
|
|
18
|
+
cursor = cursor[key];
|
|
19
|
+
}
|
|
20
|
+
cursor[keys[keys.length - 1]] = cloneJson(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function extractOpenClawTemplate(config) {
|
|
24
|
+
const template = {};
|
|
25
|
+
for (const keys of ALLOWED_PATHS) {
|
|
26
|
+
let cursor = config;
|
|
27
|
+
let found = true;
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
if (!cursor || !(key in cursor)) {
|
|
30
|
+
found = false;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
cursor = cursor[key];
|
|
34
|
+
}
|
|
35
|
+
if (found) {
|
|
36
|
+
setDeep(template, keys, cursor);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return template;
|
|
40
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { copyDir, ensureDir, pathExists, readJson, writeJson } from "./fs-utils.js";
|
|
3
|
+
|
|
4
|
+
function parseSpec(agentSpec) {
|
|
5
|
+
const [slug, version] = agentSpec.split(":");
|
|
6
|
+
return { slug, version };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function publishBundle(bundleDir, registryDir) {
|
|
10
|
+
const manifest = await readJson(path.join(bundleDir, "MANIFEST.json"));
|
|
11
|
+
if (manifest.runtime?.type !== "openclaw") {
|
|
12
|
+
throw new Error("Only OpenClaw bundles are supported.");
|
|
13
|
+
}
|
|
14
|
+
if ((manifest.includes?.memory?.private ?? 0) > 0) {
|
|
15
|
+
throw new Error("Public publish rejected: private memory detected.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const targetDir = path.join(registryDir, "agents", manifest.slug, manifest.version);
|
|
19
|
+
await ensureDir(path.join(registryDir, "agents", manifest.slug));
|
|
20
|
+
await copyDir(bundleDir, targetDir);
|
|
21
|
+
|
|
22
|
+
const indexPath = path.join(registryDir, "index.json");
|
|
23
|
+
const index = (await pathExists(indexPath)) ? await readJson(indexPath) : { agents: [] };
|
|
24
|
+
index.agents = index.agents.filter(
|
|
25
|
+
(entry) => !(entry.slug === manifest.slug && entry.version === manifest.version),
|
|
26
|
+
);
|
|
27
|
+
index.agents.push({
|
|
28
|
+
slug: manifest.slug,
|
|
29
|
+
version: manifest.version,
|
|
30
|
+
name: manifest.name,
|
|
31
|
+
description: manifest.description,
|
|
32
|
+
runtime: manifest.runtime,
|
|
33
|
+
});
|
|
34
|
+
index.agents.sort((left, right) => left.slug.localeCompare(right.slug));
|
|
35
|
+
await writeJson(indexPath, index);
|
|
36
|
+
|
|
37
|
+
return manifest;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function searchRegistry(registryDir, query) {
|
|
41
|
+
const indexPath = path.join(registryDir, "index.json");
|
|
42
|
+
if (!(await pathExists(indexPath))) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const index = await readJson(indexPath);
|
|
46
|
+
const normalized = query.toLowerCase();
|
|
47
|
+
return index.agents.filter((entry) => entry.slug.toLowerCase().includes(normalized));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function readAgentInfo(registryDir, agentSpec) {
|
|
51
|
+
const { slug, version } = parseSpec(agentSpec);
|
|
52
|
+
const baseDir = path.join(registryDir, "agents", slug);
|
|
53
|
+
if (!(await pathExists(baseDir))) {
|
|
54
|
+
throw new Error(`Agent not found: ${slug}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let selectedVersion = version;
|
|
58
|
+
if (!selectedVersion) {
|
|
59
|
+
const index = await readJson(path.join(registryDir, "index.json"));
|
|
60
|
+
const versions = index.agents.filter((entry) => entry.slug === slug).map((entry) => entry.version).sort();
|
|
61
|
+
selectedVersion = versions.at(-1);
|
|
62
|
+
}
|
|
63
|
+
return readJson(path.join(baseDir, selectedVersion, "MANIFEST.json"));
|
|
64
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Scanner
|
|
3
|
+
* 安全扫描模块
|
|
4
|
+
*
|
|
5
|
+
* 根据 PRD v1.1 安全规范实现
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
// 敏感信息模式
|
|
12
|
+
const SENSITIVE_PATTERNS = [
|
|
13
|
+
{ pattern: /sk-[a-zA-Z0-9]{20,}/g, name: "OpenAI API Key", severity: "high" },
|
|
14
|
+
{ pattern: /ghp_[a-zA-Z0-9]{36}/g, name: "GitHub Personal Token", severity: "high" },
|
|
15
|
+
{ pattern: /gho_[a-zA-Z0-9]{36}/g, name: "GitHub OAuth Token", severity: "high" },
|
|
16
|
+
{ pattern: /ghu_[a-zA-Z0-9]{36}/g, name: "GitHub User Token", severity: "high" },
|
|
17
|
+
{ pattern: /ghs_[a-zA-Z0-9]{36}/g, name: "GitHub Server Token", severity: "high" },
|
|
18
|
+
{ pattern: /ghr_[a-zA-Z0-9]{36}/g, name: "GitHub Refresh Token", severity: "high" },
|
|
19
|
+
{ pattern: /xox[baprs]-[a-zA-Z0-9-]+/g, name: "Slack Token", severity: "high" },
|
|
20
|
+
{ pattern: /eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g, name: "JWT Token", severity: "medium" },
|
|
21
|
+
{ pattern: /(password|passwd|pwd)\s*[=:]\s*['"][^'"]+['"]/gi, name: "Password", severity: "high" },
|
|
22
|
+
{ pattern: /(secret|api_key|apikey|access_key)\s*[=:]\s*['"][^'"]+['"]/gi, name: "API Key/Secret", severity: "high" },
|
|
23
|
+
{ pattern: /-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----/g, name: "Private Key", severity: "critical" },
|
|
24
|
+
{ pattern: /(token|bearer)\s*[=:]\s*['"][^'"]+['"]/gi, name: "Token", severity: "medium" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// 禁止的配置字段路径
|
|
28
|
+
const FORBIDDEN_CONFIG_PATHS = [
|
|
29
|
+
"channels",
|
|
30
|
+
"gateway",
|
|
31
|
+
"wizard",
|
|
32
|
+
"auth",
|
|
33
|
+
"credentials",
|
|
34
|
+
"secrets",
|
|
35
|
+
"tokens",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// 文件扩展名白名单(跳过二进制文件)
|
|
39
|
+
const TEXT_EXTENSIONS = new Set([
|
|
40
|
+
".md", ".txt", ".json", ".yaml", ".yml", ".xml", ".html", ".css", ".js", ".ts",
|
|
41
|
+
".py", ".sh", ".bash", ".zsh", ".env", ".conf", ".config", ".ini", ".toml",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 检查文件是否为文本文件
|
|
46
|
+
*/
|
|
47
|
+
function isTextFile(filename) {
|
|
48
|
+
const ext = path.extname(filename).toLowerCase();
|
|
49
|
+
return TEXT_EXTENSIONS.has(ext) || !ext;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 扫描文件内容中的敏感信息
|
|
54
|
+
*/
|
|
55
|
+
async function scanFileContent(filePath, content) {
|
|
56
|
+
const findings = [];
|
|
57
|
+
|
|
58
|
+
for (const { pattern, name, severity } of SENSITIVE_PATTERNS) {
|
|
59
|
+
const matches = content.match(pattern);
|
|
60
|
+
if (matches) {
|
|
61
|
+
findings.push({
|
|
62
|
+
file: filePath,
|
|
63
|
+
type: name,
|
|
64
|
+
severity,
|
|
65
|
+
count: matches.length,
|
|
66
|
+
// 不记录具体值,只记录发现
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 递归扫描目录
|
|
76
|
+
*/
|
|
77
|
+
async function scanDirectory(dirPath, basePath = "") {
|
|
78
|
+
const findings = [];
|
|
79
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
80
|
+
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
83
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
84
|
+
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
// 跳过 node_modules 和隐藏目录
|
|
87
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const subFindings = await scanDirectory(fullPath, relativePath);
|
|
91
|
+
findings.push(...subFindings);
|
|
92
|
+
} else if (entry.isFile() && isTextFile(entry.name)) {
|
|
93
|
+
try {
|
|
94
|
+
const content = await readFile(fullPath, "utf8");
|
|
95
|
+
const fileFindings = await scanFileContent(relativePath, content);
|
|
96
|
+
findings.push(...fileFindings);
|
|
97
|
+
} catch {
|
|
98
|
+
// 跳过无法读取的文件
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 检查配置文件中的禁止字段
|
|
108
|
+
*/
|
|
109
|
+
function checkForbiddenFields(config, path = "") {
|
|
110
|
+
const violations = [];
|
|
111
|
+
|
|
112
|
+
for (const key of Object.keys(config)) {
|
|
113
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
114
|
+
|
|
115
|
+
// 检查是否是禁止的路径
|
|
116
|
+
for (const forbidden of FORBIDDEN_CONFIG_PATHS) {
|
|
117
|
+
if (currentPath.startsWith(forbidden) || key === forbidden) {
|
|
118
|
+
violations.push({
|
|
119
|
+
path: currentPath,
|
|
120
|
+
reason: `禁止包含 ${forbidden} 配置`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 递归检查嵌套对象
|
|
126
|
+
if (typeof config[key] === "object" && config[key] !== null) {
|
|
127
|
+
const nestedViolations = checkForbiddenFields(config[key], currentPath);
|
|
128
|
+
violations.push(...nestedViolations);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return violations;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 扫描 Bundle
|
|
137
|
+
*/
|
|
138
|
+
export async function scanBundle(bundleDir) {
|
|
139
|
+
const result = {
|
|
140
|
+
warnings: [],
|
|
141
|
+
errors: [],
|
|
142
|
+
findings: [],
|
|
143
|
+
canPublish: true,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// 1. 扫描敏感信息
|
|
147
|
+
const workspacePath = path.join(bundleDir, "WORKSPACE");
|
|
148
|
+
try {
|
|
149
|
+
const contentFindings = await scanDirectory(workspacePath, "WORKSPACE");
|
|
150
|
+
result.findings.push(...contentFindings);
|
|
151
|
+
|
|
152
|
+
for (const finding of contentFindings) {
|
|
153
|
+
const severityIcon = finding.severity === "critical" ? "🔴" : finding.severity === "high" ? "🟠" : "🟡";
|
|
154
|
+
result.warnings.push(`${severityIcon} ${finding.file}: 发现 ${finding.type} (${finding.count} 处)`);
|
|
155
|
+
|
|
156
|
+
if (finding.severity === "critical" || finding.severity === "high") {
|
|
157
|
+
result.canPublish = false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// WORKSPACE 目录不存在
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 2. 检查私有记忆
|
|
165
|
+
const manifestPath = path.join(bundleDir, "MANIFEST.json");
|
|
166
|
+
try {
|
|
167
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
168
|
+
|
|
169
|
+
if (manifest.includes?.memory?.private > 0) {
|
|
170
|
+
result.errors.push("检测到私有记忆 (memory/private),禁止公开发布");
|
|
171
|
+
result.canPublish = false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 3. 检查配置文件
|
|
175
|
+
const configPath = path.join(bundleDir, "OPENCLAW.template.json");
|
|
176
|
+
try {
|
|
177
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
178
|
+
const violations = checkForbiddenFields(config);
|
|
179
|
+
|
|
180
|
+
for (const violation of violations) {
|
|
181
|
+
result.warnings.push(`⚠️ OPENCLAW.template.json: ${violation.reason} (${violation.path})`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (violations.length > 0) {
|
|
185
|
+
result.canPublish = false;
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// 配置文件不存在
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// MANIFEST 不存在
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 扫描工作区(打包前预扫描)
|
|
199
|
+
*/
|
|
200
|
+
export async function scanWorkspace(workspacePath) {
|
|
201
|
+
const result = {
|
|
202
|
+
warnings: [],
|
|
203
|
+
errors: [],
|
|
204
|
+
findings: [],
|
|
205
|
+
canPublish: true,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// 扫描敏感信息
|
|
209
|
+
const contentFindings = await scanDirectory(workspacePath);
|
|
210
|
+
result.findings.push(...contentFindings);
|
|
211
|
+
|
|
212
|
+
for (const finding of contentFindings) {
|
|
213
|
+
const severityIcon = finding.severity === "critical" ? "🔴" : finding.severity === "high" ? "🟠" : "🟡";
|
|
214
|
+
result.warnings.push(`${severityIcon} ${finding.file}: 发现 ${finding.type}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 检查私有记忆
|
|
218
|
+
const privateMemoryPath = path.join(workspacePath, "memory", "private");
|
|
219
|
+
try {
|
|
220
|
+
const stat_ = await stat(privateMemoryPath);
|
|
221
|
+
if (stat_.isDirectory()) {
|
|
222
|
+
const files = await readdir(privateMemoryPath);
|
|
223
|
+
if (files.length > 0) {
|
|
224
|
+
result.warnings.push("🚫 检测到 memory/private/ 目录,包含私有记忆");
|
|
225
|
+
result.warnings.push(" 公开发布时将被阻止,建议将私有记忆移出工作区");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// 目录不存在,正常
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export async function serveSkillMarkdown(request, response) {
|
|
5
|
+
const skillPath = path.join(process.cwd(), "skills", "agenthub-discover", "SKILL.md");
|
|
6
|
+
try {
|
|
7
|
+
const content = await readFile(skillPath, "utf8");
|
|
8
|
+
response.writeHead(200, {
|
|
9
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
10
|
+
"Access-Control-Allow-Origin": "*"
|
|
11
|
+
});
|
|
12
|
+
response.end(content);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
response.writeHead(404, { "Content-Type": "application/json" });
|
|
15
|
+
response.end(JSON.stringify({ error: "Skill not found" }));
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { infoCommand, installCommand, publishCommand, searchCommand } from "./index.js";
|
|
5
|
+
import { publishUploadedBundle } from "./lib/bundle-transfer.js";
|
|
6
|
+
import { notFound, readJsonBody, sendHtml, sendJson } from "./lib/http.js";
|
|
7
|
+
import { renderAgentDetailPage, renderAgentListPage, renderStatsPage } from "./lib/html.js";
|
|
8
|
+
import {
|
|
9
|
+
initDatabase,
|
|
10
|
+
incrementDownloads,
|
|
11
|
+
getAgentDownloads,
|
|
12
|
+
getAgentsDownloads,
|
|
13
|
+
getTotalDownloads,
|
|
14
|
+
getDownloadRanking,
|
|
15
|
+
getRecentDownloads,
|
|
16
|
+
getDatabaseStats
|
|
17
|
+
} from "./lib/database.js";
|
|
18
|
+
|
|
19
|
+
export async function createServer({ registryDir, port = 3000, host = "0.0.0.0" }) {
|
|
20
|
+
// 初始化数据库
|
|
21
|
+
await initDatabase(registryDir);
|
|
22
|
+
|
|
23
|
+
const server = http.createServer(async (request, response) => {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
26
|
+
|
|
27
|
+
// API: 获取 AgentHub Discover Skill
|
|
28
|
+
if (url.pathname === "/skills/agenthub-discover/SKILL.md") {
|
|
29
|
+
try {
|
|
30
|
+
const skillPath = path.join(process.cwd(), "skills", "agenthub-discover", "SKILL.md");
|
|
31
|
+
const content = await readFile(skillPath, "utf8");
|
|
32
|
+
response.writeHead(200, {
|
|
33
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
34
|
+
"Access-Control-Allow-Origin": "*"
|
|
35
|
+
});
|
|
36
|
+
response.end(content);
|
|
37
|
+
} catch {
|
|
38
|
+
response.writeHead(404, { "Content-Type": "application/json" });
|
|
39
|
+
response.end(JSON.stringify({ error: "Skill not found" }));
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (url.pathname === "/api/agents") {
|
|
45
|
+
const agents = await searchCommand(url.searchParams.get("q") ?? "", { registry: registryDir });
|
|
46
|
+
// 添加下载数
|
|
47
|
+
const slugs = agents.map(a => a.slug);
|
|
48
|
+
const downloads = await getAgentsDownloads(registryDir, slugs);
|
|
49
|
+
const agentsWithDownloads = agents.map(a => ({ ...a, downloads: downloads[a.slug] || 0 }));
|
|
50
|
+
sendJson(response, 200, { agents: agentsWithDownloads });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (url.pathname === "/api/publish" && request.method === "POST") {
|
|
55
|
+
const body = await readJsonBody(request);
|
|
56
|
+
const manifest = await publishCommand(body.bundleDir, { registry: registryDir });
|
|
57
|
+
sendJson(response, 200, manifest);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (url.pathname === "/api/publish-upload" && request.method === "POST") {
|
|
62
|
+
const body = await readJsonBody(request);
|
|
63
|
+
const manifest = await publishUploadedBundle({ payload: body, registryDir });
|
|
64
|
+
sendJson(response, 200, manifest);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (url.pathname === "/api/install" && request.method === "POST") {
|
|
69
|
+
const body = await readJsonBody(request);
|
|
70
|
+
const result = await installCommand(body.agent, {
|
|
71
|
+
registry: registryDir,
|
|
72
|
+
targetWorkspace: body.targetWorkspace,
|
|
73
|
+
});
|
|
74
|
+
// 记录下载(包含元数据)
|
|
75
|
+
const slug = body.agent.split(":")[0];
|
|
76
|
+
await incrementDownloads(registryDir, slug, {
|
|
77
|
+
targetWorkspace: body.targetWorkspace,
|
|
78
|
+
ip: request.socket.remoteAddress,
|
|
79
|
+
userAgent: request.headers['user-agent']
|
|
80
|
+
});
|
|
81
|
+
sendJson(response, 200, result);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// API: 获取下载统计
|
|
86
|
+
if (url.pathname === "/api/stats") {
|
|
87
|
+
const stats = await getDatabaseStats(registryDir);
|
|
88
|
+
const ranking = await getDownloadRanking(registryDir, 10);
|
|
89
|
+
const recent = await getRecentDownloads(registryDir, 20);
|
|
90
|
+
sendJson(response, 200, { stats, ranking, recent });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// API: 获取下载排行
|
|
95
|
+
if (url.pathname === "/api/stats/ranking") {
|
|
96
|
+
const limit = parseInt(url.searchParams.get("limit") || "10", 10);
|
|
97
|
+
const ranking = await getDownloadRanking(registryDir, limit);
|
|
98
|
+
sendJson(response, 200, { ranking });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (url.pathname.startsWith("/api/agents/")) {
|
|
103
|
+
const slug = url.pathname.slice("/api/agents/".length);
|
|
104
|
+
const manifest = await infoCommand(slug, { registry: registryDir });
|
|
105
|
+
// 添加下载数
|
|
106
|
+
const downloads = await getAgentDownloads(registryDir, slug);
|
|
107
|
+
sendJson(response, 200, { ...manifest, downloads });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (url.pathname === "/") {
|
|
112
|
+
const query = url.searchParams.get("q") ?? "";
|
|
113
|
+
const agents = await searchCommand(query, { registry: registryDir });
|
|
114
|
+
// 添加下载数
|
|
115
|
+
const slugs = agents.map(a => a.slug);
|
|
116
|
+
const downloads = await getAgentsDownloads(registryDir, slugs);
|
|
117
|
+
const totalDownloads = await getTotalDownloads(registryDir);
|
|
118
|
+
const agentsWithDownloads = agents.map(a => ({ ...a, downloads: downloads[a.slug] || 0 }));
|
|
119
|
+
sendHtml(response, 200, renderAgentListPage({ query, agents: agentsWithDownloads, totalDownloads }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (url.pathname.startsWith("/agents/")) {
|
|
124
|
+
const slug = url.pathname.slice("/agents/".length);
|
|
125
|
+
const manifest = await infoCommand(slug, { registry: registryDir });
|
|
126
|
+
// 添加下载数
|
|
127
|
+
const downloads = await getAgentDownloads(registryDir, slug);
|
|
128
|
+
sendHtml(response, 200, renderAgentDetailPage({ ...manifest, downloads }));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 统计页面
|
|
133
|
+
if (url.pathname === "/stats") {
|
|
134
|
+
const stats = await getDatabaseStats(registryDir);
|
|
135
|
+
const ranking = await getDownloadRanking(registryDir, 10);
|
|
136
|
+
const recent = await getRecentDownloads(registryDir, 20);
|
|
137
|
+
sendHtml(response, 200, renderStatsPage({ stats, ranking, recent }));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
notFound(response);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
sendJson(response, 500, { error: error.message });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
148
|
+
const address = server.address();
|
|
149
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
server,
|
|
153
|
+
port: actualPort,
|
|
154
|
+
host,
|
|
155
|
+
baseUrl: `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`,
|
|
156
|
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
|
|
157
|
+
};
|
|
158
|
+
}
|