Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-runtime/src/spec.rs
atlas c5b21403a6 hive-runtime: read the ACP provider key from bao
An opencode ACP agent got its provider API key only from the hand-placed
backendEnvironmentFile. It now also reads it from the swarm secret store
at swarm/agents/<agent>/acp-provider, field api_key, under its own
certificate, and sets it in the spawned ACP agent's environment only.
Nothing is written to disk.

Precedence: a value already in the process environment (the env file)
wins and the store is not asked. Otherwise the stored key is used when
present. With no store, nothing stored, or a failed read, the agent is
spawned without the key as before, and one line is logged without the
value.

The variable name comes from the existing per-agent option
acp.opencode.provider.apiKeyEnv, exported as HIVE_ACP_API_KEY_ENV on the
harness only for the opencode preset. Other ACP commands are unchanged.

The read lives in hive-runtime, where the ACP child is spawned, so both
hive-agent and hive-subagent-daemon use it. The subagent daemon unit
gets the key name and, when the agent has a store, the agent's store
identity (the same credentials queue-identity.nix gives the harness).

No new option or setting. Closes #4841.
2026-09-30 22:55:03 +02:00

184 lines
6.2 KiB
Rust

//! 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 variable the ACP agent reads its provider API key from. Optional. When
/// set and the process environment leaves that variable unset, the agent is
/// spawned with it read from the swarm secret store.
pub const ACP_API_KEY_ENV_ENV: &str = "HIVE_ACP_API_KEY_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<String>,
pub env: BTreeMap<String, String>,
/// See [`ACP_API_KEY_ENV_ENV`].
pub api_key_env: Option<String>,
}
/// 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, SpecError> {
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<String>) -> Result<Self, SpecError> {
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)?,
api_key_env: lookup(ACP_API_KEY_ENV_ENV).filter(|v| !v.trim().is_empty()),
}))
}
other => Err(SpecError::UnknownRuntime(other.to_owned())),
}
}
}
fn json_or_default<T: Default + serde::de::DeserializeOwned>(
lookup: &impl Fn(&str) -> Option<String>,
var: &'static str,
) -> Result<T, SpecError> {
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<RuntimeSpec, SpecError> {
let vars: HashMap<String, String> = 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"}"#,
),
("HIVE_ACP_API_KEY_ENV", "ACP_PROVIDER_API_KEY"),
])
.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(),
api_key_env: Some("ACP_PROVIDER_API_KEY".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());
assert_eq!(cmd.api_key_env, None);
}
#[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"
));
}
}