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:
parent
604146992a
commit
55716be8fc
2 changed files with 117 additions and 123 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue