agent-workspace-contract 0.6.0

Transport-neutral contracts for Agent Infra workspace APIs
Documentation
//! Admin contracts for persistent Workspace provider and launch configuration.

use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::net::IpAddr;
use std::num::NonZeroU16;

use crate::SnapshotBuildSpec;

pub const WORKSPACE_MOULIN_BUILD_CONFIG_PATH: &str = "/internal/v1/workspace/moulin-build-config";
pub const ADMIN_WORKSPACE_MOULIN_BUILD_CONFIG_PATH: &str =
    "/admin/api/workspace-moulin-build-config";

/// Non-secret defaults materialized into direct Workspace Moulin BuildJobs.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WorkspaceMoulinBuildAdminConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub http_proxy: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub https_proxy: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub no_proxy: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub registry_mirror: Option<String>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub build_args: BTreeMap<String, String>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub build_env: BTreeMap<String, String>,
}

impl WorkspaceMoulinBuildAdminConfig {
    /// Match Deploy's deterministic proxy-to-BuildJob environment semantics.
    pub fn effective_build_env(&self) -> BTreeMap<String, String> {
        let mut values = BTreeMap::new();
        if let Some(proxy) = self.http_proxy.as_deref() {
            values.insert("HTTP_PROXY".into(), proxy.into());
            values.insert("http_proxy".into(), proxy.into());
        }
        if let Some(proxy) = self.https_proxy.as_deref().or(self.http_proxy.as_deref()) {
            values.insert("HTTPS_PROXY".into(), proxy.into());
            values.insert("https_proxy".into(), proxy.into());
        }
        if let Some(no_proxy) = self.no_proxy.as_deref() {
            values.insert("NO_PROXY".into(), no_proxy.into());
            values.insert("no_proxy".into(), no_proxy.into());
        }
        values.extend(self.build_env.clone());
        values
    }
}

/// Exact destination accepted by provider defaults and per-sandbox overrides.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MoulinEgressRule {
    host: String,
    port: NonZeroU16,
}

impl MoulinEgressRule {
    pub fn new(host: impl Into<String>, port: u16) -> Result<Self, String> {
        let host = validate_exact_hostname(host.into())?;
        let port =
            NonZeroU16::new(port).ok_or_else(|| "egress port must be non-zero".to_string())?;
        Ok(Self { host, port })
    }

    pub fn host(&self) -> &str {
        &self.host
    }

    pub fn port(&self) -> u16 {
        self.port.get()
    }
}

impl<'de> Deserialize<'de> for MoulinEgressRule {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(rename_all = "camelCase", deny_unknown_fields)]
        struct WireRule {
            host: String,
            port: u16,
        }

        let rule = WireRule::deserialize(deserializer)?;
        Self::new(rule.host, rule.port).map_err(serde::de::Error::custom)
    }
}

/// Validate the bounded request-level or provider-level Moulin policy.
pub fn validate_moulin_egress_rules(rules: &[MoulinEgressRule]) -> Result<(), String> {
    if rules.len() > 20 {
        return Err("egressRules supports at most 20 entries".into());
    }
    let unique = rules
        .iter()
        .map(|rule| (rule.host(), rule.port()))
        .collect::<BTreeSet<_>>();
    if unique.len() != rules.len() {
        return Err("egressRules contains duplicates".into());
    }
    Ok(())
}

fn validate_exact_hostname(host: String) -> Result<String, String> {
    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
    if !host.is_ascii() || host.len() > 253 || host.parse::<IpAddr>().is_ok() || host.contains('*')
    {
        return Err("egress host must be an exact ASCII hostname".into());
    }
    let labels = host.split('.').collect::<Vec<_>>();
    if labels.len() < 2
        || labels.iter().any(|label| {
            label.is_empty()
                || label.len() > 63
                || label.starts_with('-')
                || label.ends_with('-')
                || !label
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
        })
    {
        return Err("egress host must be an exact ASCII hostname".into());
    }
    Ok(host)
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentProviderSlotView {
    pub base_url: String,
    #[serde(default)]
    pub build_url: String,
    pub credential_configured: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_build: Option<SnapshotBuildSpec>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_template_id: Option<String>,
    pub active: bool,
    #[serde(default)]
    pub egress_rules: Vec<MoulinEgressRule>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentProviderAdminConfig {
    pub moulin: EnvironmentProviderSlotView,
    pub e2b: EnvironmentProviderSlotView,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_snapshot_id: Option<String>,
    #[serde(default)]
    pub default_resources: crate::SandboxResources,
    pub managed: bool,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateEnvironmentProviderSlotRequest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub credential: Option<String>,
    #[serde(default)]
    pub clear_credential: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_build: Option<SnapshotBuildSpec>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_template_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub egress_rules: Option<Vec<MoulinEgressRule>>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateEnvironmentProviderAdminConfigRequest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub moulin: Option<UpdateEnvironmentProviderSlotRequest>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub e2b: Option<UpdateEnvironmentProviderSlotRequest>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_snapshot_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_resources: Option<crate::SandboxResources>,
}