diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e5e533e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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/v2@v2.10.24 + 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 diff --git a/Makefile b/Makefile index a6c88ce..7016132 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 74cbd1d..a842672 100644 --- a/README.md +++ b/README.md @@ -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 `, 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` | `/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 ` 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/` branch, two-phase + crash-safe register), pins the base commit, spawns the worker command + in it with `OPENCODE_DB=/.opencode/opencode.db` and a random + `OPENCODE_SERVER_PASSWORD`, parses `server listening on `, 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/)` and returns the exact + payload to sign: canonical JSON of `{ runId: , 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 `/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.` 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 ` → 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`. diff --git a/package.json b/package.json index 4fc38e2..5b6b25f 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/accept/wave-1.sh b/scripts/accept/wave-1.sh index 0713a49..19950ed 100755 --- a/scripts/accept/wave-1.sh +++ b/scripts/accept/wave-1.sh @@ -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 diff --git a/scripts/accept/wave-2.sh b/scripts/accept/wave-2.sh index b5f47d8..a6bf485 100644 --- a/scripts/accept/wave-2.sh +++ b/scripts/accept/wave-2.sh @@ -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"); ' diff --git a/scripts/accept/wave-3.sh b/scripts/accept/wave-3.sh index 4f39869..c1388b9 100755 --- a/scripts/accept/wave-3.sh +++ b/scripts/accept/wave-3.sh @@ -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(), diff --git a/scripts/accept/wave-4.sh b/scripts/accept/wave-4.sh new file mode 100755 index 0000000..2ddd391 --- /dev/null +++ b/scripts/accept/wave-4.sh @@ -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"