From cfac917b4d0bd7c4c6ed00a0e44bdd69bf7f768e Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 19 Jul 2026 17:46:04 +0200 Subject: [PATCH 1/6] feat(#2302): parse agent names into AgentName newtype at dashboard boundary --- hive-c0re/src/dashboard/build_logs.rs | 17 ++-- hive-c0re/src/dashboard/idents.rs | 103 +++++++++++++++++++++++++ hive-c0re/src/dashboard/journal.rs | 15 ++-- hive-c0re/src/dashboard/misc_api.rs | 13 ++-- hive-c0re/src/dashboard/mod.rs | 74 ++---------------- hive-c0re/src/dashboard/permissions.rs | 18 +++-- hive-c0re/src/dashboard/tombstones.rs | 19 +++-- hive-c0re/src/socket_server/mod.rs | 6 +- 8 files changed, 162 insertions(+), 103 deletions(-) create mode 100644 hive-c0re/src/dashboard/idents.rs diff --git a/hive-c0re/src/dashboard/build_logs.rs b/hive-c0re/src/dashboard/build_logs.rs index c469caf0..4dbf4071 100644 --- a/hive-c0re/src/dashboard/build_logs.rs +++ b/hive-c0re/src/dashboard/build_logs.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; use tokio_stream::Stream; use tokio_stream::wrappers::ReceiverStream; -use super::{AppState, error_response, validate_agent_name}; +use super::{AppState, error_response, idents::AgentName}; #[derive(Deserialize)] pub(super) struct BuildLogsAllQuery { @@ -58,11 +58,18 @@ pub(super) async fn get_build_logs_agent( AxumPath(name): AxumPath, axum::extract::Query(q): axum::extract::Query, ) -> Response { - if let Some(reason) = validate_agent_name(&name) { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } + let name = match AgentName::parse(&name) { + Ok(n) => n, + Err(reason) => { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } + }; let limit = q.limit.unwrap_or(10); - match state.coord.build_logs.list_recent_for_agent(&name, limit) { + match state + .coord + .build_logs + .list_recent_for_agent(name.as_str(), limit) + { Ok(rows) => axum::Json(rows).into_response(), Err(e) => error_response(&format!("build-logs {name}: {e:#}")), } diff --git a/hive-c0re/src/dashboard/idents.rs b/hive-c0re/src/dashboard/idents.rs new file mode 100644 index 00000000..6516573f --- /dev/null +++ b/hive-c0re/src/dashboard/idents.rs @@ -0,0 +1,103 @@ +//! Validated identifier newtypes for dashboard path-params — "parse, don't +//! validate" (#2302). +//! +//! [`AgentName`] can only be constructed through a validating parser, so +//! "this string passed the naming whitelist" becomes a compile-time fact the +//! type carries, instead of a convention every handler re-checks against the +//! raw `String`. This is **format** validation only — +//! whether the name refers to a *live* agent is a separate, stateful runtime +//! concern kept at the lookup sites (see `guard_agent_name`), deliberately not +//! folded into the constructor. + +use std::fmt; + +/// A validated agent name: 1-63 chars of `[a-z0-9_-]`. +/// +/// Constructed only via [`AgentName::parse`]. The invariant matches the +/// historical `validate_agent_name` whitelist (conservative, tracking +/// `nixos-container` basename rules and the agent-name convention across the +/// codebase): rejects empty, over-long, uppercase, slashes, dots, and any +/// non-ASCII (incl. unicode homoglyphs of dash / underscore). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentName(String); + +impl AgentName { + /// Parse + validate a path-param agent name (format only). + /// + /// # Errors + /// Returns `Err(reason)` — a caller-ready 400-body string — when `name` + /// is empty, longer than 63 chars, or contains any byte outside + /// `[a-z0-9_-]`. + pub(crate) fn parse(name: &str) -> Result { + if name.is_empty() { + return Err("agent name must not be empty"); + } + if name.len() > 63 { + return Err("agent name must be 63 characters or fewer"); + } + if !name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') + { + return Err("agent name must contain only [a-z0-9_-]"); + } + Ok(Self(name.to_owned())) + } + + /// The validated name as a string slice. + #[must_use] + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AgentName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::AgentName; + + #[test] + fn agent_name_accepts_canonical_shapes() { + for ok in [ + "damocles", + "hm1nd", + "agent-with-dashes", + "snake_case", + "mixed_2-3", + ] { + assert!(AgentName::parse(ok).is_ok(), "should accept {ok:?}"); + } + let max = "a".repeat(63); + assert!(AgentName::parse(&max).is_ok(), "63 chars is the boundary"); + } + + #[test] + fn agent_name_rejects_bad_input() { + let too_long = "a".repeat(64); + for bad in [ + "", + &too_long, + "../etc/passwd", + "alice/bob", + "Alice", + "alice bob", + "alice.bob", + "alice;DROP TABLE messages", + // Non-ASCII, incl. unicode homoglyphs of ASCII dash. + "damóclès", + "alice\u{2013}bob", // en-dash + ] { + assert!(AgentName::parse(bad).is_err(), "should reject {bad:?}"); + } + } + + #[test] + fn agent_name_round_trips_as_str() { + assert_eq!(AgentName::parse("damocles").unwrap().as_str(), "damocles"); + } +} diff --git a/hive-c0re/src/dashboard/journal.rs b/hive-c0re/src/dashboard/journal.rs index 5eb739f3..8851d9ac 100644 --- a/hive-c0re/src/dashboard/journal.rs +++ b/hive-c0re/src/dashboard/journal.rs @@ -18,7 +18,7 @@ use serde::Deserialize; use problem_details::ProblemDetails; -use super::{error_problem, strip_container_prefix, validate_agent_name}; +use super::{error_problem, idents::AgentName, strip_container_prefix}; use crate::lifecycle; #[derive(Deserialize)] @@ -57,13 +57,16 @@ pub(super) async fn get_journal( // shellout below — the `lifecycle::list()` existence check would // catch them anyway, but rejecting at the boundary keeps the // failure mode crisp. - if let Some(reason) = validate_agent_name(&name) { - return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) - .with_detail(format!("bad agent name: {reason}"))); - } + let name = match AgentName::parse(&name) { + Ok(n) => n, + Err(reason) => { + return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("bad agent name: {reason}"))); + } + }; // Validate the container name against the list of managed // containers so we don't shell out with arbitrary input. - let container = strip_container_prefix(&name); + let container = strip_container_prefix(name.as_str()); let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX); let live = lifecycle::list().await.unwrap_or_default(); if !live.iter().any(|c| c == &prefixed) { diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index 8f5fde99..dbd099fc 100644 --- a/hive-c0re/src/dashboard/misc_api.rs +++ b/hive-c0re/src/dashboard/misc_api.rs @@ -10,7 +10,7 @@ use axum::{ }; use serde::Deserialize; -use super::{AppState, error_response, scan_validated_paths, validate_agent_name}; +use super::{AppState, error_response, idents::AgentName, scan_validated_paths}; /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox. /// Returns messages addressed to `"operator"` that haven't been @@ -113,10 +113,13 @@ pub(super) async fn post_mark_all_read( State(state): State, AxumPath(name): AxumPath, ) -> Response { - if let Some(reason) = validate_agent_name(&name) { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } - match state.coord.broker.mark_all_read(&name) { + let name = match AgentName::parse(&name) { + Ok(n) => n, + Err(reason) => { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } + }; + match state.coord.broker.mark_all_read(name.as_str()) { Ok(n) => { tracing::info!(%name, marked = n, "operator marked all messages read"); axum::Json(serde_json::json!({ "marked": n })).into_response() diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 85fb0aa2..cd559acd 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -19,6 +19,8 @@ use crate::lifecycle; mod approvals; mod build_logs; mod extra_forges; +mod idents; +pub(crate) use idents::AgentName; mod infra_containers; mod journal; mod lifecycle_ops; @@ -292,33 +294,10 @@ fn try_bind(addr: SocketAddr) -> std::io::Result { sock.listen(1024) } -/// Validate that a path-param agent name conforms to the hyperhive -/// naming whitelist: 1-63 chars of `[a-z0-9_-]`. Rejects empty, -/// uppercase, slashes, dots, and any non-ASCII (incl. unicode -/// homoglyphs of dash/underscore). Returns `None` on accept, `Some(reason)` -/// on reject — caller wraps the reason in a 400 response. Conservative -/// whitelist matching `nixos-container` basename rules and the existing -/// agent-name convention across the codebase. -pub(crate) fn validate_agent_name(name: &str) -> Option<&'static str> { - if name.is_empty() { - return Some("agent name must not be empty"); - } - if name.len() > 63 { - return Some("agent name must be 63 characters or fewer"); - } - if !name - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') - { - return Some("agent name must contain only [a-z0-9_-]"); - } - None -} - /// Two-axis path-param guard for write routes. Combines: /// -/// 1. **format validation** (`validate_agent_name`) — rejects path -/// traversal / unicode homoglyphs / empty + too-long names with +/// 1. **format validation** ([`idents::AgentName::parse`]) — rejects +/// path traversal / unicode homoglyphs / empty + too-long names with /// HTTP 400. /// 2. **existence check** — looks up `name` in the coordinator's /// container snapshot; unknown name → HTTP 404 with a clear @@ -332,9 +311,9 @@ pub(crate) fn validate_agent_name(name: &str) -> Option<&'static str> { /// handler taking a name path-param. Read-only GET handlers and /// handlers that legitimately operate on tombstoned agents (e.g. /// `mark-all-read` on broker rows for a destroyed agent) call -/// `validate_agent_name` directly and skip the existence check. +/// [`idents::AgentName::parse`] directly and skip the existence check. async fn guard_agent_name(state: &AppState, name: &str) -> Option { - if let Some(reason) = validate_agent_name(name) { + if let Err(reason) = idents::AgentName::parse(name) { return Some( (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(), ); @@ -396,45 +375,4 @@ mod tests { let fv = serde_json::to_value(&five).expect("problem details serialise"); assert_eq!(fv["status"], 500); } - - #[test] - fn validate_agent_name_accepts_canonical_shapes() { - assert!(validate_agent_name("damocles").is_none()); - assert!(validate_agent_name("hm1nd").is_none()); - assert!(validate_agent_name("agent-with-dashes").is_none()); - assert!(validate_agent_name("snake_case").is_none()); - assert!(validate_agent_name("mixed_2-3").is_none()); - let max = "a".repeat(63); - assert!( - validate_agent_name(&max).is_none(), - "63-char name should pass" - ); - } - - // The two-axis guard (`guard_agent_name`) wires `validate_agent_name` - // + an async coordinator lookup. The lookup needs a populated - // `Coordinator`, which needs sqlite + tokio runtime; rather than - // build that scaffolding for an integration-flavoured test we cover - // the format axis here (the existence axis is enforced by the - // shared `containers_snapshot` API, tested in `coordinator.rs`'s - // own suite). 9 cases below cover the boundary-length case and - // other expected rejects to make the contract explicit. - #[test] - fn validate_agent_name_rejects_bad_input() { - assert!(validate_agent_name("").is_some()); - let too_long = "a".repeat(64); - assert!(validate_agent_name(&too_long).is_some()); - // Path-traversal attempts. - assert!(validate_agent_name("../etc/passwd").is_some()); - assert!(validate_agent_name("alice/bob").is_some()); - // Uppercase rejected — canonical lowercase convention. - assert!(validate_agent_name("Alice").is_some()); - // No spaces, dots, special chars. - assert!(validate_agent_name("alice bob").is_some()); - assert!(validate_agent_name("alice.bob").is_some()); - assert!(validate_agent_name("alice;DROP TABLE messages").is_some()); - // Non-ASCII (incl. unicode homoglyphs of ASCII dash). - assert!(validate_agent_name("damóclès").is_some()); - assert!(validate_agent_name("alice\u{2013}bob").is_some()); // en-dash - } } diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index 1f133354..b3fa368b 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use problem_details::ProblemDetails; -use super::{AppState, guard_agent_name, strip_container_prefix, validate_agent_name}; +use super::{AppState, guard_agent_name, idents::AgentName, strip_container_prefix}; #[derive(Serialize)] pub(super) struct ToolGroupsSnapshot { @@ -371,24 +371,26 @@ pub(super) async fn get_stale_permissions( /// /// Bypasses `guard_agent_name`'s live-roster check intentionally — /// the whole point is to remove entries for non-roster agents. Only -/// the format check (`validate_agent_name`) is applied. No rebuild is +/// the format check ([`AgentName::parse`]) is applied. No rebuild is /// enqueued (the agent doesn't exist to rebuild); the SSE snapshots /// update the P3RM1SS10NS tab live. pub(super) async fn delete_agent_permissions( State(state): State, AxumPath(name): AxumPath, ) -> Response { - let logical = strip_container_prefix(&name); - if let Some(reason) = validate_agent_name(&logical) { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } + let logical = match AgentName::parse(&strip_container_prefix(&name)) { + Ok(n) => n, + Err(reason) => { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } + }; // Run both removals regardless so we clean up as much as possible // even on partial I/O errors. Collect errors to surface below. - let tg_err = crate::tool_groups::remove_agent(&logical).err(); + let tg_err = crate::tool_groups::remove_agent(logical.as_str()).err(); if let Some(ref e) = tg_err { tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry"); } - let cap_err = crate::capabilities::remove_agent(&logical).err(); + let cap_err = crate::capabilities::remove_agent(logical.as_str()).err(); if let Some(ref e) = cap_err { tracing::warn!(agent = %logical, error = ?e, "failed to remove capabilities entry"); } diff --git a/hive-c0re/src/dashboard/tombstones.rs b/hive-c0re/src/dashboard/tombstones.rs index 885f9676..4844ef64 100644 --- a/hive-c0re/src/dashboard/tombstones.rs +++ b/hive-c0re/src/dashboard/tombstones.rs @@ -17,7 +17,7 @@ use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle; -use super::{AppState, error_response, validate_agent_name}; +use super::{AppState, error_response, idents::AgentName}; #[derive(Serialize, Clone, Debug)] pub struct TombstoneView { @@ -116,16 +116,19 @@ pub(super) async fn post_purge_tombstone( // `containers_snapshot()` is deliberately NOT used here: // tombstoned agents are gone from the snapshot by design; that's // the whole point of this endpoint. - if let Some(reason) = validate_agent_name(&name) { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } + let name = match AgentName::parse(&name) { + Ok(n) => n, + Err(reason) => { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } + }; // Sanity: refuse to purge if a live container still exists with this // name. The dashboard already filters tombstones to non-live names, // but the operator could send a stale POST. let live = lifecycle::list().await.unwrap_or_default(); if live .iter() - .any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c == &name) + .any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c.as_str() == name.as_str()) { return error_response(&format!( "refusing to purge {name}: container still exists — use DESTR0Y first" @@ -133,8 +136,8 @@ pub(super) async fn post_purge_tombstone( } let mut errors = Vec::new(); for dir in [ - crate::paths::agent_state_dir(&name), - crate::paths::applied_dir(&name), + crate::paths::agent_state_dir(name.as_str()), + crate::paths::applied_dir(name.as_str()), ] { if dir.exists() && let Err(e) = std::fs::remove_dir_all(&dir) @@ -145,7 +148,7 @@ pub(super) async fn post_purge_tombstone( let _ = state .coord .approvals - .fail_pending_for_agent(&name, "agent state purged"); + .fail_pending_for_agent(name.as_str(), "agent state purged"); if errors.is_empty() { tracing::info!(%name, "tombstone purged"); // Fire the post-purge tombstones snapshot so dashboards diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 7b04daf3..8efd8440 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -425,7 +425,7 @@ async fn handle_get_agent_meta( // the OS level. Validate it before any path is built. The `None` default // (`target == agent`) is the caller's own authenticated name, already // valid — but validating unconditionally is simplest and harmless. - if let Some(reason) = crate::dashboard::validate_agent_name(target) { + if let Err(reason) = crate::dashboard::AgentName::parse(target) { return hive_agent_sock::Response::Err { message: format!("get_agent_meta: invalid agent name {target:?}: {reason}"), }; @@ -445,7 +445,7 @@ async fn handle_get_agent_meta( // `@user:server` / `homeserver`) — the access token lives separately // in the agent's `matrix-token` and is never part of this response. // Peer visibility is intentional: it lets an agent verify/contact - // another on a public matrix instance. The `validate_agent_name` gate + // another on a public matrix instance. The `AgentName::parse` gate // above is what closes the real vector here (path traversal via `../` // in an agent-supplied name). matrix_accounts: read_agent_matrix_identities(target), @@ -748,7 +748,7 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option { /// `submit_init_config`, which builds filesystem paths from it, so validate /// before that. fn require_new_child(agent: &str, target: &str, action: &str) -> Option { - if let Some(reason) = crate::dashboard::validate_agent_name(target) { + if let Err(reason) = crate::dashboard::AgentName::parse(target) { return Some(Response::Err { message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"), }); From d4e91bfeebdfdd52f9fccd51ce9670658e6edb7d Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 19 Jul 2026 17:56:55 +0200 Subject: [PATCH 2/6] feat(#2302): fold is_plain_ident into PlainIdent newtype --- hive-c0re/src/dashboard/extra_forges.rs | 57 +++++----------- hive-c0re/src/dashboard/idents.rs | 76 +++++++++++++++++++++- hive-c0re/src/dashboard/matrix_accounts.rs | 65 ++++++------------ 3 files changed, 112 insertions(+), 86 deletions(-) diff --git a/hive-c0re/src/dashboard/extra_forges.rs b/hive-c0re/src/dashboard/extra_forges.rs index 751f8c0d..03cea449 100644 --- a/hive-c0re/src/dashboard/extra_forges.rs +++ b/hive-c0re/src/dashboard/extra_forges.rs @@ -23,20 +23,9 @@ use axum::extract::{Form, Query}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; -use super::error_response; +use super::{error_response, idents::PlainIdent}; use crate::coordinator::Coordinator; -/// Plain-identifier check matching hive-priv's `validate_name_chars` -/// (lowercase ascii + digits + hyphens) — same guard used by -/// `matrix_accounts::is_plain_ident`. Duplicated locally (private, not -/// worth a shared-util churn for one predicate) rather than exported from -/// that module, since both call sites are dashboard-only. -fn is_plain_ident(s: &str) -> bool { - !s.is_empty() - && s.chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') -} - #[derive(Deserialize)] struct ForgeSidecar { base_url: String, @@ -75,10 +64,10 @@ pub(super) struct ExtraForgesQuery { /// sidecar when present. Never returns a token. pub(super) async fn get_extra_forges(Query(q): Query) -> Response { let agent = q.agent.trim(); - if !is_plain_ident(agent) { + let Ok(agent) = PlainIdent::parse(agent) else { return error_response(&format!("extra-forges: invalid agent {agent:?}")); - } - let dir = Coordinator::agent_notes_dir(agent); + }; + let dir = Coordinator::agent_notes_dir(agent.as_str()); let mut forges = Vec::new(); match std::fs::read_dir(&dir) { Ok(entries) => { @@ -141,12 +130,12 @@ struct ExtraForgeAccountResult { pub(super) async fn post_extra_forge_account(Form(f): Form) -> Response { let agent = f.agent.trim(); let label = f.label.trim(); - if !is_plain_ident(agent) { + let Ok(agent) = PlainIdent::parse(agent) else { return error_response(&format!("extra-forge-account: invalid agent {agent:?}")); - } - if !is_plain_ident(label) { + }; + let Ok(label) = PlainIdent::parse(label) else { return error_response(&format!("extra-forge-account: invalid label {label:?}")); - } + }; match f.action.as_str() { "add" => { @@ -160,9 +149,13 @@ pub(super) async fn post_extra_forge_account(Form(f): Form { - if let Err(e) = crate::priv_client::delete_agent_extra_forge_account(agent, label).await + if let Err(e) = + crate::priv_client::delete_agent_extra_forge_account(agent.as_str(), label.as_str()) + .await { return error_response(&format!( "extra-forge-account: delete account failed: {e:#}" @@ -187,19 +182,3 @@ pub(super) async fn post_extra_forge_account(Form(f): Form Result { + if s.is_empty() { + return Err("identifier must not be empty"); + } + if !s + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err("identifier must contain only [a-z0-9-]"); + } + Ok(Self(s.to_owned())) + } + + /// The validated identifier as a string slice. + #[must_use] + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PlainIdent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + #[cfg(test)] mod tests { - use super::AgentName; + use super::{AgentName, PlainIdent}; #[test] fn agent_name_accepts_canonical_shapes() { @@ -100,4 +149,25 @@ mod tests { fn agent_name_round_trips_as_str() { assert_eq!(AgentName::parse("damocles").unwrap().as_str(), "damocles"); } + + #[test] + fn plain_ident_accepts_labels_and_accounts() { + for ok in ["codeberg", "catgirl", "my-forge-1", "acct-1"] { + assert!(PlainIdent::parse(ok).is_ok(), "should accept {ok:?}"); + } + } + + #[test] + fn plain_ident_rejects_bad_input() { + // Underscore is allowed for AgentName but NOT here (matches + // hive-priv's stricter `validate_name_chars`). + for bad in ["", "MyForge", "my_forge", "../escape", "a/b", "a.b"] { + assert!(PlainIdent::parse(bad).is_err(), "should reject {bad:?}"); + } + } + + #[test] + fn plain_ident_round_trips_as_str() { + assert_eq!(PlainIdent::parse("codeberg").unwrap().as_str(), "codeberg"); + } } diff --git a/hive-c0re/src/dashboard/matrix_accounts.rs b/hive-c0re/src/dashboard/matrix_accounts.rs index 4f5f5c52..e80ba7ea 100644 --- a/hive-c0re/src/dashboard/matrix_accounts.rs +++ b/hive-c0re/src/dashboard/matrix_accounts.rs @@ -23,7 +23,7 @@ use axum::extract::{Form, Query}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; -use super::error_response; +use super::{error_response, idents::PlainIdent}; use crate::coordinator::Coordinator; #[derive(Deserialize)] @@ -176,17 +176,6 @@ struct MatrixLoginResult { user_id: String, } -/// Plain-identifier check matching hive-priv's `validate_name_chars` -/// exactly (lowercase ascii + digits + hyphens) — the root-side guard -/// re-applies the same rule before building the token path. Keeping the -/// dashboard check identical means a name that passes here can't then be -/// rejected at the priv boundary with a confusing "write token failed". -fn is_plain_ident(s: &str) -> bool { - !s.is_empty() - && s.chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') -} - /// Provision (or refresh) the token for an agent's extra matrix account. /// password mode → `m.login.password`; token mode → validate via `whoami`. /// On success writes the token to `matrix-token-` via hive-priv and @@ -196,15 +185,15 @@ pub(super) async fn post_matrix_account_login(Form(f): Form) -> let agent = f.agent.trim(); let account = f.account.trim(); let homeserver = f.homeserver.trim().trim_end_matches('/'); - if !is_plain_ident(agent) { + let Ok(agent) = PlainIdent::parse(agent) else { return error_response(&format!("matrix-account-login: invalid agent {agent:?}")); - } - if !is_plain_ident(account) { + }; + let Ok(account) = PlainIdent::parse(account) else { return error_response(&format!( "matrix-account-login: invalid account {account:?}" )); - } - if account == "main" { + }; + if account.as_str() == "main" { return error_response( "matrix-account-login: 'main' is the hive-internal account; it is \ provisioned via the normal flow, not this form", @@ -244,15 +233,19 @@ pub(super) async fn post_matrix_account_login(Form(f): Form) -> } }; - if let Err(e) = - crate::priv_client::write_agent_matrix_token(agent, &token, Some(account), Some(homeserver)) - .await + if let Err(e) = crate::priv_client::write_agent_matrix_token( + agent.as_str(), + &token, + Some(account.as_str()), + Some(homeserver), + ) + .await { return error_response(&format!("matrix-account-login: write token failed: {e:#}")); } // Best-effort kick so the daemon picks up the new account without a full // container restart; not fatal if the container isn't running. - if let Err(e) = crate::priv_client::restart_matrix_daemon(agent).await { + if let Err(e) = crate::priv_client::restart_matrix_daemon(agent.as_str()).await { tracing::warn!( %agent, %account, error = ?e, "matrix-account-login: daemon restart failed (token written; loads on next start)" @@ -288,13 +281,13 @@ struct GithubAccountResult { pub(super) async fn post_github_account(Form(f): Form) -> Response { let agent = f.agent.trim(); let token = f.token.trim(); - if !is_plain_ident(agent) { + let Ok(agent) = PlainIdent::parse(agent) else { return error_response(&format!("github-account: invalid agent {agent:?}")); - } + }; if token.is_empty() { return error_response("github-account: token is required"); } - if let Err(e) = crate::priv_client::write_agent_github_token(agent, token).await { + if let Err(e) = crate::priv_client::write_agent_github_token(agent.as_str(), token).await { return error_response(&format!("github-account: write token failed: {e:#}")); } tracing::info!(%agent, "github-account: provisioned github PAT"); @@ -320,10 +313,10 @@ struct GithubAccountStatus { /// Never returns the token itself. pub(super) async fn get_github_account(Query(q): Query) -> Response { let agent = q.agent.trim(); - if !is_plain_ident(agent) { + let Ok(agent) = PlainIdent::parse(agent) else { return error_response(&format!("github-account: invalid agent {agent:?}")); - } - let present = Coordinator::agent_notes_dir(agent) + }; + let present = Coordinator::agent_notes_dir(agent.as_str()) .join("github-token") .exists(); axum::Json(GithubAccountStatus { present }).into_response() @@ -402,7 +395,7 @@ async fn matrix_whoami(homeserver: &str, token: &str) -> Result #[cfg(test)] mod tests { - use super::{account_name_from_filename, is_plain_ident, read_accounts_snapshot}; + use super::{account_name_from_filename, read_accounts_snapshot}; fn unique_dir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!( @@ -488,20 +481,4 @@ mod tests { assert_eq!(account_name_from_filename("notes.md"), None); assert_eq!(account_name_from_filename("matrix-avatar-icon-hash"), None); } - - #[test] - fn is_plain_ident_matches_validate_name_chars() { - // Accepts exactly what hive-priv's validate_name_chars does: - // lowercase ascii + digits + hyphens. - assert!(is_plain_ident("catgirl")); - assert!(is_plain_ident("acct-1")); - assert!(!is_plain_ident("")); - // Rejected: uppercase + underscore (would pass a looser check - // then fail at the priv boundary), and path chars. - assert!(!is_plain_ident("MyAccount")); - assert!(!is_plain_ident("my_account")); - assert!(!is_plain_ident("../escape")); - assert!(!is_plain_ident("a/b")); - assert!(!is_plain_ident("a.b")); - } } From 384dcae5f489bf9b1ed51ac41f762a2cf2a45762 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 19 Jul 2026 19:16:08 +0200 Subject: [PATCH 3/6] feat(#2302): add validated Ident newtype in hive-host-sock --- Cargo.lock | 1 + hive-host-sock/Cargo.toml | 3 + hive-host-sock/src/lib.rs | 146 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index db46ddd6..92cf8206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1672,6 +1672,7 @@ version = "0.1.0" dependencies = [ "hive-sh4re", "serde", + "serde_json", ] [[package]] diff --git a/hive-host-sock/Cargo.toml b/hive-host-sock/Cargo.toml index 23407d6f..24152da4 100644 --- a/hive-host-sock/Cargo.toml +++ b/hive-host-sock/Cargo.toml @@ -9,3 +9,6 @@ workspace = true [dependencies] hive-sh4re.workspace = true serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 7bd71ddf..6e72c844 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -51,6 +51,152 @@ pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } +/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`. +/// +/// The single ident type for agent names, forge labels, and matrix / github +/// account names — every value that becomes a filesystem path segment or an +/// nspawn machine-name component. Constructed only through the validating +/// [`Ident::parse`], so "this string passed the naming whitelist" is a fact +/// the type carries instead of a convention every call site re-checks against +/// a raw `String`. The charset is deliberately conservative — lowercase +/// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) — +/// and length-capped, tracking `nixos-container` basename rules and keeping +/// `../` traversal, unicode homoglyphs, and unbounded path segments out of +/// any path built from it. Deserialization runs the same parse, so a value +/// arriving over the wire is validated on the way in. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Ident(String); + +impl Ident { + /// Maximum length in bytes. A cap stops an unbounded operator-supplied + /// name from becoming an over-long path segment (a filesystem / `DoS` + /// footgun). + pub const MAX_LEN: usize = 63; + + /// Parse + validate an identifier. + /// + /// # Errors + /// Returns `Err(reason)` — a caller-ready message — when `s` is empty, + /// longer than [`Ident::MAX_LEN`], or contains any byte outside + /// `[a-z0-9-]`. + pub fn parse(s: &str) -> Result { + if s.is_empty() { + return Err("identifier must not be empty"); + } + if s.len() > Self::MAX_LEN { + return Err("identifier must be 63 characters or fewer"); + } + if !s + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err("identifier must contain only [a-z0-9-]"); + } + Ok(Self(s.to_owned())) + } + + /// The validated identifier as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume into the inner `String`. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl std::fmt::Display for Ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for Ident { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`. +impl std::borrow::Borrow for Ident { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl serde::Serialize for Ident { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for Ident { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error as _; + let s = String::deserialize(deserializer)?; + Ident::parse(&s).map_err(D::Error::custom) + } +} + +#[cfg(test)] +mod ident_tests { + use super::Ident; + + #[test] + fn accepts_canonical_shapes() { + for ok in [ + "damocles", + "hm1nd", + "agent-with-dashes", + "codeberg", + "acct-1", + ] { + assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}"); + } + assert!( + Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(), + "63 chars is the boundary" + ); + } + + #[test] + fn rejects_bad_input() { + let too_long = "a".repeat(Ident::MAX_LEN + 1); + for bad in [ + "", + &too_long, + "Alice", // uppercase + "snake_case", // underscore (tightened out) + "alice.bob", // dot + "alice/bob", // slash + "../etc/passwd", // traversal + "damóclès", // non-ASCII + "alice\u{2013}b", // en-dash homoglyph + ] { + assert!(Ident::parse(bad).is_err(), "should reject {bad:?}"); + } + } + + #[test] + fn round_trips_and_serde_validates() { + let id = Ident::parse("damocles").unwrap(); + assert_eq!(id.as_str(), "damocles"); + // Serialize is transparent (just the inner string). + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"damocles\""); + // Deserialize runs the same parse. + let back: Ident = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + assert!( + serde_json::from_str::("\"BAD_NAME\"").is_err(), + "deserialize must reject an invalid ident" + ); + } +} + /// Which way to reconcile an agent's config branches /// ([`HostRequest::ReconcileConfigApply`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] From 128602994751103136898abb1003406235ef0d3f Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 19 Jul 2026 19:46:55 +0200 Subject: [PATCH 4/6] feat(#2302): migrate dashboard to the single hive-host-sock Ident newtype --- hive-c0re/src/dashboard/build_logs.rs | 4 +- hive-c0re/src/dashboard/extra_forges.rs | 8 +- hive-c0re/src/dashboard/idents.rs | 173 --------------------- hive-c0re/src/dashboard/journal.rs | 4 +- hive-c0re/src/dashboard/matrix_accounts.rs | 25 ++- hive-c0re/src/dashboard/misc_api.rs | 4 +- hive-c0re/src/dashboard/mod.rs | 13 +- hive-c0re/src/dashboard/permissions.rs | 6 +- hive-c0re/src/dashboard/tombstones.rs | 4 +- hive-c0re/src/socket_server/mod.rs | 6 +- 10 files changed, 37 insertions(+), 210 deletions(-) delete mode 100644 hive-c0re/src/dashboard/idents.rs diff --git a/hive-c0re/src/dashboard/build_logs.rs b/hive-c0re/src/dashboard/build_logs.rs index 4dbf4071..b0771608 100644 --- a/hive-c0re/src/dashboard/build_logs.rs +++ b/hive-c0re/src/dashboard/build_logs.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; use tokio_stream::Stream; use tokio_stream::wrappers::ReceiverStream; -use super::{AppState, error_response, idents::AgentName}; +use super::{AppState, Ident, error_response}; #[derive(Deserialize)] pub(super) struct BuildLogsAllQuery { @@ -58,7 +58,7 @@ pub(super) async fn get_build_logs_agent( AxumPath(name): AxumPath, axum::extract::Query(q): axum::extract::Query, ) -> Response { - let name = match AgentName::parse(&name) { + let name = match Ident::parse(&name) { Ok(n) => n, Err(reason) => { return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); diff --git a/hive-c0re/src/dashboard/extra_forges.rs b/hive-c0re/src/dashboard/extra_forges.rs index 03cea449..6c849c1c 100644 --- a/hive-c0re/src/dashboard/extra_forges.rs +++ b/hive-c0re/src/dashboard/extra_forges.rs @@ -23,7 +23,7 @@ use axum::extract::{Form, Query}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; -use super::{error_response, idents::PlainIdent}; +use super::{Ident, error_response}; use crate::coordinator::Coordinator; #[derive(Deserialize)] @@ -64,7 +64,7 @@ pub(super) struct ExtraForgesQuery { /// sidecar when present. Never returns a token. pub(super) async fn get_extra_forges(Query(q): Query) -> Response { let agent = q.agent.trim(); - let Ok(agent) = PlainIdent::parse(agent) else { + let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("extra-forges: invalid agent {agent:?}")); }; let dir = Coordinator::agent_notes_dir(agent.as_str()); @@ -130,10 +130,10 @@ struct ExtraForgeAccountResult { pub(super) async fn post_extra_forge_account(Form(f): Form) -> Response { let agent = f.agent.trim(); let label = f.label.trim(); - let Ok(agent) = PlainIdent::parse(agent) else { + let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("extra-forge-account: invalid agent {agent:?}")); }; - let Ok(label) = PlainIdent::parse(label) else { + let Ok(label) = Ident::parse(label) else { return error_response(&format!("extra-forge-account: invalid label {label:?}")); }; diff --git a/hive-c0re/src/dashboard/idents.rs b/hive-c0re/src/dashboard/idents.rs deleted file mode 100644 index bb10b879..00000000 --- a/hive-c0re/src/dashboard/idents.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Validated identifier newtypes for dashboard path-params — the -//! "parse, don't validate" discipline applied to agent-name / plain-ident -//! path parameters. -//! -//! [`AgentName`] can only be constructed through a validating parser, so -//! "this string passed the naming whitelist" becomes a compile-time fact the -//! type carries, instead of a convention every handler re-checks against the -//! raw `String`. This is **format** validation only — -//! whether the name refers to a *live* agent is a separate, stateful runtime -//! concern kept at the lookup sites (see `guard_agent_name`), deliberately not -//! folded into the constructor. -//! -//! [`PlainIdent`] is the slightly stricter sibling (no underscore, no length -//! cap) used for dashboard-provisioned labels / account names — it folds the -//! two hand-synced `is_plain_ident` copies that used to live in -//! `extra_forges` + `matrix_accounts` into one parser. - -use std::fmt; - -/// A validated agent name: 1-63 chars of `[a-z0-9_-]`. -/// -/// Constructed only via [`AgentName::parse`]. The invariant matches the -/// historical `validate_agent_name` whitelist (conservative, tracking -/// `nixos-container` basename rules and the agent-name convention across the -/// codebase): rejects empty, over-long, uppercase, slashes, dots, and any -/// non-ASCII (incl. unicode homoglyphs of dash / underscore). -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct AgentName(String); - -impl AgentName { - /// Parse + validate a path-param agent name (format only). - /// - /// # Errors - /// Returns `Err(reason)` — a caller-ready 400-body string — when `name` - /// is empty, longer than 63 chars, or contains any byte outside - /// `[a-z0-9_-]`. - pub(crate) fn parse(name: &str) -> Result { - if name.is_empty() { - return Err("agent name must not be empty"); - } - if name.len() > 63 { - return Err("agent name must be 63 characters or fewer"); - } - if !name - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') - { - return Err("agent name must contain only [a-z0-9_-]"); - } - Ok(Self(name.to_owned())) - } - - /// The validated name as a string slice. - #[must_use] - pub(crate) fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for AgentName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -/// A validated plain identifier: one or more chars of `[a-z0-9-]`. -/// -/// Matches hive-priv's `validate_name_chars` (lowercase ascii + digits + -/// hyphens, no underscore, no length cap). Used for dashboard-provisioned -/// labels + account names (extra-forge labels, matrix account names) that -/// become filesystem path segments, so the same `../` / uppercase / slash -/// rejects as [`AgentName`] apply — the two differ only in the underscore -/// (allowed by `AgentName`, not here) and the 63-char cap. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct PlainIdent(String); - -impl PlainIdent { - /// Parse + validate a plain identifier. - /// - /// # Errors - /// Returns `Err(reason)` when `s` is empty or contains any byte outside - /// `[a-z0-9-]`. - pub(crate) fn parse(s: &str) -> Result { - if s.is_empty() { - return Err("identifier must not be empty"); - } - if !s - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - { - return Err("identifier must contain only [a-z0-9-]"); - } - Ok(Self(s.to_owned())) - } - - /// The validated identifier as a string slice. - #[must_use] - pub(crate) fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for PlainIdent { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -#[cfg(test)] -mod tests { - use super::{AgentName, PlainIdent}; - - #[test] - fn agent_name_accepts_canonical_shapes() { - for ok in [ - "damocles", - "hm1nd", - "agent-with-dashes", - "snake_case", - "mixed_2-3", - ] { - assert!(AgentName::parse(ok).is_ok(), "should accept {ok:?}"); - } - let max = "a".repeat(63); - assert!(AgentName::parse(&max).is_ok(), "63 chars is the boundary"); - } - - #[test] - fn agent_name_rejects_bad_input() { - let too_long = "a".repeat(64); - for bad in [ - "", - &too_long, - "../etc/passwd", - "alice/bob", - "Alice", - "alice bob", - "alice.bob", - "alice;DROP TABLE messages", - // Non-ASCII, incl. unicode homoglyphs of ASCII dash. - "damóclès", - "alice\u{2013}bob", // en-dash - ] { - assert!(AgentName::parse(bad).is_err(), "should reject {bad:?}"); - } - } - - #[test] - fn agent_name_round_trips_as_str() { - assert_eq!(AgentName::parse("damocles").unwrap().as_str(), "damocles"); - } - - #[test] - fn plain_ident_accepts_labels_and_accounts() { - for ok in ["codeberg", "catgirl", "my-forge-1", "acct-1"] { - assert!(PlainIdent::parse(ok).is_ok(), "should accept {ok:?}"); - } - } - - #[test] - fn plain_ident_rejects_bad_input() { - // Underscore is allowed for AgentName but NOT here (matches - // hive-priv's stricter `validate_name_chars`). - for bad in ["", "MyForge", "my_forge", "../escape", "a/b", "a.b"] { - assert!(PlainIdent::parse(bad).is_err(), "should reject {bad:?}"); - } - } - - #[test] - fn plain_ident_round_trips_as_str() { - assert_eq!(PlainIdent::parse("codeberg").unwrap().as_str(), "codeberg"); - } -} diff --git a/hive-c0re/src/dashboard/journal.rs b/hive-c0re/src/dashboard/journal.rs index 8851d9ac..24b77394 100644 --- a/hive-c0re/src/dashboard/journal.rs +++ b/hive-c0re/src/dashboard/journal.rs @@ -18,7 +18,7 @@ use serde::Deserialize; use problem_details::ProblemDetails; -use super::{error_problem, idents::AgentName, strip_container_prefix}; +use super::{Ident, error_problem, strip_container_prefix}; use crate::lifecycle; #[derive(Deserialize)] @@ -57,7 +57,7 @@ pub(super) async fn get_journal( // shellout below — the `lifecycle::list()` existence check would // catch them anyway, but rejecting at the boundary keeps the // failure mode crisp. - let name = match AgentName::parse(&name) { + let name = match Ident::parse(&name) { Ok(n) => n, Err(reason) => { return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) diff --git a/hive-c0re/src/dashboard/matrix_accounts.rs b/hive-c0re/src/dashboard/matrix_accounts.rs index e80ba7ea..93cf0152 100644 --- a/hive-c0re/src/dashboard/matrix_accounts.rs +++ b/hive-c0re/src/dashboard/matrix_accounts.rs @@ -23,7 +23,7 @@ use axum::extract::{Form, Query}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; -use super::{error_response, idents::PlainIdent}; +use super::{Ident, error_response}; use crate::coordinator::Coordinator; #[derive(Deserialize)] @@ -104,17 +104,14 @@ fn account_name_from_filename(fname: &str) -> Option { pub(super) async fn get_matrix_accounts(Query(q): Query) -> Response { let agent = q.agent.trim(); - // Agent names are simple identifiers; reject anything else so a crafted - // `agent` can't escape the per-agent state root via path components. - if agent.is_empty() - || !agent - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - { + // Validate through the single `Ident` type so a crafted `agent` can't + // escape the per-agent state root via path components — the same guard + // every other agent-path builder goes through. + let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("matrix-accounts: invalid agent name {agent:?}")); - } + }; - let dir = Coordinator::agent_notes_dir(agent); + let dir = Coordinator::agent_notes_dir(agent.as_str()); let (snapshot, as_of_unix) = read_accounts_snapshot(&dir); let mut accounts = Vec::new(); match std::fs::read_dir(&dir) { @@ -185,10 +182,10 @@ pub(super) async fn post_matrix_account_login(Form(f): Form) -> let agent = f.agent.trim(); let account = f.account.trim(); let homeserver = f.homeserver.trim().trim_end_matches('/'); - let Ok(agent) = PlainIdent::parse(agent) else { + let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("matrix-account-login: invalid agent {agent:?}")); }; - let Ok(account) = PlainIdent::parse(account) else { + let Ok(account) = Ident::parse(account) else { return error_response(&format!( "matrix-account-login: invalid account {account:?}" )); @@ -281,7 +278,7 @@ struct GithubAccountResult { pub(super) async fn post_github_account(Form(f): Form) -> Response { let agent = f.agent.trim(); let token = f.token.trim(); - let Ok(agent) = PlainIdent::parse(agent) else { + let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("github-account: invalid agent {agent:?}")); }; if token.is_empty() { @@ -313,7 +310,7 @@ struct GithubAccountStatus { /// Never returns the token itself. pub(super) async fn get_github_account(Query(q): Query) -> Response { let agent = q.agent.trim(); - let Ok(agent) = PlainIdent::parse(agent) else { + let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("github-account: invalid agent {agent:?}")); }; let present = Coordinator::agent_notes_dir(agent.as_str()) diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index dbd099fc..1548d8db 100644 --- a/hive-c0re/src/dashboard/misc_api.rs +++ b/hive-c0re/src/dashboard/misc_api.rs @@ -10,7 +10,7 @@ use axum::{ }; use serde::Deserialize; -use super::{AppState, error_response, idents::AgentName, scan_validated_paths}; +use super::{AppState, Ident, error_response, scan_validated_paths}; /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox. /// Returns messages addressed to `"operator"` that haven't been @@ -113,7 +113,7 @@ pub(super) async fn post_mark_all_read( State(state): State, AxumPath(name): AxumPath, ) -> Response { - let name = match AgentName::parse(&name) { + let name = match Ident::parse(&name) { Ok(n) => n, Err(reason) => { return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index cd559acd..8a25abec 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -19,8 +19,11 @@ use crate::lifecycle; mod approvals; mod build_logs; mod extra_forges; -mod idents; -pub(crate) use idents::AgentName; +// The single validated identifier type — homed in `hive-host-sock` (the crate +// owning agent-path facts) so every dashboard path-param validates through the +// same type used to build agent paths. Re-exported so submodules + the socket +// server reach it as `crate::dashboard::Ident`. +pub(crate) use hive_host_sock::Ident; mod infra_containers; mod journal; mod lifecycle_ops; @@ -296,7 +299,7 @@ fn try_bind(addr: SocketAddr) -> std::io::Result { /// Two-axis path-param guard for write routes. Combines: /// -/// 1. **format validation** ([`idents::AgentName::parse`]) — rejects +/// 1. **format validation** ([`Ident::parse`]) — rejects /// path traversal / unicode homoglyphs / empty + too-long names with /// HTTP 400. /// 2. **existence check** — looks up `name` in the coordinator's @@ -311,9 +314,9 @@ fn try_bind(addr: SocketAddr) -> std::io::Result { /// handler taking a name path-param. Read-only GET handlers and /// handlers that legitimately operate on tombstoned agents (e.g. /// `mark-all-read` on broker rows for a destroyed agent) call -/// [`idents::AgentName::parse`] directly and skip the existence check. +/// [`Ident::parse`] directly and skip the existence check. async fn guard_agent_name(state: &AppState, name: &str) -> Option { - if let Err(reason) = idents::AgentName::parse(name) { + if let Err(reason) = Ident::parse(name) { return Some( (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(), ); diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index b3fa368b..49e3d9ee 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use problem_details::ProblemDetails; -use super::{AppState, guard_agent_name, idents::AgentName, strip_container_prefix}; +use super::{AppState, Ident, guard_agent_name, strip_container_prefix}; #[derive(Serialize)] pub(super) struct ToolGroupsSnapshot { @@ -371,14 +371,14 @@ pub(super) async fn get_stale_permissions( /// /// Bypasses `guard_agent_name`'s live-roster check intentionally — /// the whole point is to remove entries for non-roster agents. Only -/// the format check ([`AgentName::parse`]) is applied. No rebuild is +/// the format check ([`Ident::parse`]) is applied. No rebuild is /// enqueued (the agent doesn't exist to rebuild); the SSE snapshots /// update the P3RM1SS10NS tab live. pub(super) async fn delete_agent_permissions( State(state): State, AxumPath(name): AxumPath, ) -> Response { - let logical = match AgentName::parse(&strip_container_prefix(&name)) { + let logical = match Ident::parse(&strip_container_prefix(&name)) { Ok(n) => n, Err(reason) => { return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); diff --git a/hive-c0re/src/dashboard/tombstones.rs b/hive-c0re/src/dashboard/tombstones.rs index 4844ef64..880d3c17 100644 --- a/hive-c0re/src/dashboard/tombstones.rs +++ b/hive-c0re/src/dashboard/tombstones.rs @@ -17,7 +17,7 @@ use crate::container_view::{ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle; -use super::{AppState, error_response, idents::AgentName}; +use super::{AppState, Ident, error_response}; #[derive(Serialize, Clone, Debug)] pub struct TombstoneView { @@ -116,7 +116,7 @@ pub(super) async fn post_purge_tombstone( // `containers_snapshot()` is deliberately NOT used here: // tombstoned agents are gone from the snapshot by design; that's // the whole point of this endpoint. - let name = match AgentName::parse(&name) { + let name = match Ident::parse(&name) { Ok(n) => n, Err(reason) => { return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 8efd8440..4fcc37de 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -425,7 +425,7 @@ async fn handle_get_agent_meta( // the OS level. Validate it before any path is built. The `None` default // (`target == agent`) is the caller's own authenticated name, already // valid — but validating unconditionally is simplest and harmless. - if let Err(reason) = crate::dashboard::AgentName::parse(target) { + if let Err(reason) = hive_host_sock::Ident::parse(target) { return hive_agent_sock::Response::Err { message: format!("get_agent_meta: invalid agent name {target:?}: {reason}"), }; @@ -445,7 +445,7 @@ async fn handle_get_agent_meta( // `@user:server` / `homeserver`) — the access token lives separately // in the agent's `matrix-token` and is never part of this response. // Peer visibility is intentional: it lets an agent verify/contact - // another on a public matrix instance. The `AgentName::parse` gate + // another on a public matrix instance. The `Ident::parse` gate // above is what closes the real vector here (path traversal via `../` // in an agent-supplied name). matrix_accounts: read_agent_matrix_identities(target), @@ -748,7 +748,7 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option { /// `submit_init_config`, which builds filesystem paths from it, so validate /// before that. fn require_new_child(agent: &str, target: &str, action: &str) -> Option { - if let Err(reason) = crate::dashboard::AgentName::parse(target) { + if let Err(reason) = hive_host_sock::Ident::parse(target) { return Some(Response::Err { message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"), }); From bf644cc126dc475f81a95e95ecbefcf25f9d6ca6 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 20 Jul 2026 00:22:06 +0200 Subject: [PATCH 5/6] feat(#2302): thread &Ident through agent path builders --- hive-c0re/src/actions.rs | 23 +++++++++---- hive-c0re/src/container_view.rs | 33 ++++++++++++------- hive-c0re/src/coordinator.rs | 32 ++++++++++-------- hive-c0re/src/dashboard/approvals.rs | 7 +++- hive-c0re/src/dashboard/extra_forges.rs | 2 +- hive-c0re/src/dashboard/matrix_accounts.rs | 4 +-- hive-c0re/src/dashboard/permissions.rs | 1 + hive-c0re/src/dashboard/tombstones.rs | 4 +-- hive-c0re/src/forge/repos.rs | 7 +++- hive-c0re/src/lifecycle/host_config.rs | 10 ++++-- hive-c0re/src/lifecycle/setup.rs | 4 ++- hive-c0re/src/matrix.rs | 10 +++--- hive-c0re/src/meta.rs | 5 ++- hive-c0re/src/migrate.rs | 26 ++++++++------- hive-c0re/src/server.rs | 17 +++++++--- .../src/socket_server/config_approvals.rs | 4 ++- hive-c0re/src/socket_server/mod.rs | 27 +++++++++------ hive-c0re/src/stats/container_stats.rs | 8 +++-- hive-c0re/src/stats/hive_stats.rs | 2 +- hive-c0re/src/workers/crash_watch.rs | 4 ++- hive-c0re/src/workers/reminder_scheduler.rs | 10 ++++-- hive-host-sock/src/lib.rs | 8 +++-- hivectl/src/util.rs | 5 ++- 23 files changed, 168 insertions(+), 85 deletions(-) diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index b689f196..d9518248 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -41,9 +41,12 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { // Sub-second git seed + forge-remote wire. Routing through // the queue would surface a queue card that's gone before // the operator's eyes refocus. Run inline. - let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent); - let claude_dir = Coordinator::agent_claude_dir(&approval.agent); - let notes_dir = Coordinator::agent_notes_dir(&approval.agent); + let agent = hive_host_sock::Ident::parse(&approval.agent).map_err(|e| { + anyhow::anyhow!("approval {} has invalid agent name: {e}", approval.id) + })?; + let proposed_dir = Coordinator::agent_proposed_dir(&agent); + let claude_dir = Coordinator::agent_claude_dir(&agent); + let notes_dir = Coordinator::agent_notes_dir(&agent); run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await } ApprovalKind::UpdateMetaInputs => { @@ -798,10 +801,16 @@ pub async fn destroy(coord: &Arc, name: &str, purge: bool) -> Resul if let Err(e) = crate::priv_client::delete_agent_subvolume(name).await { tracing::warn!(error = ?e, %name, "purge: delete state subvolume failed"); } - for dir in [ - crate::paths::agent_state_dir(name), - crate::paths::applied_dir(name), - ] { + // A malformed name can't have a persistent state tree (the state dir + // is only ever created under a validated Ident), so its removal is a + // no-op — skip the state-dir sweep and just clear the applied dir. + let state_dir = hive_host_sock::Ident::parse(name) + .ok() + .map(|id| crate::paths::agent_state_dir(&id)); + for dir in state_dir + .into_iter() + .chain([crate::paths::applied_dir(name)]) + { if dir.exists() && let Err(e) = std::fs::remove_dir_all(&dir) { diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index c5a29f48..96fcb24d 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -64,20 +64,27 @@ pub async fn build_all(coord: &Coordinator) -> Vec { let topology = crate::topology::read(); let mut out = Vec::new(); for c in &raw { - let Some(logical) = c.strip_prefix(AGENT_PREFIX).map(str::to_owned) else { + let Some(logical) = c.strip_prefix(AGENT_PREFIX) else { + continue; + }; + // Parse the nspawn machine suffix into an Ident once at this + // enumeration origin; a suffix that isn't a valid ident isn't one + // of our agents, so skip it. + let Ok(logical) = hive_host_sock::Ident::parse(logical) else { continue; }; let deployed_full = locked .get(&format!("agent-{logical}")) .map(std::string::String::as_str); - let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full).await; + let needs_update = + crate::auto_update::agent_config_pending(logical.as_str(), deployed_full).await; let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned()); let pending_reminders = coord .broker .count_pending_reminders_for(logical.as_str()) .unwrap_or(0); - let parent = topology.get(&logical).cloned().flatten(); - let running = lifecycle::is_running(&logical).await; + let parent = topology.get(logical.as_str()).cloned().flatten(); + let running = lifecycle::is_running(logical.as_str()).await; // needs_login fires when EITHER the claude session dir is missing // (boot-time / fresh container) OR the harness wrote the auth-failed // sentinel because a turn hit 401. Cleared for stopped containers — @@ -94,10 +101,10 @@ pub async fn build_all(coord: &Coordinator) -> Vec { None }; out.push(ContainerView { - port: lifecycle::agent_web_port(&logical), + port: lifecycle::agent_web_port(logical.as_str()), running, container: c.clone(), - name: logical, + name: logical.into_string(), needs_update, needs_login, deployed_sha, @@ -126,7 +133,7 @@ pub fn claude_has_session(dir: &Path) -> bool { /// the consolidated `hyperhive-harness.json`. Falls back to the legacy /// individual sentinel files written by older harness builds so in-place /// upgrades don't lose state during the transition window. -fn read_harness_flags(name: &str) -> (bool, bool) { +fn read_harness_flags(name: &hive_host_sock::Ident) -> (bool, bool) { let dir = Coordinator::agent_notes_dir(name); if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) && let Ok(v) = serde_json::from_str::(&raw) @@ -147,7 +154,7 @@ fn read_harness_flags(name: &str) -> (bool, bool) { (rate_limited, needs_login) } -fn auth_failed_sentinel(name: &str) -> bool { +fn auth_failed_sentinel(name: &hive_host_sock::Ident) -> bool { read_harness_flags(name).1 } @@ -158,7 +165,7 @@ fn auth_failed_sentinel(name: &str) -> bool { /// NB: callers building `AgentMeta` for a *stopped* container should /// clear the result — the on-disk status is a stale snapshot from /// before the stop. Use `read_agent_status_live` for that. -pub fn read_agent_status(name: &str) -> (Option, Option) { +pub fn read_agent_status(name: &hive_host_sock::Ident) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); // Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte @@ -198,8 +205,10 @@ pub fn read_agent_status(name: &str) -> (Option, Option) { /// /// Returned tuple is `(status_text, status_set_at, running)`. /// `name` is the logical agent name (same as the broker recipient). -pub async fn read_agent_status_live(name: &str) -> (Option, Option, bool) { - if !lifecycle::is_running(name).await { +pub async fn read_agent_status_live( + name: &hive_host_sock::Ident, +) -> (Option, Option, bool) { + if !lifecycle::is_running(name.as_str()).await { return (None, None, false); } let (text, set_at) = read_agent_status(name); @@ -212,7 +221,7 @@ pub async fn read_agent_status_live(name: &str) -> (Option, Option, /// so it always reflects the resolved priority (nix config > runtime /// override > default). Returns `None` when the field is absent or the /// harness has not yet started a turn. -fn read_active_model(name: &str) -> Option { +fn read_active_model(name: &hive_host_sock::Ident) -> Option { let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json"); let raw = std::fs::read_to_string(path).ok()?; let v: serde_json::Value = serde_json::from_str(&raw).ok()?; diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 88390fed..c2c76c72 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -549,12 +549,18 @@ impl Coordinator { /// created. All other paths are derived statically from `name`. #[must_use] pub fn agent_paths(name: &str, agent_dir: PathBuf) -> AgentPaths { + // `name` is validated upstream (spawn-approval / enqueue gate / the + // MANAGER_NAME const), so an invalid ident here is a construction + // bug. This is the step-3 boundary between the Ident-threaded path + // builders and the job_queue layer (threaded post hive-jobq cutover). + let name = hive_host_sock::Ident::parse(name) + .expect("agent_paths: name must be a valid ident (validated at spawn/enqueue)"); AgentPaths { agent: agent_dir, - proposed: Self::agent_proposed_dir(name), - applied: crate::paths::applied_dir(name), - claude: Self::agent_claude_dir(name), - notes: Self::agent_notes_dir(name), + proposed: Self::agent_proposed_dir(&name), + applied: crate::paths::applied_dir(name.as_str()), + claude: Self::agent_claude_dir(&name), + notes: Self::agent_notes_dir(&name), } } @@ -1442,7 +1448,7 @@ impl Coordinator { /// Manager-editable proposed config repo. Bind-mounted into the manager /// container as `/agents//config/`. - pub fn agent_proposed_dir(name: &str) -> PathBuf { + pub fn agent_proposed_dir(name: &hive_host_sock::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("config") } @@ -1450,14 +1456,14 @@ impl Coordinator { /// container at `/root/.claude` so OAuth state survives container /// destroy/recreate. Each agent owns its own token lineage — sharing /// would break on the first refresh-token rotation. - pub fn agent_claude_dir(name: &str) -> PathBuf { + pub fn agent_claude_dir(name: &hive_host_sock::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("claude") } /// Per-agent durable knowledge dir. Bind-mounted RW into the agent /// container at `/agents/{name}/state`. Survives destroy/recreate. /// Agent-visible — claude is told to write long-lived notes here. - pub fn agent_notes_dir(name: &str) -> PathBuf { + pub fn agent_notes_dir(name: &hive_host_sock::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("state") } @@ -1467,7 +1473,7 @@ impl Coordinator { /// `hyperhive-turn-stats.sqlite`, `hyperhive-model`) — kept separate /// from the agent-visible `state/` so claude's "my notes" view is /// uncluttered and the host vacuum has a clean sweep root. - pub fn agent_harness_dir(name: &str) -> PathBuf { + pub fn agent_harness_dir(name: &hive_host_sock::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("harness") } @@ -1477,14 +1483,14 @@ impl Coordinator { /// destroyed-but-kept tombstones; callers filter the latter by /// subtracting `lifecycle::list()`. #[must_use] - pub fn kept_state_names() -> Vec { + pub fn kept_state_names() -> Vec { let Ok(rd) = std::fs::read_dir(crate::paths::agents_root()) else { return Vec::new(); }; - let mut out: Vec = rd + let mut out: Vec = rd .flatten() .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) - .filter_map(|e| e.file_name().into_string().ok()) + .filter_map(|e| hive_host_sock::Ident::parse(&e.file_name().into_string().ok()?).ok()) .collect(); out.sort(); out @@ -1497,12 +1503,12 @@ impl Coordinator { /// apply-commit spawns the container. Distinct from tombstones, /// which have an applied repo from a prior deploy. #[must_use] - pub fn pending_init_names() -> Vec { + pub fn pending_init_names() -> Vec { Self::kept_state_names() .into_iter() .filter(|n| { Self::agent_proposed_dir(n).join(".git").exists() - && !crate::paths::applied_dir(n).join(".git").exists() + && !crate::paths::applied_dir(n.as_str()).join(".git").exists() }) .collect() } diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index 8c82ab5c..d92ff1d9 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -66,7 +66,12 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec) -> Respo let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("extra-forges: invalid agent {agent:?}")); }; - let dir = Coordinator::agent_notes_dir(agent.as_str()); + let dir = Coordinator::agent_notes_dir(&agent); let mut forges = Vec::new(); match std::fs::read_dir(&dir) { Ok(entries) => { diff --git a/hive-c0re/src/dashboard/matrix_accounts.rs b/hive-c0re/src/dashboard/matrix_accounts.rs index 93cf0152..a02cb8fe 100644 --- a/hive-c0re/src/dashboard/matrix_accounts.rs +++ b/hive-c0re/src/dashboard/matrix_accounts.rs @@ -111,7 +111,7 @@ pub(super) async fn get_matrix_accounts(Query(q): Query) -> return error_response(&format!("matrix-accounts: invalid agent name {agent:?}")); }; - let dir = Coordinator::agent_notes_dir(agent.as_str()); + let dir = Coordinator::agent_notes_dir(&agent); let (snapshot, as_of_unix) = read_accounts_snapshot(&dir); let mut accounts = Vec::new(); match std::fs::read_dir(&dir) { @@ -313,7 +313,7 @@ pub(super) async fn get_github_account(Query(q): Query) -> R let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("github-account: invalid agent {agent:?}")); }; - let present = Coordinator::agent_notes_dir(agent.as_str()) + let present = Coordinator::agent_notes_dir(&agent) .join("github-token") .exists(); axum::Json(GithubAccountStatus { present }).into_response() diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index 49e3d9ee..39d03d37 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -344,6 +344,7 @@ pub(super) async fn get_stale_permissions( let kept: std::collections::HashSet = crate::coordinator::Coordinator::kept_state_names() .into_iter() + .map(hive_host_sock::Ident::into_string) .collect(); // Known = live roster ∪ kept-state names. let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect(); diff --git a/hive-c0re/src/dashboard/tombstones.rs b/hive-c0re/src/dashboard/tombstones.rs index 880d3c17..6c4420c3 100644 --- a/hive-c0re/src/dashboard/tombstones.rs +++ b/hive-c0re/src/dashboard/tombstones.rs @@ -57,7 +57,7 @@ pub(super) fn build_tombstone_views( .unwrap_or(0); let has_creds = claude_has_session(&Coordinator::agent_claude_dir(&name)); TombstoneView { - name, + name: name.into_string(), state_bytes, last_seen, has_creds, @@ -136,7 +136,7 @@ pub(super) async fn post_purge_tombstone( } let mut errors = Vec::new(); for dir in [ - crate::paths::agent_state_dir(name.as_str()), + crate::paths::agent_state_dir(&name), crate::paths::applied_dir(name.as_str()), ] { if dir.exists() diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index 99443318..ef5c74ee 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -420,7 +420,12 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } - let proposed_dir = Coordinator::agent_proposed_dir(name); + // A malformed name has no proposed config repo (repos are only created + // under a validated Ident), so there's nothing to wire — no-op. + let Ok(agent) = hive_host_sock::Ident::parse(name) else { + return Ok(()); + }; + let proposed_dir = Coordinator::agent_proposed_dir(&agent); if !proposed_dir.join(".git").exists() { return Ok(()); } diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 247b4f6a..3f280ee2 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -51,7 +51,11 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; /// state") for the rationale. Creates missing host-side directories so /// nspawn doesn't refuse to start; missing dirs are non-fatal. fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { - let child_root = crate::paths::agent_state_dir(child); + let Ok(child) = hive_host_sock::Ident::parse(child) else { + tracing::warn!(%child, "skipping child bind: invalid agent name"); + return; + }; + let child_root = crate::paths::agent_state_dir(&child); for sub in ["state", "harness", "config"] { let host = child_root.join(sub); let _ = std::fs::create_dir_all(&host); @@ -197,7 +201,9 @@ async fn set_nspawn_flags( read_only: false, }); } - let own_config = crate::paths::agent_state_dir(agent_name).join("config"); + let agent_id = hive_host_sock::Ident::parse(agent_name) + .map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?; + let own_config = crate::paths::agent_state_dir(&agent_id).join("config"); std::fs::create_dir_all(&own_config) .with_context(|| format!("create {}", own_config.display()))?; binds.push(BindMount { diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs index aeeb9cc2..da7888c4 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -212,7 +212,9 @@ pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { /// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation /// is privileged, so it's delegated to hive-priv. pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> { - let root = crate::paths::agent_state_dir(name); + let agent = hive_host_sock::Ident::parse(name) + .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; + let root = crate::paths::agent_state_dir(&agent); if root.exists() { return Ok(()); } diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 156d50b6..4674e6ec 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -65,7 +65,7 @@ pub fn admin_token_path() -> PathBuf { /// Token file inside the agent's bind-mounted state dir (visible as /// `/state/matrix-token` from inside the container). -fn token_path(name: &str) -> PathBuf { +fn token_path(name: &hive_host_sock::Ident) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-token") } @@ -89,7 +89,7 @@ fn password_path(name: &str) -> PathBuf { /// move credentials from old deployments to the new location. Safe to /// call after `destroy --purge` — the path will simply not exist and /// the migration is a no-op. -fn legacy_password_path(name: &str) -> PathBuf { +fn legacy_password_path(name: &hive_host_sock::Ident) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-password") } @@ -611,7 +611,9 @@ pub async fn ensure_user_for( register_token: &str, ) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let path = token_path(name); + let agent = hive_host_sock::Ident::parse(name) + .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; + let path = token_path(&agent); if path.exists() && let Ok(existing) = std::fs::read_to_string(&path) && !existing.trim().is_empty() @@ -623,7 +625,7 @@ pub async fn ensure_user_for( // One-time migration: move the password from the old location inside // agent_notes_dir (purgeable) to the new location outside it. let new_pw_path = password_path(name); - let old_pw_path = legacy_password_path(name); + let old_pw_path = legacy_password_path(&agent); if !new_pw_path.exists() && old_pw_path.exists() { if let Some(parent) = new_pw_path.parent() { std::fs::create_dir_all(parent).ok(); diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 8a75e409..2de62d64 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -122,7 +122,10 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { // only fills in missing entries. Idempotent; when nothing changed // the file isn't touched. let agent_names: Vec = agents.iter().map(|a| a.name.clone()).collect(); - let pending = crate::coordinator::Coordinator::pending_init_names(); + let pending: Vec = crate::coordinator::Coordinator::pending_init_names() + .into_iter() + .map(hive_host_sock::Ident::into_string) + .collect(); crate::topology::reconcile(&agent_names, &pending) .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index f15dc8f4..20cbe2c0 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -68,11 +68,11 @@ pub async fn run(coord: &Arc) -> Result<()> { tracing::debug!("migration: phase 1+2 (applied + proposed repos)"); for name in &names { tracing::debug!(%name, "migration: applied+proposed"); - if let Err(e) = migrate_applied_repo(name).await { + if let Err(e) = migrate_applied_repo(name.as_str()).await { tracing::warn!(%name, error = ?e, "migration: applied repo rewrite failed"); } let proposed_dir = Coordinator::agent_proposed_dir(name); - let proposed = lifecycle::setup_proposed(&proposed_dir, name); + let proposed = lifecycle::setup_proposed(&proposed_dir, name.as_str()); match tokio::time::timeout(GIT_TIMEOUT, proposed).await { Ok(Err(e)) => tracing::warn!(%name, error = ?e, "migration: setup_proposed failed"), Err(_) => { @@ -108,8 +108,9 @@ pub async fn run(coord: &Arc) -> Result<()> { // update activation triggers. Without this, crash_watch // would fire ContainerCrash for every agent here and the // manager would spuriously try to recover them. - let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding); - let result = repoint_container(name).await; + let guard = + coord.transient_guard(name.as_str(), crate::coordinator::TransientKind::Rebuilding); + let result = repoint_container(name.as_str()).await; drop(guard); if let Err(e) = result { tracing::warn!(%name, error = ?e, "migration: container repoint failed"); @@ -140,7 +141,7 @@ pub async fn run(coord: &Arc) -> Result<()> { /// and into the sibling harness dir. Best-effort: logs warnings but never /// fails. Idempotent — each file is only moved if present at the old path /// and absent at the new path. -fn migrate_harness_files(name: &str) { +fn migrate_harness_files(name: &hive_host_sock::Ident) { const HARNESS_FILES: &[&str] = &[ "hyperhive-events.sqlite", "hyperhive-turn-stats.sqlite", @@ -266,16 +267,17 @@ async fn rename_manager_container(coord: &Arc) { } } -async fn enumerate_agents() -> Vec { +async fn enumerate_agents() -> Vec { let containers = lifecycle::list().await.unwrap_or_default(); containers .into_iter() .filter_map(|c| { - if c == MANAGER_CONTAINER { - Some(MANAGER_NAME.to_owned()) + let name = if c == MANAGER_CONTAINER { + MANAGER_NAME } else { - c.strip_prefix(AGENT_PREFIX).map(str::to_owned) - } + c.strip_prefix(AGENT_PREFIX)? + }; + hive_host_sock::Ident::parse(name).ok() }) .collect() } @@ -351,8 +353,8 @@ async fn repoint_container(name: &str) -> Result<()> { /// Idempotent — skips when entry already present. Prevents a silent tool /// downgrade when upgrading from a build that relied on the manager-flavor /// fallback in `effective_tool_groups()`. -fn backfill_manager_tool_groups(names: &[String]) { - if !names.iter().any(|n| n == MANAGER_NAME) { +fn backfill_manager_tool_groups(names: &[hive_host_sock::Ident]) { + if !names.iter().any(|n| n.as_str() == MANAGER_NAME) { return; // ruth not deployed — nothing to backfill } let existing = tool_groups::groups_for(MANAGER_NAME); diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index cc6053a1..8bc16ebe 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -321,7 +321,9 @@ fn matrix_http_client() -> Result { /// True when `name` has a state dir under the agents root, i.e. it's a /// managed agent rather than a bare (operator/human) matrix account. fn agent_exists(name: &str) -> Result { - crate::paths::agent_state_dir(name) + let name = hive_host_sock::Ident::parse(name) + .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; + crate::paths::agent_state_dir(&name) .try_exists() .with_context(|| format!("check agent state dir for {name}")) } @@ -354,7 +356,9 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result crate::matrix::ensure_user_for(&client, name, ®ister_token) .await .with_context(|| format!("matrix create-user {name}"))?; - let path = Coordinator::agent_notes_dir(name).join("matrix-token"); + let agent = hive_host_sock::Ident::parse(name) + .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; + let path = Coordinator::agent_notes_dir(&agent).join("matrix-token"); out.push(format!("matrix: provisioned agent user '{name}'")); out.push(format!("token persisted at: {}", path.display())); } else { @@ -405,7 +409,9 @@ async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result< crate::forge::ensure_user_for(name) .await .with_context(|| format!("forge create-user {name}"))?; - let path = Coordinator::agent_notes_dir(name).join("forge-token"); + let agent = hive_host_sock::Ident::parse(name) + .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; + let path = Coordinator::agent_notes_dir(&agent).join("forge-token"); out.push(format!("forge: provisioned agent user '{name}'")); out.push(format!("token persisted at: {}", path.display())); } else { @@ -459,7 +465,10 @@ async fn handle_quota_limit(name: &str, limit: Option) -> Result) -> Result { let agents: Vec = match name { Some(n) => vec![n.to_owned()], - None => Coordinator::kept_state_names(), + None => Coordinator::kept_state_names() + .into_iter() + .map(hive_host_sock::Ident::into_string) + .collect(), }; let mut rows = Vec::with_capacity(agents.len()); for agent in &agents { diff --git a/hive-c0re/src/socket_server/config_approvals.rs b/hive-c0re/src/socket_server/config_approvals.rs index 3b35f8be..c52f5ce7 100644 --- a/hive-c0re/src/socket_server/config_approvals.rs +++ b/hive-c0re/src/socket_server/config_approvals.rs @@ -195,7 +195,9 @@ pub(crate) fn submit_init_config( parent: Option<&str>, description: Option, ) -> anyhow::Result { - let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name); + let agent = hive_host_sock::Ident::parse(name) + .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; + let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent); if proposed_dir.join(".git").exists() { anyhow::bail!( "proposed config repo for '{name}' already exists at {} - \ diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 4fcc37de..152a5397 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -425,13 +425,16 @@ async fn handle_get_agent_meta( // the OS level. Validate it before any path is built. The `None` default // (`target == agent`) is the caller's own authenticated name, already // valid — but validating unconditionally is simplest and harmless. - if let Err(reason) = hive_host_sock::Ident::parse(target) { - return hive_agent_sock::Response::Err { - message: format!("get_agent_meta: invalid agent name {target:?}: {reason}"), - }; - } + let target_id = match hive_host_sock::Ident::parse(target) { + Ok(id) => id, + Err(reason) => { + return hive_agent_sock::Response::Err { + message: format!("get_agent_meta: invalid agent name {target:?}: {reason}"), + }; + } + }; let (status_text, status_set_at, running) = - crate::container_view::read_agent_status_live(target).await; + crate::container_view::read_agent_status_live(&target_id).await; let (hive_name, swarm_name) = crate::container_view::hive_swarm_names(); hive_agent_sock::Response::AgentMeta { name: target.to_owned(), @@ -448,7 +451,7 @@ async fn handle_get_agent_meta( // another on a public matrix instance. The `Ident::parse` gate // above is what closes the real vector here (path traversal via `../` // in an agent-supplied name). - matrix_accounts: read_agent_matrix_identities(target), + matrix_accounts: read_agent_matrix_identities(&target_id), } } @@ -458,7 +461,7 @@ async fn handle_get_agent_meta( /// or the daemon not up yet) yields an empty list. The `MatrixIdentity` /// serde shape matches the snapshot entries; the snapshot's `live` field is /// ignored (only live accounts are written). -fn read_agent_matrix_identities(agent: &str) -> Vec { +fn read_agent_matrix_identities(agent: &hive_host_sock::Ident) -> Vec { let path = Coordinator::agent_notes_dir(agent).join("matrix-accounts.json"); std::fs::read_to_string(&path) .ok() @@ -1112,8 +1115,12 @@ pub(crate) fn handle_send( }; } if resolved != hive_sh4re::OPERATOR_RECIPIENT { - let state_root = crate::paths::agent_state_dir(&resolved); - if !state_root.exists() { + // A name that doesn't parse as an Ident can't be a local agent, so + // it collapses into the same "unknown recipient" error as a valid + // name with no state dir. + let exists = hive_host_sock::Ident::parse(&resolved) + .is_ok_and(|id| crate::paths::agent_state_dir(&id).exists()); + if !exists { return Response::Err { message: format!( "send failed: unknown recipient `{resolved}` \ diff --git a/hive-c0re/src/stats/container_stats.rs b/hive-c0re/src/stats/container_stats.rs index cada8706..ccf7cca5 100644 --- a/hive-c0re/src/stats/container_stats.rs +++ b/hive-c0re/src/stats/container_stats.rs @@ -115,7 +115,7 @@ async fn du_bytes(path: &std::path::Path) -> Option { /// agent's state-dir contribution was 0; with the writable rootfs nearly empty /// (almost everything is bind-mounted), that surfaced as all agents reporting /// 0 disk. -async fn measure_agent_disk(name: &str) -> u64 { +async fn measure_agent_disk(name: &hive_host_sock::Ident) -> u64 { let state_dir = Coordinator::agent_notes_dir(name); let rootfs = PathBuf::from(format!("{NIXOS_CONTAINERS_ROOT}/h-{name}")); let mut total = 0u64; @@ -136,7 +136,7 @@ pub async fn disk_sampler_loop() { for name in Coordinator::kept_state_names() { let bytes = measure_agent_disk(&name).await; if let Ok(mut cache) = disk_cache().write() { - cache.insert(name, bytes); + cache.insert(name.into_string(), bytes); } } sleep(DISK_SAMPLE_INTERVAL).await; @@ -240,7 +240,9 @@ pub async fn gather() -> Vec { .into_iter() .filter_map(|name| { let dir = scope_dir(&format!("h-{name}")); - dir.join("cpu.stat").exists().then_some((name, dir)) + dir.join("cpu.stat") + .exists() + .then_some((name.into_string(), dir)) }) .collect(); diff --git a/hive-c0re/src/stats/hive_stats.rs b/hive-c0re/src/stats/hive_stats.rs index 6ef47bf6..bbb7eba7 100644 --- a/hive-c0re/src/stats/hive_stats.rs +++ b/hive-c0re/src/stats/hive_stats.rs @@ -366,7 +366,7 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats { *bash_mix.entry(h.clone()).or_insert(0) += c; } agents.push(AgentRollup { - name, + name: name.into_string(), turns: agg.turns, input_tokens: agg.input, output_tokens: agg.output, diff --git a/hive-c0re/src/workers/crash_watch.rs b/hive-c0re/src/workers/crash_watch.rs index e5ebacdf..19166278 100644 --- a/hive-c0re/src/workers/crash_watch.rs +++ b/hive-c0re/src/workers/crash_watch.rs @@ -41,7 +41,9 @@ pub fn spawn(coord: Arc) { if lifecycle::is_running(&logical).await { current_running.insert(logical.clone()); } - if claude_has_session(&Coordinator::agent_claude_dir(&logical)) { + if hive_host_sock::Ident::parse(&logical) + .is_ok_and(|id| claude_has_session(&Coordinator::agent_claude_dir(&id))) + { current_logged_in.insert(logical.clone()); } } diff --git a/hive-c0re/src/workers/reminder_scheduler.rs b/hive-c0re/src/workers/reminder_scheduler.rs index 6c69fe7e..a0da2317 100644 --- a/hive-c0re/src/workers/reminder_scheduler.rs +++ b/hive-c0re/src/workers/reminder_scheduler.rs @@ -133,6 +133,8 @@ fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String { /// inline-falls-back). `pub` because `socket_server::handle_remind` /// reuses it for the at-remind-time auto-file path. pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> { + let agent = hive_host_sock::Ident::parse(agent) + .map_err(|e| format!("invalid agent name {agent:?}: {e}"))?; let Some(parent) = host_path.parent() else { return Err("internal: host path has no parent".to_owned()); }; @@ -143,7 +145,7 @@ pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), let parent_canonical = parent .canonicalize() .map_err(|e| format!("parent canonicalize failed: {e}"))?; - let agent_root = Coordinator::agent_notes_dir(agent) + let agent_root = Coordinator::agent_notes_dir(&agent) .canonicalize() .map_err(|e| format!("agent state root canonicalize failed: {e}"))?; if !parent_canonical.starts_with(&agent_root) { @@ -189,7 +191,9 @@ pub fn container_state_prefix(agent: &str) -> String { /// reason string on rejection. `pub` so `socket_server::handle_remind` /// can reuse it for the at-remind-time auto-file path. pub fn resolve_host_path(agent: &str, req_path: &str) -> Result { - let prefix = container_state_prefix(agent); + let agent = hive_host_sock::Ident::parse(agent) + .map_err(|e| format!("invalid agent name {agent:?}: {e}"))?; + let prefix = container_state_prefix(agent.as_str()); let Some(rel) = req_path.strip_prefix(&prefix) else { return Err(format!( "must be absolute and under `{prefix}` (got `{req_path}`)" @@ -209,7 +213,7 @@ pub fn resolve_host_path(agent: &str, req_path: &str) -> Result } } } - Ok(Coordinator::agent_notes_dir(agent).join(rel_path)) + Ok(Coordinator::agent_notes_dir(&agent).join(rel_path)) } #[cfg(test)] diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 6e72c844..46d3ae58 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -31,9 +31,13 @@ pub const HOST_SOCKET: &str = "/run/hyperhive/host.sock"; pub const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; /// `agents/` — one agent's persistent state root. +/// +/// Takes a validated [`Ident`] (not a raw `&str`) so a per-agent state path +/// can never be built from an unvalidated name — the `../` traversal guard is +/// the type, enforced at the one place every agent path is rooted. #[must_use] -pub fn agent_state_dir(name: &str) -> PathBuf { - PathBuf::from(AGENTS_ROOT).join(name) +pub fn agent_state_dir(name: &Ident) -> PathBuf { + PathBuf::from(AGENTS_ROOT).join(name.as_str()) } /// `gateway/gateway.htpasswd` — nginx basic-auth credential store for the diff --git a/hivectl/src/util.rs b/hivectl/src/util.rs index 3cedbf14..a1d33dc7 100644 --- a/hivectl/src/util.rs +++ b/hivectl/src/util.rs @@ -115,7 +115,10 @@ pub(crate) async fn query_hive_urls(socket: &Path) -> Option Result { - let root = hive_host_sock::agent_state_dir(name); + let Ok(name) = hive_host_sock::Ident::parse(name) else { + bail!("invalid agent name {name:?}"); + }; + let root = hive_host_sock::agent_state_dir(&name); match root.try_exists() { Ok(found) => Ok(found), Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => bail!( From 84b750fba558a4152bc8dc337bee1dbe4792be42 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 20 Jul 2026 21:21:03 +0200 Subject: [PATCH 6/6] refactor(#2302): type socket wire fields as ident, validated by serde on deserialize --- Cargo.lock | 12 +- Cargo.toml | 2 + hive-c0re/Cargo.toml | 1 + hive-c0re/src/actions.rs | 4 +- hive-c0re/src/container_view.rs | 12 +- hive-c0re/src/coordinator.rs | 18 +- hive-c0re/src/dashboard/approvals.rs | 2 +- hive-c0re/src/dashboard/mod.rs | 2 +- hive-c0re/src/dashboard/permissions.rs | 2 +- hive-c0re/src/forge/repos.rs | 2 +- hive-c0re/src/lifecycle/host_config.rs | 4 +- hive-c0re/src/lifecycle/setup.rs | 2 +- hive-c0re/src/matrix.rs | 6 +- hive-c0re/src/meta.rs | 2 +- hive-c0re/src/migrate.rs | 8 +- hive-c0re/src/server.rs | 87 ++++---- .../src/socket_server/config_approvals.rs | 2 +- hive-c0re/src/socket_server/mod.rs | 8 +- hive-c0re/src/stats/container_stats.rs | 2 +- hive-c0re/src/workers/crash_watch.rs | 2 +- hive-c0re/src/workers/reminder_scheduler.rs | 4 +- hive-host-sock/Cargo.toml | 4 +- hive-host-sock/src/lib.rs | 189 ++---------------- hive-types/Cargo.toml | 13 ++ hive-types/src/lib.rs | 153 ++++++++++++++ hivectl/Cargo.toml | 1 + hivectl/src/agents.rs | 14 +- hivectl/src/forge.rs | 6 +- hivectl/src/github.rs | 2 +- hivectl/src/matrix.rs | 6 +- hivectl/src/quota.rs | 4 +- hivectl/src/subvol.rs | 8 +- hivectl/src/util.rs | 12 +- 33 files changed, 333 insertions(+), 263 deletions(-) create mode 100644 hive-types/Cargo.toml create mode 100644 hive-types/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 92cf8206..b69af92e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1619,6 +1619,7 @@ dependencies = [ "hive-host-sock", "hive-priv-sock", "hive-sh4re", + "hive-types", "hmac 0.13.0", "indicatif", "libc", @@ -1671,8 +1672,8 @@ name = "hive-host-sock" version = "0.1.0" dependencies = [ "hive-sh4re", + "hive-types", "serde", - "serde_json", ] [[package]] @@ -1758,6 +1759,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hive-types" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "hivectl" version = "0.1.0" @@ -1768,6 +1777,7 @@ dependencies = [ "clap_complete", "hive-host-sock", "hive-sh4re", + "hive-types", "indicatif", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 95993f7d..cc38c6fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "hive-priv", "hive-priv-sock", "hive-sh4re", + "hive-types", "hivectl", ] @@ -54,6 +55,7 @@ hive-agent-sock = { path = "hive-agent-sock" } hive-claude = { path = "hive-claude" } hive-host-sock = { path = "hive-host-sock" } hive-priv-sock = { path = "hive-priv-sock" } +hive-types = { path = "hive-types" } thiserror = "2" tower-http = { version = "0.7", features = ["fs"] } rmcp = { version = "2", default-features = false, features = [ diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 6c7edbeb..9f304815 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -34,6 +34,7 @@ hive-agent-sock.workspace = true hive-sh4re.workspace = true hive-host-sock.workspace = true hive-priv-sock.workspace = true +hive-types.workspace = true libc.workspace = true listenfd = "1" petgraph.workspace = true diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index d9518248..e091a686 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -41,7 +41,7 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { // Sub-second git seed + forge-remote wire. Routing through // the queue would surface a queue card that's gone before // the operator's eyes refocus. Run inline. - let agent = hive_host_sock::Ident::parse(&approval.agent).map_err(|e| { + let agent = hive_types::Ident::parse(&approval.agent).map_err(|e| { anyhow::anyhow!("approval {} has invalid agent name: {e}", approval.id) })?; let proposed_dir = Coordinator::agent_proposed_dir(&agent); @@ -804,7 +804,7 @@ pub async fn destroy(coord: &Arc, name: &str, purge: bool) -> Resul // A malformed name can't have a persistent state tree (the state dir // is only ever created under a validated Ident), so its removal is a // no-op — skip the state-dir sweep and just clear the applied dir. - let state_dir = hive_host_sock::Ident::parse(name) + let state_dir = hive_types::Ident::parse(name) .ok() .map(|id| crate::paths::agent_state_dir(&id)); for dir in state_dir diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 96fcb24d..abefef7b 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -70,7 +70,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { // Parse the nspawn machine suffix into an Ident once at this // enumeration origin; a suffix that isn't a valid ident isn't one // of our agents, so skip it. - let Ok(logical) = hive_host_sock::Ident::parse(logical) else { + let Ok(logical) = hive_types::Ident::parse(logical) else { continue; }; let deployed_full = locked @@ -133,7 +133,7 @@ pub fn claude_has_session(dir: &Path) -> bool { /// the consolidated `hyperhive-harness.json`. Falls back to the legacy /// individual sentinel files written by older harness builds so in-place /// upgrades don't lose state during the transition window. -fn read_harness_flags(name: &hive_host_sock::Ident) -> (bool, bool) { +fn read_harness_flags(name: &hive_types::Ident) -> (bool, bool) { let dir = Coordinator::agent_notes_dir(name); if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) && let Ok(v) = serde_json::from_str::(&raw) @@ -154,7 +154,7 @@ fn read_harness_flags(name: &hive_host_sock::Ident) -> (bool, bool) { (rate_limited, needs_login) } -fn auth_failed_sentinel(name: &hive_host_sock::Ident) -> bool { +fn auth_failed_sentinel(name: &hive_types::Ident) -> bool { read_harness_flags(name).1 } @@ -165,7 +165,7 @@ fn auth_failed_sentinel(name: &hive_host_sock::Ident) -> bool { /// NB: callers building `AgentMeta` for a *stopped* container should /// clear the result — the on-disk status is a stale snapshot from /// before the stop. Use `read_agent_status_live` for that. -pub fn read_agent_status(name: &hive_host_sock::Ident) -> (Option, Option) { +pub fn read_agent_status(name: &hive_types::Ident) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); // Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte @@ -206,7 +206,7 @@ pub fn read_agent_status(name: &hive_host_sock::Ident) -> (Option, Optio /// Returned tuple is `(status_text, status_set_at, running)`. /// `name` is the logical agent name (same as the broker recipient). pub async fn read_agent_status_live( - name: &hive_host_sock::Ident, + name: &hive_types::Ident, ) -> (Option, Option, bool) { if !lifecycle::is_running(name.as_str()).await { return (None, None, false); @@ -221,7 +221,7 @@ pub async fn read_agent_status_live( /// so it always reflects the resolved priority (nix config > runtime /// override > default). Returns `None` when the field is absent or the /// harness has not yet started a turn. -fn read_active_model(name: &hive_host_sock::Ident) -> Option { +fn read_active_model(name: &hive_types::Ident) -> Option { let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json"); let raw = std::fs::read_to_string(path).ok()?; let v: serde_json::Value = serde_json::from_str(&raw).ok()?; diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index c2c76c72..707ac73c 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -553,7 +553,7 @@ impl Coordinator { // MANAGER_NAME const), so an invalid ident here is a construction // bug. This is the step-3 boundary between the Ident-threaded path // builders and the job_queue layer (threaded post hive-jobq cutover). - let name = hive_host_sock::Ident::parse(name) + let name = hive_types::Ident::parse(name) .expect("agent_paths: name must be a valid ident (validated at spawn/enqueue)"); AgentPaths { agent: agent_dir, @@ -1448,7 +1448,7 @@ impl Coordinator { /// Manager-editable proposed config repo. Bind-mounted into the manager /// container as `/agents//config/`. - pub fn agent_proposed_dir(name: &hive_host_sock::Ident) -> PathBuf { + pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("config") } @@ -1456,14 +1456,14 @@ impl Coordinator { /// container at `/root/.claude` so OAuth state survives container /// destroy/recreate. Each agent owns its own token lineage — sharing /// would break on the first refresh-token rotation. - pub fn agent_claude_dir(name: &hive_host_sock::Ident) -> PathBuf { + pub fn agent_claude_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("claude") } /// Per-agent durable knowledge dir. Bind-mounted RW into the agent /// container at `/agents/{name}/state`. Survives destroy/recreate. /// Agent-visible — claude is told to write long-lived notes here. - pub fn agent_notes_dir(name: &hive_host_sock::Ident) -> PathBuf { + pub fn agent_notes_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("state") } @@ -1473,7 +1473,7 @@ impl Coordinator { /// `hyperhive-turn-stats.sqlite`, `hyperhive-model`) — kept separate /// from the agent-visible `state/` so claude's "my notes" view is /// uncluttered and the host vacuum has a clean sweep root. - pub fn agent_harness_dir(name: &hive_host_sock::Ident) -> PathBuf { + pub fn agent_harness_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("harness") } @@ -1483,14 +1483,14 @@ impl Coordinator { /// destroyed-but-kept tombstones; callers filter the latter by /// subtracting `lifecycle::list()`. #[must_use] - pub fn kept_state_names() -> Vec { + pub fn kept_state_names() -> Vec { let Ok(rd) = std::fs::read_dir(crate::paths::agents_root()) else { return Vec::new(); }; - let mut out: Vec = rd + let mut out: Vec = rd .flatten() .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) - .filter_map(|e| hive_host_sock::Ident::parse(&e.file_name().into_string().ok()?).ok()) + .filter_map(|e| hive_types::Ident::parse(&e.file_name().into_string().ok()?).ok()) .collect(); out.sort(); out @@ -1503,7 +1503,7 @@ impl Coordinator { /// apply-commit spawns the container. Distinct from tombstones, /// which have an applied repo from a prior deploy. #[must_use] - pub fn pending_init_names() -> Vec { + pub fn pending_init_names() -> Vec { Self::kept_state_names() .into_iter() .filter(|n| { diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index d92ff1d9..04c1ee96 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -66,7 +66,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec = crate::coordinator::Coordinator::kept_state_names() .into_iter() - .map(hive_host_sock::Ident::into_string) + .map(hive_types::Ident::into_string) .collect(); // Known = live roster ∪ kept-state names. let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect(); diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index ef5c74ee..a9fe91d8 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -422,7 +422,7 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> { } // A malformed name has no proposed config repo (repos are only created // under a validated Ident), so there's nothing to wire — no-op. - let Ok(agent) = hive_host_sock::Ident::parse(name) else { + let Ok(agent) = hive_types::Ident::parse(name) else { return Ok(()); }; let proposed_dir = Coordinator::agent_proposed_dir(&agent); diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 3f280ee2..39195f96 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -51,7 +51,7 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; /// state") for the rationale. Creates missing host-side directories so /// nspawn doesn't refuse to start; missing dirs are non-fatal. fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { - let Ok(child) = hive_host_sock::Ident::parse(child) else { + let Ok(child) = hive_types::Ident::parse(child) else { tracing::warn!(%child, "skipping child bind: invalid agent name"); return; }; @@ -201,7 +201,7 @@ async fn set_nspawn_flags( read_only: false, }); } - let agent_id = hive_host_sock::Ident::parse(agent_name) + let agent_id = hive_types::Ident::parse(agent_name) .map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?; let own_config = crate::paths::agent_state_dir(&agent_id).join("config"); std::fs::create_dir_all(&own_config) diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs index da7888c4..39d037db 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -212,7 +212,7 @@ pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { /// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation /// is privileged, so it's delegated to hive-priv. pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> { - let agent = hive_host_sock::Ident::parse(name) + let agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; let root = crate::paths::agent_state_dir(&agent); if root.exists() { diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 4674e6ec..eff37f84 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -65,7 +65,7 @@ pub fn admin_token_path() -> PathBuf { /// Token file inside the agent's bind-mounted state dir (visible as /// `/state/matrix-token` from inside the container). -fn token_path(name: &hive_host_sock::Ident) -> PathBuf { +fn token_path(name: &hive_types::Ident) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-token") } @@ -89,7 +89,7 @@ fn password_path(name: &str) -> PathBuf { /// move credentials from old deployments to the new location. Safe to /// call after `destroy --purge` — the path will simply not exist and /// the migration is a no-op. -fn legacy_password_path(name: &hive_host_sock::Ident) -> PathBuf { +fn legacy_password_path(name: &hive_types::Ident) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-password") } @@ -611,7 +611,7 @@ pub async fn ensure_user_for( register_token: &str, ) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let agent = hive_host_sock::Ident::parse(name) + let agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; let path = token_path(&agent); if path.exists() diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 2de62d64..55f4edf5 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -124,7 +124,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let agent_names: Vec = agents.iter().map(|a| a.name.clone()).collect(); let pending: Vec = crate::coordinator::Coordinator::pending_init_names() .into_iter() - .map(hive_host_sock::Ident::into_string) + .map(hive_types::Ident::into_string) .collect(); crate::topology::reconcile(&agent_names, &pending) .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 20cbe2c0..355bba5d 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -141,7 +141,7 @@ pub async fn run(coord: &Arc) -> Result<()> { /// and into the sibling harness dir. Best-effort: logs warnings but never /// fails. Idempotent — each file is only moved if present at the old path /// and absent at the new path. -fn migrate_harness_files(name: &hive_host_sock::Ident) { +fn migrate_harness_files(name: &hive_types::Ident) { const HARNESS_FILES: &[&str] = &[ "hyperhive-events.sqlite", "hyperhive-turn-stats.sqlite", @@ -267,7 +267,7 @@ async fn rename_manager_container(coord: &Arc) { } } -async fn enumerate_agents() -> Vec { +async fn enumerate_agents() -> Vec { let containers = lifecycle::list().await.unwrap_or_default(); containers .into_iter() @@ -277,7 +277,7 @@ async fn enumerate_agents() -> Vec { } else { c.strip_prefix(AGENT_PREFIX)? }; - hive_host_sock::Ident::parse(name).ok() + hive_types::Ident::parse(name).ok() }) .collect() } @@ -353,7 +353,7 @@ async fn repoint_container(name: &str) -> Result<()> { /// Idempotent — skips when entry already present. Prevents a silent tool /// downgrade when upgrading from a build that relied on the manager-flavor /// fallback in `effective_tool_groups()`. -fn backfill_manager_tool_groups(names: &[hive_host_sock::Ident]) { +fn backfill_manager_tool_groups(names: &[hive_types::Ident]) { if !names.iter().any(|n| n.as_str() == MANAGER_NAME) { return; // ruth not deployed — nothing to backfill } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 8bc16ebe..21089867 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -83,11 +83,11 @@ async fn handle(stream: UnixStream, coord: Arc) -> Result<()> { async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let result: anyhow::Result = async { Ok(match req { - HostRequest::Spawn { name } => handle_spawn(&coord, name).await?, + HostRequest::Spawn { name } => handle_spawn(&coord, name.as_str()).await?, HostRequest::RequestSpawn { name } => { tracing::info!(%name, "request_spawn"); let id = coord.approvals.submit_kind( - name, + name.as_str(), hive_sh4re::ApprovalKind::Spawn, "", None, @@ -97,8 +97,10 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { tracing::info!(%id, %name, "spawn approval queued"); HostResponse::success() } - HostRequest::Kill { name } => submit_single(&coord, name, Verb::Kill).await, - HostRequest::Restart { name } => submit_single(&coord, name, Verb::Restart).await, + HostRequest::Kill { name } => submit_single(&coord, name.as_str(), Verb::Kill).await, + HostRequest::Restart { name } => { + submit_single(&coord, name.as_str(), Verb::Restart).await + } HostRequest::RestartAll => handle_restart_all(&coord).await?, HostRequest::RestartScoped { scope, graceful } => { handle_restart_scoped(&coord, scope, *graceful).await? @@ -140,10 +142,12 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { handle_start(&coord, &agents, &infra).await? } HostRequest::Destroy { name, purge } => { - actions::destroy(&coord, name, *purge).await?; + actions::destroy(&coord, name.as_str(), *purge).await?; HostResponse::success() } - HostRequest::Rebuild { name } => submit_single(&coord, name, Verb::Rebuild).await, + HostRequest::Rebuild { name } => { + submit_single(&coord, name.as_str(), Verb::Rebuild).await + } HostRequest::QueueDag { id } => { // A multi-step op is one DAG now (no fan-out children to gather). let dags = coord @@ -179,7 +183,10 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { // skip both the messages and the disk write per the // topology fast-path. coord - .reparent_with_notify(child, new_parent.as_deref()) + .reparent_with_notify( + child.as_str(), + new_parent.as_ref().map(hive_types::Ident::as_str), + ) .await .map_err(anyhow::Error::msg)?; HostResponse::success() @@ -188,8 +195,12 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { handle_matrix_create_user(name, password.as_deref()).await? } HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?, - HostRequest::MatrixPromoteUser { name } => handle_matrix_promote_user(name).await?, - HostRequest::MatrixResetPassword { name } => handle_matrix_reset_password(name).await?, + HostRequest::MatrixPromoteUser { name } => { + handle_matrix_promote_user(name.as_str()).await? + } + HostRequest::MatrixResetPassword { name } => { + handle_matrix_reset_password(name.as_str()).await? + } HostRequest::MatrixInvite { user, room } => { handle_matrix_invite(user, room.as_deref()).await? } @@ -197,10 +208,10 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { handle_forge_create_user(name, password.as_deref()).await? } HostRequest::ReconcileConfigStatus { agent, verbose } => { - crate::forge::reconcile_config_status(agent, *verbose).await? + crate::forge::reconcile_config_status(agent.as_str(), *verbose).await? } HostRequest::ReconcileConfigApply { agent, direction } => { - crate::forge::reconcile_config_apply(agent, *direction).await? + crate::forge::reconcile_config_apply(agent.as_str(), *direction).await? } HostRequest::GatewayCreateUser { username, password } => { HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?]) @@ -212,24 +223,30 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::messages(crate::gateway_nginx::list_users()?) } HostRequest::SetAgentGithubToken { agent, token } => { - handle_set_agent_github_token(agent, token).await? + handle_set_agent_github_token(agent.as_str(), token).await? } HostRequest::QuotaEnable => handle_quota_enable().await?, - HostRequest::QuotaLimit { name, limit } => handle_quota_limit(name, *limit).await?, - HostRequest::QuotaShow { name } => handle_quota_show(name.as_deref()).await?, - HostRequest::UpgradeSubvolume { name } => handle_upgrade_subvolume(name).await?, + HostRequest::QuotaLimit { name, limit } => { + handle_quota_limit(name.as_str(), *limit).await? + } + HostRequest::QuotaShow { name } => { + handle_quota_show(name.as_ref().map(hive_types::Ident::as_str)).await? + } + HostRequest::UpgradeSubvolume { name } => { + handle_upgrade_subvolume(name.as_str()).await? + } HostRequest::SnapshotSubvolume { name, label } => { - handle_snapshot_subvolume(name, label).await? + handle_snapshot_subvolume(name.as_str(), label).await? } HostRequest::DeleteSnapshot { name, label } => { - handle_delete_snapshot(name, label).await? + handle_delete_snapshot(name.as_str(), label).await? } HostRequest::SendSnapshot { name, label, parent, dest, - } => handle_send_snapshot(name, label, parent.as_deref(), dest).await?, + } => handle_send_snapshot(name.as_str(), label, parent.as_deref(), dest).await?, }) } .await; @@ -320,10 +337,8 @@ fn matrix_http_client() -> Result { /// True when `name` has a state dir under the agents root, i.e. it's a /// managed agent rather than a bare (operator/human) matrix account. -fn agent_exists(name: &str) -> Result { - let name = hive_host_sock::Ident::parse(name) - .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; - crate::paths::agent_state_dir(&name) +fn agent_exists(name: &hive_types::Ident) -> Result { + crate::paths::agent_state_dir(name) .try_exists() .with_context(|| format!("check agent state dir for {name}")) } @@ -338,7 +353,10 @@ async fn require_matrix_present() -> Result<()> { ) } -async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result { +async fn handle_matrix_create_user( + name: &hive_types::Ident, + password: Option<&str>, +) -> Result { require_matrix_present().await?; let register_token = crate::matrix::ensure_register_token().context("read matrix register token")?; @@ -353,12 +371,10 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result "matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" ); } - crate::matrix::ensure_user_for(&client, name, ®ister_token) + crate::matrix::ensure_user_for(&client, name.as_str(), ®ister_token) .await .with_context(|| format!("matrix create-user {name}"))?; - let agent = hive_host_sock::Ident::parse(name) - .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; - let path = Coordinator::agent_notes_dir(&agent).join("matrix-token"); + let path = Coordinator::agent_notes_dir(name).join("matrix-token"); out.push(format!("matrix: provisioned agent user '{name}'")); out.push(format!("token persisted at: {}", path.display())); } else { @@ -368,7 +384,7 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result }; let token = crate::matrix::provision_user_token( &client, - name, + name.as_str(), ®ister_token, &effective_password, ) @@ -391,7 +407,10 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result Ok(HostResponse::messages(out)) } -async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result { +async fn handle_forge_create_user( + name: &hive_types::Ident, + password: Option<&str>, +) -> Result { if !crate::forge::is_present().await { anyhow::bail!( "hive-forge container not running — wait for hive-c0re to start it before provisioning forge users" @@ -406,16 +425,14 @@ async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result< "forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token" ); } - crate::forge::ensure_user_for(name) + crate::forge::ensure_user_for(name.as_str()) .await .with_context(|| format!("forge create-user {name}"))?; - let agent = hive_host_sock::Ident::parse(name) - .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; - let path = Coordinator::agent_notes_dir(&agent).join("forge-token"); + let path = Coordinator::agent_notes_dir(name).join("forge-token"); out.push(format!("forge: provisioned agent user '{name}'")); out.push(format!("token persisted at: {}", path.display())); } else { - let token = crate::forge::provision_user_token(name, password) + let token = crate::forge::provision_user_token(name.as_str(), password) .await .with_context(|| format!("forge create-user {name}"))?; out.push(format!( @@ -467,7 +484,7 @@ async fn handle_quota_show(name: Option<&str>) -> Result { Some(n) => vec![n.to_owned()], None => Coordinator::kept_state_names() .into_iter() - .map(hive_host_sock::Ident::into_string) + .map(hive_types::Ident::into_string) .collect(), }; let mut rows = Vec::with_capacity(agents.len()); diff --git a/hive-c0re/src/socket_server/config_approvals.rs b/hive-c0re/src/socket_server/config_approvals.rs index c52f5ce7..e852892c 100644 --- a/hive-c0re/src/socket_server/config_approvals.rs +++ b/hive-c0re/src/socket_server/config_approvals.rs @@ -195,7 +195,7 @@ pub(crate) fn submit_init_config( parent: Option<&str>, description: Option, ) -> anyhow::Result { - let agent = hive_host_sock::Ident::parse(name) + let agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent); if proposed_dir.join(".git").exists() { diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 152a5397..86b0a6a7 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -425,7 +425,7 @@ async fn handle_get_agent_meta( // the OS level. Validate it before any path is built. The `None` default // (`target == agent`) is the caller's own authenticated name, already // valid — but validating unconditionally is simplest and harmless. - let target_id = match hive_host_sock::Ident::parse(target) { + let target_id = match hive_types::Ident::parse(target) { Ok(id) => id, Err(reason) => { return hive_agent_sock::Response::Err { @@ -461,7 +461,7 @@ async fn handle_get_agent_meta( /// or the daemon not up yet) yields an empty list. The `MatrixIdentity` /// serde shape matches the snapshot entries; the snapshot's `live` field is /// ignored (only live accounts are written). -fn read_agent_matrix_identities(agent: &hive_host_sock::Ident) -> Vec { +fn read_agent_matrix_identities(agent: &hive_types::Ident) -> Vec { let path = Coordinator::agent_notes_dir(agent).join("matrix-accounts.json"); std::fs::read_to_string(&path) .ok() @@ -751,7 +751,7 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option { /// `submit_init_config`, which builds filesystem paths from it, so validate /// before that. fn require_new_child(agent: &str, target: &str, action: &str) -> Option { - if let Err(reason) = hive_host_sock::Ident::parse(target) { + if let Err(reason) = hive_types::Ident::parse(target) { return Some(Response::Err { message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"), }); @@ -1118,7 +1118,7 @@ pub(crate) fn handle_send( // A name that doesn't parse as an Ident can't be a local agent, so // it collapses into the same "unknown recipient" error as a valid // name with no state dir. - let exists = hive_host_sock::Ident::parse(&resolved) + let exists = hive_types::Ident::parse(&resolved) .is_ok_and(|id| crate::paths::agent_state_dir(&id).exists()); if !exists { return Response::Err { diff --git a/hive-c0re/src/stats/container_stats.rs b/hive-c0re/src/stats/container_stats.rs index ccf7cca5..b9fe1b9a 100644 --- a/hive-c0re/src/stats/container_stats.rs +++ b/hive-c0re/src/stats/container_stats.rs @@ -115,7 +115,7 @@ async fn du_bytes(path: &std::path::Path) -> Option { /// agent's state-dir contribution was 0; with the writable rootfs nearly empty /// (almost everything is bind-mounted), that surfaced as all agents reporting /// 0 disk. -async fn measure_agent_disk(name: &hive_host_sock::Ident) -> u64 { +async fn measure_agent_disk(name: &hive_types::Ident) -> u64 { let state_dir = Coordinator::agent_notes_dir(name); let rootfs = PathBuf::from(format!("{NIXOS_CONTAINERS_ROOT}/h-{name}")); let mut total = 0u64; diff --git a/hive-c0re/src/workers/crash_watch.rs b/hive-c0re/src/workers/crash_watch.rs index 19166278..49111296 100644 --- a/hive-c0re/src/workers/crash_watch.rs +++ b/hive-c0re/src/workers/crash_watch.rs @@ -41,7 +41,7 @@ pub fn spawn(coord: Arc) { if lifecycle::is_running(&logical).await { current_running.insert(logical.clone()); } - if hive_host_sock::Ident::parse(&logical) + if hive_types::Ident::parse(&logical) .is_ok_and(|id| claude_has_session(&Coordinator::agent_claude_dir(&id))) { current_logged_in.insert(logical.clone()); diff --git a/hive-c0re/src/workers/reminder_scheduler.rs b/hive-c0re/src/workers/reminder_scheduler.rs index a0da2317..f05f8d92 100644 --- a/hive-c0re/src/workers/reminder_scheduler.rs +++ b/hive-c0re/src/workers/reminder_scheduler.rs @@ -133,7 +133,7 @@ fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String { /// inline-falls-back). `pub` because `socket_server::handle_remind` /// reuses it for the at-remind-time auto-file path. pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> { - let agent = hive_host_sock::Ident::parse(agent) + let agent = hive_types::Ident::parse(agent) .map_err(|e| format!("invalid agent name {agent:?}: {e}"))?; let Some(parent) = host_path.parent() else { return Err("internal: host path has no parent".to_owned()); @@ -191,7 +191,7 @@ pub fn container_state_prefix(agent: &str) -> String { /// reason string on rejection. `pub` so `socket_server::handle_remind` /// can reuse it for the at-remind-time auto-file path. pub fn resolve_host_path(agent: &str, req_path: &str) -> Result { - let agent = hive_host_sock::Ident::parse(agent) + let agent = hive_types::Ident::parse(agent) .map_err(|e| format!("invalid agent name {agent:?}: {e}"))?; let prefix = container_state_prefix(agent.as_str()); let Some(rel) = req_path.strip_prefix(&prefix) else { diff --git a/hive-host-sock/Cargo.toml b/hive-host-sock/Cargo.toml index 24152da4..b39f0f9e 100644 --- a/hive-host-sock/Cargo.toml +++ b/hive-host-sock/Cargo.toml @@ -8,7 +8,5 @@ workspace = true [dependencies] hive-sh4re.workspace = true +hive-types.workspace = true serde.workspace = true - -[dev-dependencies] -serde_json.workspace = true diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 46d3ae58..1b5ffcdd 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use hive_sh4re::{AgentStatusRow, Approval, jobs}; +use hive_types::Ident; use serde::{Deserialize, Serialize}; // ── Shared hive layout facts ────────────────────────────────────────────── @@ -55,152 +56,6 @@ pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } -/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`. -/// -/// The single ident type for agent names, forge labels, and matrix / github -/// account names — every value that becomes a filesystem path segment or an -/// nspawn machine-name component. Constructed only through the validating -/// [`Ident::parse`], so "this string passed the naming whitelist" is a fact -/// the type carries instead of a convention every call site re-checks against -/// a raw `String`. The charset is deliberately conservative — lowercase -/// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) — -/// and length-capped, tracking `nixos-container` basename rules and keeping -/// `../` traversal, unicode homoglyphs, and unbounded path segments out of -/// any path built from it. Deserialization runs the same parse, so a value -/// arriving over the wire is validated on the way in. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Ident(String); - -impl Ident { - /// Maximum length in bytes. A cap stops an unbounded operator-supplied - /// name from becoming an over-long path segment (a filesystem / `DoS` - /// footgun). - pub const MAX_LEN: usize = 63; - - /// Parse + validate an identifier. - /// - /// # Errors - /// Returns `Err(reason)` — a caller-ready message — when `s` is empty, - /// longer than [`Ident::MAX_LEN`], or contains any byte outside - /// `[a-z0-9-]`. - pub fn parse(s: &str) -> Result { - if s.is_empty() { - return Err("identifier must not be empty"); - } - if s.len() > Self::MAX_LEN { - return Err("identifier must be 63 characters or fewer"); - } - if !s - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - { - return Err("identifier must contain only [a-z0-9-]"); - } - Ok(Self(s.to_owned())) - } - - /// The validated identifier as a string slice. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Consume into the inner `String`. - #[must_use] - pub fn into_string(self) -> String { - self.0 - } -} - -impl std::fmt::Display for Ident { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl AsRef for Ident { - fn as_ref(&self) -> &str { - &self.0 - } -} - -/// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`. -impl std::borrow::Borrow for Ident { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl serde::Serialize for Ident { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.0) - } -} - -impl<'de> serde::Deserialize<'de> for Ident { - fn deserialize>(deserializer: D) -> Result { - use serde::de::Error as _; - let s = String::deserialize(deserializer)?; - Ident::parse(&s).map_err(D::Error::custom) - } -} - -#[cfg(test)] -mod ident_tests { - use super::Ident; - - #[test] - fn accepts_canonical_shapes() { - for ok in [ - "damocles", - "hm1nd", - "agent-with-dashes", - "codeberg", - "acct-1", - ] { - assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}"); - } - assert!( - Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(), - "63 chars is the boundary" - ); - } - - #[test] - fn rejects_bad_input() { - let too_long = "a".repeat(Ident::MAX_LEN + 1); - for bad in [ - "", - &too_long, - "Alice", // uppercase - "snake_case", // underscore (tightened out) - "alice.bob", // dot - "alice/bob", // slash - "../etc/passwd", // traversal - "damóclès", // non-ASCII - "alice\u{2013}b", // en-dash homoglyph - ] { - assert!(Ident::parse(bad).is_err(), "should reject {bad:?}"); - } - } - - #[test] - fn round_trips_and_serde_validates() { - let id = Ident::parse("damocles").unwrap(); - assert_eq!(id.as_str(), "damocles"); - // Serialize is transparent (just the inner string). - let json = serde_json::to_string(&id).unwrap(); - assert_eq!(json, "\"damocles\""); - // Deserialize runs the same parse. - let back: Ident = serde_json::from_str(&json).unwrap(); - assert_eq!(back, id); - assert!( - serde_json::from_str::("\"BAD_NAME\"").is_err(), - "deserialize must reject an invalid ident" - ); - } -} - /// Which way to reconcile an agent's config branches /// ([`HostRequest::ReconcileConfigApply`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -221,23 +76,23 @@ pub enum HostRequest { /// Create and start a sub-agent container directly, bypassing the /// approval queue. Privileged-context only. See /// `docs/approvals.md::Approval kinds (wire shapes)`. - Spawn { name: String }, + Spawn { name: Ident }, /// Submit a spawn request for the operator to approve. See /// `docs/approvals.md::Approval kinds (wire shapes)` (`Spawn`). - RequestSpawn { name: String }, + RequestSpawn { name: Ident }, /// Stop a managed container (graceful). - Kill { name: String }, + Kill { name: Ident }, /// Tear down a sub-agent container, optionally purging state. /// See `docs/approvals.md::Destroy semantics`. Destroy { - name: String, + name: Ident, #[serde(default)] purge: bool, }, /// Stop and start a managed container without rebuilding config. /// For "kick the container" operations that don't touch the flake or /// nspawn flags. Mirrors `lifecycle::restart` (kill + start). - Restart { name: String }, + Restart { name: Ident }, /// Stop and restart all managed containers in sequence. Convenience /// wrapper for `hivectl agents restart-all`; iterates the live /// container list and restarts each one. @@ -265,7 +120,7 @@ pub enum HostRequest { graceful: bool, }, /// Apply pending config to a managed container. - Rebuild { name: String }, + Rebuild { name: Ident }, /// List managed containers. List, /// List managed agents with their full status + technical state @@ -296,8 +151,8 @@ pub enum HostRequest { /// Validation rules + bind-mount caveat documented in /// `docs/agent-hierarchy.md::Current state`. SetParent { - child: String, - new_parent: Option, + child: Ident, + new_parent: Option, }, /// Stop managed containers hive-wide in one operator action /// (`hivectl stop`): agents plus the selected infra containers. `scope` @@ -327,7 +182,7 @@ pub enum HostRequest { /// [`HostResponse::messages`]. `password` is resolved by the client /// (inline flag or stdin) and `None` requests a random throwaway. MatrixCreateUser { - name: String, + name: Ident, #[serde(default)] password: Option, }, @@ -337,11 +192,11 @@ pub enum HostRequest { /// Promote a matrix user to homeserver admin via the admin API. /// Uses the daemon's system admin token; `server_name` is discovered /// from the running homeserver. - MatrixPromoteUser { name: String }, + MatrixPromoteUser { name: Ident }, /// Reset a matrix user's password via the admin API and persist the /// new password to the matrix creds dir so a later token mint can /// re-login. Returns the outcome in [`HostResponse::messages`]. - MatrixResetPassword { name: String }, + MatrixResetPassword { name: Ident }, /// Invite a matrix user to the hive Space (default) or a specific /// `room`. Uses the daemon's admin token; idempotent /// (already-member / already-invited is a no-op). @@ -357,7 +212,7 @@ pub enum HostRequest { /// in [`HostResponse::messages`]. `password` is resolved client-side /// (inline flag or stdin) and only meaningful for non-agent accounts. ForgeCreateUser { - name: String, + name: Ident, #[serde(default)] password: Option, }, @@ -369,7 +224,7 @@ pub enum HostRequest { /// — never mutates either side. Backs `hivectl forge reconcile-config` /// (the diff it always shows first). ReconcileConfigStatus { - agent: String, + agent: Ident, #[serde(default)] verbose: bool, }, @@ -380,7 +235,7 @@ pub enum HostRequest { /// local needs lifting branch protection — resolve via a config PR). /// Backs `hivectl forge reconcile-config --from `. ReconcileConfigApply { - agent: String, + agent: Ident, direction: ReconcileDirection, }, /// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file @@ -403,7 +258,7 @@ pub enum HostRequest { /// set-token`. `token` is resolved + non-empty-validated client-side /// (inline flag or stdin); the daemon just persists it. Read live by /// the agent's `gh` wrapper / git credential helper — no rebuild needed. - SetAgentGithubToken { agent: String, token: String }, + SetAgentGithubToken { agent: Ident, token: String }, /// Turn on btrfs qgroup accounting on the agent-state filesystem, via /// the privileged helper. Daemon-side equivalent of `hivectl quota /// enable`. Returns advisory lines in [`HostResponse::messages`]. @@ -414,7 +269,7 @@ pub enum HostRequest { /// bare success — the client prints the confirmation from the value it /// sent. QuotaLimit { - name: String, + name: Ident, #[serde(default)] limit: Option, }, @@ -426,27 +281,27 @@ pub enum HostRequest { /// plain [`HostResponse::error`] so the client can print the enable hint. QuotaShow { #[serde(default)] - name: Option, + name: Option, }, /// Migrate an agent's plain state dir to a btrfs subvolume via the /// privileged helper (`hivectl subvol upgrade`). The agent MUST already /// be stopped — the client orchestrates stop → this → start. Returns a /// bare success; the client prints its own progress lines. - UpgradeSubvolume { name: String }, + UpgradeSubvolume { name: Ident }, /// Create a read-only btrfs snapshot of an agent's state subvolume /// (`hivectl subvol snapshot create`). `label` is validated client-side /// AND by hive-priv. Returns the snapshot's host path in /// [`HostResponse::messages`]. - SnapshotSubvolume { name: String, label: String }, + SnapshotSubvolume { name: Ident, label: String }, /// Delete a snapshot created by `SnapshotSubvolume` (`hivectl subvol /// snapshot delete`). Bare success; the client prints the confirmation. - DeleteSnapshot { name: String, label: String }, + DeleteSnapshot { name: Ident, label: String }, /// Export a snapshot to a local file via `btrfs send` (`hivectl subvol /// snapshot send`). `dest` is a bare filename (hive-priv rejects paths); /// `parent` names an optional parent snapshot for an incremental send. /// Returns the written file's host path in [`HostResponse::messages`]. SendSnapshot { - name: String, + name: Ident, label: String, #[serde(default)] parent: Option, diff --git a/hive-types/Cargo.toml b/hive-types/Cargo.toml new file mode 100644 index 00000000..d472adb5 --- /dev/null +++ b/hive-types/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "hive-types" +edition.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/hive-types/src/lib.rs b/hive-types/src/lib.rs new file mode 100644 index 00000000..c6b6a536 --- /dev/null +++ b/hive-types/src/lib.rs @@ -0,0 +1,153 @@ +//! Foundational shared newtypes for the hyperhive workspace. +//! +//! A zero-dependency (bar `serde`) leaf crate so every wire-type crate +//! (`hive-sh4re`, `hive-host-sock`, `hive-agent-sock`) and both binaries +//! (`hive-c0re`, `hivectl`) can type their agent-name fields as [`Ident`] +//! and get serde-validated parsing at the socket boundary for free — with +//! no cross-crate coupling and without growing `hive-sh4re`. + +/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`. +/// +/// The single ident type for agent names, forge labels, and matrix / github +/// account names — every value that becomes a filesystem path segment or an +/// nspawn machine-name component. Constructed only through the validating +/// [`Ident::parse`], so "this string passed the naming whitelist" is a fact +/// the type carries instead of a convention every call site re-checks against +/// a raw `String`. The charset is deliberately conservative — lowercase +/// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) — +/// and length-capped, tracking `nixos-container` basename rules and keeping +/// `../` traversal, unicode homoglyphs, and unbounded path segments out of +/// any path built from it. Deserialization runs the same parse, so a value +/// arriving over the wire is validated on the way in. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Ident(String); + +impl Ident { + /// Maximum length in bytes. A cap stops an unbounded operator-supplied + /// name from becoming an over-long path segment (a filesystem / `DoS` + /// footgun). + pub const MAX_LEN: usize = 63; + + /// Parse + validate an identifier. + /// + /// # Errors + /// Returns `Err(reason)` — a caller-ready message — when `s` is empty, + /// longer than [`Ident::MAX_LEN`], or contains any byte outside + /// `[a-z0-9-]`. + pub fn parse(s: &str) -> Result { + if s.is_empty() { + return Err("identifier must not be empty"); + } + if s.len() > Self::MAX_LEN { + return Err("identifier must be 63 characters or fewer"); + } + if !s + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err("identifier must contain only [a-z0-9-]"); + } + Ok(Self(s.to_owned())) + } + + /// The validated identifier as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume into the inner `String`. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl std::fmt::Display for Ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for Ident { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`. +impl std::borrow::Borrow for Ident { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl serde::Serialize for Ident { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for Ident { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error as _; + let s = String::deserialize(deserializer)?; + Ident::parse(&s).map_err(D::Error::custom) + } +} + +#[cfg(test)] +mod ident_tests { + use super::Ident; + + #[test] + fn accepts_canonical_shapes() { + for ok in [ + "damocles", + "hm1nd", + "agent-with-dashes", + "codeberg", + "acct-1", + ] { + assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}"); + } + assert!( + Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(), + "63 chars is the boundary" + ); + } + + #[test] + fn rejects_bad_input() { + let too_long = "a".repeat(Ident::MAX_LEN + 1); + for bad in [ + "", + &too_long, + "Alice", // uppercase + "snake_case", // underscore (tightened out) + "alice.bob", // dot + "alice/bob", // slash + "../etc/passwd", // traversal + "damóclès", // non-ASCII + "alice\u{2013}b", // en-dash homoglyph + ] { + assert!(Ident::parse(bad).is_err(), "should reject {bad:?}"); + } + } + + #[test] + fn round_trips_and_serde_validates() { + let id = Ident::parse("damocles").unwrap(); + assert_eq!(id.as_str(), "damocles"); + // Serialize is transparent (just the inner string). + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"damocles\""); + // Deserialize runs the same parse. + let back: Ident = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + assert!( + serde_json::from_str::("\"BAD_NAME\"").is_err(), + "deserialize must reject an invalid ident" + ); + } +} diff --git a/hivectl/Cargo.toml b/hivectl/Cargo.toml index 5107a6e3..aad05e71 100644 --- a/hivectl/Cargo.toml +++ b/hivectl/Cargo.toml @@ -17,6 +17,7 @@ clap_complete.workspace = true clap-markdown = "0.1" hive-host-sock.workspace = true hive-sh4re.workspace = true +hive-types.workspace = true indicatif.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs index 7c602acf..16fa73d6 100644 --- a/hivectl/src/agents.rs +++ b/hivectl/src/agents.rs @@ -14,7 +14,7 @@ async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> let resp = crate::client::request( socket, hive_host_sock::HostRequest::Restart { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, }, ) .await @@ -135,18 +135,23 @@ pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> { AgentsCmd::Restart { name, no_wait } => agents_restart(socket, &name, no_wait).await, AgentsCmd::RestartAll { no_wait } => agents_restart_all(socket, no_wait).await, AgentsCmd::Spawn { name } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::Spawn { name }).await?) } AgentsCmd::RequestSpawn { name } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::RequestSpawn { name }).await?) } AgentsCmd::Kill { name } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::Kill { name }).await?) } AgentsCmd::Destroy { name, purge } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::Destroy { name, purge }).await?) } AgentsCmd::Rebuild { name } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::Rebuild { name }).await?) } AgentsCmd::SetParent { @@ -154,7 +159,12 @@ pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> { parent, root, } => { - let new_parent = if root { None } else { parent }; + let child = crate::util::parse_ident(&child)?; + let new_parent = if root { + None + } else { + parent.map(|p| crate::util::parse_ident(&p)).transpose()? + }; render( crate::client::request(socket, HostRequest::SetParent { child, new_parent }) .await?, diff --git a/hivectl/src/forge.rs b/hivectl/src/forge.rs index 9f8128fe..bebad895 100644 --- a/hivectl/src/forge.rs +++ b/hivectl/src/forge.rs @@ -25,7 +25,7 @@ pub(crate) async fn forge_create_user( daemon_request( socket, hive_host_sock::HostRequest::ForgeCreateUser { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, password, }, "forge", @@ -45,7 +45,7 @@ pub(crate) async fn forge_reconcile_config( daemon_request( socket, HostRequest::ReconcileConfigStatus { - agent: agent.to_owned(), + agent: crate::util::parse_ident(agent)?, verbose, }, "forge", @@ -62,7 +62,7 @@ pub(crate) async fn forge_reconcile_config( daemon_request( socket, HostRequest::ReconcileConfigApply { - agent: agent.to_owned(), + agent: crate::util::parse_ident(agent)?, direction, }, "forge", diff --git a/hivectl/src/github.rs b/hivectl/src/github.rs index ef296b1b..42ee792b 100644 --- a/hivectl/src/github.rs +++ b/hivectl/src/github.rs @@ -39,7 +39,7 @@ pub(crate) async fn github_set_token( daemon_request( socket, hive_host_sock::HostRequest::SetAgentGithubToken { - agent: agent.to_owned(), + agent: crate::util::parse_ident(agent)?, token, }, "github", diff --git a/hivectl/src/matrix.rs b/hivectl/src/matrix.rs index ca02cfdc..a75ce7bf 100644 --- a/hivectl/src/matrix.rs +++ b/hivectl/src/matrix.rs @@ -59,7 +59,7 @@ async fn matrix_create_user( matrix_request( socket, hive_host_sock::HostRequest::MatrixCreateUser { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, password, }, ) @@ -74,7 +74,7 @@ async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> { matrix_request( socket, hive_host_sock::HostRequest::MatrixPromoteUser { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, }, ) .await @@ -95,7 +95,7 @@ async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> { matrix_request( socket, hive_host_sock::HostRequest::MatrixResetPassword { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, }, ) .await diff --git a/hivectl/src/quota.rs b/hivectl/src/quota.rs index f8cb30c4..15e5546d 100644 --- a/hivectl/src/quota.rs +++ b/hivectl/src/quota.rs @@ -20,7 +20,7 @@ pub(crate) async fn quota_show(socket: &Path, name: Option<&str>) -> Result<()> let resp = crate::client::request( socket, hive_host_sock::HostRequest::QuotaShow { - name: name.map(str::to_owned), + name: name.map(crate::util::parse_ident).transpose()?, }, ) .await @@ -60,7 +60,7 @@ pub(crate) async fn quota_limit(socket: &Path, name: &str, size: &str) -> Result daemon_request( socket, hive_host_sock::HostRequest::QuotaLimit { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, limit, }, "quota", diff --git a/hivectl/src/subvol.rs b/hivectl/src/subvol.rs index eb4c4036..202d1aff 100644 --- a/hivectl/src/subvol.rs +++ b/hivectl/src/subvol.rs @@ -89,7 +89,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> { let upgrade = daemon_request( socket, hive_host_sock::HostRequest::UpgradeSubvolume { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, }, "upgrade", ) @@ -170,7 +170,7 @@ async fn subvol_snapshot_create(socket: &Path, name: &str, label: String) -> Res daemon_request( socket, hive_host_sock::HostRequest::SnapshotSubvolume { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, label, }, "snapshot", @@ -184,7 +184,7 @@ async fn subvol_snapshot_delete(socket: &Path, name: &str, label: &str) -> Resul daemon_request( socket, hive_host_sock::HostRequest::DeleteSnapshot { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, label: label.to_owned(), }, "snapshot delete", @@ -211,7 +211,7 @@ async fn subvol_snapshot_send( daemon_request( socket, hive_host_sock::HostRequest::SendSnapshot { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, label: label.to_owned(), parent: parent.map(str::to_owned), dest: dest.to_owned(), diff --git a/hivectl/src/util.rs b/hivectl/src/util.rs index a1d33dc7..ceec313a 100644 --- a/hivectl/src/util.rs +++ b/hivectl/src/util.rs @@ -6,6 +6,16 @@ use std::path::Path; use anyhow::{Context as _, Result, bail}; +/// Parse a CLI-supplied agent/account name into a validated +/// [`hive_types::Ident`], mapping the parse error to an `anyhow` error that +/// names the offending input. Used at hivectl's `HostRequest` construction +/// sites so the wire `Ident` fields are built from validated names (the daemon +/// re-validates on deserialize; parsing here gives the operator an immediate, +/// local error instead of a round-trip rejection). +pub(crate) fn parse_ident(name: &str) -> Result { + hive_types::Ident::parse(name).map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}")) +} + /// Send a provisioning request to the daemon and print its result lines. /// The daemon owns the provisioning logic; hivectl just relays the outcome, /// prefixing any error with `label` (e.g. `forge` / `github`). @@ -115,7 +125,7 @@ pub(crate) async fn query_hive_urls(socket: &Path) -> Option Result { - let Ok(name) = hive_host_sock::Ident::parse(name) else { + let Ok(name) = hive_types::Ident::parse(name) else { bail!("invalid agent name {name:?}"); }; let root = hive_host_sock::agent_state_dir(&name);