runx-runtime 0.6.19

Native Rust runtime for local runx execution, adapters, harness replay, receipts, and sandboxing.
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use runx_contracts::JsonObject;
use runx_core::policy::{SandboxProfile, is_reserved_runx_sandbox_env_name};
use runx_parser::SkillSandbox;

use crate::RuntimeError;
use crate::receipts::paths::RUNX_CWD_ENV;

use super::backend::SandboxRuntime;
use super::policy::{sandbox_violation, workspace_cwd};
use super::template::json_value_env;

const MAX_INLINE_INPUTS_BYTES: usize = 48 * 1024;
const MAX_INLINE_INPUT_VALUE_BYTES: usize = 8 * 1024;
pub(super) const DEFAULT_ENV_ALLOWLIST: [&str; 9] = [
    "PATH",
    "HOME",
    "TMPDIR",
    "TMP",
    "TEMP",
    "SystemRoot",
    "WINDIR",
    "COMSPEC",
    "PATHEXT",
];

pub(super) fn child_env(
    sandbox: Option<&SkillSandbox>,
    base_env: &BTreeMap<String, String>,
    inputs: &JsonObject,
    cleanup_paths: &mut Vec<PathBuf>,
) -> Result<BTreeMap<String, String>, RuntimeError> {
    let mut env = child_base_env(sandbox, base_env)?;
    let serialized = serde_json::to_string(inputs)
        .map_err(|source| RuntimeError::json("serializing runtime inputs", source))?;
    if serialized.len() > MAX_INLINE_INPUTS_BYTES {
        let (inputs_path, cleanup_path) = write_inputs_file(base_env, &serialized)?;
        env.insert("RUNX_INPUTS_PATH".to_owned(), inputs_path);
        push_cleanup_path(cleanup_paths, cleanup_path);
    } else {
        env.insert("RUNX_INPUTS_JSON".to_owned(), serialized);
    }
    let mut input_env_names = BTreeMap::new();
    for (key, value) in inputs {
        let serialized = json_value_env(value)?;
        if serialized.len() <= MAX_INLINE_INPUT_VALUE_BYTES {
            let env_name = input_env_name(key);
            if let Some(prior_key) = input_env_names.insert(env_name.clone(), key) {
                return Err(sandbox_violation(format!(
                    "input keys {prior_key:?} and {key:?} collide on environment variable {env_name}"
                )));
            }
            env.insert(env_name, serialized);
        }
    }
    Ok(env)
}

pub(super) fn child_base_env(
    sandbox: Option<&SkillSandbox>,
    base_env: &BTreeMap<String, String>,
) -> Result<BTreeMap<String, String>, RuntimeError> {
    let mut env = allowed_base_env(sandbox, base_env)?;
    env.insert(
        RUNX_CWD_ENV.to_owned(),
        workspace_root(base_env)?.to_string_lossy().into_owned(),
    );
    Ok(env)
}

fn workspace_root(base_env: &BTreeMap<String, String>) -> Result<PathBuf, RuntimeError> {
    workspace_cwd(base_env)?.map_or_else(
        || {
            std::env::current_dir()
                .map_err(|source| RuntimeError::io("resolving workspace cwd", source))
        },
        Ok,
    )
}

fn write_inputs_file(
    base_env: &BTreeMap<String, String>,
    serialized: &str,
) -> Result<(String, PathBuf), RuntimeError> {
    let temp_root = base_env
        .get("TMPDIR")
        .or_else(|| base_env.get("TMP"))
        .or_else(|| base_env.get("TEMP"))
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir);
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |duration| duration.as_nanos());
    let dir = temp_root.join(format!("runx-cli-inputs-{}-{nanos}", std::process::id()));
    fs::create_dir_all(&dir)
        .map_err(|source| RuntimeError::io("creating inputs temp dir", source))?;
    let path = dir.join("inputs.json");
    let mut file = fs::File::create(&path)
        .map_err(|source| RuntimeError::io("creating inputs temp file", source))?;
    file.write_all(serialized.as_bytes())
        .map_err(|source| RuntimeError::io("writing inputs temp file", source))?;
    Ok((path.to_string_lossy().into_owned(), dir))
}

fn allowed_base_env(
    sandbox: Option<&SkillSandbox>,
    base_env: &BTreeMap<String, String>,
) -> Result<BTreeMap<String, String>, RuntimeError> {
    let mut allowed = DEFAULT_ENV_ALLOWLIST
        .iter()
        .filter_map(|key| {
            base_env
                .get(*key)
                .cloned()
                .map(|value| ((*key).to_owned(), value))
        })
        .collect::<BTreeMap<_, _>>();
    if let Some(env_allowlist) = sandbox.and_then(|sandbox| sandbox.env_allowlist.as_ref()) {
        for key in env_allowlist {
            if is_reserved_runx_sandbox_env_name(key) {
                return Err(sandbox_violation(format!(
                    "sandbox env_allowlist cannot include reserved runx environment variable {key}"
                )));
            }
            if let Some(value) = base_env.get(key) {
                allowed.insert(key.clone(), value.clone());
            }
        }
    }
    allowed.retain(|key, _| !is_reserved_runx_sandbox_env_name(key));
    Ok(allowed)
}

pub(super) fn prepare_sandbox_tmp_env(
    sandbox: Option<&SkillSandbox>,
    runtime: &Option<SandboxRuntime>,
    env: &mut BTreeMap<String, String>,
    cleanup_paths: &mut Vec<PathBuf>,
) -> Result<(), RuntimeError> {
    if !sandbox_private_tmp_enabled(sandbox, runtime.as_ref()) {
        return Ok(());
    }
    let private_tmp = create_private_tmp()?;
    let private_tmp_str = private_tmp.to_string_lossy().into_owned();
    env.insert("TMPDIR".to_owned(), private_tmp_str.clone());
    env.insert("TMP".to_owned(), private_tmp_str.clone());
    env.insert("TEMP".to_owned(), private_tmp_str);
    cleanup_paths.push(private_tmp);
    Ok(())
}

pub(super) fn sandbox_private_tmp_enabled(
    sandbox: Option<&SkillSandbox>,
    runtime: Option<&SandboxRuntime>,
) -> bool {
    sandbox.is_some_and(|sandbox| sandbox.profile != SandboxProfile::UnrestrictedLocalDev)
        && !matches!(runtime, Some(SandboxRuntime::Direct))
}

fn create_private_tmp() -> Result<PathBuf, RuntimeError> {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |duration| duration.as_nanos());
    let path =
        std::env::temp_dir().join(format!("runx-local-sandbox-{}-{nanos}", std::process::id()));
    fs::create_dir_all(&path)
        .map_err(|source| RuntimeError::io("creating sandbox private temp dir", source))?;
    Ok(path)
}

fn push_cleanup_path(cleanup_paths: &mut Vec<PathBuf>, cleanup_path: PathBuf) {
    if cleanup_paths
        .iter()
        .any(|existing| cleanup_path.starts_with(existing))
    {
        return;
    }
    cleanup_paths.push(cleanup_path);
}

pub(super) fn cleanup_paths_quietly(paths: &[PathBuf]) {
    for path in paths {
        let _ = fs::remove_dir_all(path);
    }
}

fn input_env_name(key: &str) -> String {
    let mut suffix = String::new();
    let mut pending_separator = false;
    for ch in key.chars() {
        if ch.is_ascii_alphanumeric() {
            if pending_separator && !suffix.is_empty() {
                suffix.push('_');
            }
            suffix.push(ch.to_ascii_uppercase());
            pending_separator = false;
        } else {
            pending_separator = true;
        }
    }
    format!("RUNX_INPUT_{suffix}")
}