diff --git a/Cargo.lock b/Cargo.lock index 92cf8206..b69af92e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1619,6 +1619,7 @@ dependencies = [ "hive-host-sock", "hive-priv-sock", "hive-sh4re", + "hive-types", "hmac 0.13.0", "indicatif", "libc", @@ -1671,8 +1672,8 @@ name = "hive-host-sock" version = "0.1.0" dependencies = [ "hive-sh4re", + "hive-types", "serde", - "serde_json", ] [[package]] @@ -1758,6 +1759,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hive-types" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "hivectl" version = "0.1.0" @@ -1768,6 +1777,7 @@ dependencies = [ "clap_complete", "hive-host-sock", "hive-sh4re", + "hive-types", "indicatif", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 95993f7d..cc38c6fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "hive-priv", "hive-priv-sock", "hive-sh4re", + "hive-types", "hivectl", ] @@ -54,6 +55,7 @@ hive-agent-sock = { path = "hive-agent-sock" } hive-claude = { path = "hive-claude" } hive-host-sock = { path = "hive-host-sock" } hive-priv-sock = { path = "hive-priv-sock" } +hive-types = { path = "hive-types" } thiserror = "2" tower-http = { version = "0.7", features = ["fs"] } rmcp = { version = "2", default-features = false, features = [ diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 6c7edbeb..9f304815 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -34,6 +34,7 @@ hive-agent-sock.workspace = true hive-sh4re.workspace = true hive-host-sock.workspace = true hive-priv-sock.workspace = true +hive-types.workspace = true libc.workspace = true listenfd = "1" petgraph.workspace = true diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index d9518248..e091a686 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -41,7 +41,7 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { // Sub-second git seed + forge-remote wire. Routing through // the queue would surface a queue card that's gone before // the operator's eyes refocus. Run inline. - let agent = hive_host_sock::Ident::parse(&approval.agent).map_err(|e| { + let agent = hive_types::Ident::parse(&approval.agent).map_err(|e| { anyhow::anyhow!("approval {} has invalid agent name: {e}", approval.id) })?; let proposed_dir = Coordinator::agent_proposed_dir(&agent); @@ -804,7 +804,7 @@ pub async fn destroy(coord: &Arc, name: &str, purge: bool) -> Resul // A malformed name can't have a persistent state tree (the state dir // is only ever created under a validated Ident), so its removal is a // no-op — skip the state-dir sweep and just clear the applied dir. - let state_dir = hive_host_sock::Ident::parse(name) + let state_dir = hive_types::Ident::parse(name) .ok() .map(|id| crate::paths::agent_state_dir(&id)); for dir in state_dir diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 96fcb24d..abefef7b 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -70,7 +70,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { // Parse the nspawn machine suffix into an Ident once at this // enumeration origin; a suffix that isn't a valid ident isn't one // of our agents, so skip it. - let Ok(logical) = hive_host_sock::Ident::parse(logical) else { + let Ok(logical) = hive_types::Ident::parse(logical) else { continue; }; let deployed_full = locked @@ -133,7 +133,7 @@ pub fn claude_has_session(dir: &Path) -> bool { /// the consolidated `hyperhive-harness.json`. Falls back to the legacy /// individual sentinel files written by older harness builds so in-place /// upgrades don't lose state during the transition window. -fn read_harness_flags(name: &hive_host_sock::Ident) -> (bool, bool) { +fn read_harness_flags(name: &hive_types::Ident) -> (bool, bool) { let dir = Coordinator::agent_notes_dir(name); if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) && let Ok(v) = serde_json::from_str::(&raw) @@ -154,7 +154,7 @@ fn read_harness_flags(name: &hive_host_sock::Ident) -> (bool, bool) { (rate_limited, needs_login) } -fn auth_failed_sentinel(name: &hive_host_sock::Ident) -> bool { +fn auth_failed_sentinel(name: &hive_types::Ident) -> bool { read_harness_flags(name).1 } @@ -165,7 +165,7 @@ fn auth_failed_sentinel(name: &hive_host_sock::Ident) -> bool { /// NB: callers building `AgentMeta` for a *stopped* container should /// clear the result — the on-disk status is a stale snapshot from /// before the stop. Use `read_agent_status_live` for that. -pub fn read_agent_status(name: &hive_host_sock::Ident) -> (Option, Option) { +pub fn read_agent_status(name: &hive_types::Ident) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); // Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte @@ -206,7 +206,7 @@ pub fn read_agent_status(name: &hive_host_sock::Ident) -> (Option, Optio /// Returned tuple is `(status_text, status_set_at, running)`. /// `name` is the logical agent name (same as the broker recipient). pub async fn read_agent_status_live( - name: &hive_host_sock::Ident, + name: &hive_types::Ident, ) -> (Option, Option, bool) { if !lifecycle::is_running(name.as_str()).await { return (None, None, false); @@ -221,7 +221,7 @@ pub async fn read_agent_status_live( /// so it always reflects the resolved priority (nix config > runtime /// override > default). Returns `None` when the field is absent or the /// harness has not yet started a turn. -fn read_active_model(name: &hive_host_sock::Ident) -> Option { +fn read_active_model(name: &hive_types::Ident) -> Option { let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json"); let raw = std::fs::read_to_string(path).ok()?; let v: serde_json::Value = serde_json::from_str(&raw).ok()?; diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index c2c76c72..707ac73c 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -553,7 +553,7 @@ impl Coordinator { // MANAGER_NAME const), so an invalid ident here is a construction // bug. This is the step-3 boundary between the Ident-threaded path // builders and the job_queue layer (threaded post hive-jobq cutover). - let name = hive_host_sock::Ident::parse(name) + let name = hive_types::Ident::parse(name) .expect("agent_paths: name must be a valid ident (validated at spawn/enqueue)"); AgentPaths { agent: agent_dir, @@ -1448,7 +1448,7 @@ impl Coordinator { /// Manager-editable proposed config repo. Bind-mounted into the manager /// container as `/agents//config/`. - pub fn agent_proposed_dir(name: &hive_host_sock::Ident) -> PathBuf { + pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("config") } @@ -1456,14 +1456,14 @@ impl Coordinator { /// container at `/root/.claude` so OAuth state survives container /// destroy/recreate. Each agent owns its own token lineage — sharing /// would break on the first refresh-token rotation. - pub fn agent_claude_dir(name: &hive_host_sock::Ident) -> PathBuf { + pub fn agent_claude_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("claude") } /// Per-agent durable knowledge dir. Bind-mounted RW into the agent /// container at `/agents/{name}/state`. Survives destroy/recreate. /// Agent-visible — claude is told to write long-lived notes here. - pub fn agent_notes_dir(name: &hive_host_sock::Ident) -> PathBuf { + pub fn agent_notes_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("state") } @@ -1473,7 +1473,7 @@ impl Coordinator { /// `hyperhive-turn-stats.sqlite`, `hyperhive-model`) — kept separate /// from the agent-visible `state/` so claude's "my notes" view is /// uncluttered and the host vacuum has a clean sweep root. - pub fn agent_harness_dir(name: &hive_host_sock::Ident) -> PathBuf { + pub fn agent_harness_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("harness") } @@ -1483,14 +1483,14 @@ impl Coordinator { /// destroyed-but-kept tombstones; callers filter the latter by /// subtracting `lifecycle::list()`. #[must_use] - pub fn kept_state_names() -> Vec { + pub fn kept_state_names() -> Vec { let Ok(rd) = std::fs::read_dir(crate::paths::agents_root()) else { return Vec::new(); }; - let mut out: Vec = rd + let mut out: Vec = rd .flatten() .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) - .filter_map(|e| hive_host_sock::Ident::parse(&e.file_name().into_string().ok()?).ok()) + .filter_map(|e| hive_types::Ident::parse(&e.file_name().into_string().ok()?).ok()) .collect(); out.sort(); out @@ -1503,7 +1503,7 @@ impl Coordinator { /// apply-commit spawns the container. Distinct from tombstones, /// which have an applied repo from a prior deploy. #[must_use] - pub fn pending_init_names() -> Vec { + pub fn pending_init_names() -> Vec { Self::kept_state_names() .into_iter() .filter(|n| { diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index d92ff1d9..04c1ee96 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -66,7 +66,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec = crate::coordinator::Coordinator::kept_state_names() .into_iter() - .map(hive_host_sock::Ident::into_string) + .map(hive_types::Ident::into_string) .collect(); // Known = live roster ∪ kept-state names. let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect(); diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index ef5c74ee..a9fe91d8 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -422,7 +422,7 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> { } // A malformed name has no proposed config repo (repos are only created // under a validated Ident), so there's nothing to wire — no-op. - let Ok(agent) = hive_host_sock::Ident::parse(name) else { + let Ok(agent) = hive_types::Ident::parse(name) else { return Ok(()); }; let proposed_dir = Coordinator::agent_proposed_dir(&agent); diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 3f280ee2..39195f96 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -51,7 +51,7 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; /// state") for the rationale. Creates missing host-side directories so /// nspawn doesn't refuse to start; missing dirs are non-fatal. fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { - let Ok(child) = hive_host_sock::Ident::parse(child) else { + let Ok(child) = hive_types::Ident::parse(child) else { tracing::warn!(%child, "skipping child bind: invalid agent name"); return; }; @@ -201,7 +201,7 @@ async fn set_nspawn_flags( read_only: false, }); } - let agent_id = hive_host_sock::Ident::parse(agent_name) + let agent_id = hive_types::Ident::parse(agent_name) .map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?; let own_config = crate::paths::agent_state_dir(&agent_id).join("config"); std::fs::create_dir_all(&own_config) diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs index da7888c4..39d037db 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -212,7 +212,7 @@ pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { /// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation /// is privileged, so it's delegated to hive-priv. pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> { - let agent = hive_host_sock::Ident::parse(name) + let agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; let root = crate::paths::agent_state_dir(&agent); if root.exists() { diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 4674e6ec..eff37f84 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -65,7 +65,7 @@ pub fn admin_token_path() -> PathBuf { /// Token file inside the agent's bind-mounted state dir (visible as /// `/state/matrix-token` from inside the container). -fn token_path(name: &hive_host_sock::Ident) -> PathBuf { +fn token_path(name: &hive_types::Ident) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-token") } @@ -89,7 +89,7 @@ fn password_path(name: &str) -> PathBuf { /// move credentials from old deployments to the new location. Safe to /// call after `destroy --purge` — the path will simply not exist and /// the migration is a no-op. -fn legacy_password_path(name: &hive_host_sock::Ident) -> PathBuf { +fn legacy_password_path(name: &hive_types::Ident) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-password") } @@ -611,7 +611,7 @@ pub async fn ensure_user_for( register_token: &str, ) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let agent = hive_host_sock::Ident::parse(name) + let agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; let path = token_path(&agent); if path.exists() diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 2de62d64..55f4edf5 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -124,7 +124,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let agent_names: Vec = agents.iter().map(|a| a.name.clone()).collect(); let pending: Vec = crate::coordinator::Coordinator::pending_init_names() .into_iter() - .map(hive_host_sock::Ident::into_string) + .map(hive_types::Ident::into_string) .collect(); crate::topology::reconcile(&agent_names, &pending) .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 20cbe2c0..355bba5d 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -141,7 +141,7 @@ pub async fn run(coord: &Arc) -> Result<()> { /// and into the sibling harness dir. Best-effort: logs warnings but never /// fails. Idempotent — each file is only moved if present at the old path /// and absent at the new path. -fn migrate_harness_files(name: &hive_host_sock::Ident) { +fn migrate_harness_files(name: &hive_types::Ident) { const HARNESS_FILES: &[&str] = &[ "hyperhive-events.sqlite", "hyperhive-turn-stats.sqlite", @@ -267,7 +267,7 @@ async fn rename_manager_container(coord: &Arc) { } } -async fn enumerate_agents() -> Vec { +async fn enumerate_agents() -> Vec { let containers = lifecycle::list().await.unwrap_or_default(); containers .into_iter() @@ -277,7 +277,7 @@ async fn enumerate_agents() -> Vec { } else { c.strip_prefix(AGENT_PREFIX)? }; - hive_host_sock::Ident::parse(name).ok() + hive_types::Ident::parse(name).ok() }) .collect() } @@ -353,7 +353,7 @@ async fn repoint_container(name: &str) -> Result<()> { /// Idempotent — skips when entry already present. Prevents a silent tool /// downgrade when upgrading from a build that relied on the manager-flavor /// fallback in `effective_tool_groups()`. -fn backfill_manager_tool_groups(names: &[hive_host_sock::Ident]) { +fn backfill_manager_tool_groups(names: &[hive_types::Ident]) { if !names.iter().any(|n| n.as_str() == MANAGER_NAME) { return; // ruth not deployed — nothing to backfill } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 8bc16ebe..21089867 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -83,11 +83,11 @@ async fn handle(stream: UnixStream, coord: Arc) -> Result<()> { async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let result: anyhow::Result = async { Ok(match req { - HostRequest::Spawn { name } => handle_spawn(&coord, name).await?, + HostRequest::Spawn { name } => handle_spawn(&coord, name.as_str()).await?, HostRequest::RequestSpawn { name } => { tracing::info!(%name, "request_spawn"); let id = coord.approvals.submit_kind( - name, + name.as_str(), hive_sh4re::ApprovalKind::Spawn, "", None, @@ -97,8 +97,10 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { tracing::info!(%id, %name, "spawn approval queued"); HostResponse::success() } - HostRequest::Kill { name } => submit_single(&coord, name, Verb::Kill).await, - HostRequest::Restart { name } => submit_single(&coord, name, Verb::Restart).await, + HostRequest::Kill { name } => submit_single(&coord, name.as_str(), Verb::Kill).await, + HostRequest::Restart { name } => { + submit_single(&coord, name.as_str(), Verb::Restart).await + } HostRequest::RestartAll => handle_restart_all(&coord).await?, HostRequest::RestartScoped { scope, graceful } => { handle_restart_scoped(&coord, scope, *graceful).await? @@ -140,10 +142,12 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { handle_start(&coord, &agents, &infra).await? } HostRequest::Destroy { name, purge } => { - actions::destroy(&coord, name, *purge).await?; + actions::destroy(&coord, name.as_str(), *purge).await?; HostResponse::success() } - HostRequest::Rebuild { name } => submit_single(&coord, name, Verb::Rebuild).await, + HostRequest::Rebuild { name } => { + submit_single(&coord, name.as_str(), Verb::Rebuild).await + } HostRequest::QueueDag { id } => { // A multi-step op is one DAG now (no fan-out children to gather). let dags = coord @@ -179,7 +183,10 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { // skip both the messages and the disk write per the // topology fast-path. coord - .reparent_with_notify(child, new_parent.as_deref()) + .reparent_with_notify( + child.as_str(), + new_parent.as_ref().map(hive_types::Ident::as_str), + ) .await .map_err(anyhow::Error::msg)?; HostResponse::success() @@ -188,8 +195,12 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { handle_matrix_create_user(name, password.as_deref()).await? } HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?, - HostRequest::MatrixPromoteUser { name } => handle_matrix_promote_user(name).await?, - HostRequest::MatrixResetPassword { name } => handle_matrix_reset_password(name).await?, + HostRequest::MatrixPromoteUser { name } => { + handle_matrix_promote_user(name.as_str()).await? + } + HostRequest::MatrixResetPassword { name } => { + handle_matrix_reset_password(name.as_str()).await? + } HostRequest::MatrixInvite { user, room } => { handle_matrix_invite(user, room.as_deref()).await? } @@ -197,10 +208,10 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { handle_forge_create_user(name, password.as_deref()).await? } HostRequest::ReconcileConfigStatus { agent, verbose } => { - crate::forge::reconcile_config_status(agent, *verbose).await? + crate::forge::reconcile_config_status(agent.as_str(), *verbose).await? } HostRequest::ReconcileConfigApply { agent, direction } => { - crate::forge::reconcile_config_apply(agent, *direction).await? + crate::forge::reconcile_config_apply(agent.as_str(), *direction).await? } HostRequest::GatewayCreateUser { username, password } => { HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?]) @@ -212,24 +223,30 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::messages(crate::gateway_nginx::list_users()?) } HostRequest::SetAgentGithubToken { agent, token } => { - handle_set_agent_github_token(agent, token).await? + handle_set_agent_github_token(agent.as_str(), token).await? } HostRequest::QuotaEnable => handle_quota_enable().await?, - HostRequest::QuotaLimit { name, limit } => handle_quota_limit(name, *limit).await?, - HostRequest::QuotaShow { name } => handle_quota_show(name.as_deref()).await?, - HostRequest::UpgradeSubvolume { name } => handle_upgrade_subvolume(name).await?, + HostRequest::QuotaLimit { name, limit } => { + handle_quota_limit(name.as_str(), *limit).await? + } + HostRequest::QuotaShow { name } => { + handle_quota_show(name.as_ref().map(hive_types::Ident::as_str)).await? + } + HostRequest::UpgradeSubvolume { name } => { + handle_upgrade_subvolume(name.as_str()).await? + } HostRequest::SnapshotSubvolume { name, label } => { - handle_snapshot_subvolume(name, label).await? + handle_snapshot_subvolume(name.as_str(), label).await? } HostRequest::DeleteSnapshot { name, label } => { - handle_delete_snapshot(name, label).await? + handle_delete_snapshot(name.as_str(), label).await? } HostRequest::SendSnapshot { name, label, parent, dest, - } => handle_send_snapshot(name, label, parent.as_deref(), dest).await?, + } => handle_send_snapshot(name.as_str(), label, parent.as_deref(), dest).await?, }) } .await; @@ -320,10 +337,8 @@ fn matrix_http_client() -> Result { /// True when `name` has a state dir under the agents root, i.e. it's a /// managed agent rather than a bare (operator/human) matrix account. -fn agent_exists(name: &str) -> Result { - let name = hive_host_sock::Ident::parse(name) - .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; - crate::paths::agent_state_dir(&name) +fn agent_exists(name: &hive_types::Ident) -> Result { + crate::paths::agent_state_dir(name) .try_exists() .with_context(|| format!("check agent state dir for {name}")) } @@ -338,7 +353,10 @@ async fn require_matrix_present() -> Result<()> { ) } -async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result { +async fn handle_matrix_create_user( + name: &hive_types::Ident, + password: Option<&str>, +) -> Result { require_matrix_present().await?; let register_token = crate::matrix::ensure_register_token().context("read matrix register token")?; @@ -353,12 +371,10 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result "matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" ); } - crate::matrix::ensure_user_for(&client, name, ®ister_token) + crate::matrix::ensure_user_for(&client, name.as_str(), ®ister_token) .await .with_context(|| format!("matrix create-user {name}"))?; - let agent = hive_host_sock::Ident::parse(name) - .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; - let path = Coordinator::agent_notes_dir(&agent).join("matrix-token"); + let path = Coordinator::agent_notes_dir(name).join("matrix-token"); out.push(format!("matrix: provisioned agent user '{name}'")); out.push(format!("token persisted at: {}", path.display())); } else { @@ -368,7 +384,7 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result }; let token = crate::matrix::provision_user_token( &client, - name, + name.as_str(), ®ister_token, &effective_password, ) @@ -391,7 +407,10 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result Ok(HostResponse::messages(out)) } -async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result { +async fn handle_forge_create_user( + name: &hive_types::Ident, + password: Option<&str>, +) -> Result { if !crate::forge::is_present().await { anyhow::bail!( "hive-forge container not running — wait for hive-c0re to start it before provisioning forge users" @@ -406,16 +425,14 @@ async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result< "forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token" ); } - crate::forge::ensure_user_for(name) + crate::forge::ensure_user_for(name.as_str()) .await .with_context(|| format!("forge create-user {name}"))?; - let agent = hive_host_sock::Ident::parse(name) - .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; - let path = Coordinator::agent_notes_dir(&agent).join("forge-token"); + let path = Coordinator::agent_notes_dir(name).join("forge-token"); out.push(format!("forge: provisioned agent user '{name}'")); out.push(format!("token persisted at: {}", path.display())); } else { - let token = crate::forge::provision_user_token(name, password) + let token = crate::forge::provision_user_token(name.as_str(), password) .await .with_context(|| format!("forge create-user {name}"))?; out.push(format!( @@ -467,7 +484,7 @@ async fn handle_quota_show(name: Option<&str>) -> Result { Some(n) => vec![n.to_owned()], None => Coordinator::kept_state_names() .into_iter() - .map(hive_host_sock::Ident::into_string) + .map(hive_types::Ident::into_string) .collect(), }; let mut rows = Vec::with_capacity(agents.len()); diff --git a/hive-c0re/src/socket_server/config_approvals.rs b/hive-c0re/src/socket_server/config_approvals.rs index c52f5ce7..e852892c 100644 --- a/hive-c0re/src/socket_server/config_approvals.rs +++ b/hive-c0re/src/socket_server/config_approvals.rs @@ -195,7 +195,7 @@ pub(crate) fn submit_init_config( parent: Option<&str>, description: Option, ) -> anyhow::Result { - let agent = hive_host_sock::Ident::parse(name) + let agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent); if proposed_dir.join(".git").exists() { diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 152a5397..86b0a6a7 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -425,7 +425,7 @@ async fn handle_get_agent_meta( // the OS level. Validate it before any path is built. The `None` default // (`target == agent`) is the caller's own authenticated name, already // valid — but validating unconditionally is simplest and harmless. - let target_id = match hive_host_sock::Ident::parse(target) { + let target_id = match hive_types::Ident::parse(target) { Ok(id) => id, Err(reason) => { return hive_agent_sock::Response::Err { @@ -461,7 +461,7 @@ async fn handle_get_agent_meta( /// or the daemon not up yet) yields an empty list. The `MatrixIdentity` /// serde shape matches the snapshot entries; the snapshot's `live` field is /// ignored (only live accounts are written). -fn read_agent_matrix_identities(agent: &hive_host_sock::Ident) -> Vec { +fn read_agent_matrix_identities(agent: &hive_types::Ident) -> Vec { let path = Coordinator::agent_notes_dir(agent).join("matrix-accounts.json"); std::fs::read_to_string(&path) .ok() @@ -751,7 +751,7 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option { /// `submit_init_config`, which builds filesystem paths from it, so validate /// before that. fn require_new_child(agent: &str, target: &str, action: &str) -> Option { - if let Err(reason) = hive_host_sock::Ident::parse(target) { + if let Err(reason) = hive_types::Ident::parse(target) { return Some(Response::Err { message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"), }); @@ -1118,7 +1118,7 @@ pub(crate) fn handle_send( // A name that doesn't parse as an Ident can't be a local agent, so // it collapses into the same "unknown recipient" error as a valid // name with no state dir. - let exists = hive_host_sock::Ident::parse(&resolved) + let exists = hive_types::Ident::parse(&resolved) .is_ok_and(|id| crate::paths::agent_state_dir(&id).exists()); if !exists { return Response::Err { diff --git a/hive-c0re/src/stats/container_stats.rs b/hive-c0re/src/stats/container_stats.rs index ccf7cca5..b9fe1b9a 100644 --- a/hive-c0re/src/stats/container_stats.rs +++ b/hive-c0re/src/stats/container_stats.rs @@ -115,7 +115,7 @@ async fn du_bytes(path: &std::path::Path) -> Option { /// agent's state-dir contribution was 0; with the writable rootfs nearly empty /// (almost everything is bind-mounted), that surfaced as all agents reporting /// 0 disk. -async fn measure_agent_disk(name: &hive_host_sock::Ident) -> u64 { +async fn measure_agent_disk(name: &hive_types::Ident) -> u64 { let state_dir = Coordinator::agent_notes_dir(name); let rootfs = PathBuf::from(format!("{NIXOS_CONTAINERS_ROOT}/h-{name}")); let mut total = 0u64; diff --git a/hive-c0re/src/workers/crash_watch.rs b/hive-c0re/src/workers/crash_watch.rs index 19166278..49111296 100644 --- a/hive-c0re/src/workers/crash_watch.rs +++ b/hive-c0re/src/workers/crash_watch.rs @@ -41,7 +41,7 @@ pub fn spawn(coord: Arc) { if lifecycle::is_running(&logical).await { current_running.insert(logical.clone()); } - if hive_host_sock::Ident::parse(&logical) + if hive_types::Ident::parse(&logical) .is_ok_and(|id| claude_has_session(&Coordinator::agent_claude_dir(&id))) { current_logged_in.insert(logical.clone()); diff --git a/hive-c0re/src/workers/reminder_scheduler.rs b/hive-c0re/src/workers/reminder_scheduler.rs index a0da2317..f05f8d92 100644 --- a/hive-c0re/src/workers/reminder_scheduler.rs +++ b/hive-c0re/src/workers/reminder_scheduler.rs @@ -133,7 +133,7 @@ fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String { /// inline-falls-back). `pub` because `socket_server::handle_remind` /// reuses it for the at-remind-time auto-file path. pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> { - let agent = hive_host_sock::Ident::parse(agent) + let agent = hive_types::Ident::parse(agent) .map_err(|e| format!("invalid agent name {agent:?}: {e}"))?; let Some(parent) = host_path.parent() else { return Err("internal: host path has no parent".to_owned()); @@ -191,7 +191,7 @@ pub fn container_state_prefix(agent: &str) -> String { /// reason string on rejection. `pub` so `socket_server::handle_remind` /// can reuse it for the at-remind-time auto-file path. pub fn resolve_host_path(agent: &str, req_path: &str) -> Result { - let agent = hive_host_sock::Ident::parse(agent) + let agent = hive_types::Ident::parse(agent) .map_err(|e| format!("invalid agent name {agent:?}: {e}"))?; let prefix = container_state_prefix(agent.as_str()); let Some(rel) = req_path.strip_prefix(&prefix) else { diff --git a/hive-host-sock/Cargo.toml b/hive-host-sock/Cargo.toml index 24152da4..b39f0f9e 100644 --- a/hive-host-sock/Cargo.toml +++ b/hive-host-sock/Cargo.toml @@ -8,7 +8,5 @@ workspace = true [dependencies] hive-sh4re.workspace = true +hive-types.workspace = true serde.workspace = true - -[dev-dependencies] -serde_json.workspace = true diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 46d3ae58..1b5ffcdd 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use hive_sh4re::{AgentStatusRow, Approval, jobs}; +use hive_types::Ident; use serde::{Deserialize, Serialize}; // ── Shared hive layout facts ────────────────────────────────────────────── @@ -55,152 +56,6 @@ pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } -/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`. -/// -/// The single ident type for agent names, forge labels, and matrix / github -/// account names — every value that becomes a filesystem path segment or an -/// nspawn machine-name component. Constructed only through the validating -/// [`Ident::parse`], so "this string passed the naming whitelist" is a fact -/// the type carries instead of a convention every call site re-checks against -/// a raw `String`. The charset is deliberately conservative — lowercase -/// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) — -/// and length-capped, tracking `nixos-container` basename rules and keeping -/// `../` traversal, unicode homoglyphs, and unbounded path segments out of -/// any path built from it. Deserialization runs the same parse, so a value -/// arriving over the wire is validated on the way in. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Ident(String); - -impl Ident { - /// Maximum length in bytes. A cap stops an unbounded operator-supplied - /// name from becoming an over-long path segment (a filesystem / `DoS` - /// footgun). - pub const MAX_LEN: usize = 63; - - /// Parse + validate an identifier. - /// - /// # Errors - /// Returns `Err(reason)` — a caller-ready message — when `s` is empty, - /// longer than [`Ident::MAX_LEN`], or contains any byte outside - /// `[a-z0-9-]`. - pub fn parse(s: &str) -> Result { - if s.is_empty() { - return Err("identifier must not be empty"); - } - if s.len() > Self::MAX_LEN { - return Err("identifier must be 63 characters or fewer"); - } - if !s - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - { - return Err("identifier must contain only [a-z0-9-]"); - } - Ok(Self(s.to_owned())) - } - - /// The validated identifier as a string slice. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Consume into the inner `String`. - #[must_use] - pub fn into_string(self) -> String { - self.0 - } -} - -impl std::fmt::Display for Ident { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl AsRef for Ident { - fn as_ref(&self) -> &str { - &self.0 - } -} - -/// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`. -impl std::borrow::Borrow for Ident { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl serde::Serialize for Ident { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.0) - } -} - -impl<'de> serde::Deserialize<'de> for Ident { - fn deserialize>(deserializer: D) -> Result { - use serde::de::Error as _; - let s = String::deserialize(deserializer)?; - Ident::parse(&s).map_err(D::Error::custom) - } -} - -#[cfg(test)] -mod ident_tests { - use super::Ident; - - #[test] - fn accepts_canonical_shapes() { - for ok in [ - "damocles", - "hm1nd", - "agent-with-dashes", - "codeberg", - "acct-1", - ] { - assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}"); - } - assert!( - Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(), - "63 chars is the boundary" - ); - } - - #[test] - fn rejects_bad_input() { - let too_long = "a".repeat(Ident::MAX_LEN + 1); - for bad in [ - "", - &too_long, - "Alice", // uppercase - "snake_case", // underscore (tightened out) - "alice.bob", // dot - "alice/bob", // slash - "../etc/passwd", // traversal - "damóclès", // non-ASCII - "alice\u{2013}b", // en-dash homoglyph - ] { - assert!(Ident::parse(bad).is_err(), "should reject {bad:?}"); - } - } - - #[test] - fn round_trips_and_serde_validates() { - let id = Ident::parse("damocles").unwrap(); - assert_eq!(id.as_str(), "damocles"); - // Serialize is transparent (just the inner string). - let json = serde_json::to_string(&id).unwrap(); - assert_eq!(json, "\"damocles\""); - // Deserialize runs the same parse. - let back: Ident = serde_json::from_str(&json).unwrap(); - assert_eq!(back, id); - assert!( - serde_json::from_str::("\"BAD_NAME\"").is_err(), - "deserialize must reject an invalid ident" - ); - } -} - /// Which way to reconcile an agent's config branches /// ([`HostRequest::ReconcileConfigApply`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -221,23 +76,23 @@ pub enum HostRequest { /// Create and start a sub-agent container directly, bypassing the /// approval queue. Privileged-context only. See /// `docs/approvals.md::Approval kinds (wire shapes)`. - Spawn { name: String }, + Spawn { name: Ident }, /// Submit a spawn request for the operator to approve. See /// `docs/approvals.md::Approval kinds (wire shapes)` (`Spawn`). - RequestSpawn { name: String }, + RequestSpawn { name: Ident }, /// Stop a managed container (graceful). - Kill { name: String }, + Kill { name: Ident }, /// Tear down a sub-agent container, optionally purging state. /// See `docs/approvals.md::Destroy semantics`. Destroy { - name: String, + name: Ident, #[serde(default)] purge: bool, }, /// Stop and start a managed container without rebuilding config. /// For "kick the container" operations that don't touch the flake or /// nspawn flags. Mirrors `lifecycle::restart` (kill + start). - Restart { name: String }, + Restart { name: Ident }, /// Stop and restart all managed containers in sequence. Convenience /// wrapper for `hivectl agents restart-all`; iterates the live /// container list and restarts each one. @@ -265,7 +120,7 @@ pub enum HostRequest { graceful: bool, }, /// Apply pending config to a managed container. - Rebuild { name: String }, + Rebuild { name: Ident }, /// List managed containers. List, /// List managed agents with their full status + technical state @@ -296,8 +151,8 @@ pub enum HostRequest { /// Validation rules + bind-mount caveat documented in /// `docs/agent-hierarchy.md::Current state`. SetParent { - child: String, - new_parent: Option, + child: Ident, + new_parent: Option, }, /// Stop managed containers hive-wide in one operator action /// (`hivectl stop`): agents plus the selected infra containers. `scope` @@ -327,7 +182,7 @@ pub enum HostRequest { /// [`HostResponse::messages`]. `password` is resolved by the client /// (inline flag or stdin) and `None` requests a random throwaway. MatrixCreateUser { - name: String, + name: Ident, #[serde(default)] password: Option, }, @@ -337,11 +192,11 @@ pub enum HostRequest { /// Promote a matrix user to homeserver admin via the admin API. /// Uses the daemon's system admin token; `server_name` is discovered /// from the running homeserver. - MatrixPromoteUser { name: String }, + MatrixPromoteUser { name: Ident }, /// Reset a matrix user's password via the admin API and persist the /// new password to the matrix creds dir so a later token mint can /// re-login. Returns the outcome in [`HostResponse::messages`]. - MatrixResetPassword { name: String }, + MatrixResetPassword { name: Ident }, /// Invite a matrix user to the hive Space (default) or a specific /// `room`. Uses the daemon's admin token; idempotent /// (already-member / already-invited is a no-op). @@ -357,7 +212,7 @@ pub enum HostRequest { /// in [`HostResponse::messages`]. `password` is resolved client-side /// (inline flag or stdin) and only meaningful for non-agent accounts. ForgeCreateUser { - name: String, + name: Ident, #[serde(default)] password: Option, }, @@ -369,7 +224,7 @@ pub enum HostRequest { /// — never mutates either side. Backs `hivectl forge reconcile-config` /// (the diff it always shows first). ReconcileConfigStatus { - agent: String, + agent: Ident, #[serde(default)] verbose: bool, }, @@ -380,7 +235,7 @@ pub enum HostRequest { /// local needs lifting branch protection — resolve via a config PR). /// Backs `hivectl forge reconcile-config --from `. ReconcileConfigApply { - agent: String, + agent: Ident, direction: ReconcileDirection, }, /// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file @@ -403,7 +258,7 @@ pub enum HostRequest { /// set-token`. `token` is resolved + non-empty-validated client-side /// (inline flag or stdin); the daemon just persists it. Read live by /// the agent's `gh` wrapper / git credential helper — no rebuild needed. - SetAgentGithubToken { agent: String, token: String }, + SetAgentGithubToken { agent: Ident, token: String }, /// Turn on btrfs qgroup accounting on the agent-state filesystem, via /// the privileged helper. Daemon-side equivalent of `hivectl quota /// enable`. Returns advisory lines in [`HostResponse::messages`]. @@ -414,7 +269,7 @@ pub enum HostRequest { /// bare success — the client prints the confirmation from the value it /// sent. QuotaLimit { - name: String, + name: Ident, #[serde(default)] limit: Option, }, @@ -426,27 +281,27 @@ pub enum HostRequest { /// plain [`HostResponse::error`] so the client can print the enable hint. QuotaShow { #[serde(default)] - name: Option, + name: Option, }, /// Migrate an agent's plain state dir to a btrfs subvolume via the /// privileged helper (`hivectl subvol upgrade`). The agent MUST already /// be stopped — the client orchestrates stop → this → start. Returns a /// bare success; the client prints its own progress lines. - UpgradeSubvolume { name: String }, + UpgradeSubvolume { name: Ident }, /// Create a read-only btrfs snapshot of an agent's state subvolume /// (`hivectl subvol snapshot create`). `label` is validated client-side /// AND by hive-priv. Returns the snapshot's host path in /// [`HostResponse::messages`]. - SnapshotSubvolume { name: String, label: String }, + SnapshotSubvolume { name: Ident, label: String }, /// Delete a snapshot created by `SnapshotSubvolume` (`hivectl subvol /// snapshot delete`). Bare success; the client prints the confirmation. - DeleteSnapshot { name: String, label: String }, + DeleteSnapshot { name: Ident, label: String }, /// Export a snapshot to a local file via `btrfs send` (`hivectl subvol /// snapshot send`). `dest` is a bare filename (hive-priv rejects paths); /// `parent` names an optional parent snapshot for an incremental send. /// Returns the written file's host path in [`HostResponse::messages`]. SendSnapshot { - name: String, + name: Ident, label: String, #[serde(default)] parent: Option, diff --git a/hive-types/Cargo.toml b/hive-types/Cargo.toml new file mode 100644 index 00000000..d472adb5 --- /dev/null +++ b/hive-types/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "hive-types" +edition.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/hive-types/src/lib.rs b/hive-types/src/lib.rs new file mode 100644 index 00000000..c6b6a536 --- /dev/null +++ b/hive-types/src/lib.rs @@ -0,0 +1,153 @@ +//! Foundational shared newtypes for the hyperhive workspace. +//! +//! A zero-dependency (bar `serde`) leaf crate so every wire-type crate +//! (`hive-sh4re`, `hive-host-sock`, `hive-agent-sock`) and both binaries +//! (`hive-c0re`, `hivectl`) can type their agent-name fields as [`Ident`] +//! and get serde-validated parsing at the socket boundary for free — with +//! no cross-crate coupling and without growing `hive-sh4re`. + +/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`. +/// +/// The single ident type for agent names, forge labels, and matrix / github +/// account names — every value that becomes a filesystem path segment or an +/// nspawn machine-name component. Constructed only through the validating +/// [`Ident::parse`], so "this string passed the naming whitelist" is a fact +/// the type carries instead of a convention every call site re-checks against +/// a raw `String`. The charset is deliberately conservative — lowercase +/// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) — +/// and length-capped, tracking `nixos-container` basename rules and keeping +/// `../` traversal, unicode homoglyphs, and unbounded path segments out of +/// any path built from it. Deserialization runs the same parse, so a value +/// arriving over the wire is validated on the way in. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Ident(String); + +impl Ident { + /// Maximum length in bytes. A cap stops an unbounded operator-supplied + /// name from becoming an over-long path segment (a filesystem / `DoS` + /// footgun). + pub const MAX_LEN: usize = 63; + + /// Parse + validate an identifier. + /// + /// # Errors + /// Returns `Err(reason)` — a caller-ready message — when `s` is empty, + /// longer than [`Ident::MAX_LEN`], or contains any byte outside + /// `[a-z0-9-]`. + pub fn parse(s: &str) -> Result { + if s.is_empty() { + return Err("identifier must not be empty"); + } + if s.len() > Self::MAX_LEN { + return Err("identifier must be 63 characters or fewer"); + } + if !s + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err("identifier must contain only [a-z0-9-]"); + } + Ok(Self(s.to_owned())) + } + + /// The validated identifier as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume into the inner `String`. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl std::fmt::Display for Ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for Ident { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`. +impl std::borrow::Borrow for Ident { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl serde::Serialize for Ident { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for Ident { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error as _; + let s = String::deserialize(deserializer)?; + Ident::parse(&s).map_err(D::Error::custom) + } +} + +#[cfg(test)] +mod ident_tests { + use super::Ident; + + #[test] + fn accepts_canonical_shapes() { + for ok in [ + "damocles", + "hm1nd", + "agent-with-dashes", + "codeberg", + "acct-1", + ] { + assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}"); + } + assert!( + Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(), + "63 chars is the boundary" + ); + } + + #[test] + fn rejects_bad_input() { + let too_long = "a".repeat(Ident::MAX_LEN + 1); + for bad in [ + "", + &too_long, + "Alice", // uppercase + "snake_case", // underscore (tightened out) + "alice.bob", // dot + "alice/bob", // slash + "../etc/passwd", // traversal + "damóclès", // non-ASCII + "alice\u{2013}b", // en-dash homoglyph + ] { + assert!(Ident::parse(bad).is_err(), "should reject {bad:?}"); + } + } + + #[test] + fn round_trips_and_serde_validates() { + let id = Ident::parse("damocles").unwrap(); + assert_eq!(id.as_str(), "damocles"); + // Serialize is transparent (just the inner string). + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"damocles\""); + // Deserialize runs the same parse. + let back: Ident = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + assert!( + serde_json::from_str::("\"BAD_NAME\"").is_err(), + "deserialize must reject an invalid ident" + ); + } +} diff --git a/hivectl/Cargo.toml b/hivectl/Cargo.toml index 5107a6e3..aad05e71 100644 --- a/hivectl/Cargo.toml +++ b/hivectl/Cargo.toml @@ -17,6 +17,7 @@ clap_complete.workspace = true clap-markdown = "0.1" hive-host-sock.workspace = true hive-sh4re.workspace = true +hive-types.workspace = true indicatif.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs index 7c602acf..16fa73d6 100644 --- a/hivectl/src/agents.rs +++ b/hivectl/src/agents.rs @@ -14,7 +14,7 @@ async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> let resp = crate::client::request( socket, hive_host_sock::HostRequest::Restart { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, }, ) .await @@ -135,18 +135,23 @@ pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> { AgentsCmd::Restart { name, no_wait } => agents_restart(socket, &name, no_wait).await, AgentsCmd::RestartAll { no_wait } => agents_restart_all(socket, no_wait).await, AgentsCmd::Spawn { name } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::Spawn { name }).await?) } AgentsCmd::RequestSpawn { name } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::RequestSpawn { name }).await?) } AgentsCmd::Kill { name } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::Kill { name }).await?) } AgentsCmd::Destroy { name, purge } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::Destroy { name, purge }).await?) } AgentsCmd::Rebuild { name } => { + let name = crate::util::parse_ident(&name)?; render(crate::client::request(socket, HostRequest::Rebuild { name }).await?) } AgentsCmd::SetParent { @@ -154,7 +159,12 @@ pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> { parent, root, } => { - let new_parent = if root { None } else { parent }; + let child = crate::util::parse_ident(&child)?; + let new_parent = if root { + None + } else { + parent.map(|p| crate::util::parse_ident(&p)).transpose()? + }; render( crate::client::request(socket, HostRequest::SetParent { child, new_parent }) .await?, diff --git a/hivectl/src/forge.rs b/hivectl/src/forge.rs index 9f8128fe..bebad895 100644 --- a/hivectl/src/forge.rs +++ b/hivectl/src/forge.rs @@ -25,7 +25,7 @@ pub(crate) async fn forge_create_user( daemon_request( socket, hive_host_sock::HostRequest::ForgeCreateUser { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, password, }, "forge", @@ -45,7 +45,7 @@ pub(crate) async fn forge_reconcile_config( daemon_request( socket, HostRequest::ReconcileConfigStatus { - agent: agent.to_owned(), + agent: crate::util::parse_ident(agent)?, verbose, }, "forge", @@ -62,7 +62,7 @@ pub(crate) async fn forge_reconcile_config( daemon_request( socket, HostRequest::ReconcileConfigApply { - agent: agent.to_owned(), + agent: crate::util::parse_ident(agent)?, direction, }, "forge", diff --git a/hivectl/src/github.rs b/hivectl/src/github.rs index ef296b1b..42ee792b 100644 --- a/hivectl/src/github.rs +++ b/hivectl/src/github.rs @@ -39,7 +39,7 @@ pub(crate) async fn github_set_token( daemon_request( socket, hive_host_sock::HostRequest::SetAgentGithubToken { - agent: agent.to_owned(), + agent: crate::util::parse_ident(agent)?, token, }, "github", diff --git a/hivectl/src/matrix.rs b/hivectl/src/matrix.rs index ca02cfdc..a75ce7bf 100644 --- a/hivectl/src/matrix.rs +++ b/hivectl/src/matrix.rs @@ -59,7 +59,7 @@ async fn matrix_create_user( matrix_request( socket, hive_host_sock::HostRequest::MatrixCreateUser { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, password, }, ) @@ -74,7 +74,7 @@ async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> { matrix_request( socket, hive_host_sock::HostRequest::MatrixPromoteUser { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, }, ) .await @@ -95,7 +95,7 @@ async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> { matrix_request( socket, hive_host_sock::HostRequest::MatrixResetPassword { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, }, ) .await diff --git a/hivectl/src/quota.rs b/hivectl/src/quota.rs index f8cb30c4..15e5546d 100644 --- a/hivectl/src/quota.rs +++ b/hivectl/src/quota.rs @@ -20,7 +20,7 @@ pub(crate) async fn quota_show(socket: &Path, name: Option<&str>) -> Result<()> let resp = crate::client::request( socket, hive_host_sock::HostRequest::QuotaShow { - name: name.map(str::to_owned), + name: name.map(crate::util::parse_ident).transpose()?, }, ) .await @@ -60,7 +60,7 @@ pub(crate) async fn quota_limit(socket: &Path, name: &str, size: &str) -> Result daemon_request( socket, hive_host_sock::HostRequest::QuotaLimit { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, limit, }, "quota", diff --git a/hivectl/src/subvol.rs b/hivectl/src/subvol.rs index eb4c4036..202d1aff 100644 --- a/hivectl/src/subvol.rs +++ b/hivectl/src/subvol.rs @@ -89,7 +89,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> { let upgrade = daemon_request( socket, hive_host_sock::HostRequest::UpgradeSubvolume { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, }, "upgrade", ) @@ -170,7 +170,7 @@ async fn subvol_snapshot_create(socket: &Path, name: &str, label: String) -> Res daemon_request( socket, hive_host_sock::HostRequest::SnapshotSubvolume { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, label, }, "snapshot", @@ -184,7 +184,7 @@ async fn subvol_snapshot_delete(socket: &Path, name: &str, label: &str) -> Resul daemon_request( socket, hive_host_sock::HostRequest::DeleteSnapshot { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, label: label.to_owned(), }, "snapshot delete", @@ -211,7 +211,7 @@ async fn subvol_snapshot_send( daemon_request( socket, hive_host_sock::HostRequest::SendSnapshot { - name: name.to_owned(), + name: crate::util::parse_ident(name)?, label: label.to_owned(), parent: parent.map(str::to_owned), dest: dest.to_owned(), diff --git a/hivectl/src/util.rs b/hivectl/src/util.rs index a1d33dc7..ceec313a 100644 --- a/hivectl/src/util.rs +++ b/hivectl/src/util.rs @@ -6,6 +6,16 @@ use std::path::Path; use anyhow::{Context as _, Result, bail}; +/// Parse a CLI-supplied agent/account name into a validated +/// [`hive_types::Ident`], mapping the parse error to an `anyhow` error that +/// names the offending input. Used at hivectl's `HostRequest` construction +/// sites so the wire `Ident` fields are built from validated names (the daemon +/// re-validates on deserialize; parsing here gives the operator an immediate, +/// local error instead of a round-trip rejection). +pub(crate) fn parse_ident(name: &str) -> Result { + hive_types::Ident::parse(name).map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}")) +} + /// Send a provisioning request to the daemon and print its result lines. /// The daemon owns the provisioning logic; hivectl just relays the outcome, /// prefixing any error with `label` (e.g. `forge` / `github`). @@ -115,7 +125,7 @@ pub(crate) async fn query_hive_urls(socket: &Path) -> Option Result { - let Ok(name) = hive_host_sock::Ident::parse(name) else { + let Ok(name) = hive_types::Ident::parse(name) else { bail!("invalid agent name {name:?}"); }; let root = hive_host_sock::agent_state_dir(&name);