diff --git a/AGENTS.md b/AGENTS.md index 33cf4d1..3d63408 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,11 @@ src/ ws-guard.ts The WebSocket upgrade decision, as a pure function of (peer address, Origin, allowlist). Extracted for the same reason: server.ts listens at import time. + path-guard.ts The realpath-then-prefix filesystem check, in one place. + Used by the context-ref preview AND the headless-run + log reader. Not pure — resolving symlinks is the point. + launch-log.ts Reader side of the launch logs: the inverse of + launchLogName, fenced-block extraction, run timestamps. launch-policy.ts Reads and validates the shared launch roster, and builds the spawn argv. Same extraction reason. types.ts Shared types. The Task shape mirrors task-queue-mcp's @@ -29,6 +34,7 @@ src/ panels/ task-list.ts List view, filters, grouping, per-row actions task-detail.ts Detail view, history timeline, amendments, previews + headless-runs.ts Headless-run list and log viewer, below the task list styles.ts Theme colours (read live from CloudCLI CSS vars), helpers ws-client.ts Reconnecting WebSocket client tests/ node --test @@ -44,7 +50,12 @@ src/ - **There is exactly one agent roster, and it is not in this repo.** `~/scripts/agent-launch.yml` (override: `AGENT_LAUNCH_POLICY`) is read by this plugin *and* by `task-dispatcher.py`. A hardcoded `AGENT_PROJECTS` map used to live in `server.ts`; it drifted, lost an agent, and that drift is vikunja#523. Do not add a literal roster back — extend the file. - **A run-as agent goes through its launcher, always.** An entry with `run_as_user` is spawned as `sudo -n -u …`, never as `claude`. Spawning `claude` directly for such an agent bypasses the launcher's identity guard and yields a session that appears as that agent in every log while holding none of its credentials. A missing or non-executable launcher is refused by name — there is no fallback path, deliberately. - **The launch policy fails closed, loudly.** A missing or malformed policy file disables Start with a named error. It must never degrade to an empty policy: an empty policy makes `run_as_user` absent for every agent, which is precisely the impersonation above. -- **Path guards resolve symlinks.** `previewFile` uses `fs.realpathSync` before the prefix check; `path.resolve` alone normalises `..` but follows nothing, so a symlink inside an allowed prefix would escape it. +- **Path guards resolve symlinks, and there is one guard.** `resolveAllowedPath` in `path-guard.ts` calls `fs.realpathSync` *before* the prefix compare; `path.resolve` alone normalises `..` but follows nothing, so a symlink inside an allowed prefix would escape it. The trailing `path.sep` on each prefix is load-bearing — without it `/comms-other` matches `/comms`. Both the context-ref preview and the headless-run reader call this one function. Do not write a second check; a subtly different copy is how this property gets lost. +- **The headless-runs section reads launch logs and never writes them.** Both routes are `GET`, resolve through `resolveAllowedPath`, and never treat a route id as a path: `:id` is validated as `-` and the filename is then *rebuilt* via `launchLogName`, so the caller's string cannot reach the filesystem even before the realpath guard runs. The guard applies to the **list** route as well as the detail route — the list head-reads every file, so without it a symlink planted in the log directory puts the first line of its target into a row. That was verified against a real `~/.secrets/forge.env` symlink, which leaked with the guard removed and was refused with it present. +- **The launch-log filename parser is the inverse of `launchLogName`, and is pinned to it by a round-trip test.** `launch-policy.ts` owns the name shape and two producers write it (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 or empty agent. +- **A run's status comes from the queue, never from the log.** A log proves a session ran; it does not prove its task closed. `steward-f42d3aeb` completed on 2026-08-23 against a task that sat at `approved` for four days. Render that disagreement — do not infer status from the log's prose. +- **`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, so for every migrated run birthtime is *later* than mtime. Trusting it reports each as starting "just now" and running for a negative duration. An untrustworthy birthtime yields a `null` duration, rendered as unknown rather than as zero. +- **Unterminated fenced blocks are dropped from `commands`.** Those strings are handed to the operator behind a copy button, i.e. built to be pasted into a shell. A fence with no closing delimiter has no known end, and half of a destructive command is worse than none. The full text is still rendered in the log pane. - **The WebSocket upgrade gates on the peer address first, and on `Origin` only if one is present.** The server binds `127.0.0.1` on an ephemeral port, so a non-loopback peer is refused outright; a loopback peer with a *present but wrong* `Origin` is still refused. A loopback peer with **no** `Origin` is accepted, because that is CloudCLI's own plugin WS proxy — it uses the `ws` client library, which sends no `Origin` unless one is passed, and its browser leg is already authenticated by CloudCLI's `verifyClient` before the proxy is invoked. v0.4.0 inverted this, rejecting a *missing* `Origin` on the reasoning that only non-browser clients omit it. On this deployment the only non-browser client is that trusted proxy, so every connect was 403'd for three weeks (2239 failures) and the tab read `disconnected` throughout. Do not restore the missing-Origin rejection; the loopback bind is the boundary, and `Origin` alone never did the work this deployment needed. The rule is a pure function in `ws-guard.ts` with tests covering all three cases. @@ -56,7 +67,7 @@ npm run build # tsc --noEmit is the typecheck gate; esbuild bundles after it p npm test ``` -Tests cover `control-api.ts` (the secret gate, task-id validation, header and body shape per action, transport-failure mapping, pass-through of the MCP's authorization rejections), `ws-guard.ts` (all three upgrade cases, including the loopback-with-no-Origin one that v0.4.0 broke), `launch-policy.ts` (every closed-set rejection, whole-document rejection, and both argv shapes), and the reconnect schedule. The UI panels are not unit-tested — verify them in CloudCLI after `./deploy.sh && pm2 restart cloudcli`. +Tests cover `control-api.ts` (the secret gate, task-id validation, header and body shape per action, transport-failure mapping, pass-through of the MCP's authorization rejections), `ws-guard.ts` (all three upgrade cases, including the loopback-with-no-Origin one that v0.4.0 broke), `launch-policy.ts` (every closed-set rejection, whole-document rejection, and both argv shapes), `path-guard.ts` (a **real** symlink escape, traversal, the `/comms-other` sibling case — with real files in a tmpdir, because a mocked `fs` cannot demonstrate that realpath runs first), `launch-log.ts` (round-trip against the real `launchLogName`, the live bare-UUID orphans, path-shaped route ids, fence extraction including the dropped unterminated case, and the birthtime-after-mtime fallback), and the reconnect schedule. The UI panels are not unit-tested — verify them in CloudCLI after `./deploy.sh && pm2 restart cloudcli`. Two build/test gotchas worth knowing before you touch either script: diff --git a/CHANGELOG.md b/CHANGELOG.md index 35acc91..4eb78ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,63 @@ All notable changes to this project will be documented in this file. +## [0.6.0] - 2026-08-27 + +Adds a **Headless runs** section below the task list: a read-only view of agent sessions +that ran with nobody watching. The output already existed — every headless launch writes +its full stdout to `~/.claude/comms/artifacts/task-launches/-.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. + +### Added +- `GET /headless-runs` and `GET /headless-runs/:id` — read-only, inside the existing + preview allowlist. No new `manifest.json` permission and no new env var. +- `panels/headless-runs.ts` — scannable rows (agent, task id, status, started, duration, + first line of output), an agent filter, and a detail view with the full log in + monospace. Clicking a row opens it; a task's detail view links to its run output. +- **Commands** block in the run detail: every fenced code block scraped out of the log, + each with a copy button. Deliberately dumb — no inference about which lines are "really" + commands. Note that *none* of the 26 pre-existing logs contains a fenced block, so this + surfaces nothing until an agent emits one. +- `path-guard.ts` and `launch-log.ts`, extracted for the reason `ws-guard.ts` was: + `server.ts` listens at import time, so a test importing it boots a real listener. + +### Changed +- `previewFile` now calls the shared `resolveAllowedPath` rather than carrying its own + copy of the realpath-then-prefix check. One guard, two callers. +- `HeadlessRun`/`HeadlessRunDetail` live in `types.ts` and are imported by `server.ts`, so + the route's response shape has a single definition across the bundle boundary. + +### Security +- The route id is validated as `-` and the filename is then *rebuilt* via + `launchLogName`, so a caller's string never reaches the filesystem as a path — two + independent barriers ahead of 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`. 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 command is worse than none. +- Security audit 2026-08-27: **PASS**, no Critical/High/Medium. One Low was **accepted** — + these routes make launch logs (raw agent stdout) reachable from a browser, where they were + previously readable only over SSH. Accepted because the audience does not widen: the + backend is loopback-bound and reached only through CloudCLI's authenticated plugin RPC + proxy, so any caller is already an operator-level principal. The surface *is* wider than + the pre-existing `/tasks/:id` context-ref preview; redaction, if wanted, belongs on the + log producers rather than on a read-only viewer. See `SECURITY[accepted]` in `server.ts`. + +### Notes +- `birthtime` is trusted only when it precedes `mtime`. The 26 historical logs were + copy-migrated into the launch-log directory, and a copy resets birthtime while `cp -p` + preserves mtime — so trusting it verbatim reported every migrated run as starting "just + now" and lasting a negative number of seconds. Those runs 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. + ## [0.5.0] - 2026-08-27 Requires a launch policy file at `~/scripts/agent-launch.yml` (see README). The Start diff --git a/README.md b/README.md index f289996..c7f68a8 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ The backend exposes a small HTTP API consumed by the UI via `api.rpc()`. | `POST` | `/tasks/:id/park` | Park; body `{note?}` — proxied | | `POST` | `/tasks/:id/unpark` | Unpark; body `{note?, status?}` — proxied | | `POST` | `/tasks/:id/amend` | Append an amendment; body `{amendment, reason?}` — proxied | +| `GET` | `/headless-runs` | List headless agent runs; query param `agent`. Read-only | +| `GET` | `/headless-runs/:id` | One run's full log text plus scraped commands; `:id` is `-`. Read-only | Reads are served directly from the queue YAML. Every proxied mutation carries the `X-Task-Queue-Secret` header and an `actor` of `operator`. diff --git a/manifest.json b/manifest.json index ba2a359..239e404 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "task-queue", "displayName": "Task Queue", - "version": "0.5.0", + "version": "0.6.0", "description": "Task queue dashboard — view, filter, and launch agent tasks.", "author": "TadMSTR", "icon": "icon.svg", diff --git a/package.json b/package.json index 1e0a5ee..688e322 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cloudcli-plugin-task-queue", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/src/index.ts b/src/index.ts index e395e32..1ff4a51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ -import type { PluginAPI, PluginContext, Task, ThemeColors } from './types.js'; +import type { PluginAPI, PluginContext, Task, ThemeColors, HeadlessRun, HeadlessRunDetail } from './types.js'; import { themeColors, injectGlobalStyles, MONO } from './panels/styles.js'; import { renderTaskList } from './panels/task-list.js'; import { renderTaskDetail } from './panels/task-detail.js'; +import { renderHeadlessRuns } from './panels/headless-runs.js'; import { createWsClient, WsClient } from './panels/ws-client.js'; // ── State ────────────────────────────────────────────────────────────── @@ -15,6 +16,10 @@ interface AppState { error: string | null; wsConnected: boolean; filters: { agent: string; status: string; taskType: string }; + headlessRuns: HeadlessRun[]; + selectedRunId: string | null; + selectedRun: HeadlessRunDetail | null; + runAgentFilter: string; } // ── Mount ────────────────────────────────────────────────────────────── @@ -31,6 +36,10 @@ export function mount(container: HTMLElement, api: PluginAPI): void { error: null, wsConnected: false, filters: { agent: '', status: '', taskType: '' }, + headlessRuns: [], + selectedRunId: null, + selectedRun: null, + runAgentFilter: '', }; let wsClient: WsClient | null = null; @@ -79,10 +88,60 @@ export function mount(container: HTMLElement, api: PluginAPI): void { } catch (err) { state.error = (err as Error).message; } + await loadHeadlessRuns(); state.loading = false; render(api.context); } + // Runs refresh on the same cadence as the task list rather than on their own timer. + // There is nothing to stream: `claude -p` writes its final message on exit, so a run + // only ever changes once, and this surface is opened deliberately. + async function loadHeadlessRuns(): Promise { + // Loaded UNFILTERED. The agent filter is applied for display in the panel, so it + // costs no round trip and — more importantly — runForTask() can still find a task's + // run while the section is filtered to a different agent. The route's ?agent= + // parameter still exists for direct API use. + try { + const res = await api.rpc('GET', 'headless-runs') as { runs: HeadlessRun[] }; + state.headlessRuns = res.runs ?? []; + } catch { + // A failure here must not blank the task list — the runs section is secondary. + state.headlessRuns = []; + } + } + + async function loadRunDetail(id: string): Promise { + try { + state.selectedRun = await api.rpc('GET', `headless-runs/${id}`) as HeadlessRunDetail; + state.error = null; + } catch (err) { + state.selectedRunId = null; + state.selectedRun = null; + state.error = (err as Error).message; + } + render(api.context); + } + + function openRun(id: string): void { + state.selectedRunId = id; + state.selectedRun = null; + state.selectedTaskId = null; + state.selectedTask = null; + render(api.context); + loadRunDetail(id); + } + + async function handleCopyCommand(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + showToast('Command copied'); + } catch { + // Clipboard access can be refused (insecure context, permissions). Say so rather + // than reporting a copy that did not happen. + showToast('Copy failed — select the text manually'); + } + } + async function loadTaskDetail(taskId: string): Promise { try { const res = await api.rpc('GET', `tasks/${taskId}`) as { task: Task; previews: Record }; @@ -261,6 +320,9 @@ export function mount(container: HTMLElement, api: PluginAPI): void { onUnpark: handleUnpark, onAmend: handleAmend, onSetStatus: handleSetStatus, + onOpenRun: runForTask(state.selectedTask.id) + ? () => openRun(runForTask(state.selectedTask!.id)!.id) + : undefined, }); } else { const listContainer = document.createElement('div'); @@ -286,9 +348,37 @@ export function mount(container: HTMLElement, api: PluginAPI): void { onAmend: handleAmend, onSetStatus: handleSetStatus, }); + + renderHeadlessRuns(root, { + runs: state.headlessRuns, + selectedRun: state.selectedRun, + selectedRunId: state.selectedRunId, + agentFilter: state.runAgentFilter, + colors: c, + onAgentFilterChange: (agent) => { + state.runAgentFilter = agent; + render(ctx); + }, + onSelect: openRun, + onBack: () => { + state.selectedRunId = null; + state.selectedRun = null; + render(ctx); + }, + onCopy: handleCopyCommand, + }); } } + /** + * The launch log for a task, if one was recorded. Matched on the full task id the + * backend resolved from the log's 8-char prefix, so a prefix collision (which the + * backend reports as unresolved) never links to the wrong task. + */ + function runForTask(taskId: string): HeadlessRun | undefined { + return state.headlessRuns.find(r => r.task_id === taskId); + } + function renderHeader(parent: HTMLElement, c: ThemeColors): void { const header = document.createElement('div'); header.style.cssText = `display:flex;align-items:center;gap:12px;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid ${c.border};`; diff --git a/src/launch-log.ts b/src/launch-log.ts new file mode 100644 index 0000000..91cc551 --- /dev/null +++ b/src/launch-log.ts @@ -0,0 +1,139 @@ +/** + * Headless launch logs — filename parsing and content extraction. + * + * Every headless agent run writes its full stdout+stderr to one file in + * ~/.claude/comms/artifacts/task-launches/. Two producers write there and both name + * the file with launchLogName() from launch-policy.ts: this plugin's launchSession() + * and task-dispatcher.py. This module is the READER side. + * + * Extracted from server.ts for the ws-guard.ts reason — server.ts listens at import + * time, so a test importing it boots a real listener. + */ + +import { launchLogName } from './launch-policy.ts'; + +export interface ParsedLaunchLog { + agent: string; + /** First 8 characters of the task UUID — NOT a whole task id. */ + taskId8: string; +} + +/** + * The inverse of launchLogName(agent, taskId) -> `-.log`. + * + * Written against that function rather than as a free-standing guess, and pinned to + * it by a round-trip test, because a hardcoded regex here would drift silently from + * the producer and the section would simply stop listing runs. + * + * The agent segment is greedy and the task segment is a fixed 8 hex chars anchored to + * `.log`, so the split is unambiguous even if an agent name ever contains a hyphen. + * Task ids are uuid4, so their first 8 characters are always lowercase hex. + * + * Returns null for anything that does not match. Callers must SKIP those rather than + * guessing at an agent — two pre-2026-08 orphans in the live directory are named with + * a bare task UUID and no agent, and a row with an empty agent is worse than no row. + */ +const LAUNCH_LOG_RE = /^([a-z][a-z0-9_-]*)-([0-9a-f]{8})\.log$/; + +export function parseLaunchLogName(filename: string): ParsedLaunchLog | null { + const m = LAUNCH_LOG_RE.exec(filename); + if (!m) return null; + return { agent: m[1], taskId8: m[2] }; +} + +/** The `-` route id for a run, i.e. its filename without `.log`. */ +export function runId(agent: string, taskId8: string): string { + return `${agent}-${taskId8}`; +} + +/** Parse a `-` route id by reusing the filename parser. */ +export function parseRunId(id: string): ParsedLaunchLog | null { + return parseLaunchLogName(`${id}.log`); +} + +/** The filename a run id maps to. Goes through launchLogName so there is one producer. */ +export function runIdToFilename(parsed: ParsedLaunchLog): string { + return launchLogName(parsed.agent, parsed.taskId8); +} + +/** + * First non-empty line of a run's output, collapsed to one line for a list row. + */ +export function firstLine(text: string, maxChars = 200): string { + for (const line of text.split('\n')) { + const t = line.trim(); + if (t) return t.length > maxChars ? t.slice(0, maxChars - 1) + '…' : t; + } + return ''; +} + +/** + * Every fenced code block in the log, as the operator's copyable "Commands" list. + * + * Deliberately dumb: no inference about which lines are "really" commands, no + * filtering by language tag, no rewriting. A false positive costs a glance; a false + * negative costs the operator the thing they came for. + * + * UNTERMINATED fences are dropped. A fence with no closing delimiter has no defined + * end, so we cannot know we hold the whole command — and these strings are offered + * with a copy button, i.e. built to be pasted into a shell. Half of `rm -rf /a/b + * --exclude=c` is not a safe thing to hand someone. The full text is rendered in the + * log pane regardless, so nothing is hidden, only un-copyable. + */ +export function extractFencedBlocks(text: string): string[] { + const blocks: string[] = []; + let current: string[] | null = null; + + for (const line of text.split('\n')) { + const marker = line.trimStart().startsWith('```'); + if (current === null) { + if (marker) current = []; + continue; + } + if (marker) { + const body = current.join('\n').trim(); + if (body) blocks.push(body); + current = null; + continue; + } + current.push(line); + } + + // current !== null here means EOF arrived inside a fence — dropped, per above. + return blocks; +} + +/** The subset of fs.Stats runTimes needs, so it is testable without a real file. */ +export interface RunStat { + mtimeMs: number; + birthtimeMs: number; +} + +export interface RunTimes { + started: string; + ended: string; + /** null when the start time is unknowable — rendered as unknown, never as zero. */ + duration_s: number | null; +} + +/** + * Wall-clock span of a run, from its file's timestamps. + * + * birthtime is trusted ONLY when it precedes mtime. The 26 pre-existing logs were + * copy-migrated into the launch-log directory on 2026-08-27, and a copy resets + * birthtime to the copy time while `cp -p` preserves mtime — so for every historical + * run birthtime is *later* than mtime. Trusting it blindly reports each of them as + * having started "just now" and run for a negative duration. + * + * Some filesystems report birthtime as 0 rather than as unavailable; that is treated + * the same way. + */ +export function runTimes(st: RunStat): RunTimes { + const { mtimeMs, birthtimeMs } = st; + const usable = Number.isFinite(birthtimeMs) && birthtimeMs > 0 && birthtimeMs <= mtimeMs; + return { + started: new Date(usable ? birthtimeMs : mtimeMs).toISOString(), + ended: new Date(mtimeMs).toISOString(), + duration_s: usable ? Math.round((mtimeMs - birthtimeMs) / 1000) : null, + }; +} diff --git a/src/panels/headless-runs.ts b/src/panels/headless-runs.ts new file mode 100644 index 0000000..61cf872 --- /dev/null +++ b/src/panels/headless-runs.ts @@ -0,0 +1,223 @@ +import type { ThemeColors, HeadlessRun, HeadlessRunDetail } from '../types.js'; +import { escHtml, ago, statusColor, MONO } from './styles.js'; + +/** + * The Headless runs section — a read-only view of agent runs that had no operator + * watching them. + * + * It sits below the task list in the same tab, deliberately: these runs went unnoticed + * for days precisely because their output lived somewhere nobody opened. A separate tab + * would reproduce that. + */ +interface HeadlessRunsOptions { + runs: HeadlessRun[]; + /** Loaded detail for the open run, if any. */ + selectedRun: HeadlessRunDetail | null; + selectedRunId: string | null; + agentFilter: string; + colors: ThemeColors; + onAgentFilterChange: (agent: string) => void; + onSelect: (id: string) => void; + onBack: () => void; + onCopy: (text: string) => void; +} + +function formatDuration(seconds: number | null): string { + // An unknown duration reads as unknown. Rendering it as 0s would claim the run was + // instantaneous, which is a different and false statement. + if (seconds === null) return '—'; + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m}m ${seconds % 60}s`; + return `${Math.floor(m / 60)}h ${m % 60}m`; +} + +export function renderHeadlessRuns(parent: HTMLElement, opts: HeadlessRunsOptions): void { + const { runs, colors: c } = opts; + const visible = opts.agentFilter ? runs.filter(r => r.agent === opts.agentFilter) : runs; + + const section = document.createElement('div'); + section.style.cssText = `margin-top:28px;padding-top:16px;border-top:1px solid ${c.border};`; + + // ── Heading ─────────────────────────────────────────────────────────── + // Visually subordinate to the task list: this is a status surface, not the tab's + // primary object. + const head = document.createElement('div'); + head.style.cssText = 'display:flex;align-items:baseline;gap:10px;margin-bottom:4px;'; + head.innerHTML = ` + Headless runs + ${ + opts.agentFilter ? `${visible.length} of ${runs.length}` : `${runs.length} recorded` + } + `; + section.appendChild(head); + + const blurb = document.createElement('div'); + blurb.style.cssText = `color:${c.muted};font-size:11px;margin-bottom:12px;line-height:1.5;`; + blurb.textContent = + 'Agent sessions launched without an operator watching. Each one wrote its final ' + + 'output to a log; this is that output. Status comes from the task queue, not from ' + + 'the log — a finished run whose task is still open is worth noticing.'; + section.appendChild(blurb); + + if (opts.selectedRunId && opts.selectedRun) { + renderDetail(section, opts); + } else if (opts.selectedRunId) { + const loading = document.createElement('div'); + loading.style.cssText = `color:${c.muted};font-size:12px;padding:16px;`; + loading.textContent = 'Loading run…'; + section.appendChild(loading); + } else { + renderList(section, opts, visible); + } + + parent.appendChild(section); +} + +// ── List ──────────────────────────────────────────────────────────────── + +function renderList(section: HTMLElement, opts: HeadlessRunsOptions, visible: HeadlessRun[]): void { + const { runs, colors: c } = opts; + + // Agent options come from ALL runs, not the visible ones — otherwise selecting an + // agent would empty the list it was chosen from and strand the operator there. + const agents = [...new Set(runs.map(r => r.agent))].sort(); + if (agents.length > 1 || opts.agentFilter) { + const bar = document.createElement('div'); + bar.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:10px;'; + + const label = document.createElement('span'); + label.style.cssText = `color:${c.muted};font-size:11px;`; + label.textContent = 'agent'; + bar.appendChild(label); + + const select = document.createElement('select'); + select.style.cssText = `background:${c.surface};color:${c.text};border:1px solid ${c.border};` + + `border-radius:3px;padding:2px 6px;font-size:11px;font-family:${MONO};`; + for (const [value, text] of [['', 'all'], ...agents.map(a => [a, a])]) { + const o = document.createElement('option'); + o.value = value; + o.textContent = text; + if (value === opts.agentFilter) o.selected = true; + select.appendChild(o); + } + select.addEventListener('change', () => opts.onAgentFilterChange(select.value)); + bar.appendChild(select); + section.appendChild(bar); + } + + if (visible.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = `color:${c.muted};font-size:12px;padding:16px;` + + `background:${c.surface};border:1px solid ${c.border};border-radius:4px;`; + empty.textContent = opts.agentFilter + ? `No headless runs recorded for ${opts.agentFilter}.` + : 'No headless runs recorded.'; + section.appendChild(empty); + return; + } + + for (const run of visible) { + section.appendChild(renderRow(run, opts)); + } +} + +function renderRow(run: HeadlessRun, opts: HeadlessRunsOptions): HTMLElement { + const c = opts.colors; + + const row = document.createElement('div'); + row.style.cssText = `display:flex;align-items:center;gap:10px;padding:7px 10px;` + + `border:1px solid ${c.border};border-radius:4px;margin-bottom:4px;` + + `background:${c.surface};cursor:pointer;font-size:11px;`; + row.addEventListener('mouseenter', () => { row.style.borderColor = c.accent; }); + row.addEventListener('mouseleave', () => { row.style.borderColor = c.border; }); + row.addEventListener('click', () => opts.onSelect(run.id)); + + row.innerHTML = ` + ${escHtml(run.agent)} + ${escHtml(run.task_id8)} + ${escHtml(run.status)} + ${escHtml(ago(run.started))} + ${escHtml(formatDuration(run.duration_s))} + `; + + // The first line is agent stdout. textContent, never innerHTML. + const first = document.createElement('span'); + first.style.cssText = `color:${c.text};flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;`; + first.textContent = run.first_line; + row.appendChild(first); + + return row; +} + +// ── Detail ────────────────────────────────────────────────────────────── + +function renderDetail(section: HTMLElement, opts: HeadlessRunsOptions): void { + const c = opts.colors; + const run = opts.selectedRun as HeadlessRunDetail; + + const back = document.createElement('button'); + back.textContent = '← all runs'; + back.style.cssText = `background:transparent;color:${c.accent};border:none;cursor:pointer;` + + `font-size:11px;font-family:${MONO};padding:0;margin-bottom:10px;`; + back.addEventListener('click', opts.onBack); + section.appendChild(back); + + const meta = document.createElement('div'); + meta.style.cssText = 'display:flex;gap:10px;align-items:center;margin-bottom:10px;font-size:11px;'; + meta.innerHTML = ` + ${escHtml(run.agent)} + ${escHtml(run.task_id ?? run.task_id8)} + ${escHtml(run.status)} + `; + section.appendChild(meta); + + // ── Commands ── + // Omitted entirely when there are none, rather than rendered as an empty header. + if (run.commands.length > 0) { + const wrap = document.createElement('div'); + wrap.style.cssText = `margin-bottom:12px;padding:10px;background:${c.dim};` + + `border:1px solid ${c.border};border-radius:4px;`; + + const title = document.createElement('div'); + title.style.cssText = `color:${c.text};font-size:11px;font-weight:600;margin-bottom:8px;`; + title.textContent = `Commands (${run.commands.length})`; + wrap.appendChild(title); + + for (const cmd of run.commands) { + const line = document.createElement('div'); + line.style.cssText = 'display:flex;gap:8px;align-items:flex-start;margin-bottom:6px;'; + + const code = document.createElement('pre'); + code.style.cssText = `flex:1;margin:0;padding:6px 8px;background:${c.surface};` + + `border:1px solid ${c.border};border-radius:3px;color:${c.text};font-size:11px;` + + `font-family:${MONO};white-space:pre-wrap;word-break:break-all;`; + code.textContent = cmd; // scraped from the log — never innerHTML + line.appendChild(code); + + const copy = document.createElement('button'); + copy.textContent = 'copy'; + copy.style.cssText = `background:transparent;color:${c.muted};border:1px solid ${c.border};` + + `border-radius:3px;padding:3px 8px;font-size:10px;cursor:pointer;font-family:${MONO};`; + copy.addEventListener('click', () => opts.onCopy(cmd)); + line.appendChild(copy); + + wrap.appendChild(line); + } + section.appendChild(wrap); + } + + if (run.truncated) { + const note = document.createElement('div'); + note.style.cssText = `color:${c.warn};font-size:11px;margin-bottom:6px;`; + note.textContent = 'Output truncated — showing the first 512 KB of this log.'; + section.appendChild(note); + } + + const body = document.createElement('pre'); + body.style.cssText = `margin:0;padding:12px;background:${c.surface};border:1px solid ${c.border};` + + `border-radius:4px;color:${c.text};font-size:11px;font-family:${MONO};` + + 'white-space:pre-wrap;word-break:break-word;max-height:460px;overflow-y:auto;'; + body.textContent = run.text; // agent stdout — textContent, never innerHTML + section.appendChild(body); +} diff --git a/src/panels/task-detail.ts b/src/panels/task-detail.ts index 9369182..15fccff 100644 --- a/src/panels/task-detail.ts +++ b/src/panels/task-detail.ts @@ -13,6 +13,12 @@ interface TaskDetailOptions { onUnpark: (taskId: string) => void; onAmend: (taskId: string) => void; onSetStatus: (taskId: string, status: string) => void; + /** + * Set when a headless launch log exists for this task. This is the affordance that + * answers "did that run finish and hand off?" from where the operator already is, + * rather than requiring them to scroll to the runs section and match ids by eye. + */ + onOpenRun?: () => void; } const DETAIL_TERMINAL_STATUSES = ['completed', 'failed', 'cancelled']; @@ -28,6 +34,7 @@ export function renderTaskDetail(container: HTMLElement, opts: TaskDetailOptions const wrapper = document.createElement('div'); wrapper.className = 'tq-up'; + const { onOpenRun } = opts; // Back button const backBtn = document.createElement('button'); @@ -53,6 +60,16 @@ export function renderTaskDetail(container: HTMLElement, opts: TaskDetailOptions `; wrapper.appendChild(header); + if (onOpenRun) { + const runLink = document.createElement('button'); + runLink.textContent = 'view run output \u2192'; + runLink.style.cssText = `background:transparent;color:${c.accent};border:1px solid ${c.border};` + + `border-radius:3px;padding:3px 10px;font-size:11px;font-family:${MONO};cursor:pointer;` + + 'margin-bottom:12px;'; + runLink.addEventListener('click', onOpenRun); + wrapper.appendChild(runLink); + } + // Metadata grid const meta = document.createElement('div'); meta.style.cssText = `display:grid;grid-template-columns:120px 1fr;gap:4px 12px;font-size:12px;margin-bottom:16px;padding:12px;background:${c.surface};border:1px solid ${c.border};border-radius:4px;`; diff --git a/src/path-guard.ts b/src/path-guard.ts new file mode 100644 index 0000000..b345e2a --- /dev/null +++ b/src/path-guard.ts @@ -0,0 +1,37 @@ +/** + * Filesystem path authorization. + * + * Extracted from server.ts's previewFile() for the same reason control-api.ts and + * ws-guard.ts were: server.ts calls server.listen() at import time, so a test that + * imports it boots a real listener. Unlike those two this is not a pure function — + * it must touch the filesystem, because resolving symlinks is the whole point. + * + * There is exactly one copy of this check. previewFile() and the headless-run log + * reader both call it. A second, subtly different implementation is precisely how + * the realpath property gets lost. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Resolve `filePath` and return it only if it lands inside one of `allowedPrefixes`. + * Returns null for anything that does not exist, cannot be resolved, or escapes. + * + * realpath BEFORE the prefix compare: path.resolve normalises `..` but follows no + * symlinks, so a symlink sitting inside an allowed prefix and pointing at + * ~/.secrets would pass a resolve-only check. + * + * The trailing path.sep on the prefix is load-bearing — without it `/comms-other` + * matches the `/comms` prefix. + */ +export function resolveAllowedPath(filePath: string, allowedPrefixes: string[]): string | null { + let resolved: string; + try { + resolved = fs.realpathSync(path.resolve(filePath)); + } catch { + return null; + } + if (!allowedPrefixes.some(p => resolved.startsWith(p + path.sep))) return null; + return resolved; +} diff --git a/src/server.ts b/src/server.ts index 83d014e..004036d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,17 @@ import { WebSocketServer, WebSocket } from 'ws'; import { load as yamlLoad } from 'js-yaml'; import { callControlApi, type ControlAction } from './control-api.ts'; import { evaluateUpgrade, allowedOrigins } from './ws-guard.ts'; +import { resolveAllowedPath } from './path-guard.ts'; +import type { HeadlessRun, HeadlessRunDetail } from './types.ts'; +import { + parseLaunchLogName, + parseRunId, + runIdToFilename, + runId, + firstLine, + extractFencedBlocks, + runTimes, +} from './launch-log.ts'; import { loadLaunchPolicy, policyPath, @@ -142,16 +153,10 @@ const PREVIEW_ALLOWED_PREFIXES = [ ]; function previewFile(filePath: string, lines = 20): string | null { - // path.resolve normalises `..` but does not resolve symlinks, so a symlink inside an - // allowed prefix pointing anywhere on disk would pass the check. realpath first, then - // compare — and keep the trailing separator so `/comms-other` can't match `/comms`. - let resolved: string; - try { - resolved = fs.realpathSync(path.resolve(filePath)); - } catch { - return null; - } - if (!PREVIEW_ALLOWED_PREFIXES.some(p => resolved.startsWith(p + path.sep))) return null; + // The realpath-then-prefix check lives in path-guard.ts — one copy, shared with the + // headless-run log reader. See that module for why realpath comes first. + const resolved = resolveAllowedPath(filePath, PREVIEW_ALLOWED_PREFIXES); + if (!resolved) return null; try { const content = fs.readFileSync(resolved, 'utf-8'); const result = content.split('\n').slice(0, lines).join('\n'); @@ -261,6 +266,155 @@ function launchSession(taskId: string, targetAgent: string, mode: StartMode): { } } +// ── Headless run reader (read-only) ─────────────────────────────────── + +// This section READS launch logs and never writes them. Both routes resolve through +// the shared path guard; neither ever treats a route id as a path. +// +// SECURITY[accepted]: these routes make launch logs — raw `claude -p` stdout — reachable +// from a browser, where previously they were readable only over SSH. If an agent ever +// prints a credential into its final message, this surfaces it. Accepted 2026-08-27: the +// audience does not widen. The backend binds 127.0.0.1 on an ephemeral port and is reached +// only through CloudCLI's authenticated plugin RPC proxy, so any caller here is already an +// operator-level principal who can read every task payload, launch sessions as any agent, +// and cancel work. Reading agent stdout is not a step up from that. Note the surface IS +// wider than the pre-existing /tasks/:id preview, which returns 20 lines of only the files +// a task names in context_refs — this returns full text for every log, enumerable. Redaction, +// if ever wanted, belongs on the two PRODUCERS (task-dispatcher.py and launchSession), not +// here: a read-only viewer cannot know what a token looks like in arbitrary prose. +// Audit: 2026-08-27/task-queue-headless-runs-ui-2026-08 (F-01, Low). + +const HEADLESS_HEAD_BYTES = 2048; +const HEADLESS_MAX_BYTES = 512 * 1024; + +/** + * Map an 8-char task-id prefix to the live queue task, for the derived status. + * + * A log proves a session ran; it does not prove its task closed. When the two + * disagree that is real signal, not noise — steward-f42d3aeb is a completed run whose + * task sat at `approved` for four days. Render the disagreement; never infer a status + * from the log's prose. + * + * A prefix collision resolves to `unknown` rather than to an arbitrary one of the two. + */ +function queueIndexByPrefix(): Map { + const idx = new Map(); + const collided = new Set(); + for (const t of listTasks()) { + const id = typeof t.id === 'string' ? t.id : ''; + if (!id) continue; + const key = id.slice(0, 8); + if (collided.has(key)) continue; + if (idx.has(key)) { idx.delete(key); collided.add(key); continue; } + idx.set(key, { status: typeof t.status === 'string' ? t.status : 'unknown', taskId: id }); + } + return idx; +} + +/** Read at most `bytes` from the head of a file. Never reads a whole log to get one line. */ +function readHead(filePath: string, bytes: number): string { + let fd: number | null = null; + try { + fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(bytes); + const n = fs.readSync(fd, buf, 0, bytes, 0); + return buf.subarray(0, n).toString('utf-8'); + } catch { + return ''; + } finally { + if (fd !== null) { try { fs.closeSync(fd); } catch { /* already gone */ } } + } +} + +function listHeadlessRuns(agentFilter?: string): HeadlessRun[] { + let names: string[]; + try { + names = fs.readdirSync(LAUNCH_LOG_DIR); + } catch { + return []; // directory absent is an empty list, not an error + } + + const queue = queueIndexByPrefix(); + const runs: HeadlessRun[] = []; + + for (const name of names) { + // Skip anything that is not -.log rather than guessing at an agent. + // Two pre-2026-08 orphans are named with a bare task UUID and no agent. + const parsed = parseLaunchLogName(name); + if (!parsed) continue; + if (agentFilter && parsed.agent !== agentFilter) continue; + + // The guard runs on the LIST route too, not only on the detail route: this loop + // head-reads every file, so a symlink planted in the log dir would otherwise put + // the first line of its target into a row. + const resolved = resolveAllowedPath(path.join(LAUNCH_LOG_DIR, name), PREVIEW_ALLOWED_PREFIXES); + if (!resolved) continue; + + let st: fs.Stats; + try { st = fs.statSync(resolved); } catch { continue; } + if (!st.isFile()) continue; + + const match = queue.get(parsed.taskId8); + runs.push({ + id: runId(parsed.agent, parsed.taskId8), + agent: parsed.agent, + task_id8: parsed.taskId8, + task_id: match?.taskId ?? null, + status: match?.status ?? 'unknown', + size: st.size, + first_line: firstLine(readHead(resolved, HEADLESS_HEAD_BYTES)), + ...runTimes(st), + }); + } + + runs.sort((a, b) => b.ended.localeCompare(a.ended)); + return runs; +} + +function readHeadlessRun(id: string): HeadlessRunDetail | null { + // `id` is validated as - and the filename is then REBUILT via + // launchLogName, so the caller's string never reaches the filesystem as a path even + // before the realpath guard runs. Two independent barriers, deliberately. + const parsed = parseRunId(id); + if (!parsed) return null; + + const resolved = resolveAllowedPath( + path.join(LAUNCH_LOG_DIR, runIdToFilename(parsed)), + PREVIEW_ALLOWED_PREFIXES, + ); + if (!resolved) return null; + + let text: string; + let truncated: boolean; + let fd: number | null = null; + try { + const st = fs.statSync(resolved); + if (!st.isFile()) return null; + fd = fs.openSync(resolved, 'r'); + // Read one byte past the cap so a file exactly at the cap is not called truncated. + const buf = Buffer.alloc(HEADLESS_MAX_BYTES + 1); + const n = fs.readSync(fd, buf, 0, HEADLESS_MAX_BYTES + 1, 0); + truncated = n > HEADLESS_MAX_BYTES; + text = buf.subarray(0, Math.min(n, HEADLESS_MAX_BYTES)).toString('utf-8'); + } catch { + return null; + } finally { + if (fd !== null) { try { fs.closeSync(fd); } catch { /* already gone */ } } + } + + const match = queueIndexByPrefix().get(parsed.taskId8); + return { + id: runId(parsed.agent, parsed.taskId8), + agent: parsed.agent, + task_id8: parsed.taskId8, + task_id: match?.taskId ?? null, + status: match?.status ?? 'unknown', + text, + commands: extractFencedBlocks(text), + truncated, + }; +} + // ── File watcher ────────────────────────────────────────────────────── let watchDebounce: ReturnType | null = null; @@ -353,6 +507,23 @@ const server = http.createServer(async (req, res) => { return; } + // List headless runs. Read-only. + if (pathname === '/headless-runs' && req.method === 'GET') { + const agent = url.searchParams.get('agent'); + res.end(JSON.stringify({ runs: listHeadlessRuns(agent ?? undefined) })); + return; + } + + // One headless run's full output. Read-only. The id is matched by the same strict + // character class as /tasks/:id and is never treated as a path — see readHeadlessRun. + const runMatch = pathname.match(/^\/headless-runs\/([a-zA-Z0-9_-]+)$/); + if (runMatch && req.method === 'GET') { + const run = readHeadlessRun(runMatch[1]); + if (!run) { res.statusCode = 404; res.end(JSON.stringify({ error: 'not found' })); return; } + res.end(JSON.stringify(run)); + return; + } + // Start task const startMatch = pathname.match(/^\/tasks\/([a-zA-Z0-9_-]+)\/start$/); if (startMatch && req.method === 'POST') { diff --git a/src/tests/launch-log.test.ts b/src/tests/launch-log.test.ts new file mode 100644 index 0000000..3c56305 --- /dev/null +++ b/src/tests/launch-log.test.ts @@ -0,0 +1,180 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { launchLogName } from '../launch-policy.ts'; +import { + parseLaunchLogName, + parseRunId, + runIdToFilename, + runId, + firstLine, + extractFencedBlocks, + runTimes, +} from '../launch-log.ts'; + +// ── The producer/parser contract ────────────────────────────────────── +// These are the tests that matter most. The parser is the inverse of launchLogName, +// and pinning it to the real function is what stops the two drifting apart silently — +// a drifted parser does not error, it just lists nothing. + +test('parseLaunchLogName inverts launchLogName for every rostered agent', () => { + const taskId = 'f42d3aeb-1c4e-4a77-9b2f-0d5e6a7b8c9d'; + for (const agent of ['sysadmin', 'developer', 'research', 'writer', 'security', 'steward']) { + const parsed = parseLaunchLogName(launchLogName(agent, taskId)); + assert.deepEqual(parsed, { agent, taskId8: 'f42d3aeb' }, `round trip failed for ${agent}`); + } +}); + +test('parseLaunchLogName inverts launchLogName for a hyphenated agent name', () => { + // No rostered agent has a hyphen today, but the split must stay unambiguous if one + // ever does — the task segment is fixed-width and anchored, so it cannot be eaten. + const name = launchLogName('build-worker', 'abcdef01-2222-3333-4444-555555555555'); + assert.deepEqual(parseLaunchLogName(name), { agent: 'build-worker', taskId8: 'abcdef01' }); +}); + +test('runIdToFilename round-trips a parsed run id back through the producer', () => { + const name = launchLogName('steward', 'f42d3aeb-1c4e-4a77-9b2f-0d5e6a7b8c9d'); + const parsed = parseRunId('steward-f42d3aeb'); + assert.ok(parsed); + assert.equal(runIdToFilename(parsed), name); + assert.equal(runId(parsed.agent, parsed.taskId8), 'steward-f42d3aeb'); +}); + +// ── Rejection ───────────────────────────────────────────────────────── + +test('parseLaunchLogName rejects the bare-UUID orphans in the live log directory', () => { + // Two real files predating the - scheme. They must be SKIPPED, never + // rendered as a row with a guessed or empty agent. + assert.equal(parseLaunchLogName('94e1e015-b8dd-4841-a2ae-03be0742b11f.log'), null); + assert.equal(parseLaunchLogName('ddf53cb9-eda6-46ec-8015-48268c92a466.log'), null); +}); + +test('parseLaunchLogName rejects malformed names', () => { + for (const bad of [ + 'steward.log', // no task segment + 'steward-f42d3aeb', // no extension + 'steward-f42d3ae.log', // 7 chars + 'steward-f42d3aebc.log', // 9 chars + 'steward-f42d3aeg.log', // 'g' is not hex + 'steward-F42D3AEB.log', // uppercase — uuid4 renders lowercase + '-f42d3aeb.log', // empty agent + '9steward-f42d3aeb.log', // agent must start with a letter + 'Steward-f42d3aeb.log', // uppercase agent + 'steward-f42d3aeb.log.bak', + 'steward-f42d3aeb.txt', + '', + ]) { + assert.equal(parseLaunchLogName(bad), null, `should reject: ${bad}`); + } +}); + +test('parseRunId rejects anything path-shaped', () => { + // The route id must never be usable as a path. This is the first of the two + // barriers; resolveAllowedPath is the second. + for (const bad of [ + '../../../etc/passwd', + '../../.secrets/forge', + 'steward-f42d3aeb/../../etc/passwd', + '/etc/passwd', + 'steward-f42d3aeb ', + '..', + '.', + ]) { + assert.equal(parseRunId(bad), null, `should reject: ${bad}`); + } +}); + +// ── first_line ──────────────────────────────────────────────────────── + +test('firstLine returns the first non-empty line, trimmed', () => { + assert.equal(firstLine('\n\n Both memory checkpoints are written. \nnext\n'), + 'Both memory checkpoints are written.'); +}); + +test('firstLine returns empty string for blank or empty input', () => { + assert.equal(firstLine(''), ''); + assert.equal(firstLine('\n \n\t\n'), ''); +}); + +test('firstLine truncates with an ellipsis at the cap', () => { + const out = firstLine('x'.repeat(500), 20); + assert.equal(out.length, 20); + assert.ok(out.endsWith('…')); +}); + +// ── Fenced-block extraction ─────────────────────────────────────────── + +test('extractFencedBlocks pulls the body of each fence, dropping the info string', () => { + const text = [ + 'Run this:', '```bash', 'pm2 restart cloudcli', '```', + 'and then:', '```', './deploy.sh', '```', 'done', + ].join('\n'); + assert.deepEqual(extractFencedBlocks(text), ['pm2 restart cloudcli', './deploy.sh']); +}); + +test('extractFencedBlocks keeps a multi-line block intact', () => { + const text = '```\ncd ~/repos\ngit status\n```'; + assert.deepEqual(extractFencedBlocks(text), ['cd ~/repos\ngit status']); +}); + +test('extractFencedBlocks returns an empty array when there are no fences', () => { + // Every one of the 26 logs migrated on 2026-08-27 is in this state — the Commands + // block must be omitted entirely rather than rendered empty. + assert.deepEqual(extractFencedBlocks('Task b32e75e2 is done, nothing to run.'), []); +}); + +test('extractFencedBlocks skips an empty fence', () => { + assert.deepEqual(extractFencedBlocks('```\n\n```'), []); +}); + +test('extractFencedBlocks DROPS an unterminated fence', () => { + // These strings are handed to the operator with a copy button. A fence with no + // closing delimiter has no known end, and half a command is worse than none. + assert.deepEqual(extractFencedBlocks('```bash\nrm -rf /tmp/x --exclude=keep'), []); + assert.deepEqual( + extractFencedBlocks('```\nfirst\n```\ntext\n```\ntruncated here'), + ['first'], + ); +}); + +test('extractFencedBlocks handles an indented fence', () => { + assert.deepEqual(extractFencedBlocks(' ```sh\n echo hi\n ```'), ['echo hi']); +}); + +// ── Run timestamps ──────────────────────────────────────────────────── + +test('runTimes uses birthtime when it precedes mtime', () => { + const start = Date.UTC(2026, 7, 23, 20, 34, 2); + const end = Date.UTC(2026, 7, 23, 20, 35, 29); + const t = runTimes({ birthtimeMs: start, mtimeMs: end }); + assert.equal(t.started, new Date(start).toISOString()); + assert.equal(t.ended, new Date(end).toISOString()); + assert.equal(t.duration_s, 87); +}); + +test('runTimes falls back to mtime when birthtime is AFTER mtime', () => { + // The copy-migration case, and the reason this guard exists: cp -p preserves mtime + // but resets birthtime to the copy time, so every migrated log has birthtime in the + // future relative to mtime. Reporting that verbatim would show a run that started + // "just now" and lasted a negative number of seconds. + const written = Date.UTC(2026, 7, 23, 20, 35, 29); + const copied = Date.UTC(2026, 7, 27, 17, 9, 34); + const t = runTimes({ birthtimeMs: copied, mtimeMs: written }); + assert.equal(t.started, new Date(written).toISOString()); + assert.equal(t.ended, new Date(written).toISOString()); + assert.equal(t.duration_s, null, 'duration must be unknown, never negative'); +}); + +test('runTimes falls back to mtime when birthtime is unavailable', () => { + const end = Date.UTC(2026, 7, 23, 20, 35, 29); + for (const birthtimeMs of [0, NaN]) { + const t = runTimes({ birthtimeMs, mtimeMs: end }); + assert.equal(t.started, new Date(end).toISOString()); + assert.equal(t.duration_s, null); + } +}); + +test('runTimes reports a zero-second run as 0, not as unknown', () => { + // birthtime === mtime is a legitimately fast run, distinct from an unusable one. + const at = Date.UTC(2026, 7, 23, 20, 35, 29); + assert.equal(runTimes({ birthtimeMs: at, mtimeMs: at }).duration_s, 0); +}); diff --git a/src/tests/path-guard.test.ts b/src/tests/path-guard.test.ts new file mode 100644 index 0000000..6eebf29 --- /dev/null +++ b/src/tests/path-guard.test.ts @@ -0,0 +1,84 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveAllowedPath } from '../path-guard.ts'; + +// Real files and real symlinks. The property under test is that realpath runs BEFORE +// the prefix compare, and a mocked fs cannot demonstrate that. +function fixture(): { root: string; allowed: string; cleanup: () => void } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-')); + const allowed = path.join(root, 'comms'); + fs.mkdirSync(allowed, { recursive: true }); + fs.mkdirSync(path.join(root, 'secrets'), { recursive: true }); + fs.writeFileSync(path.join(allowed, 'ok.log'), 'inside\n'); + fs.writeFileSync(path.join(root, 'secrets', 'forge.env'), 'TOKEN=hunter2\n'); + return { root, allowed, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }; +} + +test('resolveAllowedPath accepts a real file inside an allowed prefix', () => { + const f = fixture(); + try { + const target = path.join(f.allowed, 'ok.log'); + assert.equal(resolveAllowedPath(target, [f.allowed]), fs.realpathSync(target)); + } finally { f.cleanup(); } +}); + +test('resolveAllowedPath refuses a symlink that escapes the allowed prefix', () => { + const f = fixture(); + try { + // The classic escape: the link itself sits inside the allowed prefix, so a + // resolve-only check would pass it. Its target does not. + const link = path.join(f.allowed, 'evil-deadbeef.log'); + fs.symlinkSync(path.join(f.root, 'secrets', 'forge.env'), link); + assert.equal(resolveAllowedPath(link, [f.allowed]), null); + } finally { f.cleanup(); } +}); + +test('resolveAllowedPath refuses traversal out of the prefix', () => { + const f = fixture(); + try { + const escape = path.join(f.allowed, '..', 'secrets', 'forge.env'); + assert.equal(resolveAllowedPath(escape, [f.allowed]), null); + } finally { f.cleanup(); } +}); + +test('resolveAllowedPath refuses a sibling directory sharing the prefix string', () => { + const f = fixture(); + try { + // Without the trailing separator, `/comms-other` startsWith `/comms`. + const sibling = path.join(f.root, 'comms-other'); + fs.mkdirSync(sibling, { recursive: true }); + const target = path.join(sibling, 'ok.log'); + fs.writeFileSync(target, 'outside\n'); + assert.equal(resolveAllowedPath(target, [f.allowed]), null); + } finally { f.cleanup(); } +}); + +test('resolveAllowedPath returns null for a file that does not exist', () => { + const f = fixture(); + try { + assert.equal(resolveAllowedPath(path.join(f.allowed, 'nope.log'), [f.allowed]), null); + } finally { f.cleanup(); } +}); + +test('resolveAllowedPath accepts a file under any one of several prefixes', () => { + const f = fixture(); + try { + const second = path.join(f.root, 'queue'); + fs.mkdirSync(second, { recursive: true }); + const target = path.join(second, 'a.yml'); + fs.writeFileSync(target, 'x\n'); + assert.equal(resolveAllowedPath(target, [f.allowed, second]), fs.realpathSync(target)); + } finally { f.cleanup(); } +}); + +test('resolveAllowedPath refuses the allowed directory itself', () => { + const f = fixture(); + try { + // The prefix compare requires something AFTER the separator, so the bare + // directory is not a readable member of itself. + assert.equal(resolveAllowedPath(f.allowed, [f.allowed]), null); + } finally { f.cleanup(); } +}); diff --git a/src/types.ts b/src/types.ts index 82061e6..2d50e9c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,3 +82,42 @@ export interface Task { }; history: TaskHistoryEntry[]; } + +// ── Headless runs ────────────────────────────────────────────────────── + +/** + * One headless agent run, derived from its launch log file. Defined here rather than + * separately in server.ts and index.ts so the route's response shape has exactly one + * definition — the two are a single contract across the bundle boundary. + */ +export interface HeadlessRun { + /** `-` — the route id, and the log filename without `.log`. */ + id: string; + agent: string; + task_id8: string; + /** Full task id, when a live queue task matches the prefix. */ + task_id: string | null; + /** + * Derived from the QUEUE, not from the log. A log proves a session ran; it does not + * prove its task closed. When the two disagree that is signal worth rendering. + */ + status: string; + started: string; + ended: string; + /** null when the start time is unknowable — render as unknown, never as zero. */ + duration_s: number | null; + size: number; + first_line: string; +} + +export interface HeadlessRunDetail { + id: string; + agent: string; + task_id8: string; + task_id: string | null; + status: string; + text: string; + /** Fenced code blocks scraped from the log, offered with copy buttons. */ + commands: string[]; + truncated: boolean; +}