dashboard: clickable file-path previews

agents constantly emit pointer strings to /agents/<n>/state/foo.md
since broker bodies cap at 1 KiB. now those tokens linkify in the
message flow, question bodies, answer text, and operator inbox;
clicking expands an inline <details> that lazy-fetches via the
new /api/state-file?path=... endpoint.

endpoint allow-list: per-agent state dirs + shared docs, both
in their container-mount form (/agents/<n>/state, /shared) and
host form (/var/lib/hyperhive/...). 1 MiB read cap; canonicalises
before the prefix check so `..` / symlinks can't escape.

legacy bare `/state/...` is deliberately not matched — ambiguous
from the host's perspective (we'd need to know which agent the
message references to translate). agents should use the qualified
form going forward.
This commit is contained in:
müde 2026-05-17 22:08:15 +02:00
parent a15fafb5de
commit cb71a07300
4 changed files with 249 additions and 18 deletions

View file

@ -54,6 +54,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/cancel-question/{id}", post(post_cancel_question))
.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/agent-config/{name}", get(get_agent_config))
.route("/request-spawn", post(post_request_spawn))
.route("/op-send", post(post_op_send))
@ -885,6 +886,103 @@ async fn get_agent_config(AxumPath(name): AxumPath<String>) -> Response {
}
}
#[derive(Deserialize)]
struct StateFileQuery {
path: String,
}
/// Bounded-size read of a file under one of two allow-listed
/// roots: `/var/lib/hyperhive/agents/<n>/state/` (per-agent durable
/// notes — the only writable path agents have outside their
/// container) and `/var/lib/hyperhive/shared/` (shared docs). Both
/// path forms are accepted:
/// - canonical host: `/var/lib/hyperhive/agents/alice/state/foo.md`
/// - container view: `/agents/alice/state/foo.md`
/// - shared: `/shared/foo.md`
///
/// `/state/...` on its own is *not* accepted — the in-container
/// mount is ambiguous from the host's perspective (we don't know
/// which agent's `/state` it refers to) and using it would silently
/// resolve to the wrong file.
///
/// Path is canonicalised before the allow-list check so `..`
/// 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<StateFileQuery>,
) -> Response {
const MAX_BYTES: usize = 1 << 20; // 1 MiB
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 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/") {
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}"))
} 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}"));
};
// 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: {}",
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/<n>/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)",
dir.unwrap_or("(root)")
));
}
}
let meta = match std::fs::metadata(&canonical) {
Ok(m) => m,
Err(e) => return error_response(&format!("state-file: stat {}: {e}", canonical.display())),
};
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())),
};
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 {
body.push_str(&format!(
"\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n"
));
}
([("content-type", "text/plain; charset=utf-8")], body).into_response()
}
async fn post_purge_tombstone(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,