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:
parent
89092caba4
commit
7022cd3826
5 changed files with 73 additions and 92 deletions
|
|
@ -74,12 +74,6 @@ const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notific
|
||||||
/// See `docs/forge.md::Token scopes`.
|
/// 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";
|
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 —
|
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
|
||||||
/// `nixos-container list` is just a directory scan in /etc. Routed
|
/// `nixos-container list` is just a directory scan in /etc. Routed
|
||||||
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
|
/// 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)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mint a fresh access token for `name` and persist it to `path` (0600).
|
/// Mint a fresh Forgejo access token for an agent and write it to the
|
||||||
/// For paths outside the agent state tree (e.g. the core admin token at
|
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
|
||||||
/// `/var/lib/hyperhive/forge-core-token`), writes directly — hive-c0re
|
/// cannot write to agent-owned (0755) state directories directly.
|
||||||
/// owns those paths. For paths inside `AGENT_STATE_ROOT` the write is
|
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
|
||||||
/// routed through hive-priv (root helper) because hive-c0re runs
|
let token = mint_token(name, TOKEN_SCOPES).await?;
|
||||||
/// unprivileged and cannot write to agent-owned state directories.
|
crate::priv_client::write_agent_forge_token(name, &token)
|
||||||
async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> {
|
.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;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let token = mint_token(name, scopes).await?;
|
let token = mint_token("core", CORE_TOKEN_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.
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
std::fs::write(path, format!("{token}\n"))
|
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));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,7 +321,7 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
ensure_user_exists(name, false, None).await?;
|
ensure_user_exists(name, false, None).await?;
|
||||||
ensure_user_email(name).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
|
/// 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?;
|
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)
|
let raw = std::fs::read_to_string(path)
|
||||||
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
||||||
Ok(raw.trim().to_owned())
|
Ok(raw.trim().to_owned())
|
||||||
|
|
|
||||||
|
|
@ -420,7 +420,7 @@ pub async fn ensure_user_for(
|
||||||
// unprivileged `hive-core` user and cannot write to agent-owned state
|
// unprivileged `hive-core` user and cannot write to agent-owned state
|
||||||
// directories directly. hive-priv writes the file 0600 and chowns it
|
// directories directly. hive-priv writes the file 0600 and chowns it
|
||||||
// to the agent user so it is readable from inside the container.
|
// to the agent user so it is readable from inside the container.
|
||||||
crate::priv_client::write_agent_state_file(name, "matrix-token", &format!("{access_token}\n"))
|
crate::priv_client::write_agent_matrix_token(name, &access_token)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
|
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
|
||||||
tracing::info!(%name, "matrix: provisioned access token");
|
tracing::info!(%name, "matrix: provisioned access token");
|
||||||
|
|
|
||||||
|
|
@ -256,18 +256,26 @@ pub async fn run_forge_admin(args: &[&str]) -> Result<(String, String)> {
|
||||||
check(call(&PrivRequest::RunForgeAdmin { args: owned }).await?)
|
check(call(&PrivRequest::RunForgeAdmin { args: owned }).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `content` to `<agent_state_root>/<agent_name>/state/<filename>`
|
/// Write the Forgejo access token for `agent_name` to
|
||||||
/// via hive-priv (running as root). The file is written 0600 and chowned
|
/// `<agent_state_root>/<agent_name>/state/forge-token` via hive-priv
|
||||||
/// to the agent user so it is readable from inside the agent container.
|
/// (running as root). The file is written 0600 and chowned to the agent
|
||||||
///
|
/// user so it is readable from inside the agent container.
|
||||||
/// Used for credential files (forge-token, matrix-token) that hive-c0re
|
pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()> {
|
||||||
/// mints but cannot write directly because those paths are inside agent-
|
ok(call(&PrivRequest::WriteAgentForgeToken {
|
||||||
/// owned (0755) state directories and hive-c0re runs unprivileged.
|
|
||||||
pub async fn write_agent_state_file(agent_name: &str, filename: &str, content: &str) -> Result<()> {
|
|
||||||
ok(call(&PrivRequest::WriteAgentStateFile {
|
|
||||||
agent_name: agent_name.to_owned(),
|
agent_name: agent_name.to_owned(),
|
||||||
filename: filename.to_owned(),
|
token: token.to_owned(),
|
||||||
content: content.to_owned(),
|
})
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the Matrix access token for `agent_name` to
|
||||||
|
/// `<agent_state_root>/<agent_name>/state/matrix-token` via hive-priv
|
||||||
|
/// (running as root). The file is written 0600 and chowned to the agent
|
||||||
|
/// user so it is readable from inside the agent container.
|
||||||
|
pub async fn write_agent_matrix_token(agent_name: &str, token: &str) -> Result<()> {
|
||||||
|
ok(call(&PrivRequest::WriteAgentMatrixToken {
|
||||||
|
agent_name: agent_name.to_owned(),
|
||||||
|
token: token.to_owned(),
|
||||||
})
|
})
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,38 +322,28 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
|
||||||
run_forge_admin(args).await
|
run_forge_admin(args).await
|
||||||
}
|
}
|
||||||
|
|
||||||
PrivRequest::WriteAgentStateFile {
|
PrivRequest::WriteAgentForgeToken {
|
||||||
ref agent_name,
|
ref agent_name,
|
||||||
ref filename,
|
ref token,
|
||||||
ref content,
|
|
||||||
} => {
|
} => {
|
||||||
validate_agent_name(agent_name)?;
|
validate_agent_name(agent_name)?;
|
||||||
validate_state_filename(filename)?;
|
write_agent_state_file(agent_name, "forge-token", &format!("{token}\n"))
|
||||||
write_agent_state_file(agent_name, filename, content)
|
}
|
||||||
|
|
||||||
|
PrivRequest::WriteAgentMatrixToken {
|
||||||
|
ref agent_name,
|
||||||
|
ref token,
|
||||||
|
} => {
|
||||||
|
validate_agent_name(agent_name)?;
|
||||||
|
write_agent_state_file(agent_name, "matrix-token", &format!("{token}\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicit allowlist of filenames `WriteAgentStateFile` may write.
|
/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`.
|
||||||
/// New credential files must be added here deliberately — hive-priv
|
/// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
|
||||||
/// rejects any filename not in this list.
|
/// chowns to the agent user (derived from the state dir's existing owner),
|
||||||
const ALLOWED_STATE_FILENAMES: &[&str] = &["forge-token", "matrix-token"];
|
/// and chmods 0600. Running as root (hive-priv), so this succeeds
|
||||||
|
|
||||||
/// Validate a filename destined for `WriteAgentStateFile` against the
|
|
||||||
/// explicit allowlist. Only known credential filenames are accepted.
|
|
||||||
fn validate_state_filename(filename: &str) -> Result<()> {
|
|
||||||
if !ALLOWED_STATE_FILENAMES.contains(&filename) {
|
|
||||||
bail!(
|
|
||||||
"state filename {filename:?} not in allowlist {:?}",
|
|
||||||
ALLOWED_STATE_FILENAMES
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
|
|
||||||
/// chown to the agent user (derived from the state dir's existing owner),
|
|
||||||
/// and chmod 0600. Running as root (hive-priv), so all of this succeeds
|
|
||||||
/// regardless of the file's prior owner/permissions.
|
/// regardless of the file's prior owner/permissions.
|
||||||
fn write_agent_state_file(
|
fn write_agent_state_file(
|
||||||
agent_name: &str,
|
agent_name: &str,
|
||||||
|
|
|
||||||
|
|
@ -229,29 +229,29 @@ pub enum PrivRequest {
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Agent state file writes ---
|
// --- Agent credential writes ---
|
||||||
/// Write a credential file into an agent's bind-mounted state directory.
|
/// Write `forge-token` into `AGENT_STATE_ROOT/<agent_name>/state/forge-token`.
|
||||||
///
|
///
|
||||||
/// Path resolved by hive-priv: `AGENT_STATE_ROOT/<agent_name>/state/<filename>`.
|
/// hive-priv validates `agent_name`, creates the state dir if absent,
|
||||||
/// hive-priv validates both the agent name and filename before writing.
|
/// writes the file 0600, and chowns it to the state dir's owner so
|
||||||
///
|
/// the agent process can read it. Required because hive-c0re runs
|
||||||
/// After writing, the file is chowned to the agent user's uid/gid
|
/// unprivileged and cannot write to agent-owned state directories.
|
||||||
/// (read from the state directory's owner) and chmoded 0600 so
|
WriteAgentForgeToken {
|
||||||
/// only the agent process can read it.
|
|
||||||
///
|
|
||||||
/// Required because hive-c0re runs as the unprivileged `hive-core`
|
|
||||||
/// user and cannot write to agent-owned (0755) state directories
|
|
||||||
/// after the privsep that moved c0re from root to a dedicated unix user.
|
|
||||||
WriteAgentStateFile {
|
|
||||||
/// Logical agent name (validated by `validate_agent_name`).
|
/// Logical agent name (validated by `validate_agent_name`).
|
||||||
agent_name: String,
|
agent_name: String,
|
||||||
/// Allowlisted credential filename within the state dir.
|
/// Token value. hive-priv appends a trailing newline before writing.
|
||||||
/// Only `"forge-token"` and `"matrix-token"` are accepted;
|
token: String,
|
||||||
/// hive-priv rejects any other value.
|
},
|
||||||
filename: String,
|
|
||||||
/// File content to write. Written as-is; caller is responsible for
|
/// Write `matrix-token` into `AGENT_STATE_ROOT/<agent_name>/state/matrix-token`.
|
||||||
/// including any trailing newline.
|
///
|
||||||
content: String,
|
/// Same semantics as `WriteAgentForgeToken` — validates name, creates
|
||||||
|
/// dir, writes 0600, chowns to agent owner.
|
||||||
|
WriteAgentMatrixToken {
|
||||||
|
/// Logical agent name (validated by `validate_agent_name`).
|
||||||
|
agent_name: String,
|
||||||
|
/// Token value. hive-priv appends a trailing newline before writing.
|
||||||
|
token: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue