From e7721827245501ea2090610de6a42f8d54a7d866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:25:51 +0200 Subject: [PATCH 1/7] path linkify: skip trailing-slash (directory) matches --- hive-c0re/assets/app.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index a2206eb9..18f9c4d7 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -48,7 +48,13 @@ // perspective (we'd need to know which agent the message is about // to translate it). Prefer `/agents//state/...` in agent // outputs and the link will resolve. - const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g; + // Each branch insists the tail is at least one segment AND the + // last character is `[\w.-]` (not `/`), so trailing-slash paths + // — i.e. plain directories — don't linkify. The /api/state-file + // endpoint also refuses non-files; this is the front-end peer so + // the operator doesn't see a dead link they'll just get an error + // from on click. + const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w.-]+|\/var\/lib\/hyperhive\/shared\/(?:[\w.-]+\/)*[\w.-]+|\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w.-]+|\/shared\/(?:[\w.-]+\/)*[\w.-]+)/g; async function fetchStateFile(path) { const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path)); const text = await resp.text(); From d890509be356e4dfa44960b6af4397eda471bfaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:28:34 +0200 Subject: [PATCH 2/7] docs: turn_stats sink + event-driven agent badges + dashboard event vocabulary --- CLAUDE.md | 43 +++++++++++++++- docs/persistence.md | 35 ++++++++++--- docs/web-ui.md | 123 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 171 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab826887..200996b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,9 +45,19 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) src/crash_watch.rs poll every 10s; fire HelperEvent::ContainerCrash when a previously-running container disappears without an operator-initiated transient + src/container_view.rs ContainerView struct + build_all helper; + shared between dashboard.rs (cold-load via + /api/state) and coordinator.rs's + rescan_containers_and_emit src/coordinator.rs shared state (broker/approvals/operator_questions/ transient/sockets) + tombstone enumeration + - kick_agent + notify_agent (helper-event push) + kick_agent + notify_agent (helper-event push) + + last_containers cache + rescan_and_emit diff helper + src/open_threads.rs loose-ends aggregator (pending approvals + + unanswered questions) — for_agent (filtered) and + hive_wide (manager surface). Backs + AgentRequest::GetOpenThreads + ManagerRequest:: + GetOpenThreads (the get_open_threads MCP tool). src/actions.rs approve/deny/destroy (transient-aware) src/auto_update.rs startup rebuild scan + ensure_manager + meta::lock_update_hyperhive bump @@ -85,6 +95,9 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries src/client.rs generic JSON-line request/response over unix socket src/web_ui.rs per-container axum HTTP page (incl /api/cancel, /api/compact, /api/model, /events/history) + src/turn_stats.rs per-turn analytics sink (one sqlite row per + turn at /state/hyperhive-turn-stats.sqlite); + schema + best-effort writer src/events.rs LiveEvent + broadcast Bus + sqlite-backed history (/state/hyperhive-events.sqlite) + TurnState + model selection (persisted at /state/hyperhive-model) @@ -193,6 +206,34 @@ Prune freely. domain tooling — the agent flake's `inputs` block pulls the external flake, `agent.nix` references it via `flakeInputs..packages.${pkgs.system}.default`. +- **Just landed:** per-turn analytics sink. New + `hive-ag3nt::turn_stats` writes one row per claude turn to + `/state/hyperhive-turn-stats.sqlite`: identity (model, + wake_from, result_kind), timing (started/ended_at, + duration_ms), cost (full token-usage breakdown), behaviour + (tool_call_count + per-tool JSON map), and post-turn snapshot + metrics (open_threads_count, open_reminders_count fetched via + the existing GetOpenThreads + new CountPendingReminders RPC). + Both ag3nt + m1nd bin loops capture, both Bus accumulates + tool_use blocks via observe_stream during the stdout pump. + Writes are best-effort. No host-side vacuum yet — TODO under + Telemetry; same shape as events_vacuum, target 90d retention. +- **Just landed:** agent web UI event-driven badges. New + `LiveEvent::StatusChanged / ModelChanged / TokenUsageChanged + / TurnStateChanged` variants replace the per-agent page's + /api/state polling for the state row. Status/model/token/state + badges all update from SSE; /api/state only fetched on cold + load + during the login flow (session output isn't event- + shaped). Per-agent endpoints (`/api/cancel|compact|model| + new-session`, `/login/*`) all flip 303→200. New `alive-badge` + chip carries the harness reachability signal (replaces the + "● harness alive" paragraph); new `ctx-badge` mirrors Claude + Code's bottom-right "N tokens" indicator. Every chip carries + a `title=...` tooltip for hover detail. +- **Just landed:** events_vacuum simplified to age-only — + `KEEP_SECS = 7d`, no row cap. Chatty turn no longer evicts + a quiet day's history sooner than expected. Hourly sweep + unchanged. - **Just landed:** Phase 6 container events. New `DashboardEvent::ContainerStateChanged { container }` + `ContainerRemoved { name }` close the last refetch loop on the diff --git a/docs/persistence.md b/docs/persistence.md index 55b951d4..eea0a98b 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -41,17 +41,36 @@ One table: harness emits during turn loop execution. The harness writes; the host vacuums. `hive-c0re::events_vacuum` -runs hourly and sweeps every existing agent state dir, applying the -same two-stage delete to each file: drop rows older than 7 days, -then trim to the 2000 most-recent. Centralising retention on the -host means a misbehaving harness can't disable its own vacuum and -agents don't need any cleanup wiring of their own. +runs hourly and sweeps every existing agent state dir, deleting +rows older than 7 days. Age-only — no row cap — so a chatty turn +doesn't lose history sooner than a quiet one; disk pressure on a +sustained burst is the cheaper problem to have. Centralising +retention on the host means a misbehaving harness can't disable +its own vacuum and agents don't need any cleanup wiring of their +own. Path overridable via `HYPERHIVE_EVENTS_DB` (for dev / no-`/state` setups). On open failure the `Bus` falls back to no-store mode rather than crashing the harness — events still broadcast over SSE, just nothing persisted. +### `/state/hyperhive-turn-stats.sqlite` (per agent) + +Per-turn analytics sink. One row per claude turn captures +identity (`model`, `wake_from`, `result_kind`), timing +(`started_at`, `ended_at`, `duration_ms`), cost (input / output / +cache_read / cache_creation token counts), behaviour +(`tool_call_count` + `tool_call_breakdown_json`), and post-turn +snapshot metrics (`open_threads_count`, +`open_reminders_count` — fetched via the same socket the harness +already uses for `GetOpenThreads` + `CountPendingReminders`). +Bin-loop helpers `build_row` + `record` land each row at +`turn_end`; writes are best-effort, a sqlite hiccup logs + lets +the turn loop continue. + +No host-side vacuum yet — tracked in `TODO.md` under Telemetry +(target retention ~90 days, age-only sweep like events_vacuum). + ### `/state/hyperhive-model` (per agent) Single-line text file holding the claude model name currently @@ -68,8 +87,10 @@ Under `/var/lib/hyperhive/agents//`: - `config/` — the proposed nix repo (manager-editable). - `claude/` — claude OAuth credentials, bind-mounted RW to `/root/.claude` inside the container. -- `state/` — durable notes + the events.sqlite db, bind-mounted - to `/state` inside the container. +- `state/` — durable notes, the events.sqlite db, and the + turn-stats sqlite db. Bind-mounted to `/agents//state` + inside the container (the manager still uses the legacy + `/state` mount point — same host path either way). Under `/var/lib/hyperhive/applied//` — the hive-c0re-only applied repo. Tracks `flake.nix` (module-only boilerplate; never diff --git a/docs/web-ui.md b/docs/web-ui.md index 79c9ca2f..1304e66e 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -201,6 +201,22 @@ not ours. a managed container. - `GET /api/agent-config/{name}` — read-only view of the applied `agent.nix`. +- `GET /api/state-file?path=` — bounded + text read of a file under the per-agent `state/` subtree or + the shared `/var/lib/hyperhive/shared/`. Accepts the + container-view forms (`/agents//state/...`, `/shared/...`) + and the host form. Canonicalises + verifies the path stays + inside the allow-list, refuses anything but a regular file, + refuses `/agents//claude` / `config` subtrees, truncates + bodies at 1 MiB. Backs the dashboard's inline path-link + preview (PATH_RE detects pointer strings in message bodies, + question/answer text, and the operator inbox; clicking + expands a `
` that lazy-fetches via this endpoint). + Trailing-slash matches (i.e. directory paths) are skipped on + the client side — only files linkify. +- `GET /api/reminders` — list pending reminders for the + dashboard's queued-reminders panel. +- `POST /cancel-reminder/{id}` — hard-delete a pending reminder. - `GET /dashboard/stream` — unified live event channel: broker `sent` / `delivered`, plus the mutation events listed below. Each frame carries `seq`. @@ -223,21 +239,37 @@ payload): queue + history mutations. Client mutates a derived store and re-renders only the approvals section. - `question_added` (id, asker, question, options, multi, - asked_at, deadline_at) / `question_resolved` (id, answer, - answerer, answered_at, cancelled) — operator-targeted - questions only (peer-to-peer questions never fire these). The - ttl watchdog fires `question_resolved` with - `answerer = "ttl-watchdog"` on expiry. + asked_at, deadline_at, target) / `question_resolved` (id, + answer, answerer, answered_at, cancelled, target) — both + operator-targeted and peer (agent-to-agent) threads fire + these. The dashboard's questions pane surfaces both, with + filter chips (all / @operator / @peer / per-participant) and + an `0V3RR1D3` button on peer rows so the operator can + answer when an agent is stuck. The ttl watchdog fires + `question_resolved` with `answerer = "ttl-watchdog"` on + expiry. - `transient_set` (name, transient_kind, since_unix) / `transient_cleared` (name) — lifecycle action spinners. The client ticks the elapsed-seconds badge off `since_unix` client-side, no polling. +- `container_state_changed` (container: ContainerView) / + `container_removed` (name) — per-row container mutations, + emitted by `Coordinator::rescan_containers_and_emit` from + every mutation site (`actions::approve` post-spawn, + `actions::destroy`, the lifecycle_action wrapper, + `auto_update::rebuild_agent`) and from the 10s + `crash_watch` poll. Client upserts/removes by name; the + pending overlay is read from `transientsState` since the + payload doesn't carry it. -`/api/state` still serves `approvals` / `approval_history` / -`questions` / `question_history` / `transients` for cold-start -on first page load and as a safety-net resync from the 5s poll; -the client maintains the same arrays in derived stores and -applies the events on top. +`/api/state` is **only fetched on cold-load and on the few +forms that mutate non-event-derived state** (PURG3 + +meta-update, since tombstones + meta_inputs aren't event- +shaped yet). Every other section — approvals, questions, +transients, containers, operator inbox, message flow — +derives from `/dashboard/stream` after the initial snapshot, +maintaining its own client-side store and applying events on +top. The 5s periodic poll is gone. Generalised form helpers: `form[data-confirm="…"]` pops `confirm()` before submit; `form[data-prompt="…"]` pops @@ -250,16 +282,34 @@ Layout, top to bottom: - Banner (gradient shimmer while state=thinking). - Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`. -- Status section (online / needs login / login-in-progress). -- **State row**: state badge + model chip + last-turn timing + - cancel-turn button + new-session button. +- Status section: empty when online (alive-badge in the state + row carries the signal), populated with the login form / + OAuth URL when `status` is `needs_login_*`. +- **State row**: alive badge + state badge + model chip + ctx + badge + last-turn timing + cancel-turn button + new-session + button. Every chip carries a `title=...` tooltip with the + detailed breakdown. + - Alive badge: `● alive` (green) / `◌ needs login` (amber) / + `◌ logging in` / `○ offline` / `… connecting`. Driven by + `LiveEvent::StatusChanged`; replaces the old "harness alive + — turn loop running" paragraph so the state row carries + every reachability signal. - State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` / `○ offline` / `… booting`, with an age suffix (`12s`, - `2m 14s`). Driven from `/api/state.turn_state` + - `turn_state_since`; SSE turn_start/turn_end still flip it - instantly between polls. Authoritative source is the - harness's `Bus::state_snapshot()`. - - Model chip: `model · ` (e.g. `model · haiku`). + `2m 14s`). Driven by `LiveEvent::TurnStateChanged` + (`{state, since_unix}`) — the bus emits on every + `Bus::set_state` so the badge updates without a /api/state + refetch. Cold-load via `/api/state.turn_state` + + `turn_state_since`. + - Model chip: `model · ` (e.g. `model · haiku`). Driven + by `LiveEvent::ModelChanged`; emitted from `Bus::set_model`. + - Ctx badge: `ctx · 142k` — total prompt tokens in the + current context window (input + cache_read + cache_write), + mirroring claude code's bottom-right indicator. Hover for + the breakdown including output. Driven by + `LiveEvent::TokenUsageChanged`; emitted from + `Bus::record_usage` whenever the terminal `result` event + delivers a fresh usage block. - Last-turn chip: `last turn 12.3s` appears after the first turn ends, computed from the state-since deltas. - `■ cancel turn` button: visible only while state=thinking, @@ -269,6 +319,11 @@ Layout, top to bottom: arm a one-shot Bus flag — the next turn drops `--continue`, starting a fresh claude session. Subsequent turns resume normal `--continue`. + + Polling: `/api/state` is fetched **once** on cold load, and + again while `status === 'needs_login_in_progress'` (login + session output isn't event-shaped yet). Every other badge + updates from SSE; no periodic refresh timer runs. - Inbox `
` block (collapsed): `inbox · N` — last 30 messages addressed to this agent, fetched via `AgentRequest::Recent { limit: 30 }`. (Separate from @@ -345,14 +400,38 @@ Unknown `/foo` shows an error row instead of being silently sent. ### Per-agent endpoints +All POSTs return 200 (no 303 redirects). The matching mutations +fire `LiveEvent` variants on the per-agent bus, so the client +doesn't refetch `/api/state` on submit — the SSE stream +delivers the new state faster anyway. Only the login flow still +polls (session output streams in updates that aren't event- +shaped). + - `POST /send` — operator-injected message into this agent's inbox. - `POST /login/{start,code,cancel}` — claude OAuth login flow. -- `POST /api/cancel` — SIGINT the in-flight claude turn. + Start/cancel emit `LiveEvent::StatusChanged` to flip the + badge to/from `needs_login_in_progress`. +- `POST /api/cancel` — SIGINT the in-flight claude turn. Emits a + `LiveEvent::Note`. - `POST /api/compact` — run `/compact` on the persistent session (same MCP config + system prompt + allowed tools as a normal - turn — only the stdin payload differs). + turn — only the stdin payload differs). Flips state to + `Compacting` via `Bus::set_state`, which emits + `TurnStateChanged`. - `POST /api/model` (`model=`) — switch the model for - future turns. + future turns. `Bus::set_model` emits `ModelChanged`. - `POST /api/new-session` — arm a one-shot for the next turn to - drop `--continue`. + drop `--continue`. Emits a `LiveEvent::Note`. - `GET /events/history` — replay buffer for the terminal. + +Bus events (new vocabulary on `/events/stream`): + +- `status_changed { status }` — `online` / + `needs_login_idle` / `needs_login_in_progress`. Drives the + alive-badge. +- `model_changed { model }` — drives the model chip. +- `token_usage_changed { usage: TokenUsage }` — drives the + ctx-badge. Emitted from `Bus::record_usage` whenever the + stream-json `result` event delivers a fresh usage block. +- `turn_state_changed { state, since_unix }` — drives the + state badge (`idle`/`thinking`/`compacting`). From bcb3f580ff5cc26840567b2fba86033b1a7375b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:28:59 +0200 Subject: [PATCH 3/7] todo: split harness-internal state from agent-visible /state --- TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/TODO.md b/TODO.md index bbcb7780..daba967d 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,7 @@ - Shared space for all agents to access documents/files without manager routing - Private git forge agents can push to and create new repos in - Move bind mounts in agents to `/agents//state` so path for agent = path for manager +- **Split harness-internal state from agent-visible state**: today `/state/` (== host `/var/lib/hyperhive/agents//state/`) mixes the agent's durable notes with harness internals — `hyperhive-events.sqlite`, `hyperhive-turn-stats.sqlite`, `hyperhive-model`, future per-agent skill caches, etc. The agent can accidentally overwrite a harness file, the harness clutters what claude thinks is "my notes dir", and the host-side vacuum has to special-case filenames it owns. Move harness internals to a sibling dir, e.g. `/var/lib/hyperhive/agents//harness/`, bind-mounted RW into the container as `/harness/` (manager-readable too via `/agents//harness/`). Container's `/state/` becomes purely agent-owned. Touches: `paths.rs` (new `harness_dir()`), `events.rs`, `turn_stats.rs` (default paths flip), `events_vacuum.rs` (sweep root flips), `lifecycle.rs` (extra bind mount), and a migration that moves existing files on first boot under the new layout. Side benefit: makes the privsep TODO cheaper — the unprivileged web server only needs read access to `/state/` (operator-meaningful files), not `/harness/`. - **Broadcast messaging**: allow sending messages with recipient "*" to all agents; deliver with hint "this was a broadcast and may not need any action from you" - **Multi-agent restart coordination**: when rebuilding all agents, manager should start first so it can coordinate post-restart confusion (notify agents, suppress unnecessary retries, etc) - **Shared docs/skills repo (RO)**: a single repo on the hive forge that every agent has read-only access to — common references, prompts, runbooks, "skills" the operator wants every agent to inherit without baking into the system prompt or `/shared`. Implementation likely: seed an `org-shared/docs` repo on first hive-forge boot, grant every per-agent user a read membership in the org. Agents `git clone` it (or use the API) to read; only the manager + operator can push. From 9585edef9b34856506742443d7658a7391210432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:30:28 +0200 Subject: [PATCH 4/7] =?UTF-8?q?todo:=20rewrite=20harness-state=20split=20?= =?UTF-8?q?=E2=80=94=20/agents//{state,harness},=20kill=20legacy=20/sta?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index daba967d..6322a24e 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,7 @@ - Shared space for all agents to access documents/files without manager routing - Private git forge agents can push to and create new repos in - Move bind mounts in agents to `/agents//state` so path for agent = path for manager -- **Split harness-internal state from agent-visible state**: today `/state/` (== host `/var/lib/hyperhive/agents//state/`) mixes the agent's durable notes with harness internals — `hyperhive-events.sqlite`, `hyperhive-turn-stats.sqlite`, `hyperhive-model`, future per-agent skill caches, etc. The agent can accidentally overwrite a harness file, the harness clutters what claude thinks is "my notes dir", and the host-side vacuum has to special-case filenames it owns. Move harness internals to a sibling dir, e.g. `/var/lib/hyperhive/agents//harness/`, bind-mounted RW into the container as `/harness/` (manager-readable too via `/agents//harness/`). Container's `/state/` becomes purely agent-owned. Touches: `paths.rs` (new `harness_dir()`), `events.rs`, `turn_stats.rs` (default paths flip), `events_vacuum.rs` (sweep root flips), `lifecycle.rs` (extra bind mount), and a migration that moves existing files on first boot under the new layout. Side benefit: makes the privsep TODO cheaper — the unprivileged web server only needs read access to `/state/` (operator-meaningful files), not `/harness/`. +- **Split harness-internal state from agent-visible state**: the `/agents//state/` mount (host `/var/lib/hyperhive/agents//state/`) currently mixes the agent's durable notes with harness internals — `hyperhive-events.sqlite`, `hyperhive-turn-stats.sqlite`, `hyperhive-model`, future per-agent skill caches, etc. The agent can accidentally overwrite a harness file, the harness clutters what claude thinks is "my notes dir", and the host-side vacuum has to special-case filenames it owns. Move harness internals to a sibling dir, e.g. `/var/lib/hyperhive/agents//harness/`, bind-mounted RW into the container as `/agents//harness/` (same path inside + out, same convention as state). Container's `/agents//state/` becomes purely agent-owned. Touches: `paths.rs` (new `harness_dir()`), `events.rs`, `turn_stats.rs` (default paths flip), `events_vacuum.rs` (sweep root flips), `lifecycle.rs` (extra bind mount), and a migration that moves existing files on first boot under the new layout. Side benefit: makes the privsep TODO cheaper — the unprivileged web server only needs read access to `/agents//state/` (operator-meaningful files), not `/agents//harness/`. The legacy bare `/state` mount the manager still uses (`container_state_prefix("manager") == "/state/"`, manager bind in `lifecycle::set_nspawn_flags`) gets removed in the same pass — manager goes to `/agents/manager/state/` + `/agents/manager/harness/` like every other agent. - **Broadcast messaging**: allow sending messages with recipient "*" to all agents; deliver with hint "this was a broadcast and may not need any action from you" - **Multi-agent restart coordination**: when rebuilding all agents, manager should start first so it can coordinate post-restart confusion (notify agents, suppress unnecessary retries, etc) - **Shared docs/skills repo (RO)**: a single repo on the hive forge that every agent has read-only access to — common references, prompts, runbooks, "skills" the operator wants every agent to inherit without baking into the system prompt or `/shared`. Implementation likely: seed an `org-shared/docs` repo on first hive-forge boot, grant every per-agent user a read membership in the org. Agents `git clone` it (or use the API) to read; only the manager + operator can push. From 0e2d26304e80c704c296c98f8eecec1559482f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:32:37 +0200 Subject: [PATCH 5/7] path linkify: require last segment to look like name.ext (skips dir refs) --- hive-c0re/assets/app.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 18f9c4d7..3364cee6 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -48,13 +48,18 @@ // perspective (we'd need to know which agent the message is about // to translate it). Prefer `/agents//state/...` in agent // outputs and the link will resolve. - // Each branch insists the tail is at least one segment AND the - // last character is `[\w.-]` (not `/`), so trailing-slash paths - // — i.e. plain directories — don't linkify. The /api/state-file - // endpoint also refuses non-files; this is the front-end peer so - // the operator doesn't see a dead link they'll just get an error - // from on click. - const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w.-]+|\/var\/lib\/hyperhive\/shared\/(?:[\w.-]+\/)*[\w.-]+|\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w.-]+|\/shared\/(?:[\w.-]+\/)*[\w.-]+)/g; + // Each branch insists the final segment looks like a filename: + // at least one non-dot char, a literal dot, then an extension + // (`[\w-]+\.[\w.-]+`). That catches the common case (`notes.md`, + // `2026-01.log`, `foo.bar.baz`) while skipping bare directory + // names like `/agents/foo/state/notes` whether or not they carry + // a trailing slash. Misses extensionless files (`README`, + // `Makefile`) — accepted trade-off; the /api/state-file endpoint + // still serves them if the operator types the path manually. + // The endpoint also refuses non-files at the server level; this + // is the front-end peer so the operator doesn't see a dead link + // they'll just get an error from on click. + const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/var\/lib\/hyperhive\/shared\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/shared\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+)/g; async function fetchStateFile(path) { const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path)); const text = await resp.text(); From 6e098fad296f30fee0a60b1ceb5b6479e10f27e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:36:44 +0200 Subject: [PATCH 6/7] path linkify: server-side validation via /api/state-file/check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit regex back to permissive ("looks like a path") — the server is authoritative on whether each match is a file. anchors render optimistically, paths queue for batch validation (50ms coalesce), non-files downgrade to plain text + the sibling
preview is dropped. session-scoped cache (pathValidity Map) so repeated paths skip the roundtrip. new endpoint POST /api/state-file/check accepts { paths } and returns { results: {: bool} }. shares resolve_state_path helper with the read endpoint so security rules can't drift — both refuse anything outside the allow-list, anything resolved outside via symlink, or anything in a per-agent subdir other than state/. capped at 64 paths/request. drops the brittle client-side filename heuristic (the .ext- required rule that missed README/Makefile and still matched bare dirs without trailing slash). single source of truth. --- hive-c0re/assets/app.js | 102 +++++++++++++++++++++++++++++++------ hive-c0re/src/dashboard.rs | 84 ++++++++++++++++++++---------- 2 files changed, 145 insertions(+), 41 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 3364cee6..2c16c824 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -48,18 +48,75 @@ // perspective (we'd need to know which agent the message is about // to translate it). Prefer `/agents//state/...` in agent // outputs and the link will resolve. - // Each branch insists the final segment looks like a filename: - // at least one non-dot char, a literal dot, then an extension - // (`[\w-]+\.[\w.-]+`). That catches the common case (`notes.md`, - // `2026-01.log`, `foo.bar.baz`) while skipping bare directory - // names like `/agents/foo/state/notes` whether or not they carry - // a trailing slash. Misses extensionless files (`README`, - // `Makefile`) — accepted trade-off; the /api/state-file endpoint - // still serves them if the operator types the path manually. - // The endpoint also refuses non-files at the server level; this - // is the front-end peer so the operator doesn't see a dead link - // they'll just get an error from on click. - const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/var\/lib\/hyperhive\/shared\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/shared\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+)/g; + // Match anything that *looks* like a path under the allow-listed + // roots; the server endpoint `/api/state-file/check` is the + // authority on whether each match is actually a file. Optimistic + // anchors render first; a batched validation request downgrades + // non-files (dirs, missing, forbidden subtrees) back to plain + // text. No client-side filename heuristics — the regex's job is + // just "spot a path-shaped token". + const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g; + + // Session-scoped truthiness cache for paths the server has + // already verified. `true` = render as a clickable anchor; + // `false` = strip the anchor on next reflow. Cleared only on + // page reload — agents creating new files mid-session show up + // next time the path is referenced. + const pathValidity = new Map(); + // Anchors awaiting validation. Keyed by path so we can rewrite + // every anchor for the same path in one shot when the result + // lands. Each entry: { anchor, details } so we can also drop + // the sibling preview when the path turns out to be invalid. + const pendingAnchors = new Map(); + let validateTimer = null; + function queuePathForValidation(path, anchor, details) { + if (!pendingAnchors.has(path)) pendingAnchors.set(path, []); + pendingAnchors.get(path).push({ anchor, details }); + if (validateTimer) clearTimeout(validateTimer); + // Coalesce bursts (a backfill replay can emit dozens of rows + // in one tick) into a single batched request. + validateTimer = setTimeout(flushPathValidation, 50); + } + async function flushPathValidation() { + validateTimer = null; + const paths = Array.from(pendingAnchors.keys()); + if (!paths.length) return; + // Snapshot the queue + clear it before we await — additional + // anchors that land while the request is in flight queue into + // a fresh batch. + const snapshot = new Map(pendingAnchors); + pendingAnchors.clear(); + let results = {}; + try { + const resp = await fetch('/api/state-file/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths }), + }); + if (resp.ok) results = (await resp.json()).results || {}; + } catch (err) { + console.warn('path validation batch failed', err); + // On transport failure leave anchors as-is — clicking them + // will surface the real error from /api/state-file inline. + return; + } + for (const [path, entries] of snapshot) { + const ok = !!results[path]; + pathValidity.set(path, ok); + if (ok) continue; + // Downgrade every pending anchor for this path back to + // plain text + drop its sibling
preview. + for (const { anchor, details } of entries) { + if (anchor.parentNode) { + anchor.parentNode.replaceChild(document.createTextNode(path), anchor); + } + if (details && details.parentNode) { + details.parentNode.removeChild(details); + } + } + } + } + async function fetchStateFile(path) { const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path)); const text = await resp.text(); @@ -99,6 +156,9 @@ // Append `text` to `parent` as a mix of text nodes + path anchors. // Returns the array of generated `
` previews so the // caller can append them as block siblings under the row. + // Anchors render optimistically; paths unseen this session are + // queued for batch validation, and the server's verdict either + // confirms or strips them via `flushPathValidation`. function appendLinkified(parent, text) { const previews = []; if (text == null) return previews; @@ -110,9 +170,21 @@ if (m.index > lastIdx) { parent.appendChild(document.createTextNode(str.slice(lastIdx, m.index))); } - const { anchor, details } = makePathPreview(m[0]); - parent.appendChild(anchor); - previews.push(details); + const path = m[0]; + const cached = pathValidity.get(path); + if (cached === false) { + // Already known to be a non-file — render plain text, no + // anchor, no preview. The text still shows up so the + // operator sees the path; it's just not clickable. + parent.appendChild(document.createTextNode(path)); + } else { + const { anchor, details } = makePathPreview(path); + parent.appendChild(anchor); + previews.push(details); + // Unknown paths queue for validation; known-good ones + // skip the roundtrip entirely. + if (cached !== true) queuePathForValidation(path, anchor, details); + } lastIdx = m.index + m[0].length; } if (lastIdx < str.length) { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index d854691a..73618cf8 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -55,6 +55,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/api/journal/{name}", get(get_journal)) .route("/api/state-file", get(get_state_file)) + .route("/api/state-file/check", post(post_state_file_check)) .route("/api/reminders", get(api_reminders)) .route("/cancel-reminder/{id}", post(post_cancel_reminder)) .route("/api/agent-config/{name}", get(get_agent_config)) @@ -911,15 +912,17 @@ struct StateFileQuery { /// traversal and symlink games can't escape the roots. Files larger /// than `MAX_BYTES` are truncated with a banner so a runaway log /// can't OOM the browser. -async fn get_state_file( - axum::extract::Query(q): axum::extract::Query, -) -> Response { - const MAX_BYTES: usize = 1 << 20; // 1 MiB +/// Resolve a caller-supplied path string to a canonical host path +/// that has been verified against the allow-list. Returns `Err` +/// with a human-readable reason for every failure mode (path +/// outside roots, canonicalize failure, escape via symlink, +/// per-agent subdir not `state`). Shared by `get_state_file` (read) +/// and `post_state_file_check` (existence probe) so both endpoints +/// apply identical security rules. +fn resolve_state_path(raw: &str) -> std::result::Result { const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; - let raw = q.path.trim(); - // Translate the container-view forms to host paths so the - // allow-list check has a single canonical shape to match. + let raw = raw.trim(); let mapped: std::path::PathBuf = if let Some(rest) = raw.strip_prefix("/agents/") { std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")) } else if let Some(rest) = raw.strip_prefix("/shared/") { @@ -927,38 +930,37 @@ async fn get_state_file( } else if raw.starts_with(AGENTS_ROOT) || raw.starts_with(SHARED_ROOT) { std::path::PathBuf::from(raw) } else { - return error_response(&format!("state-file: path not in allow-list: {raw}")); + return Err(format!("path not in allow-list: {raw}")); }; - // Canonicalise so `..` / symlinks resolve before the prefix - // check. A failure here means the path doesn't exist on disk - // (or we can't reach it) — surface the underlying error. - let canonical = match std::fs::canonicalize(&mapped) { - Ok(p) => p, - Err(e) => return error_response(&format!("state-file: {}: {e}", mapped.display())), - }; - let allowed = canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT); - if !allowed { - return error_response(&format!( - "state-file: resolved path escapes allow-list: {}", + let canonical = std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?; + if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) { + return Err(format!( + "resolved path escapes allow-list: {}", canonical.display() )); } - // For per-agent paths, also require the second-from-root - // component to be `state` (not `claude` or `config`). Claude - // creds shouldn't leak through this endpoint; config is the - // applied repo (already exposed via /api/agent-config). Reading - // `/var/lib/hyperhive/agents//state/...` is the intended use. if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) { let mut components = rel.components(); let _agent = components.next(); let dir = components.next().and_then(|c| c.as_os_str().to_str()); if dir != Some("state") { - return error_response(&format!( - "state-file: only per-agent state/ is readable here ({} dir not allowed)", + return Err(format!( + "only per-agent state/ is readable here ({} dir not allowed)", dir.unwrap_or("(root)") )); } } + Ok(canonical) +} + +async fn get_state_file( + axum::extract::Query(q): axum::extract::Query, +) -> Response { + const MAX_BYTES: usize = 1 << 20; // 1 MiB + let canonical = match resolve_state_path(&q.path) { + Ok(p) => p, + Err(e) => return error_response(&format!("state-file: {e}")), + }; let meta = match std::fs::metadata(&canonical) { Ok(m) => m, Err(e) => return error_response(&format!("state-file: stat {}: {e}", canonical.display())), @@ -985,6 +987,36 @@ async fn get_state_file( ([("content-type", "text/plain; charset=utf-8")], body).into_response() } +#[derive(Deserialize)] +struct StateFileCheckReq { + paths: Vec, +} + +/// Batch existence/file-ness probe behind the path-link autodetect. +/// The client collects regex-candidate paths from a message body, +/// fires one POST with the whole batch, and downgrades anchors +/// whose result is `false` back to plain text. Same security rules +/// as `get_state_file` (via `resolve_state_path`); a path is `true` +/// iff it resolves, lives in the allow-list, and is a regular file +/// (not a dir, symlink-to-dir, missing file, or forbidden subtree). +/// Capped per-request to keep a runaway message body from +/// triggering thousands of canonicalize calls in one request. +async fn post_state_file_check( + axum::Json(req): axum::Json, +) -> Response { + const MAX_PATHS: usize = 64; + let mut out: std::collections::HashMap = + std::collections::HashMap::with_capacity(req.paths.len().min(MAX_PATHS)); + for raw in req.paths.into_iter().take(MAX_PATHS) { + let is_file = match resolve_state_path(&raw) { + Ok(p) => std::fs::metadata(&p).is_ok_and(|m| m.is_file()), + Err(_) => false, + }; + out.insert(raw, is_file); + } + axum::Json(serde_json::json!({ "results": out })).into_response() +} + async fn api_reminders(State(state): State) -> Response { match state.coord.broker.list_pending_reminders() { Ok(rows) => axum::Json(rows).into_response(), From 76e4034e01f78c2f37e615878c454de57e631c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:44:50 +0200 Subject: [PATCH 7/7] path linkify: server attaches file_refs at message ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drop the /api/state-file/check probe endpoint (which let any dashboard visitor enumerate filesystem layout by feeding paths) and the client's optimistic-then-downgrade dance. instead, the broker forwarder calls scan_validated_paths(body) — same allow-list helper as the read endpoint — and attaches the verified file tokens to DashboardEvent::Sent/Delivered as file_refs: Vec. /dashboard/history backfill does the same per-row. client appendLinkified takes a (text, refs) pair, walks left-to-right linkifying every occurrence of any ref token, longest-first tie-break. no regex, no probe, no cache, no queue. when refs is empty/absent the body emits as plain text (question/answer/reminder rendering — refs for those are a follow-up). operator inbox stores file_refs from the sent event so its renderer gets the same anchors as the message-flow terminal. --- docs/web-ui.md | 19 ++-- hive-c0re/assets/app.js | 156 ++++++++++-------------------- hive-c0re/src/dashboard.rs | 82 ++++++++++------ hive-c0re/src/dashboard_events.rs | 11 +++ hive-c0re/src/main.rs | 4 + 5 files changed, 131 insertions(+), 141 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index 1304e66e..dd933a9b 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -208,12 +208,19 @@ not ours. and the host form. Canonicalises + verifies the path stays inside the allow-list, refuses anything but a regular file, refuses `/agents//claude` / `config` subtrees, truncates - bodies at 1 MiB. Backs the dashboard's inline path-link - preview (PATH_RE detects pointer strings in message bodies, - question/answer text, and the operator inbox; clicking - expands a `
` that lazy-fetches via this endpoint). - Trailing-slash matches (i.e. directory paths) are skipped on - the client side — only files linkify. + bodies at 1 MiB. Click-time backing for the inline path-link + preview. + + Detection of which tokens *are* path links is done + **server-side at broker-message ingest**, not client-side: + the broker forwarder calls `scan_validated_paths(body)` — + same allow-list helper the read endpoint uses — and attaches + the verified file tokens to the event as `file_refs: Vec`. + The client trusts that list and linkifies only those tokens, + so directories, missing files, and forbidden subtrees never + become anchors. No probe endpoint, no client-side regex + heuristics. Historical messages get the same treatment on + `/dashboard/history` backfill. - `GET /api/reminders` — list pending reminders for the dashboard's queued-reminders panel. - `POST /cancel-reminder/{id}` — hard-delete a pending reminder. diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 2c16c824..bd4a3e39 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -48,75 +48,6 @@ // perspective (we'd need to know which agent the message is about // to translate it). Prefer `/agents//state/...` in agent // outputs and the link will resolve. - // Match anything that *looks* like a path under the allow-listed - // roots; the server endpoint `/api/state-file/check` is the - // authority on whether each match is actually a file. Optimistic - // anchors render first; a batched validation request downgrades - // non-files (dirs, missing, forbidden subtrees) back to plain - // text. No client-side filename heuristics — the regex's job is - // just "spot a path-shaped token". - const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g; - - // Session-scoped truthiness cache for paths the server has - // already verified. `true` = render as a clickable anchor; - // `false` = strip the anchor on next reflow. Cleared only on - // page reload — agents creating new files mid-session show up - // next time the path is referenced. - const pathValidity = new Map(); - // Anchors awaiting validation. Keyed by path so we can rewrite - // every anchor for the same path in one shot when the result - // lands. Each entry: { anchor, details } so we can also drop - // the sibling preview when the path turns out to be invalid. - const pendingAnchors = new Map(); - let validateTimer = null; - function queuePathForValidation(path, anchor, details) { - if (!pendingAnchors.has(path)) pendingAnchors.set(path, []); - pendingAnchors.get(path).push({ anchor, details }); - if (validateTimer) clearTimeout(validateTimer); - // Coalesce bursts (a backfill replay can emit dozens of rows - // in one tick) into a single batched request. - validateTimer = setTimeout(flushPathValidation, 50); - } - async function flushPathValidation() { - validateTimer = null; - const paths = Array.from(pendingAnchors.keys()); - if (!paths.length) return; - // Snapshot the queue + clear it before we await — additional - // anchors that land while the request is in flight queue into - // a fresh batch. - const snapshot = new Map(pendingAnchors); - pendingAnchors.clear(); - let results = {}; - try { - const resp = await fetch('/api/state-file/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ paths }), - }); - if (resp.ok) results = (await resp.json()).results || {}; - } catch (err) { - console.warn('path validation batch failed', err); - // On transport failure leave anchors as-is — clicking them - // will surface the real error from /api/state-file inline. - return; - } - for (const [path, entries] of snapshot) { - const ok = !!results[path]; - pathValidity.set(path, ok); - if (ok) continue; - // Downgrade every pending anchor for this path back to - // plain text + drop its sibling
preview. - for (const { anchor, details } of entries) { - if (anchor.parentNode) { - anchor.parentNode.replaceChild(document.createTextNode(path), anchor); - } - if (details && details.parentNode) { - details.parentNode.removeChild(details); - } - } - } - } - async function fetchStateFile(path) { const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path)); const text = await resp.text(); @@ -153,42 +84,54 @@ }); return { anchor, details }; } - // Append `text` to `parent` as a mix of text nodes + path anchors. - // Returns the array of generated `
` previews so the - // caller can append them as block siblings under the row. - // Anchors render optimistically; paths unseen this session are - // queued for batch validation, and the server's verdict either - // confirms or strips them via `flushPathValidation`. - function appendLinkified(parent, text) { + // Append `text` to `parent` as a mix of text nodes + path + // anchors. `refs` is the server-attached `file_refs` array + // (verified-file tokens that appear in `text`); each occurrence + // of a ref in `text` becomes a clickable anchor + a sibling + //
preview that lazy-fetches from /api/state-file. + // Anything not in `refs` stays plain text. No client-side + // regex, no probe endpoint — the server saw the body first + // and made the call. When `refs` is empty/missing we just + // emit plain text. + function appendLinkified(parent, text, refs) { const previews = []; if (text == null) return previews; const str = String(text); - let lastIdx = 0; - PATH_RE.lastIndex = 0; - let m; - while ((m = PATH_RE.exec(str)) !== null) { - if (m.index > lastIdx) { - parent.appendChild(document.createTextNode(str.slice(lastIdx, m.index))); - } - const path = m[0]; - const cached = pathValidity.get(path); - if (cached === false) { - // Already known to be a non-file — render plain text, no - // anchor, no preview. The text still shows up so the - // operator sees the path; it's just not clickable. - parent.appendChild(document.createTextNode(path)); - } else { - const { anchor, details } = makePathPreview(path); - parent.appendChild(anchor); - previews.push(details); - // Unknown paths queue for validation; known-good ones - // skip the roundtrip entirely. - if (cached !== true) queuePathForValidation(path, anchor, details); - } - lastIdx = m.index + m[0].length; + const tokens = (refs || []).slice(); + if (!tokens.length) { + if (str) parent.appendChild(document.createTextNode(str)); + return previews; } - if (lastIdx < str.length) { - parent.appendChild(document.createTextNode(str.slice(lastIdx))); + // Walk the string left-to-right, at each step looking for the + // next occurrence of any token. Longest-first tie-break so a + // ref like `/agents/foo/state/x.md` wins over a (hypothetical) + // shorter token that prefixes it. O(text * refs) worst case; + // refs is bounded server-side to whatever fits in a body, so + // this stays cheap. + tokens.sort((a, b) => b.length - a.length); + let i = 0; + while (i < str.length) { + let bestStart = -1; + let bestToken = null; + for (const t of tokens) { + const idx = str.indexOf(t, i); + if (idx === -1) continue; + if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) { + bestStart = idx; + bestToken = t; + } + } + if (bestStart === -1) { + parent.appendChild(document.createTextNode(str.slice(i))); + break; + } + if (bestStart > i) { + parent.appendChild(document.createTextNode(str.slice(i, bestStart))); + } + const { anchor, details } = makePathPreview(bestToken); + parent.appendChild(anchor); + previews.push(details); + i = bestStart + bestToken.length; } return previews; } @@ -1000,7 +943,12 @@ const operatorInbox = []; function inboxAppendFromEvent(ev) { if (ev.kind !== 'sent' || ev.to !== 'operator') return false; - operatorInbox.unshift({ from: ev.from, body: ev.body, at: ev.at }); + operatorInbox.unshift({ + from: ev.from, + body: ev.body, + at: ev.at, + file_refs: ev.file_refs || [], + }); if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT; return true; } @@ -1017,7 +965,7 @@ for (const m of operatorInbox) { const li = el('li'); const body = el('span', { class: 'msg-body' }); - const previews = appendLinkified(body, m.body); + const previews = appendLinkified(body, m.body, m.file_refs); li.append( el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', el('span', { class: 'msg-from' }, m.from), ' ', @@ -1543,7 +1491,7 @@ to.className = 'msg-to'; to.textContent = ev.to; const body = document.createElement('span'); body.className = 'msg-body'; - const previews = appendLinkified(body, ev.body); + const previews = appendLinkified(body, ev.body, ev.file_refs); row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); for (const d of previews) row.appendChild(d); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 73618cf8..c23acb74 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -55,7 +55,6 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/api/journal/{name}", get(get_journal)) .route("/api/state-file", get(get_state_file)) - .route("/api/state-file/check", post(post_state_file_check)) .route("/api/reminders", get(api_reminders)) .route("/cancel-reminder/{id}", post(post_cancel_reminder)) .route("/api/agent-config/{name}", get(get_agent_config)) @@ -635,21 +634,25 @@ async fn dashboard_history(State(state): State) -> Response { .into_iter() .map(|m| match m { crate::broker::MessageEvent::Sent { from, to, body, at } => { + let file_refs = scan_validated_paths(&body); crate::dashboard_events::DashboardEvent::Sent { seq: 0, from, to, body, at, + file_refs, } } crate::broker::MessageEvent::Delivered { from, to, body, at } => { + let file_refs = scan_validated_paths(&body); crate::dashboard_events::DashboardEvent::Delivered { seq: 0, from, to, body, at, + file_refs, } } }) @@ -953,6 +956,53 @@ fn resolve_state_path(raw: &str) -> std::result::Result Vec { + const PREFIXES: [&str; 4] = [ + "/agents/", + "/shared/", + "/var/lib/hyperhive/agents/", + "/var/lib/hyperhive/shared/", + ]; + let mut out = Vec::::new(); + for raw in body.split(|c: char| c.is_whitespace()) { + // Trim trailing natural-language punctuation that wouldn't + // be part of any real path. Inline rather than via a regex + // dep — the set is small and the call is hot. + let token = raw.trim_end_matches(|c: char| matches!(c, ',' | ';' | ':' | ')' | ']' | '}' | '.' | '\'' | '"')); + if token.is_empty() { + continue; + } + if !PREFIXES.iter().any(|p| token.starts_with(p)) { + continue; + } + // Cheap dedupe — typical message has 0-3 refs. + if out.iter().any(|s| s == token) { + continue; + } + if let Ok(canonical) = resolve_state_path(token) { + if std::fs::metadata(&canonical).is_ok_and(|m| m.is_file()) { + out.push(token.to_owned()); + } + } + } + out +} + async fn get_state_file( axum::extract::Query(q): axum::extract::Query, ) -> Response { @@ -987,36 +1037,6 @@ async fn get_state_file( ([("content-type", "text/plain; charset=utf-8")], body).into_response() } -#[derive(Deserialize)] -struct StateFileCheckReq { - paths: Vec, -} - -/// Batch existence/file-ness probe behind the path-link autodetect. -/// The client collects regex-candidate paths from a message body, -/// fires one POST with the whole batch, and downgrades anchors -/// whose result is `false` back to plain text. Same security rules -/// as `get_state_file` (via `resolve_state_path`); a path is `true` -/// iff it resolves, lives in the allow-list, and is a regular file -/// (not a dir, symlink-to-dir, missing file, or forbidden subtree). -/// Capped per-request to keep a runaway message body from -/// triggering thousands of canonicalize calls in one request. -async fn post_state_file_check( - axum::Json(req): axum::Json, -) -> Response { - const MAX_PATHS: usize = 64; - let mut out: std::collections::HashMap = - std::collections::HashMap::with_capacity(req.paths.len().min(MAX_PATHS)); - for raw in req.paths.into_iter().take(MAX_PATHS) { - let is_file = match resolve_state_path(&raw) { - Ok(p) => std::fs::metadata(&p).is_ok_and(|m| m.is_file()), - Err(_) => false, - }; - out.insert(raw, is_file); - } - axum::Json(serde_json::json!({ "results": out })).into_response() -} - async fn api_reminders(State(state): State) -> Response { match state.coord.broker.list_pending_reminders() { Ok(rows) => axum::Json(rows).into_response(), diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index e17a23e0..4e531c26 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -31,20 +31,31 @@ use crate::container_view::ContainerView; #[serde(rename_all = "snake_case", tag = "kind")] pub enum DashboardEvent { /// Broker `Sent` event mirrored onto the dashboard channel. + /// `file_refs` carries every path-shaped token in `body` that + /// hive-c0re verified is a regular file under the allow-listed + /// roots (per-agent `state/` + `shared/`). The forwarder + /// pre-validates so the dashboard doesn't need a probe + /// endpoint — the client renders anchors only for tokens that + /// appear in this list, everything else stays plain text. Sent { seq: u64, from: String, to: String, body: String, at: i64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + file_refs: Vec, }, /// Broker `Delivered` event mirrored onto the dashboard channel. + /// `file_refs` is the same shape as `Sent`. Delivered { seq: u64, from: String, to: String, body: String, at: i64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + file_refs: Vec, }, /// A new approval landed in the pending queue. Payload carries /// enough to render the dashboard row without a `/api/state` diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index c543269e..052d6470 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -226,21 +226,25 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { loop { match rx.recv().await { Ok(MessageEvent::Sent { from, to, body, at }) => { + let file_refs = dashboard::scan_validated_paths(&body); coord.emit_dashboard_event(DashboardEvent::Sent { seq: coord.next_seq(), from, to, body, at, + file_refs, }); } Ok(MessageEvent::Delivered { from, to, body, at }) => { + let file_refs = dashboard::scan_validated_paths(&body); coord.emit_dashboard_event(DashboardEvent::Delivered { seq: coord.next_seq(), from, to, body, at, + file_refs, }); } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {