Compare commits

..
40 changed files with 332 additions and 547 deletions

11
Cargo.lock generated
View file

@ -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",

View file

@ -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 = [

View file

@ -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

View file

@ -41,12 +41,9 @@ pub async fn approve(coord: Arc<Coordinator>, 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<Coordinator>, 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)
{

View file

@ -64,27 +64,20 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
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<ContainerView> {
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::<serde_json::Value>(&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<String>, Option<i64>) {
pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
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<String>, Option<i6
///
/// 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_types::Ident,
) -> (Option<String>, Option<i64>, bool) {
if !lifecycle::is_running(name.as_str()).await {
pub async fn read_agent_status_live(name: &str) -> (Option<String>, Option<i64>, 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<String> {
fn read_active_model(name: &str) -> Option<String> {
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()?;

View file

@ -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/<name>/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<hive_types::Ident> {
pub fn kept_state_names() -> Vec<String> {
let Ok(rd) = std::fs::read_dir(crate::paths::agents_root()) else {
return Vec::new();
};
let mut out: Vec<hive_types::Ident> = rd
let mut out: Vec<String> = 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<hive_types::Ident> {
pub fn pending_init_names() -> Vec<String> {
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()
}

View file

@ -66,12 +66,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
) {
return true;
}
let Ok(agent) = hive_types::Ident::parse(&a.agent) else {
let _ = coord.approvals.mark_failed(a.id, "invalid agent name");
tracing::warn!(id = a.id, agent = %a.agent, "auto-failed approval with invalid agent name");
return false;
};
if Coordinator::agent_proposed_dir(&agent).exists() {
if Coordinator::agent_proposed_dir(&a.agent).exists() {
true
} else {
let note = "agent state dir missing";

View file

@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
use tokio_stream::Stream;
use tokio_stream::wrappers::ReceiverStream;
use super::{AppState, Ident, error_response};
use super::{AppState, error_response, validate_agent_name};
#[derive(Deserialize)]
pub(super) struct BuildLogsAllQuery {
@ -58,18 +58,11 @@ pub(super) async fn get_build_logs_agent(
AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<BuildLogsQuery>,
) -> 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:#}")),
}

View file

@ -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<ExtraForgesQuery>) -> 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<ExtraForgeAccountForm>) -> 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<ExtraForgeAccountForm
let Some(token) = f.token.as_deref().filter(|t| !t.is_empty()) else {
return error_response("extra-forge-account: token is required");
};
if let Err(e) = crate::priv_client::write_agent_extra_forge_account(
agent.as_str(),
label.as_str(),
base_url,
token,
)
.await
if let Err(e) =
crate::priv_client::write_agent_extra_forge_account(agent, label, base_url, token)
.await
{
return error_response(&format!(
"extra-forge-account: write account failed: {e:#}"
@ -164,9 +171,7 @@ pub(super) async fn post_extra_forge_account(Form(f): Form<ExtraForgeAccountForm
tracing::info!(%agent, %label, "extra-forge-account: provisioned");
}
"remove" => {
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<ExtraForgeAccountForm
}
axum::Json(ExtraForgeAccountResult { ok: true }).into_response()
}
#[cfg(test)]
mod tests {
use super::is_plain_ident;
#[test]
fn is_plain_ident_matches_validate_name_chars() {
assert!(is_plain_ident("codeberg"));
assert!(is_plain_ident("my-forge-1"));
assert!(!is_plain_ident(""));
assert!(!is_plain_ident("MyForge"));
assert!(!is_plain_ident("my_forge"));
assert!(!is_plain_ident("../escape"));
assert!(!is_plain_ident("a/b"));
}
}

View file

@ -18,7 +18,7 @@ use serde::Deserialize;
use problem_details::ProblemDetails;
use super::{Ident, error_problem, strip_container_prefix};
use super::{error_problem, strip_container_prefix, validate_agent_name};
use crate::lifecycle;
#[derive(Deserialize)]
@ -57,16 +57,13 @@ pub(super) async fn get_journal(
// shellout below — the `lifecycle::list()` existence check would
// catch them anyway, but rejecting at the boundary keeps the
// failure mode crisp.
let name = match Ident::parse(&name) {
Ok(n) => 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) {

View file

@ -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<String> {
pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) -> 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-<account>` via hive-priv and
@ -182,15 +196,15 @@ pub(super) async fn post_matrix_account_login(Form(f): Form<MatrixLoginForm>) ->
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<MatrixLoginForm>) ->
}
};
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<GithubAccountForm>) -> 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<GithubAccountQuery>) -> 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<String, String>
#[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"));
}
}

View file

@ -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<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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()

View file

@ -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<tokio::net::TcpListener> {
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<tokio::net::TcpListener> {
/// 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<Response> {
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
}
}

View file

@ -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<String> =
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<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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");
}

View file

@ -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

View file

@ -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(());
}

View file

@ -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<BindMount>) {
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 {

View file

@ -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(());
}

View file

@ -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();

View file

@ -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<String> = agents.iter().map(|a| a.name.clone()).collect();
let pending: Vec<String> = 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()))?;

View file

@ -68,11 +68,11 @@ pub async fn run(coord: &Arc<Coordinator>) -> 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<Coordinator>) -> 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<Coordinator>) -> 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<Coordinator>) {
}
}
async fn enumerate_agents() -> Vec<hive_types::Ident> {
async fn enumerate_agents() -> Vec<String> {
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);

View file

@ -83,11 +83,11 @@ async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let result: anyhow::Result<HostResponse> = 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<Coordinator>) -> 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<Coordinator>) -> 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<Coordinator>) -> 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<Coordinator>) -> 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<Coordinator>) -> 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<Coordinator>) -> 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<reqwest::Client> {
/// 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<bool> {
fn agent_exists(name: &str) -> Result<bool> {
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<HostResponse> {
async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result<HostResponse> {
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(), &register_token)
crate::matrix::ensure_user_for(&client, name, &register_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,
&register_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<HostResponse> {
async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result<HostResponse> {
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<u64>) -> Result<HostRespon
async fn handle_quota_show(name: Option<&str>) -> Result<HostResponse> {
let agents: Vec<String> = 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 {

View file

@ -195,9 +195,7 @@ pub(crate) fn submit_init_config(
parent: Option<&str>,
description: Option<String>,
) -> anyhow::Result<i64> {
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 {} - \

View file

@ -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<hive_sh4re::MatrixIdentity> {
fn read_agent_matrix_identities(agent: &str) -> Vec<hive_sh4re::MatrixIdentity> {
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<Response> {
/// `submit_init_config`, which builds filesystem paths from it, so validate
/// before that.
fn require_new_child(agent: &str, target: &str, action: &str) -> Option<Response> {
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}` \

View file

@ -115,7 +115,7 @@ async fn du_bytes(path: &std::path::Path) -> Option<u64> {
/// 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<ContainerResource> {
.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();

View file

@ -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,

View file

@ -41,9 +41,7 @@ pub fn spawn(coord: Arc<Coordinator>) {
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());
}
}

View file

@ -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<PathBuf, String> {
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<PathBuf, String>
}
}
}
Ok(Coordinator::agent_notes_dir(&agent).join(rel_path))
Ok(Coordinator::agent_notes_dir(agent).join(rel_path))
}
#[cfg(test)]

View file

@ -8,5 +8,4 @@ workspace = true
[dependencies]
hive-sh4re.workspace = true
hive-types.workspace = true
serde.workspace = true

View file

@ -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/<name>` — 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<Ident>,
child: String,
new_parent: Option<String>,
},
/// 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<String>,
},
@ -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<String>,
},
@ -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 <forge|local>`.
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<u64>,
},
@ -281,27 +276,27 @@ pub enum HostRequest {
/// plain [`HostResponse::error`] so the client can print the enable hint.
QuotaShow {
#[serde(default)]
name: Option<Ident>,
name: Option<String>,
},
/// 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<String>,

View file

@ -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

View file

@ -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<Self, &'static str> {
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<str> 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<str> for Ident {
fn borrow(&self) -> &str {
&self.0
}
}
impl serde::Serialize for Ident {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> serde::Deserialize<'de> for Ident {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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::<Ident>("\"BAD_NAME\"").is_err(),
"deserialize must reject an invalid ident"
);
}
}

View file

@ -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

View file

@ -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?,

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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(),

View file

@ -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> {
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<hive_host_sock::Hiv
/// "needs root" error fixes that first-run footgun, where running a
/// privileged verb without sudo reported as a missing agent.
pub(crate) fn agent_exists(name: &str) -> Result<bool> {
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!(