diff --git a/Cargo.lock b/Cargo.lock index b69af92e..db46ddd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1619,7 +1619,6 @@ dependencies = [ "hive-host-sock", "hive-priv-sock", "hive-sh4re", - "hive-types", "hmac 0.13.0", "indicatif", "libc", @@ -1672,7 +1671,6 @@ name = "hive-host-sock" version = "0.1.0" dependencies = [ "hive-sh4re", - "hive-types", "serde", ] @@ -1759,14 +1757,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "hive-types" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "hivectl" version = "0.1.0" @@ -1777,7 +1767,6 @@ dependencies = [ "clap_complete", "hive-host-sock", "hive-sh4re", - "hive-types", "indicatif", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index cc38c6fb..95993f7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ members = [ "hive-priv", "hive-priv-sock", "hive-sh4re", - "hive-types", "hivectl", ] @@ -55,7 +54,6 @@ 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 9f304815..6c7edbeb 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -34,7 +34,6 @@ 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 e091a686..b689f196 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -41,12 +41,9 @@ 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_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); - let claude_dir = Coordinator::agent_claude_dir(&agent); - let notes_dir = Coordinator::agent_notes_dir(&agent); + 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); run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await } ApprovalKind::UpdateMetaInputs => { @@ -801,16 +798,10 @@ 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"); } - // 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_types::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)]) - { + for dir in [ + crate::paths::agent_state_dir(name), + 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 abefef7b..c5a29f48 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -64,27 +64,20 @@ 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) 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_types::Ident::parse(logical) else { + let Some(logical) = c.strip_prefix(AGENT_PREFIX).map(str::to_owned) 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.as_str(), deployed_full).await; + let needs_update = crate::auto_update::agent_config_pending(&logical, 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.as_str()).cloned().flatten(); - let running = lifecycle::is_running(logical.as_str()).await; + let parent = topology.get(&logical).cloned().flatten(); + let running = lifecycle::is_running(&logical).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 — @@ -101,10 +94,10 @@ pub async fn build_all(coord: &Coordinator) -> Vec { None }; out.push(ContainerView { - port: lifecycle::agent_web_port(logical.as_str()), + port: lifecycle::agent_web_port(&logical), running, container: c.clone(), - name: logical.into_string(), + name: logical, needs_update, needs_login, deployed_sha, @@ -133,7 +126,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_types::Ident) -> (bool, bool) { +fn read_harness_flags(name: &str) -> (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 +147,7 @@ fn read_harness_flags(name: &hive_types::Ident) -> (bool, bool) { (rate_limited, needs_login) } -fn auth_failed_sentinel(name: &hive_types::Ident) -> bool { +fn auth_failed_sentinel(name: &str) -> bool { read_harness_flags(name).1 } @@ -165,7 +158,7 @@ fn auth_failed_sentinel(name: &hive_types::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_types::Ident) -> (Option, Option) { +pub fn read_agent_status(name: &str) -> (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 @@ -205,10 +198,8 @@ pub fn read_agent_status(name: &hive_types::Ident) -> (Option, Option (Option, Option, bool) { - if !lifecycle::is_running(name.as_str()).await { +pub async fn read_agent_status_live(name: &str) -> (Option, Option, bool) { + if !lifecycle::is_running(name).await { return (None, None, false); } let (text, set_at) = read_agent_status(name); @@ -221,7 +212,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_types::Ident) -> Option { +fn read_active_model(name: &str) -> 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 707ac73c..88390fed 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -549,18 +549,12 @@ 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_types::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.as_str()), - claude: Self::agent_claude_dir(&name), - notes: Self::agent_notes_dir(&name), + proposed: Self::agent_proposed_dir(name), + applied: crate::paths::applied_dir(name), + claude: Self::agent_claude_dir(name), + notes: Self::agent_notes_dir(name), } } @@ -1448,7 +1442,7 @@ impl Coordinator { /// Manager-editable proposed config repo. Bind-mounted into the manager /// container as `/agents//config/`. - pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf { + pub fn agent_proposed_dir(name: &str) -> PathBuf { crate::paths::agent_state_dir(name).join("config") } @@ -1456,14 +1450,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_types::Ident) -> PathBuf { + pub fn agent_claude_dir(name: &str) -> 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_types::Ident) -> PathBuf { + pub fn agent_notes_dir(name: &str) -> PathBuf { crate::paths::agent_state_dir(name).join("state") } @@ -1473,7 +1467,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_types::Ident) -> PathBuf { + pub fn agent_harness_dir(name: &str) -> PathBuf { crate::paths::agent_state_dir(name).join("harness") } @@ -1483,14 +1477,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_types::Ident::parse(&e.file_name().into_string().ok()?).ok()) + .filter_map(|e| e.file_name().into_string().ok()) .collect(); out.sort(); out @@ -1503,12 +1497,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.as_str()).join(".git").exists() + && !crate::paths::applied_dir(n).join(".git").exists() }) .collect() } diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index 04c1ee96..8c82ab5c 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -66,12 +66,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec, axum::extract::Query(q): axum::extract::Query, ) -> Response { - let name = match Ident::parse(&name) { - Ok(n) => n, - Err(reason) => { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } - }; + if let Some(reason) = validate_agent_name(&name) { + 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.as_str(), limit) - { + match state.coord.build_logs.list_recent_for_agent(&name, limit) { Ok(rows) => axum::Json(rows).into_response(), Err(e) => error_response(&format!("build-logs {name}: {e:#}")), } diff --git a/hive-c0re/src/dashboard/extra_forges.rs b/hive-c0re/src/dashboard/extra_forges.rs index fcf4e0f2..751f8c0d 100644 --- a/hive-c0re/src/dashboard/extra_forges.rs +++ b/hive-c0re/src/dashboard/extra_forges.rs @@ -23,9 +23,20 @@ use axum::extract::{Form, Query}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; -use super::{Ident, error_response}; +use super::error_response; 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, @@ -64,10 +75,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(); - let Ok(agent) = Ident::parse(agent) else { + if !is_plain_ident(agent) { return error_response(&format!("extra-forges: invalid agent {agent:?}")); - }; - let dir = Coordinator::agent_notes_dir(&agent); + } + let dir = Coordinator::agent_notes_dir(agent); let mut forges = Vec::new(); match std::fs::read_dir(&dir) { Ok(entries) => { @@ -130,12 +141,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(); - let Ok(agent) = Ident::parse(agent) else { + if !is_plain_ident(agent) { return error_response(&format!("extra-forge-account: invalid agent {agent:?}")); - }; - let Ok(label) = Ident::parse(label) else { + } + if !is_plain_ident(label) { return error_response(&format!("extra-forge-account: invalid label {label:?}")); - }; + } match f.action.as_str() { "add" => { @@ -149,13 +160,9 @@ pub(super) async fn post_extra_forge_account(Form(f): Form { - if let Err(e) = - crate::priv_client::delete_agent_extra_forge_account(agent.as_str(), label.as_str()) - .await + if let Err(e) = crate::priv_client::delete_agent_extra_forge_account(agent, label).await { return error_response(&format!( "extra-forge-account: delete account failed: {e:#}" @@ -182,3 +187,19 @@ pub(super) async fn post_extra_forge_account(Form(f): Form n, - Err(reason) => { - return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) - .with_detail(format!("bad agent name: {reason}"))); - } - }; + if let Some(reason) = validate_agent_name(&name) { + 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.as_str()); + let container = strip_container_prefix(&name); 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/matrix_accounts.rs b/hive-c0re/src/dashboard/matrix_accounts.rs index a02cb8fe..4f5f5c52 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::{Ident, error_response}; +use super::error_response; use crate::coordinator::Coordinator; #[derive(Deserialize)] @@ -104,14 +104,17 @@ fn account_name_from_filename(fname: &str) -> Option { pub(super) async fn get_matrix_accounts(Query(q): Query) -> Response { let agent = q.agent.trim(); - // 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 { + // 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 == '_') + { return error_response(&format!("matrix-accounts: invalid agent name {agent:?}")); - }; + } - let dir = Coordinator::agent_notes_dir(&agent); + 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) { @@ -173,6 +176,17 @@ 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 @@ -182,15 +196,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('/'); - let Ok(agent) = Ident::parse(agent) else { + if !is_plain_ident(agent) { return error_response(&format!("matrix-account-login: invalid agent {agent:?}")); - }; - let Ok(account) = Ident::parse(account) else { + } + if !is_plain_ident(account) { return error_response(&format!( "matrix-account-login: invalid account {account:?}" )); - }; - if account.as_str() == "main" { + } + if account == "main" { return error_response( "matrix-account-login: 'main' is the hive-internal account; it is \ provisioned via the normal flow, not this form", @@ -230,19 +244,15 @@ pub(super) async fn post_matrix_account_login(Form(f): Form) -> } }; - if let Err(e) = crate::priv_client::write_agent_matrix_token( - agent.as_str(), - &token, - Some(account.as_str()), - Some(homeserver), - ) - .await + if let Err(e) = + crate::priv_client::write_agent_matrix_token(agent, &token, Some(account), 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.as_str()).await { + if let Err(e) = crate::priv_client::restart_matrix_daemon(agent).await { tracing::warn!( %agent, %account, error = ?e, "matrix-account-login: daemon restart failed (token written; loads on next start)" @@ -278,13 +288,13 @@ 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) = Ident::parse(agent) else { + if !is_plain_ident(agent) { 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.as_str(), token).await { + if let Err(e) = crate::priv_client::write_agent_github_token(agent, token).await { return error_response(&format!("github-account: write token failed: {e:#}")); } tracing::info!(%agent, "github-account: provisioned github PAT"); @@ -310,10 +320,10 @@ 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) = Ident::parse(agent) else { + if !is_plain_ident(agent) { return error_response(&format!("github-account: invalid agent {agent:?}")); - }; - let present = Coordinator::agent_notes_dir(&agent) + } + let present = Coordinator::agent_notes_dir(agent) .join("github-token") .exists(); axum::Json(GithubAccountStatus { present }).into_response() @@ -392,7 +402,7 @@ async fn matrix_whoami(homeserver: &str, token: &str) -> Result #[cfg(test)] mod tests { - use super::{account_name_from_filename, read_accounts_snapshot}; + use super::{account_name_from_filename, is_plain_ident, read_accounts_snapshot}; fn unique_dir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!( @@ -478,4 +488,20 @@ 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")); + } } diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index 1548d8db..8f5fde99 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, Ident, error_response, scan_validated_paths}; +use super::{AppState, error_response, scan_validated_paths, validate_agent_name}; /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox. /// Returns messages addressed to `"operator"` that haven't been @@ -113,13 +113,10 @@ pub(super) async fn post_mark_all_read( State(state): State, AxumPath(name): AxumPath, ) -> Response { - let name = match Ident::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()) { + 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) { 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 00a65b4c..85fb0aa2 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -19,11 +19,6 @@ use crate::lifecycle; mod approvals; mod build_logs; mod extra_forges; -// 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_types::Ident; mod infra_containers; mod journal; mod lifecycle_ops; @@ -297,10 +292,33 @@ 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** ([`Ident::parse`]) — rejects -/// path traversal / unicode homoglyphs / empty + too-long names with +/// 1. **format validation** (`validate_agent_name`) — 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 @@ -314,9 +332,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 -/// [`Ident::parse`] directly and skip the existence check. +/// `validate_agent_name` directly and skip the existence check. async fn guard_agent_name(state: &AppState, name: &str) -> Option { - if let Err(reason) = Ident::parse(name) { + if let Some(reason) = validate_agent_name(name) { return Some( (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(), ); @@ -378,4 +396,45 @@ 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 4b0049b0..1f133354 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, Ident, guard_agent_name, strip_container_prefix}; +use super::{AppState, guard_agent_name, strip_container_prefix, validate_agent_name}; #[derive(Serialize)] pub(super) struct ToolGroupsSnapshot { @@ -344,7 +344,6 @@ pub(super) async fn get_stale_permissions( let kept: std::collections::HashSet = crate::coordinator::Coordinator::kept_state_names() .into_iter() - .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(); @@ -372,26 +371,24 @@ 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 ([`Ident::parse`]) is applied. No rebuild is +/// the format check (`validate_agent_name`) 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 Ident::parse(&strip_container_prefix(&name)) { - Ok(n) => n, - Err(reason) => { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_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(); + } // 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.as_str()).err(); + let tg_err = crate::tool_groups::remove_agent(&logical).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.as_str()).err(); + let cap_err = crate::capabilities::remove_agent(&logical).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 6c4420c3..885f9676 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, Ident, error_response}; +use super::{AppState, error_response, validate_agent_name}; #[derive(Serialize, Clone, Debug)] pub struct TombstoneView { @@ -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.into_string(), + name, state_bytes, last_seen, has_creds, @@ -116,19 +116,16 @@ 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 Ident::parse(&name) { - Ok(n) => n, - Err(reason) => { - return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); - } - }; + if let Some(reason) = validate_agent_name(&name) { + 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.as_str() == name.as_str()) + .any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c == &name) { return error_response(&format!( "refusing to purge {name}: container still exists — use DESTR0Y first" @@ -137,7 +134,7 @@ 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.as_str()), + crate::paths::applied_dir(&name), ] { if dir.exists() && let Err(e) = std::fs::remove_dir_all(&dir) @@ -148,7 +145,7 @@ pub(super) async fn post_purge_tombstone( let _ = state .coord .approvals - .fail_pending_for_agent(name.as_str(), "agent state purged"); + .fail_pending_for_agent(&name, "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/forge/repos.rs b/hive-c0re/src/forge/repos.rs index a9fe91d8..99443318 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -420,12 +420,7 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } - // 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_types::Ident::parse(name) else { - return Ok(()); - }; - let proposed_dir = Coordinator::agent_proposed_dir(&agent); + let proposed_dir = Coordinator::agent_proposed_dir(name); 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 39195f96..247b4f6a 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -51,11 +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_types::Ident::parse(child) else { - tracing::warn!(%child, "skipping child bind: invalid agent name"); - return; - }; - let child_root = crate::paths::agent_state_dir(&child); + 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); @@ -201,9 +197,7 @@ async fn set_nspawn_flags( read_only: false, }); } - 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"); + let own_config = crate::paths::agent_state_dir(agent_name).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 39d037db..aeeb9cc2 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -212,9 +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_types::Ident::parse(name) - .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; - let root = crate::paths::agent_state_dir(&agent); + let root = crate::paths::agent_state_dir(name); if root.exists() { return Ok(()); } diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index eff37f84..156d50b6 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_types::Ident) -> PathBuf { +fn token_path(name: &str) -> 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_types::Ident) -> PathBuf { +fn legacy_password_path(name: &str) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-password") } @@ -611,9 +611,7 @@ pub async fn ensure_user_for( register_token: &str, ) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let agent = hive_types::Ident::parse(name) - .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; - let path = token_path(&agent); + let path = token_path(name); if path.exists() && let Ok(existing) = std::fs::read_to_string(&path) && !existing.trim().is_empty() @@ -625,7 +623,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(&agent); + let old_pw_path = legacy_password_path(name); 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 55f4edf5..8a75e409 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -122,10 +122,7 @@ 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: Vec = crate::coordinator::Coordinator::pending_init_names() - .into_iter() - .map(hive_types::Ident::into_string) - .collect(); + let pending = crate::coordinator::Coordinator::pending_init_names(); 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 355bba5d..f15dc8f4 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.as_str()).await { + if let Err(e) = migrate_applied_repo(name).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.as_str()); + let proposed = lifecycle::setup_proposed(&proposed_dir, name); match tokio::time::timeout(GIT_TIMEOUT, proposed).await { Ok(Err(e)) => tracing::warn!(%name, error = ?e, "migration: setup_proposed failed"), Err(_) => { @@ -108,9 +108,8 @@ 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.as_str(), crate::coordinator::TransientKind::Rebuilding); - let result = repoint_container(name.as_str()).await; + let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding); + let result = repoint_container(name).await; drop(guard); if let Err(e) = result { tracing::warn!(%name, error = ?e, "migration: container repoint failed"); @@ -141,7 +140,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_types::Ident) { +fn migrate_harness_files(name: &str) { const HARNESS_FILES: &[&str] = &[ "hyperhive-events.sqlite", "hyperhive-turn-stats.sqlite", @@ -267,17 +266,16 @@ 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| { - let name = if c == MANAGER_CONTAINER { - MANAGER_NAME + if c == MANAGER_CONTAINER { + Some(MANAGER_NAME.to_owned()) } else { - c.strip_prefix(AGENT_PREFIX)? - }; - hive_types::Ident::parse(name).ok() + c.strip_prefix(AGENT_PREFIX).map(str::to_owned) + } }) .collect() } @@ -353,8 +351,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: &[hive_types::Ident]) { - if !names.iter().any(|n| n.as_str() == MANAGER_NAME) { +fn backfill_manager_tool_groups(names: &[String]) { + if !names.iter().any(|n| n == 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 21089867..cc6053a1 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.as_str()).await?, + HostRequest::Spawn { name } => handle_spawn(&coord, name).await?, HostRequest::RequestSpawn { name } => { tracing::info!(%name, "request_spawn"); let id = coord.approvals.submit_kind( - name.as_str(), + name, hive_sh4re::ApprovalKind::Spawn, "", None, @@ -97,10 +97,8 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { tracing::info!(%id, %name, "spawn approval queued"); HostResponse::success() } - 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::Kill { name } => submit_single(&coord, name, Verb::Kill).await, + HostRequest::Restart { name } => submit_single(&coord, name, Verb::Restart).await, HostRequest::RestartAll => handle_restart_all(&coord).await?, HostRequest::RestartScoped { scope, graceful } => { handle_restart_scoped(&coord, scope, *graceful).await? @@ -142,12 +140,10 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { handle_start(&coord, &agents, &infra).await? } HostRequest::Destroy { name, purge } => { - actions::destroy(&coord, name.as_str(), *purge).await?; + actions::destroy(&coord, name, *purge).await?; HostResponse::success() } - HostRequest::Rebuild { name } => { - submit_single(&coord, name.as_str(), Verb::Rebuild).await - } + HostRequest::Rebuild { name } => submit_single(&coord, name, Verb::Rebuild).await, HostRequest::QueueDag { id } => { // A multi-step op is one DAG now (no fan-out children to gather). let dags = coord @@ -183,10 +179,7 @@ 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.as_str(), - new_parent.as_ref().map(hive_types::Ident::as_str), - ) + .reparent_with_notify(child, new_parent.as_deref()) .await .map_err(anyhow::Error::msg)?; HostResponse::success() @@ -195,12 +188,8 @@ 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.as_str()).await? - } - HostRequest::MatrixResetPassword { name } => { - handle_matrix_reset_password(name.as_str()).await? - } + HostRequest::MatrixPromoteUser { name } => handle_matrix_promote_user(name).await?, + HostRequest::MatrixResetPassword { name } => handle_matrix_reset_password(name).await?, HostRequest::MatrixInvite { user, room } => { handle_matrix_invite(user, room.as_deref()).await? } @@ -208,10 +197,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.as_str(), *verbose).await? + crate::forge::reconcile_config_status(agent, *verbose).await? } HostRequest::ReconcileConfigApply { agent, direction } => { - crate::forge::reconcile_config_apply(agent.as_str(), *direction).await? + crate::forge::reconcile_config_apply(agent, *direction).await? } HostRequest::GatewayCreateUser { username, password } => { HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?]) @@ -223,30 +212,24 @@ 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.as_str(), token).await? + handle_set_agent_github_token(agent, token).await? } HostRequest::QuotaEnable => handle_quota_enable().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::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::SnapshotSubvolume { name, label } => { - handle_snapshot_subvolume(name.as_str(), label).await? + handle_snapshot_subvolume(name, label).await? } HostRequest::DeleteSnapshot { name, label } => { - handle_delete_snapshot(name.as_str(), label).await? + handle_delete_snapshot(name, label).await? } HostRequest::SendSnapshot { name, label, parent, dest, - } => handle_send_snapshot(name.as_str(), label, parent.as_deref(), dest).await?, + } => handle_send_snapshot(name, label, parent.as_deref(), dest).await?, }) } .await; @@ -337,7 +320,7 @@ 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: &hive_types::Ident) -> Result { +fn agent_exists(name: &str) -> Result { crate::paths::agent_state_dir(name) .try_exists() .with_context(|| format!("check agent state dir for {name}")) @@ -353,10 +336,7 @@ async fn require_matrix_present() -> Result<()> { ) } -async fn handle_matrix_create_user( - name: &hive_types::Ident, - password: Option<&str>, -) -> Result { +async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result { require_matrix_present().await?; let register_token = crate::matrix::ensure_register_token().context("read matrix register token")?; @@ -371,7 +351,7 @@ async fn handle_matrix_create_user( "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.as_str(), ®ister_token) + 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"); @@ -384,7 +364,7 @@ async fn handle_matrix_create_user( }; let token = crate::matrix::provision_user_token( &client, - name.as_str(), + name, ®ister_token, &effective_password, ) @@ -407,10 +387,7 @@ async fn handle_matrix_create_user( Ok(HostResponse::messages(out)) } -async fn handle_forge_create_user( - name: &hive_types::Ident, - password: Option<&str>, -) -> Result { +async fn handle_forge_create_user(name: &str, 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" @@ -425,14 +402,14 @@ async fn handle_forge_create_user( "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.as_str()) + crate::forge::ensure_user_for(name) .await .with_context(|| format!("forge create-user {name}"))?; 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.as_str(), password) + let token = crate::forge::provision_user_token(name, password) .await .with_context(|| format!("forge create-user {name}"))?; out.push(format!( @@ -482,10 +459,7 @@ 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() - .into_iter() - .map(hive_types::Ident::into_string) - .collect(), + None => Coordinator::kept_state_names(), }; 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 e852892c..3b35f8be 100644 --- a/hive-c0re/src/socket_server/config_approvals.rs +++ b/hive-c0re/src/socket_server/config_approvals.rs @@ -195,9 +195,7 @@ pub(crate) fn submit_init_config( parent: Option<&str>, description: Option, ) -> anyhow::Result { - 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); + let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name); 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 86b0a6a7..7b04daf3 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -425,16 +425,13 @@ 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_types::Ident::parse(target) { - Ok(id) => id, - Err(reason) => { - return hive_agent_sock::Response::Err { - message: format!("get_agent_meta: invalid agent name {target:?}: {reason}"), - }; - } - }; + if let Some(reason) = crate::dashboard::validate_agent_name(target) { + 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_id).await; + crate::container_view::read_agent_status_live(target).await; let (hive_name, swarm_name) = crate::container_view::hive_swarm_names(); hive_agent_sock::Response::AgentMeta { name: target.to_owned(), @@ -448,10 +445,10 @@ 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 `Ident::parse` gate + // another on a public matrix instance. The `validate_agent_name` gate // above is what closes the real vector here (path traversal via `../` // in an agent-supplied name). - matrix_accounts: read_agent_matrix_identities(&target_id), + matrix_accounts: read_agent_matrix_identities(target), } } @@ -461,7 +458,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_types::Ident) -> Vec { +fn read_agent_matrix_identities(agent: &str) -> Vec { let path = Coordinator::agent_notes_dir(agent).join("matrix-accounts.json"); std::fs::read_to_string(&path) .ok() @@ -751,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) = hive_types::Ident::parse(target) { + if let Some(reason) = crate::dashboard::validate_agent_name(target) { return Some(Response::Err { message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"), }); @@ -1115,12 +1112,8 @@ pub(crate) fn handle_send( }; } if resolved != hive_sh4re::OPERATOR_RECIPIENT { - // 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_types::Ident::parse(&resolved) - .is_ok_and(|id| crate::paths::agent_state_dir(&id).exists()); - if !exists { + let state_root = crate::paths::agent_state_dir(&resolved); + if !state_root.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 b9fe1b9a..cada8706 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_types::Ident) -> u64 { +async fn measure_agent_disk(name: &str) -> 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.into_string(), bytes); + cache.insert(name, bytes); } } sleep(DISK_SAMPLE_INTERVAL).await; @@ -240,9 +240,7 @@ 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.into_string(), dir)) + dir.join("cpu.stat").exists().then_some((name, dir)) }) .collect(); diff --git a/hive-c0re/src/stats/hive_stats.rs b/hive-c0re/src/stats/hive_stats.rs index bbb7eba7..6ef47bf6 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.into_string(), + name, 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 49111296..e5ebacdf 100644 --- a/hive-c0re/src/workers/crash_watch.rs +++ b/hive-c0re/src/workers/crash_watch.rs @@ -41,9 +41,7 @@ pub fn spawn(coord: Arc) { if lifecycle::is_running(&logical).await { current_running.insert(logical.clone()); } - if hive_types::Ident::parse(&logical) - .is_ok_and(|id| claude_has_session(&Coordinator::agent_claude_dir(&id))) - { + if claude_has_session(&Coordinator::agent_claude_dir(&logical)) { 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 f05f8d92..6c69fe7e 100644 --- a/hive-c0re/src/workers/reminder_scheduler.rs +++ b/hive-c0re/src/workers/reminder_scheduler.rs @@ -133,8 +133,6 @@ 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_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()); }; @@ -145,7 +143,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) { @@ -191,9 +189,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_types::Ident::parse(agent) - .map_err(|e| format!("invalid agent name {agent:?}: {e}"))?; - let prefix = container_state_prefix(agent.as_str()); + let prefix = container_state_prefix(agent); let Some(rel) = req_path.strip_prefix(&prefix) else { return Err(format!( "must be absolute and under `{prefix}` (got `{req_path}`)" @@ -213,7 +209,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/Cargo.toml b/hive-host-sock/Cargo.toml index b39f0f9e..23407d6f 100644 --- a/hive-host-sock/Cargo.toml +++ b/hive-host-sock/Cargo.toml @@ -8,5 +8,4 @@ workspace = true [dependencies] hive-sh4re.workspace = true -hive-types.workspace = true serde.workspace = true diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 1b5ffcdd..7bd71ddf 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -9,7 +9,6 @@ use std::path::PathBuf; use hive_sh4re::{AgentStatusRow, Approval, jobs}; -use hive_types::Ident; use serde::{Deserialize, Serialize}; // ── Shared hive layout facts ────────────────────────────────────────────── @@ -32,13 +31,9 @@ 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: &Ident) -> PathBuf { - PathBuf::from(AGENTS_ROOT).join(name.as_str()) +pub fn agent_state_dir(name: &str) -> PathBuf { + PathBuf::from(AGENTS_ROOT).join(name) } /// `gateway/gateway.htpasswd` — nginx basic-auth credential store for the @@ -76,23 +71,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: Ident }, + Spawn { name: String }, /// Submit a spawn request for the operator to approve. See /// `docs/approvals.md::Approval kinds (wire shapes)` (`Spawn`). - RequestSpawn { name: Ident }, + RequestSpawn { name: String }, /// Stop a managed container (graceful). - Kill { name: Ident }, + Kill { name: String }, /// Tear down a sub-agent container, optionally purging state. /// See `docs/approvals.md::Destroy semantics`. Destroy { - name: Ident, + name: String, #[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: Ident }, + Restart { name: String }, /// Stop and restart all managed containers in sequence. Convenience /// wrapper for `hivectl agents restart-all`; iterates the live /// container list and restarts each one. @@ -120,7 +115,7 @@ pub enum HostRequest { graceful: bool, }, /// Apply pending config to a managed container. - Rebuild { name: Ident }, + Rebuild { name: String }, /// List managed containers. List, /// List managed agents with their full status + technical state @@ -151,8 +146,8 @@ pub enum HostRequest { /// Validation rules + bind-mount caveat documented in /// `docs/agent-hierarchy.md::Current state`. SetParent { - child: Ident, - new_parent: Option, + child: String, + new_parent: Option, }, /// Stop managed containers hive-wide in one operator action /// (`hivectl stop`): agents plus the selected infra containers. `scope` @@ -182,7 +177,7 @@ pub enum HostRequest { /// [`HostResponse::messages`]. `password` is resolved by the client /// (inline flag or stdin) and `None` requests a random throwaway. MatrixCreateUser { - name: Ident, + name: String, #[serde(default)] password: Option, }, @@ -192,11 +187,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: Ident }, + MatrixPromoteUser { name: String }, /// 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: Ident }, + MatrixResetPassword { name: String }, /// 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). @@ -212,7 +207,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: Ident, + name: String, #[serde(default)] password: Option, }, @@ -224,7 +219,7 @@ pub enum HostRequest { /// — never mutates either side. Backs `hivectl forge reconcile-config` /// (the diff it always shows first). ReconcileConfigStatus { - agent: Ident, + agent: String, #[serde(default)] verbose: bool, }, @@ -235,7 +230,7 @@ pub enum HostRequest { /// local needs lifting branch protection — resolve via a config PR). /// Backs `hivectl forge reconcile-config --from `. ReconcileConfigApply { - agent: Ident, + agent: String, direction: ReconcileDirection, }, /// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file @@ -258,7 +253,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: Ident, token: String }, + SetAgentGithubToken { agent: String, 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`]. @@ -269,7 +264,7 @@ pub enum HostRequest { /// bare success — the client prints the confirmation from the value it /// sent. QuotaLimit { - name: Ident, + name: String, #[serde(default)] limit: Option, }, @@ -281,27 +276,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: Ident }, + UpgradeSubvolume { name: String }, /// 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: Ident, label: String }, + SnapshotSubvolume { name: String, label: String }, /// Delete a snapshot created by `SnapshotSubvolume` (`hivectl subvol /// snapshot delete`). Bare success; the client prints the confirmation. - DeleteSnapshot { name: Ident, label: String }, + DeleteSnapshot { name: String, 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: Ident, + name: String, label: String, #[serde(default)] parent: Option, diff --git a/hive-types/Cargo.toml b/hive-types/Cargo.toml deleted file mode 100644 index d472adb5..00000000 --- a/hive-types/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[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 deleted file mode 100644 index c6b6a536..00000000 --- a/hive-types/src/lib.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! 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 aad05e71..5107a6e3 100644 --- a/hivectl/Cargo.toml +++ b/hivectl/Cargo.toml @@ -17,7 +17,6 @@ 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 16fa73d6..7c602acf 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: crate::util::parse_ident(name)?, + name: name.to_owned(), }, ) .await @@ -135,23 +135,18 @@ 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 { @@ -159,12 +154,7 @@ pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> { parent, root, } => { - let child = crate::util::parse_ident(&child)?; - let new_parent = if root { - None - } else { - parent.map(|p| crate::util::parse_ident(&p)).transpose()? - }; + let new_parent = if root { None } else { parent }; render( crate::client::request(socket, HostRequest::SetParent { child, new_parent }) .await?, diff --git a/hivectl/src/forge.rs b/hivectl/src/forge.rs index bebad895..9f8128fe 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: crate::util::parse_ident(name)?, + name: name.to_owned(), password, }, "forge", @@ -45,7 +45,7 @@ pub(crate) async fn forge_reconcile_config( daemon_request( socket, HostRequest::ReconcileConfigStatus { - agent: crate::util::parse_ident(agent)?, + agent: agent.to_owned(), verbose, }, "forge", @@ -62,7 +62,7 @@ pub(crate) async fn forge_reconcile_config( daemon_request( socket, HostRequest::ReconcileConfigApply { - agent: crate::util::parse_ident(agent)?, + agent: agent.to_owned(), direction, }, "forge", diff --git a/hivectl/src/github.rs b/hivectl/src/github.rs index 42ee792b..ef296b1b 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: crate::util::parse_ident(agent)?, + agent: agent.to_owned(), token, }, "github", diff --git a/hivectl/src/matrix.rs b/hivectl/src/matrix.rs index a75ce7bf..ca02cfdc 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: crate::util::parse_ident(name)?, + name: name.to_owned(), password, }, ) @@ -74,7 +74,7 @@ async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> { matrix_request( socket, hive_host_sock::HostRequest::MatrixPromoteUser { - name: crate::util::parse_ident(name)?, + name: name.to_owned(), }, ) .await @@ -95,7 +95,7 @@ async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> { matrix_request( socket, hive_host_sock::HostRequest::MatrixResetPassword { - name: crate::util::parse_ident(name)?, + name: name.to_owned(), }, ) .await diff --git a/hivectl/src/quota.rs b/hivectl/src/quota.rs index 15e5546d..f8cb30c4 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(crate::util::parse_ident).transpose()?, + name: name.map(str::to_owned), }, ) .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: crate::util::parse_ident(name)?, + name: name.to_owned(), limit, }, "quota", diff --git a/hivectl/src/subvol.rs b/hivectl/src/subvol.rs index 202d1aff..eb4c4036 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: crate::util::parse_ident(name)?, + name: name.to_owned(), }, "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: crate::util::parse_ident(name)?, + name: name.to_owned(), 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: crate::util::parse_ident(name)?, + name: name.to_owned(), label: label.to_owned(), }, "snapshot delete", @@ -211,7 +211,7 @@ async fn subvol_snapshot_send( daemon_request( socket, hive_host_sock::HostRequest::SendSnapshot { - name: crate::util::parse_ident(name)?, + name: name.to_owned(), 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 ceec313a..3cedbf14 100644 --- a/hivectl/src/util.rs +++ b/hivectl/src/util.rs @@ -6,16 +6,6 @@ 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`). @@ -125,10 +115,7 @@ pub(crate) async fn query_hive_urls(socket: &Path) -> Option Result { - let Ok(name) = hive_types::Ident::parse(name) else { - bail!("invalid agent name {name:?}"); - }; - let root = hive_host_sock::agent_state_dir(&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!(