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
+5
View File
@@ -68,6 +68,7 @@ pub struct AgentDeps {
pub workspace: Option<Arc<Workspace>>,
pub extension_manager: Option<Arc<ExtensionManager>>,
pub skill_registry: Option<Arc<std::sync::RwLock<SkillRegistry>>>,
pub skill_catalog: Option<Arc<crate::skills::catalog::SkillCatalog>>,
pub skills_config: SkillsConfig,
pub hooks: Arc<HookRegistry>,
/// Cost enforcement guardrails (daily budget, hourly rate limits).
@@ -174,6 +175,10 @@ impl Agent {
self.deps.skill_registry.as_ref()
}
pub(super) fn skill_catalog(&self) -> Option<&Arc<crate::skills::catalog::SkillCatalog>> {
self.deps.skill_catalog.as_ref()
}
/// Select active skills for a message using deterministic prefiltering.
pub(super) fn select_active_skills(
&self,
+154
View File
@@ -15,6 +15,17 @@ use crate::channels::{IncomingMessage, StatusUpdate};
use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning};
/// Format a count with a suffix, using K/M abbreviations for large numbers.
fn format_count(n: u64, suffix: &str) -> String {
if n >= 1_000_000 {
format!("{:.1}M {}", n as f64 / 1_000_000.0, suffix)
} else if n >= 1_000 {
format!("{:.1}K {}", n as f64 / 1_000.0, suffix)
} else {
format!("{} {}", n, suffix)
}
}
impl Agent {
/// Handle job-related intents without turn tracking.
pub(super) async fn handle_job_or_command(
@@ -373,6 +384,10 @@ impl Agent {
" /thread <id> Switch to thread\n",
" /resume <id> Resume from checkpoint\n",
"\n",
"Skills:\n",
" /skills List installed skills\n",
" /skills search <q> Search ClawHub registry\n",
"\n",
"Agent:\n",
" /heartbeat Run heartbeat check\n",
" /summarize Summarize current thread\n",
@@ -405,6 +420,22 @@ impl Agent {
))
}
"skills" => {
if args.first().map(|s| s.as_str()) == Some("search") {
let query = args[1..].join(" ");
if query.is_empty() {
return Ok(SubmissionResult::error("Usage: /skills search <query>"));
}
self.handle_skills_search(&query).await
} else if args.is_empty() {
self.handle_skills_list().await
} else {
Ok(SubmissionResult::error(
"Usage: /skills or /skills search <query>",
))
}
}
"model" => {
let current = self.llm().active_model_name();
@@ -475,6 +506,129 @@ impl Agent {
}
}
/// List installed skills.
async fn handle_skills_list(&self) -> Result<SubmissionResult, Error> {
let Some(registry) = self.skill_registry() else {
return Ok(SubmissionResult::error("Skills system not enabled."));
};
let guard = match registry.read() {
Ok(g) => g,
Err(e) => {
return Ok(SubmissionResult::error(format!(
"Skill registry lock error: {}",
e
)));
}
};
let skills = guard.skills();
if skills.is_empty() {
return Ok(SubmissionResult::response(
"No skills installed.\n\nUse /skills search <query> to find skills on ClawHub.",
));
}
let mut out = String::from("Installed skills:\n\n");
for s in skills {
let desc = if s.manifest.description.chars().count() > 60 {
let truncated: String = s.manifest.description.chars().take(57).collect();
format!("{}...", truncated)
} else {
s.manifest.description.clone()
};
out.push_str(&format!(
" {:<24} v{:<10} [{}] {}\n",
s.manifest.name, s.manifest.version, s.trust, desc,
));
}
out.push_str("\nUse /skills search <query> to find more on ClawHub.");
Ok(SubmissionResult::response(out))
}
/// Search ClawHub for skills.
async fn handle_skills_search(&self, query: &str) -> Result<SubmissionResult, Error> {
let catalog = match self.skill_catalog() {
Some(c) => c,
None => {
return Ok(SubmissionResult::error("Skill catalog not available."));
}
};
let outcome = catalog.search(query).await;
// Enrich top results with detail data (stars, downloads, owner)
let mut entries = outcome.results;
catalog.enrich_search_results(&mut entries, 5).await;
let mut out = format!("ClawHub results for \"{}\":\n\n", query);
if entries.is_empty() {
if let Some(ref err) = outcome.error {
out.push_str(&format!(" (registry error: {})\n", err));
} else {
out.push_str(" No results found.\n");
}
} else {
for entry in &entries {
let owner_str = entry
.owner
.as_deref()
.map(|o| format!(" by {}", o))
.unwrap_or_default();
let stats_parts: Vec<String> = [
entry.stars.map(|s| format!("{} stars", s)),
entry.downloads.map(|d| format_count(d, "downloads")),
]
.into_iter()
.flatten()
.collect();
let stats_str = if stats_parts.is_empty() {
String::new()
} else {
format!(" {}", stats_parts.join(" "))
};
out.push_str(&format!(
" {:<24} v{:<10}{}{}\n",
entry.name, entry.version, owner_str, stats_str,
));
if !entry.description.is_empty() {
out.push_str(&format!(" {}\n\n", entry.description));
}
}
}
// Show matching installed skills
if let Some(registry) = self.skill_registry()
&& let Ok(guard) = registry.read()
{
let query_lower = query.to_lowercase();
let matches: Vec<_> = guard
.skills()
.iter()
.filter(|s| {
s.manifest.name.to_lowercase().contains(&query_lower)
|| s.manifest.description.to_lowercase().contains(&query_lower)
})
.collect();
if !matches.is_empty() {
out.push_str(&format!("Installed skills matching \"{}\":\n", query));
for s in &matches {
out.push_str(&format!(
" {:<24} v{:<10} [{}]\n",
s.manifest.name, s.manifest.version, s.trust,
));
}
}
}
Ok(SubmissionResult::response(out))
}
/// Handle legacy command routing from the Router (job commands that go through
/// process_user_input -> router -> handle_job_or_command -> here).
pub(super) async fn handle_command(
+1
View File
@@ -960,6 +960,7 @@ mod tests {
workspace: None,
extension_manager: None,
skill_registry: None,
skill_catalog: None,
skills_config: SkillsConfig::default(),
hooks: Arc::new(HookRegistry::new()),
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
+47
View File
@@ -62,6 +62,23 @@ impl SubmissionParser {
args: vec![],
};
}
if lower == "/skills" {
return Submission::SystemCommand {
command: "skills".to_string(),
args: vec![],
};
}
if lower.starts_with("/skills ") {
let args: Vec<String> = trimmed
.split_whitespace()
.skip(1)
.map(|s| s.to_string())
.collect();
return Submission::SystemCommand {
command: "skills".to_string(),
args,
};
}
if lower == "/ping" {
return Submission::SystemCommand {
command: "ping".to_string(),
@@ -693,6 +710,36 @@ mod tests {
assert!(!submission.starts_turn());
}
#[test]
fn test_parser_system_command_skills() {
let submission = SubmissionParser::parse("/skills");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "skills" && args.is_empty())
);
// Case insensitive
let submission = SubmissionParser::parse("/SKILLS");
assert!(
matches!(submission, Submission::SystemCommand { command, .. } if command == "skills")
);
}
#[test]
fn test_parser_system_command_skills_search() {
let submission = SubmissionParser::parse("/skills search markdown");
assert!(
matches!(submission, Submission::SystemCommand { command, args }
if command == "skills" && args == vec!["search", "markdown"])
);
// Multiple words in query
let submission = SubmissionParser::parse("/skills search code review tools");
assert!(
matches!(submission, Submission::SystemCommand { command, args }
if command == "skills" && args == vec!["search", "code", "review", "tools"])
);
}
#[test]
fn test_parser_quit() {
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
+2 -1
View File
@@ -692,7 +692,8 @@ impl AppBuilder {
// Skills system
let (skill_registry, skill_catalog) = if self.config.skills.enabled {
let mut registry = SkillRegistry::new(self.config.skills.local_dir.clone());
let mut registry = SkillRegistry::new(self.config.skills.local_dir.clone())
.with_installed_dir(self.config.skills.installed_dir.clone());
let loaded = registry.discover_all().await;
if !loaded.is_empty() {
tracing::info!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
+26 -2
View File
@@ -20,8 +20,10 @@ pub struct BootInfo {
pub heartbeat_enabled: bool,
pub heartbeat_interval_secs: u64,
pub sandbox_enabled: bool,
pub docker_status: crate::sandbox::detect::DockerStatus,
pub claude_code_enabled: bool,
pub routines_enabled: bool,
pub skills_enabled: bool,
pub channels: Vec<String>,
/// Public URL from a managed tunnel (e.g., "https://abc.ngrok.io").
pub tunnel_url: Option<String>,
@@ -35,6 +37,7 @@ pub fn print_boot_screen(info: &BootInfo) {
let bold = "\x1b[1m";
let cyan = "\x1b[36m";
let dim = "\x1b[90m";
let yellow = "\x1b[33m";
let yellow_underline = "\x1b[33;4m";
let reset = "\x1b[0m";
@@ -90,8 +93,19 @@ pub fn print_boot_screen(info: &BootInfo) {
let mins = info.heartbeat_interval_secs / 60;
features.push(format!("heartbeat ({mins}m)"));
}
if info.sandbox_enabled {
features.push("sandbox".to_string());
match info.docker_status {
crate::sandbox::detect::DockerStatus::Available => {
features.push("sandbox".to_string());
}
crate::sandbox::detect::DockerStatus::NotInstalled => {
features.push(format!("{yellow}sandbox (docker not installed){reset}"));
}
crate::sandbox::detect::DockerStatus::NotRunning => {
features.push(format!("{yellow}sandbox (docker not running){reset}"));
}
crate::sandbox::detect::DockerStatus::Disabled => {
// Don't show sandbox when disabled
}
}
if info.claude_code_enabled {
features.push("claude-code".to_string());
@@ -99,6 +113,9 @@ pub fn print_boot_screen(info: &BootInfo) {
if info.routines_enabled {
features.push("routines".to_string());
}
if info.skills_enabled {
features.push("skills".to_string());
}
if !features.is_empty() {
println!(
" {dim}features{reset} {cyan}{}{reset}",
@@ -140,6 +157,7 @@ pub fn print_boot_screen(info: &BootInfo) {
#[cfg(test)]
mod tests {
use super::*;
use crate::sandbox::detect::DockerStatus;
#[test]
fn test_print_boot_screen_full() {
@@ -158,8 +176,10 @@ mod tests {
heartbeat_enabled: true,
heartbeat_interval_secs: 1800,
sandbox_enabled: true,
docker_status: DockerStatus::Available,
claude_code_enabled: false,
routines_enabled: true,
skills_enabled: true,
channels: vec![
"repl".to_string(),
"gateway".to_string(),
@@ -189,8 +209,10 @@ mod tests {
heartbeat_enabled: false,
heartbeat_interval_secs: 0,
sandbox_enabled: false,
docker_status: DockerStatus::Disabled,
claude_code_enabled: false,
routines_enabled: false,
skills_enabled: false,
channels: vec![],
tunnel_url: None,
tunnel_provider: None,
@@ -216,8 +238,10 @@ mod tests {
heartbeat_enabled: false,
heartbeat_interval_secs: 0,
sandbox_enabled: false,
docker_status: DockerStatus::Disabled,
claude_code_enabled: false,
routines_enabled: false,
skills_enabled: false,
channels: vec!["repl".to_string()],
tunnel_url: None,
tunnel_provider: None,
+1
View File
@@ -34,6 +34,7 @@ pub async fn extensions_list_handler(
authenticated: ext.authenticated,
active: ext.active,
tools: ext.tools,
needs_setup: ext.needs_setup,
})
.collect();
+22 -17
View File
@@ -1,23 +1,28 @@
//! Handler modules for the web gateway API.
//!
//! Each module groups related endpoint handlers by domain.
//!
//! # Migration status
//!
//! `skills` is the canonical implementation used by `server.rs`.
//! The remaining modules are in-progress migrations from inline server.rs
//! handlers; their functions are not yet wired up, hence the `dead_code` allow.
pub mod chat;
pub mod extensions;
pub mod jobs;
pub mod memory;
pub mod routines;
pub mod settings;
pub mod skills;
pub mod static_files;
// Re-export all handler functions so `server.rs` can reference them
// as `handlers::chat_send_handler`, etc.
pub use chat::*;
pub use extensions::*;
pub use jobs::*;
pub use memory::*;
pub use routines::*;
pub use settings::*;
pub use skills::*;
pub use static_files::*;
// Modules not yet wired into server.rs router -- suppress dead_code until
// they replace their inline counterparts.
#[allow(dead_code)]
pub mod chat;
#[allow(dead_code)]
pub mod extensions;
#[allow(dead_code)]
pub mod jobs;
#[allow(dead_code)]
pub mod memory;
#[allow(dead_code)]
pub mod routines;
#[allow(dead_code)]
pub mod settings;
#[allow(dead_code)]
pub mod static_files;
+15 -4
View File
@@ -58,8 +58,14 @@ pub async fn skills_search_handler(
))?;
// Search ClawHub catalog
let catalog_results = catalog.search(&req.query).await;
let catalog_json: Vec<serde_json::Value> = catalog_results
let catalog_outcome = catalog.search(&req.query).await;
let catalog_error = catalog_outcome.error.clone();
// Enrich top results with detail data (stars, downloads, owner)
let mut entries = catalog_outcome.results;
catalog.enrich_search_results(&mut entries, 5).await;
let catalog_json: Vec<serde_json::Value> = entries
.into_iter()
.map(|e| {
serde_json::json!({
@@ -68,6 +74,10 @@ pub async fn skills_search_handler(
"description": e.description,
"version": e.version,
"score": e.score,
"updatedAt": e.updated_at,
"stars": e.stars,
"downloads": e.downloads,
"owner": e.owner,
})
})
.collect();
@@ -103,6 +113,7 @@ pub async fn skills_search_handler(
catalog: catalog_json,
installed,
registry_url: catalog.registry_url().to_string(),
catalog_error,
}))
}
@@ -147,7 +158,7 @@ pub async fn skills_install_handler(
)));
};
// Parse, check duplicates, and get user_dir under a brief read lock.
// Parse, check duplicates, and get install_dir under a brief read lock.
let (user_dir, skill_name_from_parse) = {
let guard = registry.read().map_err(|e| {
(
@@ -168,7 +179,7 @@ pub async fn skills_install_handler(
))));
}
(guard.user_dir().to_path_buf(), skill_name)
(guard.install_target_dir().to_path_buf(), skill_name)
};
// Perform async I/O (write to disk, load) with no lock held.
+1
View File
@@ -15,6 +15,7 @@
//! ```
pub mod auth;
pub(crate) mod handlers;
pub mod log_layer;
pub mod openai_compat;
pub mod server;
+3 -247
View File
@@ -28,6 +28,9 @@ use uuid::Uuid;
use crate::agent::SessionManager;
use crate::channels::IncomingMessage;
use crate::channels::web::auth::{AuthState, auth_middleware};
use crate::channels::web::handlers::skills::{
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
};
use crate::channels::web::log_layer::LogBroadcaster;
use crate::channels::web::sse::SseManager;
use crate::channels::web::types::*;
@@ -2086,253 +2089,6 @@ async fn pairing_approve_handler(
}
}
// --- Skills handlers ---
async fn skills_list_handler(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<super::types::SkillListResponse>, (StatusCode, String)> {
let registry = state.skill_registry.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Skills system not enabled".to_string(),
))?;
let guard = registry.read().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Skill registry lock poisoned: {}", e),
)
})?;
let skills: Vec<super::types::SkillInfo> = guard
.skills()
.iter()
.map(|s| super::types::SkillInfo {
name: s.manifest.name.clone(),
description: s.manifest.description.clone(),
version: s.manifest.version.clone(),
trust: s.trust.to_string(),
source: format!("{:?}", s.source),
keywords: s.manifest.activation.keywords.clone(),
})
.collect();
let count = skills.len();
Ok(Json(super::types::SkillListResponse { skills, count }))
}
async fn skills_search_handler(
State(state): State<Arc<GatewayState>>,
Json(req): Json<super::types::SkillSearchRequest>,
) -> Result<Json<super::types::SkillSearchResponse>, (StatusCode, String)> {
let registry = state.skill_registry.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Skills system not enabled".to_string(),
))?;
let catalog = state.skill_catalog.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Skill catalog not available".to_string(),
))?;
// Search ClawHub catalog
let catalog_results = catalog.search(&req.query).await;
let catalog_json: Vec<serde_json::Value> = catalog_results
.into_iter()
.map(|e| {
serde_json::json!({
"slug": e.slug,
"name": e.name,
"description": e.description,
"version": e.version,
"score": e.score,
})
})
.collect();
// Search local skills
let query_lower = req.query.to_lowercase();
let installed: Vec<super::types::SkillInfo> = {
let guard = registry.read().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Skill registry lock poisoned: {}", e),
)
})?;
guard
.skills()
.iter()
.filter(|s| {
s.manifest.name.to_lowercase().contains(&query_lower)
|| s.manifest.description.to_lowercase().contains(&query_lower)
})
.map(|s| super::types::SkillInfo {
name: s.manifest.name.clone(),
description: s.manifest.description.clone(),
version: s.manifest.version.clone(),
trust: s.trust.to_string(),
source: format!("{:?}", s.source),
keywords: s.manifest.activation.keywords.clone(),
})
.collect()
};
Ok(Json(super::types::SkillSearchResponse {
catalog: catalog_json,
installed,
registry_url: catalog.registry_url().to_string(),
}))
}
async fn skills_install_handler(
State(state): State<Arc<GatewayState>>,
headers: axum::http::HeaderMap,
Json(req): Json<super::types::SkillInstallRequest>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
// Require explicit confirmation header to prevent accidental installs.
// Chat tools have requires_approval(); this is the equivalent for the web API.
if headers
.get("x-confirm-action")
.and_then(|v| v.to_str().ok())
!= Some("true")
{
return Err((
StatusCode::BAD_REQUEST,
"Skill install requires X-Confirm-Action: true header".to_string(),
));
}
let registry = state.skill_registry.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Skills system not enabled".to_string(),
))?;
let content = if let Some(ref raw) = req.content {
raw.clone()
} else if let Some(ref url) = req.url {
// Fetch from explicit URL (with SSRF protection)
crate::tools::builtin::skill_tools::fetch_skill_content(url)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
} else if let Some(ref catalog) = state.skill_catalog {
let url = crate::skills::catalog::skill_download_url(catalog.registry_url(), &req.name);
crate::tools::builtin::skill_tools::fetch_skill_content(&url)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
} else {
return Ok(Json(ActionResponse::fail(
"Provide 'content' or 'url' to install a skill".to_string(),
)));
};
// Parse, check duplicates, and get user_dir under a brief read lock.
let (user_dir, skill_name_from_parse) = {
let guard = registry.read().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Skill registry lock poisoned: {}", e),
)
})?;
let normalized = crate::skills::normalize_line_endings(&content);
let parsed = crate::skills::parser::parse_skill_md(&normalized)
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let skill_name = parsed.manifest.name.clone();
if guard.has(&skill_name) {
return Ok(Json(ActionResponse::fail(format!(
"Skill '{}' already exists",
skill_name
))));
}
(guard.user_dir().to_path_buf(), skill_name)
};
// Perform async I/O (write to disk, load) with no lock held.
let normalized = crate::skills::normalize_line_endings(&content);
let (skill_name, loaded_skill) =
crate::skills::registry::SkillRegistry::prepare_install_to_disk(
&user_dir,
&skill_name_from_parse,
&normalized,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Commit: brief write lock for in-memory addition
let mut guard = registry.write().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Skill registry lock poisoned: {}", e),
)
})?;
match guard.commit_install(&skill_name, loaded_skill) {
Ok(()) => Ok(Json(ActionResponse::ok(format!(
"Skill '{}' installed",
skill_name
)))),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
async fn skills_remove_handler(
State(state): State<Arc<GatewayState>>,
headers: axum::http::HeaderMap,
Path(name): Path<String>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
// Require explicit confirmation header to prevent accidental removals.
if headers
.get("x-confirm-action")
.and_then(|v| v.to_str().ok())
!= Some("true")
{
return Err((
StatusCode::BAD_REQUEST,
"Skill removal requires X-Confirm-Action: true header".to_string(),
));
}
let registry = state.skill_registry.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Skills system not enabled".to_string(),
))?;
// Validate removal under a brief read lock
let skill_path = {
let guard = registry.read().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Skill registry lock poisoned: {}", e),
)
})?;
guard
.validate_remove(&name)
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
};
// Delete files from disk (async I/O, no lock held)
crate::skills::registry::SkillRegistry::delete_skill_files(&skill_path)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Remove from in-memory registry under a brief write lock
let mut guard = registry.write().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Skill registry lock poisoned: {}", e),
)
})?;
match guard.commit_remove(&name) {
Ok(()) => Ok(Json(ActionResponse::ok(format!(
"Skill '{}' removed",
name
)))),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
// --- Routines handlers ---
async fn routines_list_handler(
+331 -4
View File
@@ -93,7 +93,11 @@ function apiFetch(path, options) {
opts.body = JSON.stringify(opts.body);
}
return fetch(path, opts).then((res) => {
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
if (!res.ok) {
return res.text().then(function(body) {
throw new Error(body || (res.status + ' ' + res.statusText));
});
}
return res.json();
});
}
@@ -846,6 +850,7 @@ function switchTab(tab) {
if (tab === 'routines') loadRoutines();
if (tab === 'logs') applyLogFilters();
if (tab === 'extensions') loadExtensions();
if (tab === 'skills') loadSkills();
}
// --- Memory (filesystem tree) ---
@@ -2713,6 +2718,328 @@ function addMcpServer() {
});
}
// --- Skills ---
function loadSkills() {
var skillsList = document.getElementById('skills-list');
apiFetch('/api/skills').then(function(data) {
if (!data.skills || data.skills.length === 0) {
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
return;
}
skillsList.innerHTML = '';
for (var i = 0; i < data.skills.length; i++) {
skillsList.appendChild(renderSkillCard(data.skills[i]));
}
}).catch(function(err) {
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
});
}
function renderSkillCard(skill) {
var card = document.createElement('div');
card.className = 'ext-card';
var header = document.createElement('div');
header.className = 'ext-header';
var name = document.createElement('span');
name.className = 'ext-name';
name.textContent = skill.name;
header.appendChild(name);
var trust = document.createElement('span');
var trustClass = skill.trust.toLowerCase() === 'trusted' ? 'trust-trusted' : 'trust-installed';
trust.className = 'skill-trust ' + trustClass;
trust.textContent = skill.trust;
header.appendChild(trust);
var version = document.createElement('span');
version.className = 'skill-version';
version.textContent = 'v' + skill.version;
header.appendChild(version);
card.appendChild(header);
var desc = document.createElement('div');
desc.className = 'ext-desc';
desc.textContent = skill.description;
card.appendChild(desc);
if (skill.keywords && skill.keywords.length > 0) {
var kw = document.createElement('div');
kw.className = 'ext-keywords';
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
card.appendChild(kw);
}
var actions = document.createElement('div');
actions.className = 'ext-actions';
// Only show Remove for registry-installed skills, not user-placed trusted skills
if (skill.trust.toLowerCase() !== 'trusted') {
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
actions.appendChild(removeBtn);
}
card.appendChild(actions);
return card;
}
function searchClawHub() {
var input = document.getElementById('skill-search-input');
var query = input.value.trim();
if (!query) return;
var resultsDiv = document.getElementById('skill-search-results');
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
apiFetch('/api/skills/search', {
method: 'POST',
body: { query: query },
}).then(function(data) {
resultsDiv.innerHTML = '';
// Show registry error as a warning banner if present
if (data.catalog_error) {
var warning = document.createElement('div');
warning.className = 'empty-state';
warning.style.color = '#f0ad4e';
warning.style.borderLeft = '3px solid #f0ad4e';
warning.style.paddingLeft = '12px';
warning.style.marginBottom = '16px';
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
resultsDiv.appendChild(warning);
}
// Show catalog results
if (data.catalog && data.catalog.length > 0) {
// Build a set of installed skill names for quick lookup
var installedNames = {};
if (data.installed) {
for (var j = 0; j < data.installed.length; j++) {
installedNames[data.installed[j].name] = true;
}
}
for (var i = 0; i < data.catalog.length; i++) {
var card = renderCatalogSkillCard(data.catalog[i], installedNames);
card.style.animationDelay = (i * 0.06) + 's';
resultsDiv.appendChild(card);
}
}
// Show matching installed skills too
if (data.installed && data.installed.length > 0) {
for (var k = 0; k < data.installed.length; k++) {
var installedCard = renderSkillCard(data.installed[k]);
installedCard.style.animationDelay = ((data.catalog ? data.catalog.length : 0) + k) * 0.06 + 's';
installedCard.classList.add('skill-search-result');
resultsDiv.appendChild(installedCard);
}
}
if (resultsDiv.children.length === 0) {
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
}
}).catch(function(err) {
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
});
}
function renderCatalogSkillCard(entry, installedNames) {
var card = document.createElement('div');
card.className = 'ext-card ext-available skill-search-result';
var header = document.createElement('div');
header.className = 'ext-header';
var name = document.createElement('a');
name.className = 'ext-name';
name.textContent = entry.name || entry.slug;
name.href = 'https://clawhub.ai/skills/' + encodeURIComponent(entry.slug);
name.target = '_blank';
name.rel = 'noopener';
name.style.textDecoration = 'none';
name.style.color = 'inherit';
name.title = 'View on ClawHub';
header.appendChild(name);
if (entry.version) {
var version = document.createElement('span');
version.className = 'skill-version';
version.textContent = 'v' + entry.version;
header.appendChild(version);
}
card.appendChild(header);
if (entry.description) {
var desc = document.createElement('div');
desc.className = 'ext-desc';
desc.textContent = entry.description;
card.appendChild(desc);
}
// Metadata row: owner, stars, downloads, recency
var meta = document.createElement('div');
meta.className = 'ext-meta';
meta.style.fontSize = '11px';
meta.style.color = '#888';
meta.style.marginTop = '6px';
function addMetaSep() {
if (meta.children.length > 0) {
meta.appendChild(document.createTextNode(' \u00b7 '));
}
}
if (entry.owner) {
var ownerSpan = document.createElement('span');
ownerSpan.textContent = 'by ' + entry.owner;
meta.appendChild(ownerSpan);
}
if (entry.stars != null) {
addMetaSep();
var starsSpan = document.createElement('span');
starsSpan.textContent = entry.stars + ' stars';
meta.appendChild(starsSpan);
}
if (entry.downloads != null) {
addMetaSep();
var dlSpan = document.createElement('span');
dlSpan.textContent = formatCompactNumber(entry.downloads) + ' downloads';
meta.appendChild(dlSpan);
}
if (entry.updatedAt) {
var ago = formatTimeAgo(entry.updatedAt);
if (ago) {
addMetaSep();
var updatedSpan = document.createElement('span');
updatedSpan.textContent = 'updated ' + ago;
meta.appendChild(updatedSpan);
}
}
if (meta.children.length > 0) {
card.appendChild(meta);
}
var actions = document.createElement('div');
actions.className = 'ext-actions';
var slug = entry.slug || entry.name;
var isInstalled = installedNames[entry.name] || installedNames[slug];
if (isInstalled) {
var label = document.createElement('span');
label.className = 'ext-active-label';
label.textContent = 'Installed';
actions.appendChild(label);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = 'Install';
installBtn.addEventListener('click', (function(s, btn) {
return function() {
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
btn.disabled = true;
btn.textContent = 'Installing...';
installSkill(s, null, btn);
};
})(slug, installBtn));
actions.appendChild(installBtn);
}
card.appendChild(actions);
return card;
}
function formatCompactNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return '' + n;
}
function formatTimeAgo(epochMs) {
var now = Date.now();
var diff = now - epochMs;
if (diff < 0) return null;
var minutes = Math.floor(diff / 60000);
if (minutes < 60) return minutes <= 1 ? 'just now' : minutes + 'm ago';
var hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
var days = Math.floor(hours / 24);
if (days < 30) return days + 'd ago';
var months = Math.floor(days / 30);
if (months < 12) return months + 'mo ago';
return Math.floor(months / 12) + 'y ago';
}
function installSkill(nameOrSlug, url, btn) {
var body = { name: nameOrSlug };
if (url) body.url = url;
apiFetch('/api/skills/install', {
method: 'POST',
headers: { 'X-Confirm-Action': 'true' },
body: body,
}).then(function(res) {
if (res.success) {
showToast('Installed skill "' + nameOrSlug + '"', 'success');
} else {
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
}
loadSkills();
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
}).catch(function(err) {
showToast('Install failed: ' + err.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
});
}
function removeSkill(name) {
if (!confirm('Remove skill "' + name + '"?')) return;
apiFetch('/api/skills/' + encodeURIComponent(name), {
method: 'DELETE',
headers: { 'X-Confirm-Action': 'true' },
}).then(function(res) {
if (res.success) {
showToast('Removed skill "' + name + '"', 'success');
} else {
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
}
loadSkills();
}).catch(function(err) {
showToast('Remove failed: ' + err.message, 'error');
});
}
function installSkillFromForm() {
var name = document.getElementById('skill-install-name').value.trim();
if (!name) { showToast('Skill name is required', 'error'); return; }
var url = document.getElementById('skill-install-url').value.trim() || null;
if (url && !url.startsWith('https://')) {
showToast('URL must use HTTPS', 'error');
return;
}
if (!confirm('Install skill "' + name + '"?')) return;
installSkill(name, url, null);
document.getElementById('skill-install-name').value = '';
document.getElementById('skill-install-url').value = '';
}
// Wire up Enter key on search input
document.getElementById('skill-search-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') searchClawHub();
});
// --- Keyboard shortcuts ---
document.addEventListener('keydown', (e) => {
@@ -2720,10 +3047,10 @@ document.addEventListener('keydown', (e) => {
const tag = (e.target.tagName || '').toLowerCase();
const inInput = tag === 'input' || tag === 'textarea';
// Mod+1-5: switch tabs
if (mod && e.key >= '1' && e.key <= '5') {
// Mod+1-6: switch tabs
if (mod && e.key >= '1' && e.key <= '6') {
e.preventDefault();
const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions'];
const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions', 'skills'];
const idx = parseInt(e.key) - 1;
if (tabs[idx]) switchTab(tabs[idx]);
return;
+29
View File
@@ -42,6 +42,7 @@
<button data-tab="jobs">Jobs</button>
<button data-tab="routines">Routines</button>
<button data-tab="extensions">Extensions</button>
<button data-tab="skills">Skills</button>
<div class="spacer"></div>
<button class="status-logs-btn" data-tab="logs" title="Logs">Logs</button>
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
@@ -231,6 +232,34 @@
</div>
</div>
</div>
<!-- Skills Tab -->
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3>Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" placeholder="Search for skills...">
<button onclick="searchClawHub()">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3>Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3>Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()">Install</button>
</div>
</div>
</div>
</div>
</div>
<div id="toasts"></div>
+76
View File
@@ -2809,6 +2809,82 @@ mark {
transform: scale(0.98);
}
/* --- Skills tab --- */
.skill-search-box {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.skill-search-box input {
flex: 1;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
}
.skill-search-box input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
}
.skill-search-box button {
padding: 8px 20px;
background: var(--accent);
color: #09090b;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: background 0.2s, transform 0.2s;
}
.skill-search-box button:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.skill-trust {
font-size: 10px;
padding: 2px 6px;
border-radius: 8px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.skill-trust.trust-trusted {
background: rgba(52, 211, 153, 0.15);
color: var(--success);
}
.skill-trust.trust-installed {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
.skill-version {
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
@keyframes skillFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.skill-search-result {
animation: skillFadeIn 0.3s ease-out both;
}
/* --- Activity toolbar --- */
.activity-toolbar {
+3
View File
@@ -508,6 +508,9 @@ pub struct SkillSearchResponse {
pub catalog: Vec<serde_json::Value>,
pub installed: Vec<SkillInfo>,
pub registry_url: String,
/// If the catalog registry was unreachable or errored, a human-readable message.
#[serde(skip_serializing_if = "Option::is_none")]
pub catalog_error: Option<String>,
}
#[derive(Debug, Deserialize)]
+20 -4
View File
@@ -8,8 +8,12 @@ use crate::error::ConfigError;
pub struct SkillsConfig {
/// Whether the skills system is enabled.
pub enabled: bool,
/// Directory containing local skills (default: ~/.ironclaw/skills/).
/// Directory containing user-placed skills (default: ~/.ironclaw/skills/).
/// Skills here are loaded with `Trusted` trust level.
pub local_dir: PathBuf,
/// Directory containing registry-installed skills (default: ~/.ironclaw/installed_skills/).
/// Skills here are loaded with `Installed` trust level and get read-only tool access.
pub installed_dir: PathBuf,
/// Maximum number of skills that can be active simultaneously.
pub max_active_skills: usize,
/// Maximum total context tokens allocated to skill prompts.
@@ -19,15 +23,16 @@ pub struct SkillsConfig {
impl Default for SkillsConfig {
fn default() -> Self {
Self {
enabled: false,
enabled: true,
local_dir: default_skills_dir(),
installed_dir: default_installed_skills_dir(),
max_active_skills: 3,
max_context_tokens: 4000,
}
}
}
/// Get the default skills directory (~/.ironclaw/skills/).
/// Get the default user skills directory (~/.ironclaw/skills/).
fn default_skills_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
@@ -35,13 +40,24 @@ fn default_skills_dir() -> PathBuf {
.join("skills")
}
/// Get the default installed skills directory (~/.ironclaw/installed_skills/).
fn default_installed_skills_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join("installed_skills")
}
impl SkillsConfig {
pub(crate) fn resolve() -> Result<Self, ConfigError> {
Ok(Self {
enabled: parse_bool_env("SKILLS_ENABLED", false)?,
enabled: parse_bool_env("SKILLS_ENABLED", true)?,
local_dir: optional_env("SKILLS_DIR")?
.map(PathBuf::from)
.unwrap_or_else(default_skills_dir),
installed_dir: optional_env("SKILLS_INSTALLED_DIR")?
.map(PathBuf::from)
.unwrap_or_else(default_installed_skills_dir),
max_active_skills: parse_optional_env("SKILLS_MAX_ACTIVE", 3)?,
max_context_tokens: parse_optional_env("SKILLS_MAX_CONTEXT_TOKENS", 4000)?,
})
+73 -43
View File
@@ -220,9 +220,35 @@ async fn main() -> anyhow::Result<()> {
// ── Orchestrator / container job manager ────────────────────────────
// Proactive Docker detection
let docker_status = if config.sandbox.enabled {
let detection = ironclaw::sandbox::check_docker().await;
match detection.status {
ironclaw::sandbox::DockerStatus::Available => {
tracing::info!("Docker is available");
}
ironclaw::sandbox::DockerStatus::NotInstalled => {
tracing::warn!(
"Docker is not installed -- sandbox disabled for this session. {}",
detection.platform.install_hint()
);
}
ironclaw::sandbox::DockerStatus::NotRunning => {
tracing::warn!(
"Docker is installed but not running -- sandbox disabled for this session. {}",
detection.platform.start_hint()
);
}
ironclaw::sandbox::DockerStatus::Disabled => {}
}
detection.status
} else {
ironclaw::sandbox::DockerStatus::Disabled
};
let job_event_tx: Option<
tokio::sync::broadcast::Sender<(uuid::Uuid, ironclaw::channels::web::types::SseEvent)>,
> = if config.sandbox.enabled {
> = if config.sandbox.enabled && docker_status.is_ok() {
let (tx, _) = tokio::sync::broadcast::channel(256);
Some(tx)
} else {
@@ -233,51 +259,52 @@ async fn main() -> anyhow::Result<()> {
std::collections::VecDeque<ironclaw::orchestrator::api::PendingPrompt>,
>::new()));
let container_job_manager: Option<Arc<ContainerJobManager>> = if config.sandbox.enabled {
let token_store = TokenStore::new();
let job_config = ContainerJobConfig {
image: config.sandbox.image.clone(),
memory_limit_mb: config.sandbox.memory_limit_mb,
cpu_shares: config.sandbox.cpu_shares,
orchestrator_port: 50051,
claude_code_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
claude_code_oauth_token: ironclaw::config::ClaudeCodeConfig::extract_oauth_token(),
claude_code_model: config.claude_code.model.clone(),
claude_code_max_turns: config.claude_code.max_turns,
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
};
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
let container_job_manager: Option<Arc<ContainerJobManager>> =
if config.sandbox.enabled && docker_status.is_ok() {
let token_store = TokenStore::new();
let job_config = ContainerJobConfig {
image: config.sandbox.image.clone(),
memory_limit_mb: config.sandbox.memory_limit_mb,
cpu_shares: config.sandbox.cpu_shares,
orchestrator_port: 50051,
claude_code_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
claude_code_oauth_token: ironclaw::config::ClaudeCodeConfig::extract_oauth_token(),
claude_code_model: config.claude_code.model.clone(),
claude_code_max_turns: config.claude_code.max_turns,
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
};
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
// Start the orchestrator internal API in the background
let orchestrator_state = OrchestratorState {
llm: components.llm.clone(),
job_manager: Arc::clone(&jm),
token_store,
job_event_tx: job_event_tx.clone(),
prompt_queue: Arc::clone(&prompt_queue),
store: components.db.clone(),
secrets_store: components.secrets_store.clone(),
user_id: "default".to_string(),
};
// Start the orchestrator internal API in the background
let orchestrator_state = OrchestratorState {
llm: components.llm.clone(),
job_manager: Arc::clone(&jm),
token_store,
job_event_tx: job_event_tx.clone(),
prompt_queue: Arc::clone(&prompt_queue),
store: components.db.clone(),
secrets_store: components.secrets_store.clone(),
user_id: "default".to_string(),
};
tokio::spawn(async move {
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
tracing::error!("Orchestrator API failed: {}", e);
tokio::spawn(async move {
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
tracing::error!("Orchestrator API failed: {}", e);
}
});
if config.claude_code.enabled {
tracing::info!(
"Claude Code sandbox mode available (model: {}, max_turns: {})",
config.claude_code.model,
config.claude_code.max_turns
);
}
});
if config.claude_code.enabled {
tracing::info!(
"Claude Code sandbox mode available (model: {}, max_turns: {})",
config.claude_code.model,
config.claude_code.max_turns
);
}
Some(jm)
} else {
None
};
Some(jm)
} else {
None
};
// ── Channel setup ──────────────────────────────────────────────────
@@ -517,8 +544,10 @@ async fn main() -> anyhow::Result<()> {
heartbeat_enabled: config.heartbeat.enabled,
heartbeat_interval_secs: config.heartbeat.interval_secs,
sandbox_enabled: config.sandbox.enabled,
docker_status,
claude_code_enabled: config.claude_code.enabled,
routines_enabled: config.routines.enabled,
skills_enabled: config.skills.enabled,
channels: channel_names,
tunnel_url: active_tunnel
.as_ref()
@@ -558,6 +587,7 @@ async fn main() -> anyhow::Result<()> {
workspace: components.workspace,
extension_manager: components.extension_manager,
skill_registry: components.skill_registry,
skill_catalog: components.skill_catalog,
skills_config: config.skills.clone(),
hooks: components.hooks,
cost_guard: components.cost_guard,
+1 -1
View File
@@ -28,7 +28,7 @@ pub struct SandboxConfig {
impl Default for SandboxConfig {
fn default() -> Self {
Self {
enabled: false, // Disabled by default until Docker is confirmed available
enabled: true, // Startup check disables gracefully if Docker unavailable
policy: SandboxPolicy::ReadOnly,
timeout: Duration::from_secs(120),
memory_limit_mb: 2048,
+83 -15
View File
@@ -26,7 +26,7 @@
//! ```
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Duration;
use bollard::Docker;
@@ -490,40 +490,108 @@ impl ContainerRunner {
///
/// Tries these locations in order:
/// 1. `DOCKER_HOST` env var (bollard default)
/// 2. `/var/run/docker.sock` (Linux default)
/// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS)
/// 2. `/var/run/docker.sock` (Linux default; also used by OrbStack and Podman Desktop on macOS)
/// 3. `~/.docker/run/docker.sock` (Docker Desktop 4.13+ on macOS — primary user-owned socket)
/// 4. `~/.colima/default/docker.sock` (Colima — popular lightweight Docker Desktop alternative)
/// 5. `~/.rd/docker.sock` (Rancher Desktop on macOS)
/// 6. `$XDG_RUNTIME_DIR/docker.sock` (common rootless Docker socket on Linux)
/// 7. `/run/user/$UID/docker.sock` (rootless Docker fallback on Linux)
pub async fn connect_docker() -> Result<Docker> {
// First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock)
// First try bollard defaults (checks DOCKER_HOST env var, then /var/run/docker.sock).
// This covers Linux, OrbStack (updates the /var/run symlink), and any user with
// DOCKER_HOST set to their runtime's socket.
if let Ok(docker) = Docker::connect_with_local_defaults()
&& docker.ping().await.is_ok()
{
return Ok(docker);
}
// Try Docker Desktop socket (macOS)
if let Some(home) = std::env::var_os("HOME") {
let desktop_sock = std::path::Path::new(&home).join(".docker/run/docker.sock");
if desktop_sock.exists() {
let sock_str = desktop_sock.to_string_lossy();
if let Ok(docker) =
Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION)
&& docker.ping().await.is_ok()
{
return Ok(docker);
#[cfg(unix)]
{
// Try well-known user-owned socket locations for desktop and rootless runtimes.
// Docker Desktop 4.13+ (stabilised in 4.18) stopped creating the
// /var/run/docker.sock symlink by default and moved the API socket
// to ~/.docker/run/docker.sock.
for sock in unix_socket_candidates() {
if sock.exists() {
let sock_str = sock.to_string_lossy();
if let Ok(docker) =
Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION)
&& docker.ping().await.is_ok()
{
return Ok(docker);
}
}
}
}
Err(SandboxError::DockerNotAvailable {
reason: "Could not connect to Docker. Tried: default socket, ~/.docker/run/docker.sock"
reason: "Could not connect to Docker daemon. Tried: $DOCKER_HOST, \
/var/run/docker.sock, ~/.docker/run/docker.sock, \
~/.colima/default/docker.sock, ~/.rd/docker.sock, \
$XDG_RUNTIME_DIR/docker.sock, /run/user/$UID/docker.sock"
.to_string(),
})
}
#[cfg(unix)]
fn unix_socket_candidates() -> Vec<PathBuf> {
unix_socket_candidates_from_env(
std::env::var_os("HOME").map(PathBuf::from),
std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from),
std::env::var("UID").ok(),
)
}
#[cfg(unix)]
fn unix_socket_candidates_from_env(
home: Option<PathBuf>,
xdg_runtime_dir: Option<PathBuf>,
uid: Option<String>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
let mut push_unique = |path: PathBuf| {
if !candidates.iter().any(|existing| existing == &path) {
candidates.push(path);
}
};
if let Some(home) = home {
push_unique(home.join(".docker/run/docker.sock")); // Docker Desktop 4.13+
push_unique(home.join(".colima/default/docker.sock")); // Colima
push_unique(home.join(".rd/docker.sock")); // Rancher Desktop
}
if let Some(xdg_runtime_dir) = xdg_runtime_dir {
push_unique(xdg_runtime_dir.join("docker.sock"));
}
if let Some(uid) = uid.filter(|value| !value.is_empty()) {
push_unique(PathBuf::from(format!("/run/user/{uid}/docker.sock")));
}
candidates
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn test_unix_socket_candidates_include_rootless_paths() {
let candidates = unix_socket_candidates_from_env(
Some(PathBuf::from("/home/tester")),
Some(PathBuf::from("/run/user/1000")),
Some("1000".to_string()),
);
assert!(candidates.contains(&PathBuf::from("/home/tester/.docker/run/docker.sock")));
assert!(candidates.contains(&PathBuf::from("/home/tester/.colima/default/docker.sock")));
assert!(candidates.contains(&PathBuf::from("/home/tester/.rd/docker.sock")));
assert!(candidates.contains(&PathBuf::from("/run/user/1000/docker.sock")));
}
#[tokio::test]
async fn test_docker_connection() {
// This test requires Docker to be running
+233
View File
@@ -0,0 +1,233 @@
//! Proactive Docker detection with platform-specific guidance.
//!
//! Checks whether Docker is both installed (binary on PATH) and running
//! (daemon responding to ping), and provides platform-appropriate
//! installation or startup instructions when it is not.
//!
//! # Detection Limitations
//!
//! - **macOS**: High confidence. Detects both standard Docker Desktop socket
//! (`~/.docker/run/docker.sock`) and the default `/var/run/docker.sock`.
//!
//! - **Linux**: High confidence for standard installs. Rootless Docker uses
//! a different socket path (`/run/user/$UID/docker.sock`) which is now
//! checked by the fallback in `connect_docker()`. If `DOCKER_HOST` is set,
//! bollard's default connection still takes precedence.
//!
//! - **Windows**: Medium confidence. Binary detection uses `where.exe` which
//! works reliably. Daemon detection relies on bollard's default named pipe
//! connection (`//./pipe/docker_engine`) which works with Docker Desktop.
//! The Unix socket fallback in `connect_docker()` is a no-op on Windows,
//! so detection also probes `docker version`/`docker info` via CLI if the
//! named pipe is unavailable.
/// Docker daemon availability status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockerStatus {
/// Docker binary found on PATH and daemon responding to ping.
Available,
/// `docker` binary not found on PATH.
NotInstalled,
/// Binary found but daemon not responding.
NotRunning,
/// Sandbox feature not enabled (no check performed).
Disabled,
}
impl DockerStatus {
/// Returns true if Docker is available and ready.
pub fn is_ok(&self) -> bool {
matches!(self, DockerStatus::Available)
}
/// Human-readable status string.
pub fn as_str(&self) -> &'static str {
match self {
DockerStatus::Available => "available",
DockerStatus::NotInstalled => "not installed",
DockerStatus::NotRunning => "not running",
DockerStatus::Disabled => "disabled",
}
}
}
/// Host platform for install guidance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
MacOS,
Linux,
Windows,
}
impl Platform {
/// Detect the current platform.
pub fn current() -> Self {
match std::env::consts::OS {
"macos" => Platform::MacOS,
"windows" => Platform::Windows,
_ => Platform::Linux,
}
}
/// Installation instructions for Docker on this platform.
pub fn install_hint(&self) -> &'static str {
match self {
Platform::MacOS => {
"Install Docker Desktop: https://docs.docker.com/desktop/install/mac-install/"
}
Platform::Linux => "Install Docker Engine: https://docs.docker.com/engine/install/",
Platform::Windows => {
"Install Docker Desktop: https://docs.docker.com/desktop/install/windows-install/"
}
}
}
/// Instructions to start the Docker daemon on this platform.
pub fn start_hint(&self) -> &'static str {
match self {
Platform::MacOS => "Start Docker Desktop from Applications, or run: open -a Docker",
Platform::Linux => "Start the Docker daemon: sudo systemctl start docker",
Platform::Windows => "Start Docker Desktop from the Start menu",
}
}
}
/// Result of a Docker detection check.
pub struct DockerDetection {
pub status: DockerStatus,
pub platform: Platform,
}
/// Check whether Docker is installed and running.
///
/// 1. Checks if `docker` binary exists on PATH
/// 2. If found, tries to connect and ping the Docker daemon via `connect_docker()`
/// 3. Returns `Available`, `NotInstalled`, or `NotRunning`
pub async fn check_docker() -> DockerDetection {
let platform = Platform::current();
// Step 1: Check if docker binary is on PATH
if !docker_binary_exists() {
return DockerDetection {
status: DockerStatus::NotInstalled,
platform,
};
}
// Step 2: Try to connect to the daemon
if crate::sandbox::connect_docker().await.is_ok() {
return DockerDetection {
status: DockerStatus::Available,
platform,
};
}
// Windows fallback: if the named pipe probe fails but docker CLI can still
// reach the daemon/server, treat Docker as available.
#[cfg(windows)]
if docker_cli_daemon_reachable() {
return DockerDetection {
status: DockerStatus::Available,
platform,
};
}
DockerDetection {
status: DockerStatus::NotRunning,
platform,
}
}
/// Check if the `docker` binary exists on PATH.
fn docker_binary_exists() -> bool {
#[cfg(unix)]
{
std::process::Command::new("which")
.arg("docker")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[cfg(windows)]
{
std::process::Command::new("where")
.arg("docker")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
}
#[cfg(windows)]
fn docker_cli_daemon_reachable() -> bool {
let stdout = std::process::Stdio::null();
let stderr = std::process::Stdio::null();
// `docker version` requires daemon reachability for server fields.
let version_ok = std::process::Command::new("docker")
.args(["version", "--format", "{{.Server.Version}}"])
.stdout(stdout)
.stderr(stderr)
.status()
.is_ok_and(|s| s.success());
if version_ok {
return true;
}
// Fallback for environments where `docker version --format` behaves differently.
std::process::Command::new("docker")
.args(["info", "--format", "{{.ServerVersion}}"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_platform() {
let platform = Platform::current();
match platform {
Platform::MacOS | Platform::Linux | Platform::Windows => {}
}
}
#[test]
fn test_install_hint_not_empty() {
for platform in [Platform::MacOS, Platform::Linux, Platform::Windows] {
assert!(!platform.install_hint().is_empty());
assert!(!platform.start_hint().is_empty());
}
}
#[test]
fn test_docker_status_display() {
assert_eq!(DockerStatus::Available.as_str(), "available");
assert_eq!(DockerStatus::NotInstalled.as_str(), "not installed");
assert_eq!(DockerStatus::NotRunning.as_str(), "not running");
assert_eq!(DockerStatus::Disabled.as_str(), "disabled");
}
#[test]
fn test_docker_status_is_ok() {
assert!(DockerStatus::Available.is_ok());
assert!(!DockerStatus::NotInstalled.is_ok());
assert!(!DockerStatus::NotRunning.is_ok());
assert!(!DockerStatus::Disabled.is_ok());
}
#[tokio::test]
async fn test_check_docker_returns_valid_status() {
let result = check_docker().await;
match result.status {
DockerStatus::Available | DockerStatus::NotInstalled | DockerStatus::NotRunning => {}
DockerStatus::Disabled => panic!("check_docker should never return Disabled"),
}
}
}
+1 -1
View File
@@ -460,7 +460,7 @@ mod tests {
#[test]
fn test_builder_defaults() {
let manager = SandboxManagerBuilder::new().build();
assert!(!manager.config.enabled); // Disabled by default
assert!(manager.config.enabled); // Enabled by default (startup check disables if Docker unavailable)
}
#[test]
+2
View File
@@ -87,12 +87,14 @@
pub mod config;
pub mod container;
pub mod detect;
pub mod error;
pub mod manager;
pub mod proxy;
pub use config::{ResourceLimits, SandboxConfig, SandboxPolicy};
pub use container::{ContainerOutput, ContainerRunner, connect_docker};
pub use detect::{DockerDetection, DockerStatus, Platform, check_docker};
pub use error::{Result, SandboxError};
pub use manager::{ExecOutput, SandboxManager, SandboxManagerBuilder};
pub use proxy::{
+89 -5
View File
@@ -8,7 +8,8 @@
//! 5. Embeddings
//! 6. Channel configuration
//! 7. Extensions (tool installation from registry)
//! 8. Heartbeat (background tasks)
//! 8. Docker sandbox
//! 9. Heartbeat (background tasks)
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -140,7 +141,7 @@ impl SetupWizard {
print_step(1, 1, "Channel Configuration");
self.step_channels().await?;
} else {
let total_steps = 8;
let total_steps = 9;
// Step 1: Database
print_step(1, total_steps, "Database Connection");
@@ -191,8 +192,13 @@ impl SetupWizard {
print_step(7, total_steps, "Extensions");
self.step_extensions().await?;
// Step 8: Heartbeat
print_step(8, total_steps, "Background Tasks");
// Step 8: Docker Sandbox
print_step(8, total_steps, "Docker Sandbox");
self.step_docker_sandbox().await?;
self.persist_after_step().await;
// Step 9: Heartbeat
print_step(9, total_steps, "Background Tasks");
self.step_heartbeat()?;
self.persist_after_step().await;
}
@@ -1722,7 +1728,85 @@ impl SetupWizard {
Ok(())
}
/// Step 8: Heartbeat configuration.
/// Step 8: Docker Sandbox -- check Docker installation and availability.
async fn step_docker_sandbox(&mut self) -> Result<(), SetupError> {
print_info("IronClaw can execute code, run builds, and use tools inside Docker");
print_info("containers. This keeps your system safe -- commands from the LLM run");
print_info("in an isolated sandbox with no access to your credentials, limited");
print_info("filesystem access, and network traffic restricted to an allowlist.");
println!();
print_info("Without Docker, code execution tools (shell, file write) run directly");
print_info("on your machine with no isolation.");
println!();
if !confirm("Enable Docker sandbox?", false).map_err(SetupError::Io)? {
self.settings.sandbox.enabled = false;
print_info("Sandbox disabled. You can enable it later with SANDBOX_ENABLED=true.");
return Ok(());
}
// Check Docker availability
let detection = crate::sandbox::detect::check_docker().await;
match detection.status {
crate::sandbox::detect::DockerStatus::Available => {
self.settings.sandbox.enabled = true;
print_success("Docker is installed and running. Sandbox enabled.");
}
crate::sandbox::detect::DockerStatus::NotInstalled
| crate::sandbox::detect::DockerStatus::NotRunning => {
println!();
let not_installed =
detection.status == crate::sandbox::detect::DockerStatus::NotInstalled;
if not_installed {
print_error("Docker is not installed.");
print_info(detection.platform.install_hint());
} else {
print_error("Docker is installed but not running.");
print_info(detection.platform.start_hint());
}
println!();
let retry_prompt = if not_installed {
"Retry after installing Docker?"
} else {
"Retry after starting Docker?"
};
if confirm(retry_prompt, false).map_err(SetupError::Io)? {
let retry = crate::sandbox::detect::check_docker().await;
if retry.status.is_ok() {
self.settings.sandbox.enabled = true;
print_success(if not_installed {
"Docker is now available. Sandbox enabled."
} else {
"Docker is now running. Sandbox enabled."
});
} else {
self.settings.sandbox.enabled = false;
print_info(if not_installed {
"Docker still not available. Sandbox disabled for now."
} else {
"Docker still not responding. Sandbox disabled for now."
});
}
} else {
self.settings.sandbox.enabled = false;
print_info(if not_installed {
"Sandbox disabled. Install Docker and set SANDBOX_ENABLED=true later."
} else {
"Sandbox disabled. Start Docker and set SANDBOX_ENABLED=true later."
});
}
}
crate::sandbox::detect::DockerStatus::Disabled => {
self.settings.sandbox.enabled = false;
}
}
Ok(())
}
/// Step 9: Heartbeat configuration.
fn step_heartbeat(&mut self) -> Result<(), SetupError> {
print_info("Heartbeat runs periodic background tasks (e.g., checking your calendar,");
print_info("monitoring for notifications, running scheduled workflows).");
+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);
}
}
+1
View File
@@ -289,6 +289,7 @@ impl TestHarnessBuilder {
workspace: None,
extension_manager: None,
skill_registry: None,
skill_catalog: None,
skills_config: SkillsConfig::default(),
hooks,
cost_guard,
+230 -15
View File
@@ -155,7 +155,14 @@ impl Tool for SkillSearchTool {
let query = require_str(&params, "query")?;
// Search the ClawHub catalog (async, best-effort)
let catalog_results = self.catalog.search(query).await;
let catalog_outcome = self.catalog.search(query).await;
let catalog_error = catalog_outcome.error.clone();
// Enrich top results with detail data (stars, downloads, owner)
let mut catalog_entries = catalog_outcome.results;
self.catalog
.enrich_search_results(&mut catalog_entries, 5)
.await;
// Search locally loaded skills
let installed_names: Vec<String> = {
@@ -171,7 +178,7 @@ impl Tool for SkillSearchTool {
};
// Mark catalog entries that are already installed
let catalog_json: Vec<serde_json::Value> = catalog_results
let catalog_json: Vec<serde_json::Value> = catalog_entries
.iter()
.map(|entry| {
let is_installed = installed_names.iter().any(|n| {
@@ -185,6 +192,9 @@ impl Tool for SkillSearchTool {
"version": entry.version,
"score": entry.score,
"installed": is_installed,
"stars": entry.stars,
"downloads": entry.downloads,
"owner": entry.owner,
})
})
.collect();
@@ -218,13 +228,16 @@ impl Tool for SkillSearchTool {
.collect()
};
let output = serde_json::json!({
let mut output = serde_json::json!({
"catalog": catalog_json,
"catalog_count": catalog_json.len(),
"installed": local_matches,
"installed_count": local_matches.len(),
"registry_url": self.catalog.registry_url(),
});
if let Some(err) = catalog_error {
output["catalog_error"] = serde_json::Value::String(err);
}
Ok(ToolOutput::success(output, start.elapsed()))
}
@@ -298,7 +311,7 @@ impl Tool for SkillInstallTool {
fetch_skill_content(&download_url).await?
};
// Check for duplicates and get user_dir under a brief read lock.
// Check for duplicates and get install_dir under a brief read lock.
let (user_dir, skill_name_from_parse) = {
let guard = self
.registry
@@ -318,7 +331,7 @@ impl Tool for SkillInstallTool {
)));
}
(guard.user_dir().to_path_buf(), skill_name)
(guard.install_target_dir().to_path_buf(), skill_name)
};
// Perform async I/O (write to disk, validate round-trip) with no lock held.
@@ -383,14 +396,23 @@ pub fn validate_fetch_url(url_str: &str) -> Result<(), ToolError> {
.host_str()
.ok_or_else(|| ToolError::ExecutionFailed("URL has no host".to_string()))?;
// Check if host is an IP address and reject private ranges
if let Ok(ip) = host.parse::<std::net::IpAddr>()
&& (ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) || is_link_local_ip(&ip))
{
return Err(ToolError::ExecutionFailed(format!(
"URL points to a private/loopback/link-local address: {}",
host
)));
// Check if host is an IP address and reject private ranges.
// Unwrap IPv4-mapped IPv6 addresses (e.g. ::ffff:192.168.1.1) to catch
// SSRF bypasses that encode private IPv4 addresses as IPv6.
if let Ok(raw_ip) = host.parse::<std::net::IpAddr>() {
let ip = match raw_ip {
std::net::IpAddr::V6(v6) => v6
.to_ipv4_mapped()
.map(std::net::IpAddr::V4)
.unwrap_or(std::net::IpAddr::V6(v6)),
other => other,
};
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) || is_link_local_ip(&ip) {
return Err(ToolError::ExecutionFailed(format!(
"URL points to a private/loopback/link-local address: {}",
host
)));
}
}
// Reject common internal hostnames
@@ -435,6 +457,11 @@ fn is_link_local_ip(ip: &std::net::IpAddr) -> bool {
}
/// Fetch SKILL.md content from a URL with SSRF protection.
///
/// The ClawHub registry returns skill downloads as ZIP archives containing
/// `SKILL.md` and `_meta.json`. This function detects ZIP responses (by the
/// `PK\x03\x04` magic bytes) and extracts `SKILL.md` automatically. Plain
/// text responses are returned as-is.
pub async fn fetch_skill_content(url: &str) -> Result<String, ToolError> {
validate_fetch_url(url)?;
@@ -457,10 +484,28 @@ pub async fn fetch_skill_content(url: &str) -> Result<String, ToolError> {
)));
}
let content = response
.text()
// Limit download size to prevent memory exhaustion from large responses.
const MAX_DOWNLOAD_BYTES: usize = 10 * 1024 * 1024; // 10 MB
let bytes = response
.bytes()
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read response body: {}", e)))?;
if bytes.len() > MAX_DOWNLOAD_BYTES {
return Err(ToolError::ExecutionFailed(format!(
"Response too large: {} bytes (max {} bytes)",
bytes.len(),
MAX_DOWNLOAD_BYTES
)));
}
// Detect ZIP archive (PK\x03\x04 magic) and extract SKILL.md
let content = if bytes.starts_with(b"PK\x03\x04") {
extract_skill_from_zip(&bytes)?
} else {
String::from_utf8(bytes.to_vec()).map_err(|e| {
ToolError::ExecutionFailed(format!("Response is not valid UTF-8: {}", e))
})?
};
// Basic size check
if content.len() as u64 > crate::skills::MAX_PROMPT_FILE_SIZE {
@@ -474,6 +519,102 @@ pub async fn fetch_skill_content(url: &str) -> Result<String, ToolError> {
Ok(content)
}
/// Extract `SKILL.md` from a ZIP archive returned by the ClawHub download API.
///
/// Walks ZIP local file headers looking for an entry named `SKILL.md`.
/// Supports Store (method 0) and Deflate (method 8) compression.
fn extract_skill_from_zip(data: &[u8]) -> Result<String, ToolError> {
use flate2::read::DeflateDecoder;
use std::io::Read;
// SKILL.md files should never be larger than 1 MB.
const MAX_DECOMPRESSED: usize = 1_024 * 1_024;
let mut offset = 0;
while offset + 30 <= data.len() {
// Local file header signature = PK\x03\x04
if data[offset..offset + 4] != [0x50, 0x4B, 0x03, 0x04] {
break;
}
let compression = u16::from_le_bytes([data[offset + 8], data[offset + 9]]);
let compressed_size = u32::from_le_bytes([
data[offset + 18],
data[offset + 19],
data[offset + 20],
data[offset + 21],
]) as usize;
let uncompressed_size = u32::from_le_bytes([
data[offset + 22],
data[offset + 23],
data[offset + 24],
data[offset + 25],
]) as usize;
let name_len = u16::from_le_bytes([data[offset + 26], data[offset + 27]]) as usize;
let extra_len = u16::from_le_bytes([data[offset + 28], data[offset + 29]]) as usize;
let name_start = offset + 30;
let name_end = name_start + name_len;
if name_end > data.len() {
break;
}
let file_name = std::str::from_utf8(&data[name_start..name_end]).unwrap_or("");
let data_start = name_end
.checked_add(extra_len)
.ok_or_else(|| ToolError::ExecutionFailed("ZIP header offset overflow".to_string()))?;
let data_end = data_start
.checked_add(compressed_size)
.ok_or_else(|| ToolError::ExecutionFailed("ZIP header size overflow".to_string()))?;
if file_name == "SKILL.md" {
if data_end > data.len() {
return Err(ToolError::ExecutionFailed(
"ZIP archive truncated".to_string(),
));
}
if uncompressed_size > MAX_DECOMPRESSED {
return Err(ToolError::ExecutionFailed(
"ZIP entry too large to decompress safely".to_string(),
));
}
let raw = &data[data_start..data_end];
let decompressed = match compression {
0 => raw.to_vec(), // Store
8 => {
// Deflate -- wrap with a read limit to guard against ZIP bombs
// where the declared size is small but decompressed output is huge.
let mut decoder = DeflateDecoder::new(raw).take(MAX_DECOMPRESSED as u64);
let mut buf = Vec::with_capacity(uncompressed_size.min(MAX_DECOMPRESSED));
decoder.read_to_end(&mut buf).map_err(|e| {
ToolError::ExecutionFailed(format!("Failed to decompress SKILL.md: {}", e))
})?;
buf
}
other => {
return Err(ToolError::ExecutionFailed(format!(
"Unsupported ZIP compression method: {}",
other
)));
}
};
return String::from_utf8(decompressed).map_err(|e| {
ToolError::ExecutionFailed(format!("SKILL.md in archive is not valid UTF-8: {}", e))
});
}
// Skip to next entry
offset = data_end;
}
Err(ToolError::ExecutionFailed(
"ZIP archive does not contain SKILL.md".to_string(),
))
}
// ── skill_remove ────────────────────────────────────────────────────────
pub struct SkillRemoveTool {
@@ -675,4 +816,78 @@ mod tests {
let err = super::validate_fetch_url("file:///etc/passwd").unwrap_err();
assert!(err.to_string().contains("Only HTTPS"));
}
#[test]
fn test_extract_skill_from_zip_deflate() {
// Build a real ZIP with flate2 + manual header construction.
use flate2::Compression;
use flate2::write::DeflateEncoder;
use std::io::Write;
let skill_md = b"---\nname: test\n---\n# Test Skill\n";
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
encoder.write_all(skill_md).unwrap();
let compressed = encoder.finish().unwrap();
let mut zip = Vec::new();
// Local file header
zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); // signature
zip.extend_from_slice(&[0x14, 0x00]); // version needed (2.0)
zip.extend_from_slice(&[0x00, 0x00]); // flags
zip.extend_from_slice(&[0x08, 0x00]); // compression: deflate
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // mod time/date
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // crc32 (unused)
zip.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); // compressed size
zip.extend_from_slice(&(skill_md.len() as u32).to_le_bytes()); // uncompressed size
zip.extend_from_slice(&8u16.to_le_bytes()); // filename length
zip.extend_from_slice(&0u16.to_le_bytes()); // extra field length
zip.extend_from_slice(b"SKILL.md");
zip.extend_from_slice(&compressed);
let result = super::extract_skill_from_zip(&zip).unwrap();
assert_eq!(result, "---\nname: test\n---\n# Test Skill\n");
}
#[test]
fn test_extract_skill_from_zip_store() {
let skill_md = b"---\nname: stored\n---\n# Stored\n";
let mut zip = Vec::new();
// Local file header
zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]);
zip.extend_from_slice(&[0x0A, 0x00]); // version needed (1.0)
zip.extend_from_slice(&[0x00, 0x00]); // flags
zip.extend_from_slice(&[0x00, 0x00]); // compression: store
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // mod time/date
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // crc32
zip.extend_from_slice(&(skill_md.len() as u32).to_le_bytes()); // compressed = uncompressed
zip.extend_from_slice(&(skill_md.len() as u32).to_le_bytes());
zip.extend_from_slice(&8u16.to_le_bytes()); // filename length
zip.extend_from_slice(&0u16.to_le_bytes()); // extra field length
zip.extend_from_slice(b"SKILL.md");
zip.extend_from_slice(skill_md);
let result = super::extract_skill_from_zip(&zip).unwrap();
assert_eq!(result, "---\nname: stored\n---\n# Stored\n");
}
#[test]
fn test_extract_skill_from_zip_missing_skill_md() {
let mut zip = Vec::new();
zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]);
zip.extend_from_slice(&[0x0A, 0x00]); // version
zip.extend_from_slice(&[0x00, 0x00]); // flags
zip.extend_from_slice(&[0x00, 0x00]); // compression: store
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // mod time/date
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // crc32
zip.extend_from_slice(&2u32.to_le_bytes()); // compressed size
zip.extend_from_slice(&2u32.to_le_bytes()); // uncompressed size
zip.extend_from_slice(&10u16.to_le_bytes()); // filename length
zip.extend_from_slice(&0u16.to_le_bytes()); // extra field length
zip.extend_from_slice(b"_meta.json");
zip.extend_from_slice(b"{}");
let err = super::extract_skill_from_zip(&zip).unwrap_err();
assert!(err.to_string().contains("does not contain SKILL.md"));
}
}