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