//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell; //! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page //! state as JSON; the JS app renders. Live events stream on //! `/events/stream`. Action POSTs return either a //! 303 Redirect (for browsers that submit the form normally) or just //! 200 OK — the JS app re-fetches `/api/state` afterwards. //! //! Handlers are split by concern into the submodules below; this file owns the //! shared [`AppState`], the listener + router wiring in [`serve`], and a couple //! of small shared helpers ([`error_response`], [`SOCKET_FETCH_TIMEOUT`]). mod actions; mod auth; mod proxy; mod screen; mod state; mod stats; mod stream; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::{Context, Result}; use axum::{ Router, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, }; use backon::{ExponentialBuilder, Retryable}; use tower_http::services::ServeDir; use crate::events::Bus; use crate::login::LoginState; /// Deadline for broker-backed fetches on web-UI request paths. The /// page's critical fields (status, turn state, usage) are all /// in-memory; a busy or stalled hive-c0re must degrade the /// socket-backed extras (inbox rows, loose ends, reminder stats) /// instead of hanging the whole response — an unbounded await here is /// what let `/api/state` stall long enough to bork the terminal. const SOCKET_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); /// Live login state for the web UI. The harness updates this in place as it /// transitions between `NeedsLogin` and `Online`; the UI reads on each /// render. pub type LoginStateCell = Arc>; #[derive(Clone)] struct AppState { label: String, login: LoginStateCell, bus: Bus, socket: PathBuf, /// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup. /// `None` when unset (gui not enabled for this agent). gui_vnc_port: Option, /// Set by `post_cancel_turn` on a successful SIGINT; read-and-cleared /// by the serve loop's next `handle_turn` to prepend /// `hive_sh4re::inbox::INTERRUPTED_HINT` to that turn's wake prompt. Shared /// with the serve loop via the same `Arc` (see `serve_main`) — an /// in-memory flag, not a marker file, since a `/cancel` from a prior /// process lifetime isn't meaningful once the harness restarts. interrupted: Arc, } /// The API routes, without the extra proxies and the static fallback. fn routes() -> Router { Router::new() .route("/api/state", get(state::api_state)) .route("/api/dashboard-state", get(state::api_dashboard_state)) .route("/events/stream", get(stream::events_stream)) .route("/events/history", get(stream::events_history)) .route("/api/cancel", post(actions::post_cancel_turn)) .route("/api/compact", post(actions::post_compact)) .route("/api/new-session", post(actions::post_new_session)) .route("/api/logout", post(auth::post_logout)) .route("/api/todos", get(stats::api_todos)) .route("/api/todos/mark-done", post(actions::post_mark_todos_done)) .route("/api/stats", get(stats::api_stats)) .route("/screen/ws", get(screen::screen_ws)) .route("/icon", get(screen::serve_icon)) } /// Bind the per-container web listener and serve the SPA. /// /// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the /// dual-mode transition + gateway-side consumer live in /// `docs/4-implementation/web-ui/shape.md::Listener bind` and /// `docs/4-implementation/networking/gateway.md::Per-agent unix-socket upstream`. /// /// # Errors /// /// Returns an error if neither the TCP listener (default) nor the /// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or /// if `HIVE_STATIC_DIR` is missing. pub async fn serve( label: String, port: u16, login: LoginStateCell, bus: Bus, socket: PathBuf, interrupted: Arc, ) -> Result<()> { let gui_vnc_port = read_gui_vnc_port(); let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR") .map(PathBuf::from) .context( "HIVE_STATIC_DIR env var not set — point it at the merged \ per-agent dist (see services.hyperhive.agent.frontend.mergedDist in nix)", )?; if !static_dir.is_dir() { anyhow::bail!( "HIVE_STATIC_DIR ({}) is not a directory", static_dir.display() ); } tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved"); let state = AppState { label, login, bus, socket, gui_vnc_port, interrupted, }; let app = routes(); // Mount any `services.hyperhive.agent.extraWebProxies` under `/extra//` before the // static fallback so declared proxies win over `ServeDir`. let app = proxy::mount_extra_proxies(app) // Anything else (`/`, `/stats`, `/screen`, `/static/*`) // falls through to the merged dist. ServeDir auto-appends // `.html` when the URL is a bare path that matches a file // (so `/stats` → `dist/stats.html`, `/screen` → `dist/ // screen.html`). Per-agent `extraFiles` additions are // already layered into this same directory (see // services.hyperhive.agent.frontend.mergedDist in nix). .fallback_service(ServeDir::new(&static_dir)) .with_state(state); // `HIVE_WEB_SOCKET` opt-in: when set + non-empty, bind a // `UnixListener` at the given path. Empty string treated as // unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an // un-bindable empty path. Falls through to the TCP path below // otherwise. See docs/4-implementation/networking/gateway.md::Per-agent unix-socket upstream // for the gateway-side consumer. if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET") && !socket_path.is_empty() { let path = PathBuf::from(socket_path); let listener = bind_unix(&path)?; tracing::info!(socket = %path.display(), "web UI listening on unix socket"); axum::serve(listener, app).await?; return Ok(()); } let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; tracing::info!(%port, "web UI listening on tcp"); axum::serve(listener, app).await?; Ok(()) } /// Bind a `UnixListener` at `path` and drop a `hyperhive-socket-bound` marker /// next to it so c0re's gateway-map writer knows the socket is live. /// Best-effort unlinks any stale socket left from a crashed previous /// harness (nothing removes the socket or marker on exit, and `bind(2)` /// refuses to overwrite an existing file) and `mkdir -p`s the parent for /// first-boot. Mode /// `0o666` — world-accessible so the gateway's nginx process can /// `connect(2)` without sharing a group with the agent user. What bounds /// that is the per-agent subdir (`/run/hive-agent//`): the socket /// mode grants everyone, so the directory's permissions decide who gets /// to ask. /// /// Marker-gating + the gateway-side consumer: see /// `docs/4-implementation/networking/gateway.md::Per-agent unix-socket upstream`. fn bind_unix(path: &Path) -> Result { use std::os::unix::fs::PermissionsExt; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create socket parent dir {}", parent.display()))?; } // Best-effort: ENOENT is fine (no stale file); any other error // surfaces via the bind below with a clearer "AddrInUse" / perms // message than a partial cleanup would. let _ = std::fs::remove_file(path); let listener = tokio::net::UnixListener::bind(path) .with_context(|| format!("bind unix socket at {}", path.display()))?; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666)) .with_context(|| format!("set perms on {}", path.display()))?; // Best-effort ready marker: failed write isn't fatal (the harness // still binds + serves), it just means the gateway side keeps the // TCP upstream for one more sync tick. if let Some(parent) = path.parent() { let marker = parent.join("hyperhive-socket-bound"); if let Err(e) = std::fs::write(&marker, b"") { tracing::warn!( marker = %marker.display(), error = %e, "failed to write hyperhive-socket-bound marker — gateway may keep TCP upstream" ); } } Ok(listener) } /// Maximum bind attempts before `bind_with_retry` gives up on `AddrInUse`. const MAX_BIND_ATTEMPTS: u32 = 12; /// Bind a TCP listener with `SO_REUSEADDR` set, retrying on `AddrInUse` with /// exponential backoff capped at 2s, up to [`MAX_BIND_ATTEMPTS`] attempts. If /// the port is still held after the final attempt, returns the `AddrInUse` /// error rather than looping forever (a genuine collision needs the operator, /// not an unbounded wait). /// /// Retry rationale + dashboard-banner-on-real-collision: /// see `docs/4-implementation/web-ui/shape.md::Listener bind`. async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result { let mut retries = 0u32; let bound = (|| async { try_bind(addr) }) .retry( ExponentialBuilder::default() .with_min_delay(Duration::from_millis(250)) .with_max_delay(Duration::from_secs(2)) .with_max_times((MAX_BIND_ATTEMPTS - 1) as usize), ) .when(|e| e.kind() == std::io::ErrorKind::AddrInUse) .notify(|_, delay| { retries += 1; tracing::warn!( %addr, attempt = retries, "{label}: AddrInUse, retrying in {}ms", delay.as_millis() ); }) .await; match bound { Ok(l) => { if retries > 0 { tracing::info!(%addr, attempts = retries, "{label}: bind succeeded after retry"); } Ok(l) } Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => Err(e).with_context(|| { format!("bind {label} on {addr}: still AddrInUse after {MAX_BIND_ATTEMPTS} attempts") }), Err(e) => Err(e).with_context(|| format!("bind {label} on {addr}")), } } fn try_bind(addr: SocketAddr) -> std::io::Result { let sock = match addr { SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?, SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?, }; sock.set_reuseaddr(true)?; sock.bind(addr)?; sock.listen(1024) } /// The fixed VNC port weston bound, from the `HIVE_GUI_VNC_PORT` env var /// the harness service sets when gui is enabled (see weston-vnc.nix). /// `None` when unset (gui not enabled for this agent) or unparseable. /// The port is a fixed, container-local value — no per-agent hashing, no /// marker file — because network isolation is unconditional (each agent /// has its own netns, so the port can't collide across containers). fn read_gui_vnc_port() -> Option { std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok() } /// Outcome of [`sigint_claude`] — shared by `/api/cancel` and `/api/logout` /// so both can render the same three cases. enum SigintOutcome { /// The tracked pid was signalled. Signalled, /// No claude child is currently tracked (turn not in flight, or it /// finished/exited between the check and the signal — same "nothing to /// interrupt" outcome either way). NoProcess, /// The `kill` syscall itself failed for a reason other than "no such /// process" (permission issue, etc). Failed(std::io::Error), } /// Find a `claude` process that is a direct OS child of this harness /// process, if any. `hive-claude`'s driver spawns the turn's claude /// directly (`Command::new(program).spawn()`, no shell in between), so the /// harness is always the immediate parent of any claude turn it started — /// scanning `/proc/*/status` for `PPid: ` plus `/proc/*/cmdline` /// for an `argv[0]` of `claude` finds *that* specific process without needing /// the driver to surface its pid through any extra plumbing. Distinguishes /// the harness's own tracked turn from an unrelated `claude` someone is /// running interactively in the same container (a manually shelled-in /// "choom" session) — that one's parent is a login shell, not us. /// /// **Matches on `cmdline`, not `status`'s `Name:` field.** The nixpkgs /// `claude-code` package wraps its real binary (`wrapProgram`-style: the /// executable on `PATH` is a thin `exec -a claude .../.claude-wrapped ...` /// shim) — `exec -a` only overrides `argv[0]` as the process itself/`cmdline` /// see it, not the kernel's own `comm` (what `status`'s `Name:` line /// reports, set from the executed binary's own basename at `execve` time). /// So `Name:` shows `.claude-wrapped`, not `claude`, on a wrapped package — /// `cmdline`'s first argument still carries the bare name /// `Command::new("claude")` resolved on `PATH`, which is what actually /// matters here. Best-effort: a process that exits mid-scan (its /// `/proc//{status,cmdline}` read fails, ESRCH) is just skipped. fn find_claude_child() -> Option { let own_pid = std::process::id(); for entry in std::fs::read_dir("/proc").ok()?.flatten() { let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { continue; // not a pid dir (self, cwd, net, ...) }; let Ok(status) = std::fs::read_to_string(entry.path().join("status")) else { continue; }; let parent_pid = status .lines() .find_map(|line| line.strip_prefix("PPid:")) .and_then(|v| v.trim().parse::().ok()); if parent_pid != Some(own_pid) { continue; } let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else { continue; }; let argv0 = cmdline.split(|&b| b == 0).next().unwrap_or_default(); if argv0 == b"claude" { return Some(pid); } } None } /// Stop the turn in flight and say so in the live panel as `by`. Returns /// whether a turn was actually stopped: a cancel that raced an already /// finished turn is `false`, and only a `true` marks the next wake prompt as /// cut off. Shared by the dashboard's `/cancel` and the swarm queue's cancel /// command, so the two cannot disagree about what cancelling is. pub(crate) fn cancel_turn(bus: &Bus, interrupted: &AtomicBool, by: &str) -> bool { let (acted, what) = if let Some(canceller) = bus.turn_canceller() { if canceller.cancel() { (true, "sent session/cancel to the ACP agent".to_owned()) } else { (false, "no ACP turn to cancel".to_owned()) } } else { match sigint_claude() { SigintOutcome::Signalled => (true, "sent SIGINT to claude".to_owned()), SigintOutcome::NoProcess => (false, "no claude process to interrupt".to_owned()), SigintOutcome::Failed(e) => (false, format!("kill failed: {e}")), } }; if acted { // The next turn's wake prompt tells the agent it was cut off mid-work. interrupted.store(true, std::sync::atomic::Ordering::Relaxed); } bus.emit(crate::events::LiveEvent::Note { text: format!("{by} - {what}"), }); acted } /// SIGINT this container's harness-spawned claude child, if any /// (best-effort). See [`find_claude_child`] for how it's identified — /// **not** a name-based `pkill claude`, which would also hit an unrelated /// `claude` process someone is running interactively in the container. fn sigint_claude() -> SigintOutcome { let Some(pid) = find_claude_child() else { return SigintOutcome::NoProcess; }; sigint_turn(pid) } /// SIGINT `pid`, and its whole process group when it leads one, so the /// commands a turn's claude started stop with it. `hive-claude` spawns the /// turn's claude as the leader of its own group. A `claude` child that /// leads none (`plugins.rs`'s `claude plugin` calls) shares the harness's /// group, so it is signalled alone. fn sigint_turn(pid: u32) -> SigintOutcome { // A child is never pid 0 or 1, and `-0` / `-1` would reach the // harness's own group or every process it may signal. let Some(pid) = i32::try_from(pid).ok().filter(|&pid| pid > 1) else { return SigintOutcome::NoProcess; }; // SAFETY: `getpgid(2)` only reads; a gone pid returns -1, never `pid`. let target = if unsafe { libc::getpgid(pid) } == pid { -pid } else { pid }; // SAFETY: plain `kill(2)` on the child or the group it leads. if unsafe { libc::kill(target, libc::SIGINT) } == 0 { return SigintOutcome::Signalled; } let err = std::io::Error::last_os_error(); if err.raw_os_error() == Some(libc::ESRCH) { // It exited between the scan and the signal: same as never found. SigintOutcome::NoProcess } else { SigintOutcome::Failed(err) } } fn error_response(status: StatusCode, message: &str) -> Response { // Plain text — JS app surfaces in `alert()`, HTML wrapping would just // be noise. Status is per-caller: 400 for bad input, 409 for a // retryable state conflict (turn in flight / hive-c0re busy), 500 only // for a genuine server/transport failure — the frontend shows the code // in its alert, so a benign "busy, retry" must not read as a 500. (status, message.to_owned()).into_response() } /// Why a deadline-bounded broker request via the per-agent socket didn't /// yield a response. Callers `.ok()` both today; the variants stay distinct so a /// handler that must tell a busy hive-c0re from a broken socket can. enum BrokerError { /// Outran [`SOCKET_FETCH_TIMEOUT`] — hive-c0re is busy or stalled, not a /// broken socket. Timeout, /// The socket transport itself failed (connect / encode / decode). Transport, } /// Issue a broker request over the per-agent socket, bounded by /// [`SOCKET_FETCH_TIMEOUT`] so a busy or stalled hive-c0re degrades the /// response instead of hanging it. Callers match the returned [`Response`] /// variant themselves; the error side distinguishes a retryable timeout from /// a transport failure. This is the one shared broker-call scaffold — every /// web-UI handler that talks to the broker goes through it. async fn broker_request( socket: &Path, req: &hive_core_agent_sock::Request, ) -> std::result::Result { match tokio::time::timeout( SOCKET_FETCH_TIMEOUT, hive_sock_client::request::<_, hive_core_agent_sock::Response>( socket, req, crate::CONTROL_SOCKET_RETRY, ), ) .await { Ok(Ok(resp)) => Ok(resp), Ok(Err(_)) => Err(BrokerError::Transport), Err(_) => Err(BrokerError::Timeout), } } #[cfg(test)] mod tests { use std::os::unix::process::CommandExt as _; use std::path::Path; use std::process::{Command, Stdio}; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode, header}; use tower::ServiceExt; use super::{AppState, SigintOutcome, bind_with_retry, routes, sigint_turn, try_bind}; use crate::events::Bus; use crate::login::LoginState; /// Hold a loopback port just below the kernel's ephemeral range. Once the /// holder drops, nothing that binds `:0` or connects outward can take the /// port, and the pid offset keeps concurrent test processes apart. fn hold_port_below_ephemeral_range() -> tokio::net::TcpListener { let low = std::fs::read_to_string("/proc/sys/net/ipv4/ip_local_port_range") .ok() .and_then(|s| s.split_whitespace().next()?.parse::().ok()) .unwrap_or(32768); let start = low.saturating_sub(1 + (std::process::id() % 2000) as u16); (1024..=start) .rev() .find_map(|port| try_bind(([127, 0, 0, 1], port).into()).ok()) .expect("a free port below the ephemeral range") } /// A port another listener holds for a moment is waited out. The paused /// clock runs all retries in microseconds of real time, so the port must /// not be one the kernel hands to other sockets once released. #[tokio::test(start_paused = true)] async fn bind_waits_out_a_port_that_is_briefly_held() { let holder = hold_port_below_ephemeral_range(); let addr = holder.local_addr().expect("local addr"); let release = tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(1)).await; drop(holder); }); let bound = bind_with_retry(addr, "test") .await .expect("bind after release"); assert_eq!(bound.local_addr().expect("local addr"), addr); release.await.expect("release task"); } /// A port that stays held ends in an `AddrInUse` error after 12 attempts: /// 11 waits of 250ms doubling to the 2s cap. #[tokio::test(start_paused = true)] async fn bind_gives_up_when_the_port_stays_held() { let holder = try_bind("127.0.0.1:0".parse().expect("addr")).expect("holder"); let addr = holder.local_addr().expect("local addr"); let start = tokio::time::Instant::now(); let err = bind_with_retry(addr, "test") .await .expect_err("port is held"); assert_eq!( start.elapsed(), Duration::from_millis(250 + 500 + 1000 + 8 * 2000) ); assert!(err.to_string().contains("after 12 attempts"), "{err:#}"); assert_eq!( err.downcast_ref::() .map(std::io::Error::kind), Some(std::io::ErrorKind::AddrInUse) ); drop(holder); } fn form(path: &str, body: &'static str) -> Request { Request::post(path) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap() } /// The swarm sets the model and effort, so the agent serves no route for /// either. #[test] fn neither_model_nor_effort_has_a_route() { let (model, effort, compact) = crate::paths::with_harness_dir(|| { let state = AppState { label: "test".to_owned(), login: Arc::new(Mutex::new(LoginState::Online)), bus: Bus::new(), socket: crate::paths::harness_dir().join("mcp.sock"), gui_vnc_port: None, interrupted: Arc::new(AtomicBool::new(false)), }; let app = routes().with_state(state); tokio::runtime::Builder::new_current_thread() .build() .unwrap() .block_on(async { let model = app.clone().oneshot(form("/api/model", "model=opus")); let effort = app.clone().oneshot(form("/api/effort", "effort=high")); // A route the router does serve, so the 404s above are not // the router refusing everything. let compact = app.oneshot(form("/api/compact", "")); ( model.await.unwrap(), effort.await.unwrap(), compact.await.unwrap(), ) }) }); assert_eq!(model.status(), StatusCode::NOT_FOUND); assert_eq!(effort.status(), StatusCode::NOT_FOUND); assert_eq!(compact.status(), StatusCode::OK); } /// Poll `path` until it holds a complete, newline-terminated pid. fn read_pid(path: &Path) -> i32 { for _ in 0..250 { if let Ok(text) = std::fs::read_to_string(path) && text.ends_with('\n') { return text.trim().parse().expect("pid file holds a pid"); } std::thread::sleep(Duration::from_millis(20)); } unreachable!("no pid in {} within 5s", path.display()); } /// Whether `pid` is still running. A zombie counts as gone: it has /// exited, and `kill(pid, 0)` only keeps finding it until it is reaped. fn is_running(pid: i32) -> bool { // SAFETY: signal 0 delivers nothing; it only checks that `pid` exists. if unsafe { libc::kill(pid, 0) } != 0 { return false; } !std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| { stat.rsplit_once(") ") .is_some_and(|(_, rest)| rest.starts_with('Z')) }) } #[test] fn a_cancel_stops_the_commands_a_turn_started() { let dir = tempfile::tempdir().expect("tempdir"); let pidfile = dir.path().join("grandchild.pid"); // A turn's claude as hive-claude spawns it: leading its own group, // with a command of its own running in the foreground (a // non-interactive shell starts `&` jobs with SIGINT ignored). The // trailing `exit` keeps `sh -c` from exec'ing into that command. let mut turn = Command::new("/bin/sh") .args([ "-c", r#"sh -c 'echo $$ >"$1"; exec sleep 300' sh "$1"; exit"#, "sh", ]) .arg(&pidfile) .stdout(Stdio::null()) .stderr(Stdio::null()) .process_group(0) .spawn() .expect("spawn the stand-in turn"); let grandchild = read_pid(&pidfile); assert_ne!( i32::try_from(turn.id()).ok(), Some(grandchild), "the command must be a child of the turn, not the turn itself" ); assert!(matches!(sigint_turn(turn.id()), SigintOutcome::Signalled)); let mut alive = is_running(grandchild); for _ in 0..250 { if !alive { break; } std::thread::sleep(Duration::from_millis(20)); alive = is_running(grandchild); } if alive { // SAFETY: `kill(2)` on a pid this test started; keeps a failed // run from leaving a 300s `sleep` behind. unsafe { libc::kill(grandchild, libc::SIGKILL); } } let _ = turn.wait(); assert!(!alive, "grandchild {grandchild} outlived the cancel"); } #[test] fn a_claude_sharing_the_harness_group_is_signalled_alone() { // In this test process's own group, as `claude plugin` runs. A group // signal here would SIGINT the test binary itself. let mut child = Command::new("sleep") .arg("300") .spawn() .expect("spawn sleep"); assert!(matches!(sigint_turn(child.id()), SigintOutcome::Signalled)); let status = child.wait().expect("reap sleep"); assert_eq!( std::os::unix::process::ExitStatusExt::signal(&status), Some(libc::SIGINT) ); } }