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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ jobs:
- name: Vocabulary parity with task-queue-mcp main
run: npm run gate:vocabulary

# Its own step for the same reason, and separate from the vocabulary gate because
# it reads a DIFFERENT upstream: this one goes red when task-dispatcher's launch
# policy corpus changes, which is a different instruction again ("go make
# launch-policy.ts match"). One step per upstream, so which one moved is legible
# from the job list rather than from a log dive.
#
# Do NOT set TASK_DISPATCHER_REF here — it defaults to `main`, which is what the
# dispatcher is actually running.
- name: Launch policy parity with task-dispatcher main
run: npm run gate:corpus

# Production dependencies only. The dev tree carries advisories that do not
# apply to a plugin bundled at build time and never served by a dev server.
- name: Audit production dependencies
Expand Down
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,68 @@

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

## [0.10.0] - 2026-08-29

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

### Fixed

- **A trailing slash in `project_dir` resolved differently here than in the dispatcher.**
Node's `path.normalize` keeps a trailing separator (`/a/b/` → `/a/b/`) where Python's
`os.path.normpath` strips it (`/a/b/` → `/a/b`). Both sides ACCEPTED the entry, so no
verdict ever disagreed — only the resolved value did, and that value becomes a spawned
session's working directory. Found by the new corpus gate on its first run, not by
review; it is the `.resolve()` divergence in a second costume.

### Added

- **`npm run gate:corpus`** — a second parity gate, in its own CI step. task-dispatcher
owns `tests/fixtures/launch-policy-corpus.json` (27 accept/reject cases) and this
plugin fetches it from that repo's `main`, asserting `validateLaunchPolicy()` agrees on
every case. `launch-policy.ts` carried a comment saying the Python side "must keep
computing this the same way"; this is that comment as a test.

**Resolved values are compared, not just verdicts.** A verdict-only comparison would
have reported the two implementations in agreement throughout both divergences found so
far. Like `gate:vocabulary` it has no skip-on-no-network path, and it refuses an empty
corpus rather than reporting a vacuous pass.

It is a separate step from `gate:vocabulary` because it reads a different upstream —
one red means "edit `src/vocabulary.ts`", the other means "edit `src/launch-policy.ts`".

- **Pre-launch credential guards (`src/launch-guards.ts`).** A Start of a
directly-launched agent is now refused, by name, when `SCOPED_MCP_BEARER_TOKEN` is
unresolved or no usable Anthropic credential is available — the two checks
task-dispatcher has always made. Previously such a Start spawned a session doomed to
401 from every scoped-mcp tool, or to short-circuit to "Not logged in" before reading
the prompt; both present to the operator as an agent that started and did nothing.

The port includes the dispatcher's `load_agent_env`: `/opt/appdata/agents/<agent>/.env`
is layered into the child environment, which this plugin never did. That is the
substance of the fix. Measured on forge: the CloudCLI process env carries
`CLAUDE_CODE_OAUTH_TOKEN` but no `SCOPED_MCP_BEARER_TOKEN`, so a guard checking only
`process.env` would have refused every non-run-as Start — correctly, in that those
sessions really were starting without scoped-mcp tools, but that is the bug rather than
the fix. The environment that is checked is the one the child is spawned with; passing
`process.env` on to `spawn` after checking a different object would make the guard
decoration.

**Both checks are skipped for a `run_as_user` agent, deliberately.** Those credentials
are not in this process's environment by design — the launcher sources them as the
target user and makes the equivalent checks itself. Running them here would fail every
launch for the one agent whose isolation is working correctly.

### Tests

151 → 175. Ten mutants covering every new branch were each confirmed to turn the suite (or
`gate:corpus`) red, including "the run-as short-circuit is removed" and "the run-as path
reads the agent env file it must not touch".

One of them survived the first run: the agent-name traversal test pointed at a path that
did not exist, so `readFileSync` threw and the function returned `{}` whether or not the
name check ran. It asserted the right outcome for the wrong reason. The target file is now
planted, so removing the guard changes the result rather than the route to it.

## [0.9.0] - 2026-08-29

Reads the run records both launchers now write, and makes a Start leave a mark on the task
Expand Down
51 changes: 48 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,44 @@ deployment, a cron dispatcher reads the same file). A second copy of this roster
this release removes: the plugin's private map had drifted and was missing an agent
entirely, so Start refused it.

#### Two validators, one corpus

`~/scripts/agent-launch.yml` is validated independently by this plugin and by the cron
dispatcher, in two languages, with no shared code. They have already disagreed: one
resolved symlinks on the project root and the other did not, so an entry accepted here was
rejected there — and on the reference deployment that did not merely reject an entry, it
made the dispatcher fail to import on every tick.

`npm run gate:corpus` closes that. task-dispatcher owns
`tests/fixtures/launch-policy-corpus.json`, a set of accept/reject cases; this plugin
fetches it from that repo's `main` and asserts its own validator agrees on every one.

It compares **resolved values**, not just verdicts, and that is not belt-and-braces. Its
first run found a second live divergence: Node's `path.normalize` keeps a trailing
separator where Python's `os.path.normpath` strips it, so a `project_dir` written with a
trailing slash was accepted by both sides and resolved to two different strings — one of
which becomes a spawned session's working directory. No verdict ever disagreed.

#### Pre-launch credential guards

A Start of a directly-launched agent is refused, by name, if `SCOPED_MCP_BEARER_TOKEN` is
unresolved or no usable Anthropic credential is available. Without this, such a session
spawns and then fails deep inside — a 401 from every scoped-mcp tool, or a `claude -p`
that short-circuits to "Not logged in" before it reads the prompt. From the operator's
side both look like an agent that started and did nothing.

The plugin also layers `/opt/appdata/agents/<agent>/.env` into the child environment,
which the dispatcher has always done and this plugin did not. That is the substance of the
fix rather than a side effect: on the reference deployment the plugin's own process
carries no `SCOPED_MCP_BEARER_TOKEN`, so directly-launched sessions were genuinely
starting without one.

**Neither guard runs for a `run_as_user` agent, and that asymmetry is deliberate.** Such
an agent's credentials are not in this process's environment by design — they are in a
file only the target user can read, sourced by the launcher as that user, which performs
the equivalent checks itself. Running these checks on that path would fail every launch
for the one agent whose isolation is working correctly.

**`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
Expand Down Expand Up @@ -310,11 +348,18 @@ then, and reporting the launch as failed would be the bigger lie.
npm install
npm run build # tsc --noEmit (typecheck) + esbuild bundle to dist/
npm test # node --test — requires Node 22.18+
npm run gate:vocabulary # asserts the queue vocabulary matches task-queue-mcp's main.
# Reaches the network and fails if it cannot — deliberately;
# a parity check that skips offline has verified nothing.
npm run gate:vocabulary # asserts the queue vocabulary matches task-queue-mcp's main
npm run gate:corpus # asserts the launch-policy validator agrees with
# task-dispatcher's, over a corpus that repo owns
```

Both gates reach the network and fail if they cannot — deliberately; a parity check that
skips offline has verified nothing. They are **separate** npm scripts and separate CI
steps because they read different upstreams: a red from one means "edit
`src/vocabulary.ts`" and a red from the other means "edit `src/launch-policy.ts`". Folding
them together would let either hide the other, and would make "which upstream moved" a log
dive.

`npm run build` is the typecheck gate — `tsc --noEmit` runs first and the bundle only happens if it passes.

The test runner executes the `.ts` files directly using Node's built-in type stripping, so **`npm test` needs Node 22.18+** even though the plugin itself runs on Node 20+ (`dist/` is bundled plain JS).
Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "task-queue",
"displayName": "Task Queue",
"version": "0.9.0",
"description": "Task queue dashboard — view, filter, and launch agent tasks.",
"version": "0.10.0",
"description": "Task queue dashboard \u2014 view, filter, and launch agent tasks.",
"author": "TadMSTR",
"icon": "icon.svg",
"type": "module",
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"name": "cloudcli-plugin-task-queue",
"version": "0.9.0",
"version": "0.10.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --sourcemap && esbuild src/server.ts --bundle --format=esm --platform=node --outfile=dist/server.js --sourcemap --external:ws",
"test": "node --test src/tests/*.test.ts",
"gate:vocabulary": "node src/gates/vocabulary-parity.ts"
"gate:vocabulary": "node src/gates/vocabulary-parity.ts",
"gate:corpus": "node src/gates/launch-policy-corpus.ts"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
Expand Down
Loading
Loading