agent-workspace-contract 0.6.0

Transport-neutral contracts for Agent Infra workspace APIs
Documentation
use super::*;
use std::collections::BTreeMap;

/// Product path for installing a Skills Hub package into a running Sandbox.
pub const SANDBOX_SKILLS_PATH: &str = "/internal/v1/workspace/sandboxes/{sandboxId}/skills";

/// Product path for installing a Skills Hub package into a Runtime-bound Space.
pub const SPACE_SKILLS_PATH: &str = "/internal/v1/workspace/spaces/{spaceId}/skills";

/// Product path for searching published Skills Hub packages.
pub const SKILLS_SEARCH_PATH: &str = "/internal/v1/workspace/skills:search";

/// Sandbox-root directory that receives unpacked skill packages.
pub const SANDBOX_SKILL_INSTALL_ROOT: &str = "skills";

/// Space-relative directory that Runtime workspace tools expose to the agent.
pub const SPACE_SKILL_INSTALL_ROOT: &str = "skills";

pub const SPACE_CLI_INSTALL_ROOT: &str = ".local/bin";

/// POSIX export file written next to installed skill packages so later execs
/// can inject `os.environ` values.
pub const SKILL_ENV_FILE: &str = "skills/.env";

/// Default Skills Hub version selector when the caller omits `version`.
pub const SANDBOX_SKILL_DEFAULT_VERSION: &str = "latest";

/// Default sandbox provider when the caller omits `provider`.
pub const SANDBOX_SKILL_DEFAULT_PROVIDER: &str = "local";

/// Maximum number of environment variables accepted on one skill install.
pub const SKILL_ENV_MAX_ENTRIES: usize = 64;

/// Maximum UTF-8 bytes allowed for one skill environment value.
pub const SKILL_ENV_MAX_VALUE_BYTES: usize = 8 * 1024;

fn default_sandbox_skill_provider() -> String {
    SANDBOX_SKILL_DEFAULT_PROVIDER.to_string()
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstallSandboxSkillRequest {
    pub group: String,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// Sandbox provider to write into: `local`, `e2b`, or `moulin`.
    #[serde(default = "default_sandbox_skill_provider")]
    pub provider: String,
    /// Process environment injected for later skill script execution.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstallSandboxSkillResource {
    pub sandbox_id: String,
    pub provider: String,
    pub group: String,
    pub name: String,
    pub version: String,
    pub install_path: String,
    pub files: Vec<String>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstallSpaceSkillRequest {
    pub group: String,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// Computer provider that owns the Space: `local`, `e2b`, or `moulin`.
    #[serde(default = "default_sandbox_skill_provider")]
    pub provider: String,
    /// Process environment injected for later skill script execution.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstallSpaceSkillResource {
    pub space_id: String,
    pub computer_id: String,
    pub provider: String,
    pub group: String,
    pub name: String,
    pub version: String,
    pub install_path: String,
    pub files: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub clis: Vec<InstalledSpaceCli>,
    pub revision: u64,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstalledSpaceCli {
    pub full_name: String,
    pub version: String,
    pub entrypoint: String,
    pub install_path: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillPackageFile {
    pub path: String,
    pub content: Vec<u8>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillPackage {
    pub group: String,
    pub name: String,
    pub version: String,
    pub files: Vec<SkillPackageFile>,
    pub cli: BTreeMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillCliPackage {
    pub full_name: String,
    pub version: String,
    pub entrypoint: String,
    pub bytes: Vec<u8>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillSearchQuery {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub q: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub page: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub limit: Option<u32>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillSearchItem {
    pub group: String,
    pub name: String,
    pub full_name: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub latest_version: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skill_type: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillSearchPage {
    pub items: Vec<SkillSearchItem>,
    #[serde(default)]
    pub page: u32,
    #[serde(default)]
    pub limit: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub total: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub has_more: Option<bool>,
}

/// Downloads a published skill archive. Implementations live in adapter crates.
#[async_trait]
pub trait SkillHubClient: Send + Sync + Debug {
    async fn download_skill(
        &self,
        group: &str,
        name: &str,
        version: &str,
        user_authorization: &str,
    ) -> Result<SkillPackage>;

    async fn download_cli(
        &self,
        full_name: &str,
        version: &str,
        os: &str,
        arch: &str,
        user_authorization: &str,
    ) -> Result<SkillCliPackage> {
        let _ = (full_name, version, os, arch, user_authorization);
        Err(EnvironmentDomainError::new(
            EnvironmentErrorCode::CapabilityUnsupported,
            "Skills Hub client does not support CLI distributions",
        )
        .into())
    }

    async fn search_skills(
        &self,
        query: &SkillSearchQuery,
        user_authorization: &str,
    ) -> Result<SkillSearchPage>;
}

/// Validate skill-install environment names and values.
pub fn validate_skill_env(env: &BTreeMap<String, String>) -> Result<()> {
    if env.len() > SKILL_ENV_MAX_ENTRIES {
        anyhow::bail!("skill env cannot contain more than {SKILL_ENV_MAX_ENTRIES} entries");
    }
    let mut total = 0_usize;
    for (name, value) in env {
        if !valid_skill_env_name(name) {
            anyhow::bail!("skill env name {name:?} is invalid");
        }
        if value.contains('\0') {
            anyhow::bail!("skill env value for {name} contains a NUL byte");
        }
        if value.len() > SKILL_ENV_MAX_VALUE_BYTES {
            anyhow::bail!("skill env value for {name} exceeds size policy");
        }
        total = total
            .checked_add(name.len())
            .and_then(|bytes| bytes.checked_add(value.len()))
            .ok_or_else(|| anyhow::anyhow!("skill env exceeds size policy"))?;
        if total > SKILL_ENV_MAX_ENTRIES * SKILL_ENV_MAX_VALUE_BYTES {
            anyhow::bail!("skill env exceeds size policy");
        }
    }
    Ok(())
}

/// POSIX identifier: `[A-Za-z_][A-Za-z0-9_]*`.
pub fn valid_skill_env_name(name: &str) -> bool {
    let mut bytes = name.bytes();
    matches!(bytes.next(), Some(byte) if byte == b'_' || byte.is_ascii_alphabetic())
        && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric())
        && name.len() <= 191
}

/// Absolute path providers source before executing a command in a Space.
pub fn space_skill_env_abs_path(space_root: &str) -> String {
    let root = space_root.trim_end_matches('/');
    if root.is_empty() {
        format!("/{SKILL_ENV_FILE}")
    } else {
        format!("{root}/{SKILL_ENV_FILE}")
    }
}

/// Absolute path providers source before executing a command in a Sandbox.
pub fn sandbox_skill_env_abs_path() -> String {
    format!("/{SKILL_ENV_FILE}")
}

/// `export NAME='value'` file that login/non-login shells can source.
pub fn encode_skill_env_file(env: &BTreeMap<String, String>) -> String {
    env.iter()
        .map(|(name, value)| format!("export {name}={}\n", posix_single_quote(value)))
        .collect()
}

/// Parse the file produced by [`encode_skill_env_file`].
pub fn parse_skill_env_file(content: &str) -> BTreeMap<String, String> {
    let mut env = BTreeMap::new();
    for line in content.lines() {
        let line = line.trim();
        let Some(rest) = line.strip_prefix("export ") else {
            continue;
        };
        let Some((name, quoted)) = rest.split_once('=') else {
            continue;
        };
        if !valid_skill_env_name(name) {
            continue;
        }
        if let Some(value) = unquote_posix_single(quoted) {
            env.insert(name.to_string(), value);
        }
    }
    env
}

/// Prefix that no-ops when the skill env file is absent.
pub fn skill_env_source_prefix(env_file: &str) -> String {
    let file = posix_single_quote(env_file);
    format!("if [ -f {file} ]; then set -a; . {file}; set +a; fi; ")
}

pub fn posix_single_quote(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\\''"))
}

fn unquote_posix_single(value: &str) -> Option<String> {
    let mut chars = value.chars().peekable();
    let mut out = String::new();
    let mut in_quote = false;
    while let Some(ch) = chars.next() {
        match ch {
            '\'' => in_quote = !in_quote,
            '\\' if !in_quote => out.push(chars.next()?),
            other => out.push(other),
        }
    }
    (!in_quote).then_some(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn skill_env_round_trips_quotes_and_rejects_invalid_names() {
        let mut env = BTreeMap::new();
        env.insert("SKYWORKS_AGENT_PLUGIN_ROUTER".into(), "agent-xxx".into());
        env.insert("NOTE".into(), "it's a value".into());
        let encoded = encode_skill_env_file(&env);
        assert_eq!(parse_skill_env_file(&encoded), env);
        assert!(validate_skill_env(&env).is_ok());
        assert!(!valid_skill_env_name("1BAD"));
        assert!(!valid_skill_env_name("HAS-DASH"));
        assert!(validate_skill_env(&BTreeMap::from([("HAS-DASH".into(), "x".into())])).is_err());
        assert_eq!(
            skill_env_source_prefix("/skills/.env"),
            "if [ -f '/skills/.env' ]; then set -a; . '/skills/.env'; set +a; fi; "
        );
        assert_eq!(
            space_skill_env_abs_path("/home/user/spaces/spc_1"),
            "/home/user/spaces/spc_1/skills/.env"
        );
        assert_eq!(sandbox_skill_env_abs_path(), "/skills/.env");
    }
}