Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/backend_env.rs

296 lines
11 KiB
Rust

//! 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<AppState>,
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
Json(req): Json<PutBackendEnvRequest>,
) -> Result<StatusCode, problem_details::ProblemDetails> {
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<String, String> {
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<FakeStore>) -> 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<FakeStore>,
agent: &str,
env: &str,
) -> Result<StatusCode, problem_details::ProblemDetails> {
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<String> {
store
.read_optional::<BackendEnv>(&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:?}");
}
}