mirror of
https://github.com/openclaw/clawhub.git
synced 2026-07-22 03:35:29 -04:00
fix(cors): complete CORS + tokenized CLI reads
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
### Fixed
|
||||
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
|
||||
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
|
||||
- HTTP/CORS: add preflight handler + include CORS headers on API/download errors; CLI: include auth token for owner-visible installs/updates (#146) (thanks @Grenghis-Khan).
|
||||
|
||||
## 0.6.1 - 2026-02-13
|
||||
|
||||
|
||||
+19
-7
@@ -32,7 +32,7 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
if (!skillResult?.skill) {
|
||||
return new Response('Skill not found', {
|
||||
status: 404,
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,20 +41,32 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
if (mod?.isMalwareBlocked) {
|
||||
return new Response(
|
||||
'Blocked: this skill has been flagged as malicious by VirusTotal and cannot be downloaded.',
|
||||
{ status: 403 },
|
||||
{
|
||||
status: 403,
|
||||
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (mod?.isPendingScan) {
|
||||
return new Response(
|
||||
'This skill is pending a security scan by VirusTotal. Please try again in a few minutes.',
|
||||
{ status: 423 },
|
||||
{
|
||||
status: 423,
|
||||
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (mod?.isRemoved) {
|
||||
return new Response('This skill has been removed by a moderator.', { status: 410 })
|
||||
return new Response('This skill has been removed by a moderator.', {
|
||||
status: 410,
|
||||
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
|
||||
})
|
||||
}
|
||||
if (mod?.isHiddenByMod) {
|
||||
return new Response('This skill is currently unavailable.', { status: 403 })
|
||||
return new Response('This skill is currently unavailable.', {
|
||||
status: 403,
|
||||
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
|
||||
})
|
||||
}
|
||||
|
||||
const skill = skillResult.skill
|
||||
@@ -75,13 +87,13 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
if (!version) {
|
||||
return new Response('Version not found', {
|
||||
status: 404,
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
|
||||
})
|
||||
}
|
||||
if (version.softDeletedAt) {
|
||||
return new Response('Version not available', {
|
||||
status: 410,
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ function json(value: unknown, status = 200) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -254,6 +255,7 @@ function text(value: string, status: number) {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+20
-3
@@ -503,14 +503,31 @@ async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
export const publishSkillV1Http = httpAction(publishSkillV1Handler)
|
||||
|
||||
export const preflightHandler = httpAction(async (_ctx, _request) => {
|
||||
export const preflightHandler = httpAction(async (_ctx, request) => {
|
||||
const requestedHeaders =
|
||||
request.headers.get('access-control-request-headers')?.trim() ||
|
||||
request.headers.get('Access-Control-Request-Headers')?.trim() ||
|
||||
null
|
||||
const requestedMethod =
|
||||
request.headers.get('access-control-request-method')?.trim() ||
|
||||
request.headers.get('Access-Control-Request-Method')?.trim() ||
|
||||
null
|
||||
const vary = [
|
||||
...(requestedMethod ? ['Access-Control-Request-Method'] : []),
|
||||
...(requestedHeaders ? ['Access-Control-Request-Headers'] : []),
|
||||
].join(', ')
|
||||
|
||||
// No cookies/credentials supported; allow any origin for simple browser access.
|
||||
// If we ever add cookie auth, this must switch to reflecting origin + Allow-Credentials.
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Digest, X-Clawhub-Version',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD',
|
||||
'Access-Control-Allow-Headers':
|
||||
requestedHeaders ?? 'Content-Type, Authorization, Digest, X-Clawhub-Version',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
...(vary ? { Vary: vary } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ export async function applyRateLimit(
|
||||
{
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
headers,
|
||||
),
|
||||
|
||||
@@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}))
|
||||
|
||||
const mockReadGlobalConfig = vi.fn(async () => null)
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: () => mockReadGlobalConfig(),
|
||||
}))
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
} from '../../schema/index.js'
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError } from '../ui.js'
|
||||
@@ -31,12 +32,15 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
if (!trimmed) fail('Slug required')
|
||||
if (options.version && options.tag) fail('Use either --version or --tag')
|
||||
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token ?? undefined
|
||||
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner('Fetching skill')
|
||||
try {
|
||||
const skillResult = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
@@ -67,6 +71,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
|
||||
targetVersion,
|
||||
)}`,
|
||||
token,
|
||||
},
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
)
|
||||
@@ -80,7 +85,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
spinner.text = `Fetching versions (${limit})`
|
||||
versionsList = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
{ method: 'GET', url: url.toString(), token },
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
)
|
||||
}
|
||||
@@ -97,7 +102,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
url.searchParams.set('version', latestVersion)
|
||||
}
|
||||
spinner.text = `Fetching ${options.file}`
|
||||
fileContent = await fetchText(registry, { url: url.toString() })
|
||||
fileContent = await fetchText(registry, { url: url.toString(), token })
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
@@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}))
|
||||
|
||||
const mockReadGlobalConfig = vi.fn(async () => null)
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: () => mockReadGlobalConfig(),
|
||||
}))
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
@@ -50,7 +55,7 @@ vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
}))
|
||||
|
||||
const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
|
||||
const { clampLimit, cmdExplore, cmdInstall, cmdUpdate, formatExploreLine } = await import('./skills')
|
||||
const {
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
@@ -189,3 +194,29 @@ describe('cmdUpdate', () => {
|
||||
expect(args?.url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdInstall', () => {
|
||||
it('passes optional auth token to API + download requests', async () => {
|
||||
mockReadGlobalConfig.mockResolvedValue({ token: 'tkn' })
|
||||
mockApiRequest.mockResolvedValue({
|
||||
skill: { slug: 'demo', displayName: 'Demo', summary: null, tags: {}, stats: {}, createdAt: 0, updatedAt: 0 },
|
||||
latestVersion: { version: '1.0.0' },
|
||||
owner: null,
|
||||
moderation: null,
|
||||
})
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
|
||||
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} })
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(writeSkillOrigin).mockResolvedValue()
|
||||
vi.mocked(extractZipToDir).mockResolvedValue()
|
||||
vi.mocked(stat).mockRejectedValue(new Error('missing'))
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
|
||||
await cmdInstall(makeOpts(), 'demo')
|
||||
|
||||
const [, requestArgs] = mockApiRequest.mock.calls[0] ?? []
|
||||
expect(requestArgs?.token).toBe('tkn')
|
||||
const [, zipArgs] = mockDownloadZip.mock.calls[0] ?? []
|
||||
expect(zipArgs?.token).toBe('tkn')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,6 +148,9 @@ export async function cmdUpdate(
|
||||
if (options.version && !semver.valid(options.version)) fail('--version must be valid semver')
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token ?? undefined
|
||||
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const lock = await readLockfile(opts.workdir)
|
||||
const slugs = slug ? [slug] : Object.keys(lock.skills)
|
||||
@@ -165,7 +168,7 @@ export async function cmdUpdate(
|
||||
// Always fetch skill metadata to check moderation status
|
||||
const skillMeta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}`, token },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
@@ -206,7 +209,7 @@ export async function cmdUpdate(
|
||||
|
||||
let resolveResult: ResolveResult
|
||||
if (localFingerprint) {
|
||||
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint)
|
||||
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint, token)
|
||||
} else {
|
||||
resolveResult = { match: null, latestVersion: skillMeta.latestVersion ?? null }
|
||||
}
|
||||
@@ -259,7 +262,7 @@ export async function cmdUpdate(
|
||||
spinner.start(`Updating ${entry} -> ${targetVersion}`)
|
||||
}
|
||||
await rm(target, { recursive: true, force: true })
|
||||
const zip = await downloadZip(registry, { slug: entry, version: targetVersion })
|
||||
const zip = await downloadZip(registry, { slug: entry, version: targetVersion, token })
|
||||
await extractZipToDir(zip, target)
|
||||
|
||||
const existingOrigin = await readSkillOrigin(target)
|
||||
@@ -411,13 +414,13 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
|
||||
async function resolveSkillVersion(registry: string, slug: string, hash: string, token?: string) {
|
||||
const url = new URL(ApiRoutes.resolve, registry)
|
||||
url.searchParams.set('slug', slug)
|
||||
url.searchParams.set('hash', hash)
|
||||
return apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
{ method: 'GET', url: url.toString(), token },
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
|
||||
let done = 0
|
||||
const resolved = await mapWithConcurrency(locals, Math.min(concurrency, 16), async (skill) => {
|
||||
try {
|
||||
return await checkRegistrySyncState(registry, skill, resolveSupport)
|
||||
return await checkRegistrySyncState(registry, skill, resolveSupport, token)
|
||||
} finally {
|
||||
done += 1
|
||||
candidatesSpinner.text = `Checking registry sync state ${done}/${locals.length}`
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function checkRegistrySyncState(
|
||||
registry: string,
|
||||
skill: LocalSkill,
|
||||
resolveSupport: { value: boolean | null },
|
||||
token?: string,
|
||||
): Promise<Candidate> {
|
||||
if (resolveSupport.value !== false) {
|
||||
try {
|
||||
@@ -108,6 +109,7 @@ export async function checkRegistrySyncState(
|
||||
{
|
||||
method: 'GET',
|
||||
path: `${ApiRoutes.resolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
|
||||
token,
|
||||
},
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
)
|
||||
@@ -149,7 +151,7 @@ export async function checkRegistrySyncState(
|
||||
|
||||
const meta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}` },
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}`, token },
|
||||
ApiV1SkillResponseSchema,
|
||||
).catch(() => null)
|
||||
|
||||
@@ -163,7 +165,7 @@ export async function checkRegistrySyncState(
|
||||
}
|
||||
}
|
||||
|
||||
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion })
|
||||
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion, token })
|
||||
const remote = hashSkillZip(zip).fingerprint
|
||||
const matchVersion = remote === skill.fingerprint ? latestVersion : null
|
||||
|
||||
|
||||
@@ -105,11 +105,16 @@ describe('apiRequest', () => {
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const bytes = await downloadZip('https://example.com', { slug: 'demo', version: '1.0.0' })
|
||||
const bytes = await downloadZip('https://example.com', {
|
||||
slug: 'demo',
|
||||
version: '1.0.0',
|
||||
token: 'clh_token',
|
||||
})
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3])
|
||||
const [url] = fetchMock.mock.calls[0] as [string]
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('slug=demo')
|
||||
expect(url).toContain('version=1.0.0')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer clh_token')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user