fix: split WriteAgentStateFile into WriteAgentForgeToken + WriteAgentMatrixToken

Addresses mara's review: each credential type gets its own PrivRequest
variant, making the exact priv surface visible in the wire protocol.
No runtime filename dispatch — the operation name is the gate.

- WriteAgentForgeToken { agent_name, token } → state/forge-token
- WriteAgentMatrixToken { agent_name, token } → state/matrix-token
- priv_client: two typed fns (write_agent_forge_token, write_agent_matrix_token)
- forge.rs: split mint_and_persist_token into mint_and_persist_agent_token
  (priv) + mint_and_persist_core_token (direct write); drop dead token_path fn
- matrix.rs: call write_agent_matrix_token directly
This commit is contained in:
atlas 2026-06-04 13:53:43 +02:00 committed by mara
commit 7022cd3826
5 changed files with 73 additions and 92 deletions

View file

@ -74,12 +74,6 @@ const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notific
/// See `docs/forge.md::Token scopes`.
const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
/// Token file inside the agent's bind-mounted state dir (visible as
/// `/state/forge-token` from inside the container).
fn token_path(name: &str) -> PathBuf {
Coordinator::agent_notes_dir(name).join("forge-token")
}
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
/// `nixos-container list` is just a directory scan in /etc. Routed
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
@ -291,41 +285,30 @@ async fn mint_token(name: &str, scopes: &str) -> Result<String> {
Ok(token)
}
/// Mint a fresh access token for `name` and persist it to `path` (0600).
/// For paths outside the agent state tree (e.g. the core admin token at
/// `/var/lib/hyperhive/forge-core-token`), writes directly — hive-c0re
/// owns those paths. For paths inside `AGENT_STATE_ROOT` the write is
/// routed through hive-priv (root helper) because hive-c0re runs
/// unprivileged and cannot write to agent-owned state directories.
async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> {
/// Mint a fresh Forgejo access token for an agent and write it to the
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
/// cannot write to agent-owned (0755) state directories directly.
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
let token = mint_token(name, TOKEN_SCOPES).await?;
crate::priv_client::write_agent_forge_token(name, &token)
.await
.with_context(|| format!("write forge-token for {name} via hive-priv"))
}
/// Mint a fresh Forgejo access token for the `core` admin user and
/// write it directly to `path`. Unlike agent tokens this path is owned
/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct
/// write is both correct and necessary (no priv round-trip).
async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let token = mint_token(name, scopes).await?;
// Agent state paths must go through hive-priv.
// Heuristic: any path containing an "agents" component is considered
// an agent state path (matches `/var/lib/hyperhive/agents/<name>/...`).
// All current callers pass either CORE_TOKEN_PATH (no "agents" component)
// or `token_path(name)` (under AGENT_STATE_ROOT which contains "agents").
// If a future non-agent path ever gains an "agents" component, this guard
// would incorrectly route it to priv — add an explicit exclusion then.
if path.components().any(|c| c.as_os_str() == "agents") {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.context("could not determine filename for priv write")?;
return crate::priv_client::write_agent_state_file(name, filename, &format!("{token}\n"))
.await
.with_context(|| format!("write {filename} for {name} via hive-priv"));
}
// Non-agent paths (core admin token, etc.): write directly.
let token = mint_token("core", CORE_TOKEN_SCOPES).await?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, format!("{token}\n"))
.with_context(|| format!("write token to {}", path.display()))?;
.with_context(|| format!("write core token to {}", path.display()))?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
tracing::info!(%name, path = %path.display(), "forge: persisted access token");
tracing::info!(path = %path.display(), "forge: persisted core access token");
Ok(())
}
@ -338,7 +321,7 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
}
ensure_user_exists(name, false, None).await?;
ensure_user_email(name).await;
mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await
mint_and_persist_agent_token(name).await
}
/// Provision a forgejo user for `name` and return the freshly-minted
@ -451,7 +434,7 @@ async fn ensure_core_user_and_token() -> Result<String> {
}
}
ensure_user_exists("core", true, None).await?;
mint_and_persist_token("core", path, CORE_TOKEN_SCOPES).await?;
mint_and_persist_core_token(path).await?;
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
Ok(raw.trim().to_owned())