From c5b21403a6f6c5071ce1bc4bfb8a294df17aafe0 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 30 Sep 2026 21:23:05 +0200 Subject: [PATCH] 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//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. --- Cargo.lock | 1 + docs/tools/subagent.md | 5 +- hive-runtime/Cargo.toml | 3 + hive-runtime/src/acp/mod.rs | 14 +- hive-runtime/src/acp/provider_key.rs | 281 +++++++++++++++++++++++++++ hive-runtime/src/acp/rpc.rs | 14 +- hive-runtime/src/spec.rs | 10 + hive-subagent-mcp/src/session.rs | 1 + nix/agent-modules/agent-service.nix | 13 +- nix/agent-modules/mcp.nix | 26 ++- nix/module-eval/agent-runtime.nix | 60 ++++++ swarm-secret-client/src/acp.rs | 64 ++++++ swarm-secret-client/src/lib.rs | 3 +- 13 files changed, 486 insertions(+), 9 deletions(-) create mode 100644 hive-runtime/src/acp/provider_key.rs create mode 100644 swarm-secret-client/src/acp.rs diff --git a/Cargo.lock b/Cargo.lock index 6ed628b0..8ab275c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1987,6 +1987,7 @@ dependencies = [ "hive-claude", "serde", "serde_json", + "swarm-secret-client", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md index a21796f2..43ffab8a 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -299,7 +299,10 @@ page applies as written. On `acp`: - each run spawns its own copy of the parent's ACP agent, which exits - when the run ends. The unit loads `backendEnvironmentFile` for it. + when the run ends. The unit loads `backendEnvironmentFile` for it. On the + `opencode` preset, the daemon reads a provider key that file leaves unset + from the swarm secret store at `swarm/agents//acp-provider`, as the + harness does. - the session id lives in `subagent-acp-session-` under the harness dir. `start` moves an old one aside, `continue` loads it into a fresh agent process, and a `continue` with none recorded fails straight away. diff --git a/hive-runtime/Cargo.toml b/hive-runtime/Cargo.toml index 56b1701b..4c200a71 100644 --- a/hive-runtime/Cargo.toml +++ b/hive-runtime/Cargo.toml @@ -11,6 +11,9 @@ workspace = true hive-claude.workspace = true serde.workspace = true serde_json.workspace = true +# The ACP agent's provider key, read under the agent's own certificate +# (`acp::provider_key`). +swarm-secret-client.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/hive-runtime/src/acp/mod.rs b/hive-runtime/src/acp/mod.rs index 838df7bb..1bf1edb1 100644 --- a/hive-runtime/src/acp/mod.rs +++ b/hive-runtime/src/acp/mod.rs @@ -5,6 +5,7 @@ //! reporting no usage fails with [`AcpError::EmptyEndTurn`]. An agent that //! never reports usage therefore surfaces every such turn as that error. +mod provider_key; mod rpc; mod stream; @@ -418,7 +419,17 @@ impl AcpRuntime

{ async fn start(&self, config: &Config) -> Result { let (_, servers) = mcp_servers(config)?; let cwd = session_cwd(config); - let conn = Connection::spawn(&self.command, &cwd, self.permit.clone(), servers)?; + let key = match &self.command.api_key_env { + Some(name) => provider_key::resolve(name).await, + None => None, + }; + let conn = Connection::spawn( + &self.command, + key.as_ref(), + &cwd, + self.permit.clone(), + servers, + )?; let init = conn .request( "initialize", @@ -1164,6 +1175,7 @@ done .iter() .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect::>(), + api_key_env: None, }; AcpRuntime::new( command, diff --git a/hive-runtime/src/acp/provider_key.rs b/hive-runtime/src/acp/provider_key.rs new file mode 100644 index 00000000..1b467322 --- /dev/null +++ b/hive-runtime/src/acp/provider_key.rs @@ -0,0 +1,281 @@ +//! The ACP agent's provider API key, read from the swarm secret store at +//! `swarm/agents//acp-provider` (field `api_key`) under the agent's own +//! certificate, and handed to the spawned agent's environment only. +//! +//! A value the process already inherited (`backendEnvironmentFile`) wins and +//! the store is not asked. Without either, the agent is spawned without it. + +use std::future::Future; +use std::time::Duration; + +use swarm_secret_client::{ + SecretStore, + acp::{ProviderKey, provider_key_path}, + client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings}, + policy, +}; + +/// Names the agent whose key is read; the cert-auth role and the store path +/// are both built from it. +const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME"; + +/// How long one read of the store may take before the agent is spawned +/// without the key. +const STORE_READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// A variable to add to the spawned agent's environment. +/// +/// No `Debug`: `value` is the key. +pub(super) struct KeyVar { + pub(super) name: String, + pub(super) value: String, +} + +/// Where [`resolve_with`] reads the key from. A trait so the precedence can +/// be exercised without a store. +trait KeySource { + /// The store path, for log lines. Never the value. + fn origin(&self) -> &str; + + /// The key stored now, or `None` when nothing is stored. + fn fetch(&self) -> impl Future, swarm_secret_client::Error>>; +} + +struct StoreSource { + settings: Settings, + role: String, + path: String, +} + +impl KeySource for StoreSource { + fn origin(&self) -> &str { + &self.path + } + + async fn fetch(&self) -> Result, swarm_secret_client::Error> { + let store = SecretStore::connect(&self.settings, &self.role, DEFAULT_CERT_MOUNT).await?; + let stored: Option = store.read_optional(&self.path).await?; + Ok(stored.map(|k| k.api_key)) + } +} + +/// Where this agent's key would be read from, or `None` when this process +/// was given no store. Same variables as `hive-agent`'s `swarm_queue`. +fn store_source( + get: impl Fn(&str) -> Option, +) -> Result, swarm_secret_client::Error> { + if get(ENV_ADDR).is_none_or(|v| v.is_empty()) { + return Ok(None); + } + let agent = get(ENV_AGENT_NAME) + .filter(|v| !v.is_empty()) + .ok_or(swarm_secret_client::Error::MissingEnv(ENV_AGENT_NAME))?; + let settings = + Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))?; + Ok(Some(StoreSource { + settings, + role: policy::agent_object_name(&agent)?, + path: provider_key_path(&agent)?, + })) +} + +/// Whether the CA bundle at `path` is a file with bytes in it. Absent means +/// the container's own trust store. +fn ca_is_usable(path: &str) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.len() > 0) +} + +/// The variable to add for `name` to the agent's environment, if any. +pub(super) async fn resolve(name: &str) -> Option { + let inherited = std::env::var(name).ok(); + resolve_with( + name, + inherited, + || { + store_source(|k| std::env::var(k).ok()).unwrap_or_else(|e| { + tracing::warn!( + error = %e, + "this agent's secret store coordinates are incomplete, so its ACP \ + provider key cannot be read from the store" + ); + None + }) + }, + STORE_READ_TIMEOUT, + ) + .await +} + +/// [`resolve`] over an inherited value and a store, each logged once without +/// the key. +async fn resolve_with( + name: &str, + inherited: Option, + source: impl FnOnce() -> Option, + timeout: Duration, +) -> Option { + if inherited.is_some_and(|v| !v.trim().is_empty()) { + tracing::info!(var = name, "ACP provider key is set in the environment"); + return None; + } + let Some(source) = source() else { + tracing::info!( + var = name, + "ACP provider key is not set and this agent has no secret store; spawning without it" + ); + return None; + }; + let value = match tokio::time::timeout(timeout, source.fetch()).await { + Ok(Ok(value)) => value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()), + Ok(Err(e)) => { + // `%e`, not the source chain: a decode error's source can quote the stored value. + tracing::warn!( + var = name, + path = source.origin(), + error = %e, + "reading the ACP provider key from the store failed; spawning without it" + ); + return None; + } + Err(_) => { + tracing::warn!( + var = name, + path = source.origin(), + "the store did not answer within {timeout:?}; spawning the ACP agent without \ + its provider key" + ); + return None; + } + }; + let Some(value) = value else { + tracing::info!( + var = name, + path = source.origin(), + "no ACP provider key is stored; spawning without it" + ); + return None; + }; + tracing::info!( + var = name, + path = source.origin(), + "ACP provider key read from the store" + ); + Some(KeyVar { + name: name.to_owned(), + value, + }) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + const VAR: &str = "ACP_PROVIDER_API_KEY"; + + /// A store answering `answer`, counting how often it is asked. + struct Fake { + answer: fn() -> Result, swarm_secret_client::Error>, + asked: Cell, + } + + impl Fake { + fn new(answer: fn() -> Result, swarm_secret_client::Error>) -> Self { + Self { + answer, + asked: Cell::new(0), + } + } + } + + impl KeySource for &Fake { + fn origin(&self) -> &'static str { + "swarm/agents/a1/acp-provider" + } + + async fn fetch(&self) -> Result, swarm_secret_client::Error> { + self.asked.set(self.asked.get() + 1); + (self.answer)() + } + } + + async fn resolve(inherited: Option<&str>, store: Option<&Fake>) -> Option<(String, String)> { + resolve_with( + VAR, + inherited.map(str::to_owned), + || store, + Duration::from_secs(1), + ) + .await + .map(|k| (k.name, k.value)) + } + + #[tokio::test] + async fn an_inherited_key_wins_and_the_store_is_not_asked() { + let store = Fake::new(|| Ok(Some("from-store".into()))); + assert!(resolve(Some("from-file"), Some(&store)).await.is_none()); + assert_eq!(store.asked.get(), 0); + } + + #[tokio::test] + async fn without_an_inherited_key_the_stored_one_is_added() { + let store = Fake::new(|| Ok(Some("from-store\n".into()))); + assert_eq!( + resolve(None, Some(&store)).await, + Some((VAR.to_owned(), "from-store".to_owned())) + ); + // An empty inherited value is no value. + assert!(resolve(Some(" "), Some(&store)).await.is_some()); + } + + #[tokio::test] + async fn neither_adds_nothing() { + assert!(resolve(None, None).await.is_none()); + let empty = Fake::new(|| Ok(None)); + assert!(resolve(None, Some(&empty)).await.is_none()); + let blank = Fake::new(|| Ok(Some(" ".into()))); + assert!(resolve(None, Some(&blank)).await.is_none()); + } + + #[tokio::test] + async fn an_unreachable_store_adds_nothing() { + let failing = Fake::new(|| Err(swarm_secret_client::Error::MissingEnv("BAO_ADDR"))); + assert!(resolve(None, Some(&failing)).await.is_none()); + assert_eq!(failing.asked.get(), 1); + } + + #[test] + fn no_store_address_means_no_store() { + let source = store_source(|_| None).expect("an absent store is legal"); + assert!(source.is_none()); + } + + #[test] + fn a_store_without_the_agent_name_is_refused() { + let Err(e) = store_source(|k| (k == ENV_ADDR).then(|| "https://bao:8200".to_owned())) + else { + panic!("a store address without an agent name is a half-delivered container"); + }; + assert!(e.to_string().contains(ENV_AGENT_NAME), "{e}"); + } + + #[test] + fn the_store_source_reads_the_agents_own_path() { + let env = [ + (ENV_ADDR, "https://bao:8200"), + (ENV_AGENT_NAME, "a1"), + ("BAO_CLIENT_CERT", "/run/credentials/c"), + ("BAO_CLIENT_KEY", "/run/credentials/k"), + ]; + let source = store_source(|k| { + env.iter() + .find(|(n, _)| *n == k) + .map(|(_, v)| (*v).to_owned()) + }) + .expect("a complete environment") + .expect("a store address was given"); + assert_eq!(source.path, "swarm/agents/a1/acp-provider"); + assert_eq!(source.role, policy::agent_object_name("a1").unwrap()); + } +} diff --git a/hive-runtime/src/acp/rpc.rs b/hive-runtime/src/acp/rpc.rs index 6cccfb25..3875fd9f 100644 --- a/hive-runtime/src/acp/rpc.rs +++ b/hive-runtime/src/acp/rpc.rs @@ -12,6 +12,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::{mpsc, oneshot}; +use super::provider_key::KeyVar; use super::stream::mcp_server_of; use super::{AcpError, PermissionAsk, PermissionPolicy}; use crate::spec::AcpCommand; @@ -38,16 +39,21 @@ pub(super) struct Connection { } impl Connection { - /// Spawn the agent in `cwd` and start reading its output. + /// Spawn the agent in `cwd`, with `key` added to its environment, and + /// start reading its output. pub(super) fn spawn( command: &AcpCommand, + key: Option<&KeyVar>, cwd: &Path, permit: PermissionPolicy, servers: Vec, ) -> Result { - let mut child = Command::new(&command.command) - .args(&command.args) - .envs(&command.env) + let mut cmd = Command::new(&command.command); + cmd.args(&command.args).envs(&command.env); + if let Some(key) = key { + cmd.env(&key.name, &key.value); + } + let mut child = cmd .current_dir(cwd) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/hive-runtime/src/spec.rs b/hive-runtime/src/spec.rs index d3ad48f4..93fa3cf1 100644 --- a/hive-runtime/src/spec.rs +++ b/hive-runtime/src/spec.rs @@ -11,6 +11,10 @@ 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)] @@ -25,6 +29,8 @@ pub struct AcpCommand { pub command: String, pub args: Vec, pub env: BTreeMap, + /// See [`ACP_API_KEY_ENV_ENV`]. + pub api_key_env: Option, } /// A runtime configuration that cannot be acted on. @@ -63,6 +69,7 @@ impl RuntimeSpec { 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())), @@ -119,6 +126,7 @@ mod tests { "HIVE_ACP_ENV", r#"{"AGENT_CONFIG":"/nix/store/y/config.json"}"#, ), + ("HIVE_ACP_API_KEY_ENV", "ACP_PROVIDER_API_KEY"), ]) .unwrap(); assert_eq!( @@ -127,6 +135,7 @@ mod tests { 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()), }) ); } @@ -139,6 +148,7 @@ mod tests { }; assert!(cmd.args.is_empty()); assert!(cmd.env.is_empty()); + assert_eq!(cmd.api_key_env, None); } #[test] diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index faf8f61c..c601deae 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -4171,6 +4171,7 @@ done mode.to_owned(), ], env: std::collections::BTreeMap::new(), + api_key_env: None, }, sessions: dir.to_path_buf(), }; diff --git a/nix/agent-modules/agent-service.nix b/nix/agent-modules/agent-service.nix index d8f72759..c8738397 100644 --- a/nix/agent-modules/agent-service.nix +++ b/nix/agent-modules/agent-service.nix @@ -210,6 +210,10 @@ in restored — makes systemd skip it rather than refuse to start the harness. See `services.hyperhive.agent.useApiKey`'s doc for the option this one is paired with. + + On an ACP agent using the `opencode` preset, a provider key this file + leaves unset is read from the swarm secret store at + `swarm/agents//acp-provider`, field `api_key`, if one is stored. ''; }; @@ -325,7 +329,9 @@ in default = "ACP_PROVIDER_API_KEY"; description = '' Environment variable opencode reads the provider's API key from. - Set it in `services.hyperhive.agent.backendEnvironmentFile`. + Set it in `services.hyperhive.agent.backendEnvironmentFile`, or store + the key in the swarm secret store at + `swarm/agents//acp-provider`, field `api_key`. ''; }; }; @@ -519,6 +525,11 @@ in HIVE_ACP_COMMAND = acp.command; HIVE_ACP_ARGS = builtins.toJSON acp.args; HIVE_ACP_ENV = builtins.toJSON acp.env; + } + // lib.optionalAttrs (isAcp && acp.preset == "opencode") { + # Read by `hive_runtime`, which fills it from the store when the + # environment leaves it unset. + HIVE_ACP_API_KEY_ENV = oc.provider.apiKeyEnv; }; serviceConfig = { ExecStart = "${config.services.hyperhive.agent.packages.hive-agent}/bin/${binary}"; diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index 11e3de73..e41b69df 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -30,6 +30,12 @@ let # has the memory to spare, which is what keeps overprovisioning # (several agents that rarely compile at the same time) working. subagentMemoryHigh = containerMemoryMaxBytes * 2 / 3; + # Set on the harness only for an ACP agent on the opencode preset + # (./agent-service.nix). The subagent daemon then reads that key from the + # store as this agent, so it is handed the same store identity + # ./queue-identity.nix hands the harness. + acpApiKeyEnv = config.systemd.services.hive-agent.environment.HIVE_ACP_API_KEY_ENV or null; + subagentReadsStore = acpApiKeyEnv != null && config.services.hyperhive.agent.bao.addr != null; in { options.services.hyperhive.agent.allowedRecipients = lib.mkOption { @@ -392,12 +398,13 @@ in # — the same "absent" the harness itself would see. HIVE_TOOL_GROUPS = config.systemd.services.hive-agent.environment.HIVE_TOOL_GROUPS or null; # The harness's runtime selection, forwarded the same way, so an ACP - # agent's subagents run on its ACP agent. All four are absent on a + # agent's subagents run on its ACP agent. All five are absent on a # claude agent, which the daemon reads as claude. HIVE_RUNTIME = config.systemd.services.hive-agent.environment.HIVE_RUNTIME or null; HIVE_ACP_COMMAND = config.systemd.services.hive-agent.environment.HIVE_ACP_COMMAND or null; HIVE_ACP_ARGS = config.systemd.services.hive-agent.environment.HIVE_ACP_ARGS or null; HIVE_ACP_ENV = config.systemd.services.hive-agent.environment.HIVE_ACP_ENV or null; + HIVE_ACP_API_KEY_ENV = acpApiKeyEnv; # Same `services.hyperhive.agent.availableModels` the harness's own assertions gate # the primary session's model against, so a subagent can't be spawned # on a model the operator didn't make available to this agent. The @@ -424,6 +431,14 @@ in # only — the operator's ruling was explicit that the main agent's # environment stays as is. BASH_DEFAULT_TIMEOUT_MS = "1800000"; # 30 minutes + } + // lib.optionalAttrs subagentReadsStore { + # Paths and a name only. `%d` is this unit's own credentials directory. + HIVE_AGENT_NAME = userName; + BAO_ADDR = config.services.hyperhive.agent.bao.addr; + BAO_CLIENT_CERT = "%d/hive-agent-bao-cert"; + BAO_CLIENT_KEY = "%d/hive-agent-bao-key"; + BAO_CACERT = "%d/hive-agent-bao-server-ca"; }; serviceConfig = { ExecStart = "${config.services.hyperhive.agent.packages.hive-subagent-daemon}/bin/hive-subagent-daemon --http 127.0.0.1:${toString config.services.hyperhive.agent.mcp.subagentHttpPort}"; @@ -459,6 +474,15 @@ in // lib.optionalAttrs (containerMemoryMaxBytes != null) { MemoryHigh = toString subagentMemoryHigh; } + // lib.optionalAttrs subagentReadsStore { + # Bare ids: inherits the credentials the container manager passed in, + # the same form ./queue-identity.nix uses. + LoadCredential = [ + "hive-agent-bao-cert" + "hive-agent-bao-key" + "hive-agent-bao-server-ca" + ]; + } // lib.optionalAttrs ( diff --git a/nix/module-eval/agent-runtime.nix b/nix/module-eval/agent-runtime.nix index 311334c4..b69b9374 100644 --- a/nix/module-eval/agent-runtime.nix +++ b/nix/module-eval/agent-runtime.nix @@ -39,6 +39,29 @@ let claude = agentOn "claude"; acp = agentOn "acp"; + # The opencode preset, with and without a secret store. + opencodeWith = + extra: + agentWith { + services.hyperhive.agent = { + runtime = "acp"; + acp.preset = "opencode"; + acp.opencode.provider.baseUrl = "https://inference.t.local/v1"; + acp.opencode.provider.apiKeyEnv = "T_PROVIDER_KEY"; + acp.opencode.model = "m"; + } + // extra; + }; + opencode = opencodeWith { }; + opencodeBao = opencodeWith { bao.addr = "https://bao.t.local:8200"; }; + acpBao = agentWith { + services.hyperhive.agent = { + runtime = "acp"; + acp.command = "/bin/agent"; + bao.addr = "https://bao.t.local:8200"; + }; + }; + subagent = machine: machine.systemd.services.hive-subagent-daemon; harness = machine: machine.systemd.services.hive-agent; runtimeVars = [ @@ -56,6 +79,43 @@ let var: (subagent acp).environment.${var} or null == (harness acp).environment.${var} ) runtimeVars; } + { + name = "an opencode agent's harness and subagent daemon are told its provider key variable"; + ok = + (harness opencode).environment.HIVE_ACP_API_KEY_ENV == "T_PROVIDER_KEY" + && (subagent opencode).environment.HIVE_ACP_API_KEY_ENV == "T_PROVIDER_KEY"; + } + { + # Only the opencode preset has a provider key variable; any other ACP + # command reads nothing from the store. + name = "an ACP agent off the opencode preset is told no provider key variable"; + ok = + !((harness acpBao).environment ? HIVE_ACP_API_KEY_ENV) + && (subagent acpBao).environment.HIVE_ACP_API_KEY_ENV or null == null + && !((subagent acpBao).serviceConfig ? LoadCredential); + } + { + name = "an opencode agent's subagent daemon gets the agent's store identity"; + ok = + let + u = subagent opencodeBao; + in + u.serviceConfig.LoadCredential == [ + "hive-agent-bao-cert" + "hive-agent-bao-key" + "hive-agent-bao-server-ca" + ] + && u.environment.HIVE_AGENT_NAME == "a1" + && u.environment.BAO_ADDR == "https://bao.t.local:8200" + && u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert" + && u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"; + } + { + name = "an opencode agent with no store hands its subagent daemon no store identity"; + ok = + !((subagent opencode).serviceConfig ? LoadCredential) + && !((subagent opencode).environment ? BAO_ADDR); + } { name = "an ACP agent's subagent daemon loads the backend credentials"; ok = (subagent acp).serviceConfig.EnvironmentFile or null == "-${backendEnv}"; diff --git a/swarm-secret-client/src/acp.rs b/swarm-secret-client/src/acp.rs new file mode 100644 index 00000000..065325bd --- /dev/null +++ b/swarm-secret-client/src/acp.rs @@ -0,0 +1,64 @@ +//! The ACP provider agreement: where an agent's inference-provider API key +//! lives in the store, and what the object at that path holds. +//! +//! The operator writes it by hand; the agent reads it under its own +//! certificate when it spawns its ACP agent (`hive_runtime`). + +use serde::{Deserialize, Serialize}; + +use crate::{ + Error, + path::{Kind, principal_prefix}, +}; + +/// The path holding `agent`'s ACP provider API key. +/// +/// A flat leaf under the agent's prefix, like [`crate::forge::agent_token_path`], +/// so the agent's own read stanza ([`crate::policy::render_agent`]) already +/// covers it. +/// +/// # Errors +/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, +/// which is what keeps one agent's name from addressing another agent's secret. +pub fn provider_key_path(agent: &str) -> Result { + let prefix = principal_prefix(Kind::Agent, agent)?; + Ok(format!("{prefix}/acp-provider")) +} + +/// What an agent's ACP provider path holds. +/// +/// No `Debug`: `api_key` is a live provider credential. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderKey { + /// The key itself. The operator enters this field name in the store's UI. + pub api_key: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_key_lands_under_the_agent_prefix() { + // Spelled out: the operator types this path into the store's UI. + assert_eq!( + provider_key_path("atlas").expect("a plain name is legal"), + "swarm/agents/atlas/acp-provider" + ); + } + + #[test] + fn a_traversal_in_the_agent_name_is_refused() { + for bad in ["../argus", "a/b", "a.b", ""] { + let e = provider_key_path(bad).expect_err("a traversal is not legal"); + assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}"); + } + } + + #[test] + fn the_stored_field_is_api_key() { + let stored: ProviderKey = + serde_json::from_str(r#"{"api_key":"k"}"#).expect("the documented shape decodes"); + assert_eq!(stored.api_key, "k"); + } +} diff --git a/swarm-secret-client/src/lib.rs b/swarm-secret-client/src/lib.rs index 6d7bccb2..d9cdde2b 100644 --- a/swarm-secret-client/src/lib.rs +++ b/swarm-secret-client/src/lib.rs @@ -5,7 +5,7 @@ //! the rules every path obeys ([`path`]), the translation from this //! deployment's environment into a logged-in client ([`client`]), and, per kind //! of secret, the path it lives at together with the fields it holds -//! ([`matrix`], [`queue`], [`mtls`], [`forge`]). Each of those is a thing the controller +//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`acp`]). Each of those is a thing the controller //! and a hive must say identically, so it is said once here. //! //! [`policy`] is the same kind of agreement seen from the other side: which of @@ -22,6 +22,7 @@ //! [`mtls`] is the one module about reaching the store rather than about a //! value inside it, and its doc explains why that is not circular. +pub mod acp; pub mod client; pub mod forge; pub mod matrix;