diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 6c33ffea..edfcdfa8 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -102,6 +102,51 @@ impl From for SocketReply { } } +/// Write (or remove) the status file in the agent's own `state/` directory. +/// Called by both `AgentServer::set_status` and `ManagerServer::set_status` +/// before dispatching the wire `SetStatus` request (which only triggers a +/// dashboard rescan on the host side — file I/O moved here because the +/// harness runs as the agent user and has write access to `state/`, whereas +/// hive-c0re's `hive-core` user does not after the privsep migration). +/// +/// Mirrors the validation in `hive-c0re::limits::check_status_text` so +/// the file is never written with text the server would later reject (which +/// would leave a stale invalid entry on disk). +fn write_status_file(text: &str) -> Result<(), String> { + let trimmed = text.trim(); + if !trimmed.is_empty() { + if trimmed.contains('\n') || trimmed.contains('\r') { + return Err( + "set_status text must be a single line — write multi-line context to \ + a file under your state/ dir and reference that path from the chip instead" + .to_owned(), + ); + } + // 200 chars mirrors STATUS_MAX_CHARS in hive-c0re/src/limits.rs. + // Keep in sync if that constant changes. + const STATUS_MAX_CHARS: usize = 200; + let len = trimmed.chars().count(); + if len > STATUS_MAX_CHARS { + return Err(format!( + "set_status text too long ({len} chars, max {STATUS_MAX_CHARS}); trim to a short summary" + )); + } + } + let path = crate::paths::state_dir().join("hyperhive-status"); + let result = if trimmed.is_empty() { + std::fs::remove_file(&path).or_else(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + } + }) + } else { + std::fs::write(&path, format!("{trimmed}\n")) + }; + result.map_err(|e| format!("set_status write failed: {e}")) +} + /// Format helper for "send-like" tools (anything that expects an `Ok`). /// `tool` and `ok_msg` only appear in the result string; they don't change /// behavior. @@ -763,6 +808,9 @@ impl AgentServer { )] async fn set_status(&self, Parameters(args): Parameters) -> String { run_tool_envelope("set_status", args.text.clone(), async move { + if let Err(e) = write_status_file(&args.text) { + return e; + } let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::SetStatus { text: args.text }) .await; @@ -1821,6 +1869,9 @@ impl ManagerServer { )] async fn set_status(&self, Parameters(args): Parameters) -> String { run_tool_envelope("set_status", args.text.clone(), async move { + if let Err(e) = write_status_file(&args.text) { + return e; + } let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::SetStatus { text: args.text }) .await; diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 9dcf468e..3ff38bd9 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -239,29 +239,13 @@ pub(crate) async fn dispatch_shared( if let Err(message) = crate::limits::check_status_text(text) { return Some(hive_sh4re::Response::Err { message }); } - let path = - crate::coordinator::Coordinator::agent_notes_dir(agent).join("hyperhive-status"); - let result = if text.trim().is_empty() { - std::fs::remove_file(&path).or_else(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - Ok(()) - } else { - Err(e) - } - }) - } else { - std::fs::write(&path, format!("{}\n", text.trim())) - }; - match result { - Ok(()) => { - let coord2 = Arc::clone(coord); - tokio::spawn(async move { coord2.rescan_containers_and_emit().await }); - hive_sh4re::Response::Ok - } - Err(e) => hive_sh4re::Response::Err { - message: format!("set_status write failed: {e}"), - }, - } + // The harness writes the status file to its own `state/` dir + // before sending this request (it runs as the agent user, so + // it has write access). We just trigger a dashboard rescan so + // the new value is reflected immediately. + let coord2 = Arc::clone(coord); + tokio::spawn(async move { coord2.rescan_containers_and_emit().await }); + hive_sh4re::Response::Ok } hive_sh4re::Request::GetAgentMeta { name } => { let target = name.as_deref().unwrap_or(agent); diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index b327f8e9..e0ca2679 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -303,7 +303,17 @@ fn auth_failed_sentinel(name: &str) -> bool { pub fn read_agent_status(name: &str) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); - let s = std::fs::read_to_string(&path).ok(); + // Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte + // width per char, +2 for the trailing newline. Guards against a + // pathologically large file written outside the harness validation path. + let s = { + use std::io::Read as _; + std::fs::File::open(&path).ok().and_then(|f| { + let cap = (crate::limits::STATUS_MAX_CHARS * 4 + 2) as u64; + let mut buf = String::new(); + f.take(cap).read_to_string(&mut buf).ok().map(|_| buf) + }) + }; let text = s .as_deref() .map(str::trim) diff --git a/hive-c0re/src/limits.rs b/hive-c0re/src/limits.rs index 06b68a03..da61ac7a 100644 --- a/hive-c0re/src/limits.rs +++ b/hive-c0re/src/limits.rs @@ -48,6 +48,9 @@ pub fn check_size(label: &str, body: &str) -> Result<(), String> { /// payload. Cap at 200 chars to fit the chip plus a little /// descriptive padding without forcing the operator to read a /// scrolling chunk. +/// NOTE: `hive-ag3nt/src/mcp.rs::write_status_file` mirrors this constant +/// client-side so invalid text is caught before the file is written. +/// Keep in sync if this value changes. pub const STATUS_MAX_CHARS: usize = 200; /// Validate a `set_status` payload. Single-line + bounded so diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index ec51197a..7d4f0f75 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -441,8 +441,9 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] agent: Option, }, - /// Set a free-text status string visible on the dashboard. Persisted - /// to `{state_dir}/hyperhive-status` so it survives harness restarts. + /// Set a free-text status string visible on the dashboard. The harness + /// writes `{state_dir}/hyperhive-status` locally before sending this + /// request; hive-c0re just triggers a dashboard rescan on receipt. /// Pass an empty string to clear the status. SetStatus { text: String }, /// Fetch identity + status for an agent. `name = None` =