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
|
|
@ -38,6 +38,78 @@
|
|||
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 ──────────────────────────────────────────────
|
||||
// Fires OS notifications on three operator-bound signals:
|
||||
// - new approval landed in the queue
|
||||
|
|
@ -721,7 +793,10 @@
|
|||
+ Math.floor((remaining % 3600) / 60) + 'm';
|
||||
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', {
|
||||
method: 'POST', action: '/answer-question/' + q.id,
|
||||
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: 'msg-sep' }, 'asked:'),
|
||||
);
|
||||
li.append(
|
||||
head,
|
||||
el('div', { class: 'q-body' }, q.question),
|
||||
el('div', { class: 'q-answer' },
|
||||
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||||
el('span', { class: 'q-answer-text' }, q.answer || '(none)'),
|
||||
),
|
||||
const histBody = el('div', { class: 'q-body' });
|
||||
const histBodyPreviews = appendLinkified(histBody, q.question);
|
||||
const ansText = el('span', { class: 'q-answer-text' });
|
||||
const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)');
|
||||
const ansLine = el('div', { class: 'q-answer' },
|
||||
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);
|
||||
}
|
||||
details.append(hul);
|
||||
|
|
@ -854,12 +933,15 @@
|
|||
const ul = el('ul', { class: 'inbox' });
|
||||
for (const m of operatorInbox) {
|
||||
const li = el('li');
|
||||
const body = el('span', { class: 'msg-body' });
|
||||
const previews = appendLinkified(body, m.body);
|
||||
li.append(
|
||||
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||||
el('span', { class: 'msg-from' }, m.from), ' ',
|
||||
el('span', { class: 'msg-sep' }, '→ '),
|
||||
el('span', { class: 'msg-body' }, m.body),
|
||||
body,
|
||||
);
|
||||
for (const d of previews) li.appendChild(d);
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
|
|
@ -1288,14 +1370,24 @@
|
|||
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
|
||||
}
|
||||
function renderMsg(ev, api, glyph) {
|
||||
const el = api.row('msgrow ' + ev.kind, '');
|
||||
el.innerHTML =
|
||||
'<span class="msg-ts">' + tsFmt(ev.at) + '</span>' +
|
||||
'<span class="msg-arrow">' + glyph + '</span>' +
|
||||
'<span class="msg-from">' + esc(ev.from) + '</span>' +
|
||||
'<span class="msg-sep">→</span>' +
|
||||
'<span class="msg-to">' + esc(ev.to) + '</span>' +
|
||||
'<span class="msg-body">' + esc(ev.body) + '</span>';
|
||||
const row = api.row('msgrow ' + ev.kind, '');
|
||||
// Build via DOM so path anchors stay live + escape rules are
|
||||
// automatic (text nodes don't need esc()).
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
|
||||
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({
|
||||
logEl: flow,
|
||||
|
|
|
|||
|
|
@ -450,6 +450,41 @@ summary:hover { color: var(--purple); }
|
|||
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); }
|
||||
}
|
||||
/* 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
|
||||
up amber to match the rest of the dashboard's selection accents. */
|
||||
.questions-filters {
|
||||
|
|
|
|||
|
|
@ -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