mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
Fix skills system: enable by default, fix registry and install (#300)
* feat: add Docker detection module with platform guidance Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add Docker sandbox step to setup wizard Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: show Docker status in boot screen Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: check Docker availability at startup When SANDBOX_ENABLED=true, proactively detect whether Docker is installed and running before creating the ContainerJobManager. If Docker is unavailable, log a warning with platform-specific guidance and disable the sandbox for the session. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: enable sandbox by default, improve wizard explanation, document detection limits - SandboxConfig defaults to enabled=true (startup check disables gracefully if Docker is unavailable) - Wizard step explains why Docker matters: isolation for LLM-generated code vs running directly on the host - Document detection confidence per platform in detect.rs module docs: high on macOS/Linux, medium on Windows (named pipe edge cases) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: cargo fmt + update test_builder_defaults for enabled-by-default Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: deduplicate wizard Docker status handling per review Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: fix skills system - enable by default, fix registry connectivity and install - Enable skills system by default (SKILLS_ENABLED no longer required) - Bypass Vercel TLS fingerprint blocking by pointing DEFAULT_REGISTRY_URL directly at the Convex backend (wry-manatee-359.convex.site) - Handle ZIP archives from ClawHub download API - the registry returns ZIP files containing SKILL.md, not raw text. Uses flate2 (existing dep) to extract SKILL.md from the archive. - Surface catalog search errors in the UI with a yellow warning banner instead of silently returning empty results - Handle both {"results":[...]} envelope and bare [...] array JSON formats from the search API - Add ClawHub links and metadata to search result cards (clickable skill names linking to clawhub.ai, relevance score, "updated X ago" recency) - Fix 3 pre-existing clippy warnings in tests/html_to_markdown.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address security review feedback on ZIP extraction and SSRF - Cap download size to 10 MB before reading response body - Guard against ZIP bombs: cap uncompressed_size at 1 MB, wrap DeflateDecoder with .take() read limit - Use checked_add for ZIP header offset arithmetic to prevent overflow - Remove .unwrap() on try_into() -- use direct array construction - Handle IPv4-mapped IPv6 addresses (::ffff:192.168.x.x) in SSRF checks - Don't leak internal registry URLs in user-facing catalog_error messages - Fix non-ASCII panic in catalog response debug logging (use .get() instead of byte slicing) Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add /skills command and enrich search results with ClawHub metadata - Parse /skills and /skills search <query> as SystemCommands in submission.rs - Add skill_catalog to AgentDeps and wire it through main.rs - Handle "skills" command in commands.rs: list installed skills and search ClawHub - Add /skills and /skills search <q> entries to /help output - Add SkillDetail, SkillStats, SkillOwner structs to catalog.rs - Add fetch_skill_detail() calling GET /api/v1/skills/{slug} on Convex backend - Add enrich_search_results() to fetch stars/downloads/owner for top 5 results in parallel - Fix SkillDetailResponse wrapper struct to match actual API shape: {"skill":{...},"owner":{...}} - Surface stars, downloads, owner in web UI skill search cards (app.js) - Surface enriched data in skills web handler and skill_search tool output Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: cargo fmt after merge conflict resolution Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: separate installed_skills dir for correct trust on restart, remove duplicate handlers Trust level bug: skills installed from ClawHub were written to user_dir (~/.ironclaw/skills/) which is discovered as Trusted on restart. Now installs go to ~/.ironclaw/installed_skills/ which is discovered as Installed, matching the documented skill directory layout. Changes: - SkillsConfig: add installed_dir field (SKILLS_INSTALLED_DIR env var, default ~/.ironclaw/installed_skills/) - SkillRegistry: add with_installed_dir() builder, installed_dir()/ install_target_dir() accessors, and discover installed_dir with SkillTrust::Installed in discover_all() - All install paths (web handler, skill tool) use install_target_dir() instead of user_dir() so new installs land in the correct directory - 3 new registry tests: test_installed_dir_uses_installed_trust, test_install_target_dir_prefers_installed_dir, test_user_dir_stays_trusted_with_installed_dir Duplicate handler cleanup: handlers/skills.rs was the canonical implementation but the handlers module was never compiled (not declared in web/mod.rs), so server.rs had its own duplicate inline definitions that the router used. Wire up the handlers module, delete the 260-line duplicate in server.rs, and have server.rs import skills handlers from handlers::skills. Fix pre-existing compile error in handlers/extensions.rs (missing needs_setup field). Add #[allow(dead_code)] on not-yet-migrated handler modules to suppress warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: probe more Docker socket paths on macOS Docker Desktop 4.13+ (stabilised in 4.18) no longer creates the /var/run/docker.sock symlink by default. The API socket lives at ~/.docker/run/docker.sock, which bollard's connect_with_local_defaults() does not try. Add a fallback probe list covering the common macOS container runtimes: - ~/.docker/run/docker.sock — Docker Desktop 4.13+ - ~/.colima/default/docker.sock — Colima - ~/.rd/docker.sock — Rancher Desktop Remove the bogus ~/.docker/desktop/docker.sock path that was added previously; it is not an API socket on any known Docker installation. Fixes the false-negative "Docker is installed but not running" warning reported by Illia on macOS with Docker Desktop 4.18+. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Harden Docker detection for rootless Linux and Windows fallback --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f4ba85ffa2
commit
4e2dd76ae5
+310
-28
@@ -5,7 +5,7 @@
|
||||
//! up-to-date with the registry.
|
||||
//!
|
||||
//! Configuration:
|
||||
//! - `CLAWHUB_REGISTRY` env var overrides the default base URL (`https://clawhub.ai`)
|
||||
//! - `CLAWHUB_REGISTRY` env var overrides the default base URL
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -14,7 +14,10 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Default ClawHub registry URL.
|
||||
const DEFAULT_REGISTRY_URL: &str = "https://clawhub.ai";
|
||||
///
|
||||
/// Points directly at the Convex backend, bypassing Vercel's edge which
|
||||
/// rejects non-browser TLS fingerprints (JA3/JA4 filtering).
|
||||
const DEFAULT_REGISTRY_URL: &str = "https://wry-manatee-359.convex.site";
|
||||
|
||||
/// How long cached search results remain valid (5 minutes).
|
||||
const CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
@@ -25,6 +28,15 @@ const MAX_RESULTS: usize = 25;
|
||||
/// HTTP request timeout for catalog queries.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Result of a catalog search, carrying both results and any error that occurred.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CatalogSearchOutcome {
|
||||
/// Skill entries returned by the search (empty on error).
|
||||
pub results: Vec<CatalogEntry>,
|
||||
/// If the registry was unreachable or returned an error, a human-readable message.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A skill entry from the ClawHub catalog.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CatalogEntry {
|
||||
@@ -41,18 +53,102 @@ pub struct CatalogEntry {
|
||||
/// Relevance score from the search API.
|
||||
#[serde(default)]
|
||||
pub score: f64,
|
||||
/// Last updated timestamp (epoch milliseconds from registry).
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<u64>,
|
||||
/// Star count (populated via detail enrichment).
|
||||
#[serde(default)]
|
||||
pub stars: Option<u64>,
|
||||
/// Total download count (populated via detail enrichment).
|
||||
#[serde(default)]
|
||||
pub downloads: Option<u64>,
|
||||
/// Current install count (populated via detail enrichment).
|
||||
#[serde(default)]
|
||||
pub installs_current: Option<u64>,
|
||||
/// Owner handle (populated via detail enrichment).
|
||||
#[serde(default)]
|
||||
pub owner: Option<String>,
|
||||
}
|
||||
|
||||
/// Top-level wrapper from the ClawHub `/api/v1/skills/{slug}` response.
|
||||
///
|
||||
/// The API returns `{"skill": {...}, "owner": {...}, "latestVersion": {...}}`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SkillDetailResponse {
|
||||
skill: SkillDetailInner,
|
||||
#[serde(default)]
|
||||
owner: Option<SkillOwner>,
|
||||
}
|
||||
|
||||
/// Inner `skill` object within `SkillDetailResponse`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkillDetailInner {
|
||||
pub slug: String,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stats: Option<SkillStats>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Detailed skill information from the ClawHub `/api/v1/skills/{slug}` endpoint.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillDetail {
|
||||
pub slug: String,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stats: Option<SkillStats>,
|
||||
#[serde(default)]
|
||||
pub owner: Option<SkillOwner>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Statistics for a skill from the ClawHub detail endpoint.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillStats {
|
||||
#[serde(default)]
|
||||
pub stars: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub downloads: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub installs_current: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub installs_all_time: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub versions: Option<u64>,
|
||||
}
|
||||
|
||||
/// Owner information for a skill.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SkillOwner {
|
||||
#[serde(default)]
|
||||
pub handle: Option<String>,
|
||||
#[serde(default, rename = "displayName")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Cached search result with TTL.
|
||||
struct CachedSearch {
|
||||
query: String,
|
||||
results: Vec<CatalogEntry>,
|
||||
outcome: CatalogSearchOutcome,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
/// Runtime skill catalog that queries ClawHub's API.
|
||||
pub struct SkillCatalog {
|
||||
/// Base URL for the registry (e.g. `https://clawhub.ai`).
|
||||
/// Base URL for the registry.
|
||||
registry_url: String,
|
||||
/// HTTP client (reused across requests).
|
||||
client: reqwest::Client,
|
||||
@@ -64,7 +160,7 @@ impl SkillCatalog {
|
||||
/// Create a new catalog.
|
||||
///
|
||||
/// Reads `CLAWHUB_REGISTRY` (or legacy `CLAWDHUB_REGISTRY`) from the
|
||||
/// environment, falling back to `https://clawhub.ai`.
|
||||
/// environment, falling back to the Convex backend.
|
||||
pub fn new() -> Self {
|
||||
let registry_url = std::env::var("CLAWHUB_REGISTRY")
|
||||
.or_else(|_| std::env::var("CLAWDHUB_REGISTRY"))
|
||||
@@ -102,9 +198,10 @@ impl SkillCatalog {
|
||||
/// Search for skills in the catalog.
|
||||
///
|
||||
/// First checks the in-memory cache. If not cached or expired, fetches
|
||||
/// from the ClawHub API. Returns an empty Vec on network errors (catalog
|
||||
/// search is best-effort, never blocks the agent).
|
||||
pub async fn search(&self, query: &str) -> Vec<CatalogEntry> {
|
||||
/// from the ClawHub API. Returns a [`CatalogSearchOutcome`] that carries
|
||||
/// both results and any error that occurred (catalog search is best-effort,
|
||||
/// never blocks the agent).
|
||||
pub async fn search(&self, query: &str) -> CatalogSearchOutcome {
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
// Check cache
|
||||
@@ -113,12 +210,12 @@ impl SkillCatalog {
|
||||
if let Some(cached) = cache.iter().find(|c| c.query == query_lower)
|
||||
&& cached.fetched_at.elapsed() < CACHE_TTL
|
||||
{
|
||||
return cached.results.clone();
|
||||
return cached.outcome.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
let results = self.fetch_search(&query_lower).await;
|
||||
let outcome = self.fetch_search(&query_lower).await;
|
||||
|
||||
// Update cache
|
||||
{
|
||||
@@ -131,43 +228,75 @@ impl SkillCatalog {
|
||||
}
|
||||
cache.push(CachedSearch {
|
||||
query: query_lower,
|
||||
results: results.clone(),
|
||||
outcome: outcome.clone(),
|
||||
fetched_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
results
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Fetch search results from the ClawHub API.
|
||||
async fn fetch_search(&self, query: &str) -> Vec<CatalogEntry> {
|
||||
async fn fetch_search(&self, query: &str) -> CatalogSearchOutcome {
|
||||
let url = format!("{}/api/v1/search", self.registry_url);
|
||||
|
||||
let response = match self.client.get(&url).query(&[("q", query)]).send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::debug!("Catalog search failed (network): {}", e);
|
||||
return Vec::new();
|
||||
tracing::warn!("Catalog search failed (network): {}", e);
|
||||
return CatalogSearchOutcome {
|
||||
results: Vec::new(),
|
||||
error: Some("Registry unreachable".to_string()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
tracing::debug!(
|
||||
"Catalog search returned status {}: {}",
|
||||
response.status(),
|
||||
status,
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "(no body)".to_string())
|
||||
);
|
||||
return Vec::new();
|
||||
return CatalogSearchOutcome {
|
||||
results: Vec::new(),
|
||||
error: Some(format!("Registry returned status {status}")),
|
||||
};
|
||||
}
|
||||
|
||||
// Parse the response -- ClawHub returns an array of results.
|
||||
// We try the v1 format first (with slug, displayName, version, score),
|
||||
// then fall back to a simpler format.
|
||||
match response.json::<Vec<CatalogSearchResult>>().await {
|
||||
Ok(results) => results
|
||||
// Parse the response body as text first so we can try multiple formats.
|
||||
let body = match response.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::debug!("Catalog search: failed to read response body: {}", e);
|
||||
return CatalogSearchOutcome {
|
||||
results: Vec::new(),
|
||||
error: Some("Failed to read registry response".to_string()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Try wrapped format first: {"results": [...]}
|
||||
// Then fall back to bare array: [...]
|
||||
let raw_results = if let Ok(envelope) = serde_json::from_str::<CatalogSearchEnvelope>(&body)
|
||||
{
|
||||
envelope.results
|
||||
} else if let Ok(arr) = serde_json::from_str::<Vec<CatalogSearchResult>>(&body) {
|
||||
arr
|
||||
} else {
|
||||
let preview = body.get(..200).unwrap_or(&body);
|
||||
tracing::debug!("Catalog search: failed to parse response: {}", preview);
|
||||
return CatalogSearchOutcome {
|
||||
results: Vec::new(),
|
||||
error: Some("Invalid response from registry".to_string()),
|
||||
};
|
||||
};
|
||||
|
||||
CatalogSearchOutcome {
|
||||
results: raw_results
|
||||
.into_iter()
|
||||
.take(MAX_RESULTS)
|
||||
.map(|r| CatalogEntry {
|
||||
@@ -176,11 +305,78 @@ impl SkillCatalog {
|
||||
description: r.summary.unwrap_or_default(),
|
||||
version: r.version.unwrap_or_default(),
|
||||
score: r.score.unwrap_or(0.0),
|
||||
updated_at: r.updated_at,
|
||||
stars: None,
|
||||
downloads: None,
|
||||
installs_current: None,
|
||||
owner: None,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::debug!("Catalog search: failed to parse response: {}", e);
|
||||
Vec::new()
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch detailed information for a single skill by slug.
|
||||
///
|
||||
/// Calls `GET /api/v1/skills/{slug}` and returns the detail if available.
|
||||
/// Returns `None` on any network or parse error (best-effort).
|
||||
pub async fn fetch_skill_detail(&self, slug: &str) -> Option<SkillDetail> {
|
||||
let url = format!(
|
||||
"{}/api/v1/skills/{}",
|
||||
self.registry_url,
|
||||
urlencoding::encode(slug)
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await.ok()?;
|
||||
if !response.status().is_success() {
|
||||
tracing::debug!(
|
||||
"Skill detail for '{}' returned status {}",
|
||||
slug,
|
||||
response.status()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let wrapper = response.json::<SkillDetailResponse>().await.ok()?;
|
||||
let inner = wrapper.skill;
|
||||
Some(SkillDetail {
|
||||
slug: inner.slug,
|
||||
display_name: inner.display_name,
|
||||
summary: inner.summary,
|
||||
version: None, // not returned in detail response
|
||||
stats: inner.stats,
|
||||
owner: wrapper.owner,
|
||||
updated_at: inner.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Enrich catalog entries with detail data (stars, downloads, owner).
|
||||
///
|
||||
/// Fetches detail for up to `max` entries in parallel. Best-effort: entries
|
||||
/// that fail to enrich keep their `None` values.
|
||||
pub async fn enrich_search_results(&self, entries: &mut [CatalogEntry], max: usize) {
|
||||
let count = entries.len().min(max);
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let futures: Vec<_> = entries[..count]
|
||||
.iter()
|
||||
.map(|e| self.fetch_skill_detail(&e.slug))
|
||||
.collect();
|
||||
|
||||
let details = futures::future::join_all(futures).await;
|
||||
|
||||
for (entry, detail) in entries[..count].iter_mut().zip(details.into_iter()) {
|
||||
if let Some(detail) = detail {
|
||||
if let Some(ref stats) = detail.stats {
|
||||
entry.stars = stats.stars;
|
||||
entry.downloads = stats.downloads;
|
||||
entry.installs_current = stats.installs_current;
|
||||
}
|
||||
if let Some(ref owner) = detail.owner {
|
||||
entry.owner = owner.handle.clone().or_else(|| owner.display_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +398,12 @@ impl Default for SkillCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper for ClawHub's `{"results": [...]}` envelope.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CatalogSearchEnvelope {
|
||||
results: Vec<CatalogSearchResult>,
|
||||
}
|
||||
|
||||
/// Internal type matching ClawHub's `/api/v1/search` response items.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -215,6 +417,8 @@ struct CatalogSearchResult {
|
||||
summary: Option<String>,
|
||||
#[serde(default)]
|
||||
score: Option<f64>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Construct the download URL for a skill's SKILL.md from the registry.
|
||||
@@ -252,11 +456,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_returns_empty_on_network_error() {
|
||||
async fn test_search_returns_error_on_network_failure() {
|
||||
// Point at an invalid URL to trigger a network error
|
||||
let catalog = SkillCatalog::with_url("http://127.0.0.1:1");
|
||||
let results = catalog.search("test").await;
|
||||
assert!(results.is_empty());
|
||||
let outcome = catalog.search("test").await;
|
||||
assert!(outcome.results.is_empty());
|
||||
assert!(outcome.error.is_some());
|
||||
assert!(outcome.error.unwrap().contains("Registry unreachable"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -295,6 +501,77 @@ mod tests {
|
||||
assert!(url.contains("slug=foo%26bar%3Dbaz%23frag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wrapped_response() {
|
||||
// ClawHub returns {"results": [...]} format
|
||||
let json = r#"{"results":[{"slug":"markdown","displayName":"Markdown","summary":"A skill","version":"1.0.0","score":3.5}]}"#;
|
||||
let envelope: CatalogSearchEnvelope = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(envelope.results.len(), 1);
|
||||
assert_eq!(envelope.results[0].slug, "markdown");
|
||||
assert_eq!(
|
||||
envelope.results[0].display_name.as_deref(),
|
||||
Some("Markdown")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bare_array_response() {
|
||||
// Fallback: bare array format
|
||||
let json = r#"[{"slug":"markdown","displayName":"Markdown","summary":"A skill","version":"1.0.0","score":3.5}]"#;
|
||||
let results: Vec<CatalogSearchResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].slug, "markdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_detail() {
|
||||
// Response format matches the actual ClawHub API: {"skill": {...}, "owner": {...}}
|
||||
let json = r#"{
|
||||
"skill": {
|
||||
"slug": "steipete/markdown-writer",
|
||||
"displayName": "Markdown Writer",
|
||||
"summary": "Write markdown docs",
|
||||
"stats": {
|
||||
"stars": 142,
|
||||
"downloads": 8400,
|
||||
"installsCurrent": 55,
|
||||
"installsAllTime": 200,
|
||||
"versions": 5
|
||||
},
|
||||
"updatedAt": 1700000000000
|
||||
},
|
||||
"owner": {
|
||||
"handle": "steipete",
|
||||
"displayName": "Peter S."
|
||||
},
|
||||
"latestVersion": {
|
||||
"version": "1.2.3",
|
||||
"createdAt": 1700000000000,
|
||||
"changelog": ""
|
||||
}
|
||||
}"#;
|
||||
|
||||
let wrapper: SkillDetailResponse = serde_json::from_str(json).unwrap();
|
||||
let inner = &wrapper.skill;
|
||||
assert_eq!(inner.slug, "steipete/markdown-writer");
|
||||
assert_eq!(inner.display_name.as_deref(), Some("Markdown Writer"));
|
||||
|
||||
let stats = inner.stats.as_ref().unwrap();
|
||||
assert_eq!(stats.stars, Some(142));
|
||||
assert_eq!(stats.downloads, Some(8400));
|
||||
assert_eq!(stats.installs_current, Some(55));
|
||||
|
||||
let owner = wrapper.owner.as_ref().unwrap();
|
||||
assert_eq!(owner.handle.as_deref(), Some("steipete"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_skill_detail_returns_none_on_error() {
|
||||
let catalog = SkillCatalog::with_url("http://127.0.0.1:1");
|
||||
let result = catalog.fetch_skill_detail("nonexistent/skill").await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_catalog_entry_serde() {
|
||||
let entry = CatalogEntry {
|
||||
@@ -303,6 +580,11 @@ mod tests {
|
||||
description: "A test".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
score: 0.95,
|
||||
updated_at: Some(1700000000000),
|
||||
stars: Some(42),
|
||||
downloads: Some(1000),
|
||||
installs_current: None,
|
||||
owner: Some("tester".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
let parsed: CatalogEntry = serde_json::from_str(&json).unwrap();
|
||||
|
||||
+115
-1
@@ -68,8 +68,10 @@ pub enum SkillRegistryError {
|
||||
pub struct SkillRegistry {
|
||||
/// All loaded skills.
|
||||
skills: Vec<LoadedSkill>,
|
||||
/// User skills directory (~/.ironclaw/skills/).
|
||||
/// User skills directory (~/.ironclaw/skills/). Skills here are Trusted.
|
||||
user_dir: PathBuf,
|
||||
/// Registry-installed skills directory (~/.ironclaw/installed_skills/). Skills here are Installed.
|
||||
installed_dir: Option<PathBuf>,
|
||||
/// Optional workspace skills directory.
|
||||
workspace_dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -80,10 +82,22 @@ impl SkillRegistry {
|
||||
Self {
|
||||
skills: Vec::new(),
|
||||
user_dir,
|
||||
installed_dir: None,
|
||||
workspace_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the registry-installed skills directory.
|
||||
///
|
||||
/// Skills installed via ClawHub or the skill tools are written here and
|
||||
/// loaded with `SkillTrust::Installed` (read-only tool access). This
|
||||
/// directory is separate from the user dir so that trust levels survive
|
||||
/// restarts correctly.
|
||||
pub fn with_installed_dir(mut self, dir: PathBuf) -> Self {
|
||||
self.installed_dir = Some(dir);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a workspace skills directory.
|
||||
pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self {
|
||||
self.workspace_dir = Some(dir);
|
||||
@@ -95,6 +109,7 @@ impl SkillRegistry {
|
||||
/// Discovery order (earlier wins on name collision):
|
||||
/// 1. Workspace skills directory (if set) -- Trusted
|
||||
/// 2. User skills directory -- Trusted
|
||||
/// 3. Installed skills directory (if set) -- Installed
|
||||
pub async fn discover_all(&mut self) -> Vec<String> {
|
||||
let mut loaded_names: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
@@ -129,6 +144,25 @@ impl SkillRegistry {
|
||||
self.skills.push(skill);
|
||||
}
|
||||
|
||||
// 3. Installed skills (registry-installed, lowest priority)
|
||||
if let Some(inst_dir) = self.installed_dir.clone() {
|
||||
let inst_skills = self
|
||||
.discover_from_dir(&inst_dir, SkillTrust::Installed, SkillSource::User)
|
||||
.await;
|
||||
for (name, skill) in inst_skills {
|
||||
if seen.contains(&name) {
|
||||
tracing::debug!(
|
||||
"Skipping installed skill '{}' (overridden by user/workspace)",
|
||||
name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
seen.insert(name.clone());
|
||||
loaded_names.push(name);
|
||||
self.skills.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
loaded_names
|
||||
}
|
||||
|
||||
@@ -424,6 +458,20 @@ impl SkillRegistry {
|
||||
pub fn user_dir(&self) -> &Path {
|
||||
&self.user_dir
|
||||
}
|
||||
|
||||
/// Get the installed skills directory path, if configured.
|
||||
pub fn installed_dir(&self) -> Option<&Path> {
|
||||
self.installed_dir.as_deref()
|
||||
}
|
||||
|
||||
/// Get the directory where new registry installs should be written.
|
||||
///
|
||||
/// Returns the installed_dir if configured (preferred), otherwise falls
|
||||
/// back to user_dir. In practice, the installed_dir is always set when
|
||||
/// the app is running; the fallback exists for test registries.
|
||||
pub fn install_target_dir(&self) -> &Path {
|
||||
self.installed_dir.as_deref().unwrap_or(&self.user_dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and validate a single SKILL.md file from disk.
|
||||
@@ -948,4 +996,70 @@ mod tests {
|
||||
let h2 = compute_hash("world");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
/// Skills in the installed_dir are discovered with SkillTrust::Installed,
|
||||
/// not Trusted. This ensures registry-installed skills do not gain full
|
||||
/// tool access after an agent restart.
|
||||
#[tokio::test]
|
||||
async fn test_installed_dir_uses_installed_trust() {
|
||||
let user_dir = tempfile::tempdir().unwrap();
|
||||
let inst_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Place a skill in the installed dir
|
||||
let skill_dir = inst_dir.path().join("registry-skill");
|
||||
fs::create_dir(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: registry-skill\nversion: \"1.2.3\"\n---\n\nInstalled prompt.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut registry = SkillRegistry::new(user_dir.path().to_path_buf())
|
||||
.with_installed_dir(inst_dir.path().to_path_buf());
|
||||
let loaded = registry.discover_all().await;
|
||||
|
||||
assert_eq!(loaded, vec!["registry-skill"]);
|
||||
let skill = registry.find_by_name("registry-skill").unwrap();
|
||||
assert_eq!(
|
||||
skill.trust,
|
||||
SkillTrust::Installed,
|
||||
"installed_dir skills must be Installed"
|
||||
);
|
||||
assert_eq!(skill.manifest.version, "1.2.3");
|
||||
}
|
||||
|
||||
/// install_target_dir() returns installed_dir when set, user_dir otherwise.
|
||||
#[test]
|
||||
fn test_install_target_dir_prefers_installed_dir() {
|
||||
let user_dir = PathBuf::from("/tmp/user-skills");
|
||||
let inst_dir = PathBuf::from("/tmp/installed-skills");
|
||||
|
||||
let registry = SkillRegistry::new(user_dir.clone()).with_installed_dir(inst_dir.clone());
|
||||
assert_eq!(registry.install_target_dir(), inst_dir.as_path());
|
||||
|
||||
let registry_no_inst = SkillRegistry::new(user_dir.clone());
|
||||
assert_eq!(registry_no_inst.install_target_dir(), user_dir.as_path());
|
||||
}
|
||||
|
||||
/// User skills (user_dir) remain Trusted even when installed_dir is set.
|
||||
#[tokio::test]
|
||||
async fn test_user_dir_stays_trusted_with_installed_dir() {
|
||||
let user_dir = tempfile::tempdir().unwrap();
|
||||
let inst_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let skill_dir = user_dir.path().join("my-skill");
|
||||
fs::create_dir(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: my-skill\n---\n\nUser prompt.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut registry = SkillRegistry::new(user_dir.path().to_path_buf())
|
||||
.with_installed_dir(inst_dir.path().to_path_buf());
|
||||
registry.discover_all().await;
|
||||
|
||||
let skill = registry.find_by_name("my-skill").unwrap();
|
||||
assert_eq!(skill.trust, SkillTrust::Trusted);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user