forge: external forge accounts live in swarm bao; the agent fetches them itself
An operator now links an agent's external forge account (label, base URL, token) in the swarm UI. swarm-controller stores it at swarm/agents/<agent>/forge/<label>. There is no index: the store's listing of the agent's forge/ directory is the set of accounts. In the agent, hive-agent-forge-accounts (oneshot + 2-minute timer, as the agent user, under its own store certificate) lists swarm/agents/<agent>/forge/ with the `list` #4866 grants an agent on its own metadata subtree, reads each account, and writes <state>/forge-<label>-token and forge-<label>.json in the names and shape hive-forge -f already reads. An empty listing (a 404, which `bao kv list -format=json` answers with `{}` and an empty stderr) is zero accounts; a denial or an unreachable store fails the unit. It never deletes: files for labels not listed, including ones the hive wrote, stay as they are. Removed: the dashboard FORGES tab (credentials.js/html section and its CSS), hive-c0re's extra_forges.rs and its routes, priv_client's extra-forge calls, and hive-priv's WriteAgentExtraForgeAccount / DeleteAgentExtraForgeAccount with their helpers. The GITHUB tab and WriteAgentGithubToken stay. Also: persistence.md's matrix avatar note names the exit-75 restart on a changed account listing, not the dashboard, as what brings a linked account up. Refs #4348
This commit is contained in:
parent
97fb76ce99
commit
2c7e586f47
27 changed files with 815 additions and 748 deletions
|
|
@ -1,16 +1,20 @@
|
|||
//! The forge agreement: where an agent's own forge token lives in the store,
|
||||
//! and what the object at that path holds.
|
||||
//! The forge agreement: where an agent's forge tokens live in the store, and
|
||||
//! what the objects at those paths hold.
|
||||
//!
|
||||
//! `swarm-controller` mints the token with the forge's admin API and writes it
|
||||
//! here; the agent container reads it back under its own certificate
|
||||
//! (`nix/agent-modules/forge-token.nix`). Neither end is senior, so both halves
|
||||
//! are stated once, here, beside [`crate::matrix`].
|
||||
//! Two kinds. The agent's own token on the swarm's forge
|
||||
//! ([`agent_token_path`]): `swarm-controller` mints it with the forge's admin
|
||||
//! API, and `nix/agent-modules/forge-token.nix` reads it back under the agent's
|
||||
//! own certificate. And the agent's accounts on external forges
|
||||
//! ([`account_path`]): an operator hands the controller a URL and a token, the
|
||||
//! controller stores them there, and `nix/agent-modules/forge-accounts.nix`
|
||||
//! lists [`accounts_dir`] and reads each account. Neither end is senior, so
|
||||
//! both halves are stated once, here, beside [`crate::matrix`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, principal_prefix},
|
||||
path::{Kind, checked_segment, principal_prefix},
|
||||
};
|
||||
|
||||
/// The path holding `agent`'s own forge access token.
|
||||
|
|
@ -53,10 +57,134 @@ impl std::fmt::Debug for Credential {
|
|||
}
|
||||
}
|
||||
|
||||
/// The path holding `agent`'s account on the external forge it calls `label`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when either name contains anything but
|
||||
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
|
||||
/// another agent's secret.
|
||||
pub fn account_path(agent: &str, label: &str) -> Result<String, Error> {
|
||||
checked_segment("label", label)?;
|
||||
Ok(format!("{}/{label}", accounts_dir(agent)?))
|
||||
}
|
||||
|
||||
/// The directory every one of `agent`'s [`account_path`]s is under, in the
|
||||
/// form [`crate::SecretStore::list`] takes. Named apart from
|
||||
/// [`agent_token_path`]'s `forge-token` key, so no key is also a directory.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`.
|
||||
pub fn accounts_dir(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/forge"))
|
||||
}
|
||||
|
||||
/// What an [`account_path`] holds.
|
||||
///
|
||||
/// No `Debug` derive, for [`Credential`]'s reason.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
/// The token. `forge-accounts.nix` reads this field by name.
|
||||
pub value: String,
|
||||
|
||||
/// The forge's base URL, without a trailing slash. Not secret.
|
||||
/// `forge-accounts.nix` reads this field by name too.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Account {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Account")
|
||||
.field("value", &"<redacted>")
|
||||
.field("url", &self.url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_account_and_its_directory_land_under_the_agent_prefix() {
|
||||
// Spelled out: `forge-accounts.nix` spells the same strings.
|
||||
assert_eq!(
|
||||
account_path("atlas", "codeberg").expect("both segments are legal"),
|
||||
"swarm/agents/atlas/forge/codeberg"
|
||||
);
|
||||
assert_eq!(
|
||||
accounts_dir("atlas").expect("legal"),
|
||||
"swarm/agents/atlas/forge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_forge_key_is_also_a_directory() {
|
||||
// KV-v2 cannot hold a key whose name is also a prefix of another key.
|
||||
let dir = accounts_dir("atlas").expect("legal");
|
||||
let key = agent_token_path("atlas").expect("legal");
|
||||
assert_ne!(key, dir);
|
||||
assert!(!key.starts_with(&format!("{dir}/")), "{key}");
|
||||
assert!(!dir.starts_with(&format!("{key}/")), "{dir}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_that_could_escape_the_directory_is_refused() {
|
||||
for bad in ["../argus", "a/b", "a.b", ""] {
|
||||
assert!(account_path("atlas", bad).is_err(), "label {bad:?}");
|
||||
assert!(account_path(bad, "codeberg").is_err(), "agent {bad:?}");
|
||||
}
|
||||
// The control: the legal charset stays reachable.
|
||||
assert!(account_path("a-b_C9", "d-e0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agents_own_grants_list_its_accounts_and_read_each() {
|
||||
let policy = crate::policy::render_agent("atlas").expect("legal");
|
||||
let covers = |path: &str| {
|
||||
policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||
.is_some_and(|prefix| path.starts_with(prefix))
|
||||
})
|
||||
};
|
||||
let dir = accounts_dir("atlas").expect("legal");
|
||||
assert!(covers(&format!("secret/metadata/{dir}/")));
|
||||
assert!(covers(&format!(
|
||||
"secret/data/{}",
|
||||
account_path("atlas", "codeberg").expect("legal")
|
||||
)));
|
||||
// The control: another agent's accounts are outside both grants.
|
||||
let other = accounts_dir("argus").expect("legal");
|
||||
assert!(!covers(&format!("secret/metadata/{other}/")));
|
||||
assert!(!covers(&format!(
|
||||
"secret/data/{}",
|
||||
account_path("argus", "codeberg").expect("legal")
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_account_fields_match_what_the_nix_reader_asks_for() {
|
||||
let json = serde_json::to_string(&Account {
|
||||
value: "t".to_owned(),
|
||||
url: "https://codeberg.org".to_owned(),
|
||||
})
|
||||
.expect("two Strings serialise");
|
||||
assert_eq!(json, r#"{"value":"t","url":"https://codeberg.org"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_an_accounts_value() {
|
||||
let a = Account {
|
||||
value: "0123456789abcdef".to_owned(),
|
||||
url: "https://codeberg.org".to_owned(),
|
||||
};
|
||||
let shown = format!("{a:?}");
|
||||
assert!(!shown.contains("0123456789abcdef"), "{shown}");
|
||||
assert!(shown.contains("codeberg.org"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_lands_under_the_agent_prefix() {
|
||||
// Spelled out rather than rebuilt from the pieces the code uses: the
|
||||
|
|
|
|||
Loading…
Reference in a new issue