feat: wire skill market install api

Route marketplace install requests through server-side detail lookup, eligibility checks, trusted package URL validation, bounded zip downloads, and the local user skill installer.

Reject client-supplied targets and arbitrary package URLs so the desktop cannot direct writes outside the computed user skill directory.

Tested: bun test src/server/__tests__/skill-market.test.ts src/server/__tests__/skill-market-install.test.ts

Tested: cd desktop && bun run test -- SkillCenter.test.tsx skillMarketStore.test.ts

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run build

Not-tested: bun run check:server is blocked by missing @whiskeysockets/baileys in adapters/whatsapp/session.ts in this checkout.

Scope-risk: moderate

Confidence: high
This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 19:52:17 +08:00
parent 6ae97c020f
commit b305e798e7
2 changed files with 385 additions and 9 deletions

View File

@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { zipSync } from 'fflate'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
@ -11,6 +12,7 @@ import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMar
import { analyzeSkillRisk } from '../services/skillMarket/risk.js'
import { createSkillMarketService } from '../services/skillMarket/service.js'
import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js'
import type { SkillMarketDetail } from '../services/skillMarket/types.js'
import {
CLAWHUB_SCAN_RESPONSE,
CLAWHUB_TOP_SKILLS_RESPONSE,
@ -18,6 +20,8 @@ import {
SKILLHUB_TOP_SKILLS_RESPONSE,
} from './fixtures/skill-market.js'
const encoder = new TextEncoder()
describe('skill market fixtures', () => {
it('keeps representative ClawHub fixture shape stable', () => {
expect(CLAWHUB_TOP_SKILLS_RESPONSE.items[0]).toMatchObject({
@ -821,8 +825,46 @@ describe('skill market risk analysis', () => {
})
describe('skill market API', () => {
const originalFetch = globalThis.fetch
function makeApiDetail(
overrides: Partial<SkillMarketDetail> & Record<string, unknown> = {},
): SkillMarketDetail & Record<string, unknown> {
const source = overrides.source === 'skillhub' ? 'skillhub' : 'clawhub'
const slug = typeof overrides.slug === 'string' ? overrides.slug : 'skill-vetter'
return {
source,
sourceMode: source === 'clawhub' ? 'primary' : 'fallback',
slug,
displayName: 'Skill Vetter',
summary: 'Reviews skill packages before install.',
canonicalUrl: source === 'clawhub'
? `https://clawhub.ai/${slug}`
: `https://skillhub.cn/skills/${slug}`,
trustState: source === 'clawhub' ? 'clean' : 'benign',
installed: false,
files: [],
riskLabels: [],
installEligibility: { status: 'installable' },
...overrides,
} as SkillMarketDetail & Record<string, unknown>
}
function setInstallDetail(detail: SkillMarketDetail & Record<string, unknown> | null): void {
setSkillMarketServiceFactoryForTests(() => ({
list: async () => {
throw new Error('list should not be called by install route')
},
listSkills: async () => {
throw new Error('listSkills should not be called by install route')
},
getDetail: async () => detail,
}))
}
afterEach(() => {
resetSkillMarketServiceFactoryForTests()
globalThis.fetch = originalFetch
})
it('rejects unsupported methods', async () => {
@ -847,7 +889,70 @@ describe('skill market API', () => {
await expect(res.json()).resolves.toMatchObject({ error: 'target_path_not_allowed' })
})
it('returns install skeleton response for safe install requests', async () => {
it('rejects install requests with arbitrary package URLs', async () => {
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({
source: 'clawhub',
slug: 'skill-vetter',
downloadUrl: 'https://clawhub.ai/packages/skill-vetter.zip',
}),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({
error: 'unsupported_install_field',
message: 'Unsupported install request field: downloadUrl',
})
})
it('rejects unsupported install sources before detail lookup', async () => {
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'auto', slug: 'skill-vetter' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: 'unsupported_source' })
})
it('rejects empty install slugs before detail lookup', async () => {
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'clawhub', slug: ' ' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: 'invalid_slug' })
})
it('returns 404 when install detail lookup misses the requested skill', async () => {
setInstallDetail(null)
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'skillhub', slug: 'not-found' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(404)
await expect(res.json()).resolves.toMatchObject({ error: 'not_found' })
})
it('blocks install requests when marketplace eligibility is blocked', async () => {
setInstallDetail(makeApiDetail({
installEligibility: { status: 'blocked', reason: 'Full package safety scan is required before install.' },
}))
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
@ -856,8 +961,85 @@ describe('skill market API', () => {
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(501)
await expect(res.json()).resolves.toMatchObject({ error: 'install_not_wired' })
expect(res.status).toBe(409)
await expect(res.json()).resolves.toMatchObject({
error: 'install_blocked',
installEligibility: {
status: 'blocked',
reason: 'Full package safety scan is required before install.',
},
})
})
it('does not install from canonical or upstream URLs when package metadata is missing', async () => {
const fetchCalls: string[] = []
setInstallDetail(makeApiDetail({
canonicalUrl: 'https://clawhub.ai/packages/skill-vetter.zip',
upstreamUrl: 'https://clawhub.ai/upstream/skill-vetter.zip',
}))
globalThis.fetch = async (input) => {
fetchCalls.push(String(input))
return Response.json({})
}
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'clawhub', slug: 'skill-vetter' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(422)
expect(fetchCalls).toEqual([])
await expect(res.json()).resolves.toMatchObject({ error: 'install_not_available' })
})
it('installs an installable marketplace package into the user skill directory', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-market-api-install-'))
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const packageZip = Buffer.from(zipSync({
'skill-vetter/SKILL.md': encoder.encode('---\ndescription: Safe skill\n---\n# Skill Vetter'),
}))
const fetchCalls: string[] = []
try {
process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, '.claude')
setInstallDetail(makeApiDetail({
version: '1.0.0',
downloadUrl: 'https://clawhub.ai/packages/skill-vetter.zip',
}))
globalThis.fetch = async (input) => {
fetchCalls.push(String(input))
return new Response(packageZip, {
headers: { 'content-length': String(packageZip.byteLength) },
})
}
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'clawhub', slug: 'skill-vetter', version: '1.0.0' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(200)
expect(fetchCalls).toEqual(['https://clawhub.ai/packages/skill-vetter.zip'])
await expect(res.json()).resolves.toEqual({
installed: true,
skillName: 'skill-vetter',
targetPath: path.join(tmpDir, '.claude', 'skills', 'skill-vetter'),
})
await expect(
fs.readFile(path.join(tmpDir, '.claude', 'skills', 'skill-vetter', 'SKILL.md'), 'utf-8'),
).resolves.toContain('# Skill Vetter')
} finally {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
await fs.rm(tmpDir, { recursive: true, force: true })
}
})
it('rejects invalid install JSON', async () => {

View File

@ -4,15 +4,34 @@ import {
type SkillMarketService,
type SkillMarketServiceOptions,
} from '../services/skillMarket/service.js'
import type { SkillMarketSource } from '../services/skillMarket/types.js'
import { installUserSkillFromZipBytes } from '../services/skillMarket/installer.js'
import type { SkillMarketDetail, SkillMarketSource } from '../services/skillMarket/types.js'
import { collectUserSkillNames } from './skills.js'
type SkillMarketServiceFactory = (options: SkillMarketServiceOptions) => SkillMarketService
type SkillMarketInstallRequest = {
source: SkillMarketSource
slug: string
version?: string
}
const SUPPORTED_SOURCES = new Set(['auto', 'clawhub', 'skillhub'])
const SUPPORTED_DETAIL_SOURCES = new Set(['clawhub', 'skillhub'])
const SUPPORTED_SORTS = new Set(['downloads', 'installs', 'stars', 'updated', 'trending'])
const MAX_LIMIT = 100
const MAX_PACKAGE_BYTES = 50 * 1024 * 1024
const INSTALL_REQUEST_KEYS = new Set(['source', 'slug', 'version'])
const PACKAGE_URL_FIELDS = [
'downloadUrl',
'downloadURL',
'download_url',
'packageUrl',
'package_url',
'archiveUrl',
'archive_url',
'zipUrl',
'zip_url',
] as const
let skillMarketServiceFactory: SkillMarketServiceFactory = createSkillMarketService
@ -48,7 +67,7 @@ export async function handleSkillMarketApi(
if (req.method !== 'POST') {
return jsonError('method_not_allowed', 'Method not allowed for skill market install.', 405)
}
return handleInstallSkeleton(req)
return handleInstall(req)
}
if (action !== undefined) {
@ -141,7 +160,7 @@ function decodeSkillSlug(segment: string | undefined): string | null {
}
}
async function handleInstallSkeleton(req: Request): Promise<Response> {
async function handleInstall(req: Request): Promise<Response> {
const body = await parseJsonObject(req)
if (!body) {
return jsonError('invalid_json', 'Request body must be a JSON object.', 400)
@ -151,7 +170,178 @@ async function handleInstallSkeleton(req: Request): Promise<Response> {
return jsonError('target_path_not_allowed', 'Install target is computed by the server.', 400)
}
return jsonError('install_not_wired', 'Skill market install is not wired yet.', 501)
const installRequest = parseInstallRequest(body)
if (installRequest instanceof Response) {
return installRequest
}
const service = skillMarketServiceFactory({
installedSkillNames: collectUserSkillNames,
})
const detail = await service.getDetail({
source: installRequest.source,
slug: installRequest.slug,
})
if (!detail) {
return jsonError('not_found', 'Skill market skill not found.', 404)
}
if (installRequest.version !== undefined && detail.version !== undefined && detail.version !== installRequest.version) {
return jsonError('version_mismatch', 'Requested skill version does not match marketplace detail.', 409, {
detail,
})
}
const eligibility = detail.installEligibility
if (eligibility.status === 'installed') {
return jsonError('skill_already_installed', 'Skill is already installed.', 409, {
installEligibility: eligibility,
detail,
})
}
if (eligibility.status === 'conflict') {
return jsonError('install_conflict', 'Skill install target already exists.', 409, {
installEligibility: eligibility,
detail,
})
}
if (eligibility.status === 'blocked') {
return jsonError('install_blocked', eligibility.reason, 409, {
installEligibility: eligibility,
detail,
})
}
const packageUrl = resolvePackageDownloadUrl(detail)
if (!packageUrl) {
return jsonError(
'install_not_available',
'Marketplace detail does not provide a safe downloadable package for this skill.',
422,
{ detail },
)
}
let zipBytes: Buffer
try {
zipBytes = await downloadPackageZip(packageUrl)
} catch (error) {
return jsonError('install_download_failed', errorMessage(error), 502, { detail })
}
try {
const result = await installUserSkillFromZipBytes({
skillName: detail.slug,
zipBytes,
})
return Response.json(result)
} catch (error) {
if (isAlreadyExistsError(error)) {
return jsonError('install_conflict', errorMessage(error), 409, { detail })
}
return jsonError('install_failed', errorMessage(error), 422, { detail })
}
}
function parseInstallRequest(body: Record<string, unknown>): SkillMarketInstallRequest | Response {
for (const key of Object.keys(body)) {
if (!INSTALL_REQUEST_KEYS.has(key)) {
return jsonError('unsupported_install_field', `Unsupported install request field: ${key}`, 400)
}
}
const source = body.source
if (source !== 'clawhub' && source !== 'skillhub') {
return jsonError('unsupported_source', 'Install source must be clawhub or skillhub.', 400)
}
const slug = typeof body.slug === 'string' ? body.slug.trim() : ''
if (!slug) {
return jsonError('invalid_slug', 'Install slug must be a non-empty string.', 400)
}
const version = body.version
if (version === undefined) {
return { source, slug }
}
if (typeof version !== 'string' || !version.trim()) {
return jsonError('invalid_version', 'Install version must be a non-empty string when provided.', 400)
}
return { source, slug, version: version.trim() }
}
function resolvePackageDownloadUrl(detail: SkillMarketDetail): URL | null {
const record = detail as SkillMarketDetail & Record<string, unknown>
const sourceHosts = allowedPackageHosts(detail.source)
for (const field of PACKAGE_URL_FIELDS) {
const url = parseSafePackageUrl(record[field], sourceHosts)
if (url) {
return url
}
}
return null
}
function parseSafePackageUrl(value: unknown, allowedHosts: string[]): URL | null {
if (typeof value !== 'string' || !value.trim()) {
return null
}
let url: URL
try {
url = new URL(value)
} catch {
return null
}
if (
url.protocol !== 'https:'
|| url.username
|| url.password
|| !isAllowedHost(url.hostname, allowedHosts)
) {
return null
}
return url
}
function allowedPackageHosts(source: SkillMarketSource): string[] {
return source === 'clawhub' ? ['clawhub.ai'] : ['skillhub.cn']
}
function isAllowedHost(hostname: string, allowedHosts: string[]): boolean {
const normalized = hostname.toLowerCase()
return allowedHosts.some((allowedHost) => normalized === allowedHost || normalized.endsWith(`.${allowedHost}`))
}
async function downloadPackageZip(url: URL): Promise<Buffer> {
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Package download failed with HTTP ${res.status}`)
}
const contentLength = res.headers.get('content-length')
if (contentLength !== null) {
const parsedLength = Number(contentLength)
if (!Number.isFinite(parsedLength) || parsedLength > MAX_PACKAGE_BYTES) {
throw new Error('Package download is too large')
}
}
const buffer = Buffer.from(await res.arrayBuffer())
if (buffer.byteLength > MAX_PACKAGE_BYTES) {
throw new Error('Package download is too large')
}
return buffer
}
function isAlreadyExistsError(error: unknown): boolean {
return errorMessage(error).includes('already exists')
}
async function parseJsonObject(req: Request): Promise<Record<string, unknown> | null> {
@ -166,6 +356,10 @@ async function parseJsonObject(req: Request): Promise<Record<string, unknown> |
}
}
function jsonError(error: string, message: string, status: number): Response {
return Response.json({ error, message }, { status })
function jsonError(error: string, message: string, status: number, extra: Record<string, unknown> = {}): Response {
return Response.json({ error, message, ...extra }, { status })
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}