Problem
Dispatch accepts a single bearer token (DISPATCH_AGENT_TOKEN, src/lib/dispatch-env.ts getAcceptedAgentTokens), and every bearer caller gets full rights. The caller name for bearer auth is the self-reported x-agent-name header (src/lib/auth.ts resolveBearerActor). Courier's courier-dispatch secret and the MCP bridge's toolhive-dispatch secret hold the same token. So an autonomous worker can do everything an operator's agent can:
Proposal: two tiers of bearer token
- worker: autonomous executors (Courier). An explicit allowlist, failing closed:
agents/[agentName]/next-task, tasks/report, heartbeat, active-work, queue, work-summary
agent-work/*
issues/claim without force
issues/unclaim for the caller's own claim only
issues/state, issues/status
- issue and PR-fix reads (
issues GET, pr-fix-queue/queued, pr-fix-queue/history)
pr-fix-queue/mark with FIXED, BLOCKED or STALE (generation required, as today); QUEUED and IGNORED are refused
- maintainer: operator agents (Claude Code via the MCP bridge, openclaw agents) and the web UI (OIDC/basic). Everything, including
pr-fix-queue/requeue, mark to QUEUED or IGNORED, force claims, groomer, lanes, admission overrides, automation/* and syncs.
A worker token calling a maintainer route gets a 403 that names the required tier, with an audit row.
Rollout without breakage
DISPATCH_AGENT_TOKEN keeps maintainer rights, so deploying changes nothing for anyone.
- Add
DISPATCH_WORKER_TOKEN, accepted with worker rights.
- Then in home-ops, point Courier's
courier-dispatch secret at the worker token. The MCP bridge and openclaw keep the maintainer token.
- Later, optionally, rename
DISPATCH_AGENT_TOKEN to DISPATCH_MAINTAINER_TOKEN, keeping the old name as an alias.
Implementation notes
getAcceptedAgentTokens becomes a token→tier lookup, and authorizeRequest returns tier alongside type/actor (OIDC/basic/disabled resolve to maintainer).
- Enforce the tier centrally, from one route→tier table, so a new route defaults to maintainer and the worker list stays explicit and reviewable.
- Split
pr-fix-queue/mark and issues/claim by body (status, force) inside the route, since the same path serves both tiers.
- Tests: a table-driven test that every worker-allowlisted route accepts a worker token, and that a sample of maintainer routes returns 403 for it.
Files
src/lib/dispatch-env.ts, src/lib/auth.ts
src/app/api/pr-fix-queue/mark/route.ts, src/app/api/pr-fix-queue/requeue/route.ts, src/app/api/issues/claim/route.ts, src/app/api/issues/unclaim/route.ts
.env.example, docs/ (auth section)
Problem
Dispatch accepts a single bearer token (
DISPATCH_AGENT_TOKEN,src/lib/dispatch-env.tsgetAcceptedAgentTokens), and every bearer caller gets full rights. The caller name for bearer auth is the self-reportedx-agent-nameheader (src/lib/auth.tsresolveBearerActor). Courier'scourier-dispatchsecret and the MCP bridge'stoolhive-dispatchsecret hold the same token. So an autonomous worker can do everything an operator's agent can:Proposal: two tiers of bearer token
agents/[agentName]/next-task,tasks/report,heartbeat,active-work,queue,work-summaryagent-work/*issues/claimwithoutforceissues/unclaimfor the caller's own claim onlyissues/state,issues/statusissuesGET,pr-fix-queue/queued,pr-fix-queue/history)pr-fix-queue/markwith FIXED, BLOCKED or STALE (generation required, as today); QUEUED and IGNORED are refusedpr-fix-queue/requeue, mark to QUEUED or IGNORED,forceclaims, groomer, lanes, admission overrides,automation/*and syncs.A worker token calling a maintainer route gets a 403 that names the required tier, with an audit row.
Rollout without breakage
DISPATCH_AGENT_TOKENkeeps maintainer rights, so deploying changes nothing for anyone.DISPATCH_WORKER_TOKEN, accepted with worker rights.courier-dispatchsecret at the worker token. The MCP bridge and openclaw keep the maintainer token.DISPATCH_AGENT_TOKENtoDISPATCH_MAINTAINER_TOKEN, keeping the old name as an alias.Implementation notes
getAcceptedAgentTokensbecomes a token→tier lookup, andauthorizeRequestreturnstieralongsidetype/actor(OIDC/basic/disabled resolve to maintainer).pr-fix-queue/markandissues/claimby body (status,force) inside the route, since the same path serves both tiers.Files
src/lib/dispatch-env.ts,src/lib/auth.tssrc/app/api/pr-fix-queue/mark/route.ts,src/app/api/pr-fix-queue/requeue/route.ts,src/app/api/issues/claim/route.ts,src/app/api/issues/unclaim/route.ts.env.example,docs/(auth section)