Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,19 @@ 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
YAML schema — keep them in step.
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
Expand All @@ -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 <user> <launcher> …`, 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 `<agent>-<task8>` 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.

Expand All @@ -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:

Expand Down
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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.

### 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 `<agent>-<task8>` 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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<agent>-<task8>`. 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`.

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cloudcli-plugin-task-queue",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
92 changes: 91 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────────────────────────────
Expand All @@ -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 ──────────────────────────────────────────────────────────────
Expand All @@ -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;
Expand Down Expand Up @@ -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<void> {
// 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<void> {
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<void> {
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<void> {
try {
const res = await api.rpc('GET', `tasks/${taskId}`) as { task: Task; previews: Record<string, string | null> };
Expand Down Expand Up @@ -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');
Expand All @@ -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};`;
Expand Down
Loading
Loading