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.
184 lines
6.2 KiB
Rust
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"
|
|
));
|
|
}
|
|
}
|