diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 112b3e77..142b7d05 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -339,7 +339,8 @@ Under `/var/lib/hyperhive/agents//`: `work/` doesn't walk up into `state/AGENTS.md` and load the agent's own identity. Survives `destroy`/recreate, gone on `--purge`. Counted 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 diff --git a/docs/trust-boundary/security.md b/docs/trust-boundary/security.md index 0b69a089..a0a57a9b 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -2,8 +2,8 @@ ## Agent trust model -The sections below document specific mechanisms (nixbld isolation, -privilege separation). This section frames the model they +The sections below document specific mechanisms (the state-file endpoint, +nixbld isolation, privilege separation). This section frames the model they 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 agent capability X?." @@ -20,8 +20,9 @@ default**. Isolating credentials _from the agent itself_ is therefore **not a goal**: an agent can read its own tokens, its own `/home//.claude`, and run arbitrary commands as root inside its container. (The narrow exception is _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. That hardens -the boundary; it doesn't sandbox the agent from itself.) +below stops a build's nixbld user reading the agent's own forge token, and the +state-file endpoint stops one agent proxying another's files. Those harden the +boundary; they don't sandbox the agent from itself.) @@ -161,10 +162,45 @@ 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 provider) before handing them over. -## Agent-supplied names in paths +## State-file endpoint security model -Wherever an agent-supplied name reaches a filesystem path, hyperhive validates -it first. The agent socket's `GetAgentMeta` takes `name` as a serde-validated +`GET /api/state-file?path=

` serves files from agent state dirs and +the shared space to authenticated dashboard users (browser, operator). +It accepts two allow-listed root prefixes and rejects all other paths +before touching the filesystem: + +- `/var/lib/hyperhive/agents//state/` — per-agent durable notes + (canonical host form or the in-container view `/agents//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) before building `agent_notes_dir(name)`, so a `..` component can't traverse. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index a305e2b3..4e712c54 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -555,6 +555,23 @@ below — some endpoints aren't in it yet. stdout+stderr as `text/plain`. Used by the `↓ raw` / `↓ download raw` links in the build tabs. Works on both finished and in-progress build logs. +- `GET /static/marked.js` serves the vendored `marked` bundle used + for markdown previews. +- `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. 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`. + No dashboard page reads that list either. - `GET /api/reminders` — list pending reminders for the dashboard's queued-reminders panel. - `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide diff --git a/frontend/npm-deps-hash b/frontend/npm-deps-hash index 22918726..2616ec20 100644 --- a/frontend/npm-deps-hash +++ b/frontend/npm-deps-hash @@ -1 +1 @@ -sha256-CkojHOVdQJGFhqZV8A8dBtR2hb04cRSgd1PbV9LYkzQ= +sha256-cX/fNFPKFmN88KQiyau2yYp3xydx5wrv5ZFdMud7UcQ= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aa9a88a4..0f272639 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1001,6 +1001,8 @@ "version": "0.0.0", "dependencies": { "@hive/shared": "*", + "dompurify": "^3.2.4", + "marked": "18.0.6", "preact": "10.29.8" } }, diff --git a/frontend/packages/dashboard/package.json b/frontend/packages/dashboard/package.json index 0bc57fed..5a000b14 100644 --- a/frontend/packages/dashboard/package.json +++ b/frontend/packages/dashboard/package.json @@ -9,6 +9,8 @@ }, "dependencies": { "@hive/shared": "*", + "dompurify": "^3.2.4", + "marked": "18.0.6", "preact": "10.29.8" } } diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 5747569b..d2e90c07 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -40,6 +40,7 @@ use crate::lifecycle; (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 = "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 = "tombstones", description = "purge of retained state for destroyed agents"), ) @@ -57,6 +58,7 @@ mod lifecycle_ops; mod meta_inputs; mod misc_api; mod rebuild_queue; +mod state_files; mod state_snapshot; mod tombstones; @@ -65,6 +67,10 @@ mod tombstones; // Re-exported to preserve the `crate::dashboard::*` paths across the split. pub use meta_inputs::MetaInputView; 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 // type feeds `DashboardEvent::TombstonesChanged` (`dashboard_events.rs`). // Re-exported to preserve the `crate::dashboard::*` paths across the split. @@ -108,6 +114,7 @@ fn app(coord: Arc) -> axum::Router { .routes(routes!(health::get_health_live)) .routes(routes!(health::get_health_ready)) .routes(routes!(state_snapshot::api_state)) + .routes(routes!(state_files::get_state_file)) .routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(build_logs::get_build_logs_all)) @@ -359,6 +366,7 @@ mod router_build_probe { .routes(routes!(health::get_health_live)) .routes(routes!(health::get_health_ready)) .routes(routes!(state_snapshot::api_state)) + .routes(routes!(state_files::get_state_file)) .routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(build_logs::get_build_logs_all)) diff --git a/hive-c0re/src/dashboard/state_files.rs b/hive-c0re/src/dashboard/state_files.rs new file mode 100644 index 00000000..114eacf4 --- /dev/null +++ b/hive-c0re/src/dashboard/state_files.rs @@ -0,0 +1,322 @@ +//! 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//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 { + 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::::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, +) -> 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 . 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 +/// ``, 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()); + } +} diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index d640dc80..8327adc2 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -14,6 +14,12 @@ use chrono::{DateTime, Utc}; #[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, /// Broker row id. Allows the dashboard to track reply threads. @@ -24,8 +30,11 @@ pub enum DashboardEvent { at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] in_reply_to: Option, + #[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, /// Broker row id. Allows the dashboard to track reply threads. @@ -36,6 +45,8 @@ pub enum DashboardEvent { at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] in_reply_to: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + file_refs: Vec, }, /// A lifecycle action started for an agent (spawn / start / stop /// / restart / rebuild / destroy). Clients render a spinner next @@ -190,6 +201,7 @@ mod tests { body: String::new(), at: hive_sh4re::wire_time::from_secs(0), in_reply_to: None, + file_refs: Vec::new(), }, DashboardEvent::Delivered { seq: 1, @@ -199,6 +211,7 @@ mod tests { body: String::new(), at: hive_sh4re::wire_time::from_secs(0), in_reply_to: None, + file_refs: Vec::new(), }, DashboardEvent::TransientSet { seq: 1, diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 1c91613a..31e3fb6c 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -469,6 +469,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { at, in_reply_to, }) => { + let file_refs = dashboard::scan_validated_paths(&body); coord.emit_dashboard_event(DashboardEvent::Sent { seq: coord.next_seq(), id, @@ -477,6 +478,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { body, at: hive_sh4re::wire_time::from_secs(at), in_reply_to, + file_refs, }); } Ok(MessageEvent::Delivered { @@ -487,6 +489,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { at, in_reply_to, }) => { + let file_refs = dashboard::scan_validated_paths(&body); coord.emit_dashboard_event(DashboardEvent::Delivered { seq: coord.next_seq(), id, @@ -495,6 +498,7 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { body, at: hive_sh4re::wire_time::from_secs(at), in_reply_to, + file_refs, }); } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index d7cbddff..e35e8efb 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -195,7 +195,8 @@ pub fn meta_git_index_lock() -> PathBuf { meta_root().join(".git/index.lock") } -/// `shared/` — the cross-agent `/shared` scratch space. A `&str`, so it +/// `shared/` — the cross-agent `/shared` scratch space. A `&str` (the +/// dashboard state-file allow-list uses it for prefix checks), so it /// stays a const; [`shared_root`] wraps it. // nix: bind-mounted into every agent container as `/shared` (the harness nix modules) — must match. pub const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; diff --git a/hive-forge/tests/credential_helper.rs b/hive-forge/tests/credential_helper.rs index a4436a86..6c79de09 100644 --- a/hive-forge/tests/credential_helper.rs +++ b/hive-forge/tests/credential_helper.rs @@ -12,8 +12,9 @@ use std::process::{Command, Stdio}; const TOKEN: &str = "test-token-value"; /// A unique scratch `HYPERHIVE_STATE_DIR`, seeded with a `forge-token` -/// file. Built by hand under the system temp dir, since this crate carries no -/// `tempfile` dependency to reach for instead. +/// file. Matches the manual-tmpdir pattern `hive-c0re`'s +/// `dashboard::state_files` tests use — this crate carries no `tempfile` +/// dependency to reach for instead. fn scratch_state_dir(tag: &str) -> PathBuf { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)