ci, accept, docs: wave-4 acceptance, GitHub Actions workflow, run instructions

Adds scripts/accept/wave-4.sh (approval attacks, launcher/Director,
NATS, web build, Chromium smoke test) to `make accept`, installs with
--frozen-lockfile --ignore-scripts, updates the wave-2/3 probes to the
pinned-key flow, and adds .github/workflows/ci.yml (pull_request + push
to main, bun 1.4.2, SHA-pinned actions, nats-server + Chromium,
make check + make accept). README documents key generation, starting
the dispatcher + UI, worker/Director configuration, and NATS.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012AuVk4nEvBXaTjs4G6wdW4
This commit is contained in:
Claude committed 2026-10-16 19:32:14 +00:00
1 parent 35cd969965
commit f7a79f02f5
8 files changed
+339 -94

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
name: ci
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check-and-accept:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
# Third-party actions are pinned to full commit SHAs (tag in the comment).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: 1.4.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.23"
cache: false
- name: Install dependencies (no lifecycle scripts)
run: bun install --frozen-lockfile --ignore-scripts
- name: Install nats-server (JetStream integration test)
run: |
go install github.com/nats-io/nats-server/[email protected]
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Install Chromium for the UI smoke test
run: ./node_modules/.bin/playwright-core install --with-deps chromium
- name: make check
run: make check
- name: make accept
run: make accept
- name: Upload UI screenshot
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ui-screenshot
path: docs/evidence/ui.png
if-no-files-found: ignore
+15 -3
View File
@@ -1,7 +1,7 @@
.PHONY: check accept accept-1 accept-2 accept-3 install typecheck test
.PHONY: check accept accept-1 accept-2 accept-3 accept-4 install typecheck test build-web e2e-ui serve
install:
bun install
bun install --frozen-lockfile --ignore-scripts
typecheck:
bun run check
@@ -20,4 +20,16 @@ accept-2:
accept-3:
bash scripts/accept/wave-3.sh
accept: accept-1 accept-2 accept-3
accept-4:
bash scripts/accept/wave-4.sh
build-web:
bun run build:web
e2e-ui: build-web
bun run e2e:ui
serve:
bun run serve
accept: accept-1 accept-2 accept-3 accept-4
+150 -64
View File
@@ -1,81 +1,167 @@
# Agency Control Plane — Fleet Dispatcher Daemon + High-Fidelity WebUI
The Fleet Control Plane schedules expert missions across isolated
`jj` / Git worktrees, gates human approvals behind single-use Ed25519
tickets, and renders the whole fleet in one dark-mode WebUI with five
locked-in UX pillars.
The Fleet Control Plane runs coding workers (OpenCode v2 servers) in
isolated Git / `jj` worktrees, lets a human steer a long-lived Director
session from a chat sidebar, and gates every worker diff behind an
Ed25519 signature from a pinned human key. One dark-mode WebUI shows the
whole fleet through five UX pillars.
## Layout
## Run it
- `dispatcher/` — Fleet Dispatcher Daemon: pure DAG scheduler with
path-overlap arbitration (`model.ts`, `wildcard.ts`, `scheduler.ts`),
3-tier Bifrost failover (`limits.ts`), hash-chained audit ledger
(`audit.ts`), crash-safe worktree coordinator (`worktrees.ts`),
transactional landing (`landing.ts`), ring-buffered SSE / NATS event
bus (`event-bus.ts`), single-use approval tickets + Ed25519 helpers
(`approval-tickets.ts`), and the loopback Bun HTTP + SSE control API
(`server.ts`, `index.ts`).
- `web/` — Unified High-Fidelity WebUI (`@heretek-ai/agency-web`):
1. `KanbanBoard.tsx` — 5-column worktree Kanban (`Backlog`, `Ready`,
`In Progress`, `Waiting Approval / QA`, `Done`) with branch badges
(`fleet/task-101`), owned-path chips, and model-tier indicators.
2. `DirectorChat.tsx` — persistent dockable Director sidebar with
`/ship <spec>`, mission charter preview, and 1-click DAG injection.
3. `WorkerDrillDown.tsx` — live SSE tool-call timeline, stage gates,
token/cost telemetry, DAG dependency graph, and Docker Compose
profile badge (`core` / `appsec` / `android` / `game` / `research`).
4. `SplitDiffViewer.tsx` — side-by-side / unified diff viewer with
file tree, `+`/`-` counters, and highlighted hunks.
5. `ApprovalDrawer.tsx` — exact `subjectHash`, diff summary, QA
verdicts (`qa-functional` + `qa-adversarial`), and the 1-click /
passphrase Ed25519 approval gate.
- `scripts/accept/` — per-wave acceptance scripts (`wave-1.sh`,
`wave-2.sh`, `wave-3.sh`); `docs/reports/` — wave reports and
drift-guard verdicts; `docs/evidence/` — QA red-proof sabotage logs.
## Quickstart
Requirements: Bun 1.4.2, Git, and the `opencode` CLI (v2, `opencode serve`)
on `PATH` with a provider configured. Optional: a NATS server with
JetStream.
```sh
bun install
bun run check # typecheck + lint, zero warnings
bun test # full suite: dispatcher + web
bash scripts/accept/wave-3.sh # wave 3 gate (install, check, tests, I1 probe)
make accept # all three waves
bun install --frozen-lockfile --ignore-scripts
# 1. The human approval key (once). Same format as epistemic-factory es-core:
# human.key.enc (scrypt + AES-256-GCM sealed Ed25519 key, 0600) + human.pub.
bun run keygen -- --out ~/.local/state/agency-control-plane
# Already have an es-core key? Point the dispatcher at it instead:
# --human-pub /path/to/human.pub (or put human.pub in the state dir).
# 2. Build the WebUI and start the dispatcher on the repository to work on.
bun run build:web
bun run dispatcher -- --repo /path/to/repo --port 7600
# (or `bun run serve -- --repo ...` to build and start in one go)
# 3. Open http://127.0.0.1:7600 — the UI and /api share one loopback origin.
```
Serve the dispatcher API (loopback only) via `startServer` in
`dispatcher/src/server.ts`, then point the WebUI at it (`web/index.html`
loads `web/src/app.tsx`, which talks to the same `/api/*` routes).
Dispatcher flags (also documented in the header of `dispatcher/src/main.ts`):
| Flag / env | Default | Meaning |
|------------|---------|---------|
| `--repo` / `FLEET_REPO` | cwd | Repository the fleet works on |
| `--state-dir` / `FLEET_STATE_DIR` | `~/.local/state/agency-control-plane` | SQLite board + worktree register (`dispatcher.db`, WAL, `busy_timeout=15000`), `worktrees/`, `audit.jsonl`, `human.pub` |
| `--port` / `FLEET_PORT` | `7600` | Loopback port (never binds anything but 127.0.0.1) |
| `--human-pub` | `<state-dir>/human.pub` | Pinned human public key. Missing or invalid → approvals are disabled and the approval routes answer 503 (fail closed) |
| `--worker-cmd` / `FLEET_WORKER_CMD` | `opencode serve --port 0 --hostname 127.0.0.1` | Worker server command (JSON array or words) |
| `--director-cmd` / `FLEET_DIRECTOR_CMD` | worker command | Director server command |
| `--director-agent` | `director` | OpenCode agent of the Director session (define it in your OpenCode config) |
| `--task-agent` | server default | OpenCode agent for task sessions |
| `--fleet-cap` | `4` | Max concurrent workers (scheduler cap) |
| `--task-timeout-ms` | 30 min | Per-task worker timeout |
| `--no-workers`, `--no-director` | | Disable the launcher / Director |
| `NATS_URL` | unset | Mirror events + board into NATS JetStream / KV (start fails if set but unreachable) |
## How work flows
1. **Director chat.** Messages go to one long-lived OpenCode session
(agent `director`) started in the repository with its own isolated
OpenCode database. The reply is shown in chat. Cards are created only
from a fenced block the Director emits, validated with zod (strict;
unknown fields, bad deps, or invalid JSON create nothing and are
reported in chat):
````
```tasks
[{"key":"api","title":"Query API","objective":"…","paths":["src/api/*"],"acceptance":["…"]},
{"key":"docs","title":"Docs","deps":["api"],"paths":["docs/*"]}]
```
````
`/ship <spec>` is the explicit manual fallback: each bullet of the
spec becomes one card, no Director involved.
2. **Scheduler + launcher.** On every board change the pure scheduler
(deps, `all_success` / `one_success`, path-overlap serialization,
fleet / per-expert caps) picks tasks to start. For each one the
launcher allocates the worktree (`fleet/<task>` branch, two-phase
crash-safe register), pins the base commit, spawns the worker command
in it with `OPENCODE_DB=<worktree>/.opencode/opencode.db` and a random
`OPENCODE_SERVER_PASSWORD`, parses `server listening on <url>`, creates
a session (`POST /api/session`), sends the task prompt
(`POST /api/session/:id/prompt`), and follows `GET /api/event`:
`session.tool.*` become the drill-down live tool log, `session.status`
/ `session.text.ended` are republished on the dispatcher bus,
`session.execution.succeeded|failed|interrupted` (or the process
exiting / timing out) ends the run. Successful runs are committed onto
the task branch (never the `.opencode` state); a non-empty diff moves
the card to **Waiting Approval / QA**, an empty one to **Done**.
Failures follow the task's retry policy. Shutdown (SIGINT/SIGTERM)
kills every worker and re-queues interrupted tasks.
3. **Approval.** The human opens the card, checks the diff, and mints a
ticket. The **server** computes `subjectHash = sha256(concatenated
per-file patches of base...fleet/<task>)` and returns the exact
payload to sign: canonical JSON of `{ runId: <taskId>, stage:
"APPROVE" | "REJECT", subjectHash, timestamp }` — the same payload
shape and key format as es-core `signStageApproval` /
`verifyStageApproval`, so one human key serves both systems (the
disjoint stage names stop a record for one system being replayed on
the other). The browser recomputes the hash from the diff it displays
and refuses to sign on mismatch, unlocks the chosen `human.key.enc`
(scrypt via `@noble/hashes`, AES-GCM + Ed25519 via WebCrypto) or a
PKCS#8 key file, checks the signature against the pinned key, and
sends only `{ ticket, approve, subjectHash, signatureHex }`. The
dispatcher burns the single-use 60 s ticket, recomputes the diff hash,
and verifies against the pinned `human.pub` only (any client key or
passphrase is ignored). APPROVE → `done`, REJECT → `failed`; the full
es-core-shaped signed record goes into the hash-chained audit ledger.
4. **Persistence.** Tasks, missions, chat, counters and per-task tool
logs live in `<state-dir>/dispatcher.db`; a restart resumes the board
(tasks that were running are re-queued).
5. **NATS (optional).** With `NATS_URL`, every bus event is published to
JetStream subject `fleet.events.<type>` on stream `FLEET_EVENTS`
(drop-oldest at 10 000 messages) and every task document is mirrored to
KV bucket `fleet-board` (`nats@2.29.3`). The in-process ring buffer
remains the SSE source of truth.
## Control API (`dispatcher/src/server.ts`, Bun HTTP + SSE, `127.0.0.1` only)
| Method & Path | Purpose |
|---------------|---------|
| `GET /api/board` | Kanban columns, tasks, worktree statuses, active model tier (`tier-1-opencode` / `tier-2-commercial` / `tier-3-local`), DAG edges. |
| `POST /api/director/message` | Append steering message; decompose new task cards onto the board. |
| `GET /api/tasks/:id/drilldown` | Worker session metadata, stage gates, live tool-log buffer, git diff hunks. |
| `POST /api/approvals/:taskId/ticket` | Mint single-use 60s-TTL preview ticket bound to `{ taskId, subjectHash }`. |
| `POST /api/approvals/:taskId/sign` | Verify ticket + Ed25519 signature/passphrase; `waitingHuman` → `ready`/`done`; audit-chained. |
| `GET /api/events` | SSE stream (ring-buffered, replayable sequence IDs; `?snapshot=1` for finite replay). |
| `GET /api/board` | Columns, tasks, worktrees, tier, DAG edges, chat, scheduler plan, approval/Director availability. |
| `POST /api/director/message` | `/ship <spec>` → cards now (200); otherwise → Director turn (202; reply + cards arrive as `director.message`). 503 without a Director. |
| `GET /api/tasks/:id/drilldown` | Session, stage gates, tool log, diff hunks, `subjectHash`. |
| `POST /api/approvals/:taskId/ticket` | Single-use 60 s ticket; server-computed `subjectHash`, payloads to sign, pinned key fingerprint. 503 without a pinned key, 409 unless `waitingHuman`. |
| `POST /api/approvals/:taskId/sign` | Burn ticket, re-hash diff, verify Ed25519 against the pinned key; `waitingHuman` → `done` / `failed`. |
| `GET /api/events` | SSE (ring-buffered, `Last-Event-ID` replay, gap markers). |
| `GET /api/csrf` | CSRF token for POSTs. |
| `GET /*` | The built WebUI (`web/dist`) with a strict same-origin CSP. |
The WebUI (`web/src/app.tsx`) seeds its store from `GET /api/board`,
subscribes to `/api/events` via `EventSource`, loads per-card drill-downs
from `/api/tasks/:id/drilldown`, and mints/signs approvals through the
ticket endpoints.
## Layout
- `dispatcher/src/` — `model.ts`, `wildcard.ts`, `scheduler.ts`, `limits.ts`,
`audit.ts`, `worktrees.ts`, `landing.ts`, `event-bus.ts`,
`approval-tickets.ts`, `human-key.ts` (es-core-compatible pinned key),
`board-store.ts` (SQLite board), `opencode-client.ts` (worker process +
OpenCode v2 HTTP client behind `WorkerApi`), `worker-session.ts`,
`worker-launcher.ts`, `director.ts`, `nats-bridge.ts`, `server.ts`,
`main.ts` (daemon), `keygen.ts`.
- `dispatcher/test/fixtures/fake-opencode.ts` — fake `opencode serve`
implementing the routes above, used by the launcher/Director tests and
the browser smoke test. `fixtures/es-core-vector/` — a key + record
produced by es-core itself.
- `web/` — React UI; `web/src/main.tsx` is the browser entry,
`scripts/build-web.ts` bundles `web/index.html` into `web/dist`.
- `scripts/accept/wave-{1..4}.sh`, `scripts/e2e/ui-smoke.ts` (Chromium via
`playwright-core`; screenshot in `docs/evidence/ui.png`).
## Checks
```sh
make check # install, typecheck, bun test
make accept # waves 1-4; wave 4 builds the UI and runs the Chromium smoke test
```
The NATS integration test uses `NATS_URL` or spawns `nats-server` from
`PATH` / `~/go/bin` (`go install github.com/nats-io/nats-server/v2@v2.10.24`);
without either it is skipped. The UI smoke test needs Chromium for
`playwright-core@1.56.1` (`PLAYWRIGHT_BROWSERS_PATH`, or
`./node_modules/.bin/playwright-core install chromium`). CI
(`.github/workflows/ci.yml`) installs both and runs `make check` and
`make accept`.
## Security Invariant I1 (binding)
The human approval secret entered in the approval dialog is held **only**
in a lexical function-closure variable for the duration of the
`fetch("/api/approvals/:id/sign")` call, is overwritten and nulled in a
`finally` block immediately after, and is **never** written to
`fleet-store`, `localStorage`, `sessionStorage`, or DOM data attributes.
- Enforcement: `web/src/security/approval-client.ts`
(`approveWithPassphrase` / `approveOneClick` /
`assertNoPassphraseLeak`); the drawer (`ApprovalDrawer.tsx`) reads the
secret from an uncontrolled password input at submit time and clears
the input afterwards — no React state ever holds it.
- Proof: `web/test/approval-invariant.test.ts` (static source probe +
runtime wire/sink assertions) and the I1 section of
`scripts/accept/wave-3.sh`, which fails the wave on any violation.
The passphrase and key-file bytes are read from uncontrolled inputs at
submit time and live only in local variables of `approveWithKeyFile`
(`web/src/security/approval-client.ts`) for the duration of the sign
request; byte buffers (file, scrypt output, decrypted PKCS#8) are
zero-filled and every reference nulled in `finally`, the signing key is a
non-extractable `CryptoKey`, and nothing reaches `fleet-store`,
`localStorage`, `sessionStorage`, or DOM attributes. Proof:
`web/test/approval-invariant.test.ts`, the I1 section of
`scripts/accept/wave-3.sh`, and the in-browser sink check of
`scripts/e2e/ui-smoke.ts`.
+14 -3
View File
@@ -2,10 +2,21 @@
"name": "@heretek-ai/agency-control-plane",
"private": true,
"version": "0.0.0",
"workspaces": ["dispatcher", "web"],
"workspaces": [
"dispatcher",
"web"
],
"scripts": {
"check": "bun run --filter './dispatcher' check && bun run --filter './web' check",
"accept": "bash scripts/accept/wave-1.sh && bash scripts/accept/wave-2.sh && bash scripts/accept/wave-3.sh",
"test": "bun test"
"test": "bun test",
"build:web": "bun scripts/build-web.ts",
"e2e:ui": "bun scripts/e2e/ui-smoke.ts",
"keygen": "bun dispatcher/src/keygen.ts",
"dispatcher": "bun dispatcher/src/main.ts",
"serve": "bun scripts/build-web.ts && bun dispatcher/src/main.ts",
"accept": "bash scripts/accept/wave-1.sh && bash scripts/accept/wave-2.sh && bash scripts/accept/wave-3.sh && bash scripts/accept/wave-4.sh"
},
"devDependencies": {
"playwright-core": "1.56.1"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
echo "[wave-1] bun install"
bun install
bun install --frozen-lockfile --ignore-scripts
echo "[wave-1] typecheck dispatcher"
bun run --filter './dispatcher' check
+25 -11
View File
@@ -6,7 +6,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
echo "[wave-2] bun install"
bun install
bun install --frozen-lockfile --ignore-scripts
echo "[wave-2] typecheck dispatcher"
bun run --filter './dispatcher' check
@@ -59,7 +59,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { allocateWorktree, releaseWorktree, salvageWorktree, WorktreeStore } from "./dispatcher/src/worktrees.ts";
import { landTask } from "./dispatcher/src/landing.ts";
import { TicketStore, generateApprovalKeyPair, signSubjectHash } from "./dispatcher/src/approval-tickets.ts";
import { TicketStore } from "./dispatcher/src/approval-tickets.ts";
import { sealHumanKey, signApprovalPayload, unsealHumanKey } from "./dispatcher/src/human-key.ts";
import { EventBus } from "./dispatcher/src/event-bus.ts";
import { createServer } from "./dispatcher/src/server.ts";
@@ -117,7 +118,9 @@ if (replay.gap === null) throw new Error("overflow gap marker missing");
if (replay.events.length !== 2) throw new Error("ring buffer size wrong");
// board endpoint serves columns + tier + DAG edges; director message decomposes cards
const app = createServer({});
const keyDir = mkdtempSync(join(tmpdir(), "wave2-key-"));
sealHumanKey(keyDir, "wave-2 probe passphrase");
const app = createServer({ humanPublicKeyPath: join(keyDir, "human.pub") });
app.upsertTask({ id: "probe-h", status: "waitingHuman", title: "Human gate" });
const boardRes = await app.fetch(new Request("http://127.0.0.1/api/board"));
if (boardRes.status !== 200) throw new Error("board endpoint failed");
@@ -127,29 +130,40 @@ if (board.columns.length !== 5) throw new Error("board columns wrong");
const msgRes = await app.fetch(new Request("http://127.0.0.1/api/director/message", {
method: "POST",
headers: { "content-type": "application/json", "x-csrf-token": app.csrfToken },
body: JSON.stringify({ message: "Ship it\n- card one\n- card two" }),
body: JSON.stringify({ message: "/ship Ship it\n- card one\n- card two" }),
}));
if (msgRes.status !== 200) throw new Error("director message failed");
const msg = await msgRes.json();
if (msg.tasksCreated.length !== 2) throw new Error("director decomposition failed");
// ticket mint + Ed25519 sign transitions waitingHuman -> ready
const subject = "probe-subject";
// server-computed subject + pinned human key: waitingHuman -> done
const ticketRes = await app.fetch(new Request("http://127.0.0.1/api/approvals/probe-h/ticket", {
method: "POST",
headers: { "content-type": "application/json", "x-csrf-token": app.csrfToken },
body: JSON.stringify({ subjectHash: subject }),
body: JSON.stringify({ subjectHash: "client-chosen-ignored" }),
}));
if (ticketRes.status !== 200) throw new Error("ticket mint failed");
const { ticket } = await ticketRes.json();
const keys = generateApprovalKeyPair();
const issued = await ticketRes.json();
if (issued.subjectHash === "client-chosen-ignored") throw new Error("server accepted a client subjectHash");
const sign = (signatureHex) => app.fetch(new Request("http://127.0.0.1/api/approvals/probe-h/sign", {
method: "POST",
headers: { "content-type": "application/json", "x-csrf-token": app.csrfToken },
body: JSON.stringify({ ticket: issued.ticket, signatureHex }),
}));
const { generateKeyPairSync } = await import("node:crypto");
const forger = Buffer.from(generateKeyPairSync("ed25519", { privateKeyEncoding: { type: "pkcs8", format: "der" }, publicKeyEncoding: { type: "spki", format: "der" } }).privateKey);
if ((await sign(signApprovalPayload(forger, issued.payloads.approve))).status !== 403) throw new Error("forged approval accepted");
const fresh = await (await app.fetch(new Request("http://127.0.0.1/api/approvals/probe-h/ticket", {
method: "POST", headers: { "x-csrf-token": app.csrfToken }, body: "{}",
}))).json();
const human = unsealHumanKey(keyDir, "wave-2 probe passphrase");
const signRes = await app.fetch(new Request("http://127.0.0.1/api/approvals/probe-h/sign", {
method: "POST",
headers: { "content-type": "application/json", "x-csrf-token": app.csrfToken },
body: JSON.stringify({ ticket, subjectHash: subject, signature: signSubjectHash(keys.privateKeyPem, subject), publicKey: keys.publicKeyPem }),
body: JSON.stringify({ ticket: fresh.ticket, signatureHex: signApprovalPayload(human, fresh.payloads.approve) }),
}));
if (signRes.status !== 200) throw new Error("approval sign failed");
if ((await signRes.json()).status !== "ready") throw new Error("sign did not transition to ready");
if ((await signRes.json()).status !== "done") throw new Error("sign did not transition to done");
store.close();
console.log("[wave-2] behavior probe passed");
'
+32 -12
View File
@@ -6,12 +6,12 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
echo "[wave-3] bun install"
bun install
bun install --frozen-lockfile --ignore-scripts
echo "[wave-3] typecheck (dispatcher + web)"
bun run check
echo "[wave-3] unit tests (all 66 prior + new web tests)"
echo "[wave-3] unit tests (dispatcher + web)"
bun test
echo "[wave-3] zero-stub grep (must print nothing)"
@@ -80,6 +80,10 @@ if grep -rn -E '^import .*store' web/src/security/approval-client.ts; then
fi
grep -q 'finally' web/src/security/approval-client.ts || { echo "[wave-3] FAIL: no finally zeroing in approval-client" >&2; exit 1; }
grep -q 'secret = null' web/src/security/approval-client.ts || { echo "[wave-3] FAIL: closure secret is never nulled" >&2; exit 1; }
if grep -rn -E "from ['\"]node:|\bBuffer\b" web/src; then
echo "[wave-3] FAIL: browser sources use Node built-ins" >&2
exit 1
fi
echo "[wave-3] I1 static probe passed"
echo "[wave-3] web behavior probe"
@@ -92,7 +96,12 @@ import { DirectorChat } from "./src/components/DirectorChat.tsx";
import { KanbanBoard } from "./src/components/KanbanBoard.tsx";
import { SplitDiffViewer } from "./src/components/SplitDiffViewer.tsx";
import { WorkerDrillDown } from "./src/components/WorkerDrillDown.tsx";
import { activeApprovalSecrets, approveWithPassphrase, assertNoPassphraseLeak } from "./src/security/approval-client.ts";
import { activeApprovalSecrets, approveWithKeyFile, assertNoPassphraseLeak } from "./src/security/approval-client.ts";
import { sealHumanKey } from "../dispatcher/src/human-key.ts";
import { createServer } from "../dispatcher/src/server.ts";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFleetStore } from "./src/state/fleet-store.ts";
// store: board seed, move, drill-down, SSE tool event, diff, tier
@@ -113,10 +122,10 @@ store.setDrilldown({
sessionId: "sess-task-101", taskId: "task-101", status: "running", tier: "tier-1-opencode",
profile: "core", stageGates: [{ name: "verify", status: "pass" }], toolStream: [], tokens: 10, cost: 0.001,
});
applySsePayload(store, { topic: "worker.tool", taskId: "task-101", tool: "read", input: "a", output: "b" });
applySsePayload(store, { type: "worker.tool", data: { taskId: "task-101", tool: "read", input: "a", output: "b" } });
if (store.getState().drilldown?.toolStream.length !== 1) throw new Error("SSE tool event failed");
store.setDiff("task-101", [{ file: "a.ts", status: "modified", additions: 1, deletions: 1, patch: "-x\n+y" }]);
applySsePayload(store, { topic: "tier.fallback", targetTier: "tier-2-commercial" });
applySsePayload(store, { type: "tier.fallback", data: { targetTier: "tier-2-commercial" } });
if (store.getState().activeTier !== "tier-2-commercial") throw new Error("tier fallback failed");
// all five pillars render to static markup
@@ -127,27 +136,38 @@ const pillars = [
renderToStaticMarkup(createElement(WorkerDrillDown, { worker: state.drilldown, dagEdges: state.dagEdges })),
renderToStaticMarkup(createElement(SplitDiffViewer, { hunks: state.diffs["task-101"] ?? [] })),
renderToStaticMarkup(createElement(ApprovalDrawer, {
taskId: "task-104", subjectHash: "probe-hash", additions: 1, deletions: 1, files: 1,
taskId: "task-104", displayedSubjectHash: "probe-hash", additions: 1, deletions: 1, files: 1,
verdicts: [
{ suite: "qa-functional", status: "pass", detail: "green" },
{ suite: "qa-adversarial", status: "pending", detail: "queued" },
],
ticket: "ticket-probe",
ticket: null,
})),
];
for (const marker of ["Fleet Kanban", "Director", "sess-task-101", "diff-file-tree", "approval-drawer"]) {
if (!pillars.some((html) => html.includes(marker))) throw new Error(`pillar marker missing: ${marker}`);
}
// I1 runtime probe: secret never leaves the closure
// I1 runtime probe: WebCrypto signing against a real dispatcher; secret never leaves the closure
const secret = "probe-secret-303";
const keyDir = mkdtempSync(join(tmpdir(), "wave3-key-"));
sealHumanKey(keyDir, secret);
const app = createServer({ humanPublicKeyPath: join(keyDir, "human.pub") });
app.upsertTask({ id: "task-104", status: "waitingHuman", title: "Human gate" });
app.recordDiff("task-104", [{ file: "a.ts", status: "modified", additions: 1, deletions: 1, patch: "diff --git a/a.ts b/a.ts\n-x\n+y" }]);
const ticket = await (await app.fetch(new Request("http://127.0.0.1/api/approvals/task-104/ticket", {
method: "POST", headers: { "x-csrf-token": app.csrfToken }, body: "{}",
}))).json();
let seen = "";
const fakeFetch = async (url, init) => {
seen = typeof init?.body === "string" ? init.body : "";
return new Response(JSON.stringify({ taskId: "task-104", status: "ready", subjectHash: "probe-hash" }), { status: 200 });
const routed = async (url, init) => {
seen += init?.body ?? "";
return app.fetch(new Request(`http://127.0.0.1${url}`, { method: "POST", headers: { ...init.headers, "x-csrf-token": app.csrfToken }, body: init.body }));
};
await approveWithPassphrase("task-104", "ticket-probe", secret, "probe-hash", { fetchImpl: fakeFetch });
const keyBytes = new Uint8Array(readFileSync(join(keyDir, "human.key.enc")));
const result = await approveWithKeyFile("task-104", ticket, keyBytes, secret, { fetchImpl: routed });
if (result.status !== "done") throw new Error("browser-side signature was not accepted");
if (seen.includes(secret)) throw new Error("passphrase leaked onto the wire");
if (!keyBytes.every((b) => b === 0)) throw new Error("key file bytes not zeroed");
if (activeApprovalSecrets() !== 0) throw new Error("closure secret not zeroed");
const probe = assertNoPassphraseLeak(secret, {
storeSnapshot: store.getState(),
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Wave 4 acceptance: pinned-key approvals, real workers + Director, durable
# board, NATS bridge, and a browser-run WebUI served by the dispatcher.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
echo "[wave-4] bun install"
bun install --frozen-lockfile --ignore-scripts
echo "[wave-4] typecheck (dispatcher + web)"
bun run check
echo "[wave-4] zero-stub grep (must print nothing)"
W1='TO'"DO"
W2='FI'"XME"
W3='X'"XX"
W4='HA'"CK"
W5='not implemen'"ted"
W8='ret'"urn true;"
P_STUB="${W1}|${W2}|${W3}|${W4}|${W5}|${W8}|catch *\(.*\) *\{\}"
STUBS="$(grep -rn --include='*.ts' --include='*.tsx' -E "${P_STUB}" dispatcher/src web/src scripts/build-web.ts scripts/e2e || [ $? -eq 1 ])"
if [ -n "$STUBS" ]; then
echo "$STUBS"
echo "[wave-4] FAIL: stub patterns found" >&2
exit 1
fi
echo "[wave-4] no client-trusted approval keys or passphrase verification on the server"
if grep -n -E 'record\["publicKey|passphraseToKeypair|verifyApprovalSignature' dispatcher/src/*.ts; then
echo "[wave-4] FAIL: server still trusts client key material" >&2
exit 1
fi
echo "[wave-4] approval attacks, es-core key compatibility, launcher, Director, persistence, NATS"
bun test dispatcher/test/approvals.test.ts dispatcher/test/human-key.test.ts dispatcher/test/launcher.test.ts dispatcher/test/nats-bridge.test.ts
echo "[wave-4] browser build"
bun run build:web
echo "[wave-4] Chromium smoke test against a served dispatcher (writes docs/evidence/ui.png)"
bun run e2e:ui
echo "[wave-4] PASS"