mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
943 lines
35 KiB
Rust
943 lines
35 KiB
Rust
//! URL-based skill installation: fetch, validate, and write SKILL.md from a remote URL.
|
|
|
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
|
use std::path::Path;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::ops_discover::{discover_workflows_inner, is_workspace_trusted};
|
|
use super::ops_parse::parse_workflow_md_str;
|
|
use super::ops_types::{WorkflowFrontmatter, WorkflowScope, MAX_NAME_LEN, SKILL_MD, WORKFLOW_MD};
|
|
|
|
/// Strip userinfo, query, and fragment from a URL for safe inclusion in
|
|
/// observability tags. Returns `<scheme>://<host>[:<port>]<path>` on success,
|
|
/// or `"<unparseable>"` on parse failure. Never returns the raw URL — even
|
|
/// validated install URLs may carry signed query params or embedded creds we
|
|
/// don't want flowing to Sentry.
|
|
fn redact_url(raw: &str) -> String {
|
|
match url::Url::parse(raw) {
|
|
Ok(u) => {
|
|
let scheme = u.scheme();
|
|
let host = u.host_str().unwrap_or("");
|
|
let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
|
|
let path = u.path();
|
|
format!("{scheme}://{host}{port}{path}")
|
|
}
|
|
Err(_) => "<unparseable>".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Default wall-clock budget for the SKILL.md fetch.
|
|
pub const DEFAULT_INSTALL_TIMEOUT_SECS: u64 = 60;
|
|
/// Hard ceiling callers can request via `timeout_secs`.
|
|
pub const MAX_INSTALL_TIMEOUT_SECS: u64 = 600;
|
|
/// Upper bound on raw URL length accepted by [`validate_install_url`].
|
|
pub const MAX_INSTALL_URL_LEN: usize = 2048;
|
|
/// Upper bound on the fetched SKILL.md body. Single-file skills rarely exceed
|
|
/// a few KB; the 1 MiB cap here is a defensive limit against a hostile or
|
|
/// misconfigured host streaming an unbounded response into memory.
|
|
pub const MAX_WORKFLOW_MD_BYTES: usize = 1024 * 1024;
|
|
const ALLOW_LOCAL_HTTP_ENV: &str = "OPENHUMAN_SKILL_INSTALL_ALLOW_LOCAL_HTTP";
|
|
|
|
/// Input for [`install_workflow_from_url`]. Mirrors the `skills.install_from_url`
|
|
/// JSON-RPC payload.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct InstallWorkflowFromUrlParams {
|
|
/// Remote SKILL.md URL. Must be `https://`, resolve to a non-private host
|
|
/// (see [`validate_install_url`]), and point at a `.md` file after
|
|
/// github.com `/blob/` normalization.
|
|
pub url: String,
|
|
/// Optional wall-clock budget override, in seconds. Defaults to
|
|
/// [`DEFAULT_INSTALL_TIMEOUT_SECS`] and is capped at
|
|
/// [`MAX_INSTALL_TIMEOUT_SECS`].
|
|
#[serde(default)]
|
|
pub timeout_secs: Option<u64>,
|
|
}
|
|
|
|
/// Outcome of a successful install. `new_skills` is the set of skill slugs
|
|
/// that appeared in the catalog since the start of the call (post-discovery
|
|
/// minus pre-discovery).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct InstallWorkflowFromUrlOutcome {
|
|
/// The URL the caller submitted, trimmed.
|
|
pub url: String,
|
|
/// Human-readable install log — typically `Fetched N bytes from <url>\n
|
|
/// Installed to <path>`. Repurposed from the old npx stdout field so the
|
|
/// UI success panel keeps the same `<details>` layout.
|
|
pub stdout: String,
|
|
/// Non-fatal warnings surfaced during parse (e.g. deprecated top-level
|
|
/// `version`/`author`/`tags`). Empty on the happy path. Repurposed from
|
|
/// the old npx stderr field.
|
|
pub stderr: String,
|
|
/// Slugs that appeared in the workspace skill catalog as a result of the
|
|
/// install. Usually one, empty only when the SKILL.md could not be
|
|
/// enumerated by discovery (rare — indicates workspace trust mismatch).
|
|
pub new_skills: Vec<String>,
|
|
}
|
|
|
|
/// Install a skill by fetching its `SKILL.md` directly over HTTPS and writing
|
|
/// it to `<workspace>/.openhuman/skills/<slug>/SKILL.md`.
|
|
///
|
|
/// Design rationale: openhuman's skill discovery scans
|
|
/// `<workspace>/.openhuman/skills/` (plus `~/.openhuman/skills/` and legacy
|
|
/// paths), **not** the per-agent subdirectories that the vercel-labs `skills`
|
|
/// CLI writes to (`./claude-code/skills/`, `./cursor/skills/`, …). The CLI's
|
|
/// agent ecosystem is incompatible with openhuman's skill layout, so we fetch
|
|
/// the SKILL.md file directly and install it into a layout discovery sees.
|
|
///
|
|
/// Validation applied before any network I/O:
|
|
/// * URL length, scheme (`https` only), and host safety via
|
|
/// [`validate_install_url`] — rejects loopback, private, link-local,
|
|
/// multicast, shared-address ranges, `localhost`, and `.local` / `.localhost`
|
|
/// mDNS-style hostnames.
|
|
/// * `github.com/<o>/<r>/blob/<b>/<p>` is rewritten to the raw
|
|
/// `raw.githubusercontent.com/<o>/<r>/<b>/<p>` equivalent so humans can
|
|
/// paste the URL they see in the browser.
|
|
/// * The path must end in `.md` (case-insensitive). Repo/tree URLs and
|
|
/// tarballs are rejected with `unsupported url form:`.
|
|
/// * `timeout_secs` is clamped to [`MAX_INSTALL_TIMEOUT_SECS`].
|
|
///
|
|
/// Runtime:
|
|
/// * Body size is capped by [`MAX_WORKFLOW_MD_BYTES`] (1 MiB). The advertised
|
|
/// `Content-Length` is checked up front; the buffered body length is
|
|
/// checked again after the download as defense against a lying header.
|
|
/// * Frontmatter is validated — `name` and `description` are required per
|
|
/// the agentskills.io spec.
|
|
/// * The slug is derived from `metadata.id` when present, otherwise the
|
|
/// sanitized `name` field. If the target directory already contains a
|
|
/// `SKILL.md`, the install is treated as an idempotent success and reports
|
|
/// that the skill is already installed. Other directory collisions remain
|
|
/// fatal, and existing files are never silently overwritten.
|
|
/// * Write is atomic: `SKILL.md.tmp` in the target dir, then `rename` on
|
|
/// success.
|
|
///
|
|
/// On success the full post-install skills catalog is re-discovered and the
|
|
/// outcome includes the list of skill slugs that appeared since the start of
|
|
/// the call.
|
|
pub async fn install_workflow_from_url(
|
|
workspace_dir: &Path,
|
|
params: InstallWorkflowFromUrlParams,
|
|
) -> Result<InstallWorkflowFromUrlOutcome, String> {
|
|
let home = dirs::home_dir();
|
|
install_workflow_from_url_with_home(workspace_dir, params, home.as_deref()).await
|
|
}
|
|
|
|
pub(crate) fn should_report_install_fetch_status(status: reqwest::StatusCode) -> bool {
|
|
!status.is_success() && !status.is_client_error()
|
|
}
|
|
|
|
pub(crate) async fn install_workflow_from_url_with_home(
|
|
workspace_dir: &Path,
|
|
params: InstallWorkflowFromUrlParams,
|
|
home: Option<&Path>,
|
|
) -> Result<InstallWorkflowFromUrlOutcome, String> {
|
|
let raw_url = params.url.trim().to_string();
|
|
validate_install_url(&raw_url)?;
|
|
|
|
let timeout_secs = params
|
|
.timeout_secs
|
|
.unwrap_or(DEFAULT_INSTALL_TIMEOUT_SECS)
|
|
.clamp(1, MAX_INSTALL_TIMEOUT_SECS);
|
|
|
|
let fetch_url = normalize_install_url(&raw_url)?;
|
|
|
|
// Second-layer SSRF guard: a public-looking hostname can still resolve
|
|
// to a loopback / private / link-local address (DNS-to-private-IP). We
|
|
// resolve the host up-front and reject if any returned IP is private.
|
|
// Known caveat: this does not fully prevent DNS rebinding — reqwest's
|
|
// resolver may see different answers than ours. Closing that gap requires
|
|
// pinning a `SocketAddr` and passing it to reqwest via a custom resolver,
|
|
// tracked separately.
|
|
if !allow_local_http_install_url(&fetch_url) {
|
|
validate_resolved_host(&fetch_url).await?;
|
|
}
|
|
|
|
let redacted_raw_url = redact_url(&raw_url);
|
|
let redacted_fetch_url = redact_url(&fetch_url);
|
|
|
|
tracing::debug!(
|
|
raw_url = %redacted_raw_url,
|
|
fetch_url = %redacted_fetch_url,
|
|
workspace = %workspace_dir.display(),
|
|
timeout_secs = timeout_secs,
|
|
"[skills] install_workflow_from_url: entry"
|
|
);
|
|
|
|
let trusted_before = is_workspace_trusted(workspace_dir);
|
|
let before: std::collections::HashSet<String> =
|
|
discover_workflows_inner(home, Some(workspace_dir), trusted_before)
|
|
.into_iter()
|
|
.map(|s| s.name)
|
|
.collect();
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(timeout_secs))
|
|
.build()
|
|
.map_err(|e| format!("fetch failed: build http client: {e}"))?;
|
|
|
|
tracing::info!(
|
|
fetch_url = %redacted_fetch_url,
|
|
"[skills] install_workflow_from_url: fetching SKILL.md"
|
|
);
|
|
|
|
let response = match client.get(&fetch_url).send().await {
|
|
Ok(resp) => resp,
|
|
Err(e) => {
|
|
let (failure, msg) = if e.is_timeout() {
|
|
("timeout", format!("fetch timed out after {timeout_secs}s"))
|
|
} else {
|
|
("transport", format!("fetch failed: {e}"))
|
|
};
|
|
crate::core::observability::report_error(
|
|
msg.as_str(),
|
|
"skills",
|
|
"install_fetch",
|
|
&[("url", redacted_fetch_url.as_str()), ("failure", failure)],
|
|
);
|
|
return Err(msg);
|
|
}
|
|
};
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
// A 4xx (esp. 404/410) means the requested SKILL.md is gone or the URL
|
|
// is wrong — expected user/catalog input state, surfaced to the UI as
|
|
// "skill not found". Don't page Sentry for it (TAURI-RUST-CGE: ~1,446
|
|
// events / 72 users on `openhuman@0.57.53`, almost all 404). Keep
|
|
// reporting 5xx — a genuine remote failure is still Sentry-actionable.
|
|
// The `Err(msg)` return is unchanged in both cases so the UI always
|
|
// surfaces the failure.
|
|
let status_str = status.as_u16().to_string();
|
|
let msg = format!(
|
|
"fetch failed: {fetch_url} returned status {}",
|
|
status.as_u16()
|
|
);
|
|
let report_msg = format!(
|
|
"fetch failed: {redacted_fetch_url} returned status {}",
|
|
status.as_u16()
|
|
);
|
|
if should_report_install_fetch_status(status) {
|
|
crate::core::observability::report_error(
|
|
report_msg.as_str(),
|
|
"skills",
|
|
"install_fetch",
|
|
&[
|
|
("url", redacted_fetch_url.as_str()),
|
|
("status", status_str.as_str()),
|
|
("failure", "non_2xx"),
|
|
],
|
|
);
|
|
} else {
|
|
tracing::debug!(
|
|
fetch_url = %redacted_fetch_url,
|
|
status = status.as_u16(),
|
|
"[skills] install_workflow_from_url: skipped Sentry report for user/catalog fetch status"
|
|
);
|
|
}
|
|
return Err(msg);
|
|
}
|
|
|
|
if let Some(len) = response.content_length() {
|
|
if len > MAX_WORKFLOW_MD_BYTES as u64 {
|
|
return Err(format!(
|
|
"fetch too large: {} bytes exceeds {MAX_WORKFLOW_MD_BYTES} limit",
|
|
len
|
|
));
|
|
}
|
|
}
|
|
|
|
let bytes = match response.bytes().await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
if e.is_timeout() {
|
|
return Err(format!("fetch timed out after {timeout_secs}s"));
|
|
}
|
|
return Err(format!("fetch failed: reading body: {e}"));
|
|
}
|
|
};
|
|
|
|
if bytes.len() > MAX_WORKFLOW_MD_BYTES {
|
|
return Err(format!(
|
|
"fetch too large: {} bytes exceeds {MAX_WORKFLOW_MD_BYTES} limit",
|
|
bytes.len()
|
|
));
|
|
}
|
|
|
|
let content = String::from_utf8(bytes.to_vec())
|
|
.map_err(|e| format!("invalid SKILL.md: body is not valid utf-8: {e}"))?;
|
|
|
|
let (frontmatter, _body, parse_warnings) =
|
|
parse_workflow_md_str(&content).ok_or_else(|| {
|
|
"invalid SKILL.md: frontmatter block opened with `---` but never terminated".to_string()
|
|
})?;
|
|
|
|
if frontmatter.name.trim().is_empty() {
|
|
return Err("invalid SKILL.md: missing required field 'name'".to_string());
|
|
}
|
|
if frontmatter.description.trim().is_empty() {
|
|
return Err("invalid SKILL.md: missing required field 'description'".to_string());
|
|
}
|
|
|
|
let slug = derive_install_slug(&frontmatter)?;
|
|
|
|
// Install to user scope (`~/.openhuman/skills/<slug>`), which `discover_workflows`
|
|
// scans unconditionally. Project scope (`<ws>/.openhuman/skills/`) is gated on
|
|
// a `<ws>/.openhuman/trust` marker and would render the install invisible to the
|
|
// skills list until the user opts the workspace into trust.
|
|
let skills_root = home
|
|
.ok_or_else(|| "write failed: unable to resolve home directory".to_string())?
|
|
.join(".openhuman")
|
|
.join("skills");
|
|
let target_dir = skills_root.join(&slug);
|
|
if target_dir.exists() {
|
|
let target_file = target_dir.join(SKILL_MD);
|
|
if !target_file.is_file() {
|
|
return Err(format!(
|
|
"skill install target already exists but has no {SKILL_MD}: {}",
|
|
target_dir.display()
|
|
));
|
|
}
|
|
|
|
tracing::info!(
|
|
raw_url = %redacted_raw_url,
|
|
fetch_url = %redacted_fetch_url,
|
|
slug = %slug,
|
|
target = %target_file.display(),
|
|
"[skills] install_workflow_from_url: already installed"
|
|
);
|
|
|
|
return Ok(InstallWorkflowFromUrlOutcome {
|
|
url: raw_url,
|
|
stdout: format!(
|
|
"Skill {slug:?} is already installed at {}",
|
|
target_file.display()
|
|
),
|
|
stderr: parse_warnings.join("\n"),
|
|
new_skills: Vec::new(),
|
|
});
|
|
}
|
|
|
|
std::fs::create_dir_all(&target_dir).map_err(|e| {
|
|
format!(
|
|
"write failed: create directory {}: {e}",
|
|
target_dir.display()
|
|
)
|
|
})?;
|
|
|
|
let target_file = target_dir.join(SKILL_MD);
|
|
let temp_file = target_dir.join("SKILL.md.tmp");
|
|
|
|
// Roll the partial install back if either filesystem op fails so the
|
|
// next retry isn't blocked by a leftover empty directory. Cleanup is
|
|
// best-effort — if it fails, we surface the original write error.
|
|
let write_result: Result<(), String> = std::fs::write(&temp_file, &content)
|
|
.map_err(|e| format!("write failed: {}: {e}", temp_file.display()))
|
|
.and_then(|_| {
|
|
std::fs::rename(&temp_file, &target_file)
|
|
.map_err(|e| format!("write failed: rename {}: {e}", target_file.display()))
|
|
});
|
|
|
|
if let Err(e) = write_result {
|
|
let _ = std::fs::remove_file(&temp_file);
|
|
if let Err(rm_err) = std::fs::remove_dir(&target_dir) {
|
|
tracing::warn!(
|
|
target_dir = %target_dir.display(),
|
|
error = %rm_err,
|
|
"[skills] install_workflow_from_url: rollback remove_dir failed (non-fatal)"
|
|
);
|
|
} else {
|
|
tracing::warn!(
|
|
target_dir = %target_dir.display(),
|
|
"[skills] install_workflow_from_url: rolled back partial install after write failure"
|
|
);
|
|
}
|
|
return Err(e);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let perms = std::fs::Permissions::from_mode(0o644);
|
|
if let Err(e) = std::fs::set_permissions(&target_file, perms) {
|
|
tracing::warn!(
|
|
target = %target_file.display(),
|
|
error = %e,
|
|
"[skills] install_workflow_from_url: chmod 0644 failed (non-fatal)"
|
|
);
|
|
}
|
|
}
|
|
|
|
let trusted_after = is_workspace_trusted(workspace_dir);
|
|
let after = discover_workflows_inner(home, Some(workspace_dir), trusted_after);
|
|
let new_skills: Vec<String> = after
|
|
.into_iter()
|
|
.map(|s| s.name)
|
|
.filter(|name| !before.contains(name))
|
|
.collect();
|
|
|
|
tracing::info!(
|
|
raw_url = %redacted_raw_url,
|
|
fetch_url = %redacted_fetch_url,
|
|
slug = %slug,
|
|
bytes = content.len(),
|
|
new_count = new_skills.len(),
|
|
"[skills] install_workflow_from_url: completed"
|
|
);
|
|
|
|
let stdout = format!(
|
|
"Fetched {} bytes from {fetch_url}\nInstalled to {}",
|
|
content.len(),
|
|
target_file.display()
|
|
);
|
|
let stderr = parse_warnings.join("\n");
|
|
|
|
// Notify live agent sessions so they refresh their `## Installed Skills`
|
|
// catalogue mid-conversation (see `Agent::refresh_workflows`).
|
|
let _ = crate::core::event_bus::publish_global(
|
|
crate::core::event_bus::DomainEvent::WorkflowsChanged {
|
|
reason: "install".to_string(),
|
|
},
|
|
);
|
|
|
|
Ok(InstallWorkflowFromUrlOutcome {
|
|
url: raw_url,
|
|
stdout,
|
|
stderr,
|
|
new_skills,
|
|
})
|
|
}
|
|
|
|
/// Input for [`uninstall_workflow`]. Mirrors the `skills.uninstall` JSON-RPC payload.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct UninstallWorkflowParams {
|
|
/// On-disk slug of the installed skill — the directory name under
|
|
/// `~/.openhuman/skills/<slug>/`. Retained as `name` for wire-format
|
|
/// back-compat with pre-existing clients; semantics are slug-only.
|
|
pub name: String,
|
|
}
|
|
|
|
/// Outcome of a successful uninstall.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct UninstallWorkflowOutcome {
|
|
/// The normalised slug that was removed.
|
|
pub name: String,
|
|
/// Absolute on-disk path that was deleted (post-canonicalisation).
|
|
pub removed_path: String,
|
|
/// Scope the uninstall applied to. Always `User` today.
|
|
pub scope: WorkflowScope,
|
|
}
|
|
|
|
/// Remove an installed user-scope SKILL.md skill from `~/.openhuman/skills/`.
|
|
///
|
|
/// Only user-scope uninstalls are supported. Resolution is defensive:
|
|
/// canonicalises paths, refuses symlinks, requires SKILL.md to be present.
|
|
///
|
|
/// `home_dir_override` is for tests; production callers pass `None`.
|
|
pub fn uninstall_workflow(
|
|
params: UninstallWorkflowParams,
|
|
home_dir_override: Option<&Path>,
|
|
) -> Result<UninstallWorkflowOutcome, String> {
|
|
let trimmed = params.name.trim().to_string();
|
|
if trimmed.is_empty() {
|
|
return Err("skill name is required".to_string());
|
|
}
|
|
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
|
|
log::warn!(
|
|
"[skills] uninstall_workflow: rejected name with path separators name={:?}",
|
|
trimmed
|
|
);
|
|
return Err(format!(
|
|
"skill name '{trimmed}' must not contain path separators"
|
|
));
|
|
}
|
|
if trimmed.len() > MAX_NAME_LEN {
|
|
return Err(format!(
|
|
"skill name is {} chars (max {MAX_NAME_LEN})",
|
|
trimmed.len()
|
|
));
|
|
}
|
|
|
|
let home = match home_dir_override
|
|
.map(|p| p.to_path_buf())
|
|
.or_else(dirs::home_dir)
|
|
{
|
|
Some(h) => h,
|
|
None => return Err("could not resolve user home directory".to_string()),
|
|
};
|
|
|
|
// Workflows created post-rename live under `~/.openhuman/workflows/`; older
|
|
// ones under `~/.openhuman/skills/` or the legacy `~/.agents/skills/` root.
|
|
// Resolve whichever root actually holds this id so delete works regardless
|
|
// of when/where it was authored — and matches every user root
|
|
// discover_workflows_inner surfaces (else a listed workflow can't be
|
|
// uninstalled).
|
|
let openhuman_dir = home.join(".openhuman");
|
|
let root = [
|
|
openhuman_dir.join("workflows"),
|
|
openhuman_dir.join("skills"),
|
|
home.join(".agents").join("skills"),
|
|
]
|
|
.into_iter()
|
|
.find(|r| r.join(&trimmed).exists());
|
|
let root = match root {
|
|
Some(r) => r,
|
|
None => return Err(format!("workflow '{trimmed}' is not installed")),
|
|
};
|
|
|
|
let root_meta = std::fs::symlink_metadata(&root)
|
|
.map_err(|e| format!("stat {} failed: {e}", root.display()))?;
|
|
if root_meta.file_type().is_symlink() {
|
|
log::warn!(
|
|
"[workflows] uninstall_workflow: refused symlinked root path={}",
|
|
root.display()
|
|
);
|
|
return Err(format!(
|
|
"workflows root {} is a symlink — refusing to resolve",
|
|
root.display()
|
|
));
|
|
}
|
|
|
|
let canonical_root = std::fs::canonicalize(&root)
|
|
.map_err(|e| format!("canonicalize {} failed: {e}", root.display()))?;
|
|
|
|
let candidate = root.join(&trimmed);
|
|
match std::fs::symlink_metadata(&candidate) {
|
|
Ok(m) if m.file_type().is_symlink() => {
|
|
log::warn!(
|
|
"[skills] uninstall_workflow: refused symlinked alias name={trimmed} path={}",
|
|
candidate.display()
|
|
);
|
|
return Err(format!(
|
|
"skill '{trimmed}' is a symlinked alias — refusing to resolve"
|
|
));
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
return Err(format!("skill '{trimmed}' is not installed"));
|
|
}
|
|
Err(e) => {
|
|
return Err(format!("stat {} failed: {e}", candidate.display()));
|
|
}
|
|
}
|
|
|
|
let canonical_candidate = std::fs::canonicalize(&candidate).map_err(|e| {
|
|
if e.kind() == std::io::ErrorKind::NotFound {
|
|
format!("skill '{trimmed}' is not installed")
|
|
} else {
|
|
format!("canonicalize {} failed: {e}", candidate.display())
|
|
}
|
|
})?;
|
|
|
|
if !canonical_candidate.starts_with(&canonical_root) {
|
|
log::warn!(
|
|
"[skills] uninstall_workflow: path escape rejected candidate={} root={}",
|
|
canonical_candidate.display(),
|
|
canonical_root.display()
|
|
);
|
|
return Err(format!(
|
|
"refused to remove {} — path escapes skills root",
|
|
canonical_candidate.display()
|
|
));
|
|
}
|
|
|
|
let meta = std::fs::symlink_metadata(&canonical_candidate)
|
|
.map_err(|e| format!("stat {} failed: {e}", canonical_candidate.display()))?;
|
|
if meta.file_type().is_symlink() || !meta.is_dir() {
|
|
return Err(format!(
|
|
"{} is not a directory — refusing to remove",
|
|
canonical_candidate.display()
|
|
));
|
|
}
|
|
|
|
if !canonical_candidate.join(WORKFLOW_MD).exists()
|
|
&& !canonical_candidate.join(SKILL_MD).exists()
|
|
{
|
|
return Err(format!(
|
|
"{} does not look like a workflow (missing {WORKFLOW_MD})",
|
|
canonical_candidate.display()
|
|
));
|
|
}
|
|
|
|
log::info!(
|
|
"[skills] uninstall_workflow: removing name={trimmed} path={}",
|
|
canonical_candidate.display()
|
|
);
|
|
std::fs::remove_dir_all(&canonical_candidate)
|
|
.map_err(|e| format!("remove {} failed: {e}", canonical_candidate.display()))?;
|
|
|
|
// Notify live agent sessions to drop the removed skill from their
|
|
// `## Installed Skills` catalogue (see `Agent::refresh_workflows`).
|
|
let _ = crate::core::event_bus::publish_global(
|
|
crate::core::event_bus::DomainEvent::WorkflowsChanged {
|
|
reason: "uninstall".to_string(),
|
|
},
|
|
);
|
|
|
|
Ok(UninstallWorkflowOutcome {
|
|
name: trimmed,
|
|
removed_path: canonical_candidate.display().to_string(),
|
|
scope: WorkflowScope::User,
|
|
})
|
|
}
|
|
|
|
/// Rewrite `github.com/<o>/<r>/blob/<branch>/<path>` into its raw counterpart
|
|
/// so a URL copied from a browser's GitHub page resolves to the file body
|
|
/// instead of the HTML wrapper. Any other host is returned unchanged.
|
|
///
|
|
/// Also enforces that the final path ends in `.md` (case-insensitive). Tree,
|
|
/// commit, and whole-repo URLs are rejected here — they require a
|
|
/// fundamentally different install path (recursive fetch / tarball) that is
|
|
/// out of scope for single-file SKILL.md installs.
|
|
pub(crate) fn normalize_install_url(raw: &str) -> Result<String, String> {
|
|
let parsed =
|
|
url::Url::parse(raw).map_err(|e| format!("unsupported url form: parse {raw:?}: {e}"))?;
|
|
let host = parsed.host_str().unwrap_or("").to_ascii_lowercase();
|
|
|
|
let normalized = if host == "github.com" {
|
|
let segments: Vec<&str> = parsed
|
|
.path_segments()
|
|
.map(|it| it.collect())
|
|
.unwrap_or_default();
|
|
if segments.len() >= 5 && segments[2] == "blob" {
|
|
let owner = segments[0];
|
|
let repo = segments[1];
|
|
let branch = segments[3];
|
|
let rest = segments[4..].join("/");
|
|
format!("https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{rest}")
|
|
} else if segments.len() >= 3 && (segments[2] == "tree" || segments[2] == "raw") {
|
|
return Err(format!(
|
|
"unsupported url form: only direct SKILL.md links are supported, got {raw:?} (tree/dir URLs are not yet supported)"
|
|
));
|
|
} else if segments.len() <= 2 {
|
|
return Err(format!(
|
|
"unsupported url form: only direct SKILL.md links are supported, got {raw:?} (whole-repo URLs are not yet supported)"
|
|
));
|
|
} else {
|
|
raw.to_string()
|
|
}
|
|
} else {
|
|
raw.to_string()
|
|
};
|
|
|
|
let check = url::Url::parse(&normalized)
|
|
.map_err(|e| format!("unsupported url form: parse normalized {normalized:?}: {e}"))?;
|
|
let path_lower = check.path().to_ascii_lowercase();
|
|
if !path_lower.ends_with(".md") {
|
|
return Err(format!(
|
|
"unsupported url form: path must end in .md, got {normalized:?}"
|
|
));
|
|
}
|
|
|
|
Ok(normalized)
|
|
}
|
|
|
|
/// Derive the install directory slug from the SKILL.md frontmatter.
|
|
///
|
|
/// Prefers `metadata.id` (the spec-aligned identifier) when present. Falls
|
|
/// back to a sanitized form of `name`:
|
|
/// * lowercase ASCII
|
|
/// * non-alphanumeric runs collapsed to a single `-`
|
|
/// * leading/trailing `-` trimmed
|
|
///
|
|
/// Rejects the empty string and paths that would escape the skills root
|
|
/// (`..`, `/`, `\`). Max length is [`MAX_NAME_LEN`].
|
|
pub(crate) fn derive_install_slug(fm: &WorkflowFrontmatter) -> Result<String, String> {
|
|
let candidate = fm
|
|
.metadata
|
|
.get("id")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| fm.name.clone());
|
|
|
|
let mut out = String::with_capacity(candidate.len());
|
|
let mut last_dash = false;
|
|
for ch in candidate.chars() {
|
|
if ch.is_ascii_alphanumeric() {
|
|
out.push(ch.to_ascii_lowercase());
|
|
last_dash = false;
|
|
} else if !last_dash && !out.is_empty() {
|
|
out.push('-');
|
|
last_dash = true;
|
|
}
|
|
}
|
|
while out.ends_with('-') {
|
|
out.pop();
|
|
}
|
|
|
|
if out.is_empty() {
|
|
return Err(
|
|
"invalid SKILL.md: cannot derive slug from empty name/id — set a value in frontmatter"
|
|
.to_string(),
|
|
);
|
|
}
|
|
if out.len() > MAX_NAME_LEN {
|
|
return Err(format!(
|
|
"invalid SKILL.md: derived slug {out:?} exceeds {MAX_NAME_LEN} chars"
|
|
));
|
|
}
|
|
if out.contains("..") || out.contains('/') || out.contains('\\') {
|
|
return Err(format!(
|
|
"invalid SKILL.md: derived slug {out:?} contains forbidden path components"
|
|
));
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
/// Validate a remote skill install URL. Returns `Ok(())` when the URL is
|
|
/// well-formed, uses `https`, and points at a public host.
|
|
///
|
|
/// Rejects:
|
|
/// * empty string or > [`MAX_INSTALL_URL_LEN`] bytes
|
|
/// * non-`https` schemes (including `http`, `ftp`, `file`, `git+ssh`)
|
|
/// * missing or empty host
|
|
/// * `localhost`, `*.localhost`, `*.local`
|
|
/// * IPv4 literals in loopback (127.0.0.0/8), private (10/8, 172.16/12,
|
|
/// 192.168/16), link-local (169.254/16), shared-address (100.64/10),
|
|
/// multicast, broadcast, or unspecified (0.0.0.0) ranges
|
|
/// * IPv6 literals in loopback (::1), unspecified (::), unique-local
|
|
/// (fc00::/7), link-local (fe80::/10), or multicast (ff00::/8)
|
|
pub fn validate_install_url(raw: &str) -> Result<(), String> {
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("url must not be empty".to_string());
|
|
}
|
|
if trimmed.len() > MAX_INSTALL_URL_LEN {
|
|
return Err(format!(
|
|
"url exceeds max {MAX_INSTALL_URL_LEN} chars (got {})",
|
|
trimmed.len()
|
|
));
|
|
}
|
|
let parsed = url::Url::parse(trimmed).map_err(|e| format!("invalid url {trimmed:?}: {e}"))?;
|
|
if parsed.scheme() != "https" {
|
|
if allow_local_http_install_url(trimmed) {
|
|
return Ok(());
|
|
}
|
|
return Err(format!(
|
|
"url scheme {:?} not allowed; https only",
|
|
parsed.scheme()
|
|
));
|
|
}
|
|
let host = parsed
|
|
.host_str()
|
|
.ok_or_else(|| format!("url {trimmed:?} has no host"))?;
|
|
if host.is_empty() {
|
|
return Err(format!("url {trimmed:?} has empty host"));
|
|
}
|
|
if is_blocked_install_host(host) {
|
|
return Err(format!(
|
|
"host {host:?} not allowed (loopback/private/link-local/multicast)"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn allow_local_http_install_url(raw: &str) -> bool {
|
|
if std::env::var(ALLOW_LOCAL_HTTP_ENV).ok().as_deref() != Some("1") {
|
|
return false;
|
|
}
|
|
let Ok(parsed) = url::Url::parse(raw) else {
|
|
return false;
|
|
};
|
|
if parsed.scheme() != "http" {
|
|
return false;
|
|
}
|
|
let Some(host) = parsed.host_str() else {
|
|
return false;
|
|
};
|
|
matches!(host, "localhost" | "127.0.0.1" | "::1")
|
|
}
|
|
|
|
/// Resolve the host in the given URL and reject if any returned IP falls in
|
|
/// loopback / private / link-local / multicast / unspecified ranges.
|
|
///
|
|
/// Covers the DNS-to-private-IP SSRF vector: a public-looking hostname can
|
|
/// still resolve to 127.0.0.1 / 169.254.x / fc00::/7 etc., which
|
|
/// [`validate_install_url`] alone cannot detect because it only inspects
|
|
/// literal IP hosts.
|
|
///
|
|
/// Caveat: does **not** close the DNS-rebinding gap. `reqwest` performs its
|
|
/// own DNS lookup on the GET below, and a rebinding server can answer the
|
|
/// check with a public IP and answer reqwest with a private one. Full
|
|
/// mitigation requires resolving to a `SocketAddr` here and passing it to
|
|
/// reqwest via a custom resolver that only honours the pinned address.
|
|
pub async fn validate_resolved_host(raw_url: &str) -> Result<(), String> {
|
|
let parsed = url::Url::parse(raw_url)
|
|
.map_err(|e| format!("invalid url {raw_url:?} during DNS guard: {e}"))?;
|
|
let host = parsed
|
|
.host_str()
|
|
.ok_or_else(|| format!("url {raw_url:?} has no host (DNS guard)"))?;
|
|
// `tokio::net::lookup_host` wants "host:port". Default https → 443.
|
|
let port = parsed.port_or_known_default().unwrap_or(443);
|
|
// IPv6 literal hosts come back bracketed from `url::Url`; `lookup_host`
|
|
// needs the bracketed form for IPv6 to parse correctly.
|
|
let lookup_target = if parsed
|
|
.host()
|
|
.map(|h| matches!(h, url::Host::Ipv6(_)))
|
|
.unwrap_or(false)
|
|
{
|
|
format!("[{host}]:{port}")
|
|
} else {
|
|
format!("{host}:{port}")
|
|
};
|
|
|
|
tracing::debug!(
|
|
host = %host,
|
|
port = port,
|
|
"[skills] validate_resolved_host: resolving"
|
|
);
|
|
|
|
let mut addrs = tokio::net::lookup_host(&lookup_target)
|
|
.await
|
|
.map_err(|e| format!("dns lookup failed for {host:?}: {e}"))?
|
|
.peekable();
|
|
if addrs.peek().is_none() {
|
|
return Err(format!("host {host:?} resolved to no IP addresses"));
|
|
}
|
|
for addr in addrs {
|
|
let ip = addr.ip();
|
|
match ip {
|
|
std::net::IpAddr::V4(v4) => {
|
|
if is_private_v4(&v4) {
|
|
tracing::warn!(
|
|
host = %host,
|
|
resolved = %v4,
|
|
"[skills] validate_resolved_host: rejected private IPv4"
|
|
);
|
|
return Err(format!(
|
|
"host {host:?} resolved to non-public IPv4 {v4} (loopback/private/link-local)"
|
|
));
|
|
}
|
|
}
|
|
std::net::IpAddr::V6(v6) => {
|
|
if is_private_v6(&v6) {
|
|
tracing::warn!(
|
|
host = %host,
|
|
resolved = %v6,
|
|
"[skills] validate_resolved_host: rejected private IPv6"
|
|
);
|
|
return Err(format!(
|
|
"host {host:?} resolved to non-public IPv6 {v6} (loopback/ula/link-local)"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_blocked_install_host(host: &str) -> bool {
|
|
let lower = host.to_ascii_lowercase();
|
|
// url::Url::host_str returns IPv6 literals wrapped in brackets (e.g. "[::1]").
|
|
// Strip them before attempting Ipv6Addr parse.
|
|
let stripped = lower
|
|
.strip_prefix('[')
|
|
.and_then(|s| s.strip_suffix(']'))
|
|
.unwrap_or(&lower);
|
|
if stripped == "localhost" || stripped.ends_with(".localhost") || stripped.ends_with(".local") {
|
|
return true;
|
|
}
|
|
if let Ok(v4) = stripped.parse::<Ipv4Addr>() {
|
|
return is_private_v4(&v4);
|
|
}
|
|
if let Ok(v6) = stripped.parse::<Ipv6Addr>() {
|
|
return is_private_v6(&v6);
|
|
}
|
|
false
|
|
}
|
|
|
|
fn is_private_v4(ip: &Ipv4Addr) -> bool {
|
|
if ip.is_private()
|
|
|| ip.is_loopback()
|
|
|| ip.is_link_local()
|
|
|| ip.is_broadcast()
|
|
|| ip.is_unspecified()
|
|
|| ip.is_multicast()
|
|
{
|
|
return true;
|
|
}
|
|
let [a, b, _, _] = ip.octets();
|
|
// 100.64.0.0/10 shared address (CGN)
|
|
if a == 100 && (64..=127).contains(&b) {
|
|
return true;
|
|
}
|
|
// 0.0.0.0/8
|
|
if a == 0 {
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn is_private_v6(ip: &Ipv6Addr) -> bool {
|
|
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
|
|
return true;
|
|
}
|
|
let first = ip.segments()[0];
|
|
// fc00::/7 unique-local
|
|
if (first & 0xfe00) == 0xfc00 {
|
|
return true;
|
|
}
|
|
// fe80::/10 link-local
|
|
if (first & 0xffc0) == 0xfe80 {
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod install_fetch_tests {
|
|
use super::*;
|
|
use axum::{http::StatusCode, Router};
|
|
use tempfile::TempDir;
|
|
|
|
/// Spawn a throwaway local HTTP server whose every route answers with
|
|
/// `status`. Returns its `http://127.0.0.1:<port>` base.
|
|
async fn spawn_status(status: StatusCode) -> String {
|
|
let app = Router::new().fallback(move || async move { status });
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
format!("http://127.0.0.1:{}", addr.port())
|
|
}
|
|
|
|
/// TAURI-RUST-CGE: a non-2xx skill-install fetch always returns the
|
|
/// user-facing `Err` (so the UI surfaces "skill not found" / the failure),
|
|
/// for both a 4xx (which is NOT reported to Sentry) and a 5xx (which is).
|
|
/// Exercises both branches of the non-2xx handling; the report-suppression
|
|
/// polarity itself is asserted by `is_skills_install_client_error_event` in
|
|
/// observability. Uses the local-HTTP install escape hatch so the loopback
|
|
/// mock passes URL validation.
|
|
#[tokio::test]
|
|
async fn non_2xx_install_fetch_returns_err_for_4xx_and_5xx() {
|
|
let _env = crate::openhuman::config::TEST_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
std::env::set_var(ALLOW_LOCAL_HTTP_ENV, "1");
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
for status in [StatusCode::NOT_FOUND, StatusCode::INTERNAL_SERVER_ERROR] {
|
|
let base = spawn_status(status).await;
|
|
let url = format!("{base}/skill.md");
|
|
let err = install_workflow_from_url_with_home(
|
|
tmp.path(),
|
|
InstallWorkflowFromUrlParams {
|
|
url,
|
|
timeout_secs: Some(5),
|
|
},
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("a non-2xx fetch must return Err so the UI surfaces it");
|
|
assert!(
|
|
err.contains(&format!("returned status {}", status.as_u16())),
|
|
"error must surface the status to the UI: {err}"
|
|
);
|
|
}
|
|
|
|
std::env::remove_var(ALLOW_LOCAL_HTTP_ENV);
|
|
}
|
|
}
|