runx-runtime 0.6.19

Native Rust runtime for local runx execution, adapters, harness replay, receipts, and sandboxing.
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Component, Path};

use runx_contracts::{ContextEntry, JsonObject, sha256_prefixed};

use crate::RuntimeError;
use crate::registry::{RegistryResolveOptions, create_file_registry_store, resolve_registry_skill};

mod catalog;
mod entry;

use catalog::{validate_local_context_manifest, validate_registry_context_profile};
use entry::{SkillContextEntryInput, insert_string, skill_context_entry};

const MAX_CONTEXT_SKILLS: usize = 12;
const MAX_CONTEXT_SKILL_BYTES: usize = 64 * 1024;
const MAX_CONTEXT_SKILLS_TOTAL_BYTES: usize = 256 * 1024;

pub(crate) fn load_context_skills(
    step_id: &str,
    graph_dir: &Path,
    refs: &[String],
    env: &BTreeMap<String, String>,
    created_at: &str,
) -> Result<Vec<ContextEntry>, RuntimeError> {
    if refs.len() > MAX_CONTEXT_SKILLS {
        return Err(RuntimeError::InvalidRunStep {
            step_id: step_id.to_owned(),
            reason: format!(
                "context_skills declares {} skills; the maximum is {MAX_CONTEXT_SKILLS}",
                refs.len()
            ),
        });
    }

    let mut seen = BTreeSet::new();
    let mut total_bytes = 0usize;
    refs.iter()
        .map(|reference| {
            if !seen.insert(reference.as_str()) {
                return Err(RuntimeError::InvalidRunStep {
                    step_id: step_id.to_owned(),
                    reason: format!("context skill '{reference}' is declared more than once"),
                });
            }
            let entry = load_context_skill(step_id, graph_dir, reference, env, created_at)?;
            total_bytes += usize::try_from(entry.meta.size_bytes).unwrap_or(usize::MAX);
            if total_bytes > MAX_CONTEXT_SKILLS_TOTAL_BYTES {
                return Err(RuntimeError::InvalidRunStep {
                    step_id: step_id.to_owned(),
                    reason: format!(
                        "context_skills resolved to more than {MAX_CONTEXT_SKILLS_TOTAL_BYTES} bytes"
                    ),
                });
            }
            Ok(entry)
        })
        .collect()
}

fn load_context_skill(
    step_id: &str,
    graph_dir: &Path,
    reference: &str,
    env: &BTreeMap<String, String>,
    created_at: &str,
) -> Result<ContextEntry, RuntimeError> {
    if is_registry_ref(reference) {
        return load_registry_context_skill(step_id, reference, env, created_at);
    }
    load_local_context_skill(step_id, graph_dir, reference, env, created_at)
}

fn load_local_context_skill(
    step_id: &str,
    graph_dir: &Path,
    reference: &str,
    env: &BTreeMap<String, String>,
    created_at: &str,
) -> Result<ContextEntry, RuntimeError> {
    validate_local_context_ref(step_id, reference)?;
    let skill_dir = graph_dir.join(reference);
    let skill_path = skill_dir.join("SKILL.md");
    let metadata = fs::metadata(&skill_path)
        .map_err(|source| RuntimeError::io(format!("reading {}", skill_path.display()), source))?;
    validate_context_skill_size(
        step_id,
        reference,
        usize::try_from(metadata.len()).unwrap_or(usize::MAX),
    )?;
    let markdown = fs::read_to_string(&skill_path)
        .map_err(|source| RuntimeError::io(format!("reading {}", skill_path.display()), source))?;
    let raw = runx_parser::parse_skill_markdown(&markdown)?;
    let skill = runx_parser::validate_skill(raw).map_err(RuntimeError::from)?;
    validate_local_context_manifest(step_id, reference, &skill_dir)?;
    let digest = sha256_prefixed(markdown.as_bytes());
    let mut data = JsonObject::new();
    insert_string(&mut data, "ref", reference);
    insert_string(&mut data, "source", "local-path");
    insert_string(&mut data, "content_kind", "skill-markdown");
    insert_string(&mut data, "security_boundary", "untrusted-agent-context");
    insert_string(&mut data, "name", &skill.name);
    let skill_path_display = skill_path.to_string_lossy();
    insert_string(&mut data, "path", skill_path_display.as_ref());
    insert_string(&mut data, "sha256", &digest);
    insert_string(&mut data, "content", &markdown);
    skill_context_entry(SkillContextEntryInput {
        step_id,
        reference,
        env,
        created_at,
        digest: &digest,
        size_bytes: markdown.len() as u64,
        data,
    })
}

fn load_registry_context_skill(
    step_id: &str,
    reference: &str,
    env: &BTreeMap<String, String>,
    created_at: &str,
) -> Result<ContextEntry, RuntimeError> {
    let Some(registry_dir) = env.get("RUNX_REGISTRY_DIR") else {
        return Err(RuntimeError::InvalidRunStep {
            step_id: step_id.to_owned(),
            reason: format!(
                "context skill '{reference}' is a registry ref, but RUNX_REGISTRY_DIR is not configured"
            ),
        });
    };
    let store = create_file_registry_store(registry_dir);
    let registry_url = env.get("RUNX_REGISTRY_URL").cloned();
    let resolution = resolve_registry_skill(
        &store,
        reference,
        RegistryResolveOptions {
            version: None,
            registry_url,
        },
    )
    .map_err(|error| RuntimeError::InvalidRunStep {
        step_id: step_id.to_owned(),
        reason: format!("context skill registry ref '{reference}' could not be resolved: {error}"),
    })?
    .ok_or_else(|| RuntimeError::InvalidRunStep {
        step_id: step_id.to_owned(),
        reason: format!("context skill registry ref '{reference}' was not found"),
    })?;

    let digest = prefixed_digest(&resolution.digest);
    validate_context_skill_size(step_id, reference, resolution.markdown.len())?;
    validate_registry_context_profile(step_id, reference, resolution.profile_document.as_deref())?;
    let mut data = JsonObject::new();
    insert_string(&mut data, "ref", reference);
    insert_string(&mut data, "source", &resolution.source);
    insert_string(&mut data, "content_kind", "skill-markdown");
    insert_string(&mut data, "security_boundary", "untrusted-agent-context");
    insert_string(&mut data, "source_label", &resolution.source_label);
    insert_string(&mut data, "skill_id", &resolution.skill_id);
    insert_string(&mut data, "name", &resolution.name);
    insert_string(&mut data, "version", &resolution.version);
    insert_string(&mut data, "sha256", &digest);
    insert_string(&mut data, "content", &resolution.markdown);
    skill_context_entry(SkillContextEntryInput {
        step_id,
        reference,
        env,
        created_at,
        digest: &digest,
        size_bytes: resolution.markdown.len() as u64,
        data,
    })
}

fn validate_local_context_ref(step_id: &str, reference: &str) -> Result<(), RuntimeError> {
    if reference.trim().is_empty() {
        return Err(RuntimeError::InvalidRunStep {
            step_id: step_id.to_owned(),
            reason: "context skill ref must not be empty".to_owned(),
        });
    }
    let path = Path::new(reference);
    if path.is_absolute() {
        return Err(RuntimeError::InvalidRunStep {
            step_id: step_id.to_owned(),
            reason: format!("context skill '{reference}' must be a relative path or registry ref"),
        });
    }
    for component in path.components() {
        match component {
            Component::ParentDir => {
                return Err(RuntimeError::InvalidRunStep {
                    step_id: step_id.to_owned(),
                    reason: format!("context skill '{reference}' must not contain '..'"),
                });
            }
            Component::Normal(name) if name == "graph" => {
                return Err(RuntimeError::InvalidRunStep {
                    step_id: step_id.to_owned(),
                    reason: format!("context skill '{reference}' must not target graph stages"),
                });
            }
            Component::RootDir | Component::Prefix(_) => {
                return Err(RuntimeError::InvalidRunStep {
                    step_id: step_id.to_owned(),
                    reason: format!("context skill '{reference}' must be a relative path"),
                });
            }
            Component::CurDir | Component::Normal(_) => {}
        }
    }
    Ok(())
}

fn is_registry_ref(reference: &str) -> bool {
    reference.starts_with("registry:")
        || reference.starts_with("runx-registry:")
        || reference.starts_with("runx://skill/")
}

fn validate_context_skill_size(
    step_id: &str,
    reference: &str,
    size_bytes: usize,
) -> Result<(), RuntimeError> {
    if size_bytes <= MAX_CONTEXT_SKILL_BYTES {
        return Ok(());
    }
    Err(RuntimeError::InvalidRunStep {
        step_id: step_id.to_owned(),
        reason: format!(
            "context skill '{reference}' is {size_bytes} bytes; the maximum is {MAX_CONTEXT_SKILL_BYTES}"
        ),
    })
}

fn prefixed_digest(digest: &str) -> String {
    if digest.starts_with("sha256:") {
        digest.to_owned()
    } else {
        format!("sha256:{digest}")
    }
}