haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
use super::config::Seat;
use serde::Deserialize;
use serde_json::{Value, json};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

pub(super) struct Identity {
    pub agent: String,
    pub launch: String,
    pub pass: String,
}

#[derive(Deserialize)]
struct Answer {
    pass: String,
    audience: String,
    expires_at: u64,
}

impl Identity {
    pub fn launch(seat: &Seat) -> anyhow::Result<Self> {
        let agent = environment("LYS_AGENT")?;
        let launch = environment("LYS_AGENT_PASS")?;
        anyhow::ensure!(
            agent.len() <= 128
                && agent != "."
                && agent != ".."
                && agent
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')),
            "assistant_needs_lys: LYS_AGENT must name one agent"
        );
        anyhow::ensure!(
            launch.len() <= seat.harness.issuer_response_bytes
                && launch.is_ascii()
                && !launch.bytes().any(|byte| byte.is_ascii_control()),
            "assistant_needs_lys: LYS_AGENT_PASS is not a usable proof"
        );
        anyhow::ensure!(
            seat.harness.id != "pi",
            "pi_mcp_unavailable: this adapter drops MCP servers"
        );
        Ok(Self {
            agent,
            launch,
            pass: String::new(),
        })
    }
}

fn environment(name: &str) -> anyhow::Result<String> {
    std::env::var(name)
        .ok()
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| anyhow::anyhow!("assistant_needs_lys: {name} is required"))
}

pub(super) fn origin(value: &str) -> anyhow::Result<&str> {
    let value = value.trim_end_matches('/');
    let uri: ureq::http::Uri = value
        .parse()
        .map_err(|_| anyhow::anyhow!("assistant_issuer_invalid"))?;
    let authority = uri
        .authority()
        .ok_or_else(|| anyhow::anyhow!("assistant_issuer_invalid"))?;
    anyhow::ensure!(
        !authority.as_str().contains('@')
            && uri.query().is_none()
            && matches!(uri.path(), "" | "/")
            && !value.contains('#')
            && (uri.scheme_str() == Some("https")
                || (uri.scheme_str() == Some("http")
                    && ["127.0.0.1", "localhost", "[::1]"].contains(&authority.host()))),
        "assistant_issuer_invalid: expected HTTPS origin or loopback HTTP"
    );
    Ok(value)
}

pub(super) fn client() -> ureq::Agent {
    ureq::Agent::new_with_config(
        ureq::Agent::config_builder()
            .proxy(None)
            .max_redirects(0)
            .http_status_as_error(false)
            .timeout_global(Some(Duration::from_secs(15)))
            .build(),
    )
}

pub(super) fn acquire(
    issuer: &str,
    agent: &str,
    launch: &str,
    bound: usize,
) -> anyhow::Result<String> {
    let mut proof = ureq::http::HeaderValue::from_str(launch)
        .map_err(|_| anyhow::anyhow!("assistant_needs_lys: launch proof is not a header"))?;
    proof.set_sensitive(true);
    let mut response = client()
        .post(format!("{issuer}/api/agents/{agent}/pass"))
        .header("lys-agent-pass", proof)
        .header("content-type", "application/json")
        .send(br#"{"audience":"haematite"}"#.as_slice())
        .map_err(|_| anyhow::anyhow!("assistant_lys_unreachable"))?;
    let status = response.status().as_u16();
    let bytes = response
        .body_mut()
        .with_config()
        .limit(u64::try_from(bound)?)
        .read_to_vec()
        .map_err(|_| {
            anyhow::anyhow!("assistant_lys_response_exceeds_issuer_response_bytes_or_unreadable")
        })?;
    anyhow::ensure!(status == 200, "assistant_lys_pass_refused: HTTP {status}");
    let answer: Answer = serde_json::from_slice(&bytes)
        .map_err(|_| anyhow::anyhow!("assistant_lys_answer_invalid"))?;
    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
    anyhow::ensure!(
        answer.audience == "haematite"
            && answer.expires_at > now
            && !answer.pass.is_empty()
            && answer.pass.bytes().all(|byte| byte.is_ascii_graphic()),
        "assistant_lys_answer_invalid: expected an unexpired haematite pass"
    );
    Ok(answer.pass)
}

pub(super) fn server(seat: &Seat, agent: &str) -> anyhow::Result<Value> {
    Ok(
        json!({"name":"haematite","command":std::env::current_exe()?,
        "args":["mcp","--config",seat.serve_config,"--lys-agent",seat.issuer,agent,"--key-file",seat.key_file],
        "env":[]}),
    )
}