Watch
0
0
Fork
You've already forked hyperhive
0

dashboard: remove the state-file endpoint, the file_refs path scan and the unused marked and dompurify deps

This commit is contained in:
iris 2026-10-22 00:02:43 +02:00 • committed by mara
commit 6d080ea28b
11 changed files with 12 additions and 419 deletions

View file

@ -339,8 +339,7 @@ Under `/var/lib/hyperhive/agents/<name>/`:
`work/` doesn't walk up into `state/AGENTS.md` and load the agent's `work/` doesn't walk up into `state/AGENTS.md` and load the agent's
own identity. Survives `destroy`/recreate, gone on `--purge`. Counted own identity. Survives `destroy`/recreate, gone on `--purge`. Counted
in the per-agent disk metric (`measure_agent_disk`) together with in the per-agent disk metric (`measure_agent_disk`) together with
`state/` and the container rootfs. The dashboard doesn't serve it: `state/` and the container rootfs. The dashboard doesn't serve it.
the state-file endpoint reads `state/` only.
### Cross-agent access to state ### Cross-agent access to state

View file

@ -2,8 +2,8 @@
## Agent trust model ## Agent trust model
The sections below document specific mechanisms (the state-file endpoint, The sections below document specific mechanisms (nixbld isolation,
nixbld isolation, privilege separation). This section frames the model they privilege separation). This section frames the model they
serve: **what hyperhive defends, what it deliberately doesn't, and where the serve: **what hyperhive defends, what it deliberately doesn't, and where the
operator is accepting risk.** It's the reference for "is it safe to give an operator is accepting risk.** It's the reference for "is it safe to give an
agent capability X?." agent capability X?."
@ -20,9 +20,8 @@ default**. Isolating credentials _from the agent itself_ is therefore **not a
goal**: an agent can read its own tokens, its own `/home/<name>/.claude`, and goal**: an agent can read its own tokens, its own `/home/<name>/.claude`, and
run arbitrary commands as root inside its container. (The narrow exception is run arbitrary commands as root inside its container. (The narrow exception is
_cross-tenant_ leakage — for example the unsandboxed-nix-build `0600` token policy _cross-tenant_ leakage — for example the unsandboxed-nix-build `0600` token policy
below stops a build's nixbld user reading the agent's own forge token, and the below stops a build's nixbld user reading the agent's own forge token. That hardens
state-file endpoint stops one agent proxying another's files. Those harden the the boundary; it doesn't sandbox the agent from itself.)
boundary; they don't sandbox the agent from itself.)
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
@ -162,45 +161,10 @@ into an agent's config, that agent has exactly that reach, with no hyperhive
layer narrowing it. Scope external tokens tightly at the source (the external layer narrowing it. Scope external tokens tightly at the source (the external
provider) before handing them over. provider) before handing them over.
## State-file endpoint security model ## Agent-supplied names in paths
`GET /api/state-file?path=<p>` serves files from agent state dirs and Wherever an agent-supplied name reaches a filesystem path, hyperhive validates
the shared space to authenticated dashboard users (browser, operator). it first. The agent socket's `GetAgentMeta` takes `name` as a serde-validated
It accepts two allow-listed root prefixes and rejects all other paths
before touching the filesystem:
- `/var/lib/hyperhive/agents/<n>/state/` — per-agent durable notes
(canonical host form or the in-container view `/agents/<n>/state/`)
- `/var/lib/hyperhive/shared/` — shared docs (`/shared/` in-container)
`/state/...` without an agent prefix is explicitly _not_ accepted — it's
ambiguous from the host's perspective.
Defense-in-depth layers (in order):
1. **Allow-list prefix check** — rejects without touching the filesystem
if the path doesn't match either root.
2. **No symlinks below the matched root** — each path component is
checked with `symlink_metadata` before canonicalize. A sub-agent
that plants `ln -s /other/secret /agents/me/state/peek` can't proxy
another agent's file through this endpoint (canonicalize would
happily resolve the symlink to a still-within-allow-list path).
3. **Canonicalize as belt-and-braces** — resolves `..`/`.` traversal
and rejects if the result escapes the roots.
4. **`state/` subdir constraint** — under `AGENTS_ROOT`, the second
path component must be `state/`. Applied, proposed git repos and
config dirs are off-limits.
5. **World-readable check** — file must have `mode & 0o004` set.
A `0600` file inside `state/` would otherwise be accessible to any
operator with dashboard access.
`scan_validated_paths` (broker-message ingest, linkifier) uses the same
`resolve_state_path` helper so security rules stay in sync — the
dashboard renders anchors only for tokens that passed the same checks the
read endpoint enforces.
The same invariant holds wherever an agent-supplied name reaches a filesystem
path: the agent socket's `GetAgentMeta` takes `name` as a serde-validated
`hive_types::Ident` (or falls back to `Ident::parse` for the "self" case) `hive_types::Ident` (or falls back to `Ident::parse` for the "self" case)
before building `agent_notes_dir(name)`, so a `..` component can't traverse. before building `agent_notes_dir(name)`, so a `..` component can't traverse.

View file

@ -555,23 +555,6 @@ below — some endpoints aren't in it yet.
stdout+stderr as `text/plain`. Used by the `↓ raw` / `↓ download stdout+stderr as `text/plain`. Used by the `↓ raw` / `↓ download
raw` links in the build tabs. Works on both finished and in-progress raw` links in the build tabs. Works on both finished and in-progress
build logs. build logs.
- `GET /static/marked.js` serves the vendored `marked` bundle used
for markdown previews.
- `GET /api/state-file?path=<host-or-container-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/<n>/state/...`, `/shared/...`)
and the host form. Canonicalises + verifies the path stays
inside the allow-list, refuses anything but a regular file,
refuses `/agents/<n>/claude` / `config` subtrees, truncates
bodies at 1 MiB. No dashboard page calls it since the flow page
stopped existing.
The broker forwarder still detects path tokens **server-side at
broker-message ingest**: it 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<String>`.
No dashboard page reads that list either.
- `GET /api/reminders` — list pending reminders for the - `GET /api/reminders` — list pending reminders for the
dashboard's queued-reminders panel. dashboard's queued-reminders panel.
- `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide - `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide

View file

@ -1001,8 +1001,6 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@hive/shared": "*", "@hive/shared": "*",
"dompurify": "^3.2.4",
"marked": "18.0.6",
"preact": "10.29.8" "preact": "10.29.8"
} }
}, },

View file

@ -9,8 +9,6 @@
}, },
"dependencies": { "dependencies": {
"@hive/shared": "*", "@hive/shared": "*",
"dompurify": "^3.2.4",
"marked": "18.0.6",
"preact": "10.29.8" "preact": "10.29.8"
} }
} }

View file

@ -40,7 +40,6 @@ use crate::lifecycle;
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"), (name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
(name = "rebuild_queue", description = "rebuild-queue cancel"), (name = "rebuild_queue", description = "rebuild-queue cancel"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"), (name = "state_snapshot", description = "cold-load dashboard snapshot"),
(name = "tombstones", description = "purge of retained state for destroyed agents"), (name = "tombstones", description = "purge of retained state for destroyed agents"),
) )
@ -58,7 +57,6 @@ mod lifecycle_ops;
mod meta_inputs; mod meta_inputs;
mod misc_api; mod misc_api;
mod rebuild_queue; mod rebuild_queue;
mod state_files;
mod state_snapshot; mod state_snapshot;
mod tombstones; mod tombstones;
@ -67,10 +65,6 @@ mod tombstones;
// Re-exported to preserve the `crate::dashboard::*` paths across the split. // Re-exported to preserve the `crate::dashboard::*` paths across the split.
pub use meta_inputs::MetaInputView; pub use meta_inputs::MetaInputView;
pub(crate) use meta_inputs::emit_meta_inputs_snapshot; pub(crate) use meta_inputs::emit_meta_inputs_snapshot;
// Run at broker-message ingest by the coordinator + the operator-msg path
// (`main.rs`); re-exported to preserve the `crate::dashboard::scan_validated_paths`
// path across the split.
pub use state_files::scan_validated_paths;
// Called after destroy/purge/spawn finalisation (`actions.rs`); the view // Called after destroy/purge/spawn finalisation (`actions.rs`); the view
// type feeds `DashboardEvent::TombstonesChanged` (`dashboard_events.rs`). // type feeds `DashboardEvent::TombstonesChanged` (`dashboard_events.rs`).
// Re-exported to preserve the `crate::dashboard::*` paths across the split. // Re-exported to preserve the `crate::dashboard::*` paths across the split.
@ -114,7 +108,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(health::get_health_live)) .routes(routes!(health::get_health_live))
.routes(routes!(health::get_health_ready)) .routes(routes!(health::get_health_ready))
.routes(routes!(state_snapshot::api_state)) .routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(build_logs::get_build_logs_all)) .routes(routes!(build_logs::get_build_logs_all))
@ -366,7 +359,6 @@ mod router_build_probe {
.routes(routes!(health::get_health_live)) .routes(routes!(health::get_health_live))
.routes(routes!(health::get_health_ready)) .routes(routes!(health::get_health_ready))
.routes(routes!(state_snapshot::api_state)) .routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(build_logs::get_build_logs_all)) .routes(routes!(build_logs::get_build_logs_all))

View file

@ -1,322 +0,0 @@
//! State-file proxy + path-validation for the dashboard.
//!
//! `GET /api/state-file?path=…` serves an allow-listed file (per-agent
//! `state/` or `shared/`) with defense-in-depth symlink + traversal checks
//! (see `docs/trust-boundary/security.md::State-file endpoint`); raster images are served
//! with their real content-type, everything else as truncated text.
//! `scan_validated_paths` runs the same allow-list at broker-message ingest
//! so dashboard events carry a pre-verified file-ref set.
use std::path::Path;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use utoipa::IntoParams;
use super::error_response;
use crate::paths::{AGENTS_ROOT, SHARED_ROOT};
#[derive(Deserialize, IntoParams)]
pub(super) struct StateFileQuery {
/// Absolute path under an agent's `state/` dir or under `shared/`;
/// checked against the allow-list (`docs/trust-boundary/security.md::State-file
/// endpoint`).
path: String,
}
/// Resolve a caller-supplied path against the allow-listed roots
/// (`agents/<n>/state/` and `shared/`). Applies defense-in-depth
/// symlink + traversal checks before serving. Security model and
/// all five layers: `docs/trust-boundary/security.md::State-file endpoint`.
fn resolve_state_path(
raw: &str,
) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> {
use std::os::unix::fs::PermissionsExt as _;
let raw = raw.trim();
let (mapped, root): (std::path::PathBuf, &str) =
if let Some(rest) = raw.strip_prefix("/agents/") {
(
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
AGENTS_ROOT,
)
} else if let Some(rest) = raw.strip_prefix("/shared/") {
(
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
SHARED_ROOT,
)
} else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) {
(
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
AGENTS_ROOT,
)
} else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) {
(
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
SHARED_ROOT,
)
} else {
return Err(format!("path not in allow-list: {raw}"));
};
reject_symlinks_below(std::path::Path::new(root), &mapped)?;
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()
));
}
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 Err(format!(
"only per-agent state/ is readable here ({} dir not allowed)",
dir.unwrap_or("(root)")
));
}
}
let meta =
std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?;
if meta.is_file() {
let mode = meta.permissions().mode();
if mode & 0o004 == 0 {
return Err(format!(
"{} not world-readable (mode 0{:o}); refusing to proxy non-public file",
canonical.display(),
mode & 0o777,
));
}
}
Ok((canonical, meta))
}
/// Walk every path component under `root` and refuse if any of
/// them is a symlink. The roots themselves (`AGENTS_ROOT`,
/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only
/// the parts the agent / operator can plant matter. Components
/// that don't exist yet are skipped — `canonicalize` reports
/// non-existence separately, and missing-component checks would
/// just race the filesystem.
fn reject_symlinks_below(
root: &std::path::Path,
mapped: &std::path::Path,
) -> std::result::Result<(), String> {
let Ok(rel) = mapped.strip_prefix(root) else {
return Ok(());
};
let mut cumulative = root.to_path_buf();
for component in rel.components() {
match component {
std::path::Component::Normal(name) => {
cumulative.push(name);
match std::fs::symlink_metadata(&cumulative) {
Ok(m) if m.file_type().is_symlink() => {
return Err(format!(
"symlink at {} not allowed (canonicalize would resolve it past the \
allow-list check; refuse outright)",
cumulative.display()
));
}
Ok(_) | Err(_) => {}
}
}
std::path::Component::ParentDir => {
return Err(format!(
"path contains `..` traversal below {}; refuse outright",
root.display()
));
}
_ => {}
}
}
Ok(())
}
/// Scan `body` for path-shaped tokens and return those that pass the
/// allow-list + `is_file` check via `resolve_state_path`. Called at
/// broker-message ingest so the dashboard event already carries the
/// verified set; security rules stay in sync with the read endpoint.
pub fn scan_validated_paths(body: &str) -> Vec<String> {
let agents_slash = format!("{AGENTS_ROOT}/");
let shared_slash = format!("{SHARED_ROOT}/");
let prefixes: [&str; 4] = ["/agents/", "/shared/", &agents_slash, &shared_slash];
let mut out = Vec::<String>::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([',', ';', ':', ')', ']', '}', '.', '\'', '"']);
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, meta)) = resolve_state_path(token)
&& meta.is_file()
{
out.push(token.to_owned());
}
}
out
}
/// Serve an allow-listed file.
///
/// Raster images get their real content-type; everything else is
/// served as (possibly truncated) text.
#[utoipa::path(
get,
path = "/api/state-file",
params(StateFileQuery),
responses(
(status = 200, description = "file contents (text, truncated at 1 MiB) or image bytes"),
(status = 500, description = "path outside the allow-list, not a regular file, or read failed"),
),
tag = "state_files"
)]
pub(super) async fn get_state_file(
axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
) -> Response {
const MAX_BYTES: usize = 1 << 20; // 1 MiB
let (canonical, meta) = match resolve_state_path(&q.path) {
Ok(pair) => pair,
Err(e) => return error_response(&format!("state-file: {e}")),
};
if !meta.is_file() {
return error_response(&format!(
"state-file: {} is not a regular file",
canonical.display()
));
}
let size = meta.len();
let bytes = match std::fs::read(&canonical) {
Ok(b) => b,
Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())),
};
// Raster images: serve the raw bytes with their real content-type
// so the dashboard can render them in an <img>. Not truncated —
// a clipped binary is corrupt, so over-cap images are rejected
// instead. (SVG stays on the text path: it's text, and the client
// renders it via a data: URI.)
if let Some(ct) = image_content_type(&canonical) {
if bytes.len() > MAX_BYTES {
return error_response(&format!(
"state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap",
canonical.display()
));
}
return ([("content-type", ct)], bytes).into_response();
}
let truncated = bytes.len() > MAX_BYTES;
let body_bytes = if truncated {
&bytes[..MAX_BYTES]
} else {
&bytes[..]
};
let mut body = String::from_utf8_lossy(body_bytes).into_owned();
if truncated {
use std::fmt::Write as _;
let _ = write!(
body,
"\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n"
);
}
([("content-type", "text/plain; charset=utf-8")], body).into_response()
}
/// Content-type for a raster image the dashboard can preview in an
/// `<img>`, keyed off the file extension. `None` for non-image, SVG,
/// and text files (SVG is served on the text path and rendered
/// client-side via a `data:` URI).
fn image_content_type(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"avif" => "image/avif",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
/// Make a unique tmp subdir for the calling test. Caller is responsible
/// for cleanup (we leak on panic, fine for ephemeral CI runs).
fn tmproot(tag: &str) -> std::path::PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn reject_symlinks_below_accepts_plain_dirs_and_files() {
let root = tmproot("symlink-ok");
std::fs::create_dir_all(root.join("alice/state")).unwrap();
std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap();
assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok());
}
#[test]
fn reject_symlinks_below_rejects_leaf_symlink() {
let root = tmproot("symlink-leaf");
std::fs::create_dir_all(root.join("alice/state")).unwrap();
// Plant a symlink that points anywhere; resolve_state_path's
// canonicalize would happily resolve it past the allow-list
// check, so we have to refuse at the un-canonical layer.
symlink("/etc/shadow", root.join("alice/state/peek")).unwrap();
let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err();
assert!(err.contains("symlink at"), "msg = {err}");
assert!(err.contains("peek"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_rejects_directory_symlink_in_middle() {
let root = tmproot("symlink-mid");
std::fs::create_dir_all(root.join("real/state")).unwrap();
std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap();
// alice's "state" dir is actually a symlink to real/state — a
// sub-agent shouldn't be able to plant this and proxy real's
// private files via the dashboard.
std::fs::create_dir_all(root.join("alice")).unwrap();
symlink(root.join("real/state"), root.join("alice/state")).unwrap();
let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err();
assert!(err.contains("symlink at"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_rejects_parent_dir_traversal() {
let root = tmproot("symlink-dotdot");
// `..` doesn't survive canonicalize anyway, but we want a
// friendlier error than "path escapes allow-list" — refusing
// upfront also avoids walking ancestors with `symlink_metadata`.
let p = root.join("alice/state/../escape");
let err = reject_symlinks_below(&root, &p).unwrap_err();
assert!(err.contains("`..`"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_passes_through_when_path_not_under_root() {
// resolve_state_path's earlier allow-list check would reject
// this; reject_symlinks_below stays a no-op so the caller
// surfaces the better-fit error.
let root = std::path::Path::new("/var/lib/hyperhive/agents");
assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok());
}
}

View file

@ -14,12 +14,6 @@ use chrono::{DateTime, Utc};
#[serde(rename_all = "snake_case", tag = "kind")] #[serde(rename_all = "snake_case", tag = "kind")]
pub enum DashboardEvent { pub enum DashboardEvent {
/// Broker `Sent` event mirrored onto the dashboard channel. /// 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 { Sent {
seq: u64, seq: u64,
/// Broker row id. Allows the dashboard to track reply threads. /// Broker row id. Allows the dashboard to track reply threads.
@ -30,11 +24,8 @@ pub enum DashboardEvent {
at: DateTime<Utc>, at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
in_reply_to: Option<i64>, in_reply_to: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
file_refs: Vec<String>,
}, },
/// Broker `Delivered` event mirrored onto the dashboard channel. /// Broker `Delivered` event mirrored onto the dashboard channel.
/// `file_refs` is the same shape as `Sent`.
Delivered { Delivered {
seq: u64, seq: u64,
/// Broker row id. Allows the dashboard to track reply threads. /// Broker row id. Allows the dashboard to track reply threads.
@ -45,8 +36,6 @@ pub enum DashboardEvent {
at: DateTime<Utc>, at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
in_reply_to: Option<i64>, in_reply_to: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
file_refs: Vec<String>,
}, },
/// A lifecycle action started for an agent (spawn / start / stop /// A lifecycle action started for an agent (spawn / start / stop
/// / restart / rebuild / destroy). Clients render a spinner next /// / restart / rebuild / destroy). Clients render a spinner next
@ -201,7 +190,6 @@ mod tests {
body: String::new(), body: String::new(),
at: hive_sh4re::wire_time::from_secs(0), at: hive_sh4re::wire_time::from_secs(0),
in_reply_to: None, in_reply_to: None,
file_refs: Vec::new(),
}, },
DashboardEvent::Delivered { DashboardEvent::Delivered {
seq: 1, seq: 1,
@ -211,7 +199,6 @@ mod tests {
body: String::new(), body: String::new(),
at: hive_sh4re::wire_time::from_secs(0), at: hive_sh4re::wire_time::from_secs(0),
in_reply_to: None, in_reply_to: None,
file_refs: Vec::new(),
}, },
DashboardEvent::TransientSet { DashboardEvent::TransientSet {
seq: 1, seq: 1,

View file

@ -469,7 +469,6 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
at, at,
in_reply_to, in_reply_to,
}) => { }) => {
let file_refs = dashboard::scan_validated_paths(&body);
coord.emit_dashboard_event(DashboardEvent::Sent { coord.emit_dashboard_event(DashboardEvent::Sent {
seq: coord.next_seq(), seq: coord.next_seq(),
id, id,
@ -478,7 +477,6 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
body, body,
at: hive_sh4re::wire_time::from_secs(at), at: hive_sh4re::wire_time::from_secs(at),
in_reply_to, in_reply_to,
file_refs,
}); });
} }
Ok(MessageEvent::Delivered { Ok(MessageEvent::Delivered {
@ -489,7 +487,6 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
at, at,
in_reply_to, in_reply_to,
}) => { }) => {
let file_refs = dashboard::scan_validated_paths(&body);
coord.emit_dashboard_event(DashboardEvent::Delivered { coord.emit_dashboard_event(DashboardEvent::Delivered {
seq: coord.next_seq(), seq: coord.next_seq(),
id, id,
@ -498,7 +495,6 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
body, body,
at: hive_sh4re::wire_time::from_secs(at), at: hive_sh4re::wire_time::from_secs(at),
in_reply_to, in_reply_to,
file_refs,
}); });
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {

View file

@ -195,8 +195,7 @@ pub fn meta_git_index_lock() -> PathBuf {
meta_root().join(".git/index.lock") meta_root().join(".git/index.lock")
} }
/// `shared/` — the cross-agent `/shared` scratch space. A `&str` (the /// `shared/` — the cross-agent `/shared` scratch space. A `&str`, so it
/// dashboard state-file allow-list uses it for prefix checks), so it
/// stays a const; [`shared_root`] wraps it. /// stays a const; [`shared_root`] wraps it.
// nix: bind-mounted into every agent container as `/shared` (the harness nix modules) — must match. // nix: bind-mounted into every agent container as `/shared` (the harness nix modules) — must match.
pub const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; pub const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";

View file

@ -12,9 +12,8 @@ use std::process::{Command, Stdio};
const TOKEN: &str = "test-token-value"; const TOKEN: &str = "test-token-value";
/// A unique scratch `HYPERHIVE_STATE_DIR`, seeded with a `forge-token` /// A unique scratch `HYPERHIVE_STATE_DIR`, seeded with a `forge-token`
/// file. Matches the manual-tmpdir pattern `hive-c0re`'s /// file. Built by hand under the system temp dir, since this crate carries no
/// `dashboard::state_files` tests use — this crate carries no `tempfile` /// `tempfile` dependency to reach for instead.
/// dependency to reach for instead.
fn scratch_state_dir(tag: &str) -> PathBuf { fn scratch_state_dir(tag: &str) -> PathBuf {
let ts = std::time::SystemTime::now() let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)