The webhook secret slot is filled in the background, on the forge token's capped unbounded backoff, when the store can't answer at start. The hook registration waits for the secret and retries on the same backoff instead of giving up after five tries. Closes #6101
298 lines
11 KiB
Rust
298 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())
|
|
})?;
|
|
|
|
crate::backend_egress::follow_env(&state, &agent, Some(&stored.env)).await?;
|
|
|
|
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: std::sync::Arc::default(),
|
|
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:?}");
|
|
}
|
|
}
|