skill-base
Advanced tools
| /** | ||
| * Per-skill outbound webhooks: fire-and-forget POST with JSON body. | ||
| * Invalid URLs are rejected at write time; unsafe targets are dropped at send time. | ||
| */ | ||
| const net = require('node:net'); | ||
| const WEBHOOK_MAX_URL_LENGTH = 2048; | ||
| const DEFAULT_TIMEOUT_MS = 10000; | ||
| function webhookTimeoutMs() { | ||
| const n = Number(process.env.SKILL_BASE_WEBHOOK_TIMEOUT_MS); | ||
| return Number.isFinite(n) && n > 0 ? Math.min(n, 60000) : DEFAULT_TIMEOUT_MS; | ||
| } | ||
| function isValidWebhookUrl(urlString) { | ||
| if (!urlString || typeof urlString !== 'string') return false; | ||
| if (urlString.length > WEBHOOK_MAX_URL_LENGTH) return false; | ||
| try { | ||
| const u = new URL(urlString); | ||
| return u.protocol === 'http:' || u.protocol === 'https:'; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function isLocalWebhookHostname(hostname) { | ||
| const normalized = String(hostname || '').toLowerCase(); | ||
| return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; | ||
| } | ||
| function isPrivateIpv4(hostname) { | ||
| if (net.isIP(hostname) !== 4) return false; | ||
| const [a, b] = hostname.split('.').map((part) => Number(part)); | ||
| if (a === 127) return false; | ||
| if (a === 10) return true; | ||
| if (a === 192 && b === 168) return true; | ||
| if (a === 172 && b >= 16 && b <= 31) return true; | ||
| if (a === 169 && b === 254) return true; | ||
| if (a === 0) return true; | ||
| return false; | ||
| } | ||
| function isBlockedIpv6(hostname) { | ||
| if (net.isIP(hostname) !== 6) return false; | ||
| const normalized = hostname.toLowerCase(); | ||
| if (normalized === '::1') return false; | ||
| return normalized === '::' || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb') || normalized.startsWith('fc') || normalized.startsWith('fd'); | ||
| } | ||
| function isSafeWebhookTarget(urlString) { | ||
| if (!isValidWebhookUrl(urlString)) return false; | ||
| const { hostname } = new URL(urlString); | ||
| if (isLocalWebhookHostname(hostname)) return true; | ||
| if (isPrivateIpv4(hostname)) return false; | ||
| if (isBlockedIpv6(hostname)) return false; | ||
| return true; | ||
| } | ||
| function canViewSkillWebhook(currentUser, permission) { | ||
| if (!currentUser) return false; | ||
| if (currentUser.role === 'admin') return true; | ||
| return permission === 'owner'; | ||
| } | ||
| /** | ||
| * @param {unknown} raw from JSON body | ||
| * @returns {{ ok: true, value: string | null, omit: boolean } | { ok: false, detail: string }} | ||
| */ | ||
| function parseWebhookUrlField(raw) { | ||
| if (raw === undefined) return { ok: true, value: null, omit: true }; | ||
| if (raw === null || raw === '') return { ok: true, value: null, omit: false }; | ||
| const s = String(raw).trim(); | ||
| if (!s) return { ok: true, value: null, omit: false }; | ||
| if (!isValidWebhookUrl(s)) { | ||
| return { ok: false, detail: 'webhook_url must be http(s) URL' }; | ||
| } | ||
| return { ok: true, value: s, omit: false }; | ||
| } | ||
| async function postWebhook(url, payload) { | ||
| const controller = new AbortController(); | ||
| const t = setTimeout(() => controller.abort(), webhookTimeoutMs()); | ||
| try { | ||
| await fetch(url, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(payload), | ||
| signal: controller.signal | ||
| }); | ||
| } finally { | ||
| clearTimeout(t); | ||
| } | ||
| } | ||
| /** | ||
| * @param {object | null | undefined} skillRow must include webhook_url when configured | ||
| * @param {'skill.updated' | 'skill.deleted'} event | ||
| * @param {object} data | ||
| * @param {{ id: number, username?: string } | null} actor | ||
| */ | ||
| function notifySkillWebhook(skillRow, event, data, actor) { | ||
| const url = skillRow && skillRow.webhook_url; | ||
| if (!url || !isValidWebhookUrl(url) || !isSafeWebhookTarget(url)) return; | ||
| const payload = { | ||
| event, | ||
| skill_id: skillRow.id, | ||
| timestamp: new Date().toISOString(), | ||
| actor: actor && actor.id != null | ||
| ? { id: actor.id, username: actor.username || null } | ||
| : null, | ||
| data | ||
| }; | ||
| setImmediate(() => { | ||
| postWebhook(url, payload).catch(() => {}); | ||
| }); | ||
| } | ||
| module.exports = { | ||
| isValidWebhookUrl, | ||
| isSafeWebhookTarget, | ||
| canViewSkillWebhook, | ||
| parseWebhookUrlField, | ||
| notifySkillWebhook | ||
| }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
+30
-0
@@ -299,2 +299,32 @@ # Skill Base | ||
| ### PM2 | ||
| 全局安装 [PM2](https://pm2.keymetrics.io/) 后,可用 `npx` 启动 Skill Base(参数与上文 `npx skill-base` 一致)。下面 `-d` 使用 `./skill-data`,与上文快速开始一致;需要时也可换成绝对路径。 | ||
| ```bash | ||
| pnpm add -g pm2 | ||
| pm2 start npx --name skill-base -- -y skill-base -d ./skill-data -p 8000 | ||
| ``` | ||
| `-y` 传给 `npx`,避免在非交互环境下卡住。 | ||
| 若从本仓库源码运行,在仓库根目录执行,直接启动 CLI 入口: | ||
| ```bash | ||
| pm2 start bin/skill-base.js --name skill-base -- -d ./skill-data -p 8000 | ||
| ``` | ||
| 环境变量写法与普通 Shell 一致(例如使用 SQLite 存储 Session): | ||
| ```bash | ||
| SESSION_STORE=sqlite pm2 start npx --name skill-base -- -y skill-base -d ./skill-data -p 8000 | ||
| ``` | ||
| 持久化进程列表并在开机时自动拉起: | ||
| ```bash | ||
| pm2 save | ||
| pm2 startup | ||
| ``` | ||
| ### 备份 | ||
@@ -301,0 +331,0 @@ |
+1
-1
| { | ||
| "name": "skill-base", | ||
| "version": "2.0.35", | ||
| "version": "2.0.36", | ||
| "description": "Skill Base - A lightweight, privately deployable agent skills distribution hub", | ||
@@ -5,0 +5,0 @@ "main": "src/index.js", |
+28
-0
@@ -301,2 +301,30 @@ # Skill Base | ||
| ### PM2 | ||
| Install [PM2](https://pm2.keymetrics.io/) globally, then start Skill Base with `npx` (same flags as above). Below uses `./skill-data` like **Quick start**; use an absolute path if you prefer. | ||
| ```bash | ||
| pnpm add -g pm2 | ||
| pm2 start npx --name skill-base -- -y skill-base -d ./skill-data -p 8000 | ||
| ``` | ||
| If you cloned this repository and run from source, start the CLI entry from the project root: | ||
| ```bash | ||
| pm2 start bin/skill-base.js --name skill-base -- -d ./skill-data -p 8000 | ||
| ``` | ||
| Environment variables work like any shell command (for example SQLite-backed sessions): | ||
| ```bash | ||
| SESSION_STORE=sqlite pm2 start npx --name skill-base -- -y skill-base -d ./skill-data -p 8000 | ||
| ``` | ||
| For a persistent process list and boot-time restore: | ||
| ```bash | ||
| pm2 save | ||
| pm2 startup | ||
| ``` | ||
| ### Backup | ||
@@ -303,0 +331,0 @@ |
+1
-0
@@ -359,2 +359,3 @@ const { execFileSync } = require('child_process'); | ||
| try { db.exec("ALTER TABLE skill_versions ADD COLUMN description TEXT"); } catch(e) {} | ||
| try { db.exec('ALTER TABLE skills ADD COLUMN webhook_url TEXT'); } catch (e) {} | ||
@@ -361,0 +362,0 @@ // Data migration: insert skill_collaborators record for existing Skills owners |
@@ -61,4 +61,4 @@ const db = require('../database'); | ||
| // Update Skill | ||
| update(id, name, description) { | ||
| // Update Skill (webhookUrl: undefined = leave column unchanged; null or '' clears) | ||
| update(id, name, description, webhookUrl) { | ||
| const fields = []; | ||
@@ -74,2 +74,6 @@ const values = []; | ||
| } | ||
| if (webhookUrl !== undefined) { | ||
| fields.push('webhook_url = ?'); | ||
| values.push(webhookUrl); | ||
| } | ||
| if (fields.length === 0) return this.findById(id); | ||
@@ -76,0 +80,0 @@ |
@@ -5,2 +5,3 @@ const db = require('../database'); | ||
| const { invalidateSkill } = require('../utils/model-cache'); | ||
| const { notifySkillWebhook } = require('../utils/skill-webhook'); | ||
@@ -224,4 +225,4 @@ async function collaboratorsRoutes(fastify, options) { | ||
| // Check if Skill exists | ||
| const skill = db.prepare('SELECT id FROM skills WHERE id = ?').get(skill_id); | ||
| // Check if Skill exists (name + webhook_url for outbound notify) | ||
| const skill = db.prepare('SELECT id, name, webhook_url FROM skills WHERE id = ?').get(skill_id); | ||
| if (!skill) { | ||
@@ -249,3 +250,10 @@ return reply.code(404).send({ ok: false, error: 'not_found', detail: 'Skill not found' }); | ||
| invalidateSkill(skill_id); | ||
| notifySkillWebhook( | ||
| skill, | ||
| 'skill.deleted', | ||
| { name: skill.name, versions_count: versionsCount }, | ||
| request.user | ||
| ); | ||
| // Delete files from filesystem | ||
@@ -252,0 +260,0 @@ const fs = require('fs'); |
+40
-3
@@ -8,2 +8,3 @@ const fs = require('fs'); | ||
| const { canManageSkill } = require('../utils/permission'); | ||
| const { parseWebhookUrlField, notifySkillWebhook, canViewSkillWebhook } = require('../utils/skill-webhook'); | ||
@@ -43,3 +44,3 @@ function listCollaboratorUsersForSkillDetail(skillId) { | ||
| if (currentUser) { | ||
| if (currentUser.id === skill.owner_id) { | ||
| if (currentUser.role === 'admin' || currentUser.id === skill.owner_id) { | ||
| result.permission = 'owner'; | ||
@@ -58,2 +59,6 @@ } else { | ||
| if (canViewSkillWebhook(currentUser, result.permission)) { | ||
| result.webhook_url = skill.webhook_url || null; | ||
| } | ||
| return result; | ||
@@ -168,3 +173,3 @@ } | ||
| const { skill_id } = request.params; | ||
| const { name, description } = request.body || {}; | ||
| const { name, description, webhook_url: webhookUrlRaw } = request.body || {}; | ||
@@ -179,3 +184,23 @@ if (!SkillModel.exists(skill_id)) { | ||
| const updated = SkillModel.update(skill_id, name, description); | ||
| const parsed = parseWebhookUrlField(webhookUrlRaw); | ||
| if (!parsed.ok) { | ||
| return reply.code(400).send({ detail: parsed.detail }); | ||
| } | ||
| const prev = SkillModel.findById(skill_id); | ||
| const webhookColumn = parsed.omit ? undefined : parsed.value; | ||
| const updated = SkillModel.update(skill_id, name, description, webhookColumn); | ||
| const metaChanged = | ||
| (name !== undefined && String(name) !== String(prev.name)) || | ||
| (description !== undefined && String(description ?? '') !== String(prev.description ?? '')); | ||
| if (metaChanged) { | ||
| notifySkillWebhook( | ||
| updated, | ||
| 'skill.updated', | ||
| { kind: 'metadata', name: updated.name, description: updated.description }, | ||
| request.user | ||
| ); | ||
| } | ||
| return formatSkill(updated, request.user); | ||
@@ -209,2 +234,8 @@ }); | ||
| SkillModel.updateLatestVersion(skill_id, version); | ||
| notifySkillWebhook( | ||
| SkillModel.findById(skill_id), | ||
| 'skill.updated', | ||
| { kind: 'head', latest_version: version }, | ||
| request.user | ||
| ); | ||
| return { ok: true, skill_id, latest_version: version }; | ||
@@ -234,2 +265,8 @@ }); | ||
| const updated = VersionModel.update(versionRecord.id, description, changelog); | ||
| notifySkillWebhook( | ||
| SkillModel.findById(skill_id), | ||
| 'skill.updated', | ||
| { kind: 'version_metadata', version }, | ||
| request.user | ||
| ); | ||
| return formatVersion(updated); | ||
@@ -236,0 +273,0 @@ }); |
@@ -8,2 +8,3 @@ const fs = require('fs'); | ||
| const { canPublishSkill } = require('./permission'); | ||
| const { notifySkillWebhook } = require('./skill-webhook'); | ||
@@ -55,13 +56,34 @@ /** | ||
| const zipPath = getZipPath(skill_id, version); | ||
| fs.writeFileSync(zipPath, zipBuffer); | ||
| try { | ||
| fs.writeFileSync(zipPath, zipBuffer, { flag: 'wx' }); | ||
| } catch (error) { | ||
| if (error && error.code === 'EEXIST') { | ||
| return { | ||
| ok: false, | ||
| status: 409, | ||
| body: { | ||
| ok: false, | ||
| error: 'version_conflict', | ||
| detail: 'A version with the same timestamp already exists; please retry publish' | ||
| } | ||
| }; | ||
| } | ||
| throw error; | ||
| } | ||
| const zipRelativePath = getZipRelativePath(skill_id, version); | ||
| const versionRecord = VersionModel.create( | ||
| skill_id, | ||
| version, | ||
| changelog || '', | ||
| zipRelativePath, | ||
| user.id, | ||
| description || '' | ||
| ); | ||
| let versionRecord; | ||
| try { | ||
| versionRecord = VersionModel.create( | ||
| skill_id, | ||
| version, | ||
| changelog || '', | ||
| zipRelativePath, | ||
| user.id, | ||
| description || '' | ||
| ); | ||
| } catch (error) { | ||
| fs.rmSync(zipPath, { force: true }); | ||
| throw error; | ||
| } | ||
@@ -71,2 +93,9 @@ SkillModel.updateLatestVersion(skill_id, version); | ||
| notifySkillWebhook( | ||
| SkillModel.findById(skill_id), | ||
| 'skill.updated', | ||
| { kind: 'version_published', version, created_at: versionRecord.created_at }, | ||
| user | ||
| ); | ||
| return { | ||
@@ -73,0 +102,0 @@ ok: true, |
@@ -12,4 +12,4 @@ <!DOCTYPE html> | ||
| <title>Skill Base</title> | ||
| <script type="module" crossorigin src="./assets/index-B-C8F7ej.js"></script> | ||
| <link rel="stylesheet" crossorigin href="./assets/index-D3enjG_f.css"> | ||
| <script type="module" crossorigin src="./assets/index-HwIh4LJF.js"></script> | ||
| <link rel="stylesheet" crossorigin href="./assets/index-De_i-3yU.css"> | ||
| </head> | ||
@@ -16,0 +16,0 @@ <body> |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
14348115
0.08%133
0.76%9888
2.02%368
8.24%45
2.27%11
10%