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")