Spaces:
Sleeping
security(csp): remove unsafe-inline with nonce-based CSP (#288)
Browse files* fix: route coordinator sends to live sessions and parse boxed doctor output
* feat: make coordinator routing user-configurable and deployment-agnostic
* feat: add coordinator target dropdown in settings
* feat(settings): preview live coordinator routing resolution
* security(csp): remove unsafe-inline via per-request nonce policy
* fix(ui): disable new organization CTA; improve skills registry and panel defaults
- disable non-functional New organization button in nav rail
- gate Hermes Memory visibility on actual Hermes CLI binary detection
- optimize agents task stats API by replacing N+1 with grouped query
- make task board render primary data first and hydrate quality-review async
- default skills panel registry source to awesome-openclaw
- add resilient registry search fallbacks for ClawdHub/skills.sh endpoint variants
- README.md +1 -1
- docs/SECURITY-HARDENING.md +1 -1
- next.config.js +1 -14
- src/app/api/agents/route.ts +45 -18
- src/components/layout/nav-rail.tsx +4 -3
- src/components/panels/skills-panel.tsx +1 -1
- src/components/panels/task-board-panel.tsx +17 -16
- src/lib/hermes-sessions.ts +25 -2
- src/lib/skill-registry.ts +76 -43
- src/proxy.ts +37 -15
|
@@ -130,7 +130,7 @@ bash scripts/security-audit.sh
|
|
| 130 |
|
| 131 |
### Known Limitations
|
| 132 |
|
| 133 |
-
-
|
| 134 |
|
| 135 |
### Security Considerations
|
| 136 |
|
|
|
|
| 130 |
|
| 131 |
### Known Limitations
|
| 132 |
|
| 133 |
+
- No major security limitations currently tracked here for CSP; policy now uses per-request nonces (no `unsafe-inline` / `unsafe-eval`).
|
| 134 |
|
| 135 |
### Security Considerations
|
| 136 |
|
|
@@ -105,7 +105,7 @@ Mission Control sets these headers automatically:
|
|
| 105 |
|
| 106 |
| Header | Value |
|
| 107 |
|--------|-------|
|
| 108 |
-
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' '
|
| 109 |
| `X-Frame-Options` | `DENY` |
|
| 110 |
| `X-Content-Type-Options` | `nosniff` |
|
| 111 |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
|
|
|
| 105 |
|
| 106 |
| Header | Value |
|
| 107 |
|--------|-------|
|
| 108 |
+
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-<per-request>' 'strict-dynamic'; style-src 'self' 'nonce-<per-request>'` |
|
| 109 |
| `X-Frame-Options` | `DENY` |
|
| 110 |
| `X-Content-Type-Options` | `nosniff` |
|
| 111 |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
|
@@ -9,20 +9,8 @@ const nextConfig = {
|
|
| 9 |
transpilePackages: ['react-markdown', 'remark-gfm'],
|
| 10 |
|
| 11 |
// Security headers
|
|
|
|
| 12 |
async headers() {
|
| 13 |
-
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
|
| 14 |
-
|
| 15 |
-
const csp = [
|
| 16 |
-
`default-src 'self'`,
|
| 17 |
-
`script-src 'self' 'unsafe-inline' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
|
| 18 |
-
`style-src 'self' 'unsafe-inline'`,
|
| 19 |
-
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
|
| 20 |
-
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
|
| 21 |
-
`font-src 'self' data:`,
|
| 22 |
-
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
|
| 23 |
-
`worker-src 'self' blob:`,
|
| 24 |
-
].join('; ')
|
| 25 |
-
|
| 26 |
return [
|
| 27 |
{
|
| 28 |
source: '/:path*',
|
|
@@ -30,7 +18,6 @@ const nextConfig = {
|
|
| 30 |
{ key: 'X-Frame-Options', value: 'DENY' },
|
| 31 |
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
| 32 |
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
| 33 |
-
{ key: 'Content-Security-Policy', value: csp },
|
| 34 |
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
| 35 |
...(process.env.MC_ENABLE_HSTS === '1' ? [
|
| 36 |
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }
|
|
|
|
| 9 |
transpilePackages: ['react-markdown', 'remark-gfm'],
|
| 10 |
|
| 11 |
// Security headers
|
| 12 |
+
// Content-Security-Policy is set in src/proxy.ts with a per-request nonce.
|
| 13 |
async headers() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
return [
|
| 15 |
{
|
| 16 |
source: '/:path*',
|
|
|
|
| 18 |
{ key: 'X-Frame-Options', value: 'DENY' },
|
| 19 |
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
| 20 |
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
|
|
| 21 |
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
| 22 |
...(process.env.MC_ENABLE_HSTS === '1' ? [
|
| 23 |
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }
|
|
@@ -58,30 +58,57 @@ export async function GET(request: NextRequest) {
|
|
| 58 |
config: enrichAgentConfigFromWorkspace(agent.config ? JSON.parse(agent.config) : {})
|
| 59 |
}));
|
| 60 |
|
| 61 |
-
// Get task counts for
|
| 62 |
-
const
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
const agentsWithStats = agentsWithParsedData.map(agent => {
|
| 74 |
-
const taskStats =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
return {
|
| 77 |
...agent,
|
| 78 |
taskStats: {
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
in_progress: taskStats.in_progress || 0,
|
| 82 |
-
quality_review: taskStats.quality_review || 0,
|
| 83 |
-
done: taskStats.done || 0,
|
| 84 |
-
completed: taskStats.done || 0
|
| 85 |
}
|
| 86 |
};
|
| 87 |
});
|
|
|
|
| 58 |
config: enrichAgentConfigFromWorkspace(agent.config ? JSON.parse(agent.config) : {})
|
| 59 |
}));
|
| 60 |
|
| 61 |
+
// Get task counts for all listed agents in one query (avoids N+1 queries)
|
| 62 |
+
const agentNames = agentsWithParsedData.map(agent => agent.name).filter(Boolean)
|
| 63 |
+
const taskStatsByAgent = new Map<string, { total: number; assigned: number; in_progress: number; quality_review: number; done: number }>()
|
| 64 |
+
|
| 65 |
+
if (agentNames.length > 0) {
|
| 66 |
+
const placeholders = agentNames.map(() => '?').join(', ')
|
| 67 |
+
const groupedTaskStats = db.prepare(`
|
| 68 |
+
SELECT
|
| 69 |
+
assigned_to,
|
| 70 |
+
COUNT(*) as total,
|
| 71 |
+
SUM(CASE WHEN status = 'assigned' THEN 1 ELSE 0 END) as assigned,
|
| 72 |
+
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
| 73 |
+
SUM(CASE WHEN status = 'quality_review' THEN 1 ELSE 0 END) as quality_review,
|
| 74 |
+
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done
|
| 75 |
+
FROM tasks
|
| 76 |
+
WHERE workspace_id = ? AND assigned_to IN (${placeholders})
|
| 77 |
+
GROUP BY assigned_to
|
| 78 |
+
`).all(workspaceId, ...agentNames) as Array<{
|
| 79 |
+
assigned_to: string
|
| 80 |
+
total: number | null
|
| 81 |
+
assigned: number | null
|
| 82 |
+
in_progress: number | null
|
| 83 |
+
quality_review: number | null
|
| 84 |
+
done: number | null
|
| 85 |
+
}>
|
| 86 |
+
|
| 87 |
+
for (const row of groupedTaskStats) {
|
| 88 |
+
taskStatsByAgent.set(row.assigned_to, {
|
| 89 |
+
total: row.total || 0,
|
| 90 |
+
assigned: row.assigned || 0,
|
| 91 |
+
in_progress: row.in_progress || 0,
|
| 92 |
+
quality_review: row.quality_review || 0,
|
| 93 |
+
done: row.done || 0,
|
| 94 |
+
})
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
|
| 98 |
const agentsWithStats = agentsWithParsedData.map(agent => {
|
| 99 |
+
const taskStats = taskStatsByAgent.get(agent.name) || {
|
| 100 |
+
total: 0,
|
| 101 |
+
assigned: 0,
|
| 102 |
+
in_progress: 0,
|
| 103 |
+
quality_review: 0,
|
| 104 |
+
done: 0,
|
| 105 |
+
}
|
| 106 |
|
| 107 |
return {
|
| 108 |
...agent,
|
| 109 |
taskStats: {
|
| 110 |
+
...taskStats,
|
| 111 |
+
completed: taskStats.done,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
};
|
| 114 |
});
|
|
@@ -1004,10 +1004,11 @@ function ContextSwitcher({ currentUser, isAdmin, isLocal, isConnected, tenants,
|
|
| 1004 |
{!createMode ? (
|
| 1005 |
<Button
|
| 1006 |
variant="ghost"
|
| 1007 |
-
|
| 1008 |
-
|
|
|
|
| 1009 |
>
|
| 1010 |
-
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground/
|
| 1011 |
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="w-3.5 h-3.5">
|
| 1012 |
<path d="M8 3v10M3 8h10" />
|
| 1013 |
</svg>
|
|
|
|
| 1004 |
{!createMode ? (
|
| 1005 |
<Button
|
| 1006 |
variant="ghost"
|
| 1007 |
+
disabled
|
| 1008 |
+
title="Temporarily disabled — not functional yet"
|
| 1009 |
+
className="w-full flex items-center gap-2 px-2 py-1.5 h-auto rounded-md text-xs justify-start text-muted-foreground/40 cursor-not-allowed"
|
| 1010 |
>
|
| 1011 |
+
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground/40">
|
| 1012 |
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="w-3.5 h-3.5">
|
| 1013 |
<path d="M8 3v10M3 8h10" />
|
| 1014 |
</svg>
|
|
@@ -74,7 +74,7 @@ export function SkillsPanel() {
|
|
| 74 |
const [createError, setCreateError] = useState<string | null>(null)
|
| 75 |
const [isMounted, setIsMounted] = useState(false)
|
| 76 |
const [activeTab, setActiveTab] = useState<PanelTab>('installed')
|
| 77 |
-
const [registrySource, setRegistrySource] = useState<'clawhub' | 'skills-sh' | 'awesome-openclaw'>('
|
| 78 |
const [registryQuery, setRegistryQuery] = useState('')
|
| 79 |
const [registryResults, setRegistryResults] = useState<RegistrySkill[]>([])
|
| 80 |
const [registryLoading, setRegistryLoading] = useState(false)
|
|
|
|
| 74 |
const [createError, setCreateError] = useState<string | null>(null)
|
| 75 |
const [isMounted, setIsMounted] = useState(false)
|
| 76 |
const [activeTab, setActiveTab] = useState<PanelTab>('installed')
|
| 77 |
+
const [registrySource, setRegistrySource] = useState<'clawhub' | 'skills-sh' | 'awesome-openclaw'>('awesome-openclaw')
|
| 78 |
const [registryQuery, setRegistryQuery] = useState('')
|
| 79 |
const [registryResults, setRegistryResults] = useState<RegistrySkill[]>([])
|
| 80 |
const [registryLoading, setRegistryLoading] = useState(false)
|
|
@@ -351,29 +351,30 @@ export function TaskBoardPanel() {
|
|
| 351 |
const tasksList = tasksData.tasks || []
|
| 352 |
const taskIds = tasksList.map((task: Task) => task.id)
|
| 353 |
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
if (taskIds.length > 0) {
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
const
|
| 360 |
-
const
|
| 361 |
-
newAegisMap = Object.fromEntries(
|
| 362 |
Object.entries(latest).map(([id, row]: [string, any]) => [
|
| 363 |
Number(id),
|
| 364 |
row?.reviewer === 'aegis' && row?.status === 'approved'
|
| 365 |
])
|
| 366 |
)
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
| 371 |
}
|
| 372 |
-
|
| 373 |
-
storeSetTasks(tasksList)
|
| 374 |
-
setAegisMap(newAegisMap)
|
| 375 |
-
setAgents(agentsData.agents || [])
|
| 376 |
-
setProjects(projectsData.projects || [])
|
| 377 |
} catch (err) {
|
| 378 |
setError(err instanceof Error ? err.message : 'An error occurred')
|
| 379 |
} finally {
|
|
|
|
| 351 |
const tasksList = tasksData.tasks || []
|
| 352 |
const taskIds = tasksList.map((task: Task) => task.id)
|
| 353 |
|
| 354 |
+
// Render primary board data first; hydrate Aegis approvals in background.
|
| 355 |
+
storeSetTasks(tasksList)
|
| 356 |
+
setAgents(agentsData.agents || [])
|
| 357 |
+
setProjects(projectsData.projects || [])
|
| 358 |
+
|
| 359 |
if (taskIds.length > 0) {
|
| 360 |
+
fetch(`/api/quality-review?taskIds=${taskIds.join(',')}`)
|
| 361 |
+
.then((reviewResponse) => reviewResponse.ok ? reviewResponse.json() : null)
|
| 362 |
+
.then((reviewData) => {
|
| 363 |
+
const latest = reviewData?.latest || {}
|
| 364 |
+
const newAegisMap: Record<number, boolean> = Object.fromEntries(
|
|
|
|
| 365 |
Object.entries(latest).map(([id, row]: [string, any]) => [
|
| 366 |
Number(id),
|
| 367 |
row?.reviewer === 'aegis' && row?.status === 'approved'
|
| 368 |
])
|
| 369 |
)
|
| 370 |
+
setAegisMap(newAegisMap)
|
| 371 |
+
})
|
| 372 |
+
.catch(() => {
|
| 373 |
+
setAegisMap({})
|
| 374 |
+
})
|
| 375 |
+
} else {
|
| 376 |
+
setAegisMap({})
|
| 377 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
} catch (err) {
|
| 379 |
setError(err instanceof Error ? err.message : 'An error occurred')
|
| 380 |
} finally {
|
|
@@ -7,6 +7,7 @@
|
|
| 7 |
|
| 8 |
import { existsSync, readFileSync } from 'node:fs'
|
| 9 |
import { join } from 'node:path'
|
|
|
|
| 10 |
import Database from 'better-sqlite3'
|
| 11 |
import { config } from './config'
|
| 12 |
import { logger } from './logger'
|
|
@@ -50,9 +51,31 @@ function getHermesPidPath(): string {
|
|
| 50 |
return join(config.homeDir, '.hermes', 'gateway.pid')
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
export function isHermesInstalled(): boolean {
|
| 54 |
-
//
|
| 55 |
-
return
|
| 56 |
}
|
| 57 |
|
| 58 |
export function isHermesGatewayRunning(): boolean {
|
|
|
|
| 7 |
|
| 8 |
import { existsSync, readFileSync } from 'node:fs'
|
| 9 |
import { join } from 'node:path'
|
| 10 |
+
import { spawnSync } from 'node:child_process'
|
| 11 |
import Database from 'better-sqlite3'
|
| 12 |
import { config } from './config'
|
| 13 |
import { logger } from './logger'
|
|
|
|
| 51 |
return join(config.homeDir, '.hermes', 'gateway.pid')
|
| 52 |
}
|
| 53 |
|
| 54 |
+
let hermesBinaryCache: { checkedAt: number; installed: boolean } | null = null
|
| 55 |
+
|
| 56 |
+
function hasHermesCliBinary(): boolean {
|
| 57 |
+
const now = Date.now()
|
| 58 |
+
if (hermesBinaryCache && now - hermesBinaryCache.checkedAt < 30_000) {
|
| 59 |
+
return hermesBinaryCache.installed
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const candidates = [process.env.HERMES_BIN, 'hermes-agent', 'hermes'].filter((v): v is string => Boolean(v && v.trim()))
|
| 63 |
+
const installed = candidates.some((bin) => {
|
| 64 |
+
try {
|
| 65 |
+
const res = spawnSync(bin, ['--version'], { stdio: 'ignore', timeout: 1200 })
|
| 66 |
+
return res.status === 0
|
| 67 |
+
} catch {
|
| 68 |
+
return false
|
| 69 |
+
}
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
hermesBinaryCache = { checkedAt: now, installed }
|
| 73 |
+
return installed
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
export function isHermesInstalled(): boolean {
|
| 77 |
+
// Strict detection: show Hermes UI only when Hermes CLI is actually installed on this system.
|
| 78 |
+
return hasHermesCliBinary()
|
| 79 |
}
|
| 80 |
|
| 81 |
export function isHermesGatewayRunning(): boolean {
|
|
@@ -257,56 +257,89 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise
|
|
| 257 |
}
|
| 258 |
|
| 259 |
async function searchClawdHub(query: string): Promise<RegistrySearchResult> {
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
}
|
| 267 |
-
const data = await res.json() as any
|
| 268 |
-
const skills: RegistrySkill[] = (data?.results || data?.skills || []).map((s: any) => ({
|
| 269 |
-
slug: s.slug || s.id || s.name,
|
| 270 |
-
name: s.name || s.slug,
|
| 271 |
-
description: s.description || '',
|
| 272 |
-
author: s.author || s.owner || 'unknown',
|
| 273 |
-
version: s.version || s.latest_version || '0.0.0',
|
| 274 |
-
source: 'clawhub' as const,
|
| 275 |
-
installCount: s.installs || s.install_count,
|
| 276 |
-
tags: s.tags,
|
| 277 |
-
hash: s.hash || s.sha256,
|
| 278 |
-
}))
|
| 279 |
-
return { skills, total: data?.total || skills.length, source: 'clawhub' }
|
| 280 |
-
} catch (err: any) {
|
| 281 |
-
logger.warn({ err: err.message }, 'ClawdHub search error')
|
| 282 |
-
return { skills: [], total: 0, source: 'clawhub' }
|
| 283 |
}
|
|
|
|
|
|
|
| 284 |
}
|
| 285 |
|
| 286 |
async function searchSkillsSh(query: string): Promise<RegistrySearchResult> {
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
}
|
| 294 |
-
const data = await res.json() as any
|
| 295 |
-
const skills: RegistrySkill[] = (data?.skills || data?.results || []).map((s: any) => ({
|
| 296 |
-
slug: s.slug || `${s.owner}/${s.name}` || s.id,
|
| 297 |
-
name: s.name || s.slug,
|
| 298 |
-
description: s.description || '',
|
| 299 |
-
author: s.owner || s.author || 'unknown',
|
| 300 |
-
version: s.version || 'latest',
|
| 301 |
-
source: 'skills-sh' as const,
|
| 302 |
-
installCount: s.installs || s.install_count,
|
| 303 |
-
tags: s.tags,
|
| 304 |
-
}))
|
| 305 |
-
return { skills, total: data?.total || skills.length, source: 'skills-sh' }
|
| 306 |
-
} catch (err: any) {
|
| 307 |
-
logger.warn({ err: err.message }, 'skills.sh search error')
|
| 308 |
-
return { skills: [], total: 0, source: 'skills-sh' }
|
| 309 |
}
|
|
|
|
|
|
|
| 310 |
}
|
| 311 |
|
| 312 |
export async function searchRegistry(source: RegistrySource, query: string): Promise<RegistrySearchResult> {
|
|
|
|
| 257 |
}
|
| 258 |
|
| 259 |
async function searchClawdHub(query: string): Promise<RegistrySearchResult> {
|
| 260 |
+
// ClawdHub current API: /api/search?q=... (legacy /skills/search now 404s)
|
| 261 |
+
const urls = [
|
| 262 |
+
`${CLAWHUB_API}/search?q=${encodeURIComponent(query)}`,
|
| 263 |
+
`${CLAWHUB_API}/search?query=${encodeURIComponent(query)}`,
|
| 264 |
+
`${CLAWHUB_API}/skills/search?q=${encodeURIComponent(query)}`,
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
for (const url of urls) {
|
| 268 |
+
try {
|
| 269 |
+
const res = await fetchWithTimeout(url)
|
| 270 |
+
if (!res.ok) {
|
| 271 |
+
logger.warn({ status: res.status, url }, 'ClawdHub search request failed')
|
| 272 |
+
continue
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
const data = await res.json() as any
|
| 276 |
+
const rows = data?.results || data?.skills || []
|
| 277 |
+
const skills: RegistrySkill[] = rows.map((s: any) => ({
|
| 278 |
+
slug: s.slug || s.id || s.name,
|
| 279 |
+
name: s.displayName || s.name || s.slug,
|
| 280 |
+
description: s.summary || s.description || '',
|
| 281 |
+
author: s.author || s.owner || 'unknown',
|
| 282 |
+
version: s.version || s.latest_version || 'latest',
|
| 283 |
+
source: 'clawhub' as const,
|
| 284 |
+
installCount: s.installs || s.install_count,
|
| 285 |
+
tags: s.tags,
|
| 286 |
+
hash: s.hash || s.sha256,
|
| 287 |
+
}))
|
| 288 |
+
|
| 289 |
+
if (skills.length > 0) {
|
| 290 |
+
return { skills, total: data?.total || skills.length, source: 'clawhub' }
|
| 291 |
+
}
|
| 292 |
+
} catch (err: any) {
|
| 293 |
+
logger.warn({ err: err.message, url }, 'ClawdHub search error')
|
| 294 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
}
|
| 296 |
+
|
| 297 |
+
return { skills: [], total: 0, source: 'clawhub' }
|
| 298 |
}
|
| 299 |
|
| 300 |
async function searchSkillsSh(query: string): Promise<RegistrySearchResult> {
|
| 301 |
+
// skills.sh current API: /api/search?q=... (legacy /skills endpoint now 404s)
|
| 302 |
+
const urls = [
|
| 303 |
+
`${SKILLS_SH_API}/search?q=${encodeURIComponent(query)}`,
|
| 304 |
+
`${SKILLS_SH_API}/search?query=${encodeURIComponent(query)}`,
|
| 305 |
+
`${SKILLS_SH_API}/skills?q=${encodeURIComponent(query)}`,
|
| 306 |
+
]
|
| 307 |
+
|
| 308 |
+
for (const url of urls) {
|
| 309 |
+
try {
|
| 310 |
+
const res = await fetchWithTimeout(url)
|
| 311 |
+
if (!res.ok) {
|
| 312 |
+
logger.warn({ status: res.status, url }, 'skills.sh search request failed')
|
| 313 |
+
continue
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
const data = await res.json() as any
|
| 317 |
+
const rows = data?.skills || data?.results || []
|
| 318 |
+
const skills: RegistrySkill[] = rows.map((s: any) => {
|
| 319 |
+
const source = typeof s.source === 'string' ? s.source : 'unknown'
|
| 320 |
+
const slug = s.slug || s.id || (source && s.skillId ? `${source}/${s.skillId}` : s.name)
|
| 321 |
+
return {
|
| 322 |
+
slug,
|
| 323 |
+
name: s.name || s.skillId || s.slug || 'unnamed-skill',
|
| 324 |
+
description: s.description || s.summary || '',
|
| 325 |
+
author: s.owner || s.author || (source.includes('/') ? source.split('/')[0] : source),
|
| 326 |
+
version: s.version || 'latest',
|
| 327 |
+
source: 'skills-sh' as const,
|
| 328 |
+
installCount: s.installs || s.install_count,
|
| 329 |
+
tags: s.tags,
|
| 330 |
+
url: s.url,
|
| 331 |
+
}
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
if (skills.length > 0) {
|
| 335 |
+
return { skills, total: data?.total || data?.count || skills.length, source: 'skills-sh' }
|
| 336 |
+
}
|
| 337 |
+
} catch (err: any) {
|
| 338 |
+
logger.warn({ err: err.message, url }, 'skills.sh search error')
|
| 339 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
}
|
| 341 |
+
|
| 342 |
+
return { skills: [], total: 0, source: 'skills-sh' }
|
| 343 |
}
|
| 344 |
|
| 345 |
export async function searchRegistry(source: RegistrySource, query: string): Promise<RegistrySearchResult> {
|
|
@@ -82,25 +82,44 @@ function hostMatches(pattern: string, hostname: string): boolean {
|
|
| 82 |
return h === p
|
| 83 |
}
|
| 84 |
|
| 85 |
-
function
|
| 86 |
-
|
| 87 |
-
response.headers.set('X-Request-Id', requestId)
|
| 88 |
-
response.headers.set('X-Content-Type-Options', 'nosniff')
|
| 89 |
-
response.headers.set('X-Frame-Options', 'DENY')
|
| 90 |
-
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
| 91 |
-
|
| 92 |
-
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
|
| 93 |
-
const csp = [
|
| 94 |
`default-src 'self'`,
|
| 95 |
-
`
|
| 96 |
-
`
|
|
|
|
|
|
|
|
|
|
| 97 |
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
|
| 98 |
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
|
| 99 |
`font-src 'self' data:`,
|
| 100 |
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
|
| 101 |
`worker-src 'self' blob:`,
|
| 102 |
].join('; ')
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
return response
|
| 106 |
}
|
|
@@ -164,7 +183,8 @@ export function proxy(request: NextRequest) {
|
|
| 164 |
|
| 165 |
// Allow login page, auth API, and docs without session
|
| 166 |
if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs') {
|
| 167 |
-
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
// Check for session cookie
|
|
@@ -181,7 +201,8 @@ export function proxy(request: NextRequest) {
|
|
| 181 |
const looksLikeAgentApiKey = /^mca_[a-f0-9]{48}$/i.test(apiKey)
|
| 182 |
|
| 183 |
if (sessionToken || hasValidApiKey || looksLikeAgentApiKey) {
|
| 184 |
-
|
|
|
|
| 185 |
}
|
| 186 |
|
| 187 |
return addSecurityHeaders(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), request)
|
|
@@ -189,7 +210,8 @@ export function proxy(request: NextRequest) {
|
|
| 189 |
|
| 190 |
// Page routes: redirect to login if no session
|
| 191 |
if (sessionToken) {
|
| 192 |
-
|
|
|
|
| 193 |
}
|
| 194 |
|
| 195 |
// Redirect to login
|
|
|
|
| 82 |
return h === p
|
| 83 |
}
|
| 84 |
|
| 85 |
+
function buildCsp(nonce: string, googleEnabled: boolean): string {
|
| 86 |
+
return [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
`default-src 'self'`,
|
| 88 |
+
`base-uri 'self'`,
|
| 89 |
+
`object-src 'none'`,
|
| 90 |
+
`frame-ancestors 'none'`,
|
| 91 |
+
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
|
| 92 |
+
`style-src 'self' 'nonce-${nonce}'`,
|
| 93 |
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
|
| 94 |
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
|
| 95 |
`font-src 'self' data:`,
|
| 96 |
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
|
| 97 |
`worker-src 'self' blob:`,
|
| 98 |
].join('; ')
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function nextResponseWithNonce(request: NextRequest): { response: NextResponse; nonce: string } {
|
| 102 |
+
const nonce = crypto.randomBytes(16).toString('base64')
|
| 103 |
+
const requestHeaders = new Headers(request.headers)
|
| 104 |
+
requestHeaders.set('x-nonce', nonce)
|
| 105 |
+
const response = NextResponse.next({
|
| 106 |
+
request: {
|
| 107 |
+
headers: requestHeaders,
|
| 108 |
+
},
|
| 109 |
+
})
|
| 110 |
+
return { response, nonce }
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function addSecurityHeaders(response: NextResponse, _request: NextRequest, nonce?: string): NextResponse {
|
| 114 |
+
const requestId = crypto.randomUUID()
|
| 115 |
+
response.headers.set('X-Request-Id', requestId)
|
| 116 |
+
response.headers.set('X-Content-Type-Options', 'nosniff')
|
| 117 |
+
response.headers.set('X-Frame-Options', 'DENY')
|
| 118 |
+
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
| 119 |
+
|
| 120 |
+
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
|
| 121 |
+
const effectiveNonce = nonce || crypto.randomBytes(16).toString('base64')
|
| 122 |
+
response.headers.set('Content-Security-Policy', buildCsp(effectiveNonce, googleEnabled))
|
| 123 |
|
| 124 |
return response
|
| 125 |
}
|
|
|
|
| 183 |
|
| 184 |
// Allow login page, auth API, and docs without session
|
| 185 |
if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs') {
|
| 186 |
+
const { response, nonce } = nextResponseWithNonce(request)
|
| 187 |
+
return addSecurityHeaders(response, request, nonce)
|
| 188 |
}
|
| 189 |
|
| 190 |
// Check for session cookie
|
|
|
|
| 201 |
const looksLikeAgentApiKey = /^mca_[a-f0-9]{48}$/i.test(apiKey)
|
| 202 |
|
| 203 |
if (sessionToken || hasValidApiKey || looksLikeAgentApiKey) {
|
| 204 |
+
const { response, nonce } = nextResponseWithNonce(request)
|
| 205 |
+
return addSecurityHeaders(response, request, nonce)
|
| 206 |
}
|
| 207 |
|
| 208 |
return addSecurityHeaders(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), request)
|
|
|
|
| 210 |
|
| 211 |
// Page routes: redirect to login if no session
|
| 212 |
if (sessionToken) {
|
| 213 |
+
const { response, nonce } = nextResponseWithNonce(request)
|
| 214 |
+
return addSecurityHeaders(response, request, nonce)
|
| 215 |
}
|
| 216 |
|
| 217 |
// Redirect to login
|