Compare commits
40 changed files with 332 additions and 547 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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:#}")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue