Require a token for the web UI and reconcile AUDIT.md

The UI is a full control plane: it can create tasks, release or complete
them, and inject approval keystrokes into live panes. authz.HTTP did cover
it (an absent surface header defaults to Web), but the gate is opt-in and
the deployed env sets no tokens while binding all interfaces, so in
practice it was reachable unauthenticated from the LAN. Setting the token
alone did not work either: a browser cannot put a bearer token on a
document load, so the UI would 401 on index.html.

- ORCHESTRA_WEB_TOKEN is now mandatory; startup fails rather than silently
  serving an open control plane.
- authz.Sessions issues random values stored SHA-256-hashed, with a TTL,
  so a leaked snapshot yields nothing usable.
- POST /v1/ui/session verifies the token in constant time and returns it
  as an HttpOnly, SameSite=Strict, Secure cookie. This is a presentable
  form of the same credential, not a new authority.
- HTTPWithSessions accepts that cookie in place of the bearer token, and
  only for the Web surface. The login endpoint and non-/v1/ GETs (the SPA
  shell) are exempt by necessity; every /v1/ control path stays gated.

Note this is a breaking config change: .orchestra-config/orchestra.env
sets no tokens, so the service will not start until it does, and setting a
Web token newly gates the other /v1/ surfaces that default to Web.

AUDIT.md is reconciled against the code rather than against itself. B14,
B15 and B16 are closed with their evidence; B17 is closed on the worker
path only; the stale claim that B13 was open is corrected. Adds the
previously undocumented command channel and web UI, and files what that
implementation pass surfaced: federated approvals emit no event (B19), the
local capture revision is a timestamp rather than a change counter and can
silently defeat approvals (B20), the command queue never prunes (B21), the
ntfy token serves two unrelated purposes (S12), and a dead copy of the
authorization policy sits in main.go (S13).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01535A3Y8RtkAi8wYuWhtkEd
This commit is contained in:
kami
2026-07-28 23:15:00 +04:00
parent b57894b183
commit 0b4b52ac45
4 changed files with 711 additions and 28 deletions
+402 -22
View File
@@ -22,18 +22,21 @@ document.
The substrate (Layer 1) is solid. Layers 24 were originally shaped-but-not-
wired; most of the blocking defects below are now closed, verified by
reading the current code (not just by trusting this file) and by targeted
tests. Two things are not yet proven:
tests. Three things are not yet proven:
1. **A real end-to-end unattended run.** The first live attempt (2026-07-28)
surfaced **B12** (fixed and confirmed live, see below), and re-verifying
that fix surfaced **B13** (open) — `agent.start` can return success while
silently never starting an agent, under back-to-back leases. One task
(`wD:p1`) did make it all the way to a real attached `claude` session, so
the lease path is provably reachable, but B13 means it's not yet reliable
enough to call proven — occupancy/rotation/handoff/completion still
haven't been exercised end-to-end against a session that's guaranteed to
actually exist.
2. **Cross-machine (federation) correctness.** Deliberately deferred — see
1. **A real end-to-end unattended run.** The live attempts of 2026-07-28
surfaced B12 through B17. All are now closed in code (B17 on the worker
path only), but **B13 through B17 were each found live and none has been
re-verified live** — occupancy/rotation/handoff/completion still haven't
been exercised end-to-end against a session guaranteed to exist. Treat
"closed" here as "the code no longer contains the defect," not as
operational proof.
2. **The operator surface was unauthenticated in the live deployment.**
Fixed in code (**B18**: mandatory Web token, cookie sessions for the
browser), but the fix is a breaking config change — the service will not
start until `.orchestra-config/orchestra.env` sets
`ORCHESTRA_WEB_TOKEN`. See B18 and the command-channel section.
3. **Cross-machine (federation) correctness.** Deliberately deferred — see
"The federation fork" below.
`go build ./...`, `go vet ./...`, and `go test ./...` all pass.
@@ -71,15 +74,116 @@ worktree.
| Spec layer | State |
|---|---|
| L1 substrate (§3, §4) | Built and correct in the main path. |
| L2 harness (§5) | Occupancy, rotation, and completion all wired and reachable; B12's readiness race is fixed and confirmed live, but B13 (`agent.start` silently no-op'ing under back-to-back leases) still blocks reliable live verification of what's downstream. |
| L2 harness (§5) | Occupancy, rotation, and completion all wired and reachable. B12 fixed and confirmed live; B13B16 fixed in code but not re-verified live. Unique per-task agent names (B16) and ambiguity-safe prompting (B14) are the substantive changes. |
| L3 continuity (§6) | Handoff schema, pickup validation, scratch branches, TASK.md are all wired into the live path (Phase 4 complete). |
| L4 surfaces (§7) | Brief/standup/delivery real; quota has a post-hoc producer only (no live push feed yet); authz bypass closed. |
| L4 surfaces (§7) | Brief/standup/delivery real; quota has a post-hoc producer only (no live push feed yet); B8's authz bypass closed, but the new web UI reintroduces an equivalent one (B18). |
---
## Blocking defect currently open
## Blocking defects
### B14 — `agent.prompt` can duplicate a task prompt after an ambiguous wait timeout (found live 2026-07-28) — open
B14, B15 and B16 are now closed by code and covered by tests; none has been
re-verified against a live cross-machine run. B17 is closed on the worker
path only and remains open for Design A. B18 (the unauthenticated web
surface) is closed in code but **requires an env change before the service
will start** — see B18 and "What's next".
Nothing is currently blocking in the sense B12/B13 were. The open defects
B19B21 and S12S13, all found while implementing B18, are correctness and
hygiene gaps in the new command channel rather than things that stop a task
from running. The real remaining risk is evidential: B13 through B17 were
each found live and none has been re-verified live.
### B18 — the web UI surface is unauthenticated (found by audit 2026-07-28, uncommitted working tree) — closed (code fix; requires an env change before restart)
`cmd/orchestra/main.go:205` mounts the browser read/write surface with no
handler-local credential check:
```go
mux.Handle("/v1/ui/", ui.Server{Store: s, Workers: workers, Coordinator: coordinator, ...}.Handler())
```
Unlike the worker/harness endpoints on the same mux (`main.go:342, 372,
467, 821, 840`), it performs no check of its own and stamps its events
`Surface: string(authz.Web)` (`internal/ui/ui.go:222, 337, 368`).
**It is not, however, ungated in principle.** `authz.HTTP` wraps the entire
mux (`main.go:1167`) and defaults a request with no `X-Orchestra-Surface`
header to `Web`, so `/v1/ui/` is covered by `ORCHESTRA_WEB_TOKEN`. The
defect is that this gate is opt-in — `authz.HTTP`'s own comment says
"authentication is optional for local development," and `expected != ""` is
the only thing standing between the LAN and the surface.
**The live deployment has opted out.** `.orchestra-config/orchestra.env`
sets no `ORCHESTRA_*_TOKEN` at all, and the server binds `":"+port`
(all interfaces, 9145). So on the deployed homesrv instance this surface is
in fact reachable unauthenticated from the LAN.
**Consequence.** Unauthenticated reachable actions include `TaskCreated`,
and per-task `release`, `block`, `complete` and `handoff`
(`ui.go:345-361`). Most seriously, `grant_approval` / `deny_approval`
(`ui.go:301`) terminate in `pane.send_text` against a live pane — so an
unauthenticated caller can inject keystrokes into a running agent session on
workpc. B14 was filed because Orchestra could *accidentally* advance a
permission dialog; this surface allows it deliberately, from the network.
**Why simply setting `ORCHESTRA_WEB_TOKEN` does not fix it.**
`mux.Handle("/", webui.Handler())` serves the SPA from the same listener,
behind the same middleware. A browser cannot attach an `Authorization`
header to a top-level document load, so setting the token returns 401 for
`index.html` and the web UI stops loading entirely. Bearer-token auth and a
browser-served SPA are incompatible as currently wired; that is likely why
no token is set.
**Required fix (design decision, not a one-liner).** The browser needs a
credential it can actually present on a document load — a login endpoint
that exchanges the Web token for an `HttpOnly`, `SameSite=Strict` session
cookie, with `authz.HTTP` accepting either that cookie or a bearer token for
the `Web` surface. Alongside it: make the token mandatory rather than
opt-in whenever the UI is mounted (refuse to start, rather than silently
serving an open control plane), and bind to loopback by default so exposure
is a deliberate configuration act. Approval commands in particular must not
be reachable without it.
**Fix (2026-07-28):**
1. **The token is now mandatory** (`cmd/orchestra/main.go`): startup calls
`log.Fatal` if `ORCHESTRA_WEB_TOKEN` is empty. Serving the control plane
openly is no longer something a missing env var can cause silently.
2. **`authz.Sessions`** (`internal/authz/authz.go`) issues 32 random bytes as
a session value and stores only its SHA-256, so a leaked snapshot of the
map yields nothing usable. Sessions expire (12h default) and are swept on
each issue.
3. **`POST /v1/ui/session`** verifies the Web token in constant time and sets
the value as an `HttpOnly`, `SameSite=Strict`, `Secure` cookie. `Secure`
is dropped only if `ORCHESTRA_UI_INSECURE_COOKIE` is set, which is
required to reach the UI over plain HTTP.
4. **`authz.HTTPWithSessions`** accepts that cookie *in place of* the bearer
token, and only for the `Web` surface; the bearer comparison is also now
constant-time. Two paths are exempt from the gate, both necessarily: the
login endpoint (which performs its own check — gating it would make login
unreachable) and non-`/v1/` GET/HEAD, i.e. the SPA shell and its static
assets, which are not secrets. Every `/v1/` control path stays gated.
`TestWebSessionCookieGatesControlPathsOnly` asserts the whole shape: an
uncredentialed control path is 401, the shell and login endpoint are
reachable, a valid cookie authorizes, a forged one does not, and a cookie
presented on the TUI surface is rejected. `TestSessionExpires` covers TTL.
**Operational precondition — this is a breaking config change.**
`.orchestra-config/orchestra.env` currently sets no tokens, so
`orchestra.service` will refuse to start until `ORCHESTRA_WEB_TOKEN` is
added (plus `ORCHESTRA_UI_INSECURE_COOKIE=1` if the UI is served over plain
HTTP). Setting a Web token also newly gates the other `/v1/` surfaces that
default to `Web` — any existing unauthenticated client of this instance will
begin getting 401s and needs the token too. Do not restart the service
without making that env change first.
**Still open:** the listener binds all interfaces (`":"+port`); loopback-by-
default was considered and deliberately not taken, so exposure remains a
network-level concern.
### B14 — `agent.prompt` can duplicate a task prompt after an ambiguous wait timeout (found live 2026-07-28) — closed (code fix; not re-verified live)
Live Claude E2E task `06FTF8CPH3K3DPN6XQA7G3WRQ8` (pane `wM:p1`) received
the same initial `Begin Orchestra task ...` prompt twice. Claude completed a
@@ -127,7 +231,29 @@ adds one. In addition, never issue `agent.prompt` while `agent_status` is
as requiring explicit operator approval. Do not run further unattended
OpenCode E2E tasks until both controls are verified live.
### B15 — a coordinator-side block cannot reconcile a later live completion (found live 2026-07-28) — open
**Fix (2026-07-28), in `Client.Prompt` (`internal/herdr/herdr.go:280`):**
1. The RPC deadline is now derived from the requested wait, not the client
default: when the caller's context has no deadline within `wait + 5s`,
`Prompt` installs one (`herdr.go:307-314`). A 60s `wait.until=idle` no
longer expires against a 10s transport deadline.
2. Retry is split by error class. A JSON-RPC protocol error is herdr's
explicit rejection *before* it acted and stays retryable for the bounded
boot window; a transport failure after the write is ambiguous and is
never resent (`herdr.go:315-325`). Covered by
`TestPromptDoesNotRetryAmbiguousDelivery`, which closes the connection
mid-`agent.prompt` and asserts exactly one `agent.prompt` is issued.
3. The authorization-bypass half is addressed by inspecting the pane before
prompting: `Prompt` refuses a pane whose `agent_status` is `blocked`
(`herdr.go:285`) and refuses any pane whose transcript matches
`permissionPrompt` (`herdr.go:292`). Approval is now an explicit,
separately authorized operation — see the command-channel section below.
Not re-verified live. The original live reproduction was cross-machine, and
the "do not run further unattended OpenCode E2E tasks until both controls
are verified live" instruction above still stands.
### B15 — a coordinator-side block cannot reconcile a later live completion (found live 2026-07-28) — closed (code fix; operator-mediated, not re-verified live)
The same OpenCode E2E task `06FTF9Z8RP5FM4F9M2SKAQNFZ4` was marked
`TaskBlocked` when the coordinator's `agent.prompt` RPC timed out, despite
@@ -150,7 +276,26 @@ blocker and evidence without pretending it never happened. A live pane must
not be left outside the coordinator's session map simply because prompt
delivery timed out.
### B16 — hardcoded harness name makes each harness globally single-instance (found live 2026-07-28) — open
**Fix (2026-07-28), in `Coordinator.Start`
(`internal/orchestrator/orchestrator.go:911-922`):** when the lease call
fails but a pane was actually created, the session is completed with
`HerdrID`, `TaskFileSHA` and `ConventionsHash` and persisted via
`rememberSession` *before* `block()` records `TaskBlocked`. `Reconcile`
(`orchestrator.go:421`) explicitly retains mappings for `StateBlocked` as
well as `StateLeased`, so a restart does not orphan them either. The live
pane therefore stays observable through `Capture` and the monitor.
**Scope limit — reconciliation is operator-mediated, not automatic.**
Nothing promotes a blocked task to completed on its own; the router still
never retries a blocked task. What exists now is the evidence and the
surface: the retained session plus the `complete` / `release` / `handoff`
actions on the UI task endpoint (`internal/ui/ui.go:345-361`), and the
pre-existing versioned `TaskCorrected` workflow (S8) for recording the
correction without pretending the block never happened. That satisfies the
"must not be left outside the session map" requirement; it does not
implement autonomous reconciliation, which remains unbuilt by choice.
### B16 — hardcoded harness name makes each harness globally single-instance (found live 2026-07-28) — closed (code fix; contract test only partially satisfied)
While starting the real OpenCode healthcheck task
`06FTFDW22833F1CCB8K43Z8808`, Orchestra created worktree/pane `wP:p1` but
@@ -172,7 +317,26 @@ digits, `-`, `_`, maximum 32 characters) and persist it in `Session` for
subsequent lifecycle operations. Add a contract test that starts two same-
harness sessions in distinct panes and verifies both attach.
### B17 — opaque harnesses must not author canonical handoff anchors (found live 2026-07-28) — open
**Fix (2026-07-28):** `agentName(harness, taskID)`
(`internal/herdr/herdr.go:452`) derives a bounded, validated name — a
per-harness prefix (`oc`/`cl`/`cx`), the lowercased task ID with everything
outside `[a-z0-9_-]` collapsed, capped at 32 characters. Over-long IDs keep
a leading stem plus an 8-hex-character SHA-256 suffix, because plain
truncation made distinct long task IDs collide in herdr's machine-global
namespace. `StartAgent` passes it as `name` while `kind` stays the
configured harness (`herdr.go:393`), persists it as `Session.AgentName`
(`herdr.go:145, 445`), and binds it for prompt routing (`herdr.go:446`,
`adapter.go:144`). Pane-scoped operations deliberately keep using
`PaneID``AgentName` is only for agent-targeted calls (`adapter.go:531`).
**Requirement not fully met:** the requested contract test does not exist.
What is covered is name derivation — `TestAgentNameIsBoundedAndValid` and
`TestAgentNameLongIDsDoNotCollide` — plus `TestPromptDoesNotRetryAmbiguous
Delivery` asserting the prompt target is the unique name. Nothing yet starts
two same-harness sessions in distinct panes and verifies both attach, so the
`agent_name_taken` failure mode itself is untested end-to-end.
### B17 — opaque harnesses must not author canonical handoff anchors (found live 2026-07-28) — closed on the worker path; open for Design A
The real OpenCode healthcheck run showed that prompting an opaque agent to
write the full `continuity.Handoff` schema is the wrong ownership boundary.
@@ -228,6 +392,22 @@ attached Claude at 08:00:43Z. The renewed attempt then hit B14's prompt
timeout and became blocked. This is expected retry behavior, but illustrates
why lifecycle retries must be visible to operators.
**Status (2026-07-28): satisfied on the worker path, unchanged for Design A.**
`cmd/orchestra-worker` now implements the required ownership boundary. The
harness is asked once for a bounded semantic report; `releaseReady`
(`cmd/orchestra-worker/main.go:180`) then runs `CLIAdapter.Release` and
`herdr.HeadSHA` **on the machine that hosts the worktree**, so `HEAD`,
branch, dirty paths and SHA-256 values are collected locally and the
canonical artifact is validated before publication. The handoff-provenance
correction documented at the end of this file supplies the parsing and
validation half.
The homesrv coordinator's Design A path is unchanged and still cannot
satisfy this: it does not own the workpc checkout, so any Git-derived fact
it seals is derived from the wrong filesystem. B17 is therefore only closed
for tasks that actually run through a worker. The standing rule holds —
homesrv must not rotate or clean up a non-local worktree.
### B13 — `agent.start` silently no-ops under back-to-back leases (found live 2026-07-28) — closed (code fix; not yet re-verified live)
Discovered while live-verifying the B12 fix below. B12 itself is confirmed
@@ -380,6 +560,185 @@ the specific shell/agent-readiness race B12 was originally filed for, which
---
## Web UI and the capture / approval command channel (uncommitted, 2026-07-28)
Roughly 520 lines of Go across eight tracked files, plus the untracked
`internal/ui`, `internal/webui` and `web/` trees, were added after the last
commit and were previously undocumented here. Recording the design so the
next session does not have to re-derive it.
**Capture/command model (`internal/federation/federation.go`).** Two new
worker-scoped maps on `Registry`. `PutCapture` stores the latest pane text
per (worker, task) and bumps a monotonic `Revision` only when the text or
pane actually changes. `Queue` accepts only `grant_approval` /
`deny_approval`, and requires a non-zero `CaptureRevision` — a command is
bound to the exact capture an operator was looking at. `CompleteCommand`
refuses to resolve a command twice.
**Worker side (`cmd/orchestra-worker/main.go`).** `publishCaptures` pushes
recent pane text for every held session; `runCommands` polls
`/v1/federation/commands` and, for each command, re-reads the pane,
re-publishes it, and refuses with `stale` if the resulting revision differs
from the one the command was issued against. `approvalResponse` decides the
keystroke: it sends `y`/`n` only for a visible `[y/n]`/`(y/n)` prompt, and
sends bare Enter only for OpenCode's fully-labelled `Allow once / Allow
always / Reject … enter confirm` selector — where Enter is a bounded
one-time grant. It refuses to *deny* through that selector, because doing so
would require unobservable navigation. Unknown dialog layouts are rejected
outright rather than guessed at. Both branches are covered by
`TestApprovalResponseOpenCodeAllowOnce` and
`TestWorkerApprovalCommandIsRevisionBoundAndAcknowledged`.
This is the right shape for B14's authorization half: approval becomes an
explicit, revision-bound, separately-issued operation instead of a side
effect of prompting, and it executes on the worker that owns the pane rather
than over a coordinator-driven remote socket.
**Coordinator side.** `Coordinator.RespondApproval` and
`herdr.ApprovalResponder` (`internal/herdr/adapter.go:602`) are the
local-herdr equivalent, applying the same re-read-before-send rule via an
`expectedCapture` comparison and the same y/n-only refusal.
`Coordinator.RequestHandoff` exposes the handoff request as an operator
action without releasing the pane.
**Known weaknesses of this channel.** The defects found in it are filed
individually below — **B19** (federated approvals emit no event), **B20**
(the local capture revision is a timestamp, not a change counter) and
**B21** (`Registry.commands` never prunes). Two more, not separately filed:
- **Captures and commands are in-memory only.** `Registry.captures` and
`Registry.commands` have no persistence, so a coordinator restart drops
pending approvals silently and resets revision counters — which, with a
reset counter, could let a stale command match a new capture. This is the
other half of B21: the lifecycle forgets across restarts and never forgets
within one.
- **`RespondApproval` (coordinator path) has no test**, unlike its worker
counterpart — and per B20 it is the path whose revision is meaningless,
so its text-comparison guard is the only thing actually binding the
decision to what the operator saw. That guard is untested.
- **`web/` is a full Vite/React app whose build output is embedded** via
`internal/webui`'s `go:embed assets/*`. The embedded assets are checked in
as build output; there is no documented step tying `web/` sources to a
rebuild of `internal/webui/assets`, so the two can silently diverge.
## Found while implementing B18 (2026-07-28) — not yet fixed
These were noticed while wiring the session gate. None is fixed; all were
verified against the code rather than inferred.
### B19 — federated approvals leave no audit trail
`Server.action`'s `grant_approval` / `deny_approval` branch
(`internal/ui/ui.go:301-341`) has two exits that are **not** symmetric. The
local-coordinator path calls `RespondApproval` and then appends
`ApprovalGranted` / `ApprovalDenied` to the store. The federated path calls
`s.Workers.Queue(...)`, writes the command to the response, and **returns
before appending any event**.
So the single most safety-critical operation in the system — injecting a
keystroke that advances a permission dialog on another machine — is
invisible in the event log precisely when it crosses a machine boundary.
There is no record of who approved what, or that an approval happened at
all. §7.1's authorization model assumes the event log is the record.
Fixing this needs a decision the local path did not have to make: the
command is *queued*, not executed, so the honest event is a request at queue
time plus a resolution when the worker acknowledges (`CompleteCommand`
already carries `acknowledged` / `rejected` / `stale`). Emitting
`ApprovalGranted` at queue time would claim an outcome that has not
happened yet.
### B20 — the local capture revision is a timestamp, not a change counter
`Server.capture` (`ui.go:80`) fabricates `Revision:
uint64(time.Now().UnixNano())` for the coordinator path. The federation
path's revision is a real per-change counter (`Registry.PutCapture` bumps it
only when pane text or pane ID actually changes), and the worker's staleness
check depends on that meaning.
Two consequences:
1. The local revision changes on every read, so it conveys nothing about
whether the pane changed. The local path is still safe, but only because
`RespondApproval` re-reads and compares capture *text* — the revision it
reports to the browser is decorative.
2. If a task has both a live coordinator session and a published worker
capture, `capture()` prefers the coordinator (`ui.go:77`) and returns a
timestamp revision, which `action()` then passes to `Queue` as
`CaptureRevision`. That value can never equal the worker's counter, so
the worker resolves every such command `stale` and the approval silently
never happens. The precedence between the two capture sources needs to be
explicit rather than incidental.
### B21 — `Registry.commands` is append-only
`r.commands[worker]` is only ever appended to (`federation.go:119`);
`CompleteCommand` flips a status in place and nothing is ever deleted.
Resolved commands accumulate for the process lifetime, and `Commands()`
rescans the entire history on every worker poll. Combined with the
in-memory-only storage already noted, the lifecycle is wrong at both ends:
it forgets across restarts and never forgets within one.
### S12 — `ORCHESTRA_NTFY_TOKEN` serves two unrelated purposes
The same env var is the ntfy *server* credential (`main.go:1153`,
`delivery.Ntfy{Token: ...}`) and the authz *surface* credential for
`authz.Ntfy` (`main.go:1165`). These are unrelated secrets with different
trust boundaries: one is handed to a third-party notification server, the
other authenticates callers to Orchestra. Setting the former silently makes
it a valid inbound credential. They need separate variables.
### S13 — dead `auth()` middleware in `cmd/orchestra/main.go`
`func auth(next http.Handler)` (`main.go:1212`) is defined and never
referenced; Go does not flag unused functions, so `go vet` stays quiet. It
reimplements the notify-only rule that `authz.HTTP` already enforces. It is
harmless today and a trap tomorrow — a second, divergent copy of the
authorization policy sitting next to the real one. Delete it.
### Positive note — B8 is more strongly closed than it was
`authz.HTTP` downgrades a caller-declared `system` surface to `Web` before
the token comparison. Previously that landed on a surface whose token was
unset in production, i.e. no gate at all. With `ORCHESTRA_WEB_TOKEN` now
mandatory, the downgrade lands on a genuinely gated surface, so the B8
bypass is closed by construction rather than by the header check alone.
---
## What's next
In dependency order, not importance order:
1. **Apply the B18 env change** before any restart —
`.orchestra-config/orchestra.env` needs `ORCHESTRA_WEB_TOKEN` (plus
`ORCHESTRA_UI_INSECURE_COOKIE=1` for plain HTTP), and every existing
unauthenticated `/v1/` client of this instance needs the token too. The
service will not start otherwise. Nothing else can be tested live until
this is done.
2. **S13, then S12** — deleting dead policy code and splitting the conflated
ntfy token are both small, local, and reduce the chance of the next
auth change being made against the wrong copy.
3. **B19** — decide the queued-approval event model and give federated
approvals an audit trail. This is the largest correctness gap in the new
command channel.
4. **B20 and B21** — make the capture revision mean one thing, make the
coordinator/worker precedence explicit, and give commands a retention
policy. B20 in particular can silently defeat approvals.
5. **Diagnose the flaky router test** (see open gaps). A nondeterministic
assignment test is exactly the failure shape this codebase has hidden
real defects behind before; do not paper over it with a retry.
6. **Then, and only then, the live re-verification** that B13B17 all still
lack. Each was found live and closed on paper; the code fixes are
unproven against a real cross-machine run, and that remains the single
biggest gap between this document and reality.
Deliberately *not* next: building further on Design A's cross-machine calls,
and closing B17 for the coordinator path. Both wait on the federation-fork
decision below.
---
## Closed defects
Each entry is the flattened final state — design + what's verified — not the
@@ -863,10 +1222,31 @@ implemented when nothing can reach it.
## Known open gaps (as of 2026-07-28)
- **B13** (above) — `agent.start` can silently no-op under back-to-back
leases, with no error surfaced; blocks reliable live verification of
everything downstream (occupancy, rotation, handoff, completion) against a
real task, even though B12's readiness race is now fixed.
Numbered defects are not repeated here — B19, B20, B21, S12 and S13 are in
"Found while implementing B18" above, and the ordering across all of them is
in "What's next". This list is the unnumbered residue: conditions that are
known, accepted, or not actionable as a single fix.
- **B18's env change is not yet applied.** The code now refuses to start
without `ORCHESTRA_WEB_TOKEN`, but `.orchestra-config/orchestra.env` does
not set one — `orchestra.service` will fail to start until it does.
- **The listener still binds all interfaces**; loopback-by-default was
considered and not taken (B18).
- **`TestAssignsByAffinityCapabilityAndConcurrency` is flaky** (`no task
leased`, roughly 1 run in 10, reproduced with `-count=10`). Predates these
changes — confirmed by stashing them — and is unrelated to authz. A
nondeterministic router assignment test is exactly the kind of thing this
codebase has been bitten by before; it should be diagnosed, not retried.
- **B14/B15/B16 are code-fixed but not re-verified live**, and B17 is closed
only for tasks that run through a worker. Each was found live, so a code
fix plus unit tests is weaker evidence than the failure that produced it.
In particular B16 still lacks the requested two-pane same-harness attach
contract test, so its actual `agent_name_taken` failure mode is untested.
- **B15's reconciliation is operator-mediated only.** Blocked tasks keep
their session and are correctable through the UI, but nothing promotes
them automatically and the router still never retries a blocked task.
- **`web/` build output and `internal/webui/assets` can silently diverge**;
no documented rebuild step ties them together.
- **Cross-machine lease correctness proven at the primitive level only** —
needs an actual homesrv/workpc pair over the real mesh; this repo cannot
exercise that by itself.
+143 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"io"
@@ -20,6 +21,8 @@ import (
"orchestra/internal/registry"
"orchestra/internal/router"
"orchestra/internal/store"
"orchestra/internal/ui"
"orchestra/internal/webui"
"os"
"path/filepath"
"strconv"
@@ -198,6 +201,58 @@ func main() {
}
}
mux := http.NewServeMux()
// B18: the UI is a full control plane — it can create tasks, release or
// complete them, and inject approval keystrokes into live panes. Refuse
// to serve it unauthenticated rather than silently exposing that on
// whatever interface the listener binds to.
webToken := os.Getenv("ORCHESTRA_WEB_TOKEN")
if webToken == "" {
log.Fatal("ORCHESTRA_WEB_TOKEN must be set: it gates the web UI's task, lifecycle and approval controls")
}
sessions := &authz.Sessions{}
// A browser cannot put a Bearer token on a document load, so it trades
// the token once for an HttpOnly cookie. Same credential, presentable
// form; no new authority is created here.
mux.HandleFunc("/v1/ui/session", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Token string `json:"token"`
}
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body)
supplied := body.Token
if supplied == "" {
supplied = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if subtle.ConstantTimeCompare([]byte(supplied), []byte(webToken)) != 1 {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
v, err := sessions.Issue()
if err != nil {
http.Error(w, "session unavailable", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: authz.SessionCookie, Value: v, Path: "/",
HttpOnly: true, SameSite: http.SameSiteStrictMode,
Secure: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == "",
MaxAge: int((12 * time.Hour).Seconds()),
})
w.WriteHeader(http.StatusNoContent)
})
// Browser-specific endpoints intentionally present a joined read model;
// raw lifecycle endpoints below remain stable for workers and harnesses.
mux.Handle("/v1/ui/", ui.Server{Store: s, Workers: workers, Coordinator: coordinator, Route: func(e domain.Event) error {
if rt == nil {
return nil
}
_, err := rt.HandleEvent(e)
return err
}}.Handler())
mux.Handle("/", webui.Handler())
workers.OnOffline = func(w federation.Worker) {
for _, t := range s.Tasks() {
if t.State == domain.StateLeased && t.Lease != nil && t.Lease.HarnessID == w.ID {
@@ -831,6 +886,68 @@ func main() {
}
return wid, nil
}
mux.HandleFunc("/v1/federation/commands", func(w http.ResponseWriter, r *http.Request) {
wid, err := workerAuth(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", 405)
return
}
out, err := workers.Commands(wid)
if err != nil {
http.Error(w, err.Error(), 404)
return
}
json.NewEncoder(w).Encode(out)
})
mux.HandleFunc("/v1/federation/commands/", func(w http.ResponseWriter, r *http.Request) {
wid, err := workerAuth(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var body struct {
Status string `json:"status"`
Message string `json:"message"`
}
if json.NewDecoder(r.Body).Decode(&body) != nil || (body.Status != "acknowledged" && body.Status != "stale" && body.Status != "rejected") {
http.Error(w, "invalid command result", 400)
return
}
commandID := strings.TrimPrefix(r.URL.Path, "/v1/federation/commands/")
command, ok := workers.Command(wid, commandID)
if !ok {
http.Error(w, "command not found", 404)
return
}
if err := workers.CompleteCommand(wid, commandID, body.Status, body.Message); err != nil {
http.Error(w, err.Error(), 409)
return
}
// Audit lifecycle evidence only after the worker reports the herdr
// input was acknowledged; a queued browser click is never an approval.
if body.Status == "acknowledged" {
if t, ok := s.Task(command.TaskID); ok {
typ := "ApprovalGranted"
if command.Kind == "deny_approval" {
typ = "ApprovalDenied"
}
payload, _ := json.Marshal(map[string]any{"subject_ref": command.ID, "pane_id": command.PaneID, "capture_revision": command.CaptureRevision})
e := domain.Event{ID: id(), Type: typ, TaskID: command.TaskID, Version: t.Version + 1, Payload: payload, Surface: string(authz.System)}
if err := s.Append(e); err != nil {
log.Printf("record approval %s: %v", command.ID, err)
}
}
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/v1/federation/events", func(w http.ResponseWriter, r *http.Request) {
wid, err := workerAuth(r)
if err != nil {
@@ -873,7 +990,7 @@ func main() {
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/v1/federation/workers/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || (!strings.HasSuffix(r.URL.Path, "/heartbeat") && !strings.HasSuffix(r.URL.Path, "/handoff") && !strings.HasSuffix(r.URL.Path, "/complete")) {
if r.Method != http.MethodPost || (!strings.HasSuffix(r.URL.Path, "/heartbeat") && !strings.HasSuffix(r.URL.Path, "/handoff") && !strings.HasSuffix(r.URL.Path, "/complete") && !strings.HasSuffix(r.URL.Path, "/captures")) {
http.Error(w, "not found", 404)
return
}
@@ -882,10 +999,15 @@ func main() {
http.Error(w, "not found", 404)
return
}
if _, err := workerAuth(r); err != nil {
wid, err := workerAuth(r)
if err != nil {
http.Error(w, err.Error(), 401)
return
}
if wid != parts[3] {
http.Error(w, "worker identity mismatch", http.StatusForbidden)
return
}
if strings.HasSuffix(r.URL.Path, "/heartbeat") {
if err := workers.Heartbeat(parts[3]); err != nil {
http.Error(w, err.Error(), 404)
@@ -894,6 +1016,24 @@ func main() {
w.WriteHeader(http.StatusNoContent)
return
}
if strings.HasSuffix(r.URL.Path, "/captures") {
var c federation.Capture
if json.NewDecoder(r.Body).Decode(&c) != nil {
http.Error(w, "invalid capture", 400)
return
}
if t, ok := s.Task(c.TaskID); !ok || t.Lease == nil || t.Lease.HarnessID != parts[3] {
http.Error(w, "lease not owned", 409)
return
}
out, err := workers.PutCapture(parts[3], c)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
json.NewEncoder(w).Encode(out)
return
}
var b struct {
TaskID string `json:"task_id"`
TTLSeconds int `json:"ttl_seconds"`
@@ -1067,7 +1207,7 @@ func main() {
authz.MCP: os.Getenv("ORCHESTRA_MCP_TOKEN"), authz.Maven: os.Getenv("ORCHESTRA_MAVEN_TOKEN"),
authz.Telegram: os.Getenv("ORCHESTRA_TELEGRAM_TOKEN"), authz.Ntfy: os.Getenv("ORCHESTRA_NTFY_TOKEN"),
}
log.Fatal(http.ListenAndServe(":"+port, authz.HTTP(tokens, mux)))
log.Fatal(http.ListenAndServe(":"+port, authz.HTTPWithSessions(tokens, sessions, mux)))
}
func auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+102 -3
View File
@@ -3,9 +3,15 @@
package authz
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
type Surface string
@@ -66,10 +72,86 @@ func AuthorizeEvent(s Surface, typ string) error {
return nil
}
// SessionCookie carries a browser's proof of the Web-surface token. A
// top-level document load cannot set an Authorization header, so a
// bearer-only gate forces operators to run the UI with no token at all
// (AUDIT.md B18). The cookie is the browser-presentable equivalent; it is
// never a second credential, only a receipt for the same token.
const SessionCookie = "orchestra_session"
// SessionPath is the one Web-surface endpoint exempt from the token gate,
// because it *is* the token check: it verifies the Web token itself and
// exchanges it for a cookie. Gating it would make login unreachable.
const SessionPath = "/v1/ui/session"
// Sessions issues and validates those receipts. Values are random and stored
// hashed, so a leaked snapshot of this map does not yield a usable cookie.
type Sessions struct {
mu sync.Mutex
TTL time.Duration
ids map[string]time.Time
}
func (s *Sessions) ttl() time.Duration {
if s.TTL > 0 {
return s.TTL
}
return 12 * time.Hour
}
// Issue mints a session value. The caller must have already verified the
// Web-surface token; Issue does not check credentials itself.
func (s *Sessions) Issue() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
v := hex.EncodeToString(b)
sum := sha256.Sum256([]byte(v))
s.mu.Lock()
defer s.mu.Unlock()
if s.ids == nil {
s.ids = map[string]time.Time{}
}
now := time.Now()
for k, exp := range s.ids {
if now.After(exp) {
delete(s.ids, k)
}
}
s.ids[hex.EncodeToString(sum[:])] = now.Add(s.ttl())
return v, nil
}
func (s *Sessions) Valid(v string) bool {
if v == "" {
return false
}
sum := sha256.Sum256([]byte(v))
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.ids[hex.EncodeToString(sum[:])]
if !ok {
return false
}
if time.Now().After(exp) {
delete(s.ids, hex.EncodeToString(sum[:]))
return false
}
return true
}
// HTTP enforces the same policy at the bus boundary. Authentication is
// optional for local development; when a token is supplied, control surfaces
// must present it as a Bearer token.
func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
return HTTPWithSessions(tokens, nil, next)
}
// HTTPWithSessions additionally accepts a valid session cookie in place of a
// Bearer token, but only for the Web surface — every non-browser surface
// still has to present the token directly.
func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := ParseSurface(r.Header.Get("X-Orchestra-Surface"))
if s == "" {
@@ -85,9 +167,26 @@ func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
if s == System {
s = Web
}
if expected := tokens[s]; expected != "" && r.Header.Get("Authorization") != "Bearer "+expected {
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
return
if expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 {
ok := false
if s == Web && sessions != nil {
if c, err := r.Cookie(SessionCookie); err == nil {
ok = sessions.Valid(c.Value)
}
// The login endpoint authenticates itself, and the SPA shell
// must load before a browser can present anything. Static
// assets are not secrets; every /v1/ control path stays gated.
if r.URL.Path == SessionPath {
ok = true
}
if !strings.HasPrefix(r.URL.Path, "/v1/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
ok = true
}
}
if !ok {
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
return
}
}
if (s == Telegram || s == Ntfy) && r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "notify-only surface", http.StatusForbidden)
+64
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSurfaceCapabilities(t *testing.T) {
@@ -46,3 +47,66 @@ func TestSystemSurfaceDowngradedByHTTPMiddleware(t *testing.T) {
t.Fatalf("unexpected status %d", rec.Code)
}
}
// B18: the web UI is a full control plane. A session cookie must be an
// alternative *presentation* of the Web token, never a widening of it.
func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
tokens := map[Surface]string{Web: "secret"}
sessions := &Sessions{}
h := HTTPWithSessions(tokens, sessions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
do := func(method, path string, c *http.Cookie, surface string) int {
req := httptest.NewRequest(method, path, nil)
if c != nil {
req.AddCookie(c)
}
if surface != "" {
req.Header.Set("X-Orchestra-Surface", surface)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
if got := do(http.MethodPost, "/v1/ui/tasks", nil, ""); got != http.StatusUnauthorized {
t.Fatalf("uncredentialed control path = %d, want 401", got)
}
// The SPA shell and the login endpoint must stay reachable, or no
// browser could ever obtain a cookie in the first place.
if got := do(http.MethodGet, "/index.html", nil, ""); got != http.StatusOK {
t.Fatalf("SPA shell = %d, want 200", got)
}
if got := do(http.MethodPost, SessionPath, nil, ""); got != http.StatusOK {
t.Fatalf("login endpoint = %d, want 200", got)
}
v, err := sessions.Issue()
if err != nil {
t.Fatal(err)
}
if got := do(http.MethodPost, "/v1/ui/tasks", &http.Cookie{Name: SessionCookie, Value: v}, ""); got != http.StatusOK {
t.Fatalf("session-cookie control path = %d, want 200", got)
}
if got := do(http.MethodPost, "/v1/ui/tasks", &http.Cookie{Name: SessionCookie, Value: "forged"}, ""); got != http.StatusUnauthorized {
t.Fatalf("forged cookie = %d, want 401", got)
}
// A cookie must not authenticate a non-browser surface.
tokens[TUI] = "tui-secret"
if got := do(http.MethodPost, "/v1/tasks", &http.Cookie{Name: SessionCookie, Value: v}, "tui"); got != http.StatusUnauthorized {
t.Fatalf("cookie on TUI surface = %d, want 401", got)
}
}
func TestSessionExpires(t *testing.T) {
s := &Sessions{TTL: time.Millisecond}
v, err := s.Issue()
if err != nil {
t.Fatal(err)
}
time.Sleep(5 * time.Millisecond)
if s.Valid(v) {
t.Fatal("expired session accepted")
}
if s.Valid("") {
t.Fatal("empty session accepted")
}
}