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
26 changes: 16 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ src/
index.ts UI entry — mount/unmount, app state, action handlers.
Talks to the backend only via api.rpc(). No fs, no fetch.
server.ts Backend — HTTP + WebSocket, launched by CloudCLI as a
subprocess. Reads queue YAML directly; proxies every
mutation to control-api.ts. Owns the file watcher and
the session launcher.
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.
subprocess. Reads and writes the queue only through
control-api.ts (task-queue-mcp's HTTP API); parses no
queue YAML. Owns the file watcher (a change trigger)
and the session launcher.
control-api.ts The single outbound path to task-queue-mcp: queue reads
(queueGet) and mutations (callControlApi). Extracted from
server.ts so the auth and transport guards are
unit-testable without booting the server.
queue-token.ts Loads the plugin's client token from the fixed file
under $HOME, failing closed on mode or content.
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.
Expand Down Expand Up @@ -68,20 +72,22 @@ importable. esbuild and `tsc` (`allowImportingTsExtensions`) both accept `.ts`.

## Invariants

- **The plugin never writes queue YAML directly.** Reads are direct (fast, watchable); every mutation goes through `control-api.ts` to the MCP control API, inheriting its transition validation, `fcntl` locking, and atomic writes. A new mutation means a new control-API action, never an `fs.writeFile`.
- **The plugin never reads or writes queue YAML directly.** Every mutation goes through `control-api.ts` to the MCP control API, inheriting its transition validation, `fcntl` locking, and atomic writes. Since v0.11.0 every read does too (`queueGet` → `GET /tasks`, `GET /tasks/{id}`), inheriting the queue's TTL, dead-letter and status rules; before that this file globbed the YAML and applied none of them. A new mutation means a new control-API action, never an `fs.writeFile`; a new read means an API call, never a `yamlLoad` of a queue file. The `fs.watch` on the queue root is a change trigger and reads nothing.
- **`truncated` is rendered, never dropped.** `GET /tasks` returns at most 1000 records and says when it cut some off. The header and the dead-letters badge show it. If a view ever needs more than one page, report the number and the use rather than paging silently or asking for the cap to be raised.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- **`ControlAction` must match the MCP's route set.** The union type in `control-api.ts`, the route regex in `server.ts`, and the MCP's custom routes are three copies of one contract. Change one, change all three. The two copies that live in *this* repo are now pinned to each other by a source-level test in `control-api.test.ts` — nothing detected the drift before, because adding an action to the union alone compiles and the failure mode is a button that 404s against the plugin's own backend. The third copy is in another repo and still needs a human.
- **The task-queue vocabulary lives in `vocabulary.ts`, once, and is gated against its owner.** This plugin does not own the queue's statuses, task types, or workflow modes — `task-queue-mcp`'s `src/tools/queue.py` does. It used to carry four partial hand-written copies (`STATUS_ORDER`, `NON_TERMINAL_STATUSES`, `DETAIL_NON_TERMINAL_STATUSES`, the `statusColor` switch); none of them learned about `routing-failed`, so for months the status most in need of an operator sorted *below* `cancelled`, rendered the same grey as `parked`, and was not offered by the status filter. `manual-then-auto` was the same omission one field over. Two mechanisms hold the line and they are different in kind: `npm run gate:vocabulary` fetches the MCP's `main` and fails on any difference, which catches "upstream changed and we did not"; and the UI maps are `Record<Status, …>` keyed by the vocabulary itself, so adding a status without giving it a sort position and a colour is a `tsc` error rather than a silent fallthrough to `?? 9` and `muted`. Do not weaken either — a `Record<string, …>` accepts anything and covers nothing, which is precisely how this happened.
- **The vocabulary gate has no skip-on-no-network path, and tracks `main`, not a pin.** It exits non-zero if it cannot read the upstream. A check that quietly passes when it could not reach its source of truth is indistinguishable from one that verified something, and that shape is how the dispatcher-side instance (vikunja#324) stayed open for months. It likewise exits non-zero if it parses zero set literals, rather than reporting a vacuous pass over an empty comparison. A vocabulary change merged upstream turning this repo's CI red is the alarm working. `TASK_QUEUE_MCP_REF` exists only for a paired pre-merge change from a shell; **CI must not set it**.
- **The gate is its own CI step.** A red vocabulary means "go edit `src/vocabulary.ts`" — a different instruction from any unit failure. Folded into `npm test` either could hide the other, and an expected-red check buried inside a longer step masks whether anything before it ran.
- **A queued `manual-then-auto` survives the Start button.** `toWorkflowMode` maps the UI's `review` to `semi-auto` *unless* the task was queued `manual-then-auto`, in which case it passes that through. Both gate this leg; only the second lets the tasks the session spawns run `auto`. Flattening it — which is what this did before #543 — re-pins the whole downstream chain to `semi-auto`, which is the failure vikunja#533 added the mode to fix. An explicit `auto` Start still overrides.
- **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 client token comes from a fixed file under `$HOME`, never the env.** `queue-token.ts` reads `$HOME/.config/cloudcli-plugin-task-queue/token` and fails closed (missing, empty, not a regular file, any group/other bit), and `callControlApi` / `queueGet` then return 500 without fetching. Do not add an `env:` grant for a credential to `manifest.json`, and do not make the path an env var. A manifest grant only works if the CloudCLI host holds the value, and every Claude session CloudCLI launches inherits the host's env. That is exactly how the old `TASK_QUEUE_API_SECRET` reached every session (vikunja#396). A test pins the manifest's `env:` grants.
- **The token goes in `X-Task-Queue-Token`, never `Authorization`.** task-queue-mcp's framework offers any bearer to its agent-token verifier; the control routes read only their own header.
- **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, 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 dead-letters section is read-only, and its count is not conditional on being open.** `GET /dead-letters` reads `dead-letters/*.yml` directly like every other read here; the one mutation (requeue) goes through `control-api.ts` like every other mutation. The count loads on every refresh rather than on expand — a number that appears only after the operator opens the section is a number nobody sees, which is the failure this whole surface exists to end. The healthy value is zero and the heading renders that too.
- **The dead-letters section is read-only, and its count is not conditional on being open.** `GET /dead-letters` reads `GET /tasks?status=failed&include_dead_letters=true` and keeps records whose `queue_location` is `dead-letters` (never by status: a `failed` task in the live queue has no Requeue); the one mutation (requeue) goes through `control-api.ts` like every other mutation. The count loads on every refresh rather than on expand — a number that appears only after the operator opens the section is a number nobody sees, which is the failure this whole surface exists to end. The healthy value is zero and the heading renders that too.
- **Dead letters are grouped by `failed_reason`, never listed flat.** Seventeen records carrying one identical reason are one bug that fired seventeen times; seventeen sibling rows read as seventeen unrelated problems, which is roughly how they were treated for three months (vikunja#557). The grouping is a pure function in `dead-letters.ts` with tests, not a loop in the panel.
- **A dead-letter record with no `id` is skipped, not rendered.** A row that cannot be addressed is a row whose Requeue button could not work, and offering one would be a lie. Likewise an unparseable failure timestamp renders as unknown — `ago()` maps an invalid date to "just now", which on this surface would claim a three-month-old drop happened seconds ago.
- **The queue watcher does not see `dead-letters/`.** `fs.watch` is not recursive on Linux and the watch is on the queue root, so a task arriving in the subdirectory fires no event. That is deliberate: the section is collapsed, a dead letter is not live work, and the dispatcher unlinks the original from the root as it dead-letters — which does fire. Do not add a recursive watch to stream a surface nobody is watching live.
Expand All @@ -108,7 +114,7 @@ npm test
npm run gate:vocabulary # parity with task-queue-mcp main — needs network, by design
```

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, and the union/route-regex drift gate), `dead-letters.ts` (the real dispatcher record shape including the `Date`-valued `created` js-yaml hands back, the id-less and reason-less fallbacks, and the grouping rule against the live seventeen-identical-reasons case), `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), `vocabulary.ts` (every status has a sort position and a colour, `routing-failed` sorts above `in-progress` and is not muted, the derived non-terminal set, and the `manual-then-auto` pass-through through `buildLaunchArgv`), `gates/python-sets.ts` (the real `queue.py` shape including interleaved comments, and the two constructs that must NOT parse as string sets — a derived set and an annotated dict), and the reconnect schedule. The UI panels are not otherwise unit-tested — verify them in CloudCLI after `./deploy.sh && pm2 restart cloudcli`.
Tests cover `queue-token.ts` (real files in a tmpdir: missing, empty, directory, every group/other mode bit refused, `0600` and `0400` accepted, and no error carrying the file's content), `control-api.ts` (the token gate, the `X-Task-Queue-Token` header and the absence of `Authorization` and the retired secret header, the read helper and its query builder, the manifest's `env:` grants, task-id validation, header and body shape per action, transport-failure mapping, pass-through of the MCP's authorization rejections, and the union/route-regex drift gate), `dead-letters.ts` (the real dispatcher record shape including the `Date`-valued `created` js-yaml hands back, the id-less and reason-less fallbacks, and the grouping rule against the live seventeen-identical-reasons case), `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), `vocabulary.ts` (every status has a sort position and a colour, `routing-failed` sorts above `in-progress` and is not muted, the derived non-terminal set, and the `manual-then-auto` pass-through through `buildLaunchArgv`), `gates/python-sets.ts` (the real `queue.py` shape including interleaved comments, and the two constructs that must NOT parse as string sets — a derived set and an annotated dict), and the reconnect schedule. The UI panels are not otherwise 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
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,54 @@

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

## [0.11.0] - 2026-09-29

Own client token, and every read through task-queue-mcp's API. Build
`operator-panel-2026-09-p2-queue-read-api`; vikunja#396. **Requires task-queue-mcp
v0.11.0 or later** and a token file (below).

### Changed

- **The plugin authenticates with its own client token**, read from
`$HOME/.config/cloudcli-plugin-task-queue/token` and sent as `X-Task-Queue-Token`.
`manifest.json` no longer requests `env:TASK_QUEUE_API_SECRET`, and no replacement
grant was added. A manifest grant only works if the CloudCLI host holds the value, and
every Claude session CloudCLI launches inherits the host's environment, so the old
shared secret sat in every agent session. The host now holds neither the token nor its
path. Writes are recorded with `channel: cloudcli`.
- **Fails closed** when the token file is missing, empty, not a regular file, or has any
group or other permission bit. The UI error names the path and the problem; the token
is never logged. A missing file is re-checked on the next request.
- **Every queue read goes through the API** (`GET /tasks`, `GET /tasks/{id}`). That covers
the task list, the task detail, the Start route's task lookup, the dead-letters section
and the headless-run status index. The backend no longer parses queue YAML at all. The
list now follows the queue owner's rules: finished tasks past their `ttl_days` age out,
as they do for every agent's `list_tasks`, where this plugin previously showed every
file in the directory. Task detail now also resolves archived and dead-lettered ids.
- **Truncation is shown.** The API returns at most 1000 records per read. When it cuts
records off, the header reads `truncated: showing N of M`, and the dead-letters badge
says the count may be low.
- **Start launches only live, unfinished work.** Task lookup now also resolves archived and
dead-lettered records, which the old directory scan never saw. `POST /tasks/:id/start`
refuses any task outside the live queue or in a terminal status with a 409 and the reason
(`launchRefusal` in `launch-guards.ts`), so the launch surface is no wider than before.
- **`TASK_QUEUE_API` must be `https://`, or `http://` to a loopback host.** The token goes
on every read and write, so the plugin refuses to send it in cleartext to another host
(each request fails with an error naming the variable). The default passes.
- The `tasks` WebSocket event no longer carries a file `count`, which the UI never read.
The queue watcher is now a change trigger only.

### Removed

- `TASK_QUEUE_API_SECRET` and the `X-Task-Queue-Secret` header.

### Security

- **js-yaml 4.3.1 → 4.3.2** (GHSA-2883-xcg3-v3hh, high: merge keys with empty sources
are not bounded by `maxTotalMergeKeys`). The plugin parses one YAML file, the
operator-owned launch policy, so exposure was low; the production-dependency audit gate
failed on it.

## [0.10.0] - 2026-08-29

Tracker: vikunja#560. Build plan: agent-workflow-interop-2026-08, Phase 5.5 and 5.6.
Expand Down
Loading
Loading