mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
6ae97c020f
commit
b305e798e7
@ -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 () => {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user