//! An agent's backend environment: an operator hands us the text of a systemd //! `EnvironmentFile`, and we put it in the swarm's secret store. //! //! The agent end is `nix/agent-modules/backend-env.nix`, which reads it under //! the agent's own certificate into the file its harness and subagent daemon //! load as `EnvironmentFile=`. This is the operator's path: it replaces //! whatever the leaf holds, including a value the agent wrote itself while the //! leaf was empty, and the store's value wins over the agent's //! `backendEnvironmentFile`. use axum::Json; use axum::extract::State; use axum::http::StatusCode; use serde::Deserialize; use swarm_secret_client::backend_env::{self, BackendEnv}; use utoipa::ToSchema; use super::linked_accounts::AccountStore; use crate::routes::agents::swarm_hive; use crate::routes::{AppState, error_problem}; /// The most text one agent's environment may hold. const MAX_BYTES: usize = 16 * 1024; /// The text to store for one agent. /// /// No `Debug` derive: this carries live credentials. #[derive(Deserialize, ToSchema)] pub struct PutBackendEnvRequest { /// The file text, `NAME=VALUE` per line; blank lines and lines starting /// with `#` or `;` are allowed. Never logged, and never returned by this /// route. env: String, } /// Store an agent's backend environment, replacing any stored one. #[utoipa::path( put, path = "/api/hives/{hive}/agents/{agent}/backend-env", params( ("hive" = String, Path, description = "hive the agent runs on"), ("agent" = String, Path, description = "agent the environment belongs to"), ), request_body = PutBackendEnvRequest, responses( (status = 204, description = "stored"), (status = 400, description = "the agent is not an identifier, the text is empty, too large or not `NAME=VALUE` lines, or the hive is not in this swarm (problem+json)", body = String), (status = 500, description = "the store could not be written (problem+json)", body = String), ), tag = "agents" )] pub async fn put_backend_env( State(state): State, axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>, Json(req): Json, ) -> Result { let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?; let agent = hive_types::Ident::parse(&agent) .map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))? .into_string(); let path = backend_env::env_path(&agent) .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?; let stored = BackendEnv { env: validated(&req.env).map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?, }; let store = state.accounts.open().await.map_err(|e| { tracing::warn!(error = %e, "connecting to the swarm secret store failed"); error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) })?; // A plain write, not a create: the operator is authoritative over a value // the agent planted in the empty leaf. store.write(&path, &stored).await.map_err(|e| { // The path names the agent; the value is not in it. tracing::warn!(%path, error = %e, "storing the backend env failed"); error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) })?; tracing::info!(%hive, %agent, "backend env stored"); Ok(StatusCode::NO_CONTENT) } /// The text as it is stored, or why it cannot be. /// /// systemd skips a line it cannot parse and goes on, so a typo would load as /// a missing key. A quote that is not closed would swallow the lines after it /// into one value. The reasons name a line number and never its text. fn validated(text: &str) -> Result { let text = text.trim(); if text.is_empty() { return Err("env is required".to_owned()); } if text.len() > MAX_BYTES { return Err(format!("env is over {MAX_BYTES} bytes")); } if text .chars() .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')) { return Err("env holds a control character".to_owned()); } for (n, line) in text.lines().enumerate() { if !valid_line(line.trim()) { return Err(format!("line {} is not NAME=VALUE", n + 1)); } } Ok(format!("{text}\n")) } /// A blank line, a comment, or `NAME=VALUE` with a closed quote and no /// trailing backslash. fn valid_line(line: &str) -> bool { if line.is_empty() || line.starts_with(['#', ';']) { return true; } let Some((name, value)) = line.split_once('=') else { return false; }; let mut chars = name.chars(); let name_ok = chars .next() .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_'); let value = value.trim(); let closed = match value.chars().next() { Some(q @ ('"' | '\'')) => value.len() > 1 && value.ends_with(q), _ => true, }; name_ok && closed && !value.ends_with('\\') } #[cfg(test)] mod tests { use std::sync::Arc; use axum::http::StatusCode; use serde_json::json; use swarm_secret_client::backend_env::BackendEnv; use super::super::linked_accounts::{AccountStore, Accounts, tests::FakeStore}; use super::{MAX_BYTES, PutBackendEnvRequest, validated}; const SECRET: &str = "sk-live-0123"; /// Bare-minimum `AppState`, as `github_account`'s tests build it. fn state(store: &Arc) -> crate::routes::AppState { crate::routes::AppState { hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), dashboard: "pr1ma.hives.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, wanted: None, agent_status: None, agent_icons: None, agents_md: None, reported_inference: None, agent_login: None, jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), ))), webhook_secret: None, config_prs: std::sync::Arc::default(), swarm_name: None, auth: None, forge: std::sync::Arc::default(), create_gate: std::sync::Arc::default(), accounts: Accounts::Fake(Arc::clone(store)), try_outs: super::super::review_classifier::TryOuts::default(), reviews: std::sync::Arc::default(), runs: crate::review_classifier::runs::Runs::Live(None), builds: crate::builder::runs::Builds::Live(None), alerts: None, } } async fn put( store: &Arc, agent: &str, env: &str, ) -> Result { super::put_backend_env( axum::extract::State(state(store)), axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())), axum::Json(PutBackendEnvRequest { env: env.to_owned(), }), ) .await } async fn stored(store: &FakeStore, agent: &str) -> Option { store .read_optional::(&format!("swarm/agents/{agent}/backend-env")) .await .expect("store answers") .map(|b| b.env) } #[test] fn plain_lines_comments_and_quoted_values_are_accepted() { let text = "# openrouter\nANTHROPIC_BASE_URL=https://openrouter.ai/api/v1\n\n\ ; key\n_K9=\"a b\"\nEMPTY=\nS='x'\n"; let kept = validated(text).expect("valid"); assert_eq!(kept, text.trim().to_owned() + "\n"); } #[test] fn text_systemd_would_skip_or_misread_is_refused() { for bad in [ "", " \n ", "KEY", "=value", "1KEY=v", "KE Y=v", "KEY=\"open", "KEY='", "KEY=trailing\\", "A=1\nnot a line\nB=2", "A=b\0c", ] { assert!(validated(bad).is_err(), "{bad:?}"); } assert!(validated(&format!("A={}", "x".repeat(MAX_BYTES))).is_err()); // The control: the size cap is the only difference. assert!(validated(&format!("A={}", "x".repeat(MAX_BYTES - 2))).is_ok()); } #[test] fn a_refusal_names_the_line_and_not_its_text() { let reason = validated(&format!("A=1\n{SECRET}\n")).expect_err("no `=`"); assert_eq!(reason, "line 2 is not NAME=VALUE"); assert!(!reason.contains(SECRET), "{reason}"); } #[tokio::test] async fn a_plain_request_is_stored_under_the_agent() { let store = Arc::default(); let status = put(&store, "atlas", &format!("ANTHROPIC_API_KEY={SECRET}")) .await .expect("stored"); assert_eq!(status, StatusCode::NO_CONTENT); assert_eq!(store.written(), ["swarm/agents/atlas/backend-env"]); assert_eq!( stored(&store, "atlas").await.as_deref(), Some("ANTHROPIC_API_KEY=sk-live-0123\n") ); } /// The operator's write replaces a value an agent planted in the leaf. #[tokio::test] async fn a_value_already_in_the_leaf_is_overwritten() { let store = Arc::new(FakeStore::default().with( "swarm/agents/atlas/backend-env", json!({"env": "PLANTED=1\n"}), )); put(&store, "atlas", "ANTHROPIC_API_KEY=operator") .await .expect("overwritten"); assert_eq!( stored(&store, "atlas").await.as_deref(), Some("ANTHROPIC_API_KEY=operator\n") ); } #[tokio::test] async fn a_bad_agent_or_body_is_refused_before_the_store() { let store = Arc::default(); let leaky = format!("{SECRET}-no-equals"); for (agent, env) in [ ("Atlas", "A=1"), ("../x", "A=1"), ("atlas", " "), ("atlas", leaky.as_str()), ] { let problem = put(&store, agent, env).await.expect_err("refused"); assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{problem:?}"); assert!(!format!("{problem:?}").contains(SECRET), "{problem:?}"); } assert!(store.written().is_empty()); } #[tokio::test] async fn a_store_failure_does_not_echo_the_value() { let store = Arc::new(FakeStore::denying()); let problem = put(&store, "atlas", &format!("ANTHROPIC_API_KEY={SECRET}")) .await .expect_err("the store refuses"); assert_eq!(problem.status, Some(StatusCode::INTERNAL_SERVER_ERROR)); assert!(!format!("{problem:?}").contains(SECRET), "{problem:?}"); } }