Skip to content

Add a Headless runs section (v0.6.0) - #8

Merged
TadMSTR merged 3 commits into
mainfrom
feature/headless-runs-section
Aug 27, 2026
Merged

TadMSTR merged 3 commits into
mainfrom
feature/headless-runs-section

Conversation

@TadMSTR

@TadMSTR TadMSTR commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Covers vikunja#534. Build plan: task-queue-headless-runs-ui-2026-08.

Surfaces the output of headless agent runs in the Task Queue tab. The data already existed and nothing read it: every headless launch writes its full stdout to ~/.claude/comms/artifacts/task-launches/<agent>-<task8>.log, and 26 such logs had accumulated with no interface able to show them. steward-f42d3aeb completed on 2026-08-23 and stayed invisible for four days.

Backend

Two read-only routes, both inside the existing PREVIEW_ALLOWED_PREFIXES. No new manifest.json permission, no new env var.

Route Returns
GET /headless-runs {runs: [...]} — agent, task id, derived status, started, duration, size, first line. ?agent= filter
GET /headless-runs/:id full log text, scraped commands, truncated flag

path-guard.ts and launch-log.ts are extracted for the reason ws-guard.ts was: server.ts listens at import time, so a test importing it boots a real listener. previewFile now calls the shared resolveAllowedPath rather than carrying its own copy of the realpath-then-prefix check — one guard, two callers.

The filename parser is the inverse of launchLogName and is pinned to it by a round-trip test. Two producers write that name (this plugin and task-dispatcher.py); a hardcoded regex here would not error when it drifted — the section would simply list nothing. Unparseable names are skipped, never rendered with a guessed agent.

UI

panels/headless-runs.ts, mounted below the task list in the list-view branch only. Scannable rows, an agent filter, and a detail view with the full log in monospace. A Commands block scrapes every fenced code block out of the log, each with a copy button. A task's detail view links to its run output.

Security

This is the highest-risk item in the three-build programme — a browser-reachable file-read route.

  • :id is validated as <agent>-<task8> and the filename is then rebuilt via launchLogName, so a caller's string never reaches the filesystem as a path even before the realpath guard. Traversal, encoded traversal and absolute paths all 404 — verified live.
  • The path guard applies to the list route, not only the detail route. The list head-reads every file, so without it a symlink planted in the log directory renders the first line of its target as a row. Verified both ways against a real symlink to ~/.secrets/forge.env: it leaked with the guard removed, and is refused with it present.
  • Log text and scraped commands render via textContent, never innerHTML. Launch logs are agent stdout, which is not trusted markup.
  • Unterminated fenced blocks are dropped from commands. They sit behind a copy button and are meant to be pasted into a shell; half a destructive command is worse than none.

Two things the plan did not anticipate

  1. birthtime cannot be trusted after the migration. cp -p preserves mtime but resets birthtime to the copy time, so for every migrated log birthtime is later than mtime. Deriving duration = mtime - birthtime as specified would have shown every historical run as starting "just now" and lasting a negative number of seconds. birthtime is now trusted only when it precedes mtime; otherwise the duration is null and renders as unknown rather than as zero.
  2. None of the 26 existing logs contains a fenced code block. The extraction technique needs no agent-side change, as the plan says, but there is nothing for it to surface until an agent emits a fence. Verified against a synthetic log instead.

Testing

npm run build && npm test — 67 pass, 0 fail (43 before, +24 new). All 7 mutations of the new security-relevant logic were caught: skipping realpath, dropping the trailing path.sep, loosening the task-id class, dropping the regex anchors, emitting unterminated fences, trusting birthtime unconditionally, and taking line 0 without skipping blanks.

Backend verified live against the real 26-log corpus: 26 rows listed, 2 bare-UUID orphans skipped, 3 steward runs under the filter, truncation exact at the 512 KB boundary, and a missing directory yielding an empty list rather than an error.

🤖 Generated with Claude Code

developer and others added 3 commits August 27, 2026 13:21
Surfaces the output of headless agent runs in the Task Queue tab. The data
already existed and nothing read it: every headless launch writes its full
stdout to ~/.claude/comms/artifacts/task-launches/<agent>-<task8>.log, and 26
such logs had accumulated with no interface able to show them. steward-f42d3aeb
completed on 2026-08-23 and stayed invisible for four days.

Backend — two read-only routes inside the existing preview allowlist:
  GET /headless-runs        list, with an ?agent= filter
  GET /headless-runs/:id    full log text plus scraped commands

Extracted path-guard.ts and launch-log.ts for the reason ws-guard.ts was:
server.ts listens at import time, so a test importing it boots a real listener.
previewFile now calls the shared resolveAllowedPath instead of carrying its own
copy of the realpath-then-prefix check — one guard, two callers.

The filename parser is the INVERSE of launch-policy.ts's launchLogName and is
pinned to it by a round-trip test. Two producers write that name (this plugin
and task-dispatcher.py); a hardcoded regex here would not error when it drifted,
the section would simply list nothing. Unparseable names are skipped, never
rendered with a guessed agent.

Security:
- :id is validated as <agent>-<task8> and the filename is then rebuilt via
  launchLogName, so a caller's string never reaches the filesystem as a path
  even before the realpath guard. Traversal, encoded traversal and absolute
  paths all 404.
- The path guard applies to the LIST route, not only the detail route. The list
  head-reads every file, so without it a symlink planted in the log directory
  renders the first line of its target as a row. Verified both ways against a
  real symlink to ~/.secrets/forge.env: it leaked with the guard removed and is
  refused with it present.
- Log text and scraped commands render via textContent, never innerHTML.
- Unterminated fenced blocks are dropped from commands. They sit behind a copy
  button and are meant to be pasted into a shell; half a command is worse than
  none.

birthtime is trusted only when it precedes mtime. The historical logs were
copy-migrated into the launch-log directory, and a copy resets birthtime while
cp -p preserves mtime — trusting it verbatim reported every migrated run as
starting "just now" and lasting a negative number of seconds. Those now show an
unknown duration rather than a wrong one.

A run's status is derived from the QUEUE, not the log. A log proves a session
ran, not that its task closed; the two disagreeing is the signal, not noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment only, no behaviour change. F-01 (Low) from the 2026-08-27 audit: these
routes make launch logs browser-reachable. Accepted — the audience does not
widen, and redaction belongs on the log producers rather than on a read-only
viewer. Recorded here so a future audit sees the reasoning at the code rather
than only in accepted-risks.md.

Finding: F-01 from task-queue-headless-runs-ui-2026-08 audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TadMSTR
TadMSTR merged commit 2ff475c into main Aug 27, 2026
2 checks passed
@TadMSTR
TadMSTR deleted the feature/headless-runs-section branch August 27, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant