path linkify: server-side validation via /api/state-file/check

regex back to permissive ("looks like a path") — the server is
authoritative on whether each match is a file. anchors render
optimistically, paths queue for batch validation (50ms coalesce),
non-files downgrade to plain text + the sibling <details>
preview is dropped. session-scoped cache (pathValidity Map) so
repeated paths skip the roundtrip.

new endpoint POST /api/state-file/check accepts { paths } and
returns { results: {<path>: bool} }. shares resolve_state_path
helper with the read endpoint so security rules can't drift —
both refuse anything outside the allow-list, anything resolved
outside via symlink, or anything in a per-agent subdir other
than state/. capped at 64 paths/request.

drops the brittle client-side filename heuristic (the .ext-
required rule that missed README/Makefile and still matched bare
dirs without trailing slash). single source of truth.
This commit is contained in:
müde 2026-05-17 23:36:44 +02:00
parent 0e2d26304e
commit 6e098fad29
2 changed files with 145 additions and 41 deletions

View file

@ -48,18 +48,75 @@
// 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.
// Each branch insists the final segment looks like a filename:
// at least one non-dot char, a literal dot, then an extension
// (`[\w-]+\.[\w.-]+`). That catches the common case (`notes.md`,
// `2026-01.log`, `foo.bar.baz`) while skipping bare directory
// names like `/agents/foo/state/notes` whether or not they carry
// a trailing slash. Misses extensionless files (`README`,
// `Makefile`) — accepted trade-off; the /api/state-file endpoint
// still serves them if the operator types the path manually.
// The endpoint also refuses non-files at the server level; this
// is the front-end peer so the operator doesn't see a dead link
// they'll just get an error from on click.
const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/var\/lib\/hyperhive\/shared\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/agents\/[\w.-]+\/state\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+|\/shared\/(?:[\w.-]+\/)*[\w-]+\.[\w.-]+)/g;
// Match anything that *looks* like a path under the allow-listed
// roots; the server endpoint `/api/state-file/check` is the
// authority on whether each match is actually a file. Optimistic
// anchors render first; a batched validation request downgrades
// non-files (dirs, missing, forbidden subtrees) back to plain
// text. No client-side filename heuristics — the regex's job is
// just "spot a path-shaped token".
const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g;
// Session-scoped truthiness cache for paths the server has
// already verified. `true` = render as a clickable anchor;
// `false` = strip the anchor on next reflow. Cleared only on
// page reload — agents creating new files mid-session show up
// next time the path is referenced.
const pathValidity = new Map();
// Anchors awaiting validation. Keyed by path so we can rewrite
// every anchor for the same path in one shot when the result
// lands. Each entry: { anchor, details } so we can also drop
// the sibling preview when the path turns out to be invalid.
const pendingAnchors = new Map();
let validateTimer = null;
function queuePathForValidation(path, anchor, details) {
if (!pendingAnchors.has(path)) pendingAnchors.set(path, []);
pendingAnchors.get(path).push({ anchor, details });
if (validateTimer) clearTimeout(validateTimer);
// Coalesce bursts (a backfill replay can emit dozens of rows
// in one tick) into a single batched request.
validateTimer = setTimeout(flushPathValidation, 50);
}
async function flushPathValidation() {
validateTimer = null;
const paths = Array.from(pendingAnchors.keys());
if (!paths.length) return;
// Snapshot the queue + clear it before we await — additional
// anchors that land while the request is in flight queue into
// a fresh batch.
const snapshot = new Map(pendingAnchors);
pendingAnchors.clear();
let results = {};
try {
const resp = await fetch('/api/state-file/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
if (resp.ok) results = (await resp.json()).results || {};
} catch (err) {
console.warn('path validation batch failed', err);
// On transport failure leave anchors as-is — clicking them
// will surface the real error from /api/state-file inline.
return;
}
for (const [path, entries] of snapshot) {
const ok = !!results[path];
pathValidity.set(path, ok);
if (ok) continue;
// Downgrade every pending anchor for this path back to
// plain text + drop its sibling <details> preview.
for (const { anchor, details } of entries) {
if (anchor.parentNode) {
anchor.parentNode.replaceChild(document.createTextNode(path), anchor);
}
if (details && details.parentNode) {
details.parentNode.removeChild(details);
}
}
}
}
async function fetchStateFile(path) {
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
const text = await resp.text();
@ -99,6 +156,9 @@
// 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.
// Anchors render optimistically; paths unseen this session are
// queued for batch validation, and the server's verdict either
// confirms or strips them via `flushPathValidation`.
function appendLinkified(parent, text) {
const previews = [];
if (text == null) return previews;
@ -110,9 +170,21 @@
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);
const path = m[0];
const cached = pathValidity.get(path);
if (cached === false) {
// Already known to be a non-file — render plain text, no
// anchor, no preview. The text still shows up so the
// operator sees the path; it's just not clickable.
parent.appendChild(document.createTextNode(path));
} else {
const { anchor, details } = makePathPreview(path);
parent.appendChild(anchor);
previews.push(details);
// Unknown paths queue for validation; known-good ones
// skip the roundtrip entirely.
if (cached !== true) queuePathForValidation(path, anchor, details);
}
lastIdx = m.index + m[0].length;
}
if (lastIdx < str.length) {