use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use runx_contracts::sha256_prefixed;
use runx_runtime::registry::{
InstallCandidate, InstallLocalSkillOptions, materialization_cache_path,
materialization_digest_marker, parse_registry_ref, split_skill_id,
};
use crate::official_skills::official_skill_entry_by_name;
use crate::registry::{self, RegistryAction, RegistryPlan};
pub(super) struct SkillResolverOptions<'a> {
pub(super) env: &'a BTreeMap<String, String>,
pub(super) registry: Option<&'a str>,
pub(super) expected_digest: Option<&'a str>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum SkillRefKind {
ExplicitPath,
ExportedShim,
WorkspaceLocal,
Installed,
Official,
Registry,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum RegistryTrustState {
Trusted,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ResolvedSkillRef {
pub(crate) kind: SkillRefKind,
pub(crate) skill_id: Option<String>,
pub(crate) version: Option<String>,
pub(crate) digest: Option<String>,
pub(crate) profile_digest: Option<String>,
pub(crate) registry_source: Option<String>,
pub(crate) registry_source_fingerprint: Option<String>,
pub(crate) trust_state: Option<RegistryTrustState>,
pub(crate) trust_tier: Option<String>,
pub(crate) registry_key_id: Option<String>,
pub(crate) runnable_path: PathBuf,
}
pub(crate) fn resolve_skill_ref_details(
skill_ref: &Path,
cwd: &Path,
options: SkillResolverOptions<'_>,
) -> Result<ResolvedSkillRef, String> {
if skill_ref.exists() {
let path = resolve_exported_skill_shim(skill_ref)?;
let kind = if path == skill_ref {
SkillRefKind::ExplicitPath
} else {
SkillRefKind::ExportedShim
};
return Ok(local_resolved(kind, path));
}
let raw_ref = skill_ref.to_string_lossy();
let parsed = parse_registry_ref(&raw_ref);
if is_registry_prefixed_ref(&raw_ref) {
if split_skill_id(&parsed.skill_id).is_err() {
return Err(format!(
"Registry ref '{}' is ambiguous. Use '<owner>/<name>' instead.",
raw_ref
));
}
return resolve_registry_skill(&parsed.skill_id, parsed.version.as_deref(), cwd, options);
}
if is_bare_skill_ref(skill_ref) {
return resolve_bare_skill_ref(skill_ref, &raw_ref, cwd, options);
}
if is_explicit_registry_ref(&raw_ref, &parsed.skill_id) {
return resolve_registry_skill(&parsed.skill_id, parsed.version.as_deref(), cwd, options);
}
Ok(local_resolved(
SkillRefKind::ExplicitPath,
skill_ref.to_path_buf(),
))
}
fn resolve_bare_skill_ref(
skill_ref: &Path,
raw_ref: &str,
cwd: &Path,
options: SkillResolverOptions<'_>,
) -> Result<ResolvedSkillRef, String> {
if let Some(path) = resolve_installed_or_workspace_skill(raw_ref, cwd, &options)? {
return Ok(path);
}
if let Some(entry) = official_skill_entry_by_name(raw_ref) {
return resolve_official_skill(entry.skill_id, entry.version, entry.digest, cwd, options);
}
Err(format!(
"could not resolve skill ref '{}'; tried {} and {}. Search with `runx registry search {}` or run a registry ref directly with `runx skill <owner>/<name>@<version>`.",
skill_ref.display(),
cwd.join("skills").join(skill_ref).display(),
runx_runtime::resolve_runx_workspace_base(options.env, cwd)
.join("skills")
.join(skill_ref)
.display(),
raw_ref
))
}
fn resolve_installed_or_workspace_skill(
name: &str,
cwd: &Path,
options: &SkillResolverOptions<'_>,
) -> Result<Option<ResolvedSkillRef>, String> {
for root in installed_roots(cwd, options.env) {
let candidate = root.join(name);
if candidate.exists() {
let path = resolve_exported_skill_shim(&candidate)?;
let kind = if path == candidate {
if root == cwd.join("skills") {
SkillRefKind::WorkspaceLocal
} else {
SkillRefKind::Installed
}
} else {
SkillRefKind::ExportedShim
};
return Ok(Some(local_resolved(kind, path)));
}
}
Ok(None)
}
fn installed_roots(cwd: &Path, env: &BTreeMap<String, String>) -> Vec<PathBuf> {
let mut roots = vec![cwd.join("skills")];
let workspace_root = runx_runtime::resolve_runx_workspace_base(env, cwd).join("skills");
if !roots.contains(&workspace_root) {
roots.push(workspace_root);
}
roots
}
fn resolve_official_skill(
skill_id: &str,
version: &str,
digest: &str,
cwd: &Path,
options: SkillResolverOptions<'_>,
) -> Result<ResolvedSkillRef, String> {
let registry_override = official_registry_override(options.env, options.registry);
let expected_digest = options.expected_digest.unwrap_or(digest);
materialize_trusted_registry_skill(
RegistryMaterializationRequest {
skill_id,
version: Some(version),
cache_root: CacheRoot::Official,
registry_override: Some(®istry_override),
expected_digest: Some(expected_digest),
kind: SkillRefKind::Official,
cwd,
},
options,
)
}
fn resolve_registry_skill(
skill_id: &str,
version: Option<&str>,
cwd: &Path,
options: SkillResolverOptions<'_>,
) -> Result<ResolvedSkillRef, String> {
materialize_trusted_registry_skill(
RegistryMaterializationRequest {
skill_id,
version,
cache_root: CacheRoot::Registry,
registry_override: None,
expected_digest: options.expected_digest,
kind: SkillRefKind::Registry,
cwd,
},
options,
)
}
fn materialize_trusted_registry_skill(
request: RegistryMaterializationRequest<'_>,
options: SkillResolverOptions<'_>,
) -> Result<ResolvedSkillRef, String> {
let RegistryMaterializationRequest {
skill_id,
version,
cache_root,
registry_override,
expected_digest,
kind,
cwd,
} = request;
let env = options.env;
let registry = registry_override.or(options.registry);
let mut plan = RegistryPlan {
action: RegistryAction::Install,
subject: skill_id.to_owned(),
registry: registry.map(ToOwned::to_owned),
registry_dir: None,
version: version.map(ToOwned::to_owned),
expected_digest: expected_digest.map(ToOwned::to_owned),
destination: None,
owner: None,
profile: None,
trust_tier: None,
limit: None,
upsert: false,
json: true,
};
let target = registry::resolve_registry_target(&plan, env, cwd);
let source_description = registry::registry_source_description(&target);
let source_fingerprint = registry_source_fingerprint(&target);
let source_authority = target.manifest_source_authority();
let (mut candidate, _acquisition) = registry::install_candidate(&plan, target, env, cwd)
.map_err(|error| error.into_message())?;
if cache_root == CacheRoot::Official {
candidate.manifest_source_authority =
Some(runx_runtime::registry::RegistryManifestSourceAuthority::OfficialRunx);
}
canonicalize_candidate_ref(&mut candidate);
let identity = registry_cache_identity(&candidate)?;
let destination_root =
destination_root_for_cache(cache_root, env, cwd, &source_fingerprint, &identity)?;
plan.expected_digest = expected_digest.map(ToOwned::to_owned);
let install = runx_runtime::registry::install_local_skill(
&candidate,
&InstallLocalSkillOptions {
destination_root: destination_root.to_path_buf(),
expected_digest: plan.expected_digest,
trusted_manifest_keys: registry::trusted_manifest_keys_from_env_for_source(
env,
candidate
.manifest_source_authority
.clone()
.unwrap_or(source_authority),
)
.map_err(|error| error.into_message())?,
},
)
.map_err(crate::registry::RegistryCliError::from)
.map_err(|error| error.into_message())?;
let runnable_path = install
.destination
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| {
format!(
"registry install returned invalid path {}",
install.destination.display()
)
})?;
restore_runner_manifest_from_profile_state(&runnable_path)?;
if cache_root == CacheRoot::Official {
sync_packaged_official_skill_assets(&runnable_path, skill_id)?;
}
Ok(ResolvedSkillRef {
kind,
skill_id: identity.skill_id,
version: identity.version,
digest: Some(install.digest),
profile_digest: install.profile_digest,
registry_source: Some(source_description),
registry_source_fingerprint: Some(source_fingerprint),
trust_state: Some(RegistryTrustState::Trusted),
trust_tier: install.trust_tier.as_ref().map(trust_tier_string),
registry_key_id: candidate
.signed_manifest
.as_ref()
.map(|manifest| manifest.signer.key_id.clone()),
runnable_path,
})
}
struct RegistryMaterializationRequest<'a> {
skill_id: &'a str,
version: Option<&'a str>,
cache_root: CacheRoot,
registry_override: Option<&'a str>,
expected_digest: Option<&'a str>,
kind: SkillRefKind,
cwd: &'a Path,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CacheRoot {
Official,
Registry,
}
fn destination_root_for_cache(
cache_root: CacheRoot,
env: &BTreeMap<String, String>,
cwd: &Path,
source_fingerprint: &str,
identity: &RegistryCacheIdentity,
) -> Result<PathBuf, String> {
let skill_id = identity
.skill_id
.as_deref()
.ok_or_else(|| "registry skill is missing skill_id".to_owned())?;
let (owner, name) = split_skill_id(skill_id).map_err(|error| error.to_string())?;
let version = identity
.version
.as_deref()
.ok_or_else(|| "registry skill is missing version".to_owned())?;
let digest = identity
.digest
.as_deref()
.ok_or_else(|| "registry skill is missing signed digest".to_owned())?;
let root = match cache_root {
CacheRoot::Official => registry::official_skills_cache_root(env, cwd),
CacheRoot::Registry => {
registry::registry_skills_cache_root(env, cwd).join(source_fingerprint)
}
};
Ok(materialization_cache_path(
&root,
owner,
name,
version,
&cache_identity_digest(
digest,
identity.profile_digest.as_deref(),
identity.package_digest.as_deref(),
),
))
}
fn registry_cache_identity(candidate: &InstallCandidate) -> Result<RegistryCacheIdentity, String> {
let manifest = candidate.signed_manifest.as_ref().ok_or_else(|| {
format!(
"registry signed manifest is required for {}",
candidate.r#ref
)
})?;
Ok(RegistryCacheIdentity {
skill_id: candidate
.skill_id
.clone()
.or_else(|| Some(manifest.skill_id.clone())),
version: candidate
.version
.clone()
.or_else(|| Some(manifest.version.clone())),
digest: Some(manifest.digest.clone()),
profile_digest: manifest.profile_digest.clone(),
package_digest: candidate.package_digest.clone(),
})
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RegistryCacheIdentity {
skill_id: Option<String>,
version: Option<String>,
digest: Option<String>,
profile_digest: Option<String>,
package_digest: Option<String>,
}
fn canonicalize_candidate_ref(candidate: &mut InstallCandidate) {
if let (Some(skill_id), Some(version)) = (&candidate.skill_id, &candidate.version) {
candidate.r#ref = format!("{skill_id}@{version}");
}
}
fn cache_identity_digest(
digest: &str,
profile_digest: Option<&str>,
package_digest: Option<&str>,
) -> String {
sha256_prefixed(
materialization_digest_marker(digest, profile_digest, package_digest).as_bytes(),
)
}
fn registry_source_fingerprint(target: ®istry::RegistryTarget) -> String {
let source = target.fingerprint_source();
sha256_prefixed(source.as_bytes())
.trim_start_matches("sha256:")
.chars()
.take(16)
.collect()
}
fn official_registry_override(
env: &BTreeMap<String, String>,
override_value: Option<&str>,
) -> String {
override_value
.or_else(|| env.get("RUNX_REGISTRY_URL").map(String::as_str))
.or_else(|| env.get("RUNX_REGISTRY_DIR").map(String::as_str))
.unwrap_or("https://runx.ai")
.to_owned()
}
fn restore_runner_manifest_from_profile_state(skill_dir: &Path) -> Result<(), String> {
let manifest_path = skill_dir.join("X.yaml");
if manifest_path.exists() {
return Ok(());
}
let state_path = skill_dir.join(".runx").join("profile.json");
let state_raw = fs::read_to_string(&state_path).map_err(|error| {
format!(
"failed to read profile state {}: {error}",
state_path.display()
)
})?;
let state = serde_json::from_str::<runx_contracts::JsonValue>(&state_raw).map_err(|error| {
format!(
"failed to parse profile state {}: {error}",
state_path.display()
)
})?;
let profile = state
.as_object()
.and_then(|object| object.get("profile"))
.and_then(|value| value.as_object())
.ok_or_else(|| {
format!(
"profile state {} is missing profile data",
state_path.display()
)
})?;
let document = profile
.get("document")
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
format!(
"profile state {} is missing profile document",
state_path.display()
)
})?;
let expected_digest = profile.get("digest").and_then(|value| value.as_str());
if let Some(expected_digest) = expected_digest {
let actual_digest = sha256_prefixed(document.as_bytes());
if !digest_matches(expected_digest, &actual_digest) {
return Err(format!(
"profile state {} digest mismatch: expected {}, received {}",
state_path.display(),
expected_digest,
actual_digest
));
}
}
fs::write(&manifest_path, document).map_err(|error| {
format!(
"failed to restore runner manifest {}: {error}",
manifest_path.display()
)
})
}
fn sync_packaged_official_skill_assets(
target_skill_dir: &Path,
skill_id: &str,
) -> Result<(), String> {
let Some(packaged_skill_dir) = packaged_official_skill_dir(skill_id)? else {
return Ok(());
};
for entry in fs::read_dir(&packaged_skill_dir).map_err(|error| {
format!(
"failed to read packaged official skill {}: {error}",
packaged_skill_dir.display()
)
})? {
let entry = entry.map_err(|error| {
format!(
"failed to read packaged official skill entry {}: {error}",
packaged_skill_dir.display()
)
})?;
let entry_name = entry.file_name();
if entry_name == "SKILL.md" {
continue;
}
let source_path = entry.path();
let target_path = target_skill_dir.join(&entry_name);
let file_type = entry.file_type().map_err(|error| {
format!(
"failed to stat packaged official skill entry {}: {error}",
source_path.display()
)
})?;
if file_type.is_dir() {
if target_path.exists() {
fs::remove_dir_all(&target_path).map_err(|error| {
format!(
"failed to replace official skill asset directory {}: {error}",
target_path.display()
)
})?;
}
copy_dir_all(&source_path, &target_path)?;
} else if file_type.is_file() {
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to create official skill asset parent {}: {error}",
parent.display()
)
})?;
}
fs::copy(&source_path, &target_path).map_err(|error| {
format!(
"failed to copy official skill asset {} to {}: {error}",
source_path.display(),
target_path.display()
)
})?;
}
}
Ok(())
}
fn packaged_official_skill_dir(skill_id: &str) -> Result<Option<PathBuf>, String> {
let (_owner, name) = split_skill_id(skill_id).map_err(|error| error.to_string())?;
Ok(packaged_official_skill_roots()
.into_iter()
.map(|root| root.join(name))
.find(|candidate| candidate.exists()))
}
fn packaged_official_skill_roots() -> Vec<PathBuf> {
vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../skills")]
}
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> {
fs::create_dir_all(target).map_err(|error| {
format!(
"failed to create official skill asset directory {}: {error}",
target.display()
)
})?;
for entry in fs::read_dir(source).map_err(|error| {
format!(
"failed to read official skill asset directory {}: {error}",
source.display()
)
})? {
let entry = entry.map_err(|error| {
format!(
"failed to read official skill asset entry {}: {error}",
source.display()
)
})?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry.file_type().map_err(|error| {
format!(
"failed to stat official skill asset {}: {error}",
source_path.display()
)
})?;
if file_type.is_dir() {
copy_dir_all(&source_path, &target_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &target_path).map_err(|error| {
format!(
"failed to copy official skill asset {} to {}: {error}",
source_path.display(),
target_path.display()
)
})?;
}
}
Ok(())
}
fn digest_matches(expected: &str, actual_prefixed: &str) -> bool {
expected == actual_prefixed
|| actual_prefixed
.strip_prefix("sha256:")
.is_some_and(|actual_hex| expected == actual_hex)
}
fn is_bare_skill_ref(skill_ref: &Path) -> bool {
skill_ref.components().count() == 1
}
fn is_explicit_registry_ref(raw_ref: &str, skill_id: &str) -> bool {
is_registry_prefixed_ref(raw_ref) || split_skill_id(skill_id).is_ok()
}
fn is_registry_prefixed_ref(raw_ref: &str) -> bool {
raw_ref.starts_with("registry:")
|| raw_ref.starts_with("runx-registry:")
|| raw_ref.starts_with("runx://skill/")
}
fn local_resolved(kind: SkillRefKind, runnable_path: PathBuf) -> ResolvedSkillRef {
ResolvedSkillRef {
kind,
skill_id: None,
version: None,
digest: None,
profile_digest: None,
registry_source: None,
registry_source_fingerprint: None,
trust_state: None,
trust_tier: None,
registry_key_id: None,
runnable_path,
}
}
fn trust_tier_string(value: &runx_runtime::registry::TrustTier) -> String {
match value {
runx_runtime::registry::TrustTier::FirstParty => "first_party",
runx_runtime::registry::TrustTier::Verified => "verified",
runx_runtime::registry::TrustTier::Community => "community",
}
.to_owned()
}
fn resolve_exported_skill_shim(skill_ref: &Path) -> Result<PathBuf, String> {
let skill_dir = if skill_ref.is_file() {
skill_ref.parent().unwrap_or(skill_ref)
} else {
skill_ref
};
if skill_dir.join("X.yaml").exists() {
return Ok(skill_ref.to_path_buf());
}
let skill_md = if skill_ref.is_file() {
skill_ref.to_path_buf()
} else {
skill_dir.join("SKILL.md")
};
if !skill_md.exists() {
return Ok(skill_ref.to_path_buf());
}
let source = fs::read_to_string(&skill_md)
.map_err(|error| format!("failed to read {}: {error}", skill_md.display()))?;
let Some(source_path) = exported_source_path(&source) else {
return Ok(skill_ref.to_path_buf());
};
if source_path.join("X.yaml").exists() {
return Ok(source_path);
}
Err(format!(
"exported skill shim {} points at missing or invalid source {}; rerun `runx export`",
skill_md.display(),
source_path.display()
))
}
fn exported_source_path(source: &str) -> Option<PathBuf> {
source
.lines()
.find(|line| line.contains("runx-export:") && line.contains(" source="))
.and_then(|line| line.split_once(" source=").map(|(_prefix, value)| value))
.map(|value| {
let raw = value.trim().trim_end_matches("-->").trim();
let raw = raw
.strip_suffix("- generated, do not edit")
.unwrap_or(raw)
.trim();
PathBuf::from(raw)
})
.filter(|path| !path.as_os_str().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_registry_ref_requires_owner_and_name() {
assert!(is_explicit_registry_ref("acme/echo", "acme/echo"));
assert!(is_explicit_registry_ref("registry:acme/echo", "acme/echo"));
assert!(!is_explicit_registry_ref("./echo", "./echo"));
assert!(!is_explicit_registry_ref("echo", "echo"));
}
#[test]
fn cache_identity_includes_profile_digest() {
let without_profile = cache_identity_digest("sha256:abc", None, None);
let with_profile = cache_identity_digest("sha256:abc", Some("sha256:def"), None);
assert_ne!(without_profile, with_profile);
assert!(without_profile.starts_with("sha256:"));
assert!(with_profile.starts_with("sha256:"));
}
#[test]
fn official_packaged_skill_assets_do_not_use_workspace_shadow_roots() {
let roots = packaged_official_skill_roots();
let untrusted_project = Path::new("/tmp/untrusted-project");
assert!(
!roots.contains(&PathBuf::from("/tmp/untrusted-project/skills")),
"official skill companion assets must not be copied from the caller workspace"
);
assert!(
!roots.iter().any(|root| root.starts_with(untrusted_project)),
"official skill companion assets must not be copied from caller-controlled roots"
);
}
#[test]
fn unresolved_bare_skill_points_to_search_and_direct_registry_run() -> Result<(), String> {
let env = BTreeMap::new();
let error = match resolve_skill_ref_details(
Path::new("missing-skill"),
Path::new("/tmp/runx-cli-resolver-test"),
SkillResolverOptions {
env: &env,
registry: None,
expected_digest: None,
},
) {
Ok(_) => return Err("missing bare skill should fail".to_owned()),
Err(error) => error,
};
assert!(error.contains("runx registry search missing-skill"));
assert!(error.contains("runx skill <owner>/<name>@<version>"));
Ok(())
}
}