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:
parent
a15fafb5de
commit
cb71a07300
4 changed files with 249 additions and 18 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue