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
13 changes: 11 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ src/
control-api.ts The single outbound mutation path to task-queue-mcp.
Extracted from server.ts so the auth and transport
guards are unit-testable without booting the server.
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.
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/
Expand All @@ -36,8 +41,12 @@ src/
- **The plugin acts as `operator`, never as an agent.** Every proxied mutation sends `actor: 'operator'`. This is what makes the MCP's `amend_task` authorization accept it; the plugin must never assert an agent's identity.
- **Mutations fail closed without the secret.** `callControlApi` returns 500 and never attempts the fetch when `TASK_QUEUE_API_SECRET` is empty, and logs why. Do not add a fallback that proceeds without it.
- **The version comes from `package.json`.** `server.ts` reads it at startup rather than hardcoding a copy — a hardcoded constant silently drifted and reported a stale version on `/health` for two releases. `package.json` and `manifest.json` must also agree.
- **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.
- **The WebSocket upgrade rejects a missing Origin**, not just a wrong one. Browsers always send one; only non-browser clients omit it, which is precisely what the check is for.
- **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.

## Testing

Expand All @@ -47,7 +56,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, and pass-through of the MCP's authorization rejections. 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), 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
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,80 @@

All notable changes to this project will be documented in this file.

## [0.5.0] - 2026-08-27

Requires a launch policy file at `~/scripts/agent-launch.yml` (see README). The Start
button is disabled with a named error if it is missing or malformed — it never falls back
to a built-in roster, because falling back is what this release removes.

### Fixed
- **The live WebSocket, dead since v0.4.0.** v0.4.0 tightened the upgrade guard to reject a
*missing* `Origin` as well as a wrong one, reasoning that only non-browser clients omit
it. The one non-browser client here is CloudCLI's own plugin WS proxy, which uses the
`ws` library — and that sends no `Origin` unless explicitly passed. Every upstream
handshake was 403'd from 2026-08-02 to 2026-08-27: 2239 `WS proxy error … 403` lines, and
a tab that read `disconnected` the whole time. The guard now gates on the **peer**
(loopback only, refused outright otherwise) and applies the `Origin` allowlist only when
an `Origin` is actually present. A present-but-wrong `Origin` is still refused; the
loopback bind was always the real boundary. `AGENTS.md` carried the old rule as a project
invariant and has been rewritten — leaving it would have invited the same regression from
the next reviewer. New `ws-guard.ts` holds the decision as a pure function, with tests.
- **The five-second full-panel repaint.** Every failed reconnect emitted `_disconnected`,
which called `render()` — and `render()` does `root.innerHTML = ''`. So the entire panel
was torn down and rebuilt every 5s while disconnected, losing scroll position and closing
any open filter dropdown, even though `wsConnected` was already `false`. Connection state
no longer reaches `render()` at all: it updates the header badge in place, and only on a
genuine transition.
- **Start could not launch a run-as agent.** `AGENT_PROJECTS` was a second, drifted copy of
`task-dispatcher.py`'s roster with no `steward` entry, so Start refused it outright. The
literal is deleted rather than extended: simply adding an entry would have made
`launchSession()` spawn `claude` as the plugin's own user, bypassing the launcher whose
entire purpose is that agent's isolation — a session appearing as steward in every log
and holding none of steward's credentials.

### Added
- **Reconnect backoff** — 5s → 10s → 30s, capped, reset on a successful open. The first
delay is unchanged, so a transient blip still recovers as fast as before; the widening is
for outages. A fixed 5s retry is what turned a three-week outage into 2239 identical log
lines.
- **`launch-policy.ts`** — one roster, read by this plugin and by `task-dispatcher.py`, from
`~/scripts/agent-launch.yml`. `AGENT_LAUNCH_POLICY` overrides the path. Every field is
validated against a closed set (agent name shape, `project_dir` under `~/.claude/projects`,
`run_as_user` matching `agent-*`, `launcher` under `/usr/local/sbin/forge/`) and the whole
document is rejected on any violation — a partially-honoured roster would silently launch
some agent the wrong way. A missing or malformed file is a named error, never an empty
policy.
- An agent carrying `run_as_user` is launched as
`sudo -n -u <user> <launcher> --workflow-mode <mode> -- <prompt>`, mirroring the
dispatcher. A launcher that is missing or not executable is refused **by name**; it never
degrades to spawning `claude`.
- `env:CLOUDCLI_ORIGIN` in `manifest.json` permissions, so the plugin's allowlist can carry
the same origin the host's proxy sends.
- Tests for the upgrade guard, the reconnect schedule, and the launch policy. The suite grew
from 12 to 39.

### Changed
- **Launch logs moved** to `~/.claude/comms/artifacts/task-launches/<agent>-<task8>.log`.
The dispatcher wrote `~/.pm2/logs/agent-launch-<agent>-<task8>.log` and this plugin wrote
`<taskId>.log` — two destinations for one concept, so nothing could list "the launches".
Both now write the same shape to the same directory. `~/.claude/comms` is the side both
can read; `~/.pm2/logs` is not in `PREVIEW_ALLOWED_PREFIXES` and must not be added, since
that prefix covers every PM2 service log on the host.
- **Start's `review` maps to the queue's `semi-auto`** for run-as agents rather than being
passed through. `review` is not a value `task-queue-mcp` or `run-steward.sh` accepts —
passed through, the launcher refuses it by name. Note that for a run-as agent `review` is
**prompt-enforced only**: `run-steward.sh` sets `--dangerously-skip-permissions` itself and
accepts no permission mode, so `--permission-mode plan` is not reachable. The toast says so
rather than implying a tool gate that is not there.
- **Symlink handling in the launch-policy validator is now identical to the dispatcher's**
(security audit, Low). Neither side resolves symlinks — not in the containment root and
not in the candidate `project_dir`. The Python side used to `.resolve()` its root while
this side used a plain join, so with a symlink anywhere on the path the two disagreed
about the same input. Documented in both files as a rule rather than left as an accident
of two independent implementations.
- A refused WebSocket upgrade now logs why. v0.4.0's refusals were silent on this side; the
only signal was a 403 on the far side of the proxy, naming neither leg nor the cause.

## [0.4.0] - 2026-08-02

Requires [task-queue-mcp](https://github.com/TadMSTR/task-queue-mcp) **v0.4.0** — the
Expand Down
63 changes: 53 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,14 @@ pm2 restart cloudcli
|----------|---------|---------|
| `TASK_QUEUE_API` | `http://127.0.0.1:8485` | Base URL of the task-queue-mcp HTTP control API. Configurable — the default assumes the MCP server runs loopback-local to CloudCLI. |
| `TASK_QUEUE_API_SECRET` | — | Shared secret sent as `X-Task-Queue-Secret` on every mutation. **Required** — mutations fail closed if unset. Comes from your secret store, never from source. |
| `CLOUDCLI_ORIGIN` | — | Additional allowed WebSocket origin. `http://localhost:3001` and `http://127.0.0.1:3001` are always allowed; set this if CloudCLI is served from another origin. |
| `CLOUDCLI_ORIGIN` | — | Additional allowed WebSocket origin, and the origin the CloudCLI host's plugin proxy sends on its upstream leg. Both sides read the same variable so they cannot disagree. `http://localhost:3001` and `http://127.0.0.1:3001` are always allowed. |
| `AGENT_LAUNCH_POLICY` | `~/scripts/agent-launch.yml` | Path to the launch policy file (see [Session launch behaviour](#session-launch-behaviour)). |

### How the plugin receives its env vars

CloudCLI launches the backend as a subprocess and **strips host environment variables from it by default**, including secrets. A host var reaches the plugin only when **both** are true:

1. `manifest.json` declares it — `permissions: ["env:TASK_QUEUE_API", "env:TASK_QUEUE_API_SECRET"]`, and
1. `manifest.json` declares it — `permissions: ["env:TASK_QUEUE_API", "env:TASK_QUEUE_API_SECRET", "env:CLOUDCLI_ORIGIN"]`, and
2. the var is on CloudCLI's host-side plugin env allowlist.

This needs a CloudCLI build with permission-gated env passthrough. **Without it the launcher silently strips the secret and every mutation fails closed** — the UI reports an error, but nothing about the failure names the passthrough as the cause. If mutations fail while reads work, check this first. The control-API call path logs missing-secret and unreachable-transport failures to the CloudCLI process's stderr log, and never logs the secret value.
Expand All @@ -104,7 +105,11 @@ The backend exposes a small HTTP API consumed by the UI via `api.rpc()`.

Reads are served directly from the queue YAML. Every proxied mutation carries the `X-Task-Queue-Secret` header and an `actor` of `operator`.

WebSocket upgrade is handled on the same port. Clients receive `{type: "connected", version}` on connect and `{type: "tasks", count, changed}` when task files change. The upgrade handler rejects any request whose `Origin` is missing or not allowed.
WebSocket upgrade is handled on the same port. Clients receive `{type: "connected", version}` on connect and `{type: "tasks", count, changed}` when task files change.

The upgrade handler gates on the **peer address** first: the server binds `127.0.0.1` on an ephemeral port, so a non-loopback peer is refused outright. An `Origin` is then checked against the allowlist **only if one is present**. A loopback peer that sends no `Origin` is accepted, because that is what CloudCLI's own plugin WS proxy looks like — the `ws` client library sends no `Origin` unless one is passed, and that leg is already authenticated by CloudCLI before the proxy is invoked. A present-but-wrong `Origin` is still refused.

> Do not "harden" this by rejecting a missing `Origin`. v0.4.0 did exactly that and 403'd every connect for three weeks, because the only client that reaches this port is the trusted proxy. The loopback bind is the boundary. The rule is a pure function in `src/ws-guard.ts` with tests for all three cases.

## Session launch behaviour

Expand All @@ -113,16 +118,54 @@ WebSocket upgrade is handled on the same port. Clients receive `{type: "connecte
| `review` | `plan` | Read the task, present a summary, wait for approval |
| `auto` | `default` | Read the task, claim it (`in-progress`), execute |

A task's `target_agent` is mapped to a project directory to launch in. The mapping is **a hardcoded map in `src/server.ts`** — adapting this plugin to a different set of agents means editing that map:
### The launch policy file

A task's `target_agent` is resolved through a **data file**, not a map in the source:
`~/scripts/agent-launch.yml` by default, overridable with `AGENT_LAUNCH_POLICY`. Adapting
this plugin to a different set of agents means editing that file — no rebuild.

```yaml
my-agent:
project_dir: ~/.claude/projects/my-agent

```ts
const AGENT_PROJECTS: Record<string, string> = {
'my-agent': path.join(HOME, '.claude', 'projects', 'my-agent'),
// ...one entry per agent that can be a target_agent
};
# An agent that must NOT run as the plugin's own user:
my-isolated-agent:
project_dir: ~/.claude/projects/my-isolated-agent
run_as_user: agent-my-isolated-agent
launcher: /usr/local/sbin/forge/run-my-isolated-agent.sh
```

A task whose `target_agent` is absent from the map returns a clean `Unknown agent` error rather than launching.
The file is deliberately shared with whatever else launches your agents (on the reference
deployment, a cron dispatcher reads the same file). A second copy of this roster is what
this release removes: the plugin's private map had drifted and was missing an agent
entirely, so Start refused it.

**`run_as_user` is the part that matters.** An entry carrying it is launched as
`sudo -n -u <user> <launcher> --workflow-mode <mode> -- <prompt>` — never as `claude`
directly. That indirection exists because such an agent's credentials are readable only by
that user; spawning `claude` as the plugin's own user instead would produce a session that
appears as the agent in every log while holding none of its credentials. If the launcher is
missing or not executable, Start fails **by name**; it does not fall back.

Every field is validated against a closed set — agent name shape, `project_dir` under
`~/.claude/projects`, `run_as_user` matching `agent-*`, `launcher` under
`/usr/local/sbin/forge/` — and the whole document is rejected on any violation. A missing or
malformed file disables Start with a named error rather than yielding an empty policy, since
an empty policy makes `run_as_user` absent for *every* agent.

A task whose `target_agent` is absent from the file returns a clean `Unknown agent` error
rather than launching.

> **Mode vocabulary.** Start sends `review | auto`; a launcher taking `--workflow-mode`
> receives `semi-auto | auto`, mapped explicitly. For a run-as agent, `review` is
> **prompt-enforced only** — the reference launcher sets `--dangerously-skip-permissions`
> itself and accepts no permission mode, so `--permission-mode plan` is not reachable. The
> UI says so on launch rather than implying a tool gate.

### Launch logs

Each launch appends to `~/.claude/comms/artifacts/task-launches/<agent>-<task8>.log`, the
same shape and directory the reference dispatcher writes, so both are listable together.

## Development

Expand Down
8 changes: 6 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
{
"name": "task-queue",
"displayName": "Task Queue",
"version": "0.4.0",
"version": "0.5.0",
"description": "Task queue dashboard — view, filter, and launch agent tasks.",
"author": "TadMSTR",
"icon": "icon.svg",
"type": "module",
"slot": "tab",
"entry": "dist/index.js",
"server": "dist/server.js",
"permissions": ["env:TASK_QUEUE_API", "env:TASK_QUEUE_API_SECRET"]
"permissions": [
"env:TASK_QUEUE_API",
"env:TASK_QUEUE_API_SECRET",
"env:CLOUDCLI_ORIGIN"
]
}
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.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading