Repository navigation
Add a Headless runs section (v0.6.0) - #8
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-f42d3aebcompleted on 2026-08-23 and stayed invisible for four days.Backend
Two read-only routes, both inside the existing
PREVIEW_ALLOWED_PREFIXES. No newmanifest.jsonpermission, no new env var.GET /headless-runs{runs: [...]}— agent, task id, derived status, started, duration, size, first line.?agent=filterGET /headless-runs/:idtruncatedflagpath-guard.tsandlaunch-log.tsare extracted for the reasonws-guard.tswas:server.tslistens at import time, so a test importing it boots a real listener.previewFilenow calls the sharedresolveAllowedPathrather than carrying its own copy of the realpath-then-prefix check — one guard, two callers.The filename parser is the inverse of
launchLogNameand is pinned to it by a round-trip test. Two producers write that name (this plugin andtask-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.
:idis validated as<agent>-<task8>and the filename is then rebuilt vialaunchLogName, 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.~/.secrets/forge.env: it leaked with the guard removed, and is refused with it present.textContent, neverinnerHTML. Launch logs are agent stdout, which is not trusted markup.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
birthtimecannot be trusted after the migration.cp -ppreserves mtime but resets birthtime to the copy time, so for every migrated log birthtime is later than mtime. Derivingduration = mtime - birthtimeas 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 isnulland renders as unknown rather than as zero.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 trailingpath.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