hive-ag3nt + docs: extract web_ui prose (#716 batch 6)

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
This commit is contained in:
iris 2026-05-31 17:23:15 +02:00 committed by mara
commit 55716be8fc
2 changed files with 117 additions and 123 deletions

View file

@ -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 `<pre>`.
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/<name>/` 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/<hash>/*.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

View file

@ -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<tokio::net::UnixListener> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
@ -195,15 +194,9 @@ fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
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<tokio::net::UnixListener> {
/// 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<tokio::net::TcpListener> {
let mut delay_ms = 250u64;
let mut attempts = 0u32;
@ -293,12 +274,12 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
/// 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<crate::events::TokenUsage>,
/// 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<AgentLink>,
}
@ -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<AppState>) -> axum::Json<StateSnapshot> {
})
}
/// 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:<sha>` 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:<sha>` ships alongside.
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
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<AppState>) -> 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/<hash>/*.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<AppState>) -> Response {
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
let _ = tokio::process::Command::new("pkill")