//! Which runtime an agent runs, as read from its environment. use std::collections::BTreeMap; /// Selects the runtime: `claude` (also when unset) or `acp`. pub const RUNTIME_ENV: &str = "HIVE_RUNTIME"; /// The ACP agent's program, spawned as-is (a path or a name on `PATH`). pub const ACP_COMMAND_ENV: &str = "HIVE_ACP_COMMAND"; /// The ACP agent's arguments, as a JSON array of strings. Optional. pub const ACP_ARGS_ENV: &str = "HIVE_ACP_ARGS"; /// Extra environment for the ACP agent only, as a JSON object of strings. /// Optional. The agent also inherits the harness's own environment. pub const ACP_ENV_ENV: &str = "HIVE_ACP_ENV"; /// The runtime an agent is configured with. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuntimeSpec { Claude, Acp(AcpCommand), } /// How to spawn an ACP agent. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AcpCommand { pub command: String, pub args: Vec, pub env: BTreeMap, } /// A runtime configuration that cannot be acted on. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SpecError { #[error("{RUNTIME_ENV}={0:?} names no runtime (expected \"claude\" or \"acp\")")] UnknownRuntime(String), #[error("{RUNTIME_ENV}=acp but {ACP_COMMAND_ENV} is unset or empty")] MissingCommand, #[error("{var} is not valid JSON of the expected shape: {source}")] Malformed { var: &'static str, #[source] source: serde_json::Error, }, } impl RuntimeSpec { /// Read the spec from the process environment. pub fn from_env() -> Result { Self::parse(|var| std::env::var(var).ok()) } /// Build the spec from `lookup`, which returns an env var's value. pub fn parse(lookup: impl Fn(&str) -> Option) -> Result { let runtime = lookup(RUNTIME_ENV).unwrap_or_default(); match runtime.trim() { "" | "claude" => Ok(Self::Claude), "acp" => { let command = lookup(ACP_COMMAND_ENV).unwrap_or_default(); if command.trim().is_empty() { return Err(SpecError::MissingCommand); } Ok(Self::Acp(AcpCommand { command, args: json_or_default(&lookup, ACP_ARGS_ENV)?, env: json_or_default(&lookup, ACP_ENV_ENV)?, })) } other => Err(SpecError::UnknownRuntime(other.to_owned())), } } } fn json_or_default( lookup: &impl Fn(&str) -> Option, var: &'static str, ) -> Result { match lookup(var) { Some(raw) if !raw.trim().is_empty() => { serde_json::from_str(&raw).map_err(|source| SpecError::Malformed { var, source }) } _ => Ok(T::default()), } } #[cfg(test)] mod tests { use super::{AcpCommand, RuntimeSpec, SpecError}; use std::collections::HashMap; fn parse(vars: &[(&str, &str)]) -> Result { let vars: HashMap = vars .iter() .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect(); RuntimeSpec::parse(|k| vars.get(k).cloned()) } #[test] fn unset_or_claude_is_the_claude_runtime() { assert_eq!(parse(&[]).unwrap(), RuntimeSpec::Claude); assert_eq!( parse(&[("HIVE_RUNTIME", "claude")]).unwrap(), RuntimeSpec::Claude ); // ACP settings alone do not switch the runtime. assert_eq!( parse(&[("HIVE_ACP_COMMAND", "/bin/agent")]).unwrap(), RuntimeSpec::Claude ); } #[test] fn acp_resolves_command_args_and_env() { let spec = parse(&[ ("HIVE_RUNTIME", "acp"), ("HIVE_ACP_COMMAND", "/nix/store/x/bin/agent"), ("HIVE_ACP_ARGS", r#"["acp","--flag"]"#), ( "HIVE_ACP_ENV", r#"{"AGENT_CONFIG":"/nix/store/y/config.json"}"#, ), ]) .unwrap(); assert_eq!( spec, RuntimeSpec::Acp(AcpCommand { command: "/nix/store/x/bin/agent".into(), args: vec!["acp".into(), "--flag".into()], env: [("AGENT_CONFIG".into(), "/nix/store/y/config.json".into())].into(), }) ); } #[test] fn acp_args_and_env_are_optional() { let spec = parse(&[("HIVE_RUNTIME", "acp"), ("HIVE_ACP_COMMAND", "agent")]).unwrap(); let RuntimeSpec::Acp(cmd) = spec else { panic!("expected acp, got {spec:?}"); }; assert!(cmd.args.is_empty()); assert!(cmd.env.is_empty()); } #[test] fn acp_without_a_command_is_refused() { assert!(matches!( parse(&[("HIVE_RUNTIME", "acp")]), Err(SpecError::MissingCommand) )); assert!(matches!( parse(&[("HIVE_RUNTIME", "acp"), ("HIVE_ACP_COMMAND", " ")]), Err(SpecError::MissingCommand) )); } #[test] fn malformed_json_names_the_variable() { let err = parse(&[ ("HIVE_RUNTIME", "acp"), ("HIVE_ACP_COMMAND", "agent"), ("HIVE_ACP_ARGS", r#"{"not":"a list"}"#), ]) .unwrap_err(); assert!(err.to_string().starts_with("HIVE_ACP_ARGS "), "{err}"); } #[test] fn an_unknown_runtime_is_refused_rather_than_defaulted() { assert!(matches!( parse(&[("HIVE_RUNTIME", "bogus")]), Err(SpecError::UnknownRuntime(r)) if r == "bogus" )); } }