use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use ureq::http::Uri;
use super::plan::text;
use super::types::Error;
pub(super) const LAUNCHER: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/embedded/plugin/scripts/mcp.sh"
));
pub(super) const NO_AGENT: &str = concat!(
"No agent is set in the bundle. ",
"Claude Code asks for the agent id and key file when the plugin is enabled. ",
"Codex needs HAEMATITE_LYS_AGENT and a proof in its environment. ",
"Without them the plugin stops with haematite_mcp_agent_missing."
);
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Agent {
issuer: String,
id: String,
key: PathBuf,
}
impl Agent {
pub(super) fn parse(
issuer: Option<PathBuf>,
id: Option<PathBuf>,
key: Option<PathBuf>,
) -> Result<Option<Self>, Error> {
if issuer.is_none() && id.is_none() && key.is_none() {
return Ok(None);
}
let issuer = issuer.unwrap_or_else(|| "https://lys.ablative.com.au".into());
let (Some(issuer), Some(id), Some(key)) = (Some(issuer), id, key) else {
return Err(Error::new(
"bundle_lys_agent_incomplete",
"--lys-agent and --key-file must be given together; --lys-issuer defaults to the central origin",
));
};
let issuer = text(&issuer)?;
let origin: Uri = issuer.parse().map_err(|_| invalid("invalid Lys origin"))?;
let authority = origin
.authority()
.ok_or_else(|| invalid("invalid Lys origin"))?;
let local = crate::loopback_http::host(authority.host());
if !issuer.is_ascii()
|| issuer.contains('#')
|| authority.as_str().contains('@')
|| !(origin.scheme_str() == Some("https")
|| (origin.scheme_str() == Some("http") && local))
|| origin.query().is_some()
|| !matches!(origin.path(), "" | "/")
{
return Err(invalid(
"Lys issuer must be an HTTPS origin, or HTTP on loopback",
));
}
let id = text(&id)?;
if id.is_empty()
|| id.len() > 128
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err(invalid("--lys-agent must be a plain agent id"));
}
let key = std::fs::canonicalize(key)
.map_err(|error| Error::new("bundle_lys_key_invalid", &error.kind()))?;
if !key.is_file() {
return Err(invalid("--key-file must name a regular file"));
}
text(&key)?;
Ok(Some(Self {
issuer: issuer.to_owned(),
id: id.to_owned(),
key,
}))
}
pub(super) fn environment(&self, bundle: &Path) -> Result<Value, Error> {
Ok(json!({
"HAEMATITE_BIN": text(&bundle.join("bin/haematite"))?,
"HAEMATITE_URL": "https://haematite.ablative.com.au/mcp",
"HAEMATITE_LYS_ISSUER": self.issuer,
"HAEMATITE_LYS_AGENT": self.id,
"HAEMATITE_KEY_FILE": text(&self.key)?
}))
}
}
fn invalid(message: &str) -> Error {
Error::new("bundle_lys_agent_invalid", message)
}
#[cfg(test)]
mod loopback_http_tests {
#[test]
fn plain_http_is_allowed_on_the_three_loopback_hosts_only() {
for (url, allowed) in crate::loopback_http::CASES {
let refused = super::Agent::parse(
Some((*url).into()),
Some("agent-fixture".into()),
Some("/nonexistent/key".into()),
)
.err()
.is_some_and(|error| {
error
.to_string()
.contains("Lys issuer must be an HTTPS origin")
});
assert_eq!(!refused, *allowed, "{url}");
}
}
}