mirror of
https://github.com/Heretek-AI/agency-control-plane.git
synced 2026-11-07 02:36:07 -10:00
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:
8 files changed
+339
-94
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
Executable
+45
@@ -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"
|
||||
Reference in new issue
Block a user