Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller grants internal/docs read to agents; hives stop doing it

Agent creation gains a GrantDocsRead node (after CreateForgeUser) that adds
the agent as a read collaborator on internal/docs, and forge::docs_grant
runs a pass over the store's hive-agent-* roster at start and every five
minutes that queues the same job for any agent that is not one. The pass only
adds; an unreadable roster or forge read changes nothing.

hive-c0re drops shared_docs_access, its call in sync_agent, and the
SHARED_ORG / SHARED_DOCS_REPO constants only it used.
This commit is contained in:
atlas 2026-10-07 21:02:45 +02:00
commit 1ca758daa7
7 changed files with 643 additions and 59 deletions

View file

@ -294,6 +294,15 @@ A pass that can't finish logs a
`journalctl -u swarm-controller`, and retries on the next tick. While the `journalctl -u swarm-controller`, and retries on the next tick. While the
controller is down the objects stay as they are. controller is down the objects stay as they are.
The controller gives each agent **read** on `internal/docs`. A `GrantDocsRead`
node in the agent's create job adds it as a collaborator, unless it already holds read or more. A pass over the
store's `hive-agent-*` roster (`forge/docs_grant.rs`, at start and every five
minutes) adds any agent that isn't one, so an agent whose create-time grant failed
gets it within five minutes. The pass only adds: a roster it can't read, a
forge read that fails, or an `internal/docs` that doesn't exist (checked once per
pass, logged at warn) changes nothing. The grant doesn't depend on the hive
the agent runs on, or on a forge container there.
### Swarm-wide forge webhooks ### Swarm-wide forge webhooks
At startup the controller registers two Forgejo hooks pointing at At startup the controller registers two Forgejo hooks pointing at

View file

@ -1,9 +1,9 @@
//! Optional Forgejo wiring — the `core/meta` repo, meta read-access //! Optional Forgejo wiring — the `core/meta` repo and meta read-access
//! grants, and each agent's read-only grant on `internal/docs` (the //! grants. Those writes, as `core`, run only beside a local `hive-forge`
//! swarm-controller creates that repo, with the other swarm-wide orgs, //! container; reads of the forge go through [`reader_token`] on every hive.
//! teams and repos). Those writes, as `core`, run only beside a local //! The swarm-wide orgs, teams and repos, and each agent's read grant on
//! `hive-forge` container; reads of the forge go through [`reader_token`] on //! `internal/docs`, are swarm-controller's. Full design:
//! every hive. Full design: `docs/integrations/forge.md`. //! `docs/integrations/forge.md`.
mod pr_comment; mod pr_comment;
mod reader; mod reader;
@ -16,7 +16,7 @@ pub use reader::reader_token;
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status}; pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{ pub use repos::{
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main, clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main,
fetch_config_main_into_applied, meta_read_access, push_meta, shared_docs_access, fetch_config_main_into_applied, meta_read_access, push_meta,
}; };
pub use users::core_token; pub use users::core_token;
@ -100,16 +100,6 @@ fn basic_auth_header(login: &str, token: &str) -> String {
/// deploys through swarm-controller. The repos remain private, so an agent /// deploys through swarm-controller. The repos remain private, so an agent
/// still can't reach *another* agent's config. hive-c0re only reads from it. /// still can't reach *another* agent's config. hive-c0re only reads from it.
pub(crate) const CONFIG_ORG: &str = "agent-configs"; pub(crate) const CONFIG_ORG: &str = "agent-configs";
/// Forgejo org hosting the operator-curated shared docs/skills repo
/// that every agent gets read-only access to. Agents use it as a
/// common reference without the operator having to bake content into
/// the system prompt or rely on `/shared`. Only the manager + operator
/// (i.e. `core` user) can push. The swarm-controller creates the org
/// and the repo; this hive only grants its agents read access.
const SHARED_ORG: &str = "internal";
/// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent
/// at `{forge_http_base()}/internal/docs.git`.
const SHARED_DOCS_REPO: &str = "docs";
/// Leak `s` to get a `&'static str` warning `kind` for the small, bounded /// Leak `s` to get a `&'static str` warning `kind` for the small, bounded
/// set of boot warnings in [`ensure_all`] keyed by a runtime name (at /// set of boot warnings in [`ensure_all`] keyed by a runtime name (at
@ -219,15 +209,6 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed"); tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
ok = false; ok = false;
} }
// Grant read-only access to internal/docs so the agent can clone
// the operator-curated shared skills/runbook repo. Best-effort.
if let Some(token) = core_token
&& let Err(e) = shared_docs_access(name, token).await
{
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
ok = false;
}
// internal/knowledge is public — no per-agent collaborator grant needed.
ok ok
} }

View file

@ -1,7 +1,7 @@
//! Repo + org plumbing on the local Forgejo: the meta + shared-docs + //! Repo + org plumbing on the local Forgejo: the meta + knowledge repos,
//! knowledge repos, meta mirroring (`push_meta`), collaborator grants, //! meta mirroring (`push_meta`), collaborator grants, pull-mirrors, and
//! pull-mirrors, and branch-protection rules. The typed API-client //! branch-protection rules. The typed API-client constructor + org-name
//! constructor + org-name constants live in the module root (`super`). //! constants live in the module root (`super`).
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
@ -16,8 +16,8 @@ use reqwest::StatusCode;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use super::{ use super::{
CONFIG_ORG, SHARED_DOCS_REPO, SHARED_ORG, api, core_auth_header, core_token, forge_git_url, CONFIG_ORG, api, core_auth_header, core_token, forge_git_url, forge_http_base, is_present,
forge_http_base, is_present, reader_token, reader_token,
}; };
/// Creation options for an empty repo defaulting to `main`. /// Creation options for an empty repo defaulting to `main`.
@ -144,23 +144,6 @@ pub async fn push_meta(dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Grant agent `name` read-only collaborator access to `internal/docs`.
/// Idempotent: re-adding an existing collaborator succeeds (Forgejo
/// answers 204 either way). Mirrors `meta_read_access` so agents can
/// clone the shared docs repo without authentication hassle.
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
add_collaborator(
SHARED_ORG,
SHARED_DOCS_REPO,
name,
AddCollaboratorOptionPermission::Read,
core_token,
)
.await?;
tracing::info!(%name, "forge: granted shared-docs read access");
Ok(())
}
/// Grant agent `name` read-only collaborator access to `core/meta` on /// Grant agent `name` read-only collaborator access to `core/meta` on
/// the forge so the agent can clone/fetch the meta flake. Idempotent: /// the forge so the agent can clone/fetch the meta flake. Idempotent:
/// re-adding an existing collaborator succeeds (Forgejo answers 204 /// re-adding an existing collaborator succeeds (Forgejo answers 204

View file

@ -34,6 +34,7 @@ use utoipa::ToSchema;
use crate::webhook::DeliveryKind; use crate::webhook::DeliveryKind;
pub mod agent_token; pub mod agent_token;
pub mod docs_grant;
pub mod hive_token; pub mod hive_token;
pub mod legacy_tokens; pub mod legacy_tokens;
pub mod objects; pub mod objects;
@ -233,11 +234,23 @@ impl Client {
} }
/// Add `user` as a collaborator on `CONFIG_ORG/repo` at `permission`. /// Add `user` as a collaborator on `CONFIG_ORG/repo` at `permission`.
async fn add_collaborator(
&self,
repo: &str,
user: &str,
permission: AddCollaboratorOptionPermission,
) -> Result<()> {
self.add_collaborator_to(CONFIG_ORG, repo, user, permission)
.await
}
/// Add `user` as a collaborator on `owner/repo` at `permission`.
/// Idempotent: re-adding an existing collaborator just updates its /// Idempotent: re-adding an existing collaborator just updates its
/// permission (forgejo answers 204 either way; a 201 from older /// permission (forgejo answers 204 either way; a 201 from older
/// server versions is tolerated defensively). /// server versions is tolerated defensively).
async fn add_collaborator( async fn add_collaborator_to(
&self, &self,
owner: &str,
repo: &str, repo: &str,
user: &str, user: &str,
permission: AddCollaboratorOptionPermission, permission: AddCollaboratorOptionPermission,
@ -245,7 +258,7 @@ impl Client {
let res = self let res = self
.api .api
.repo_add_collaborator( .repo_add_collaborator(
CONFIG_ORG, owner,
repo, repo,
user, user,
AddCollaboratorOption { AddCollaboratorOption {
@ -258,10 +271,10 @@ impl Client {
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {} Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {}
Err(e) => { Err(e) => {
return Err(e) return Err(e)
.with_context(|| format!("add collaborator {user} to {CONFIG_ORG}/{repo}")); .with_context(|| format!("add collaborator {user} to {owner}/{repo}"));
} }
} }
tracing::debug!(%repo, %user, ?permission, "swarm forge: collaborator set"); tracing::debug!(%owner, %repo, %user, ?permission, "swarm forge: collaborator set");
Ok(()) Ok(())
} }

View file

@ -0,0 +1,427 @@
//! Each agent's read access to `internal/docs`: a collaborator grant at
//! **read**, made here with the admin API so it does not depend on which hive
//! the agent runs on or whether that hive has a forge container.
//!
//! Two callers insert the same `GrantDocsRead` job node: agent creation, and
//! [`spawn`]'s pass at start and every [`RECONCILE_INTERVAL`] over every
//! agent that holds a store identity. The pass reaches agents created before
//! this node existed and agents whose creation-time grant failed. It only ever
//! adds: nobody is removed, whatever the roster or the forge says. The
//! decision is pure ([`plan`]), so the tests pin it.
use std::sync::Arc;
use anyhow::{Context, Result};
use forgejo_api::structs::{AddCollaboratorOptionPermission, RepoCollaboratorPermission};
use swarm_secret_client::{client::DEFAULT_CERT_MOUNT, policy};
use super::Client;
use super::agent_token::is_not_found;
use super::objects::{SHARED_DOCS_REPO, SHARED_ORG};
/// How often [`spawn`] re-checks every agent.
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// What one pass found for one agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Observed {
/// The agent is already a collaborator on `internal/docs`, at any level.
Granted,
/// The forge says the agent is not a collaborator, or has no such user —
/// the queued job creates the user before it grants.
Missing,
/// The read failed. Nothing is known, so nothing is done.
Unknown,
}
/// The agents a pass queues a grant for: those the forge says are not
/// collaborators. [`Observed::Unknown`] is never queued.
pub fn plan(observed: &[(String, Observed)]) -> Vec<String> {
observed
.iter()
.filter(|(_, o)| *o == Observed::Missing)
.map(|(agent, _)| agent.clone())
.collect()
}
/// Whether a permission read says the user already has read access or more.
fn holds_read(p: &RepoCollaboratorPermission) -> bool {
matches!(
p.permission.as_deref(),
Some("read" | "write" | "admin" | "owner")
)
}
impl Client {
/// Make `agent` a **read** collaborator on `internal/docs` — the whole job
/// of the `GrantDocsRead` node. Idempotent, and never lowers: an agent that
/// already holds read or more (write, admin, owner) is left as it is, since
/// the add endpoint would overwrite the permission. Needs the forge user to
/// exist.
pub async fn grant_docs_read(&self, agent: &str) -> Result<()> {
match self
.api
.repo_get_repo_permissions(SHARED_ORG, SHARED_DOCS_REPO, agent)
.await
{
Ok(p) if holds_read(&p) => return Ok(()),
Ok(_) => {}
Err(e) if is_not_found(&e) => {}
Err(e) => {
return Err(e).with_context(|| {
format!("read {agent}'s permission on {SHARED_ORG}/{SHARED_DOCS_REPO}")
});
}
}
self.add_collaborator_to(
SHARED_ORG,
SHARED_DOCS_REPO,
agent,
AddCollaboratorOptionPermission::Read,
)
.await
}
/// Whether `agent` is a collaborator on `internal/docs` now.
async fn observe_grant(&self, agent: &str) -> Observed {
match self
.api
.repo_check_collaborator(SHARED_ORG, SHARED_DOCS_REPO, agent)
.await
{
Ok(()) => Observed::Granted,
Err(e) if is_not_found(&e) => Observed::Missing,
Err(e) => {
tracing::warn!(agent, error = %e, "docs grant: forge read failed");
Observed::Unknown
}
}
}
/// Whether `internal/docs` exists now: `None` when the forge could not
/// say. A missing repo makes every collaborator check answer 404 too, so
/// it has to be told apart from "not a collaborator" before they run.
async fn docs_repo_exists(&self) -> Option<bool> {
match self.api.repo_get(SHARED_ORG, SHARED_DOCS_REPO).await {
Ok(_) => Some(true),
Err(e) if is_not_found(&e) => Some(false),
Err(e) => {
tracing::warn!(error = %e, "docs grants: forge read of the repo failed");
None
}
}
}
/// One pass over `roster`.
async fn observe_grants(&self, roster: &[String]) -> Vec<(String, Observed)> {
let mut observed = Vec::with_capacity(roster.len());
for agent in roster {
observed.push((agent.clone(), self.observe_grant(agent).await));
}
observed
}
}
/// The store's `hive-agent-*` cert-auth roles: exactly the agents that hold a
/// store identity.
async fn read_roster() -> Result<Vec<String>> {
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let roles = store
.list_cert_roles(DEFAULT_CERT_MOUNT)
.await
.context("listing the store's cert-auth roles")?;
Ok(policy::agents_from_role_names(&roles))
}
/// One pass: observe every agent of `roster` and return the ones without a
/// grant. A roster that could not be read yields none and touches nothing; so
/// does a pass whose `internal/docs` is missing or unreadable, which is checked
/// once before any agent.
async fn run_pass(client: &Client, roster: Result<Vec<String>>) -> Vec<String> {
let roster = match roster {
Ok(roster) => roster,
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"docs grants: no roster; retrying next tick"
);
return Vec::new();
}
};
match client.docs_repo_exists().await {
Some(true) => {}
Some(false) => {
tracing::warn!(
repo = format!("{SHARED_ORG}/{SHARED_DOCS_REPO}"),
"docs grants: repo missing; no grants queued"
);
return Vec::new();
}
None => return Vec::new(),
}
let observed = client.observe_grants(&roster).await;
let agents = plan(&observed);
if agents.is_empty() {
tracing::debug!(checked = observed.len(), "docs grants: all current");
} else {
tracing::info!(
checked = observed.len(),
queueing = agents.len(),
"docs grants: queueing grants"
);
}
agents
}
/// Check every agent now and every [`RECONCILE_INTERVAL`] after, and hand the
/// agents without a grant to `enqueue`, which inserts a `GrantDocsRead` node
/// for each. A pass that fails is logged and retried on the next tick; it
/// never stops the daemon.
pub fn spawn(client: Arc<Client>, enqueue: impl Fn(Vec<String>) + Send + 'static) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
let agents = run_pass(&client, read_roster().await).await;
if !agents.is_empty() {
enqueue(agents);
}
}
});
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Mutex;
use axum::extract::{Path, State};
use axum::http::{StatusCode, header::CONTENT_TYPE};
use forgejo_api::{Auth, Forgejo};
use super::*;
/// What a stand-in `internal/docs` holds: who is a collaborator, and the
/// request log.
#[derive(Default)]
struct Docs {
/// The repo does not exist: its own lookup and every collaborator
/// check answer 404.
repo_missing: bool,
collaborators: BTreeMap<String, String>,
requests: Vec<(String, String)>,
/// Answer every collaborator check with a 500.
broken: bool,
}
type Shared = Arc<Mutex<Docs>>;
/// A stand-in forge on a loopback port — never the real one — serving
/// the collaborator check (`GET`) and add (`PUT`) on `internal/docs`.
async fn stub_forge(collaborators: &[&str], broken: bool) -> (Client, Shared) {
serve(Docs {
collaborators: collaborators
.iter()
.map(|c| ((*c).to_owned(), "read".to_owned()))
.collect(),
broken,
..Docs::default()
})
.await
}
async fn serve(docs: Docs) -> (Client, Shared) {
let docs: Shared = Arc::new(Mutex::new(docs));
let app = axum::Router::new()
.route("/api/v1/repos/internal/docs", axum::routing::get(repo))
.route(
"/api/v1/repos/internal/docs/collaborators/{user}",
axum::routing::get(check).put(add),
)
.route(
"/api/v1/repos/internal/docs/collaborators/{user}/permission",
axum::routing::get(permission),
)
.with_state(Arc::clone(&docs));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
let api = Forgejo::new(Auth::Token("stub-token"), url.clone()).unwrap();
(Client { api, url }, docs)
}
async fn check(
State(docs): State<Shared>,
Path(user): Path<String>,
) -> (
StatusCode,
[(axum::http::HeaderName, &'static str); 1],
&'static str,
) {
let mut docs = docs.lock().unwrap();
docs.requests.push(("GET".to_owned(), user.clone()));
let status = if docs.repo_missing {
StatusCode::NOT_FOUND
} else if docs.broken {
StatusCode::INTERNAL_SERVER_ERROR
} else if docs.collaborators.contains_key(&user) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
};
(status, [(CONTENT_TYPE, "application/json")], "{}")
}
async fn repo(
State(docs): State<Shared>,
) -> (
StatusCode,
[(axum::http::HeaderName, &'static str); 1],
&'static str,
) {
let mut docs = docs.lock().unwrap();
docs.requests.push(("GET".to_owned(), "repo".to_owned()));
let status = if docs.repo_missing {
StatusCode::NOT_FOUND
} else {
StatusCode::OK
};
(status, [(CONTENT_TYPE, "application/json")], "{}")
}
async fn permission(State(docs): State<Shared>, Path(user): Path<String>) -> String {
let mut docs = docs.lock().unwrap();
docs.requests
.push(("GET".to_owned(), format!("{user} permission")));
let level = docs.collaborators.get(&user).map_or("none", String::as_str);
format!(r#"{{"permission":"{level}"}}"#)
}
async fn add(State(docs): State<Shared>, Path(user): Path<String>, body: String) -> StatusCode {
let mut docs = docs.lock().unwrap();
docs.requests
.push(("PUT".to_owned(), format!("{user} {body}")));
docs.collaborators.insert(user, "read".to_owned());
StatusCode::NO_CONTENT
}
fn roster(agents: &[&str]) -> Vec<String> {
agents.iter().map(|a| (*a).to_owned()).collect()
}
fn puts(docs: &Shared) -> Vec<String> {
docs.lock()
.unwrap()
.requests
.iter()
.filter(|(m, _)| m == "PUT")
.map(|(_, what)| what.clone())
.collect()
}
#[test]
fn only_a_missing_grant_is_planned() {
let observed = [
("a".to_owned(), Observed::Granted),
("b".to_owned(), Observed::Missing),
("c".to_owned(), Observed::Unknown),
];
assert_eq!(plan(&observed), ["b"]);
}
/// The `GrantDocsRead` node's work: a **read** collaborator on
/// `internal/docs`, not write.
#[tokio::test]
async fn the_grant_adds_the_agent_as_a_read_collaborator() {
let (client, docs) = stub_forge(&[], false).await;
client.grant_docs_read("alice").await.unwrap();
let sent = puts(&docs);
assert_eq!(sent.len(), 1, "{sent:?}");
let (user, body) = sent[0].split_once(' ').unwrap();
assert_eq!(user, "alice");
let body: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(body["permission"], "read");
}
/// The backfill queues the agents that lack the grant, and only those.
#[tokio::test]
async fn the_pass_queues_the_agents_missing_the_grant() {
let (client, docs) = stub_forge(&["bob"], false).await;
let queued = run_pass(&client, Ok(roster(&["alice", "bob", "carol"]))).await;
assert_eq!(queued, ["alice", "carol"]);
assert!(puts(&docs).is_empty(), "the pass itself only reads");
}
/// Once every agent holds the grant a pass queues nothing, and writes
/// nothing — in particular it does not re-add a collaborator that holds a
/// higher permission.
#[tokio::test]
async fn the_pass_is_a_no_op_for_agents_already_granted() {
let (client, docs) = stub_forge(&["alice", "bob"], false).await;
let queued = run_pass(&client, Ok(roster(&["alice", "bob"]))).await;
assert!(queued.is_empty());
assert!(puts(&docs).is_empty());
}
/// A roster that could not be read changes nothing: no grant queued, no
/// request to the forge at all.
#[tokio::test]
async fn an_unreadable_roster_changes_nothing() {
let (client, docs) = stub_forge(&["alice"], false).await;
let queued = run_pass(&client, Err(anyhow::anyhow!("store is down"))).await;
assert!(queued.is_empty());
assert!(docs.lock().unwrap().requests.is_empty());
}
/// A forge that cannot answer the check is not a forge that says "not a
/// collaborator": nothing is queued.
#[tokio::test]
async fn an_unreadable_forge_queues_nothing() {
let (client, docs) = stub_forge(&[], true).await;
let queued = run_pass(&client, Ok(roster(&["alice"]))).await;
assert!(queued.is_empty());
assert!(puts(&docs).is_empty());
}
/// A missing `internal/docs` answers 404 to every collaborator check, which
/// would read as "not a collaborator" for every agent: nothing is queued,
/// and the agents are not asked about one by one.
#[tokio::test]
async fn a_missing_repo_queues_nothing() {
let (client, docs) = serve(Docs {
repo_missing: true,
..Docs::default()
})
.await;
let queued = run_pass(&client, Ok(roster(&["alice", "bob"]))).await;
assert!(queued.is_empty());
let requests = docs.lock().unwrap().requests.clone();
assert_eq!(requests, [("GET".to_owned(), "repo".to_owned())]);
}
/// An agent an operator already raised above read keeps it: the node reads
/// the permission first and sends no write.
#[tokio::test]
async fn the_grant_leaves_a_higher_permission_alone() {
let (client, docs) = serve(Docs {
collaborators: BTreeMap::from([("alice".to_owned(), "write".to_owned())]),
..Docs::default()
})
.await;
client.grant_docs_read("alice").await.unwrap();
assert!(puts(&docs).is_empty(), "{:?}", puts(&docs));
assert_eq!(docs.lock().unwrap().collaborators["alice"], "write");
}
}

View file

@ -48,13 +48,13 @@ const AGENTS_ORG: &str = "agents";
/// Org hosting the operator-curated shared repos. Same org as the knowledge /// Org hosting the operator-curated shared repos. Same org as the knowledge
/// repo's, named separately for the docs repo it also holds. /// repo's, named separately for the docs repo it also holds.
const SHARED_ORG: &str = KNOWLEDGE_ORG; pub(super) const SHARED_ORG: &str = KNOWLEDGE_ORG;
/// The shared docs repo inside [`SHARED_ORG`]. **Private**: every agent gets /// The shared docs repo inside [`SHARED_ORG`]. **Private**: every agent gets
/// read-only collaborator access, granted per agent by its hive. Agents use it /// read-only collaborator access, granted per agent by [`super::docs_grant`].
/// as a common reference without the operator having to bake content into the /// Agents use it as a common reference without the operator having to bake
/// system prompt. /// content into the system prompt.
const SHARED_DOCS_REPO: &str = "docs"; pub(super) const SHARED_DOCS_REPO: &str = "docs";
/// The orgs ensured on every pass. The meta repo lives at `core/meta`, the /// The orgs ensured on every pass. The meta repo lives at `core/meta`, the
/// `core` user's own namespace, so no org is needed for it (and it is not a /// `core` user's own namespace, so no org is needed for it (and it is not a

View file

@ -96,6 +96,11 @@ enum SwarmNodeKind {
/// Add `agent` as a write collaborator on its own repo. See /// Add `agent` as a write collaborator on its own repo. See
/// `forge::Client::add_repo_member`. /// `forge::Client::add_repo_member`.
AddRepoMember { agent: String }, AddRepoMember { agent: String },
/// Add `agent` as a read collaborator on `internal/docs`. See
/// `forge::docs_grant`.
///
/// Carries no hive: the grant is on a swarm-wide repo.
GrantDocsRead { agent: String },
/// Seed the agent's repo with `agent.nix` + `flake.nix`. See /// Seed the agent's repo with `agent.nix` + `flake.nix`. See
/// `forge::Client::seed_agent_config`. /// `forge::Client::seed_agent_config`.
/// ///
@ -171,6 +176,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
SwarmNodeKind::CreateRepo { .. } => "create_repo".to_owned(), SwarmNodeKind::CreateRepo { .. } => "create_repo".to_owned(),
SwarmNodeKind::CreateForgeUser { .. } => "create_forge_user".to_owned(), SwarmNodeKind::CreateForgeUser { .. } => "create_forge_user".to_owned(),
SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(), SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(),
SwarmNodeKind::GrantDocsRead { .. } => "grant_docs_read".to_owned(),
SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(), SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(),
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(), SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(), SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
@ -197,6 +203,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
| SwarmNodeKind::CreateRepo { agent } | SwarmNodeKind::CreateRepo { agent }
| SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::CreateForgeUser { agent }
| SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::AddRepoMember { agent }
| SwarmNodeKind::GrantDocsRead { agent }
| SwarmNodeKind::InitAgentConfigRepo { agent } | SwarmNodeKind::InitAgentConfigRepo { agent }
| SwarmNodeKind::MintAgentIdentity { agent } | SwarmNodeKind::MintAgentIdentity { agent }
| SwarmNodeKind::MintAgentForgeToken { agent } | SwarmNodeKind::MintAgentForgeToken { agent }
@ -340,6 +347,7 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")), Err(e) => Outcome::Failed(format!("{e:#}")),
}, },
}, },
SwarmNodeKind::GrantDocsRead { agent } => grant_docs_read(deps.forge, &agent).await,
SwarmNodeKind::InitAgentConfigRepo { agent } => match deps.forge { SwarmNodeKind::InitAgentConfigRepo { agent } => match deps.forge {
None => Outcome::Failed( None => Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \ "no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
@ -414,6 +422,26 @@ async fn mint_forge_token(
} }
} }
/// The `GrantDocsRead` arm, lifted out for the same reason.
async fn grant_docs_read(
forge: Option<Arc<forge::Client>>,
agent: &str,
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
let Some(client) = forge else {
return Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset), so no docs grant can be made"
.to_owned(),
);
};
match client.grant_docs_read(agent).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
/// The `RenewAgentQueueCredential` arm, lifted out for the same reason. /// The `RenewAgentQueueCredential` arm, lifted out for the same reason.
async fn renew_queue_credential(agent: &str) -> hive_jobq::scheduler::Outcome { async fn renew_queue_credential(agent: &str) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome; use hive_jobq::scheduler::Outcome;
@ -1980,6 +2008,14 @@ fn declare_agent_job(
}) })
.after_ok(create_repo) .after_ok(create_repo)
.after_ok(create_forge_user); .after_ok(create_forge_user);
// Read on `internal/docs`, a repo that exists whatever this agent is, so
// it needs only the user. Not a parent of the deploy: the agent reads docs
// once running, and a failed grant is retried by `forge::docs_grant::spawn`.
let _grant_docs = b
.node(SwarmNodeKind::GrantDocsRead {
agent: agent.to_owned(),
})
.after_ok(create_forge_user);
let init_config = b let init_config = b
.node(SwarmNodeKind::InitAgentConfigRepo { .node(SwarmNodeKind::InitAgentConfigRepo {
agent: agent.to_owned(), agent: agent.to_owned(),
@ -2266,6 +2302,37 @@ fn queue_forge_token_mints(
Ok(ids) Ok(ids)
} }
/// Insert one job per agent: `CreateForgeUser`, then `GrantDocsRead` `after_ok`
/// it — the same edge [`declare_agent_job`] uses. Returns the grant nodes' ids.
/// `forge::docs_grant::spawn`'s periodic pass comes through here, so a
/// backfilled grant is the same node a new agent gets; the user node is a
/// no-op for an agent that has an account.
fn queue_docs_grants(
sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
agents: Vec<String>,
) -> Result<Vec<hive_jobq::NodeId>> {
let mut sched = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut ids = Vec::with_capacity(agents.len());
for agent in agents {
let queued = sched
.insert_job(None, |b| {
let create_forge_user = b.node(SwarmNodeKind::CreateForgeUser {
agent: agent.clone(),
});
vec![
b.node(SwarmNodeKind::GrantDocsRead { agent })
.after_ok(create_forge_user)
.guid(),
]
})
.map_err(|e| anyhow::anyhow!("{e}"))?;
ids.extend(queued);
}
Ok(ids)
}
/// Insert one `MintAgentMatrixAccount` job per agent and return the nodes' /// Insert one `MintAgentMatrixAccount` job per agent and return the nodes'
/// ids. `matrix_account::agent_token::spawn`'s periodic pass comes through /// ids. `matrix_account::agent_token::spawn`'s periodic pass comes through
/// here, so a backfilled mint is the same node a new agent gets. /// here, so a backfilled mint is the same node a new agent gets.
@ -2373,7 +2440,8 @@ fn spawn_matrix_account_backfill(
} }
/// Start the forge's periodic passes — the swarm-wide objects, the runner /// Start the forge's periodic passes — the swarm-wide objects, the runner
/// registration token, hives' read tokens, and agents' tokens (which also /// registration token, hives' read tokens, agents' `internal/docs` grants, and
/// agents' tokens (which also
/// sweeps legacy tokens; see `forge::legacy_tokens`) — /// sweeps legacy tokens; see `forge::legacy_tokens`) —
/// when a forge is configured. Lifted out of `main` for /// when a forge is configured. Lifted out of `main` for
/// `clippy::too_many_lines`. /// `clippy::too_many_lines`.
@ -2395,6 +2463,12 @@ fn spawn_forge_workers(
} }
}); });
let sched = Arc::clone(jobq); let sched = Arc::clone(jobq);
forge::docs_grant::spawn(Arc::clone(&client), move |agents| {
if let Err(e) = queue_docs_grants(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "docs grants: queueing failed");
}
});
let sched = Arc::clone(jobq);
forge::agent_token::spawn(client, move |agents| { forge::agent_token::spawn(client, move |agents| {
if let Err(e) = queue_forge_token_mints(&sched, agents) { if let Err(e) = queue_forge_token_mints(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed"); tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed");
@ -4529,6 +4603,103 @@ mod tests {
); );
} }
/// Agent creation grants `internal/docs` read once the forge user exists,
/// and a failed grant does not hold up the deploy (the pass retries it).
#[tokio::test]
async fn the_docs_grant_follows_the_user_and_is_not_a_deploy_parent() {
use hive_jobq_wire::WireNode as _;
let (state, sched) = state_with_roster();
let _queued = super::create_agent(
axum::extract::State(state),
axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(),
hive: "pr1ma".to_owned(),
}),
)
.await
.expect("a hive in the roster must be accepted");
let guard = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let graph = guard.graph();
let id_of = |label: &str| {
graph
.nodes()
.find(|n| n.payload.label() == label)
.unwrap_or_else(|| panic!("the graph holds a {label} node"))
.id
};
let grant = graph.node(id_of("grant_docs_read")).expect("just found");
assert_eq!(grant.payload.data(grant.id.get())["agent"], "atlas");
let when = grant
.deps
.iter()
.find_map(|d| match d {
hive_jobq::Dep::Node { id, when } if *id == id_of("create_forge_user") => {
Some(*when)
}
_ => None,
})
.expect("the grant waits for the forge user");
assert!(
!when.accepts(hive_jobq::TerminalState::Failed),
"adding a user that was never created as a collaborator is a forge error; \
this edge has to be `after_ok`"
);
let deploy = graph.node(id_of("trigger_deploy")).expect("just found");
assert!(
!deploy.deps.iter().any(|d| matches!(
d,
hive_jobq::Dep::Node { id, .. } if *id == grant.id
)),
"the deploy must not wait on the docs grant"
);
}
/// The periodic pass queues the same pair of nodes agent creation does.
#[test]
fn a_queued_docs_grant_creates_the_forge_user_first() {
use hive_jobq_wire::WireNode as _;
let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
));
let ids = super::queue_docs_grants(&sched, vec!["a".to_owned(), "b".to_owned()])
.expect("two jobs insert");
assert_eq!(ids.len(), 2, "one returned id per agent: its grant node");
let guard = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let graph = guard.graph();
let mut labels: Vec<String> = graph.nodes().map(|n| n.payload.label()).collect();
labels.sort();
assert_eq!(
labels,
[
"create_forge_user",
"create_forge_user",
"grant_docs_read",
"grant_docs_read"
]
);
for grant in graph
.nodes()
.filter(|n| n.payload.label() == "grant_docs_read")
{
assert!(ids.contains(&grant.id));
assert!(
grant
.deps
.iter()
.any(|d| matches!(d, hive_jobq::Dep::Node { when, .. } if !when.accepts(hive_jobq::TerminalState::Failed))),
"each grant waits `after_ok` on its user"
);
}
}
#[test] #[test]
fn a_forge_token_node_renders_the_agent() { fn a_forge_token_node_renders_the_agent() {
use hive_jobq_wire::WireNode as _; use hive_jobq_wire::WireNode as _;