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:
Zaki Manian
2026-02-23 10:04:02 -08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f4ba85ffa2
commit 4e2dd76ae5
27 changed files with 1874 additions and 388 deletions
+310 -28
View File
@@ -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
View File
@@ -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);
}
}