Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/github_account.rs
atlas 88674eb63a read an agent's agents.md through the swarm and edit it with git diffs
The agent's state/AGENTS.md stays the truth and its harness the only
writer. The harness mirrors the file into a new `agents-md` KV bucket,
and swarm-controller serves that copy, so a read works while the agent is
stopped. An edit is a unified diff plus the revision it was made against,
sent over the agent's existing control subject; the harness refuses a
stale base, applies the diff with `git apply --check` then `git apply` to
a temp copy, and writes the file only when both pass. Fixes #5519.

Files:
- swarm-queue-client/src/agents_md.rs, lib.rs: the bucket name and
  subject, the mirror value, the `ApplyAgentsMd` control command and its
  reply. `AgentControl` loses `Copy` because the new variant carries text.
- hive-agent/src/swarm_agents_md.rs, main.rs: the mirror task (start,
  10 s re-read, immediate republish after an edit) and the apply.
- hive-agent/src/swarm_input.rs: the control loop answers both commands.
- hive-agent/src/claude_md_watch.rs: its AGENTS.md path is shared.
- hive-agent/Cargo.toml, Cargo.lock: sha1 for the git blob id (already in
  the lock), tempfile moves from dev to runtime for the apply directory.
- swarm-controller/src/agents_md.rs, main.rs: the bucket reader and
  GET/PUT /api/agents/{name}/agents-md; test AppState literals get the
  new field.
- swarm-controller/src/agent_control.rs: the sink hands back raw reply
  bytes, so each command decodes its own reply type.
- swarm-controller-api/src/agents_md.rs: the route bodies, shared with
  swarmctl.
- swarmctl/src/agents_md.rs, main.rs: `swarmctl agent agents-md get|apply`.
- nix/host-modules/swarm-nats.nix, nix/module-eval/nats-authelia.nix: the
  agent's publish grant on its own `$KV.agents-md.<agent>` key.

git needs no new package: agent containers enable programs.git, and the
harness unit's PATH includes /run/current-system/sw.
2026-10-18 12:35:11 +02:00

228 lines
8.4 KiB
Rust

//! An agent's GitHub personal access token: an operator hands us the token, we
//! put it in the swarm's secret store.
//!
//! The agent end is `nix/agent-modules/github-token.nix`, which reads it under
//! the agent's own certificate into the `github-token` file its `gh` wrapper,
//! git credential helper and `hive-github-notify` read. No hive is in the path.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::Deserialize;
use swarm_secret_client::github;
use utoipa::ToSchema;
use super::linked_accounts::link;
use super::{AppState, error_problem, swarm_hive};
/// The token to store for one agent.
///
/// No `Debug` derive: this carries a token.
#[derive(Deserialize, ToSchema)]
pub struct PutGithubAccountRequest {
/// The personal access token. Never logged, and never returned by this
/// route.
token: String,
}
/// Store an agent's GitHub token, unless it has one stored already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/github-account",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the token belongs to"),
),
request_body = PutGithubAccountRequest,
responses(
(status = 204, description = "stored"),
(status = 400, description = "the agent is not an identifier, the token is empty, or the hive is not in this swarm (problem+json)", body = String),
(status = 409, description = "the agent has a token stored already; nothing was written (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn put_github_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
Json(req): Json<PutGithubAccountRequest>,
) -> 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 secret_path = github::account_path(&agent)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let credential = credential(&req).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())
})?;
link(&store, &secret_path, &credential).await.map_err(|e| {
// The path names the agent; the value is not in it.
tracing::warn!(path = %secret_path, error = ?e, "linking the github token failed");
e.problem(&existing(&agent))
})?;
tracing::info!(%hive, %agent, "github token stored");
Ok(StatusCode::NO_CONTENT)
}
/// The token a refused link names.
fn existing(agent: &str) -> String {
format!("agent {agent} already has a github token")
}
/// The request as it is stored, or why it cannot be.
fn credential(req: &PutGithubAccountRequest) -> Result<github::Credential, &'static str> {
let token = req.token.trim();
if token.is_empty() {
return Err("token is required");
}
Ok(github::Credential {
value: token.to_owned(),
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::super::linked_accounts::{Accounts, tests::FakeStore};
use super::{PutGithubAccountRequest, credential};
fn request(token: &str) -> PutGithubAccountRequest {
PutGithubAccountRequest {
token: token.to_owned(),
}
}
#[test]
fn the_token_is_kept_without_surrounding_whitespace() {
let c = credential(&request(" t0k3n\n")).expect("valid");
assert_eq!(c.value, "t0k3n");
}
#[test]
fn an_empty_token_is_refused() {
assert!(credential(&request(" ")).is_err());
assert!(credential(&request("")).is_err());
}
/// Bare-minimum `AppState`, as `forge_account`'s tests build it.
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::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,
token: &str,
) -> Result<axum::http::StatusCode, problem_details::ProblemDetails> {
super::put_github_account(
axum::extract::State(state(store)),
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
axum::Json(request(token)),
)
.await
}
/// An agent name or an empty token is refused before the store.
#[tokio::test]
async fn a_bad_agent_or_an_empty_token_is_refused_before_the_store() {
let store = Arc::default();
for (agent, token) in [("Atlas", "t0k3n"), ("../x", "t0k3n"), ("atlas", " ")] {
let problem = put(&store, agent, token).await.expect_err("refused");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{agent:?}: {problem:?}"
);
}
assert!(store.written().is_empty());
}
/// The control: a plain agent and a token are written.
#[tokio::test]
async fn a_plain_request_reaches_the_store() {
use super::super::linked_accounts::AccountStore;
use swarm_secret_client::github;
let store = Arc::default();
let status = put(&store, "atlas", "t0k3n")
.await
.expect("nothing is stored");
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
assert_eq!(store.written(), ["swarm/agents/atlas/github-token"]);
let stored: github::Credential = store
.read_optional("swarm/agents/atlas/github-token")
.await
.expect("store answers")
.expect("stored");
assert_eq!(stored.value, "t0k3n");
}
#[tokio::test]
async fn linking_a_second_token_is_a_409_and_the_first_stays() {
use super::super::linked_accounts::{AccountStore, link};
use swarm_secret_client::github;
let store = FakeStore::default();
let path = github::account_path("atlas").expect("a valid path");
let first = credential(&request("t0k3n-first")).expect("valid");
let second = credential(&request("t0k3n-second")).expect("valid");
link(&store, &path, &first)
.await
.expect("nothing is stored");
let problem = link(&store, &path, &second)
.await
.expect_err("a token is stored")
.problem(&super::existing("atlas"));
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert!(
detail.contains("agent atlas already has a github token"),
"{detail}"
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: github::Credential = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.value, "t0k3n-first");
}
}