From 55716be8fceca1032539b5dadd93a5931df0aab2 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 17:23:15 +0200 Subject: [PATCH] hive-ag3nt + docs: extract web_ui prose (#716 batch 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/web-ui.md: - New `### Listener bind` subsection — uncapped TCP retry rationale (replaces stale "12 tries" claim that contradicted the code) + pointer to gateway.md for unix-socket transition - Expand `/api/logout` bullet with three-step teardown rationale (SIGINT race, selective cred-file wipe preserves --continue, wait_for_login resumption path) hive-ag3nt/src/web_ui.rs: - serve / bind_unix / bind_with_retry rustdocs trimmed to docs pointers (binding modes, .bound marker, retry budget all live in the prose now) - AgentLink / StateSnapshot.links / agent_links rustdocs trimmed to single-line summaries - post_logout / CRED_FILE_NAMES rustdocs reference docs/web-ui.md - 13 → 0 cookies --- docs/web-ui.md | 61 ++++++++++--- hive-ag3nt/src/web_ui.rs | 179 +++++++++++++++------------------------ 2 files changed, 117 insertions(+), 123 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index 441e0c0f..3581d78d 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -217,10 +217,30 @@ previews are type-aware: real content-type (text files stay UTF-8-lossy `text/plain`). - **Everything else** — raw text in a `
`.
 
-Both bind their listeners with `SO_REUSEADDR` via
-`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
-exponential backoff capped at 2s) so an nspawn restart that races
-the previous process's socket release resolves itself.
+### Listener bind
+
+Both bind their TCP listener with `SO_REUSEADDR` via
+`tokio::net::TcpSocket` plus a retry loop on `AddrInUse`
+(exponential backoff capped at 2s, **no attempt cap**) so an nspawn
+restart that races the previous process's socket release resolves
+itself. The retry is uncapped on purpose: a capped budget once
+left the harness silently UI-less for the rest of its lifetime
+when a back-to-back restart held the port longer than the cap
+allowed. Genuine port collisions are preflighted host-side
+(`lifecycle::{spawn,rebuild}` refuses with a clear error,
+surfaced on the dashboard as a banner), so at this layer a
+persistent `AddrInUse` always reflects a recoverable stale
+socket — retrying forever is the safe choice. The first 12
+attempts log at WARN; after that the level drops to INFO so a
+long-held stale socket doesn't flood the journal.
+
+The per-agent UI optionally binds a `UnixListener` instead of
+TCP when `HIVE_WEB_SOCKET` is set — the unix-socket transition
+mechanics (per-agent `/run/hive-agent//` bind-mount,
+`.bound` marker filtering, `agent-sockets.json` consumer on the
+gateway side) live in [`docs/gateway.md::Per-agent unix-socket
+upstream`](gateway.md). The env var is opt-in per agent so the
+two modes coexist while sub-agents transition.
 
 ### Per-agent relative paths
 
@@ -1162,12 +1182,33 @@ shaped).
   future turns. `Bus::set_model` emits `ModelChanged`.
 - `POST /api/new-session` — arm a one-shot for the next turn to
   drop `--continue`. Emits a `LiveEvent::Note`.
-- `POST /api/logout` — SIGINT any in-flight turn, wipe OAuth
-  credential files (`.credentials.json` + `mcp-needs-auth-cache.json`
-  under `~/.claude/`), flip `LoginState::NeedsLogin`. Session
-  history (`~/.claude/projects/`) is preserved. Returns 200 with a
-  plain-text wipe summary. Emits `needs_login_idle` status via
-  `wait_for_login` entry.
+- `POST /api/logout` — three-step teardown that re-uses the
+  existing `wait_for_login` resumption path:
+  1. SIGINT any running claude (matches `/api/cancel`'s pattern —
+     idempotent no-op when nothing is running) so the credential
+     wipe doesn't race a mid-API-call turn.
+  2. Delete only the OAuth credential files
+     (`.credentials.json` + `mcp-needs-auth-cache.json` under
+     `~/.claude/`). **Preserves** session history files
+     (`projects//*.jsonl`), sessions, shell-snapshots,
+     plans, settings, telemetry, and the dir itself, so
+     `claude --continue` keeps working after a fresh login.
+     Wholesale `remove_dir_all` of `~/.claude/` was the previous
+     shape and broke session continuity; the narrowed allow-list
+     is the fix.
+  3. Flip `LoginState::NeedsLogin` + emit a `LiveEvent::Note`
+     describing exactly what was wiped, then emit
+     `needs_login_idle`. The turn-loop's next iteration parks
+     into `wait_for_login`, which snapshots the credential dir
+     (now missing the wiped files) and resumes when a fresh
+     credentials file appears via the dashboard's `/login/code`
+     flow (the same mtime-resumption path manual re-login uses).
+
+  Always returns 200 with a body describing what happened —
+  per-file errors are folded into the response + the Note so the
+  operator sees them in the live panel rather than as an HTTP
+  error. Missing files (already logged out) are treated as
+  idempotent.
 - `GET /events/history` — replay buffer for the terminal.
 - `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
   renderer — deliberately thin, just enough to display the
diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs
index 4728269c..590555a8 100644
--- a/hive-ag3nt/src/web_ui.rs
+++ b/hive-ag3nt/src/web_ui.rs
@@ -76,21 +76,18 @@ impl AppState {
 /// `post_compact`) the allowed-tools surface claude sees.
 pub type Flavor = mcp::Flavor;
 
+/// 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/web-ui.md::Listener bind`](../../../docs/web-ui.md) and
+/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md).
+///
 /// # 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.
-///
-/// # Binding modes (#784 phase 1)
-///
-/// When `HIVE_WEB_SOCKET` is set + non-empty, bind a `UnixListener`
-/// at that path so the gateway can `proxy_pass unix:…` instead of
-/// reaching us over a TCP loopback that won't work post-#14 (private
-/// netns). When the env var is unset, fall back to TCP bind on `port`
-/// — the legacy path the gateway's `agent-ports.json` map drives. The
-/// gateway can transition to socket upstreams independently of any
-/// agent re-binding because the env var is opt-in per agent.
 pub async fn serve(
     label: String,
     port: u16,
@@ -150,11 +147,12 @@ pub async fn serve(
         // hyperhive.frontend.mergedDist in nix).
         .fallback_service(ServeDir::new(&static_dir))
         .with_state(state);
-    // `HIVE_WEB_SOCKET` opt-in (#784 phase 1): when set, bind a
+    // `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.
+    // otherwise. See docs/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()
     {
@@ -171,16 +169,17 @@ pub async fn serve(
     Ok(())
 }
 
-/// Bind a `UnixListener` at `path`. Best-effort unlinks any stale
-/// socket left over from a previous (crashed) harness — clean exit
-/// removes it, but `bind(2)` refuses to overwrite an existing file.
-/// Also `mkdir -p` the parent so a freshly-created `/run/hive-agent/`
-/// bind-mount target works on first boot.
+/// Bind a `UnixListener` at `path` and drop a `.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 (clean exit removes it, but `bind(2)` refuses to overwrite
+/// an existing file) and `mkdir -p`s the parent for first-boot. Mode
+/// `0o660` so a peer container bind-mounting the dir with a shared
+/// group can `connect(2)`; the bind-mount source dir's ownership +
+/// ACL is the real access gate.
 ///
-/// Permissions: mode `0o660` so peers in the same unix group (the
-/// gateway container, when bind-mounting the socket dir with a
-/// shared group) can `connect(2)`. The bind-mount source dir's
-/// ownership + ACL is the real access gate; this is defence-in-depth.
+/// Marker-gating + the gateway-side consumer: see
+/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md).
 fn bind_unix(path: &Path) -> Result {
     if let Some(parent) = path.parent() {
         std::fs::create_dir_all(parent)
@@ -195,15 +194,9 @@ fn bind_unix(path: &Path) -> Result {
     use std::os::unix::fs::PermissionsExt;
     std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
         .with_context(|| format!("set perms on {}", path.display()))?;
-    // Drop a `.bound` marker next to the socket so c0re's
-    // `agent_sockets::write` can filter the JSON map to only include
-    // agents whose harness has actually opted in to (and bound) the
-    // unix socket. Without this gating, gateway would `proxy_pass`
-    // to a non-existent socket for any sub-agent that hasn't flipped
-    // `hyperhive.web.useUnixSocket = true` yet — atlas's concern on
-    // PR #813. Best-effort: a failed write isn't fatal (the harness
-    // still binds + serves on the socket), it just means the
-    // gateway side keeps using the TCP upstream for one more sync.
+    // Best-effort .bound 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(".bound");
         if let Err(e) = std::fs::write(&marker, b"") {
@@ -222,23 +215,11 @@ fn bind_unix(path: &Path) -> Result {
 
 /// Bind a TCP listener with `SO_REUSEADDR` set, retrying on
 /// `AddrInUse` indefinitely with exponential backoff capped at 2s.
-/// nspawn restarts can race the previous harness's socket release;
-/// `SO_REUSEADDR` lets us reclaim a port still in `TIME_WAIT` from a
-/// clean previous exit, and the retry covers the case where the
-/// previous process is genuinely still alive (systemd restart-delay
-/// overlap).
+/// First 12 attempts log at WARN; subsequent attempts log at INFO so
+/// a long-held stale socket doesn't flood the journal.
 ///
-/// The retry has no attempt cap: capping was the proximate cause of
-/// issue #324 — two back-to-back restarts left the previous socket
-/// holding the port for longer than the ~20s the old 12-attempt
-/// budget allowed, and the harness silently lost its web UI for the
-/// rest of the process lifetime. Genuine port collisions are
-/// preflighted host-side (`lifecycle::{spawn,rebuild}`) and surfaced
-/// on the dashboard as a banner, so at this layer a persistent
-/// `AddrInUse` always reflects a recoverable stale socket — retrying
-/// forever is the safe choice. The first attempts log at WARN; once
-/// we cross attempt 12 the level drops to INFO so a long stale
-/// socket doesn't flood the journal.
+/// Uncapped retry + dashboard-banner-on-real-collision rationale:
+/// see [`docs/web-ui.md::Listener bind`](../../../docs/web-ui.md).
 async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result {
     let mut delay_ms = 250u64;
     let mut attempts = 0u32;
@@ -293,12 +274,12 @@ fn try_bind(addr: SocketAddr) -> std::io::Result {
 /// Always returns an image, so consumers (dashboard, favicon) can hit
 /// `/icon` unconditionally without probing whether one is configured.
 async fn serve_icon() -> impl IntoResponse {
-    // Per-agent icon overrides go through `/etc/hyperhive/icon.svg` (set
-    // via the `hyperhive.icon` agent.nix option); the bundled default is
-    // resolved at runtime from
-    // `$HIVE_ASSETS_DIR/branding/hyperhive.svg` (#555). If neither file
-    // can be read we serve an empty body — keeps the response a valid
-    // SVG content-type without a panic on a misconfigured container.
+    // Per-agent icon overrides go through `/etc/hyperhive/icon.svg`
+    // (set via the `hyperhive.icon` agent.nix option); the bundled
+    // default is resolved at runtime from
+    // `$HIVE_ASSETS_DIR/branding/hyperhive.svg`. If neither file can
+    // be read we serve an empty body — keeps the response a valid SVG
+    // content-type without a panic on a misconfigured container.
     let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| {
         std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default()
     });
@@ -413,7 +394,7 @@ struct StateSnapshot {
     label: String,
     /// Hive-qualified long name (`${label}@${hyperhive.domain}`) when
     /// the host has been configured for a multi-hive swarm; falls back
-    /// to the short label when the hive domain env var is unset (#589).
+    /// to the short label when the hive domain env var is unset.
     /// The frontend uses this for the page title / agent self-introduction;
     /// when it equals `label`, the page renders the short form unchanged.
     qualified_label: String,
@@ -448,20 +429,12 @@ struct StateSnapshot {
     /// Cumulative token usage across the most recent turn's inferences
     /// (cost signal). `null` until the first turn finishes.
     cost_usage: Option,
-    /// Navigation links for this agent page (issue #262). Stats is
-    /// always present; screen when the VNC compositor is enabled; the
-    /// forge profile + the agent-configs mirror repo when the agent
-    /// has a forge account; followed by any agent-declared
-    /// `hyperhive.dashboardLinks` extras (read from
-    /// `{state_dir}/hyperhive-dashboard-links.json`). Each URL is
-    /// already absolute — built server-side from the request `Host`
-    /// header — so the frontend just renders.
-    ///
-    /// This same list is the **source of truth** for the per-agent
-    /// page *and* the dashboard card's icon-only nav strip: hive-c0re
-    /// proxies it via `GET /api/agent/{name}/links` (same-origin from
-    /// the dashboard JS), avoiding CORS and centralising the link
-    /// definitions in the agent backend.
+    /// Navigation links for this agent page. The same list feeds the
+    /// dashboard card's icon-only nav strip via hive-c0re's
+    /// `GET /api/agent/{name}/links` same-origin passthrough proxy
+    /// — single source of truth, no CORS. Per-agent page also reads
+    /// this directly. See [`docs/web-ui.md::Container row`] for the
+    /// frontend resolver + which links appear in which conditions.
     links: Vec,
 }
 
@@ -469,7 +442,7 @@ struct StateSnapshot {
 /// shape feeds the dashboard's icon-only nav strip via the host's
 /// `GET /api/agent/{name}/links` passthrough proxy, so the agent
 /// backend is the single source of truth for what links an agent
-/// exposes (issue #262).
+/// exposes.
 #[derive(Serialize)]
 struct AgentLink {
     /// `kind = Container | Forge` → path; `kind = External` → full URL.
@@ -607,26 +580,20 @@ async fn api_state(State(state): State) -> axum::Json {
     })
 }
 
-/// Build the navigation link list for the agent page header
-/// (issue #262). Single source of truth: the dashboard's icon-only
-/// nav strip consumes the same list via the host's
-/// `GET /api/agent/{name}/links` proxy. URLs are paths (relative)
-/// for Container/Forge targets and absolute for External; the
-/// frontend resolves each against its `kind` so the backend never
-/// has to guess the operator's browser host.
-///
-/// The agent harness doesn't know its own deployed sha (the meta
-/// flake lock lives on the host), so the `config` link points at
-/// the repo root; the dashboard renders a `deployed:` chip
-/// alongside the strip so the operator still sees what's live.
+/// Build the navigation link list for the agent page header. URLs
+/// are paths (relative) for `Container`/`Forge` targets and absolute
+/// for `External`; the frontend resolves each against its `kind`
+/// against the right base so the backend never has to guess the
+/// operator's browser host. See
+/// [`docs/web-ui.md::Container row`](../../../docs/web-ui.md) for
+/// the resolver + how `deployed:` ships alongside.
 fn agent_links(label: &str, gui_enabled: bool) -> Vec {
     let mut links = Vec::new();
 
-    // Note: the URLs are the actual HTML files served out of the
-    // frontend dist (`stats.html` / `screen.html`); after the #273
-    // backend/frontend split the harness serves these as static
-    // files via ServeDir rather than via Rust routes, so the URL
-    // has to be the on-disk filename.
+    // URLs are the actual HTML files served out of the frontend dist
+    // (`stats.html` / `screen.html`); the harness serves them as
+    // static files via ServeDir rather than via Rust routes, so the
+    // URL has to be the on-disk filename.
     links.push(AgentLink {
         url: "/stats.html".to_owned(),
         icon: "📊".to_owned(),
@@ -969,36 +936,22 @@ async fn post_new_session(State(state): State) -> Response {
     (axum::http::StatusCode::OK, "ok").into_response()
 }
 
-/// OAuth credential filenames inside `paths::claude_dir()`. These are
-/// the files `claude auth login` writes (the bearer token + an internal
-/// MCP auth cache); wiping them invalidates the session without
-/// touching the rest of `~/.claude/` — projects/ (jsonl session
-/// history), sessions/, shell-snapshots/, telemetry/, settings.json,
-/// etc. all survive so `--continue` keeps working after a re-login.
-/// (#584 — fix for #582 which did a wholesale `remove_dir_all`.)
+/// OAuth credential filenames inside `paths::claude_dir()`. Wiping
+/// only these (and not the rest of `~/.claude/`) preserves session
+/// history so `claude --continue` keeps working after a fresh login.
+/// Rationale + the previous wholesale-wipe shape we replaced live in
+/// [`docs/web-ui.md::Per-agent endpoints`](../../../docs/web-ui.md)
+/// (the `/api/logout` bullet).
 const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"];
 
-/// Operator-driven `/logout` (closes #576, narrowed scope per #584).
-/// Three-step teardown:
-///
-/// 1. SIGINT any running claude process so we don't race a turn
-///    that's mid-API-call. Same pattern as `post_cancel_turn`;
-///    idempotent (no-op when nothing is running).
-/// 2. Delete the OAuth credential files listed in `CRED_FILE_NAMES`
-///    inside `paths::claude_dir()`. **Preserves** session history
-///    (`projects//*.jsonl`), sessions, shell-snapshots, plans,
-///    settings, telemetry, and the dir itself — `--continue` keeps
-///    working with the same session after a fresh login.
-/// 3. Flip `LoginState::NeedsLogin` + emit Note + status. The turn-
-///    loop's next iteration parks into `wait_for_login`, which
-///    snapshots the dir (now missing the cred files) and resumes
-///    only when a fresh credentials file appears via the dashboard's
-///    `/login/code` flow (#542 mtime resumption).
-///
-/// Always returns 200 with a body describing what happened — errors
-/// per file are folded into the response + the Note so the operator
-/// sees them in the live panel rather than as an HTTP error. Missing
-/// files (already logged out) are treated as idempotent.
+/// Operator-driven `/logout`: SIGINT claude, delete the credential
+/// files in `CRED_FILE_NAMES`, flip `LoginState::NeedsLogin`. The
+/// turn loop's next iteration parks into `wait_for_login` which
+/// resumes when a fresh credentials file appears via `/login/code`.
+/// Always returns 200 with a body describing what happened. See
+/// [`docs/web-ui.md::Per-agent endpoints`](../../../docs/web-ui.md)
+/// (the `/api/logout` bullet) for the three-step rationale +
+/// preservation invariants.
 async fn post_logout(State(state): State) -> Response {
     // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
     let _ = tokio::process::Command::new("pkill")