From cd3ba24c3d095d6a44305225579b08fe904ae659 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 3 Jun 2026 09:41:12 +0200 Subject: [PATCH] fix(#1112): build agent nav-links from disk; drop broken TCP proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_agent_links was proxying to http://127.0.0.1:{port}/api/state. Since all agents now bind exclusively to a unix socket (HIVE_WEB_SOCKET set unconditionally in harness-base.nix), the TCP fetch always fails silently, returning [] — so the nav-strip icons on every card disappear. Replace with container_view::build_nav_links(), which reconstructs the same link list from disk: - stats.html always (container kind) - /{name} and /agent-configs/{name} when forge-token is present (forge kind) - extras from hyperhive-dashboard-links.json (external kind) The GUI screen link is intentionally omitted — /etc/hyperhive/gui.json lives inside the agent container and is not host-visible. GUI agents are rare; the omission is acceptable until a host-visible marker lands. No new dependencies. reqwest is no longer used in dashboard.rs for this handler (still used by forge.rs and hivectl.rs). --- hive-c0re/src/container_view.rs | 51 +++++++++++++++++++++++++++++++++ hive-c0re/src/dashboard.rs | 46 +++++------------------------ 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 40d0e97e..f7879a9f 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -202,6 +202,57 @@ pub fn claude_has_session(dir: &Path) -> bool { .any(|e| e.file_type().is_ok_and(|t| t.is_file())) } +/// Build the navigation link list for an agent's dashboard card. +/// +/// Mirrors the logic in `hive_ag3nt::web_ui::agent_links` but runs on +/// the host via disk reads — no network call to the agent web UI needed. +/// The GUI screen link is omitted here; `/etc/hyperhive/gui.json` lives +/// inside the agent container and is not host-visible. +/// +/// Returns a `serde_json::Value` array matching the `AgentLink` JSON +/// shape the harness returns from `GET /api/state`, so +/// `dashboard::get_agent_links` can serve it directly. +pub fn build_nav_links(name: &str) -> serde_json::Value { + let state_dir = Coordinator::agent_notes_dir(name); + let mut links: Vec = Vec::new(); + + // Stats page — always present. + links.push(serde_json::json!({ + "url": "stats.html", + "icon": "📊", + "label": "stats", + "kind": "container", + })); + + // Forge profile + config mirror — only when the agent has a forge account. + if state_dir.join("forge-token").is_file() { + links.push(serde_json::json!({ + "url": format!("/{name}"), + "icon": "⬡", + "label": "forge", + "kind": "forge", + })); + links.push(serde_json::json!({ + "url": format!("/agent-configs/{name}"), + "icon": "↳", + "label": "config", + "kind": "forge", + })); + } + + // Agent-declared extras (absolute URLs — passed through verbatim). + for lnk in read_dashboard_links(name) { + links.push(serde_json::json!({ + "url": lnk.url, + "icon": lnk.icon, + "label": lnk.label, + "kind": "external", + })); + } + + serde_json::Value::Array(links) +} + /// Read agent-declared extra dashboard links from /// `{state_dir}/hyperhive-dashboard-links.json`. Returns an empty vec when /// the file is absent, empty, or unparseable — best-effort, never panics. diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 7c4a763d..9eaa60e0 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -26,7 +26,7 @@ use tokio_stream::{Stream, StreamExt}; use tower_http::services::ServeDir; use crate::actions; -use crate::container_view::{ContainerView, claude_has_session}; +use crate::container_view::{self, ContainerView, claude_has_session}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, MANAGER_NAME}; @@ -2122,47 +2122,15 @@ async fn post_schedule_cancel( /// Failure modes (agent down, slow response, malformed JSON) all /// degrade to an empty list so the dashboard still renders. async fn get_agent_links(AxumPath(name): AxumPath) -> Response { - // Format-only guard. GET routes return empty gracefully on - // unknown names; bad format we reject early so the downstream - // port-hash + HTTP fetch never sees garbage. + // Format-only guard. Unknown/malformed names get an empty list. if validate_agent_name(&name).is_some() { return axum::Json(serde_json::json!([])).into_response(); } - let port = lifecycle::agent_web_port(&name); - let url = format!("http://127.0.0.1:{port}/api/state"); - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(2)) - .build() - { - Ok(c) => c, - Err(e) => { - tracing::warn!(%name, error = %e, "agent-links proxy: client build failed"); - return axum::Json(serde_json::json!([])).into_response(); - } - }; - match client.get(&url).send().await { - Ok(resp) if resp.status().is_success() => match resp.json::().await { - Ok(body) => { - let links = body - .get("links") - .cloned() - .unwrap_or_else(|| serde_json::json!([])); - axum::Json(links).into_response() - } - Err(e) => { - tracing::debug!(%name, error = %e, "agent-links proxy: response parse failed"); - axum::Json(serde_json::json!([])).into_response() - } - }, - Ok(resp) => { - tracing::debug!(%name, status = %resp.status(), "agent-links proxy: non-2xx"); - axum::Json(serde_json::json!([])).into_response() - } - Err(e) => { - tracing::debug!(%name, error = %e, "agent-links proxy: fetch failed"); - axum::Json(serde_json::json!([])).into_response() - } - } + // Links are built from disk — no TCP call to the agent web UI. + // The old TCP proxy broke when all agents switched to unix-socket + // binding (HIVE_WEB_SOCKET). See container_view::build_nav_links + // for the full rationale. + axum::Json(container_view::build_nav_links(&name)).into_response() } async fn post_cancel_reminder(