Merge remote-tracking branch 'origin/main' into review/pr480-20260728

This commit is contained in:
ylhu16 2026-07-28 22:10:53 +08:00
commit fab07cbc92
264 changed files with 13043 additions and 1154 deletions

View file

@ -18,6 +18,9 @@ SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
# Usually keep empty when web and api are served from the same domain.
SKILLHUB_WEB_API_BASE_URL=
SKILLHUB_API_UPSTREAM=http://server:8080
# Enable only when a trusted TLS-terminating proxy replaces X-Forwarded-Proto
# and the web container cannot be reached directly.
SKILLHUB_TRUST_FORWARDED_PROTO=false
# Keep database and redis local-only on the host unless you explicitly need remote access.
POSTGRES_BIND_ADDRESS=127.0.0.1
@ -93,3 +96,6 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes

View file

@ -15,6 +15,9 @@ SKILLHUB_PUBLIC_BASE_URL=http://localhost
# Frontend usually keeps this empty and proxies to the backend through nginx.
SKILLHUB_WEB_API_BASE_URL=
SKILLHUB_API_UPSTREAM=http://server:8080
# Keep false for direct exposure. Enable only behind a trusted proxy that replaces
# X-Forwarded-Proto and blocks direct access to the web container.
SKILLHUB_TRUST_FORWARDED_PROTO=false
POSTGRES_BIND_ADDRESS=127.0.0.1
POSTGRES_PORT=5432
@ -104,6 +107,10 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
# Security scanner is enabled by default. Set to false to disable scanning.
SKILLHUB_SECURITY_SCANNER_ENABLED=true
# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
# runtime.sh generates and persists one automatically when this placeholder is still present.
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes
# Scanner LLM configuration (optional, for AI-powered scanning features)
SKILL_SCANNER_LLM_API_KEY=
SKILL_SCANNER_LLM_BASE_URL=

View file

@ -7,6 +7,9 @@ on:
- 'Makefile'
- '.github/workflows/pr-cli.yml'
permissions:
contents: read
jobs:
cli:
strategy:
@ -16,6 +19,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13

View file

@ -34,6 +34,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@v4

View file

@ -4,16 +4,37 @@ on:
pull_request:
paths:
- 'scripts/**'
- '.env.release.example'
- '.env.release.draft'
- 'compose.release.yml'
- 'Makefile'
- 'web/Dockerfile'
- 'web/nginx.conf.template'
- '.github/workflows/pr-cli.yml'
- '.github/workflows/pr-e2e.yml'
- '.github/workflows/pr-tests.yml'
- '.github/workflows/security.yml'
- '.github/workflows/pr-scripts.yml'
- '**/*.py'
permissions:
contents: read
jobs:
publish-cli-test:
scripts-tests:
name: Script Regression Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '21'
- run: bash scripts/tests/publish-cli-test.sh
- run: bash scripts/tests/runtime-secret-test.sh
- run: bash scripts/tests/validate-release-config-test.sh
- run: bash scripts/tests/nginx-forwarded-proto-test.sh
- run: bash scripts/tests/dev-web-host-test.sh
- run: bash scripts/tests/workflow-security-test.sh

View file

@ -25,6 +25,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@v4
@ -52,6 +54,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Java
uses: actions/setup-java@v4
@ -74,6 +78,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Detect docs changes
id: changed

87
.github/workflows/security.yml vendored Normal file
View file

@ -0,0 +1,87 @@
name: Security
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
push:
branches:
- main
schedule:
- cron: '23 3 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
dependency-review:
name: Dependency Review
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Review dependency changes
uses: actions/dependency-review-action@v4
codeql:
name: CodeQL (${{ matrix.language }})
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: java-kotlin
build-mode: manual
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Java
if: matrix.language == 'java-kotlin'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Ensure Maven wrapper is executable
if: matrix.language == 'java-kotlin'
run: chmod +x server/mvnw
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Build Java for CodeQL
if: matrix.language == 'java-kotlin'
run: cd server && ./mvnw -q -DskipTests package
- name: Analyze
uses: github/codeql-action/analyze@v3
with:
category: /language:${{ matrix.language }}

View file

@ -6,6 +6,7 @@ DEV_WEB_PID := $(DEV_DIR)/web.pid
DEV_SERVER_LOG := $(DEV_DIR)/server.log
DEV_WEB_LOG := $(DEV_DIR)/web.log
DEV_WEB_URL := http://localhost:3000
DEV_WEB_HOST ?= 127.0.0.1
DEV_API_URL := http://localhost:8080
DEV_SCANNER_URL := http://localhost:8000
STAGING_API_URL := http://localhost:8080
@ -48,7 +49,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
else \
echo "Starting frontend..."; \
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \
fi
@echo "Waiting for backend on $(DEV_API_URL) ..."
@backend_ready=0; \
@ -126,7 +127,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
@echo " Frontend: $(DEV_WEB_LOG)"
dev-server: ## 启动后端开发服务器
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)'
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)'
dev-server-restart: ## 重启后端开发服务器
@mkdir -p $(DEV_DIR)
@ -237,7 +238,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖
cd web && CI=true pnpm install --frozen-lockfile
dev-web: ## 启动前端开发服务器
cd web && pnpm run dev
cd web && pnpm exec vite --host $(DEV_WEB_HOST)
build-frontend: web-deps ## 构建前端
cd web && pnpm run build

View file

@ -436,6 +436,12 @@ namespace `my-space` plus skill slug `my-skill`.
📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)**
### [Hermes Agent](https://github.com/NousResearch/hermes-agent)
[Hermes Agent](https://github.com/NousResearch/hermes-agent) uses the standard `SKILL.md` format and recursively discovers skills under `$HERMES_HOME/skills/`. Use SkillHub CLI's explicit `--dir` option to install a complete SkillHub package into Hermes without a registry adapter, then verify it with `hermes skills list`.
📖 **[Complete Hermes Agent Integration Guide →](./docs/hermes-integration-en.md)**
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
[AstronClaw](https://agent.xfyun.cn/astron-claw) is a cloud AI assistant built on OpenClaw's core capabilities, providing 24/7 online service through enterprise platforms like WeChat Work, DingTalk, and Feishu. It features a built-in skill system with over 130 official skills. You can connect it to a self-hosted SkillHub registry to enable one-click skill installation, search repository, dialogue-based automatic installation, and even custom skills management within your organization.

View file

@ -370,6 +370,12 @@ namespace `my-space` 和 skill slug `my-skill`。
📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)**
### [Hermes Agent](https://github.com/NousResearch/hermes-agent)
[Hermes Agent](https://github.com/NousResearch/hermes-agent) 使用标准 `SKILL.md` 格式,并会递归发现 `$HERMES_HOME/skills/` 中的技能。通过 SkillHub CLI 的 `--dir` 参数即可把完整技能包安装到 Hermes无需新增 registry 适配器;安装后可使用 `hermes skills list` 验证。
📖 **[完整 Hermes Agent 集成指南 →](./docs/hermes-integration.md)**
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
[AstronClaw](https://agent.xfyun.cn/astron-claw) 是基于 OpenClaw 核心能力打造的云端 AI 助手,提供全天候在线服务,随时随地通过企业微信、钉钉、飞书等渠道提供服务。它内置了丰富的技能系统,您可以将其连接到自托管的 SkillHub 注册中心,支持技能市场一键安装、仓库搜索、对话自动安装,甚至管理和分发组织内部的自定义私有技能。

View file

@ -112,6 +112,9 @@ Logout only removes the token for the specified registry, preserving registry co
# Keyword search
skillhub search pdf
# Search with a one-off token
skillhub search pdf --token sk_xxx
# List all skills (empty query)
skillhub search "" --limit 50
@ -157,7 +160,7 @@ The CLI determines the installation location using the following logic:
1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`.
2. If `--scope user|project` is specified: Limit detection to the chosen scope.
- With `--agent <profile>`: Install to that profile's user or project skills directory directly.
- Without `--agent`: Detect existing skills directories within the chosen scope only.
- Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`<home>/.agents/skills/`) is always also offered and can be selected alone or together with detected targets.
- No detected directory in the chosen scope → Fallback to `<home>/.agents/skills/` for `--scope user` or `<cwd>/.agents/skills/` for `--scope project`.
3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged).
4. If none of the above is specified:
@ -188,7 +191,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
### File Structure After Installation
@ -333,7 +336,7 @@ Update mechanism:
| `skillhub login --token <token> [--registry <url>] [--json]` | Save token and registry configuration |
| `skillhub logout [--registry <url>] [--json]` | Remove token for specified registry |
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
| `skillhub search <query> [--registry <url>] [--limit <n>] [--json]` | Search published skills |
| `skillhub search <query> [--registry <url>] [--token <token>] [--limit <n>] [--json]` | Search published skills |
| `skillhub install <slug> [--scope <user\|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
| `skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]` | List installed skills |
| `skillhub remove <slug> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--registry <url>] [--token <token>] [--json]` | Remove a skill |

View file

@ -1,6 +1,6 @@
{
"name": "@astron-team/skillhub",
"version": "0.1.7",
"version": "0.1.9",
"description": "Manage and install skills for AI coding agents",
"keywords": [
"skillhub",

View file

@ -1,7 +1,7 @@
import { homedir } from 'node:os'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { pathExists } from '../platform/paths'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import type { AgentCandidate } from './types'
import { allProfiles, profileMap } from './detector'
@ -66,7 +66,19 @@ async function resolveScopedTargets(
} else {
candidates = await generateScopedCandidates(scope, options.cwd, scopedHome)
}
candidates = dedupeByRoot(candidates)
candidates = await dedupeByRoot(candidates)
if (scope === 'user' && agentList.length === 0 && options.interactive && !options.json) {
candidates = await dedupeByRoot([
...candidates,
{
agent: 'generic',
rootDir: `${scopedHome}/.agents/skills`,
scope: 'user',
source: 'fallback'
}
])
}
if (candidates.length === 0) {
const fallbackRoot = scope === 'user'
@ -149,17 +161,23 @@ async function resolveExplicitAgents(
return results
}
function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] {
async function dedupeByRoot(candidates: AgentCandidate[]): Promise<AgentCandidate[]> {
const seen = new Set<string>()
return candidates.filter(c => {
if (seen.has(c.rootDir)) return false
seen.add(c.rootDir)
return true
})
const deduped: AgentCandidate[] = []
for (const candidate of candidates) {
const canonicalRootDir = await canonicalizeExistingPath(candidate.rootDir)
if (seen.has(canonicalRootDir)) continue
seen.add(canonicalRootDir)
deduped.push(candidate)
}
return deduped
}
async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise<AgentCandidate[]> {
const prompts = await import('prompts')
let highlightedIndex = 0
const { selected } = await prompts.default({
type: 'multiselect',
name: 'selected',
@ -167,7 +185,13 @@ async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise
choices: candidates.map(c => ({
title: `${c.agent} (${c.rootDir})`,
value: c
}))
})),
onRender: function (this: { cursor?: number }) {
highlightedIndex = this.cursor ?? highlightedIndex
},
format: (selectedTargets: AgentCandidate[]) => (
selectedTargets.length > 0 ? selectedTargets : [candidates[highlightedIndex] ?? candidates[0]!]
)
})
if (!selected || selected.length === 0) {
throw new CliError('installation cancelled', EXIT.usage)

View file

@ -52,6 +52,11 @@ export interface DryRunResponse {
resolvedVersion: string | null
}
interface ErrorEnvelope {
msg?: unknown
requestId?: unknown
}
export class SkillHubClient {
constructor(
readonly registry: string,
@ -88,9 +93,12 @@ export class SkillHubClient {
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
if (response.status === 401 || response.status === 403) {
if (response.status === 401) {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 403) {
throw await this.createAccessDeniedError(response)
}
if (response.status === 404) {
throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry })
}
@ -155,7 +163,7 @@ export class SkillHubClient {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 403) {
throw new CliError('access denied — token may lack required scope', EXIT.auth, { registry: this.registry, next: 'regenerate token with required scopes or run `skillhub login`' })
throw await this.createAccessDeniedError(response)
}
if (response.status === 404) {
throw new CliError('resource not found', EXIT.generic, { registry: this.registry })
@ -172,6 +180,26 @@ export class SkillHubClient {
return body.data as T
}
private async createAccessDeniedError(response: Response): Promise<CliError> {
const error = await this.readErrorEnvelope(response)
return new CliError(error.message ?? 'access denied', EXIT.auth, {
registry: this.registry,
...(error.requestId ? { requestId: error.requestId } : {})
})
}
private async readErrorEnvelope(response: Response): Promise<{ message?: string; requestId?: string }> {
try {
const body = await response.json() as ErrorEnvelope
return {
...(typeof body.msg === 'string' && body.msg.trim() ? { message: body.msg } : {}),
...(typeof body.requestId === 'string' && body.requestId.trim() ? { requestId: body.requestId } : {})
}
} catch {
return {}
}
}
private headers(): HeadersInit {
return this.token ? { Authorization: `Bearer ${this.token}` } : {}
}

View file

@ -28,8 +28,8 @@ export const commands = {
},
search: {
summary: 'Search published skills',
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--json]',
examples: ['skillhub search', 'skillhub search pdf']
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--token <token>] [--json]',
examples: ['skillhub search', 'skillhub search pdf', 'skillhub search pdf --token sk_xxx']
},
install: {
summary: 'Install a skill locally',

View file

@ -5,6 +5,7 @@ import { installSkill } from '../services/install-service'
import { resolveInstallTargets } from '../agents/resolver'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { parseSkillName } from '../shared/skill-name-parser'
export interface InstallCommandOptions {
namespace?: string | undefined
@ -74,7 +75,7 @@ async function defaultPromptScope(): Promise<'user' | 'project'> {
}
export async function installCommand(
slug: string,
skillNameArg: string,
options: InstallCommandOptions,
deps: InstallCommandDeps = {}
): Promise<string> {
@ -92,7 +93,10 @@ export async function installCommand(
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
const namespace = options.namespace ?? 'global'
const parsed = parseSkillName(skillNameArg)
const namespace = options.namespace ?? parsed.namespace
const slug = parsed.slug
const resolveTargets = deps.resolveInstallTargets ?? resolveInstallTargets
const targets = await resolveTargets({

View file

@ -5,6 +5,7 @@ import { resolveRegistry, resolveToken } from '../services/registry-service'
import { removeLocalSkill } from '../services/remove-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { parseSkillName } from '../shared/skill-name-parser'
export interface RemoveCommandOptions {
agent?: string[] | undefined
@ -17,7 +18,7 @@ export interface RemoveCommandOptions {
json?: boolean | undefined
}
export async function removeCommand(slug: string, options: RemoveCommandOptions): Promise<string> {
export async function removeCommand(skillNameArg: string, options: RemoveCommandOptions): Promise<string> {
if (options.all && options.agent?.length) {
throw new CliError('--all cannot be used with --agent', EXIT.usage)
}
@ -29,9 +30,12 @@ export async function removeCommand(slug: string, options: RemoveCommandOptions)
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const parsed = parseSkillName(skillNameArg)
const namespace = options.namespace ?? parsed.namespace
const slug = parsed.slug
if (options.remote) {
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
const namespace = options.namespace ?? 'global'
if (!options.hard && process.stdout.isTTY) {
const prompts = await import('prompts')

View file

@ -1,3 +1,3 @@
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
export const PKG_NAME = "@astron-team/skillhub"
export const PKG_VERSION = "0.1.7"
export const PKG_VERSION = "0.1.9"

View file

@ -223,9 +223,10 @@ cli
cli
.command('search [query]', 'Search published skills')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--limit <n>', 'Max results', { default: 20 })
.option('--json', 'Output JSON')
.action((query: string | undefined, options: { registry?: string; limit?: number; json?: boolean }) => {
.action((query: string | undefined, options: { registry?: string; token?: string; limit?: number; json?: boolean }) => {
return runCommand(() => searchCommand(query ?? '', options), Boolean(options.json))
})

View file

@ -1,6 +1,14 @@
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { zipSync, unzipSync } from 'fflate'
import { MAX_PACKAGE_BYTES } from './download'
const MAX_ZIP_ENTRIES = 500
const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024
const EOCD_SIGNATURE = 0x06054b50
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50
const ZIP64_MARKER_16 = 0xffff
const ZIP64_MARKER_32 = 0xffffffff
/**
* Extract a zip archive buffer into the target directory.
@ -8,9 +16,15 @@ import { zipSync, unzipSync } from 'fflate'
*/
export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise<void> {
await mkdir(targetDir, { recursive: true })
const files = unzipSync(new Uint8Array(buffer))
for (const [name, data] of Object.entries(files)) {
const filePath = safeJoin(targetDir, name)
const archive = new Uint8Array(buffer)
validateZipCentralDirectory(archive)
const files = unzipSync(archive)
const entries = Object.entries(files).map(([name, data]) => ({
name,
data,
filePath: safeJoin(targetDir, name),
}))
for (const { name, data, filePath } of entries) {
if (name.endsWith('/')) {
await mkdir(filePath, { recursive: true })
continue
@ -20,6 +34,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis
}
}
function validateZipCentralDirectory(archive: Uint8Array): void {
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
const eocdOffset = findEndOfCentralDirectory(view)
if (eocdOffset < 0) {
throw new Error('invalid zip central directory')
}
const diskNumber = view.getUint16(eocdOffset + 4, true)
const centralDirectoryDisk = view.getUint16(eocdOffset + 6, true)
const entriesOnDisk = view.getUint16(eocdOffset + 8, true)
const totalEntries = view.getUint16(eocdOffset + 10, true)
const centralDirectorySize = view.getUint32(eocdOffset + 12, true)
const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true)
if (
entriesOnDisk === ZIP64_MARKER_16 ||
totalEntries === ZIP64_MARKER_16 ||
centralDirectorySize === ZIP64_MARKER_32 ||
centralDirectoryOffset === ZIP64_MARKER_32
) {
throw new Error('zip64 archives are not supported')
}
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) {
throw new Error('multi-disk zip archives are not supported')
}
if (totalEntries > MAX_ZIP_ENTRIES) {
throw new Error('zip entry count exceeds limit')
}
if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) {
throw new Error('invalid zip central directory')
}
let offset = centralDirectoryOffset
let totalUncompressedSize = 0
const decoder = new TextDecoder()
for (let i = 0; i < totalEntries; i++) {
if (offset + 46 > archive.byteLength || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) {
throw new Error('invalid zip central directory')
}
const uncompressedSize = view.getUint32(offset + 24, true)
const nameLength = view.getUint16(offset + 28, true)
const extraLength = view.getUint16(offset + 30, true)
const commentLength = view.getUint16(offset + 32, true)
const nameStart = offset + 46
const nameEnd = nameStart + nameLength
const nextOffset = nameEnd + extraLength + commentLength
if (nameEnd > archive.byteLength || nextOffset > archive.byteLength) {
throw new Error('invalid zip central directory')
}
const entryName = decoder.decode(archive.subarray(nameStart, nameEnd))
if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) {
throw new Error('zip entry size exceeds limit')
}
totalUncompressedSize += uncompressedSize
if (totalUncompressedSize > MAX_PACKAGE_BYTES) {
throw new Error('zip total uncompressed size exceeds limit')
}
offset = nextOffset
}
}
function findEndOfCentralDirectory(view: DataView): number {
const minOffset = Math.max(0, view.byteLength - 0xffff - 22)
for (let offset = view.byteLength - 22; offset >= minOffset; offset--) {
if (view.getUint32(offset, true) === EOCD_SIGNATURE) {
return offset
}
}
return -1
}
/**
* Create a zip archive from a directory.
* Returns the archive as a Blob.

View file

@ -0,0 +1,56 @@
import { EXIT } from '../shared/constants'
import { CliError } from '../shared/errors'
export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024
export async function readBoundedResponseBody(response: Response, maxBytes = MAX_PACKAGE_BYTES): Promise<ArrayBuffer> {
const contentLength = response.headers.get('content-length')
if (contentLength !== null) {
const declaredLength = Number(contentLength)
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new CliError('download exceeds maximum package size', EXIT.network, {
contentLength: declaredLength,
maxBytes
})
}
}
if (!response.body) {
const buffer = await response.arrayBuffer()
if (buffer.byteLength > maxBytes) {
throw new CliError('download exceeds maximum package size', EXIT.network, {
receivedBytes: buffer.byteLength,
maxBytes
})
}
return buffer
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let receivedBytes = 0
for (;;) {
const { done, value } = await reader.read()
if (done) {
break
}
receivedBytes += value.byteLength
if (receivedBytes > maxBytes) {
await reader.cancel()
throw new CliError('download exceeds maximum package size', EXIT.network, {
receivedBytes,
maxBytes
})
}
chunks.push(value)
}
const result = new Uint8Array(receivedBytes)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.byteLength
}
return result.buffer
}

View file

@ -24,6 +24,15 @@ export async function pathExists(path: string): Promise<boolean> {
}
}
export async function canonicalizeExistingPath(path: string): Promise<string> {
const { realpath } = await import('node:fs/promises')
try {
return await realpath(path)
} catch {
return path
}
}
export async function applyCredentialPermissions(path: string): Promise<void> {
if (process.platform === 'win32') return
const { chmod } = await import('node:fs/promises')

View file

@ -1,11 +1,12 @@
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { SkillHubClient } from '../clients/skillhub-client'
import { InventoryStore } from '../stores/inventory-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { extractZip } from '../platform/archive'
import { pathExists } from '../platform/paths'
import { readBoundedResponseBody } from '../platform/download'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import type { AgentCandidate } from '../agents/types'
export interface InstallOptions {
@ -19,54 +20,105 @@ export interface InstallOptions {
home?: string | undefined
}
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
const client = new SkillHubClient(options.registry, options.token)
const resolved = await client.resolve(options.namespace, options.slug, options.version)
const response = await client.download(options.namespace, options.slug, resolved.version)
const buffer = await response.arrayBuffer()
async function preflightInstallTargets(
targets: AgentCandidate[],
slug: string,
force: boolean
): Promise<Array<{ target: AgentCandidate; skillDir: string }>> {
const seenSkillDirs = new Set<string>()
const preparedTargets: Array<{ target: AgentCandidate; skillDir: string }> = []
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)
for (const target of targets) {
const canonicalRootDir = await canonicalizeExistingPath(target.rootDir)
const canonicalSkillDir = join(canonicalRootDir, slug)
if (seenSkillDirs.has(canonicalSkillDir)) {
throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, {
path: canonicalSkillDir,
next: 'select only one target for this directory'
})
}
seenSkillDirs.add(canonicalSkillDir)
for (const target of options.targets) {
const skillDir = join(target.rootDir, options.slug)
if (await pathExists(skillDir) && !options.force) {
const skillDir = join(target.rootDir, slug)
if (await pathExists(skillDir) && !force) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
})
}
preparedTargets.push({ target, skillDir })
}
if (await pathExists(skillDir) && options.force) {
await store.removeTargetsByInstallDir(skillDir)
await rm(skillDir, { recursive: true, force: true })
return preparedTargets
}
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force)
const client = new SkillHubClient(options.registry, options.token)
const resolved = await client.resolve(options.namespace, options.slug, options.version)
const response = await client.download(options.namespace, options.slug, resolved.version)
const buffer = await readBoundedResponseBody(response)
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)
for (const { target, skillDir } of preparedTargets) {
await mkdir(target.rootDir, { recursive: true })
const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`))
let movedIntoPlace = false
try {
await extractZip(buffer, tempDir)
const installedAt = new Date().toISOString()
const metaDir = join(tempDir, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
registry: options.registry,
namespace: options.namespace,
slug: options.slug,
version: resolved.version,
agent: target.agent,
installedAt
}, null, 2))
if (await pathExists(skillDir) && !options.force) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
})
}
if (await pathExists(skillDir) && options.force) {
await store.removeTargetsByInstallDir(skillDir)
await rm(skillDir, { recursive: true, force: true })
}
try {
await rename(tempDir, skillDir)
} catch (error) {
if (!options.force && await pathExists(skillDir)) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
})
}
throw error
}
movedIntoPlace = true
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
agent: target.agent,
rootDir: target.rootDir,
installDir: skillDir,
installedAt
})
} finally {
if (!movedIntoPlace) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
}
// Create skill directory and extract into a clean skill-specific directory.
await mkdir(skillDir, { recursive: true })
await extractZip(buffer, skillDir)
// Write .skillhub/metadata.json
const metaDir = join(skillDir, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
registry: options.registry,
namespace: options.namespace,
slug: options.slug,
version: resolved.version,
agent: target.agent,
installedAt: new Date().toISOString()
}, null, 2))
// Update inventory
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
agent: target.agent,
rootDir: target.rootDir,
installDir: skillDir,
installedAt: new Date().toISOString()
})
installed.push({ agent: target.agent, dir: skillDir })
}

View file

@ -30,6 +30,9 @@ export function renderError(error: unknown, json: boolean): string {
if (typeof cliError.details.path === 'string') {
lines.push(`Context: path ${cliError.details.path}`)
}
if (typeof cliError.details.requestId === 'string') {
lines.push(`Request ID: ${cliError.details.requestId}`)
}
if (typeof cliError.details.next === 'string') {
lines.push(`Next: ${cliError.details.next}`)
}

View file

@ -0,0 +1,27 @@
export interface ParsedSkillName {
namespace: string
slug: string
}
export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName {
const separatorIndex = skillName.indexOf('--')
if (separatorIndex <= 0) {
return {
namespace: defaultNamespace,
slug: separatorIndex === 0 ? skillName.slice(2) : skillName
}
}
if (separatorIndex === skillName.length - 2) {
return {
namespace: defaultNamespace,
slug: skillName.slice(0, -2)
}
}
return {
namespace: skillName.slice(0, separatorIndex),
slug: skillName.slice(separatorIndex + 2)
}
}

View file

@ -22,7 +22,7 @@ export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
/**
* Controls how a specific endpoint behaves when a failure is injected:
* 'auth' => 401 { code: 401, message: 'unauthorized' }
* 'forbidden' => 403 { code: 403, message: 'forbidden' }
* 'forbidden' => 403 with a standard SkillHub error envelope
* 'not_found' => 404 { code: 404, message: 'not found' }
* 'server_error' => 500 { code: 500, message: 'internal error' }
* 'network' => handler throws, causing fetch() to reject with a TypeError
@ -34,7 +34,11 @@ function failureResponse(mode: FailureMode): Response {
case 'auth':
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
case 'forbidden':
return Response.json({ code: 403, message: 'forbidden' }, { status: 403 })
return Response.json({
code: 403,
msg: 'API token is missing required scope: skill:publish',
requestId: 'req-test-forbidden'
}, { status: 403 })
case 'not_found':
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
case 'server_error':

View file

@ -218,6 +218,65 @@ describe('install command — P1', () => {
expect(result.stderr.toLowerCase()).toMatch(/auth|unauthorized|401/)
})
test('bad token stops on 401 without retrying resolve anonymously', async () => {
const env = await createTempHome()
const installDir = join(env.cwd, 'skills-no-anon-retry')
await mkdir(installDir, { recursive: true })
const resolveAuthHeaders: Array<string | null> = []
let downloadRequests = 0
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
if (resolveMatch) {
const auth = req.headers.get('authorization')
resolveAuthHeaders.push(auth)
if (auth === 'Bearer sk_bad') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({
code: 0,
data: {
namespace: resolveMatch[1],
slug: resolveMatch[2],
version: '1.0.0',
versionId: 1,
fingerprint: 'abc123',
downloadUrl: `${url.protocol}//${url.host}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/download`
}
})
}
if (url.pathname.endsWith('/download')) {
downloadRequests += 1
return new Response(makeSkillZip() as BodyInit, {
status: 200,
headers: { 'Content-Type': 'application/zip' }
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registryUrl, '--token', 'sk_bad'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('Error: authentication failed')
expect(result.stderr).toContain(`Context: registry ${registryUrl}`)
expect(result.stderr).toContain('Next:')
expect(resolveAuthHeaders).toEqual(['Bearer sk_bad'])
expect(downloadRequests).toBe(0)
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P1 — --namespace override
// -------------------------------------------------------------------------

View file

@ -172,5 +172,6 @@ describe('publish --dry-run', () => {
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('scope')
expect(result.stderr).toContain('Request ID: req-test-forbidden')
})
})

View file

@ -10,6 +10,109 @@ afterEach(() => {
})
describe('search command', () => {
test('--token sends bearer auth and takes priority over SKILLHUB_TOKEN', async () => {
let capturedAuth = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedAuth = req.headers.get('authorization') ?? ''
return Response.json({
code: 0,
data: {
items: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }],
total: 1,
limit: 20
}
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const result = await runCli(
['search', 'pdf', '--registry', `http://localhost:${server.port}`, '--token', 'sk_ok'],
{ SKILLHUB_TOKEN: 'sk_bad' }
)
expect(result.exitCode).toBe(0)
expect(capturedAuth).toBe('Bearer sk_ok')
expect(result.stdout).toContain('global/pdf-parser')
} finally {
server.stop()
}
})
test('bad --token fails with auth output and does not retry anonymously', async () => {
const authHeaders: Array<string | null> = []
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
const auth = req.headers.get('authorization')
authHeaders.push(auth)
if (auth === 'Bearer sk_bad') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({
code: 0,
data: {
items: [{ namespace: 'global', slug: 'anonymous-only', latestVersion: '1.0.0', summary: 'anonymous fallback' }],
total: 1,
limit: 20
}
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('Error: authentication failed')
expect(result.stderr).toContain(`Context: registry ${registryUrl}`)
expect(result.stderr).toContain('Next:')
expect(authHeaders).toEqual(['Bearer sk_bad'])
} finally {
server.stop()
}
})
test('bad --token returns structured json auth error', async () => {
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad', '--json'])
expect(result.exitCode).toBe(2)
const parsed = JSON.parse(result.stderr)
expect(parsed.ok).toBe(false)
expect(parsed.message).toBe('authentication failed')
expect(parsed.exitCode).toBe(2)
expect(parsed.details.registry).toBe(registryUrl)
expect(typeof parsed.details.next).toBe('string')
expect(parsed.details.next).toContain('skillhub login')
} finally {
server.stop()
}
})
test('prints compact search table', async () => {
registry = await startFakeRegistry({
searchItems: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }]

View file

@ -0,0 +1,76 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import type { AgentCandidate } from '../../../src/agents/types'
interface PromptChoice {
value: AgentCandidate
}
interface PromptOptions {
choices?: PromptChoice[]
onRender?: (this: { cursor?: number }) => void
format?: (selectedTargets: AgentCandidate[]) => AgentCandidate[]
}
const defaultSelectedTargets = (options: PromptOptions): AgentCandidate[] => options.format?.([]) ?? []
let selectPromptTargets = defaultSelectedTargets
mock.module('prompts', () => ({
default: (options: PromptOptions) => {
options.onRender?.call({ cursor: 1 })
return { selected: selectPromptTargets(options) }
}
}))
afterEach(() => {
selectPromptTargets = defaultSelectedTargets
})
const { resolveInstallTargets } = await import('../../../src/agents/resolver')
describe('resolveInstallTargets interactive prompt', () => {
test('uses the highlighted target when Enter submits an empty multiselect', async () => {
const detected: AgentCandidate[] = [
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' },
{ agent: 'claude-code', rootDir: '/repo/.claude/skills', scope: 'project', source: 'detected' }
]
const highlighted = detected[1]!
const targets = await resolveInstallTargets({
cwd: '/repo',
agents: [],
json: false,
interactive: true,
detected
})
expect(targets).toEqual([highlighted])
})
test('allows selecting generic alongside detected user targets', async () => {
selectPromptTargets = options => options.choices?.map(choice => choice.value) ?? []
const codex: AgentCandidate = {
agent: 'codex',
rootDir: '/home/u/.codex/skills',
scope: 'user',
source: 'detected'
}
const generic: AgentCandidate = {
agent: 'generic',
rootDir: '/home/u/.agents/skills',
scope: 'user',
source: 'fallback'
}
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: [],
scope: 'user',
json: false,
interactive: true,
detected: [codex]
})
expect(targets).toEqual([codex, generic])
})
})

View file

@ -1,5 +1,9 @@
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { resolveInstallTargets } from '../../../src/agents/resolver'
import type { AgentCandidate } from '../../../src/agents/types'
describe('resolveInstallTargets', () => {
test('rejects dir and agent together before filesystem writes', async () => {
@ -216,4 +220,36 @@ describe('resolveInstallTargets', () => {
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
expect(targets[0]!.scope).toBe('user')
})
test('deduplicates a symlinked detected target and the generic user target', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-resolver-home-'))
const genericRoot = join(home, '.agents', 'skills')
const codexRoot = join(home, '.codex', 'skills')
const codex: AgentCandidate = {
agent: 'codex',
rootDir: codexRoot,
scope: 'user',
source: 'detected'
}
try {
await mkdir(genericRoot, { recursive: true })
await mkdir(join(home, '.codex'), { recursive: true })
await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir')
const targets = await resolveInstallTargets({
cwd: '/repo',
home,
agents: [],
scope: 'user',
json: false,
interactive: true,
detected: [codex]
})
expect(targets).toEqual([codex])
} finally {
await rm(home, { recursive: true, force: true })
}
})
})

View file

@ -37,12 +37,21 @@ describe('SkillHubClient', () => {
})
test('download() throws auth error on 403', async () => {
const fetchImpl = (async () => new Response(null, { status: 403 })) as unknown as typeof fetch
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'API token is missing required scope: skill:read',
requestId: 'req-download'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
const err = expect(client.download('ns', 'slug')).rejects
await err.toBeInstanceOf(CliError)
await err.toHaveProperty('message', 'authentication failed')
await err.toHaveProperty('exitCode', EXIT.auth)
await expect(client.download('ns', 'slug')).rejects.toMatchObject({
message: 'API token is missing required scope: skill:read',
exitCode: EXIT.auth,
details: {
registry: 'http://registry.test',
requestId: 'req-download'
}
})
})
test('download() throws not-found error on 404', async () => {
@ -159,6 +168,35 @@ describe('SkillHubClient', () => {
// --- handleJsonResponse() non-2xx classification ---
test('whoami() surfaces server reason and request ID on 403', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'API token cannot access endpoint: /api/cli/v1/whoami',
requestId: 'req-610'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'API token cannot access endpoint: /api/cli/v1/whoami',
exitCode: EXIT.auth,
details: {
registry: 'http://registry.test',
requestId: 'req-610'
}
})
})
test('whoami() falls back to generic access denied when 403 body is invalid', async () => {
const fetchImpl = (async () => new Response('not-json', { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'access denied',
exitCode: EXIT.auth,
details: { registry: 'http://registry.test' }
})
})
test('whoami() throws generic error on 500', async () => {
const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)

View file

@ -32,6 +32,17 @@ describe('archive helpers', () => {
await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path')
})
test('rejects unsafe zip before writing earlier safe entries', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-partial-'))
const unsafe = zipSync({
'SKILL.md': new TextEncoder().encode('# Partial'),
'../escape.txt': new TextEncoder().encode('bad'),
})
await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path')
await expect(readFile(join(target, 'SKILL.md'), 'utf-8')).rejects.toThrow()
})
test('rejects zip entries with absolute paths', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-abs-'))
const unsafe = zipSync({ '/etc/passwd': new TextEncoder().encode('bad') })
@ -56,4 +67,32 @@ describe('archive helpers', () => {
const entries = await readdir(target)
expect(entries).toEqual([])
})
test('rejects zip archives with more than 500 entries before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-many-'))
const manyEntries = Object.fromEntries(
Array.from({ length: 501 }, (_, index) => [`file-${index}.txt`, new Uint8Array(0)])
)
const archive = zipSync(manyEntries)
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry count exceeds limit')
})
test('rejects zip entries larger than 10 MiB before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-file-'))
const archive = zipSync({ 'large.bin': new Uint8Array(10 * 1024 * 1024 + 1) })
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry size exceeds limit')
})
test('rejects zip archives larger than 100 MiB after decompression before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-total-'))
const oneMiB = new Uint8Array(1024 * 1024)
const entries = Object.fromEntries(
Array.from({ length: 101 }, (_, index) => [`file-${index}.bin`, oneMiB])
)
const archive = zipSync(entries)
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip total uncompressed size exceeds limit')
})
})

View file

@ -1,4 +1,4 @@
import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
@ -21,6 +21,13 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)])
))
return installFetchWithDownloadResponse(new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
))
}
function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch {
const fakeFetch = async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
@ -37,8 +44,7 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
})
}
if (path.endsWith('/download')) {
const body = archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer
return new Response(body, { status: 200 })
return downloadResponse.clone()
}
return Response.json({ code: 404 }, { status: 404 })
}
@ -66,6 +72,62 @@ describe('installSkill', () => {
})).rejects.toThrow('skill already installed')
})
test('preflights all targets before writing when a later target is occupied', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-first-root-'))
const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-second-root-'))
const firstSkillDir = join(firstRoot, 'demo')
const secondSkillDir = join(secondRoot, 'demo')
await mkdir(secondSkillDir, { recursive: true })
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' },
{ agent: 'claude-code', rootDir: secondRoot, scope: 'project', source: 'explicit' }
],
force: false,
home
})).rejects.toThrow(`skill already installed at ${secondSkillDir}`)
expect(await exists(firstSkillDir)).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('rejects canonical target aliases before writing any installation', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const targetParent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-'))
const genericRoot = join(targetParent, 'generic')
const codexRoot = join(targetParent, 'codex')
const skillDir = join(genericRoot, 'demo')
try {
await mkdir(genericRoot, { recursive: true })
await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: codexRoot, scope: 'user', source: 'detected' },
{ agent: 'generic', rootDir: genericRoot, scope: 'user', source: 'fallback' }
],
force: false,
home
})).rejects.toThrow('multiple install targets resolve to')
expect(await exists(skillDir)).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
} finally {
await rm(home, { recursive: true, force: true })
await rm(targetParent, { recursive: true, force: true })
}
})
test('force replaces the old skill directory instead of overlaying files', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
@ -125,4 +187,60 @@ describe('installSkill', () => {
expect(inventory.items[0].targets).toHaveLength(1)
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('force keeps old installation and inventory when replacement extraction fails', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 }))
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir,
installDir: skillDir,
installedAt: '2026-04-20T00:00:00.000Z'
}]
}]
}, null, 2))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('invalid zip central directory')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' })
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('rejects downloads whose content-length exceeds the package limit', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
status: 200,
headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) }
}))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false
})).rejects.toThrow('download exceeds maximum package size')
})
})

View file

@ -16,11 +16,13 @@ describe('renderError', () => {
test('renders human error without stack trace', () => {
const error = new CliError('registry unreachable', 3, {
registry: 'https://registry.example.com',
requestId: 'req-610',
next: 'check network or pass --registry'
})
expect(renderError(error, false)).toBe([
'Error: registry unreachable',
'Context: registry https://registry.example.com',
'Request ID: req-610',
'Next: check network or pass --registry'
].join('\n'))
})

View file

@ -0,0 +1,90 @@
import { describe, test, expect } from 'bun:test'
import { parseSkillName } from '../../../src/shared/skill-name-parser'
describe('parseSkillName', () => {
describe('with namespace--slug format', () => {
test('should parse namespace and slug separated by double dash', () => {
const result = parseSkillName('astroclaw--api-gateway')
expect(result).toEqual({
namespace: 'astroclaw',
slug: 'api-gateway'
})
})
test('should handle namespace and slug with single dashes', () => {
const result = parseSkillName('my-org--my-skill-name')
expect(result).toEqual({
namespace: 'my-org',
slug: 'my-skill-name'
})
})
test('should handle multiple double dashes by using first as separator', () => {
const result = parseSkillName('namespace--slug--with--dashes')
expect(result).toEqual({
namespace: 'namespace',
slug: 'slug--with--dashes'
})
})
})
describe('with slug only format', () => {
test('should use default namespace when no separator present', () => {
const result = parseSkillName('api-gateway')
expect(result).toEqual({
namespace: 'global',
slug: 'api-gateway'
})
})
test('should use custom default namespace when provided', () => {
const result = parseSkillName('api-gateway', 'myorg')
expect(result).toEqual({
namespace: 'myorg',
slug: 'api-gateway'
})
})
test('should handle slug with single dashes', () => {
const result = parseSkillName('my-skill-name')
expect(result).toEqual({
namespace: 'global',
slug: 'my-skill-name'
})
})
})
describe('edge cases', () => {
test('should handle separator at start', () => {
const result = parseSkillName('--api-gateway')
expect(result).toEqual({
namespace: 'global',
slug: 'api-gateway'
})
})
test('should handle separator at end', () => {
const result = parseSkillName('astroclaw--')
expect(result).toEqual({
namespace: 'global',
slug: 'astroclaw'
})
})
test('should handle empty string', () => {
const result = parseSkillName('')
expect(result).toEqual({
namespace: 'global',
slug: ''
})
})
test('should handle just separator', () => {
const result = parseSkillName('--')
expect(result).toEqual({
namespace: 'global',
slug: ''
})
})
})
})

View file

@ -58,6 +58,7 @@ services:
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required}
SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3}
STORAGE_BASE_PATH: /var/lib/skillhub/storage
SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-}
@ -99,6 +100,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
skill-scanner:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s
@ -113,6 +116,7 @@ services:
- "${WEB_PORT:-80}:80"
environment:
SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080}
SKILLHUB_TRUST_FORWARDED_PROTO: ${SKILLHUB_TRUST_FORWARDED_PROTO:-false}
SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
SKILLHUB_WEB_AUTH_DIRECT_ENABLED: ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED:-false}

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 |
### 3. 选择部署方式
@ -192,6 +194,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | LLM 模型名称 | 否 |
### 存储配置

View file

@ -28,6 +28,12 @@ spec:
name: skillhub-secret
key: skill-scanner-llm-api-key
optional: true
- name: SKILL_SCANNER_LLM_BASE_URL
valueFrom:
secretKeyRef:
name: skillhub-secret
key: skill-scanner-llm-base-url
optional: true
- name: SKILL_SCANNER_LLM_MODEL
valueFrom:
secretKeyRef:

View file

@ -24,6 +24,7 @@ stringData:
# LLM 配置(可选,用于技能扫描)
skill-scanner-llm-api-key: ""
skill-scanner-llm-base-url: ""
skill-scanner-llm-model: ""
# S3 存储配置(可选,使用 S3/OSS 时配置)

View file

@ -42,11 +42,17 @@ services:
BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local
OAUTH2_GITHUB_CLIENT_ID: local-placeholder
OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: staging-download-secret-32-bytes
SKILLHUB_SECURITY_SCANNER_ENABLED: "true"
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
skill-scanner:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s

View file

@ -377,7 +377,9 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
- 存储:只存 SHA-256 哈希,明文只展示一次
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401不能回退为匿名访问
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
- 拒绝原因API Token 缺少作用域或不能访问某个接口时403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER则该用户的任何 Token只要包含 `skill:publish` scope都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
@ -484,23 +486,20 @@ Session 中存储以下字段:
"code": 0,
"msg": "获取成功",
"data": {
"userId": 42,
"userId": "usr_42",
"displayName": "zhangsan",
"email": "zhangsan@company.com",
"avatarUrl": "https://...",
"oauthProvider": "github",
"platformRoles": ["SKILL_ADMIN", "AUDITOR"],
"namespaces": [
{ "slug": "ai-team", "role": "ADMIN" },
{ "slug": "global", "role": "MEMBER" }
]
"oauthProvider": "local",
"canChangePassword": true,
"platformRoles": ["SKILL_ADMIN", "AUDITOR"]
},
"timestamp": "2026-03-12T06:00:00Z",
"requestId": "req-123"
}
```
前端权限判定基于 `platformRoles` + `namespaces[].role`后端通过 `role_permission` 表查询权限码。
前端平台级权限判定基于 `platformRoles`;是否展示修改密码入口和表单基于后端返回的 `canChangePassword`后端通过 `role_permission` 表查询权限码。
统一约束:
- `/api/v1/auth/me``/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。
@ -604,8 +603,8 @@ window.location.href = '/oauth2/authorization/github'
| `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY成员空间+ PRIVATEowner/admin` | `SearchVisibilityScope` + 搜索索引状态 |
| `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` |
| `GET /api/v1/skills/{ns}/{slug}/versions` | 仅 `PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 |
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅全局 namespace 下的 `PUBLIC` skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须`PUBLISHED` | visibility + namespace type + version status |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅全局 namespace 下的 `PUBLIC` skill 可匿名 | 同上 | visibility + namespace type + version status |
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须可安装 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 可匿名 | 同上 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 |
### 10.2 Authenticated API
@ -654,6 +653,6 @@ window.location.href = '/oauth2/authorization/github'
|------|---------|---------|
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
| `GET /api/v1/search` | 可选(匿名限 PUBLIC | `SearchVisibilityScope` |
| `GET /api/v1/resolve` | 可选(匿名仅限全局 namespace 下的 PUBLIC | visibility + namespace type + version status |
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限全局 namespace 下的 PUBLIC | visibility + namespace type + version status |
| `GET /api/v1/resolve` | 可选(匿名仅限 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装 | visibility + namespace status + `SkillInstallability` |
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装 | visibility + namespace status + `SkillInstallability` |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过namespace 由 canonical slug 解析) |

View file

@ -112,7 +112,7 @@
| skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 |
| namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 |
| SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 |
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可review 场景下还能审自己的提交 | 可 | 可 |
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;promotion 和 review 场景下还能审自己的提交 | 可 | 可 |
### 对象存储写入策略

View file

@ -319,7 +319,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN
|------|------|------|
| GET | `/api/v1/admin/users` | 用户列表 |
| GET | `/api/v1/admin/users/{id}` | 用户详情 |
| PUT | `/api/v1/admin/users/{id}/roles` | 修改用户角色USER_ADMIN 不可分配 SUPER_ADMIN |
| PUT | `/api/v1/admin/users/{id}/role` | 修改用户角色USER_ADMIN 不可分配 SUPER_ADMIN,也不可修改已有 SUPER_ADMIN 的角色状态 |
| POST | `/api/v1/admin/users/{id}/approve` | 审批待准入用户 |
| POST | `/api/v1/admin/users/{id}/disable` | 封禁用户 |
| POST | `/api/v1/admin/users/{id}/enable` | 解封用户 |

View file

@ -66,7 +66,7 @@ my-skill/
```
校验规则:
- 根目录必须包含 `SKILL.md`
- 根目录必须包含规范入口文件 `SKILL.md`;上传时服务端兼容 `skill.md``Skill.md` 等大小写变体,并在内部归一化为 `SKILL.md`
- 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg`
- 单文件大小限制1MB可配置
- 总包大小限制10MB可配置

View file

@ -194,6 +194,9 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- 推荐将敏感变量放入 CI/CD Secret 或主机上的受控 `.env.release`
- 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入
- 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入
- `SKILLHUB_TRUST_FORWARDED_PROTO` 默认保持 `false`。只有 Web 容器仅能经由可信
TLS 终止代理访问,且该代理会覆盖客户端传入的 `X-Forwarded-Proto` 时才设为
`true`;否则客户端可伪造协议并影响 OAuth 回调、重定向和安全 Cookie 判断
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`

View file

@ -57,6 +57,10 @@
- 数据库列统一为 `TIMESTAMPTZ`
- 读写都按 UTC 绝对时间处理
进度登记:
- `audit_log.created_at` 已通过 V42 迁移到 `TIMESTAMPTZ`,详见 `docs/16-backend-time-inventory.md` §3.1
### 3.2 业务输入时间
适用场景:

View file

@ -131,6 +131,8 @@
- `review_task.submitted_at / reviewed_at`
- `promotion_request.submitted_at / reviewed_at`
- `idempotency_record.created_at / expires_at`
- `V42__audit_log_created_at_timestamptz.sql`
- `audit_log.created_at`
### 3.2 当前状态

View file

@ -0,0 +1,269 @@
# 云存储链接内置 Skills 配置指南
本文说明如何通过仓库内 manifest 配置 SkillHub 内置 Skills以及应用启动时这些 Skills 如何从云存储同步到 `@global` 空间。
适用场景:
- 希望 SkillHub 新部署实例默认带有一批官方内置 Skills。
- 不希望把完整 Skill 包目录长期放在代码仓库和镜像中。
- 内置 Skill 包已经上传到官方可控的云存储域名。
## 1. 方案概览
内置 Skills 不再以本地目录包的形式直接随仓库维护。当前方案只在仓库中维护一个 manifest 文件,应用启动时根据 manifest 中的云存储 URL 下载 zip 包,并通过 SkillHub 现有发布链路发布到 `@global`
流程:
```text
维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用 ready -> 后台读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见
```
核心文件:
```text
server/skillhub-app/src/main/resources/builtin-skills/manifest.json
```
首版 manifest 只需要维护三个字段:
- `slug`Skill 在 `@global` 下的 slug。
- `version`:期望同步的 Skill 版本。
- `url`Skill zip 包的云存储 HTTPS 链接。
## 2. Manifest 配置
manifest 文件格式如下:
```json
{
"skills": [
{
"slug": "skillhub-hello",
"version": "1.0.0",
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
}
]
}
```
可以配置多个 Skills也可以为同一个 `slug` 配置多个版本:
```json
{
"skills": [
{
"slug": "skillhub-hello",
"version": "1.0.0",
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
},
{
"slug": "skillhub-hello",
"version": "1.1.0",
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.1.0.zip"
},
{
"slug": "skillhub-guide",
"version": "1.0.0",
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-guide-1.0.0.zip"
}
]
}
```
配置要求:
- `skills` 必须是数组。
- 每一项必须同时填写 `slug``version``url`
- `slug` 必须符合 SkillHub slug 规则。
- 同一个 `slug + version` 重复出现时,只处理第一条,后续重复项会被跳过。
- manifest 最多处理前 100 条 entries。
- 同一个 `slug` 的多个版本建议按从旧到新的顺序排列;运行时按 manifest 文件顺序处理,不做自动版本排序。
## 3. Skill 包要求
manifest 中的 `url` 必须指向 zip 包。zip 包需要满足 SkillHub Skill 包协议:
- zip 可以在根目录直接包含 `SKILL.md`,也可以包含一个单独的顶层 Skill 目录,并在该目录下包含 `SKILL.md`
- `SKILL.md` frontmatter 中必须包含合法的 `name``description``version` 等元数据。
- `SKILL.md` 中的 `name` 经过 slug 归一化后,必须等于 manifest 中的 `slug`
- `SKILL.md` 中的 `version` 必须等于 manifest 中的 `version`
- 包内容仍会经过 SkillHub 现有发布校验,包括文件数量、文件大小、扩展名、文件类型等规则。
示例:
```text
skillhub-hello-1.0.0.zip
├── SKILL.md
├── README.md
└── scripts/
└── check.js
```
同样支持标准单目录 Skill 包:
```text
skillhub-hello-1.0.0.zip
└── skillhub-hello/
├── SKILL.md
└── README.md
```
如果 zip 中存在多个顶层目录,或在多个目录中同时出现 `SKILL.md`,同步器会跳过该项并记录错误,避免误选入口。
## 4. URL 安全限制
内置 Skill 同步由后端在启动时主动下载远程文件,因此 URL 有严格限制。
首版只允许:
- `https://` 协议。
- host 为 `bjcdn.openstorage.cn`
- host 为 `bjcdn.openstorage.cn` 的子域名,例如 `assets.bjcdn.openstorage.cn`
- 默认 HTTPS 端口,或显式 `:443`
以下 URL 会被跳过:
- `http://...`
- 非 `bjcdn.openstorage.cn` 及其子域名。
- 带 userinfo 的 URL例如 `https://user:pass@bjcdn.openstorage.cn/file.zip`
- 非 443 端口,例如 `https://bjcdn.openstorage.cn:8443/file.zip`
- `localhost`、IP 地址、IPv6 literal 等 host。
- 需要 HTTP redirect 才能拿到文件的链接。
如果某一项 URL 不符合规则SkillHub 会记录日志并跳过该项,不会阻塞应用启动。
## 5. 启动同步流程
应用 ready 后同步器会在后台执行一次,不阻塞应用 ready。
详细流程:
1. 检查 `skillhub.builtin-skills.enabled` 是否开启。
2. 读取 `classpath:builtin-skills/manifest.json`
3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。
4. 确保系统发布者 `builtin-skill-publisher` 存在,并且该账号带有系统账号标记。
5. 如果该用户 ID 已被非系统账号占用,直接跳过本次内置 Skill 同步,不授予 `@global` 权限。
6. 如果系统发布者还不是 `@global` 成员,则创建 `OWNER` 成员记录;已有成员记录不会自动改角色。
7. 按 manifest 顺序处理每一个 item。
8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。
9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。
10. 解包并校验 Skill 入口 `SKILL.md`
11. 校验 manifest 中的 `slug``version` 与包内元数据一致。
12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。
13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`
14. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`
同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。
## 6. 幂等与冲突处理
内置 Skill 同步支持重复启动和多次部署。
幂等键:
```text
@global/{slug} + version
```
行为说明:
| 场景 | 行为 |
|---|---|
| `@global/{slug}` 不存在 | 发布 manifest 中的 Skill |
| `@global/{slug}` 已存在owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 |
| 同版本已存在且已发布 | 下载前跳过 |
| 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 |
| `@global/{slug}` 已被其他 owner 创建或发布 | 下载前跳过并记录 warning |
这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill即使该 Skill 仍处于待审、未发布或已拒绝状态,也会跳过对应 manifest item。
同版本已存在时,同步器不会重新下载远端 zip也不会验证远端对象内容是否发生漂移。
如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。
## 7. 开关配置
内置 Skill 同步默认开启。
Spring 配置项:
```yaml
skillhub:
builtin-skills:
enabled: true
```
环境变量:
```dotenv
SKILLHUB_BUILTIN_SKILLS_ENABLED=true
```
如需禁用启动同步:
```dotenv
SKILLHUB_BUILTIN_SKILLS_ENABLED=false
```
禁用后,应用 ready 后不会读取 manifest也不会下载或发布任何内置 Skill。
## 8. 维护流程
新增一个内置 Skill 的推荐步骤:
1. 准备 Skill 包,并确认 zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`
2. 检查 `SKILL.md` 中的 `name``version`
3. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的官方云存储路径。
4. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。
5. 确保 manifest 中的 `slug` 等于 `SKILL.md name` 归一化后的 slug。
6. 确保 manifest 中的 `version` 等于 `SKILL.md version`
7. 本地或测试环境启动 SkillHub查看后端日志确认同步结果。
8. 在 Web UI 或 API 中确认 `@global/{slug}` 已公开可见。
更新一个已有内置 Skill 的推荐步骤:
1. 不要覆盖已经发布过的旧版本 zip 内容。
2. 在 `SKILL.md` 中提升 `version`
3. 重新打包并上传新的 zip 文件。
4. 在 manifest 中新增一条同 `slug`、新 `version` 的记录。
5. 保留旧版本记录,除非产品明确不再需要该旧版本在新实例中预置。
不推荐:
- 修改旧版本 zip 内容但保持同一个 `version`
- 把 URL 指向会发生内容变化的临时对象。
- 使用需要登录、签名跳转或重定向的下载链接。
## 9. 日志与排查
启动时可以通过后端日志观察同步结果。
常见日志含义:
| 日志含义 | 处理建议 |
|---|---|
| manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath |
| publisher account id already exists but is not a system account | `builtin-skill-publisher` 已被普通账号占用;需要人工处理账号冲突后再启用内置同步 |
| slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 |
| slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 |
| URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 |
| package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 |
| package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 |
| manifest version does not match package version | 检查 manifest `version``SKILL.md version` 是否一致 |
| slug already belongs to another user | 说明 `@global/{slug}` 已被非内置发布者创建或发布,内置同步不会覆盖 |
| published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 |
如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。
## 10. 验收检查
配置或新增内置 Skill 后,建议至少完成以下检查:
- manifest JSON 格式合法。
- 每个 item 都包含 `slug``version``url`
- URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。
- zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`
- `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。
- `SKILL.md version` 与 manifest `version` 一致。
- 启动日志没有该 item 的 warning 或 error。
- Web UI 中可以看到 `@global/{slug}`
- Skill 可被匿名或登录用户按公开 Skill 规则发现。

View file

@ -0,0 +1,278 @@
# Hermes Agent Integration Guide
This guide explains how to install skills from SkillHub into [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent), then discover, load, update, and remove those skills in Hermes.
“Hermes” in this guide means `NousResearch/hermes-agent`; it does not cover other projects with the same name.
## Validated scope
| Component | Validated version | Notes |
|-----------|-------------------|-------|
| SkillHub Server | `v0.2.13` | Public or self-hosted registry |
| SkillHub CLI | `0.1.8` | npm package `@astron-team/skillhub` |
| Hermes Agent | `0.18.2` | Upstream tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) |
Validation date: 2026-07-17.
Hermes 0.18.2 uses an [Agent Skills](https://agentskills.io/)-compatible `SKILL.md` format and recursively scans `$HERMES_HOME/skills/`. SkillHub CLI can extract a complete skill package into any explicit `--dir` target. The current integration therefore needs no format conversion, Hermes-specific CLI profile, or server adapter:
```text
SkillHub registry
-> skillhub install --dir <Hermes skills directory>
-> <Hermes skills directory>/<skill-slug>/SKILL.md
-> Hermes discovers and loads the skill on demand
```
> Hermes 0.18.2 has no native SkillHub registry source. This guide uses SkillHub CLI for search, download, and local installation, while Hermes handles discovery and execution.
## Prerequisites
1. Install and initialize Hermes Agent.
2. Install SkillHub CLI:
```bash
npm install -g @astron-team/skillhub
skillhub version
hermes version
```
3. Ensure the skill package has a valid root `SKILL.md` with at least `name` and `description` frontmatter.
The examples below use Bash/zsh. On Windows, use the same directory structure, replace the default Hermes home with `$HOME\.hermes`, and set variables using PowerShell syntax.
## Quick start
### 1. Configure the SkillHub registry
Set the public or self-hosted SkillHub URL:
```bash
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
```
You can skip login for public skills that allow anonymous downloads. For team namespaces, restricted skills, or private deployments, save an API token first:
```bash
skillhub login \
--registry "$SKILLHUB_REGISTRY" \
--token YOUR_API_TOKEN
skillhub whoami --registry "$SKILLHUB_REGISTRY"
```
Use placeholder tokens in examples. Never write a real token into `SKILL.md`, scripts, or version control.
### 2. Search for a skill
```bash
skillhub search "pdf" --registry "$SKILLHUB_REGISTRY"
```
Record the namespace, slug, and required version. The following examples use `my-team/my-skill`:
```bash
export SKILLHUB_NAMESPACE=my-team
export SKILLHUB_SKILL=my-skill
```
### 3. Install into the primary Hermes skills directory
Set the home of the active Hermes profile. The default profile normally uses `~/.hermes`; if you use a custom `HERMES_HOME` or a named profile, point it at the actual profile directory:
```bash
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE"
```
Install the skill:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI preserves `SKILL.md`, `references/`, `scripts/`, `templates/`, `assets/`, and other package files. It also writes `.skillhub/metadata.json` to record the installation source. The resulting layout looks like this:
```text
$HERMES_HOME/skills/
└── skillhub/
└── my-team/
└── my-skill/
├── SKILL.md
├── references/ # optional
├── scripts/ # optional
└── .skillhub/
└── metadata.json
```
Separating target directories by namespace reduces filesystem collisions between skills with the same slug. Hermes recursively scans these levels.
### 4. Verify and load the skill in Hermes
First, confirm that Hermes discovers the skill:
```bash
hermes skills list --source local --enabled-only
```
Then start Hermes and invoke the slash command normalized from the skill `name`:
```bash
hermes
```
```text
/my-skill
```
You can also ask Hermes in natural language to use the skill. Hermes lists the raw `SKILL.md` frontmatter `name`, but its slash command lowercases that name, replaces spaces and underscores with hyphens, removes other characters outside `a-z0-9-`, and collapses repeated hyphens. For example, `PDF_Tools` becomes `/pdf-tools`. The command may therefore differ from the SkillHub slug.
If a running session does not immediately show a new skill, run `/reload-skills` or restart the session.
## Update a skill
SkillHub CLI 0.1.8 overwrites a local skill by repeating the install command with `--force`. Omitting `--version` resolves the latest published version; you can also pin one explicitly:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
# Pinned version example
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--version 1.2.0 \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
Review the new version before overwriting because `--force` replaces the existing skill directory. Afterward, run:
```bash
skillhub list \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
hermes skills list --source local --enabled-only
```
> `skillhub update` updates SkillHub CLI itself; it does not update installed skills. Refresh an installed skill with `skillhub install ... --force`.
## Remove a skill
First, list every installation from the same registry and confirm that there are no other same-slug skills you need to keep:
```bash
skillhub list \
--registry "$SKILLHUB_REGISTRY"
```
Then remove the local installation:
```bash
skillhub remove "$SKILLHUB_SKILL" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI deletes both the skill directory and the local inventory record. Local `remove` in this version matches only registry and slug; it does not filter by namespace or directory. Every same-slug target from that registry, across all namespaces and installation directories, is removed. If the unfiltered `skillhub list` shows a match you need to keep, do not run the command; namespace- or directory-scoped removal requires a future CLI capability.
After removal, run `/reload-skills`, restart the Hermes session, or confirm that the skill is gone with:
```bash
hermes skills list --source local --enabled-only
```
## Optional: use a shared external skills directory
When several agents share `~/.agents/skills`, install SkillHub skills into that shared tree instead of the primary Hermes directory:
```bash
export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE"
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$SHARED_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
Merge the shared root into `$HERMES_HOME/config.yaml` without replacing existing `skills` settings:
```yaml
skills:
external_dirs:
- ~/.agents/skills
```
Hermes lists and loads external skills alongside local skills. Do not rely on local shadowing: Hermes 0.18.2 refuses ambiguous `skill_view` matches across the local skills directory and `external_dirs`. Rename or remove a colliding copy instead.
> `external_dirs` is not a read-only boundary. If the Hermes process can write to an external directory, Hermes skill-management tools can modify its files. Use filesystem permissions or an isolated Hermes profile when shared skills must remain read-only.
## Compatibility and security boundaries
- **Format compatibility is not complete runtime compatibility.** Hermes can read `SKILL.md` and supporting files, but agent-specific tools, MCP servers, commands, environment variables, and platform capabilities referenced by a skill still need individual verification.
- **Hermes treats this path as local.** A skill copied by SkillHub CLI does not run through the Hermes Skills Hub community-install scanner. Review the SkillHub security report and the skill contents before installation, and use Hermes terminal isolation where appropriate.
- **Keep multi-file packages intact.** Do not replace SkillHub CLI with the Hermes 0.18.2 direct-URL source for multi-file skills. That release guarantees a single `SKILL.md` for URL installs, whereas SkillHub CLI extracts the complete package.
- **Avoid name collisions.** Namespace-separated filesystem paths do not resolve slash-command collisions. Keep normalized command names unique within one Hermes profile. For example, `PDF Tools` and `pdf_tools` both become `/pdf-tools`.
- **Protect credentials.** A registry token is only for SkillHub access and does not belong in a skill package. Skills that need runtime secrets should use Hermes environment-variable and security settings.
## Troubleshooting
### The new skill is missing from the Hermes list
Check these items in order:
1. The current session uses the same `HERMES_HOME` used during installation.
2. The final path contains `<skill-directory>/SKILL.md`.
3. `SKILL.md` contains valid `name` and `description` fields.
4. `platforms` or other frontmatter does not exclude the current operating system.
5. The skill appears after `/reload-skills` or in a new session.
```bash
skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY"
hermes skills list --source local
```
### Installation reports `skill already installed`
Existing directories are not overwritten by default. Review the target version, then add `--force`:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
### The CLI reports `registry unreachable` or a download failure
- Confirm that `SKILLHUB_REGISTRY` is the SkillHub root URL.
- Run `skillhub search` against the same registry to distinguish registry reachability from a download failure.
- Check proxy, DNS, certificate, and self-hosted service status.
- Retry a transient network error only after confirming the service is healthy; do not bypass certificate failures by disabling TLS verification.
### The skill is listed but fails during execution
Check tool names, shell commands, script runtimes, packages, MCP servers, environment variables, and operating-system restrictions referenced by that skill. Those are skill-specific runtime compatibility concerns, not failures of `SKILL.md` discovery.
### Can `hermes skills install` consume a SkillHub coordinate directly?
Hermes 0.18.2 has no SkillHub registry source and cannot resolve a SkillHub namespace/slug directly. Use `skillhub install --dir ...` as shown in this guide. Native search, installation, updates, and security scanning inside Hermes would require a separately designed Hermes source adapter with its own protocol and acceptance scope.
## Regression checks after upgrades
After upgrading SkillHub CLI or Hermes, verify at least the following:
1. `skillhub install --dir` still creates `<slug>/SKILL.md` and preserves support files.
2. `hermes skills list --source local --enabled-only` discovers the skill.
3. `/skill-name` loads `SKILL.md` and exposes support-file paths; then read one referenced file with `skill_view(name, file_path)` or exercise the script/asset the skill actually uses.
4. `skillhub install --force` overwrites the skill while keeping a healthy inventory.
5. Hermes no longer discovers the skill after `skillhub remove`.
Upstream reference: [Hermes Skills System at v0.18.2](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md).

278
docs/hermes-integration.md Normal file
View file

@ -0,0 +1,278 @@
# Hermes Agent 集成指南
本文档说明如何把 SkillHub 中的技能安装到 [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent),并在 Hermes 中发现、加载、更新和移除这些技能。
本文中的 “Hermes” 特指 `NousResearch/hermes-agent`,不适用于其他同名项目。
## 已验证范围
| 组件 | 已验证版本 | 说明 |
|------|------------|------|
| SkillHub Server | `v0.2.13` | 公开或自托管 registry |
| SkillHub CLI | `0.1.8` | npm 包 `@astron-team/skillhub` |
| Hermes Agent | `0.18.2` | 上游 tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) |
验证日期2026-07-17。
Hermes 0.18.2 使用兼容 [Agent Skills](https://agentskills.io/) 的 `SKILL.md` 格式,并递归扫描 `$HERMES_HOME/skills/`。SkillHub CLI 可以通过 `--dir` 把完整技能包解压到指定目录。因此当前兼容链路不需要格式转换、Hermes 专用 CLI profile 或服务端适配:
```text
SkillHub registry
-> skillhub install --dir <Hermes 技能目录>
-> <Hermes 技能目录>/<skill-slug>/SKILL.md
-> Hermes 发现并按需加载
```
> Hermes 0.18.2 没有原生 SkillHub registry source。本指南使用 SkillHub CLI 负责搜索、下载和本地安装Hermes 负责发现和执行技能。
## 前置条件
1. 已安装并初始化 Hermes Agent。
2. 已安装 SkillHub CLI
```bash
npm install -g @astron-team/skillhub
skillhub version
hermes version
```
3. 技能包根目录包含有效的 `SKILL.md`,其中至少有 `name``description` frontmatter。
以下示例使用 Bash/zsh。Windows 用户可使用同一目录结构,将默认 Hermes 主目录替换为 `$HOME\.hermes`,并按 PowerShell 语法设置变量。
## 快速开始
### 1. 配置 SkillHub registry
设置公开或自托管 SkillHub 地址:
```bash
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
```
公开且允许匿名下载的技能可以跳过登录。访问团队命名空间、受限技能或私有部署时,先保存 API Token
```bash
skillhub login \
--registry "$SKILLHUB_REGISTRY" \
--token YOUR_API_TOKEN
skillhub whoami --registry "$SKILLHUB_REGISTRY"
```
请使用占位 Token 演示,不要把真实 Token 写入 `SKILL.md`、脚本或版本库。
### 2. 搜索技能
```bash
skillhub search "pdf" --registry "$SKILLHUB_REGISTRY"
```
记录结果中的 namespace、slug 和所需版本。下面以 `my-team/my-skill` 为例:
```bash
export SKILLHUB_NAMESPACE=my-team
export SKILLHUB_SKILL=my-skill
```
### 3. 安装到 Hermes 主技能目录
设置当前 Hermes profile 的主目录。默认 profile 通常是 `~/.hermes`;如果使用自定义 `HERMES_HOME` 或命名 profile请指向实际 profile 目录:
```bash
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE"
```
安装技能:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI 会保留技能包中的 `SKILL.md``references/``scripts/``templates/``assets/` 等文件,并额外写入 `.skillhub/metadata.json` 记录安装来源。目录结构类似:
```text
$HERMES_HOME/skills/
└── skillhub/
└── my-team/
└── my-skill/
├── SKILL.md
├── references/ # 可选
├── scripts/ # 可选
└── .skillhub/
└── metadata.json
```
按 namespace 分目录可以减少不同命名空间中同 slug 技能的文件路径冲突。Hermes 会递归扫描这些层级。
### 4. 在 Hermes 中验证和加载
先确认 Hermes 发现了技能:
```bash
hermes skills list --source local --enabled-only
```
然后启动 Hermes在会话中使用由技能 `name` 规范化得到的斜杠命令:
```bash
hermes
```
```text
/my-skill
```
也可以在自然语言请求中明确要求 Hermes 使用该技能。Hermes 列表显示 `SKILL.md` frontmatter 中的原始 `name`,斜杠命令会把它转为小写、把空格和下划线替换为连字符、移除其他非 `a-z0-9-` 字符,并合并重复连字符。例如 `PDF_Tools` 对应 `/pdf-tools`。该命令不一定与 SkillHub slug 相同。
已经运行的会话未立即显示新技能时,执行 `/reload-skills` 或重新启动会话。
## 更新技能
SkillHub CLI 0.1.8 使用同一安装命令加 `--force` 覆盖本地技能。省略 `--version` 会解析最新已发布版本;也可以显式固定版本:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
# 固定版本示例
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--version 1.2.0 \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
覆盖前请先审查新版本,因为 `--force` 会替换现有技能目录。更新后重新运行:
```bash
skillhub list \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
hermes skills list --source local --enabled-only
```
> `skillhub update` 更新的是 SkillHub CLI 自身,不会更新已安装技能。已安装技能使用 `skillhub install ... --force` 刷新。
## 移除技能
先列出同一 registry 中的全部安装,确认没有其他需要保留的同 slug 技能:
```bash
skillhub list \
--registry "$SKILLHUB_REGISTRY"
```
再移除本地安装:
```bash
skillhub remove "$SKILLHUB_SKILL" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI 会同时删除技能目录和本地 inventory 记录。当前版本的本地 `remove` 仅按 registry 和 slug 匹配,不按 namespace 或目录过滤;同一 registry 下所有 namespace、所有安装目录中的相同 slug 都会被移除。如果未过滤的 `skillhub list` 中存在需要保留的匹配项,请不要执行该命令;按 namespace 或目录精确移除需要后续 CLI 能力支持。
移除后,使用 `/reload-skills`、重启 Hermes 会话,或运行以下命令确认技能已消失:
```bash
hermes skills list --source local --enabled-only
```
## 可选:使用共享的 external skill 目录
如果多个 Agent 共用 `~/.agents/skills`,可以把 SkillHub 技能安装到共享目录,而不是 Hermes 主目录:
```bash
export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE"
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$SHARED_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
然后把共享根目录合并到 `$HERMES_HOME/config.yaml`,不要覆盖已有的 `skills` 配置:
```yaml
skills:
external_dirs:
- ~/.agents/skills
```
Hermes 会把 external skill 与本地技能一起列出和加载。不要依赖本地技能覆盖 external skillHermes 0.18.2 会拒绝加载本地技能目录与 `external_dirs` 之间存在歧义的 `skill_view` 匹配;请改名或移除其中一个冲突副本。
> `external_dirs` 不是只读边界。只要 Hermes 进程拥有写权限Hermes 的技能管理工具就可能修改其中的文件。共享目录需要只读保护时,请使用文件系统权限或隔离的 Hermes profile。
## 兼容性与安全边界
- **格式兼容不等于运行时完全兼容。** Hermes 能读取 `SKILL.md` 和配套文件,但技能引用的 Agent 专用工具、MCP server、命令、环境变量或平台能力仍需逐项验证。
- **Hermes 将此路径识别为 local skill。** 通过 SkillHub CLI 复制到本地的技能不会经过 Hermes Skills Hub 的 community 安装扫描。安装前应查看 SkillHub 安全报告并审查技能内容,必要时使用 Hermes 的终端隔离能力。
- **保留多文件包。** 不要把多文件 SkillHub 技能改成 Hermes 0.18.2 的直接 URL 安装;该版本的 URL source 只保证单个 `SKILL.md`,而 SkillHub CLI 会解压完整包。
- **避免名称冲突。** 文件路径按 namespace 隔离仍不能解决斜杠命令冲突;同一 Hermes profile 内应保持规范化后的命令名唯一。例如 `PDF Tools``pdf_tools` 都会变成 `/pdf-tools`
- **保护凭证。** Token 只用于 SkillHub registry 访问,不应写进技能包。需要运行时 secret 的技能应遵循 Hermes 的环境变量和安全设置方式。
## 常见问题
### Hermes 列表中没有新技能
依次检查:
1. 当前会话的 `HERMES_HOME` 是否与安装时一致。
2. 最终路径下是否存在 `<skill-directory>/SKILL.md`
3. `SKILL.md` 是否包含有效的 `name``description`
4. `platforms` 等 frontmatter 是否排除了当前操作系统。
5. 执行 `/reload-skills` 或启动新会话后是否出现。
```bash
skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY"
hermes skills list --source local
```
### 安装提示 `skill already installed`
已有目录默认不会被覆盖。先审查目标版本,再增加 `--force`
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
### 提示 `registry unreachable` 或下载失败
- 核对 `SKILLHUB_REGISTRY` 是否是 SkillHub 根地址。
- 先运行同一 registry 的 `skillhub search` 判断 registry 是否可达。
- 检查代理、DNS、证书和自托管服务状态。
- 短暂网络错误可以在确认服务正常后重试;不要通过关闭 TLS 校验绕过证书问题。
### 技能已列出但执行失败
检查技能引用的工具名称、shell 命令、脚本解释器、依赖包、MCP server、环境变量和操作系统限制。此类问题属于具体技能的运行时兼容性不代表 `SKILL.md` 发现链路失败。
### 能否直接运行 `hermes skills install` 安装 SkillHub 坐标?
Hermes 0.18.2 没有 SkillHub registry source不能直接解析 SkillHub 的 namespace/slug。请使用本指南中的 `skillhub install --dir ...`。如果未来需要 Hermes 内原生搜索、安装、更新和安全扫描,应单独设计 Hermes source adapter并重新定义协议和验收范围。
## 升级后的回归检查
升级 SkillHub CLI 或 Hermes 后,至少重新验证:
1. `skillhub install --dir` 仍生成 `<slug>/SKILL.md` 并保留配套文件。
2. `hermes skills list --source local --enabled-only` 能发现技能。
3. `/skill-name` 能加载 `SKILL.md` 并暴露配套文件路径;再通过 `skill_view(name, file_path)` 读取一个实际引用文件,或执行技能使用的脚本/资产验证其运行时路径。
4. `skillhub install --force` 能覆盖更新且 inventory 正常。
5. `skillhub remove` 后 Hermes 不再发现该技能。
上游参考:[Hermes Skills Systemv0.18.2](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md)。

View file

@ -61,6 +61,7 @@ Important environment variables:
Scanner-side optional environment variables:
- `SKILL_SCANNER_LLM_API_KEY`
- `SKILL_SCANNER_LLM_BASE_URL`
- `SKILL_SCANNER_LLM_MODEL`
If the LLM variables are absent, the scanner should still run with non-LLM analyzers.

View file

@ -7,6 +7,11 @@ export default defineConfig({
ignoreDeadLinks: [/^http:\/\/localhost/],
head: [],
vite: {
build: {
target: 'es2020',
},
},
// Define root locale for redirect
locales: {
@ -124,4 +129,4 @@ export default defineConfig({
},
},
},
})
})

View file

@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0
```
> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway.
> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway. Upgrading does not wipe the database, so already-registered skill packages will not be lost.
## Q: Why can't administrators (admin) and regular users create namespaces?
@ -136,11 +136,199 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
A: When using the OpenClaw CLI, you can specify the namespace using the `<namespace>--<skill-name>` format for operations like search or installation. If you encounter issues finding it on the web interface, you can also manage it by exporting the skill package and importing it into your target namespace.
## Q: What is the recommended deployment method? Can I pull the images and deploy manually?
A: We recommend the official one-line deployment script. Pulling images and deploying manually is not recommended (manual deployment is prone to initialization issues such as being redirected back to the login page after logging in):
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
The script performs a series of initialization steps. The generated runtime configuration is located at `/tmp/skillhub-runtime/` by default (containing `.env.release` and the docker-compose file).
## Q: After deployment, I enter the correct username and password but get redirected back to the login page?
A: This is most commonly seen with **manual deployment** (caused by API errors or incomplete initialization). Suggestions:
1. Switch to the one-line script above for deployment.
2. If necessary, clear and recreate the PostgreSQL data volume, then log in again.
3. If a reverse proxy is in front, verify that it forwards requests correctly.
## Q: How do I change the admin password? Why don't my config changes take effect?
A: Environment variables are injected when a container is created, so you must recreate the containers after changing them; `restart` alone does not re-inject environment variables.
1. Edit `/tmp/skillhub-runtime/.env.release` in the runtime directory (refer to [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example)).
2. Recreate the relevant containers:
```bash
docker compose \
--env-file /tmp/skillhub-runtime/.env.release \
-f /tmp/skillhub-runtime/compose.release.yml \
up -d --force-recreate
```
3. If the password was already persisted to the database and the change still doesn't take effect, you may need to clear the corresponding data and re-initialize.
## Q: Is an email verification code required to change / reset a password?
A: Yes. By default, passwords are changed or reset via an email verification code, so SMTP must be configured first. See [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md). Administrators can also reset it via `.env.release`.
## Q: Can a skill have a Chinese name?
A: Skill names are generally in English; Chinese names are not currently supported (using a Chinese skill name in OpenClaw will cause an error).
## Q: Can unreviewed skills be downloaded?
A: As long as you have permission to view it, it can generally be downloaded.
## Q: How do I hide or remove the GitHub / GitLab SSO login options on the login page?
A: Edit `application.yml` and comment out or delete the `github` and `gitlab` blocks under `spring.security.oauth2.client.registration`, along with their corresponding `provider` sections. Spring Boot then won't create these registrations at startup, and the login page won't show those entries.
## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use?
A: SkillHub has built-in security scanning. The scanner integration, task orchestration, audit persistence, and deployment integration are implemented by the iFLYTEK team; the underlying scanning service uses Cisco's [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner) (Apache License 2.0, copyright Cisco).
## Q: Which version of cisco-ai-skill-scanner does SkillHub use?
A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly without pinning a version, so the latest version on PyPI is pulled when the image is built. To pin a version, do so yourself when customizing the build.
## Q: How do I troubleshoot a `registry returned 400` error from `skillhub publish` (CLI)?
A: A 400 usually means backend validation failed. Common causes:
- `SKILL.md` is not in the package root directory;
- `SKILL.md` frontmatter is missing `name` / `description` or is malformed;
- name or version conflict (e.g. `error.skill.publish.nameConflict`, meaning a skill with the same name is already published in that namespace) — change `name` in `SKILL.md`, use another namespace, or have an admin handle the existing skill;
- the namespace does not exist, or you are not a member of it;
- the package contains suspected tokens/secrets that the CLI cannot confirm skipping;
- file type / size / path is not allowed.
You can inspect the server logs to locate the cause:
```bash
docker logs --tail=300 <skillhub-server container> 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest'
```
## Q: What directory structure does a skill package require?
A: The package root directory must contain a `SKILL.md` file, whose frontmatter must include fields such as `name` and `description`.
## Q: Publishing fails with "package validation failed / malformed input" — what do I do?
A: This error occurs while unzipping and reading file names, usually because the archive is not UTF-8 encoded (e.g. created with the built-in Windows compression tool) or contains Chinese/non-ASCII paths. Repackage using UTF-8 encoding and avoid Chinese / special-character paths.
## Q: How many files can a skill package contain? What if I hit the file-count limit?
A: The default limit is **100 files** (this is separate from the 100MB size limit). To raise it, change the `skillhub.publish.max-file-count` setting, or override it via an environment variable at deploy time:
```bash
SKILLHUB_PUBLISH_MAX_FILE_COUNT=500
```
Recreate the containers for the change to take effect; `restart` alone does not re-inject environment variables. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended.
## Q: Is there a server version requirement for using the CLI (publish / download, etc.)?
A: A SkillHub server image of **v0.2.7 or later** is required for CLI features.
## Q: Does SkillHub support MySQL?
A: Currently only PostgreSQL is supported; MySQL is not supported.
## Q: Can SkillHub be used to distribute Plugins?
A: Not supported for now.
## Q: How do I check the SkillHub version? How do I customize it (e.g. change the logo)?
A:
- Check the server image version:
```bash
docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}'
```
- Check the CLI version: `skillhub version`.
- For customization (e.g. changing the logo), it is recommended to fork the latest code, modify it, and build your own Docker image.
## Q: The page loads, but the login / register APIs return 502?
A: The page is served by the `web` container, while login, register and other APIs are proxied by `web` to `server` (default `SKILLHUB_API_UPSTREAM=http://server:8080`). When the page works but the API returns 502, check whether `server` started correctly first; a wrong upstream, DNS, or container-network problem can also produce a 502.
Troubleshooting order:
```bash
# 1. Check whether server is running
docker compose --env-file .env.release -f compose.release.yml ps
# 2. Look at the first error in the server startup log
docker compose --env-file .env.release -f compose.release.yml logs server | head -50
```
One common startup failure is:
```
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder
```
This means `server` still reads the placeholder from the template. Replace it in `.env.release` with your own random string (**at least 32 characters**) and recreate the containers:
```bash
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<your own random string, at least 32 characters>
```
Running `make validate-release-config` before startup validates `.env.release` and surfaces placeholders and missing values early.
## Q: Why doesn't my configuration change take effect?
A: Two common causes:
1. **Edited the wrong file**: `.env.release.example` is only a template; Compose reads the file passed via `--env-file`, i.e. `.env.release`. Run `cp .env.release.example .env.release` first, then edit `.env.release`.
2. **Restarted instead of recreated**: environment variables are injected when the container is created, and `restart` does not re-inject them. Recreate the containers after a config change:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate
```
## Q: What external dependencies does SkillHub require at runtime?
A: PostgreSQL and Redis are required. Object storage supports both `local` and S3, controlled by `SKILLHUB_STORAGE_PROVIDER`. `.env.release.example` explicitly selects `local`, but if the variable is completely unset when using `compose.release.yml`, the Compose fallback is `s3`. Set it explicitly; S3 is recommended for production (configured via `SKILLHUB_STORAGE_S3_*`). Only PostgreSQL is supported as the database — MySQL is not.
The release Compose file already bundles PostgreSQL and Redis, bound to `127.0.0.1` by default.
## Q: How does an account created through OAuth (GitHub / GitLab, etc.) get admin rights?
A: The first OAuth login creates a regular user. An existing `SUPER_ADMIN` (for example the bootstrap admin created during initialization) has to promote it from the admin console.
A `USER_ADMIN` can manage user status and assign platform roles other than `SUPER_ADMIN`, but cannot grant `SUPER_ADMIN` to any account or change the role of an existing `SUPER_ADMIN`. Only a `SUPER_ADMIN` can perform those two operations.
## Q: How do I install multiple skills in bulk?
A: The CLI `install` command handles one skill at a time. Both examples below use `--dir` to install the skills under the same target root; each skill is placed in `$target_dir/<skill-slug>/`:
```bash
target_dir=/opt/skillhub-skills
# install one by one
for skill in skill-a skill-b skill-c; do
skillhub install "$skill" --dir "$target_dir"
done
# or read from a manifest file (one skill name per line)
xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
```
Since **SkillHub Server v0.2.12**, public skills support anonymous search and install. Note that an invalid bearer token now fails the command instead of falling back to anonymous access — update or remove the stale credential in that case.
## Q: What should I do if I encounter issues?
A: You can get help through the following channels:
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
- **Online Docs**: https://iflytek.github.io/skillhub/
- **Documentation**: Refer to the project README.md
- **Community Discussions**: https://github.com/iflytek/skillhub/discussions

View file

@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`.
When an API-token request is denied, the CLI shows the safe reason returned by the server and its `Request ID`. Use that ID to correlate the failure with server logs. Other authorization failures continue to use a generic message.
### Check Current Identity
```bash
@ -157,7 +159,7 @@ The CLI determines the installation location using the following logic:
1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`.
2. If `--scope user|project` is specified: Limit detection to the chosen scope.
- With `--agent <profile>`: Install to that profile's user or project skills directory directly.
- Without `--agent`: Detect existing skills directories within the chosen scope only.
- Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`<home>/.agents/skills/`) is always also offered and can be selected alone or together with detected targets.
- No detected directory in the chosen scope → Fallback to `<home>/.agents/skills/` for `--scope user` or `<cwd>/.agents/skills/` for `--scope project`.
3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged).
4. If none of the above is specified:
@ -188,7 +190,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
### File Structure After Installation

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | No |
| oauth2-github-client-secret | GitHub OAuth secret | No |
| skill-scanner-llm-api-key | LLM API key | No |
| skill-scanner-llm-base-url | Local/custom LLM service base URL | No |
| skill-scanner-llm-model | LLM model name used by the scanner | No |
### 3. Choose Deployment Method

View file

@ -79,6 +79,8 @@ Enabling the LLM analysis engine can improve the accuracy of security detection:
| `SKILLHUB_SCANNER_USE_LLM` | Enable LLM analysis | `false` |
| `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM provider (anthropic / openai / azure) | `anthropic` |
| `SKILL_SCANNER_LLM_API_KEY` | LLM API key | - |
| `SKILL_SCANNER_LLM_BASE_URL` | Local/custom LLM service base URL | - |
| `SKILL_SCANNER_LLM_MODEL` | LLM model name | - |
### Deployment Notes

View file

@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0
```
> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。
> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。升级不会清空数据库,已录入的技能包不会丢失。
## Q: 为什么管理员admin和普通用户都无法创建命名空间
@ -136,11 +136,199 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
A: 使用 OpenClaw CLI 命令行工具时,可以通过 `<namespace>--<skill-name>` 的格式来指定命名空间进行操作(例如搜索、安装)。如果在网页端搜索遇到问题,也可以尝试通过先导出技能、再导入到目标命名空间的方式来完成跨空间操作。
## Q: 推荐的部署方式是什么?可以自己拉镜像手动部署吗?
A: 推荐使用官方一键部署脚本,不建议自己拉取镜像手动部署(手动部署容易出现登录后跳回登录页等初始化问题):
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
脚本会执行一系列初始化操作,生成的运行时配置默认位于 `/tmp/skillhub-runtime/`(包含 `.env.release` 和 docker-compose 文件)。
## Q: 部署后输入正确的账号密码,却又跳回登录页?
A: 该现象多见于「手动部署」场景(接口异常或初始化未完成导致)。建议:
1. 改用上面的一键脚本部署。
2. 必要时清空 PostgreSQL 数据卷后重建再登录。
3. 若前置了反向代理,检查代理配置是否正确转发。
## Q: 如何修改 admin 密码?修改配置后不生效?
A: 环境变量在容器创建时注入,修改后必须重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。
1. 修改运行时目录下的 `/tmp/skillhub-runtime/.env.release`(参考仓库 [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example))。
2. 重新创建相关容器:
```bash
docker compose \
--env-file /tmp/skillhub-runtime/.env.release \
-f /tmp/skillhub-runtime/compose.release.yml \
up -d --force-recreate
```
3. 若此前密码已写入数据库导致仍不生效,可能需要清理对应数据后重新初始化。
## Q: 修改 / 找回密码必须使用邮箱验证码吗?
A: 是的,默认通过邮箱验证码修改或找回密码,因此需要先配置 SMTP。配置方法参考 [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md)。管理员也可在 `.env.release` 中进行重置。
## Q: skill 可以起中文名吗?
A: skill name 一般使用英文,目前不支持中文名(在 OpenClaw 中使用中文 skill 名会报错)。
## Q: 未审核的 skill 可以下载吗?
A: 只要拥有可查看的权限,一般都可以下载。
## Q: 如何隐藏或删除登录页的 GitHub / GitLab SSO 登录方式?
A: 修改 `application.yml`,注释或删除 `spring.security.oauth2.client.registration` 下的 `github``gitlab` 两块,并删除对应的 `provider` 段。Spring Boot 启动时便不会创建这两个注册,登录页也不会再显示对应入口。
## Q: SkillHub 的安全扫描Skill Scanner是讯飞自研的吗使用什么协议
A: SkillHub 内置安全扫描能力。其中扫描接入、任务编排、审计落库和部署集成由讯飞团队实现;底层扫描服务使用 Cisco 的 [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner)Apache License 2.0,版权归 Cisco
## Q: SkillHub 使用的 cisco-ai-skill-scanner 是哪个版本?
A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`,未锁定版本,因此构建镜像时会拉取 PyPI 上的最新版本。如需固定版本,可在二次开发时自行锁定。
## Q: 使用 CLI `skillhub publish` 报错 `registry returned 400` 怎么排查?
A: 400 通常是后端校验未通过。常见原因:
- `SKILL.md` 不在技能包根目录;
- `SKILL.md` 的 frontmatter 缺少 `name` / `description` 或格式错误;
- 名称或版本冲突(如 `error.skill.publish.nameConflict`,表示该 namespace 下已存在同名的已发布技能)——可改 `SKILL.md` 里的 `name`、换一个 namespace或让管理员处理已有同名技能
- namespace 不存在,或你不是该 namespace 的成员;
- 包内含疑似 token/secretCLI 无法确认跳过;
- 文件类型 / 大小 / 路径不合规。
可用以下命令查看服务端日志定位:
```bash
docker logs --tail=300 <skillhub-server 容器名> 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest'
```
## Q: 技能包的目录结构有什么要求?
A: 技能包根目录必须包含一个 `SKILL.md` 文件,且其 frontmatter 需包含 `name``description` 等字段。
## Q: 发布时报“技能包校验失败 / malformed input”怎么办
A: 该错误发生在 zip 解包读取文件名阶段,通常是压缩包不是 UTF-8 编码(例如用 Windows 自带压缩工具生成)或包内含中文路径导致。请使用 UTF-8 编码重新打包,并避免中文 / 特殊字符路径。
## Q: 技能包能包含多少个文件?提示文件数超限怎么办?
A: 默认上限为 **100 个文件**(这与 100MB 的大小限制是两回事)。如需放宽,修改配置项 `skillhub.publish.max-file-count`,或在部署时用环境变量覆盖:
```bash
SKILLHUB_PUBLISH_MAX_FILE_COUNT=500
```
修改后需重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。
## Q: 使用 CLI发布 / 下载等)对服务端版本有要求吗?
A: 需要 SkillHub 服务端镜像 **v0.2.7 及以上** 才支持 CLI 功能。
## Q: SkillHub 支持 MySQL 数据库吗?
A: 目前仅支持 PostgreSQL暂不支持 MySQL。
## Q: SkillHub 可以用来分发 Plugin 吗?
A: 暂不支持。
## Q: 如何查看 SkillHub 的版本?想做定制(如修改 logo怎么办
A:
- 查看服务端镜像版本:
```bash
docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}'
```
- 查看 CLI 版本:`skillhub version`
- 如需定制(如修改 logo 等),建议基于最新代码进行二次开发并自行构建 docker 镜像。
## Q: 页面能打开,但登录 / 注册接口返回 502
A: 页面由 `web` 容器提供,登录、注册等接口由 `web` 转发给 `server`(默认 `SKILLHUB_API_UPSTREAM=http://server:8080`)。出现「页面正常但 API 502」时通常先检查 `server` 是否正常启动upstream 配置、DNS 或容器网络异常也可能返回 502。
排查顺序:
```bash
# 1. 看 server 是否处于运行状态
docker compose --env-file .env.release -f compose.release.yml ps
# 2. 看 server 启动日志中的第一条错误
docker compose --env-file .env.release -f compose.release.yml logs server | head -50
```
一条常见的启动失败日志是:
```
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder
```
说明 `server` 读到的仍是模板里的占位值。在 `.env.release` 中改成自己的随机字符串(**至少 32 个字符**)后重建容器即可:
```bash
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<替换成你自己的随机字符串至少 32 个字符>
```
启动前可以先执行 `make validate-release-config`,它会校验 `.env.release`,提前暴露这类占位值和缺失项。
## Q: 改了配置为什么不生效?
A: 两个高频原因:
1. **改错了文件**`.env.release.example` 只是模板Compose 实际读取的是 `--env-file` 指定的 `.env.release`。请先 `cp .env.release.example .env.release`,然后修改 `.env.release`
2. **只重启没重建**:环境变量在容器创建时注入,`restart` 不会重新注入。改完配置需要重建容器:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate
```
## Q: SkillHub 运行时需要哪些外部依赖?
A: 必需 PostgreSQL 和 Redis对象存储支持 `local` 与 S3 两种模式,由 `SKILLHUB_STORAGE_PROVIDER` 控制。`.env.release.example` 显式配置为 `local`,但如果使用 `compose.release.yml` 时完全没有设置该变量Compose 的回退值是 `s3`。建议始终显式设置;生产环境推荐使用 S3通过 `SKILLHUB_STORAGE_S3_*` 配置)。数据库仅支持 PostgreSQL暂不支持 MySQL。
发布版 Compose 已内置 PostgreSQL 与 Redis默认只绑定在 `127.0.0.1`
## Q: 通过 OAuthGitHub / GitLab 等)登录的账号,如何取得管理员权限?
A: OAuth 首次登录创建的是普通用户。需要由已有的 `SUPER_ADMIN`(例如初始化时的 bootstrap admin在后台将其提升为管理员。
`USER_ADMIN` 可以管理用户状态,并分配除 `SUPER_ADMIN` 之外的平台角色;但不能向任何账号授予 `SUPER_ADMIN`,也不能修改已有 `SUPER_ADMIN` 账号的角色。这两类操作只有 `SUPER_ADMIN` 可以执行。
## Q: 如何批量安装多个技能包?
A: CLI 的 `install` 一次处理一个技能包。下面两个示例都通过 `--dir` 将技能批量安装到同一个目标根目录;每个技能实际位于 `$target_dir/<skill-slug>/`
```bash
target_dir=/opt/skillhub-skills
# 逐个安装
for skill in skill-a skill-b skill-c; do
skillhub install "$skill" --dir "$target_dir"
done
# 或从清单文件读取(每行一个技能名)
xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
```
**SkillHub Server v0.2.12** 起,公开技能支持匿名搜索与安装;如果配置了无效的 Bearer Token命令会直接失败而不再回退匿名访问遇到这种情况请更新凭据或先移除无效 Token。
## Q: 遇到问题怎么办?
A: 可以通过以下方式获取帮助:
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
- **在线文档**: https://iflytek.github.io/skillhub/
- **文档**: 参考项目 README.md
- **社区讨论**: https://github.com/iflytek/skillhub/discussions

View file

@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` 会验证 token 有效性,然后将 token 存储到 `~/.skillhub/credentials.json`,同时将 registry 写入 `~/.skillhub/config.json`
API Token 请求被拒绝时CLI 会显示服务端返回的具体原因和 `Request ID`。排查问题时可使用该 ID 对照服务端日志;非 API Token 的授权失败仍只显示通用信息。
### 查看当前身份
```bash
@ -157,7 +159,7 @@ CLI 按以下逻辑确定安装位置:
1. 指定 `--dir`安装到该目录agent 标记为 `custom``--dir``--scope``--agent` 互斥。
2. 指定 `--scope user|project`:探测限定在该 scope 内。
- 同时指定 `--agent <profile>`:直接安装到该 profile 对应 scope 的 skills 目录。
- 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。
- 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。在交互式 user scope 下,始终额外提供 `generic` 目标(`<home>/.agents/skills/`),可单独选择或与已探测目标同时选择。
- 该 scope 下未探测到 → fallback`--scope user` 回退到 `<home>/.agents/skills/``--scope project` 回退到 `<cwd>/.agents/skills/`
3. 指定 `--agent`(无 `--scope`):安装到对应 Agent 的 skills 目录(沿用现有行为,不变)。
4. 三者均未指定:
@ -188,7 +190,7 @@ CLI 按以下逻辑确定安装位置:
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
对于不在列表中的 Agent使用 `--dir` 指定安装路径。当 `--scope user|project` 找不到匹配的 agent 目录时CLI 会回退到上表的 `_fallback_` 行。
对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;`--scope user|project` 找不到匹配的 agent 目录时CLI 会回退到上表的 `_fallback_` 行。
### 安装后的文件结构

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 |
### 3. 选择部署方式

View file

@ -79,6 +79,8 @@ Skill Scanner 执行多引擎分析
| `SKILLHUB_SCANNER_USE_LLM` | 启用 LLM 分析 | `false` |
| `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM 提供商anthropic / openai / azure | `anthropic` |
| `SKILL_SCANNER_LLM_API_KEY` | LLM API 密钥 | - |
| `SKILL_SCANNER_LLM_BASE_URL` | 本地/自定义 LLM 服务地址 | - |
| `SKILL_SCANNER_LLM_MODEL` | LLM 模型名称 | - |
### 部署说明

View file

@ -369,9 +369,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@ -386,9 +386,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@ -403,9 +403,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@ -420,9 +420,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@ -437,9 +437,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@ -454,9 +454,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@ -471,9 +471,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@ -488,9 +488,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@ -505,9 +505,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@ -522,9 +522,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@ -539,9 +539,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@ -556,9 +556,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@ -573,9 +573,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@ -590,9 +590,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@ -607,9 +607,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@ -624,9 +624,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@ -641,9 +641,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@ -658,9 +658,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@ -675,9 +675,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@ -692,9 +692,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@ -709,9 +709,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@ -726,9 +726,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@ -743,9 +743,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@ -760,9 +760,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@ -777,9 +777,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@ -794,9 +794,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@ -1757,9 +1757,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -1770,32 +1770,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": {
@ -2475,9 +2475,9 @@
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -11,8 +11,8 @@
"vitepress": "^1.6.3"
},
"overrides": {
"vite": "^6.4.2",
"vite": "^6.4.3",
"postcss": "^8.5.10",
"esbuild": "^0.25.0"
"esbuild": "^0.28.1"
}
}

View file

@ -23,7 +23,7 @@ SkillHub 采用基于角色的访问控制RBAC系统。
| 角色 | 代码 | 实际能力 |
|------|------|----------|
| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 |
| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;可以审批自己提交的 promotion普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 |
| 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本yank、处理技能举报可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report不能分配平台角色、不能看审计日志、不能管理用户。 |
| 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 |
| 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs``/actuator/prometheus`;治理工作台中只能看 activity不能处理 review/promotion/report也不能管理用户或技能。 |

View file

@ -23,7 +23,7 @@ The database migration seeds only 4 explicit platform roles:
| Role | Code | Effective behavior |
|------|------|--------------------|
| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. |
| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Can approve their own promotion request. For normal review tasks, the self-submission exception is also only bypassed by `SUPER_ADMIN`. |
| Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. |
| User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. |
| Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. |

View file

@ -1,10 +1,16 @@
FROM python:3.11-alpine
ARG SKILL_SCANNER_VERSION=1.0.2
WORKDIR /app
RUN apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev && \
pip install --no-cache-dir cisco-ai-skill-scanner && \
apk del .build-deps && \
COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py
RUN pip install --no-cache-dir \
"cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" \
"litellm==1.90.2" && \
python /tmp/apply_1_0_2_llm_base_url_backport.py /usr/local/lib/python3.11/site-packages && \
rm /tmp/apply_1_0_2_llm_base_url_backport.py && \
addgroup -S app && \
adduser -S app -G app && \
mkdir -p /tmp/skillhub-scans && \

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Backport SKILL_SCANNER_LLM_BASE_URL support into cisco-ai-skill-scanner 1.0.2."""
from __future__ import annotations
import re
import sys
from pathlib import Path
EXPECTED_DIST_INFO = "cisco_ai_skill_scanner-1.0.2.dist-info"
ROUTER_RELATIVE_PATH = Path("skill_scanner/api/router.py")
def replace_exact(content: str, old: str, new: str, expected_count: int, label: str) -> str:
actual_count = content.count(old)
if actual_count != expected_count:
raise SystemExit(f"Expected {expected_count} occurrences of {label}, found {actual_count}.")
return content.replace(old, new, expected_count)
def replace_regex(content: str, pattern: str, replacement: str, expected_count: int, label: str) -> str:
updated, actual_count = re.subn(pattern, replacement, content, count=expected_count, flags=re.MULTILINE)
if actual_count != expected_count:
raise SystemExit(f"Expected {expected_count} regex replacements for {label}, found {actual_count}.")
return updated
def main() -> int:
site_packages = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/usr/local/lib/python3.11/site-packages")
dist_info = site_packages / EXPECTED_DIST_INFO
if not dist_info.exists():
raise SystemExit(f"Expected {EXPECTED_DIST_INFO} under {site_packages}, but it was not found.")
router_path = site_packages / ROUTER_RELATIVE_PATH
content = router_path.read_text(encoding="utf-8")
content = replace_regex(
content,
r'^(?P<indent>\s*)llm_model = os.getenv\("SKILL_SCANNER_LLM_MODEL"\)$',
r'\g<0>\n\g<indent>llm_base_url = os.getenv("SKILL_SCANNER_LLM_BASE_URL")',
2,
"llm_model environment lookup",
)
content = replace_exact(
content,
"LLMAnalyzer(model=llm_model)",
"LLMAnalyzer(model=llm_model, base_url=llm_base_url)",
2,
"LLMAnalyzer model constructor",
)
content = replace_exact(
content,
"LLMAnalyzer(provider=provider_str)",
"LLMAnalyzer(provider=provider_str, base_url=llm_base_url)",
2,
"LLMAnalyzer provider constructor",
)
router_path.write_text(content, encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -18,7 +18,7 @@ skillhub:
security:
scanner:
# 基础配置
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false}
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000}
mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local}
@ -48,20 +48,20 @@ skillhub:
#### `enabled`
- **类型**Boolean
- **默认值**`false`
- **默认值**`true`
- **环境变量**`SKILLHUB_SECURITY_SCANNER_ENABLED`
- **说明**:是否启用安全扫描功能
- **影响**
- `true`:技能包发布时会触发安全扫描
- `false`跳过安全扫描,直接进入审核流程
- `false`仅允许 `PRIVATE` 技能跳过扫描;`PUBLIC` / `NAMESPACE_ONLY` 发布会失败并提示必须启用 Scanner
**示例**
```yaml
# 开发环境:禁用扫描
# 仅本地私有技能调试:禁用扫描
enabled: false
# 生产环境:启用扫描
# 公共或命名空间可见发布:启用扫描
enabled: true
```
@ -103,8 +103,8 @@ base-url: https://scanner.example.com
**示例**
```yaml
# Docker Compose 环境(共享卷
mode: local
# Docker Compose 环境(Scanner 独立容器,无共享发布目录
mode: upload
# Kubernetes 环境(独立 Pod
mode: upload
@ -298,7 +298,7 @@ services:
environment:
- SKILLHUB_SECURITY_SCANNER_ENABLED=true
- SKILLHUB_SECURITY_SCANNER_URL=http://skill-scanner:8000
- SKILLHUB_SECURITY_SCANNER_MODE=local
- SKILLHUB_SECURITY_SCANNER_MODE=upload
- SKILLHUB_SCANNER_USE_BEHAVIORAL=false
- SKILLHUB_SCANNER_USE_LLM=false
- SKILLHUB_SCANNER_USE_META=true
@ -348,9 +348,9 @@ stringData:
skillhub:
security:
scanner:
enabled: false # 开发时禁用扫描,加快迭代速度
enabled: true # 默认启用PUBLIC/NAMESPACE_ONLY 发布依赖扫描
base-url: http://localhost:8000
mode: local
mode: upload
analyzers:
meta: true # 只启用元数据分析
policy:
@ -366,7 +366,7 @@ skillhub:
scanner:
enabled: true # 测试环境启用扫描
base-url: http://skill-scanner:8000
mode: local
mode: upload
analyzers:
behavioral: true
meta: true

View file

@ -159,13 +159,34 @@ set_env_value() {
fi
tmp="$ENV_FILE.tmp"
if grep -q "^$key=" "$ENV_FILE"; then
sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp"
else
cat "$ENV_FILE" >"$tmp"
printf '%s=%s\n' "$key" "$value" >>"$tmp"
fi
found=false
old_umask="$(umask)"
umask 077
{
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"$key="*)
printf '%s=%s\n' "$key" "$value"
found=true
;;
*)
printf '%s\n' "$line"
;;
esac
done <"$ENV_FILE"
if [ "$found" = "false" ]; then
printf '%s=%s\n' "$key" "$value"
fi
} >"$tmp"
umask "$old_umask"
mv "$tmp" "$ENV_FILE"
secure_env_file
}
secure_env_file() {
if [ -f "$ENV_FILE" ]; then
chmod 600 "$ENV_FILE"
fi
}
get_env_value() {
@ -180,6 +201,42 @@ get_env_value() {
fi
}
generate_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
return 0
fi
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n'
return 0
fi
echo "Unable to generate SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET. Install openssl or configure it manually." >&2
exit 1
}
is_placeholder_secret() {
case "$1" in
""|change-me-in-production|replace-me|replace-with-random-download-secret-32-bytes|TODO*|todo*|replace*)
return 0
;;
*)
return 1
;;
esac
}
ensure_anonymous_download_secret() {
secret="$(get_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "")"
if ! is_placeholder_secret "$secret" && [ "${#secret}" -ge 32 ]; then
return 0
fi
set_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$(generate_secret)"
echo "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET in $ENV_FILE"
}
wait_for_postgres_ready() {
postgres_user="$1"
postgres_db="$2"
@ -199,6 +256,23 @@ wait_for_postgres_ready() {
exit 1
}
wait_for_redis_ready() {
attempt=1
while [ "$attempt" -le 60 ]; do
if run_compose exec -T redis redis-cli ping >/dev/null 2>&1; then
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
echo "Redis did not become ready in time." >&2
run_compose logs redis >&2 || true
exit 1
}
ensure_postgres_password_matches_env() {
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
@ -231,8 +305,12 @@ prepare_runtime_files() {
download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE"
if [ ! -f "$ENV_FILE" ]; then
old_umask="$(umask)"
umask 077
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
umask "$old_umask"
fi
secure_env_file
if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then
mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}"
@ -280,6 +358,12 @@ prepare_runtime_files() {
if [ -n "$SKILLHUB_PUBLIC_BASE_URL_VALUE" ]; then
set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE"
fi
if [ "$DISABLE_SCANNER" = "true" ]; then
set_env_value "SKILLHUB_SECURITY_SCANNER_ENABLED" "false"
fi
ensure_anonymous_download_secret
}
run_compose() {
@ -295,7 +379,9 @@ case "$COMMAND" in
run_compose up -d postgres
ensure_postgres_password_matches_env
if [ "$DISABLE_SCANNER" = "true" ]; then
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0
run_compose up -d redis
wait_for_redis_ready
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --no-deps --scale skill-scanner=0 server web
else
run_compose up -d
fi

View file

@ -36,8 +36,8 @@ echo "Target: $BASE_URL"
echo
check "Health endpoint" "$BASE_URL/actuator/health" "200"
check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200"
check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200"
check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401"
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
@ -67,6 +67,15 @@ else
FAIL=$((FAIL + 1))
fi
NAMESPACES_AUTH_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/namespaces" || true)"
if [[ "$NAMESPACES_AUTH_STATUS" == "200" ]]; then
echo "PASS: Namespaces API with session (HTTP $NAMESPACES_AUTH_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Namespaces API with session (got $NAMESPACES_AUTH_STATUS)"
FAIL=$((FAIL + 1))
fi
CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/auth/local/change-password" \
-b "$COOKIE_JAR" \

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MAKEFILE="$REPO_ROOT/Makefile"
fail() {
echo "FAIL: $*" >&2
exit 1
}
grep -Eq '^DEV_WEB_HOST[[:space:]]*\?=[[:space:]]*127\.0\.0\.1$' "$MAKEFILE" \
|| fail "Makefile must default DEV_WEB_HOST to 127.0.0.1"
grep -Fq 'pnpm exec vite --host $(DEV_WEB_HOST)' "$MAKEFILE" \
|| fail "dev web startup must pass DEV_WEB_HOST to vite"
grep -Fq "cd server && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \
|| fail "dev-server must inject scanner upload environment"
if grep -Fq 'pnpm exec vite --host 0.0.0.0' "$MAKEFILE"; then
fail "dev web startup must not bind Vite to 0.0.0.0 by default"
fi
echo "dev-web-host-test passed"

View file

@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TEMPLATE="$REPO_ROOT/web/nginx.conf.template"
NGINX_IMAGE="${NGINX_TEST_IMAGE:-nginx:alpine}"
TEST_ID="skillhub-nginx-forwarded-proto-$$"
NETWORK="${TEST_ID}-network"
BACKEND="${TEST_ID}-backend"
DEFAULT_PROXY="${TEST_ID}-default"
TRUSTED_PROXY="${TEST_ID}-trusted"
TMP_DIR="$(mktemp -d)"
CONTAINERS=()
cleanup() {
if ((${#CONTAINERS[@]} > 0)); then
docker rm -f "${CONTAINERS[@]}" >/dev/null 2>&1 || true
fi
docker network rm "$NETWORK" >/dev/null 2>&1 || true
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
wait_for_nginx() {
local container="$1"
local attempt
for attempt in {1..30}; do
if docker exec "$container" wget -qO- http://127.0.0.1/nginx-health >/dev/null 2>&1; then
return 0
fi
sleep 0.2
done
docker logs "$container" >&2 || true
fail "$container did not become healthy"
}
start_proxy() {
local container="$1"
local trust_forwarded_proto="$2"
docker run --detach \
--name "$container" \
--network "$NETWORK" \
--env "SKILLHUB_API_UPSTREAM=http://$BACKEND:8080" \
--env "SKILLHUB_TRUST_FORWARDED_PROTO=$trust_forwarded_proto" \
--volume "$TEMPLATE:/etc/nginx/templates/default.conf.template:ro" \
"$NGINX_IMAGE" >/dev/null
CONTAINERS+=("$container")
wait_for_nginx "$container"
}
assert_proto() {
local container="$1"
local expected="$2"
local header="${3:-}"
local path="${4:-/api/proto}"
local actual
if [[ -n "$header" ]]; then
actual="$(docker exec "$container" wget -qO- \
--header="X-Forwarded-Proto: $header" \
"http://127.0.0.1$path")"
else
actual="$(docker exec "$container" wget -qO- "http://127.0.0.1$path")"
fi
[[ "$actual" == "$expected" ]] \
|| fail "$container forwarded proto '$actual', expected '$expected' for $path with header '${header:-<none>}'"
}
cat >"$TMP_DIR/backend.conf" <<'EOF'
server {
listen 8080;
location / {
default_type text/plain;
return 200 $http_x_forwarded_proto;
}
}
EOF
docker network create "$NETWORK" >/dev/null
docker run --detach \
--name "$BACKEND" \
--network "$NETWORK" \
--volume "$TMP_DIR/backend.conf:/etc/nginx/conf.d/default.conf:ro" \
"$NGINX_IMAGE" >/dev/null
CONTAINERS+=("$BACKEND")
start_proxy "$DEFAULT_PROXY" false
start_proxy "$TRUSTED_PROXY" true
for path in /api/proto /oauth2/proto /login/oauth2/proto /.well-known/proto; do
assert_proto "$DEFAULT_PROXY" http https "$path"
assert_proto "$TRUSTED_PROXY" https https "$path"
done
assert_proto "$TRUSTED_PROXY" http
assert_proto "$TRUSTED_PROXY" http "https,http"
echo "nginx-forwarded-proto-test passed"

View file

@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/runtime.sh"
TMP_DIRS=()
cleanup() {
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
install_fake_tools() {
local bin_dir="$1"
mkdir -p "$bin_dir"
cat >"$bin_dir/docker" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "${DOCKER_LOG:?DOCKER_LOG is required}"
if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then
exit 0
fi
exit 0
EOF
chmod +x "$bin_dir/docker"
cat >"$bin_dir/openssl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "rand" && "${2:-}" == "-hex" && "${3:-}" == "32" ]]; then
printf '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n'
exit 0
fi
echo "unsupported openssl args: $*" >&2
exit 1
EOF
chmod +x "$bin_dir/openssl"
}
run_runtime() {
local home="$1"
local bin_dir="$2"
local stdout="$3"
shift 3
DOCKER_LOG="$home/docker.log" \
SKILLHUB_HOME="$home" \
SKILLHUB_RAW_BASE="file://$REPO_ROOT" \
PATH="$bin_dir:$PATH" \
sh "$SCRIPT" up --version sha-test --public-url http://localhost "$@" >"$stdout"
}
file_mode() {
local file="$1"
if stat -c %a "$file" >/dev/null 2>&1; then
stat -c %a "$file"
else
stat -f %Lp "$file"
fi
}
tmp="$(new_tmp)"
bin_dir="$tmp/bin"
install_fake_tools "$bin_dir"
home_generated="$tmp/generated"
stdout_generated="$tmp/generated.out"
mkdir -p "$home_generated"
run_runtime "$home_generated" "$bin_dir" "$stdout_generated"
generated_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_generated/.env.release" | cut -d= -f2-)"
[[ "$generated_secret" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]] \
|| fail "runtime should generate a persisted anonymous download secret"
[[ "$(file_mode "$home_generated/.env.release")" == "600" ]] \
|| fail "runtime env file must be readable only by the owner"
grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \
|| fail "runtime should explain that it generated the secret"
if grep -Fq "$generated_secret" "$stdout_generated"; then
fail "runtime must not print the generated secret value"
fi
home_preserved="$tmp/preserved"
stdout_preserved="$tmp/preserved.out"
mkdir -p "$home_preserved"
cat >"$home_preserved/.env.release" <<'EOF'
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=already-valid-runtime-secret-32-bytes
EOF
run_runtime "$home_preserved" "$bin_dir" "$stdout_preserved"
preserved_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_preserved/.env.release" | cut -d= -f2-)"
[[ "$preserved_secret" == "already-valid-runtime-secret-32-bytes" ]] \
|| fail "runtime must preserve an existing valid anonymous download secret"
[[ "$(file_mode "$home_preserved/.env.release")" == "600" ]] \
|| fail "runtime env file must remain owner-readable only when an existing secret is preserved"
if grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_preserved"; then
fail "runtime must not regenerate an existing valid secret"
fi
home_no_scanner="$tmp/no-scanner"
stdout_no_scanner="$tmp/no-scanner.out"
mkdir -p "$home_no_scanner"
run_runtime "$home_no_scanner" "$bin_dir" "$stdout_no_scanner" --no-scanner
grep -Fq "SKILLHUB_SECURITY_SCANNER_ENABLED=false" "$home_no_scanner/.env.release" \
|| fail "runtime should persist scanner disabled state for --no-scanner"
grep -Fq -- "up -d --no-deps --scale skill-scanner=0 server web" "$home_no_scanner/docker.log" \
|| fail "runtime --no-scanner should start server/web without waiting on scanner dependencies"
echo "runtime-secret-test passed"

View file

@ -0,0 +1,232 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCANNER_DIR="$REPO_ROOT/scanner"
TMP_DIRS=()
cleanup() {
local status=$?
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
exit "$status"
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmp="$(new_tmp)"
skill_dir="$tmp/skill"
mkdir -p "$skill_dir/demo-skill"
cat >"$skill_dir/demo-skill/SKILL.md" <<'EOF'
---
name: demo-skill
description: Minimal valid skill used for scanner integration coverage.
license: Apache-2.0
---
This is a harmless demo skill used for scanner integration testing.
EOF
cat >"$skill_dir/demo-skill/run.sh" <<'EOF'
#!/usr/bin/env sh
echo "demo"
EOF
chmod +x "$skill_dir/demo-skill/run.sh"
IMAGE_TAG="skillhub-scanner-llm-base-url-test:$(date +%s)"
docker build --no-cache -t "$IMAGE_TAG" "$SCANNER_DIR" >/dev/null
docker run --rm -i \
-v "$skill_dir:/work/skill:ro" \
--entrypoint python \
"$IMAGE_TAG" - <<'PY'
import asyncio
from datetime import datetime, timezone
import http.server
import io
import inspect
import json
import os
from pathlib import Path
import threading
import urllib.request
import zipfile
from fastapi.params import Query
from skill_scanner.core.models import ScanResult
import skill_scanner.api.router as router
signature = inspect.signature(router.scan_uploaded_skill)
if not isinstance(signature.parameters["use_llm"].default, Query):
raise SystemExit("scan-upload use_llm should remain a Query parameter")
if not isinstance(signature.parameters["llm_provider"].default, Query):
raise SystemExit("scan-upload llm_provider should remain a Query parameter")
state = {"base_urls": [], "paths": []}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A003
return
def do_POST(self): # noqa: N802
length = int(self.headers.get("content-length", "0"))
self.rfile.read(length)
state["paths"].append(self.path)
payload = json.dumps(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": int(datetime.now(timezone.utc).timestamp()),
"model": "local-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "No findings."},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
target_base_url = f"http://127.0.0.1:{server.server_port}/v1"
os.environ["SKILL_SCANNER_LLM_BASE_URL"] = target_base_url
os.environ["SKILL_SCANNER_LLM_MODEL"] = "test-model"
class FakeStaticAnalyzer:
pass
class FakeLLMAnalyzer:
def __init__(self, model=None, provider=None, base_url=None):
self.model = model
self.provider = provider
self.base_url = base_url
state["base_urls"].append(base_url)
def analyze(self, skill_path):
request = urllib.request.Request(
self.base_url + "/chat/completions",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
response.read()
class FakeSkillScanner:
def __init__(self, analyzers):
self.analyzers = analyzers
def scan_skill(self, skill_path):
for analyzer in self.analyzers:
analyze = getattr(analyzer, "analyze", None)
if callable(analyze):
analyze(skill_path)
return ScanResult(
skill_name="demo-skill",
skill_directory=str(skill_path),
findings=[],
scan_duration_seconds=0.05,
analyzers_used=["fake-llm"],
timestamp=datetime.now(timezone.utc),
)
router.StaticAnalyzer = FakeStaticAnalyzer
router.LLMAnalyzer = FakeLLMAnalyzer
router.SkillScanner = FakeSkillScanner
router.LLM_AVAILABLE = True
request = router.ScanRequest(
skill_directory="/work/skill/demo-skill",
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
def build_skill_archive_bytes(skill_root: str) -> bytes:
skill_path = Path(skill_root)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in skill_path.rglob("*"):
if path.is_file():
archive.writestr(str(path.relative_to(skill_path.parent)), path.read_bytes())
return buffer.getvalue()
class FakeUploadFile:
def __init__(self, filename: str, payload: bytes):
self.filename = filename
self._payload = payload
async def read(self) -> bytes:
return self._payload
try:
direct_response = asyncio.run(router.scan_skill(request))
upload_response = asyncio.run(
router.scan_uploaded_skill(
file=FakeUploadFile("demo-skill.zip", build_skill_archive_bytes("/work/skill/demo-skill")),
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
)
finally:
server.shutdown()
thread.join(timeout=5)
if not getattr(direct_response, "scan_id", None):
raise SystemExit("scan_skill should still return a scan response")
if not getattr(upload_response, "scan_id", None):
raise SystemExit("scan_uploaded_skill should still return a scan response")
if len(state["base_urls"]) != 2:
raise SystemExit(f"expected two LLM analyzer constructions, got {len(state['base_urls'])}")
if any(base_url != target_base_url for base_url in state["base_urls"]):
raise SystemExit(f"expected every base_url to be {target_base_url}, got {state['base_urls']}")
if len(state["paths"]) != 2:
raise SystemExit(f"expected two LLM requests, got {state['paths']}")
if not all(path.startswith("/v1/") for path in state["paths"]):
raise SystemExit(f"expected every request path to start with /v1/, got {state['paths']}")
PY
grep -Fq "name: SKILL_SCANNER_LLM_BASE_URL" "$REPO_ROOT/deploy/k8s/base/scanner-deployment.yaml" \
|| fail "Kubernetes scanner deployment must expose SKILL_SCANNER_LLM_BASE_URL"
grep -Fq "skill-scanner-llm-base-url" "$REPO_ROOT/deploy/k8s/base/secret.yaml.example" \
|| fail "Kubernetes secret example must document skill-scanner-llm-base-url"
echo "scanner-llm-base-url-test passed"

View file

@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/validate-release-config.sh"
TMP_DIRS=()
cleanup() {
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
write_env() {
local file="$1"
local secret="${2:-}"
local include_secret="${3:-yes}"
cat >"$file" <<EOF
SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
POSTGRES_DB=skillhub
POSTGRES_USER=skillhub
POSTGRES_PASSWORD=strong-postgres-password
SESSION_COOKIE_SECURE=true
BOOTSTRAP_ADMIN_ENABLED=false
SKILLHUB_TRUST_FORWARDED_PROTO=false
SKILLHUB_STORAGE_PROVIDER=s3
SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com
SKILLHUB_STORAGE_S3_BUCKET=skillhub
SKILLHUB_STORAGE_S3_ACCESS_KEY=release-access-key
SKILLHUB_STORAGE_S3_SECRET_KEY=release-secret-key
SKILLHUB_STORAGE_S3_REGION=us-east-1
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false
EOF
if [[ "$include_secret" == "yes" ]]; then
printf 'SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=%s\n' "$secret" >>"$file"
fi
}
expect_fail() {
local file="$1"
local expected="$2"
local output
if output="$("$SCRIPT" "$file" 2>&1)"; then
fail "expected validation to fail for $file"
fi
if [[ "$output" != *"$expected"* ]]; then
fail "expected output to contain '$expected', got: $output"
fi
}
tmp="$(new_tmp)"
valid_env="$tmp/valid.env"
write_env "$valid_env" "release-download-secret-32-bytes-minimum"
"$SCRIPT" "$valid_env" >/dev/null
missing_env="$tmp/missing.env"
write_env "$missing_env" "" no
expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"
placeholder_env="$tmp/placeholder.env"
write_env "$placeholder_env" "change-me-in-production"
expect_fail "$placeholder_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET still uses placeholder/default value"
short_env="$tmp/short.env"
write_env "$short_env" "too-short"
expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"
invalid_forwarded_proto_env="$tmp/invalid-forwarded-proto.env"
write_env "$invalid_forwarded_proto_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SKILLHUB_TRUST_FORWARDED_PROTO=yes" >>"$invalid_forwarded_proto_env"
expect_fail "$invalid_forwarded_proto_env" "SKILLHUB_TRUST_FORWARDED_PROTO must be true or false"
draft_env="$tmp/draft.env"
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=*)
printf '%s\n' "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum"
;;
*)
printf '%s\n' "$line"
;;
esac
done <"$REPO_ROOT/.env.release.draft" >"$draft_env"
expect_fail "$draft_env" "POSTGRES_PASSWORD"
echo "validate-release-config-test passed"

View file

@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SECURITY_WORKFLOW="$REPO_ROOT/.github/workflows/security.yml"
PR_SCRIPTS_WORKFLOW="$REPO_ROOT/.github/workflows/pr-scripts.yml"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_pr_workflow_hardened() {
local workflow="$1"
grep -Eq '^permissions:[[:space:]]*$' "$workflow" \
|| fail "$workflow must declare top-level permissions"
grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$workflow" \
|| fail "$workflow GITHUB_TOKEN permissions must include contents: read"
grep -Fq 'persist-credentials: false' "$workflow" \
|| fail "$workflow checkout steps must not persist credentials"
}
[[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-cli.yml"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-e2e.yml"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-tests.yml"
assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW"
assert_pr_workflow_hardened "$SECURITY_WORKFLOW"
grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \
|| fail "security workflow must run dependency review"
grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \
|| fail "security workflow must initialize CodeQL"
grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \
|| fail "security workflow must build Java with the server Maven wrapper"
grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \
|| fail "security workflow must grant SARIF upload permission"
python_source="$(find "$REPO_ROOT" \
\( -path "$REPO_ROOT/.git" -o -path '*/node_modules' -o -path '*/.venv' \) -prune -o \
-type f -name '*.py' -print -quit)"
if [[ -n "$python_source" ]]; then
grep -Fq 'language: python' "$SECURITY_WORKFLOW" \
|| fail "security workflow must run Python CodeQL when Python source exists"
else
! grep -Fq 'language: python' "$SECURITY_WORKFLOW" \
|| fail "security workflow must not run Python CodeQL without Python source"
fi
grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when security workflow changes"
grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR CLI workflow changes"
grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR E2E workflow changes"
grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR Tests workflow changes"
grep -Fq "'**/*.py'" "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when Python source changes"
grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release env example changes"
grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release env draft changes"
grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release compose changes"
grep -Fq 'web/Dockerfile' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when the web image changes"
grep -Fq 'web/nginx.conf.template' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when the nginx template changes"
grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run validate-release-config-test"
grep -Fq 'bash scripts/tests/nginx-forwarded-proto-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run nginx-forwarded-proto-test"
grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run runtime-secret-test"
grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run dev-web-host-test"
grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run workflow-security-test"
echo "workflow-security-test passed"

View file

@ -52,6 +52,23 @@ reject_values() {
done
}
reject_patterns() {
var_name="$1"
shift
eval "var_value=\${$var_name:-}"
if [ -z "$var_value" ]; then
return 0
fi
for pattern in "$@"; do
case "$var_value" in
$pattern)
error "$var_name still uses placeholder/default pattern: $var_value"
return 0
;;
esac
done
}
validate_url() {
var_name="$1"
eval "var_value=\${$var_name:-}"
@ -97,20 +114,44 @@ validate_port() {
esac
}
validate_min_length() {
var_name="$1"
min_length="$2"
eval "var_value=\${$var_name:-}"
if [ -z "$var_value" ]; then
return 0
fi
if [ "${#var_value}" -lt "$min_length" ]; then
error "$var_name must be at least $min_length characters"
fi
}
require_non_empty SKILLHUB_PUBLIC_BASE_URL
validate_url SKILLHUB_PUBLIC_BASE_URL
validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL
require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET
reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes"
reject_patterns SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "TODO_*" "todo_*" "replace*"
validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32
reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev"
reject_patterns POSTGRES_PASSWORD "TODO_*" "todo_*"
reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026"
reject_patterns BOOTSTRAP_ADMIN_PASSWORD "TODO_*" "todo_*" "replace*"
if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then
require_non_empty BOOTSTRAP_ADMIN_PASSWORD
fi
reject_values SKILLHUB_STORAGE_S3_ACCESS_KEY "replace-me"
reject_values SKILLHUB_STORAGE_S3_SECRET_KEY "replace-me"
reject_patterns SKILLHUB_STORAGE_S3_ACCESS_KEY "TODO_*" "todo_*" "replace*"
reject_patterns SKILLHUB_STORAGE_S3_SECRET_KEY "TODO_*" "todo_*" "replace*"
reject_patterns SPRING_MAIL_USERNAME "TODO_*" "todo_*" "replace*"
reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*"
validate_boolean SESSION_COOKIE_SECURE
validate_boolean BOOTSTRAP_ADMIN_ENABLED
validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO
validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE
validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub;
import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties;
import com.iflytek.skillhub.config.ProfileFieldPolicyProperties;
import com.iflytek.skillhub.config.ProfileModerationProperties;
import org.springframework.boot.SpringApplication;
@ -10,7 +11,11 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
* Main Spring Boot entry point for the SkillHub backend application.
*/
@SpringBootApplication
@EnableConfigurationProperties({ProfileModerationProperties.class, ProfileFieldPolicyProperties.class})
@EnableConfigurationProperties({
BuiltinSkillProperties.class,
ProfileModerationProperties.class,
ProfileFieldPolicyProperties.class
})
public class SkillhubApplication {
public static void main(String[] args) {
SpringApplication.run(SkillhubApplication.class, args);

View file

@ -0,0 +1,439 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem;
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.SlugValidator;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* Best-effort startup synchronizer for remotely hosted built-in skill packages.
*/
@Component
public class BuiltinSkillInitializer {
static final String GLOBAL_NAMESPACE = "global";
static final String SYSTEM_PUBLISHER_ID = "builtin-skill-publisher";
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class);
private static final Set<String> SYSTEM_PUBLISHER_ROLES = Set.of("SUPER_ADMIN");
private static final boolean CONFIRM_BUILTIN_PUBLISH_WARNINGS = true;
private final BuiltinSkillProperties properties;
private final BuiltinSkillManifestLoader manifestLoader;
private final BuiltinSkillRemotePackageDownloader downloader;
private final BuiltinSkillPackageExtractor extractor;
private final SkillMetadataParser metadataParser;
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final UserAccountRepository userAccountRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillFileRepository skillFileRepository;
private final SkillPublishService skillPublishService;
public BuiltinSkillInitializer(
BuiltinSkillProperties properties,
BuiltinSkillManifestLoader manifestLoader,
BuiltinSkillRemotePackageDownloader downloader,
BuiltinSkillPackageExtractor extractor,
SkillMetadataParser metadataParser,
NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
UserAccountRepository userAccountRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillFileRepository skillFileRepository,
SkillPublishService skillPublishService) {
this.properties = properties;
this.manifestLoader = manifestLoader;
this.downloader = downloader;
this.extractor = extractor;
this.metadataParser = metadataParser;
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.userAccountRepository = userAccountRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillFileRepository = skillFileRepository;
this.skillPublishService = skillPublishService;
}
@EventListener(ApplicationReadyEvent.class)
@Async("skillhubEventExecutor")
public void synchronizeAfterApplicationReady() {
synchronize();
}
void synchronize() {
if (!properties.isEnabled()) {
log.info("Built-in skill startup synchronization is disabled");
return;
}
Optional<Namespace> namespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE);
if (namespace.isEmpty()) {
log.warn("Global namespace '{}' does not exist, skipping built-in skill synchronization",
GLOBAL_NAMESPACE);
return;
}
List<ManifestItem> items = manifestLoader.load();
if (items.isEmpty()) {
log.info("No built-in skill manifest items to synchronize");
return;
}
try {
if (!ensureSystemPublisher(namespace.get())) {
return;
}
} catch (RuntimeException exception) {
log.error("Failed to initialize built-in skill system publisher, skipping synchronization: {}",
exception.getMessage(), exception);
return;
}
int published = 0;
int idempotentSkipped = 0;
int conflictSkipped = 0;
int failed = 0;
for (ManifestItem item : items) {
try {
SyncOutcome outcome = syncItem(namespace.get(), item);
switch (outcome) {
case PUBLISHED -> published++;
case IDEMPOTENT_SKIPPED -> idempotentSkipped++;
case CONFLICT_SKIPPED -> conflictSkipped++;
case FAILED -> failed++;
}
} catch (Exception exception) {
failed++;
log.error(
"Failed to synchronize built-in skill slug={} version={}: {}",
item.slug(),
item.version(),
exception.getMessage(),
exception
);
}
}
log.info(
"Built-in skill synchronization finished: total={}, published={}, idempotentSkipped={}, conflictSkipped={}, failed={}",
items.size(),
published,
idempotentSkipped,
conflictSkipped,
failed
);
}
private boolean ensureSystemPublisher(Namespace namespace) {
Optional<UserAccount> existingPublisher = userAccountRepository.findById(SYSTEM_PUBLISHER_ID);
UserAccount publisher;
if (existingPublisher.isPresent()) {
publisher = existingPublisher.get();
} else {
publisher = UserAccount.systemAccount(
SYSTEM_PUBLISHER_ID,
"Built-in Skill Publisher",
null,
null
);
userAccountRepository.save(publisher);
}
if (!publisher.isSystemAccount()) {
log.error("Built-in skill publisher account id '{}' already exists but is not a system account; "
+ "skipping built-in skill synchronization", SYSTEM_PUBLISHER_ID);
return false;
}
if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), SYSTEM_PUBLISHER_ID).isEmpty()) {
namespaceMemberRepository.save(new NamespaceMember(
namespace.getId(),
SYSTEM_PUBLISHER_ID,
NamespaceRole.OWNER
));
}
return true;
}
private SyncOutcome syncItem(Namespace namespace, ManifestItem item) throws Exception {
Optional<SyncOutcome> skipBeforeDownload = shouldSkipBeforeDownload(namespace.getId(), item);
if (skipBeforeDownload.isPresent()) {
return skipBeforeDownload.get();
}
Optional<URI> packageUri = parsePackageUri(item);
if (packageUri.isEmpty()) {
return SyncOutcome.FAILED;
}
Optional<byte[]> packageBytes = downloader.download(packageUri.get());
if (packageBytes.isEmpty()) {
log.warn("Skipping built-in skill slug={} version={} because package download failed",
item.slug(), item.version());
return SyncOutcome.FAILED;
}
SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get());
List<PackageEntry> entries = extractionResult.entries();
SkillMetadata metadata = parseSkillMetadata(entries);
String packageSlug = SlugValidator.slugify(metadata.name());
if (!item.slug().equals(packageSlug)) {
log.warn(
"Skipping built-in skill manifest slug={} version={} because package slug is {}",
item.slug(),
item.version(),
packageSlug
);
return SyncOutcome.FAILED;
}
if (!item.version().equals(metadata.version())) {
log.warn(
"Skipping built-in skill slug={} because manifest version {} does not match package version {}",
item.slug(),
item.version(),
metadata.version()
);
return SyncOutcome.FAILED;
}
Optional<SyncOutcome> skipExisting = shouldSkipExisting(namespace.getId(), item, entries);
if (skipExisting.isPresent()) {
return skipExisting.get();
}
try {
skillPublishService.publishFromEntries(
GLOBAL_NAMESPACE,
entries,
SYSTEM_PUBLISHER_ID,
SkillVisibility.PUBLIC,
SYSTEM_PUBLISHER_ROLES,
CONFIRM_BUILTIN_PUBLISH_WARNINGS
);
log.info("Published built-in skill slug={} version={} to @{}",
item.slug(), item.version(), GLOBAL_NAMESPACE);
return SyncOutcome.PUBLISHED;
} catch (RuntimeException exception) {
if (isAlreadyPublishedWithSameFingerprint(namespace.getId(), item, entries)) {
log.info("Built-in skill slug={} version={} was published concurrently, skipping",
item.slug(), item.version());
return SyncOutcome.IDEMPOTENT_SKIPPED;
}
log.error("Failed to publish built-in skill slug={} version={}: {}",
item.slug(), item.version(), exception.getMessage(), exception);
return SyncOutcome.FAILED;
}
}
private Optional<URI> parsePackageUri(ManifestItem item) {
try {
return Optional.of(URI.create(item.url()));
} catch (IllegalArgumentException exception) {
log.warn("Skipping built-in skill slug={} version={} because URL is not allowed: {}",
item.slug(), item.version(), exception.getMessage());
return Optional.empty();
}
}
private SkillMetadata parseSkillMetadata(List<PackageEntry> entries) {
PackageEntry skillMd = entries.stream()
.filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH));
return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
}
private Optional<SyncOutcome> shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
if (hasOtherOwnerConflict(existingSkills)) {
log.warn("Skipping built-in skill slug={} before download because the slug already belongs to another user",
item.slug());
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
}
Optional<Skill> builtinSkill = existingSkills.stream()
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
.findFirst();
if (builtinSkill.isEmpty()) {
return Optional.empty();
}
Optional<SkillVersion> existingVersion = skillVersionRepository
.findBySkillIdAndVersion(builtinSkill.get().getId(), item.version());
if (existingVersion.isEmpty()) {
return Optional.empty();
}
SkillVersion version = existingVersion.get();
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
log.info("Skipping built-in skill slug={} version={} before download because it is already published",
item.slug(), item.version());
} else {
log.info("Skipping built-in skill slug={} version={} before download because existing version status is {}",
item.slug(), item.version(), version.getStatus());
}
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
}
private Optional<SyncOutcome> shouldSkipExisting(Long namespaceId, ManifestItem item, List<PackageEntry> entries) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
if (hasOtherOwnerConflict(existingSkills)) {
log.warn("Skipping built-in skill slug={} because the slug already belongs to another user",
item.slug());
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
}
Optional<Skill> builtinSkill = existingSkills.stream()
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
.findFirst();
if (builtinSkill.isEmpty()) {
return Optional.empty();
}
Optional<SkillVersion> existingVersion = skillVersionRepository
.findBySkillIdAndVersion(builtinSkill.get().getId(), item.version());
if (existingVersion.isEmpty()) {
return Optional.empty();
}
SkillVersion version = existingVersion.get();
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
log.info("Skipping built-in skill slug={} version={} because existing version status is {}",
item.slug(), item.version(), version.getStatus());
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
}
String packageFingerprint = computeFingerprint(entries);
String existingFingerprint = computeFingerprint(version);
if (packageFingerprint.equals(existingFingerprint)) {
log.info("Skipping built-in skill slug={} version={} because it is already published",
item.slug(), item.version());
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
} else {
log.warn(
"Skipping built-in skill slug={} version={} because published fingerprint differs: existing={}, package={}",
item.slug(),
item.version(),
existingFingerprint,
packageFingerprint
);
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
}
}
private boolean isAlreadyPublishedWithSameFingerprint(Long namespaceId, ManifestItem item, List<PackageEntry> entries) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
for (Skill skill : existingSkills) {
if (!SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) {
continue;
}
Optional<SkillVersion> version = skillVersionRepository
.findBySkillIdAndVersion(skill.getId(), item.version());
if (version.isPresent() && version.get().getStatus() == SkillVersionStatus.PUBLISHED) {
String packageFingerprint = computeFingerprint(entries);
String existingFingerprint = computeFingerprint(version.get());
if (packageFingerprint.equals(existingFingerprint)) {
return true;
}
log.warn(
"Built-in skill slug={} version={} was published concurrently with different content: existing={}, package={}",
item.slug(),
item.version(),
existingFingerprint,
packageFingerprint
);
return false;
}
}
return false;
}
private boolean hasOtherOwnerConflict(List<Skill> existingSkills) {
return existingSkills.stream()
.anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()));
}
private String computeFingerprint(SkillVersion version) {
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId()).stream()
.sorted(Comparator.comparing(SkillFile::getFilePath))
.toList();
return computeFingerprintFromFileDigests(files.stream()
.map(file -> new FileDigest(file.getFilePath(), file.getSha256()))
.toList());
}
private String computeFingerprint(List<PackageEntry> entries) {
return computeFingerprintFromFileDigests(entries.stream()
.map(entry -> new FileDigest(entry.path(), sha256(entry.content())))
.toList());
}
private String computeFingerprintFromFileDigests(List<FileDigest> files) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (FileDigest file : files.stream().sorted(Comparator.comparing(FileDigest::path)).toList()) {
String line = file.path() + ":" + file.sha256() + "\n";
digest.update(line.getBytes(StandardCharsets.UTF_8));
}
return "sha256:" + HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
throw new IllegalStateException("Failed to compute built-in skill fingerprint", exception);
}
}
private static String sha256(byte[] content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content));
} catch (Exception exception) {
throw new IllegalStateException("Failed to compute built-in skill file digest", exception);
}
}
private record FileDigest(String path, String sha256) {
}
private enum SyncOutcome {
PUBLISHED,
IDEMPOTENT_SKIPPED,
CONFLICT_SKIPPED,
FAILED
}
}

View file

@ -0,0 +1,108 @@
package com.iflytek.skillhub.bootstrap;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.namespace.SlugValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Component
public class BuiltinSkillManifestLoader {
static final String MANIFEST_LOCATION = "classpath:builtin-skills/manifest.json";
static final int MAX_ITEMS = 100;
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillManifestLoader.class);
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
public BuiltinSkillManifestLoader(ObjectMapper objectMapper, ResourceLoader resourceLoader) {
this.objectMapper = objectMapper;
this.resourceLoader = resourceLoader;
}
public List<ManifestItem> load() {
Resource resource = resourceLoader.getResource(MANIFEST_LOCATION);
if (!resource.exists()) {
log.warn("Built-in skill manifest not found at {}", MANIFEST_LOCATION);
return List.of();
}
JsonNode root;
try (InputStream inputStream = resource.getInputStream()) {
root = objectMapper.readTree(inputStream);
} catch (IOException | RuntimeException ex) {
log.warn("Failed to read built-in skill manifest at {}: {}", MANIFEST_LOCATION, ex.getMessage());
return List.of();
}
if (root == null || root.isNull()) {
log.warn("Built-in skill manifest at {} is empty", MANIFEST_LOCATION);
return List.of();
}
JsonNode skillsNode = root.path("skills");
if (!skillsNode.isArray()) {
log.warn("Built-in skill manifest at {} does not contain an array field 'skills'", MANIFEST_LOCATION);
return List.of();
}
List<ManifestItem> items = new ArrayList<>();
Set<String> seenSlugVersions = new HashSet<>();
int totalEntries = skillsNode.size();
if (totalEntries > MAX_ITEMS) {
log.warn("Built-in skill manifest has {} entries, only the first {} entries will be processed",
totalEntries, MAX_ITEMS);
}
int limit = Math.min(totalEntries, MAX_ITEMS);
for (int index = 0; index < limit; index++) {
JsonNode itemNode = skillsNode.get(index);
String slug = text(itemNode, "slug");
String version = text(itemNode, "version");
String url = text(itemNode, "url");
if (!StringUtils.hasText(slug) || !StringUtils.hasText(version) || !StringUtils.hasText(url)) {
log.warn("Skipping built-in skill manifest item {} because slug, version, and url are required", index);
continue;
}
try {
SlugValidator.validate(slug);
} catch (RuntimeException ex) {
log.warn("Skipping built-in skill manifest item {} because slug is invalid [slug={}]: {}",
index, slug, ex.getMessage());
continue;
}
String key = slug + "\n" + version;
if (!seenSlugVersions.add(key)) {
log.warn("Skipping duplicate built-in skill manifest item for slug={} version={}", slug, version);
continue;
}
items.add(new ManifestItem(slug, version, url));
}
return List.copyOf(items);
}
private static String text(JsonNode node, String fieldName) {
JsonNode value = node.get(fieldName);
if (value == null || !value.isTextual()) {
return "";
}
return value.asText().trim();
}
public record ManifestItem(String slug, String version, String url) {
}
}

View file

@ -0,0 +1,78 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
@Component
public class BuiltinSkillPackageExtractor {
private final SkillPackageArchiveExtractor archiveExtractor;
public BuiltinSkillPackageExtractor(SkillPackageArchiveExtractor archiveExtractor) {
this.archiveExtractor = archiveExtractor;
}
public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException {
SkillPackageArchiveExtractor.ExtractionResult result =
archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes));
if (!result.warnings().isEmpty()) {
throw new IllegalArgumentException("Built-in skill package has warnings: "
+ String.join("; ", result.warnings()));
}
boolean hasSkillMd = result.entries().stream()
.anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path()));
if (!hasSkillMd) {
throw new IllegalArgumentException("Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH);
}
return result;
}
private record ByteArrayMultipartFile(byte[] bytes) implements MultipartFile {
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return "builtin-skill.zip";
}
@Override
public String getContentType() {
return "application/zip";
}
@Override
public boolean isEmpty() {
return bytes.length == 0;
}
@Override
public long getSize() {
return bytes.length;
}
@Override
public byte[] getBytes() {
return bytes.clone();
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(bytes);
}
@Override
public void transferTo(java.io.File dest) throws IOException {
throw new UnsupportedOperationException("Built-in skill zip adapter is read-only");
}
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.bootstrap;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "skillhub.builtin-skills")
public class BuiltinSkillProperties {
private boolean enabled = true;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

View file

@ -0,0 +1,198 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.config.SkillPublishProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
@Component
public class BuiltinSkillRemotePackageDownloader {
static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
static final String ALLOWED_HOST = "bjcdn.openstorage.cn";
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillRemotePackageDownloader.class);
private static final Pattern IPV4_LITERAL = Pattern.compile("\\d{1,3}(\\.\\d{1,3}){3}");
private final long maxPackageSize;
private final HttpClient httpClient;
private final Duration requestTimeout;
@Autowired
public BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties) {
this(
properties,
HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build(),
REQUEST_TIMEOUT
);
}
BuiltinSkillRemotePackageDownloader(SkillPublishProperties properties, HttpClient httpClient) {
this(properties, httpClient, REQUEST_TIMEOUT);
}
BuiltinSkillRemotePackageDownloader(
SkillPublishProperties properties,
HttpClient httpClient,
Duration requestTimeout) {
this.maxPackageSize = properties.getMaxPackageSize();
this.httpClient = httpClient;
this.requestTimeout = requestTimeout;
}
public Optional<byte[]> download(URI uri) {
if (!isAllowedUrl(uri)) {
log.warn("Skipping built-in skill package download because URL is not allowed: {}", safeUrl(uri));
return Optional.empty();
}
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(requestTimeout)
.GET()
.build();
try {
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream body = response.body()) {
if (response.statusCode() != 200) {
log.warn("Failed to download built-in skill package from {}: HTTP {}",
safeUrl(uri),
response.statusCode());
return Optional.empty();
}
return readBoundedWithTimeout(body, uri);
}
} catch (IOException ex) {
log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage());
return Optional.empty();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
log.warn("Interrupted while downloading built-in skill package from {}", safeUrl(uri));
return Optional.empty();
} catch (RuntimeException ex) {
log.warn("Failed to download built-in skill package from {}: {}", safeUrl(uri), ex.getMessage());
return Optional.empty();
}
}
HttpClient httpClient() {
return httpClient;
}
static boolean isAllowedUrl(URI uri) {
if (uri == null || !"https".equalsIgnoreCase(uri.getScheme())) {
return false;
}
if (uri.getRawUserInfo() != null) {
return false;
}
int port = uri.getPort();
if (port != -1 && port != 443) {
return false;
}
String host = uri.getHost();
if (host == null) {
return false;
}
String normalizedHost = host.toLowerCase(Locale.ROOT);
if (isDisallowedHostLiteral(normalizedHost)) {
return false;
}
return normalizedHost.equals(ALLOWED_HOST) || normalizedHost.endsWith("." + ALLOWED_HOST);
}
private Optional<byte[]> readBounded(InputStream inputStream) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
long totalRead = 0;
int read;
while ((read = inputStream.read(buffer)) != -1) {
totalRead += read;
if (totalRead > maxPackageSize) {
log.warn("Built-in skill package download exceeded max package size: {} bytes (max: {})",
totalRead,
maxPackageSize);
return Optional.empty();
}
outputStream.write(buffer, 0, read);
}
return Optional.of(outputStream.toByteArray());
}
private Optional<byte[]> readBoundedWithTimeout(InputStream inputStream, URI uri) throws IOException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<Optional<byte[]>> future = executor.submit(() -> readBounded(inputStream));
try {
return future.get(Math.max(1, requestTimeout.toMillis()), TimeUnit.MILLISECONDS);
} catch (TimeoutException ex) {
closeQuietly(inputStream);
future.cancel(true);
log.warn("Timed out while downloading built-in skill package body from {} after {}",
safeUrl(uri),
requestTimeout);
return Optional.empty();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
closeQuietly(inputStream);
future.cancel(true);
log.warn("Interrupted while reading built-in skill package body from {}", safeUrl(uri));
return Optional.empty();
} catch (ExecutionException ex) {
Throwable cause = ex.getCause();
if (cause instanceof IOException ioException) {
throw ioException;
}
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new IllegalStateException("Failed to read built-in skill package body", cause);
} finally {
executor.shutdownNow();
}
}
private static void closeQuietly(InputStream inputStream) {
try {
inputStream.close();
} catch (IOException ignored) {
// Best-effort cleanup after timeout/interruption.
}
}
private static boolean isDisallowedHostLiteral(String host) {
return "localhost".equals(host)
|| IPV4_LITERAL.matcher(host).matches()
|| host.contains(":");
}
private static String safeUrl(URI uri) {
if (uri == null) {
return "<null>";
}
String host = uri.getHost();
String path = uri.getRawPath();
return (host == null ? "<unknown-host>" : host) + (path == null ? "" : path);
}
}

View file

@ -10,7 +10,7 @@ public class DownloadRateLimitProperties {
private String anonymousCookieName = "skillhub_anon_dl";
private Duration anonymousCookieMaxAge = Duration.ofDays(30);
private String anonymousCookieSecret = "change-me-in-production";
private String anonymousCookieSecret;
public String getAnonymousCookieName() {
return anonymousCookieName;

View file

@ -7,7 +7,7 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "skillhub.security.scanner")
public class SkillScannerProperties {
private boolean enabled = false;
private boolean enabled = true;
private String baseUrl = "http://localhost:8000";
private String healthPath = "/health";
private String scanPath = "/scan-upload";

View file

@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.AuthProviderResponse;
import com.iflytek.skillhub.dto.DirectLoginRequest;
import com.iflytek.skillhub.dto.SessionBootstrapRequest;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import com.iflytek.skillhub.service.AuthMethodCatalog;
import com.iflytek.skillhub.service.DirectAuthService;
import com.iflytek.skillhub.service.SessionBootstrapService;
@ -56,6 +57,7 @@ public class AuthController extends BaseApiController {
private final UserRoleBindingRepository userRoleBindingRepository;
private final PlatformSessionService platformSessionService;
private final UserAccountRepository userAccountRepository;
private final AuthMeResponseAssembler authMeResponseAssembler;
public AuthController(ApiResponseFactory responseFactory,
AuthMethodCatalog authMethodCatalog,
@ -64,7 +66,8 @@ public class AuthController extends BaseApiController {
AuthFailureThrottleService authFailureThrottleService,
UserRoleBindingRepository userRoleBindingRepository,
PlatformSessionService platformSessionService,
UserAccountRepository userAccountRepository) {
UserAccountRepository userAccountRepository,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.authMethodCatalog = authMethodCatalog;
this.sessionBootstrapService = sessionBootstrapService;
@ -73,6 +76,7 @@ public class AuthController extends BaseApiController {
this.userRoleBindingRepository = userRoleBindingRepository;
this.platformSessionService = platformSessionService;
this.userAccountRepository = userAccountRepository;
this.authMeResponseAssembler = authMeResponseAssembler;
}
/**
@ -111,7 +115,7 @@ public class AuthController extends BaseApiController {
freshRoles);
platformSessionService.establishSession(principal, request, false);
}
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}
/**
@ -146,7 +150,7 @@ public class AuthController extends BaseApiController {
HttpServletRequest httpRequest) {
return ok(
"response.success.read",
AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
authMeResponseAssembler.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
);
}
@ -178,7 +182,7 @@ public class AuthController extends BaseApiController {
authFailureThrottleService.resetIdentifier(category, request.username());
return ok(
"response.success.read",
AuthMeResponse.from(principal)
authMeResponseAssembler.from(principal)
);
}

View file

@ -17,6 +17,7 @@ import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
@ -38,19 +39,22 @@ public class LocalAuthController extends BaseApiController {
private final PlatformSessionService platformSessionService;
private final AuthFailureThrottleService authFailureThrottleService;
private final PasswordResetService passwordResetService;
private final AuthMeResponseAssembler authMeResponseAssembler;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics,
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService,
PasswordResetService passwordResetService) {
PasswordResetService passwordResetService,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
this.platformSessionService = platformSessionService;
this.authFailureThrottleService = authFailureThrottleService;
this.passwordResetService = passwordResetService;
this.authMeResponseAssembler = authMeResponseAssembler;
}
@PostMapping("/register")
@ -60,7 +64,7 @@ public class LocalAuthController extends BaseApiController {
PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email());
skillHubMetrics.incrementUserRegister();
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.created", AuthMeResponse.from(principal));
return ok("response.success.created", authMeResponseAssembler.from(principal));
}
@PostMapping("/login")
@ -84,7 +88,7 @@ public class LocalAuthController extends BaseApiController {
authFailureThrottleService.resetIdentifier("local", request.username());
skillHubMetrics.recordLocalLogin(true);
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}
@PostMapping("/change-password")

View file

@ -34,6 +34,8 @@ public class MeController extends BaseApiController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String filter,
@RequestParam(required = false) String q,
@RequestParam(required = false) String namespace,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
@ -41,7 +43,7 @@ public class MeController extends BaseApiController {
return ok(
"response.success.read",
mySkillAppService.listMySkills(principal.userId(), page, size, filter, principal.platformRoles())
mySkillAppService.listMySkills(principal.userId(), page, size, filter, q, namespace, principal.platformRoles())
);
}

View file

@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Namespace portal endpoints for discovery, membership management, and
@ -155,9 +156,13 @@ public class NamespaceController extends BaseApiController {
@GetMapping("/namespaces/{slug}/members")
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
Pageable pageable,
@RequestAttribute("userId") String userId) {
@RequestAttribute("userId") String userId,
@AuthenticationPrincipal PlatformPrincipal principal) {
Set<String> platformRoles = principal != null && principal.platformRoles() != null
? principal.platformRoles()
: Set.of();
return ok("response.success.read",
namespacePortalQueryAppService.listMembers(slug, pageable, userId));
namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles));
}
@GetMapping("/namespaces/{slug}/member-candidates")

View file

@ -16,6 +16,7 @@ import jakarta.validation.constraints.Min;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@ -78,7 +79,7 @@ public class NotificationController extends BaseApiController {
return ok("response.success.deleted", null);
}
@GetMapping("/sse")
@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter sse(@RequestAttribute("userId") String userId) {
return sseEmitterManager.register(userId);
}
@ -113,6 +114,9 @@ public class NotificationController extends BaseApiController {
if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) {
return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId);
}
if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) {
return new NotificationTarget("PROFILE_REVIEW", entityId, "/dashboard/reviews?type=profile");
}
if ("PROMOTION_SUBMITTED".equals(eventType)) {
return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions");
}

View file

@ -10,6 +10,8 @@ import com.iflytek.skillhub.dto.PromotionRequestDto;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
@ -79,11 +81,19 @@ public class PromotionController extends BaseApiController {
}
@GetMapping
public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions(@RequestParam(defaultValue = "PENDING") String status,
public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions(@Parameter(schema = @Schema(allowableValues = {"PENDING", "APPROVED", "REJECTED"}, defaultValue = "PENDING"))
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@Parameter(schema = @Schema(allowableValues = {"reviewedAt"}))
@RequestParam(required = false) String sortBy,
@Parameter(schema = @Schema(allowableValues = {"ASC", "DESC"}, defaultValue = "DESC"))
@RequestParam(required = false) String sortDirection,
@RequestAttribute("userId") String userId) {
return ok("response.success.read", governanceWorkflowAppService.listPromotions(status, page, size, userId));
return ok(
"response.success.read",
governanceWorkflowAppService.listPromotions(status, page, size, sortBy, sortDirection, userId)
);
}
@GetMapping("/pending")

View file

@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.config.SkillPublishProperties;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
@ -110,7 +111,7 @@ public class MultipartPackageExtractor {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Unsafe package path: " + path);
}
return path;
return SkillPackagePolicy.canonicalizeSkillMdPath(path);
}
private String determineContentType(String filename) {

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.support;
import com.iflytek.skillhub.config.SkillPublishProperties;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
@ -112,7 +113,7 @@ public class ZipPackageExtractor {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Unsafe package path: " + path);
}
return normalizedPath;
return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath);
} catch (InvalidPathException ex) {
throw new DomainBadRequestException("error.skill.publish.package.invalid",
"Invalid package path: " + path);

View file

@ -10,15 +10,17 @@ public record AuthMeResponse(
String email,
String avatarUrl,
String oauthProvider,
boolean canChangePassword,
Set<String> platformRoles
) {
public static AuthMeResponse from(PlatformPrincipal principal) {
public static AuthMeResponse from(PlatformPrincipal principal, boolean canChangePassword) {
return new AuthMeResponse(
principal.userId(),
principal.displayName(),
principal.email() != null ? principal.email() : "",
principal.avatarUrl() != null ? principal.avatarUrl() : "",
principal.oauthProvider(),
canChangePassword,
principal.platformRoles()
);
}

View file

@ -5,9 +5,15 @@ import java.time.Instant;
public record PromotionResponseDto(
Long id,
Long sourceSkillId,
String sourceSkillDisplayName,
String sourceSkillSummary,
String sourceNamespace,
String sourceSkillSlug,
String sourceVersion,
Integer sourceVersionFileCount,
Long sourceVersionTotalSize,
Long sourceSkillDownloadCount,
Integer sourceSkillStarCount,
String targetNamespace,
Long targetSkillId,
String status,

View file

@ -8,6 +8,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
@ -30,12 +32,20 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Set<String> SKIP_PREFIXES = Set.of(
"/actuator", "/favicon.ico", "/assets/"
);
private static final Set<String> SKIP_SUFFIXES = Set.of(
"/sse"
);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String uri = request.getRequestURI();
if (isNotificationSse(uri)) {
prepareSseResponse(response);
filterChain.doFilter(request, response);
return;
}
if (shouldSkip(uri)) {
filterChain.doFilter(request, response);
return;
@ -89,9 +99,24 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
return true;
}
}
for (String suffix : SKIP_SUFFIXES) {
if (uri.endsWith(suffix)) {
return true;
}
}
return false;
}
private boolean isNotificationSse(String uri) {
return uri != null && uri.endsWith("/notifications/sse");
}
private void prepareSseResponse(HttpServletResponse response) {
response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-transform");
response.setHeader("X-Accel-Buffering", "no");
}
private String getRequestBody(ContentCachingRequestWrapper request) {
byte[] buf = request.getContentAsByteArray();
if (buf.length > 0) {

View file

@ -19,6 +19,7 @@ import org.springframework.transaction.event.TransactionalEventListener;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Component
public class NotificationEventListener {
@ -53,7 +54,7 @@ public class NotificationEventListener {
@TransactionalEventListener
public void onSkillPublished(SkillPublishedEvent event) {
skillRepository.findById(event.skillId()).ifPresent(skill -> {
if (!event.publisherId().equals(skill.getCreatedBy())) {
if (!Objects.equals(event.publisherId(), skill.getOwnerId())) {
return;
}
String title = "Skill published: " + skillDisplayName(skill);
@ -127,6 +128,22 @@ public class NotificationEventListener {
});
}
@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) {
String title = "Profile review submitted";
Map<String, Object> body = new LinkedHashMap<>();
body.put("profileReviewId", event.profileReviewId());
body.put("submitterId", event.submitterId());
body.put("fields", event.fields());
String json = toJson(body);
List<String> admins = recipientResolver.resolvePlatformUserAdmins();
for (String admin : admins.stream().distinct().toList()) {
dispatcher.dispatch(admin, NotificationCategory.REVIEW,
"PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId());
}
}
@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onReviewApproved(ReviewApprovedEvent event) {

View file

@ -39,4 +39,14 @@ public class RecipientResolver {
List::copyOf
));
}
public List<String> resolvePlatformUserAdmins() {
return userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN"))
.stream()
.map(binding -> binding.getUserId())
.collect(java.util.stream.Collectors.collectingAndThen(
java.util.stream.Collectors.toCollection(LinkedHashSet::new),
List::copyOf
));
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.ratelimit;
import com.iflytek.skillhub.config.DownloadRateLimitProperties;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@ -11,6 +12,7 @@ import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Set;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.ResponseCookie;
@ -24,6 +26,12 @@ import org.springframework.stereotype.Component;
public class AnonymousDownloadIdentityService {
private static final String COOKIE_VERSION = "v1";
private static final int MIN_SECRET_LENGTH = 32;
private static final Set<String> DISALLOWED_SECRET_VALUES = Set.of(
"change-me-in-production",
"replace-me",
"replace-with-random-download-secret-32-bytes"
);
private static final SecureRandom RANDOM = new SecureRandom();
private final DownloadRateLimitProperties properties;
@ -35,6 +43,21 @@ public class AnonymousDownloadIdentityService {
this.clientIpResolver = clientIpResolver;
}
@PostConstruct
void validateAnonymousCookieSecret() {
String secret = properties.getAnonymousCookieSecret();
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required");
}
String trimmedSecret = secret.trim();
if (DISALLOWED_SECRET_VALUES.contains(trimmedSecret)) {
throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder");
}
if (trimmedSecret.length() < MIN_SECRET_LENGTH) {
throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters");
}
}
public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) {
String ip = clientIpResolver.resolve(request);
String cookieId = extractValidCookieId(request);

View file

@ -200,9 +200,15 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
return new PromotionResponseDto(
request.getId(),
request.getSourceSkillId(),
skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(),
skill.getSummary(),
sourceNamespace.getSlug(),
skill.getSlug(),
version.getVersion(),
version.getFileCount(),
version.getTotalSize(),
skill.getDownloadCount(),
skill.getStarCount(),
targetNamespace.getSlug(),
request.getTargetSkillId(),
request.getStatus().name(),

Some files were not shown because too many files have changed in this diff Show more