From bf644cc126dc475f81a95e95ecbefcf25f9d6ca6 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 20 Jul 2026 00:22:06 +0200 Subject: [PATCH] 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!(