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":[]}),
)
}