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

@ -25,7 +25,13 @@
and peer (agent-to-agent) threads, with filter chips (all / and peer (agent-to-agent) threads, with filter chips (all /
@operator / @peer / per-participant) and an 0V3RR1D3 button on @operator / @peer / per-participant) and an 0V3RR1D3 button on
peer rows so the operator can answer when an agent is stuck. --> peer rows so the operator can answer when an agent is stuck. -->
- **Clickable file paths in message bodies**: agents drop pointer strings like `/agents/<name>/state/foo.md` constantly (it's the whole 1 KiB-cap escape hatch). Right now they're plain text — operator has to copy-paste into a terminal to peek. Detect path-shaped tokens (start with `/agents/`, `/shared/`, `/state/`, or absolute `/var/lib/hyperhive/...`) in rendered message bodies + question text + answer text + helper-event payloads, render as clickable links that hit a new `/api/state-file?path=…` dashboard endpoint. Endpoint serves the file as text (with a strict allow-list — only paths under `/var/lib/hyperhive/agents/*/state/`, `/var/lib/hyperhive/shared/`, never anything else), syntax-highlighting where it makes sense, falling back to download for binaries. Reuses the existing `<details>` collapse pattern so inline preview doesn't blow up the message-flow stream. <!-- Landed: PATH_RE-detected pointer strings in message + question +
answer + inbox bodies linkify to inline <details> previews
fetched from /api/state-file. Allow-list: `/agents/<n>/state/...`
+ `/var/lib/hyperhive/agents/<n>/state/...` + `/shared/...` +
`/var/lib/hyperhive/shared/...`. Legacy bare `/state/...` is
intentionally NOT matched (ambiguous from host's perspective);
prefer `/agents/<n>/state/...` in agent outputs. -->
- **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel - **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel
- Per-agent reminder status (pending, delivered) - Per-agent reminder status (pending, delivered)
- Reminder query interface for debugging - Reminder query interface for debugging

View file

@ -38,6 +38,78 @@
return f; return f;
}; };
// ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
// PATH_RE patterns becomes a clickable anchor; clicking expands an
// inline <details> with the file's contents, fetched lazily from
// /api/state-file. The legacy in-container `/state/...` prefix is
// deliberately not matched — it's ambiguous from the host's
// perspective (we'd need to know which agent the message is about
// to translate it). Prefer `/agents/<name>/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;
async function fetchStateFile(path) {
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
const text = await resp.text();
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
return text;
}
function makePathPreview(path) {
// Inline anchor + a sibling <details> that lazy-loads the file
// on first open. Caller appends both: the anchor inline with the
// surrounding text, the details as a block sibling after the
// line so the layout doesn't get awkward.
const anchor = el('a', {
href: '#', class: 'path-link', title: 'click to preview ' + path,
}, path);
const details = el('details', { class: 'path-preview' });
const summary = el('summary', {}, '↳ ' + path);
const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)');
details.append(summary, pre);
let fetched = false;
async function doFetch() {
if (fetched) return;
fetched = true;
try {
pre.textContent = await fetchStateFile(path);
} catch (e) {
pre.textContent = 'error: ' + (e.message || e);
fetched = false; // allow retry on next open
}
}
details.addEventListener('toggle', () => { if (details.open) doFetch(); });
anchor.addEventListener('click', (e) => {
e.preventDefault();
details.open = !details.open;
});
return { anchor, details };
}
// Append `text` to `parent` as a mix of text nodes + path anchors.
// Returns the array of generated `<details>` previews so the
// caller can append them as block siblings under the row.
function appendLinkified(parent, text) {
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 { anchor, details } = makePathPreview(m[0]);
parent.appendChild(anchor);
previews.push(details);
lastIdx = m.index + m[0].length;
}
if (lastIdx < str.length) {
parent.appendChild(document.createTextNode(str.slice(lastIdx)));
}
return previews;
}
// ─── browser notifications ────────────────────────────────────────────── // ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on three operator-bound signals: // Fires OS notifications on three operator-bound signals:
// - new approval landed in the queue // - new approval landed in the queue
@ -721,7 +793,10 @@
+ Math.floor((remaining % 3600) / 60) + 'm'; + Math.floor((remaining % 3600) / 60) + 'm';
head.append(' ', el('span', { class: 'q-ttl' }, txt)); head.append(' ', el('span', { class: 'q-ttl' }, txt));
} }
li.append(head, el('div', { class: 'q-body' }, q.question)); const qBody = el('div', { class: 'q-body' });
const qPreviews = appendLinkified(qBody, q.question);
li.append(head, qBody);
for (const d of qPreviews) li.appendChild(d);
const f = el('form', { const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id, method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '', 'data-no-refresh': '', class: 'qform', 'data-async': '', 'data-no-refresh': '',
@ -814,14 +889,18 @@
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asked:'), el('span', { class: 'msg-sep' }, 'asked:'),
); );
li.append( const histBody = el('div', { class: 'q-body' });
head, const histBodyPreviews = appendLinkified(histBody, q.question);
el('div', { class: 'q-body' }, q.question), const ansText = el('span', { class: 'q-answer-text' });
el('div', { class: 'q-answer' }, const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)');
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), const ansLine = el('div', { class: 'q-answer' },
el('span', { class: 'q-answer-text' }, q.answer || '(none)'), el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
), ansText,
); );
li.append(head, histBody);
for (const d of histBodyPreviews) li.appendChild(d);
li.append(ansLine);
for (const d of histAnsPreviews) li.appendChild(d);
hul.append(li); hul.append(li);
} }
details.append(hul); details.append(hul);
@ -854,12 +933,15 @@
const ul = el('ul', { class: 'inbox' }); const ul = el('ul', { class: 'inbox' });
for (const m of operatorInbox) { for (const m of operatorInbox) {
const li = el('li'); const li = el('li');
const body = el('span', { class: 'msg-body' });
const previews = appendLinkified(body, m.body);
li.append( li.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ', el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '), el('span', { class: 'msg-sep' }, '→ '),
el('span', { class: 'msg-body' }, m.body), body,
); );
for (const d of previews) li.appendChild(d);
ul.append(li); ul.append(li);
} }
root.append(ul); root.append(ul);
@ -1288,14 +1370,24 @@
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000); bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
} }
function renderMsg(ev, api, glyph) { function renderMsg(ev, api, glyph) {
const el = api.row('msgrow ' + ev.kind, ''); const row = api.row('msgrow ' + ev.kind, '');
el.innerHTML = // Build via DOM so path anchors stay live + escape rules are
'<span class="msg-ts">' + tsFmt(ev.at) + '</span>' + // automatic (text nodes don't need esc()).
'<span class="msg-arrow">' + glyph + '</span>' + const ts = document.createElement('span');
'<span class="msg-from">' + esc(ev.from) + '</span>' + ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
'<span class="msg-sep">→</span>' + const arrow = document.createElement('span');
'<span class="msg-to">' + esc(ev.to) + '</span>' + arrow.className = 'msg-arrow'; arrow.textContent = glyph;
'<span class="msg-body">' + esc(ev.body) + '</span>'; const from = document.createElement('span');
from.className = 'msg-from'; from.textContent = ev.from;
const sep = document.createElement('span');
sep.className = 'msg-sep'; sep.textContent = '→';
const to = document.createElement('span');
to.className = 'msg-to'; to.textContent = ev.to;
const body = document.createElement('span');
body.className = 'msg-body';
const previews = appendLinkified(body, ev.body);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
for (const d of previews) row.appendChild(d);
} }
HiveTerminal.create({ HiveTerminal.create({
logEl: flow, logEl: flow,

View file

@ -450,6 +450,41 @@ summary:hover { color: var(--purple); }
0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); } 0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); } 50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
} }
/* Path linkification agents drop pointer strings into messages
constantly; clicking the anchor expands a sibling <details> that
lazy-loads from /api/state-file. */
.path-link {
color: var(--blue, #89b4fa);
text-decoration: underline dotted;
cursor: pointer;
}
.path-link:hover { color: var(--amber); }
.path-preview {
margin: 0.2em 0 0.4em 1.5em;
border-left: 2px solid var(--border);
padding-left: 0.6em;
}
.path-preview > summary {
cursor: pointer;
color: var(--muted);
font-size: 0.85em;
list-style: none;
user-select: none;
}
.path-preview > summary::marker { content: ''; }
.path-preview-body {
background: var(--bg);
border: 1px solid var(--border);
padding: 0.5em 0.7em;
margin: 0.3em 0 0;
max-height: 30em;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85em;
color: var(--fg);
}
/* Filter chip row above the questions list. The active chip lights /* Filter chip row above the questions list. The active chip lights
up amber to match the rest of the dashboard's selection accents. */ up amber to match the rest of the dashboard's selection accents. */
.questions-filters { .questions-filters {

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("/cancel-question/{id}", post(post_cancel_question))
.route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/purge-tombstone/{name}", post(post_purge_tombstone))
.route("/api/journal/{name}", get(get_journal)) .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("/api/agent-config/{name}", get(get_agent_config))
.route("/request-spawn", post(post_request_spawn)) .route("/request-spawn", post(post_request_spawn))
.route("/op-send", post(post_op_send)) .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( async fn post_purge_tombstone(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,