From b305e798e7482a147d22778958443842ed524ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 3 Jul 2026 19:52:17 +0800 Subject: [PATCH] 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 --- src/server/__tests__/skill-market.test.ts | 188 +++++++++++++++++++- src/server/api/skill-market.ts | 206 +++++++++++++++++++++- 2 files changed, 385 insertions(+), 9 deletions(-) diff --git a/src/server/__tests__/skill-market.test.ts b/src/server/__tests__/skill-market.test.ts index 310356c2..6f23d4de 100644 --- a/src/server/__tests__/skill-market.test.ts +++ b/src/server/__tests__/skill-market.test.ts @@ -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 & Record = {}, + ): SkillMarketDetail & Record { + 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 + } + + function setInstallDetail(detail: SkillMarketDetail & Record | 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 () => { diff --git a/src/server/api/skill-market.ts b/src/server/api/skill-market.ts index b67d3eea..3e3befc6 100644 --- a/src/server/api/skill-market.ts +++ b/src/server/api/skill-market.ts @@ -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 { +async function handleInstall(req: Request): Promise { 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 { 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): 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 + 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 { + 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 | null> { @@ -166,6 +356,10 @@ async function parseJsonObject(req: Request): Promise | } } -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 = {}): Response { + return Response.json({ error, message, ...extra }, { status }) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) }