diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0db046d..445ce65 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,22 +1,11 @@ -# IUMBTEMS (Epistemic Swarm): Project Workspace Rules +# IUMBTEMS (Epistemic Swarm): session notes -This repository operates under the **IUMBTEMS Epistemic Integrity Protocol**. All Claude Code sessions within this directory are bound by strict evidentiary integrity rules. +The single source of truth for agent conventions in this repo is +[`AGENTS.md`](../AGENTS.md) at the repo root. Follow it: evidence tags on +research/harvest claims, explicit file staging (never `git add -A`), `bun run +check` before merging, and the contract invariants (human-only approvals, +control files, seat scopes). -## Core Rules for This Workspace -1. **Epistemic Invariant Enforcement**: - - Tag every factual claim using `[VERIFIED: ]`, `[INFERRED: ]`, `[HYPOTHESIS: ]`, or `[NEGATIVE_KNOWLEDGE: ]`. - - Never generate hallucinated citations. If an empirical claim cannot be retrieved and verified via tools, mark it as `[HYPOTHESIS]` or `[NEGATIVE_KNOWLEDGE]`. - -2. **Source Caching**: - - When fetching articles, documentation, or papers via web search or MCP tools, ensure the raw content is hashed and saved to `.research/sources/.md` with metadata in `.research/sources/.json`. - - Verbatim quotes must match the text inside `.research/sources/.md`. - -3. **Multi-Agent Dialectic Swarm**: - - When running deep research, use the runner script: - ```bash - python3 runner/research_swarm.py --mode --objective "" - ``` - - For preliminary planning and assumption inversion, trigger the Socratic grilling skill via `/grilling`. - -4. **IPC Scratchpad Structure**: - - Read and write session data strictly through `.research/scratchpads/{scope_id}/`. +Do not follow stale local instructions: there is no `runner/` directory, no +`.research/` tree (evidence lives under `.factory/research/`), and no +per-claim tag convention for everyday agent communication. diff --git a/.github/workflows/fleet.yml b/.github/workflows/fleet.yml new file mode 100644 index 0000000..68df9dd --- /dev/null +++ b/.github/workflows/fleet.yml @@ -0,0 +1,34 @@ +name: Fleet + +# Path-filtered job for packages/fleet (ADR 0001): fleet changes get fast +# feedback here, while the `check` job in CI stays unfiltered and keeps +# running on every push and PR (#122). + +on: + push: + branches: [rewrite, main, "integration/**", "ticket-*"] + paths: + - "packages/fleet/**" + - ".github/workflows/fleet.yml" + pull_request: + paths: + - "packages/fleet/**" + - ".github/workflows/fleet.yml" + +permissions: + contents: read + +jobs: + fleet: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.4.2" + - run: bun install --frozen-lockfile --ignore-scripts + # Local lockfile-pinned binary (not bun x: S8543, no unverified downloads). + - run: ./node_modules/.bin/biome check packages/fleet + - run: bun run typecheck + - run: bun test packages/fleet diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6a61ffc..af38752 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,6 +46,10 @@ jobs: # workspace:* to real versions), smoke-test the installed tarballs, and # publish exactly those tarballs. - run: scripts/pack-smoke.sh packs + # The release tarballs, in publish order, from the single source of + # truth (scripts/packages.ts); pack-smoke.sh packed exactly these. + - id: packages + run: echo "tarballs=$(bun scripts/packages.ts --release | awk '{print "./packs/"$2"-*.tgz"}' | tr '\n' ' ')" >> "$GITHUB_OUTPUT" # Trusted publishing needs a recent npm CLI; it picks up the OIDC token # from the environment automatically (no NODE_AUTH_TOKEN). Pinned, and no # lifecycle scripts from the fetched tarball run. @@ -54,11 +58,12 @@ jobs: env: DRY_RUN: ${{ inputs.dry_run }} run: | - # Dependency order: core, then the CLI and the OpenCode plugin. + # Publish order comes from scripts/packages.ts (core, then the CLI + # and the OpenCode plugin): the glob expands each step's tarball. # NOTE: the ./ prefix is load-bearing. A bare packs/foo.tgz is # parsed as a GitHub user/repo shorthand (git ls-remote failure); # ./packs/foo.tgz is resolved as the local tarball. - for tgz in ./packs/heretek-ai-es-core-*.tgz ./packs/heretek-ai-es-cli-*.tgz ./packs/heretek-ai-epistemic-swarm-*.tgz; do + for tgz in ${{ steps.packages.outputs.tarballs }}; do echo "::group::npm publish $tgz" if [ "$DRY_RUN" = "true" ]; then npm publish "$tgz" --provenance --access public --dry-run diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 0000000..54da015 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,38 @@ +name: Web + +# Path-filtered job for packages/web (ADR 0001, ADR 0003): web changes get +# fast feedback here, while the `check` job in CI stays unfiltered and keeps +# running on every push and PR (#129). + +on: + push: + branches: [rewrite, main, "integration/**", "ticket-*"] + paths: + - "packages/web/**" + - ".github/workflows/web.yml" + pull_request: + paths: + - "packages/web/**" + - ".github/workflows/web.yml" + +permissions: + contents: read + +jobs: + web: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.4.2" + - run: bun install --frozen-lockfile --ignore-scripts + # Local lockfile-pinned binary (not bun x: S8543, no unverified downloads). + - run: ./node_modules/.bin/biome check packages/web + - run: bun run typecheck + # Browser conditions: the web tests render with the client Solid build + # (the default toolchain resolves its server build, whose render throws). + - run: bun --conditions=browser test packages/web + - run: bun run build + working-directory: packages/web diff --git a/.gitignore b/.gitignore index e469559..1cd0618 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ evals/results/ audit.md *.pyc __pycache__/ + +# Fleet task worktrees (one isolated checkout per task, #124) +.fleet/ + +# Tier-1 phase worktrees (one checkout per ticket, merged into integration/*) +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 4034872..3ae1404 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,28 +22,44 @@ spike as proof). - `packages/opencode/src/**` — the plugin (server + TUI entrypoints). The canonical agent registry (`packages/core/src/agents/registry.ts`) compiles to v2 agents with wildcard-deny scoping; never touch built-ins or defaults. +- `packages/fleet/src/**` — the human-only fleet daemon (`es-fleet`, private + until its RPC is stable). Depends on core only; core, CLI and plugin never + import it (`scripts/deps.ts` boundaries + a path-filtered CI job). + `es-fleet status` is agent-safe, but `es-fleet status --token` prints the + bus bearer token at an interactive terminal only (human-only, like every + other fleet verb). +- `packages/web/src/**` — the web control plane (SolidJS + Vite, private, + never published; ADR 0003). Served by the fleet daemon on loopback behind + a single-use ticket exchange; a leaf surface — nothing imports it, and it + imports nothing at runtime except the fleet API's schema types + (`scripts/deps.ts` boundaries + a path-filtered CI job); the test-only exception is packages/web/test/*.contract.test.ts, which may runtime-import the fleet server harness (the WS contract test) — src/ stays type-only. - `packages/core/src/capabilities.ts` — the capability matrix. ENFORCED rows must name a proof (test file or spike record) that exists; `bun run docs:check` and `capabilities.test.ts` fail otherwise. - GENERATED — never hand-edit: `schemas/*.schema.json`, `docs/SCHEMAS.md`, `docs/CAPABILITIES.md`, `docs/CONFIG.md`. Edit the Zod schema / capability table, then run `bun run docs:gen`. -- Hand-maintained: `README.md`, `AGENTS.md`, `SYSTEM_ARCHITECTURE.md`, `docs/DOGFOOD.md`, `spikes/*/RESULTS.md`, `.github/**`. +- Hand-maintained: `README.md`, `AGENTS.md`, `SYSTEM_ARCHITECTURE.md`, `docs/DOGFOOD.md`, `docs/adr/*.md`, `docs/roadmap/*.md`, `spikes/*/RESULTS.md`, `.github/**`. +- One repo, one npm package per surface (`docs/adr/0001-monorepo.md`): core never imports the CLI or the plugin, and the CLI never imports the plugin (`bun run deps:check`). ## Commands - `bun install` · `bun run check` (biome + tsc + all tests) · `bun run test` - `bun run docs:gen` / `bun run docs:check` (drift; CI runs the check) - `scripts/v2-head.sh [tests…]` — real-host suite against OpenCode `v2` HEAD (repoints node_modules symlinks; `bun install` restores them) -- `es ` +- `es ` +- `es factory run --headless [--driver opencode] [--max-turns N] [--log-level quiet|info|debug] [--events jsonl] [--events-file ] [--turn-timeout S] [--cwd ]` (SIGINT/SIGTERM cancel, exit 130; detached HEADs and other runs' seat worktrees refused; launching one stays human-only, spend-bearing like audit/scout runs; `docs/HEADLESS.md`) +- `es mcp` pins caller identity to the adapter environment (`ES_MCP_AGENT`, else legacy `ES_AGENT`): the per-call `agent` argument is ignored; without either the caller is `mcp` (no seat) +- The CLI grammar lives in core (`packages/core/src/util/args.ts`): `parseArgs`, `flag`, `Args`, `BOOLEAN_FLAGS` — the human-only policy reads the same grammar (argv parity, #97) +- One package list (`bun scripts/packages.ts [--release] [--tsconfig] [--browser] [--no-browser]`): the typecheck loop, `pack-smoke.sh`, `publish.yml` and `deps.ts` all read it; new packages declare `esRelease.order` and `BOUNDARIES` (#102); the `bun --conditions=browser` test split comes from each manifest's `esTest.conditions` - OpenCode slash commands: `/grill /factory /audit /scout /brainstorm /harvest /design /research /gates /status /lsp /hooks /config` plus `es-*` TUI palette commands (panels: `/es-factory`, `/es-lsp-panel`, `/es-hooks`, `/es-brainstorm`; `/es-close`). ## Contract invariants (do not break) | Invariant | Where | | :--- | :--- | -| Approvals, waivers, trust and resume are human-only: terminal passphrase confirmation unlocks the sealed Ed25519 human key (signed, hash-bound); the approve/trust/resume RPCs no longer exist and the TUI only previews, then points at the terminal command; agents may request, never grant | `approval/`, `gates/waivers.ts` | +| Approvals are human-only; trust, waivers and resume stay terminal-only: passphrase confirmation (terminal echo-off, TUI masked dialog, or loopback browser input per ADR 0002) unlocks the sealed Ed25519 human key (signed, hash-bound) and every surface signs approvals in-process — the TUI with channel `tui`, the browser through the loopback-only fleet endpoints with channel `web`; the approve/trust/resume RPCs no longer exist; agents may request, never grant | `approval/`, `gates/waivers.ts` | | Control files are deny-write for all agents: `.factory/{gates.json,config.json,frontier.json,waivers,approvals,runtime}`, the evidence (`.factory/research/{sources,coverage.json,dossier.json,brief.pcrb.json}`, `.factory/claims/`), `.git/config` and git hooks. The pinned ones (gates, config, frontier, approvals, waivers) are also hash-checked at every gate run; cached sources are engine-sealed (HMAC) and content-addressed | `trust/control.ts` | -| Per-seat path scopes: manager writes factory docs, programmer writes its worktree, QA and the auditor pair write nothing, research alpha/beta write only their own notes (`.factory/research/alpha.md` / `beta.md`) and only the factory writes `research/REPORT.md`, brainstorm/harvest/design/scout write only their `.factory//notes/`; research, scout and auditor seats are `web: "cached"` (host websearch/webfetch denied, web facts only through the cached `es_research_*` tools) and run `--unshare-net` sandboxed, while user agents keep host web tools | `trust/policy.ts`, `agents/registry.ts` | +| Per-seat path scopes: manager writes factory docs, programmer writes its worktree, QA and the auditor pair write nothing, research alpha/beta write only their own notes (`.factory/research/alpha.md` / `beta.md`) and `research/REPORT.md` has one writer per run mode — the factory in build runs, the research synthesizer in research runs (`PolicyContext.runMode`; an unreadable mode denies both), brainstorm/harvest/design/scout write only their `.factory//notes/`; research, scout and auditor seats are `web: "cached"` (host websearch/webfetch denied, web facts only through the cached `es_research_*` tools) and run `--unshare-net` sandboxed, while user agents keep host web tools | `trust/policy.ts`, `agents/registry.ts` | | Factory seats launch seats in the foreground only: `subagent` with `background: true` from any seat is refused, and so is launching a seat that is still running; seat state and last activity are recorded in `.factory/runtime/seats.json` (advisory) | `opencode/src/policy.ts`, `opencode/src/seats.ts` | | Code audits block: an opened phase audit holds `passPhase` (a missing one too with `audit.phase: required`, set via `es config set`), any other open or failed audit refuses the next stage transition; every finding is witnessed against the file on disk; only a human dismisses one | `factory/machine.ts`, `codeaudit/` | | Scout verdicts are computed by core: `adopt` survives only for a verified, whitelisted, permissive license | `scout/verdict.ts` | diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa6616..523d9f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,417 @@ # Changelog +## 1.6.0 — 2026-10-09 + +Phase 5 self-improvement: runs already leave rich records (audit chain, +QA failures, gate rejections, findings, spend, evals) and now the factory +learns from them. `es improve harvest` aggregates runs and evals into one +versioned, secret-scrubbed telemetry dataset; `es improve distill` clusters +recurring failures into reviewable proposals a human applies; the +`self-dogfood` preset builds this repo from an issue into a draft PR. +The published trio (core/CLI/plugin) releases in lockstep as 1.6.0. +It is the first npm release since 1.1.4: 1.1.5 through 1.5.0 below were +never published on their own and ship here, together with the fixes from +the 1.0 cutover review (#151, remediated in #152). + +**Security.** +- **ADR 0002 I3: the CLI signs only what it previewed.** `es approve` + binds the previewed subject hash and refuses a preview older than + 120 s; TUI preview tickets are redeemed once and expire after 120 s. +- **#143: CLI passphrase attempt limiting.** Every CLI human verb shares + the TUI's 5-per-10-minute limit per state dir, with audited lockouts. +- **Human-only verbs.** `es factory run` (a spend-bearing headless run) + and `es-fleet status --token` (the bus bearer token) are refused from + agent shells. +- **#99: no "human" agent name bypass.** Research tools trust a human + operator only when the CLI builds them with `operator: "human"`; an + agent named `human` gets the registry check like any other. +- **#111: one REPORT.md writer per run mode,** read from sealed run + state; when the mode cannot be read, neither seat may write it. +- **SSRF guard resolves DNS.** Every fetch hop, redirects included, + resolves its host and refuses when any address is non-routable (fail + closed); each hop gets its own timeout. +- **#135: secret values are scrubbed,** not just secret-named keys: + `NAME=value` pairs, `Bearer` credentials, known token shapes (dashed + `sk-proj-…` bodies included) and PEM private keys. +- **Control files.** `.factory/improve/**` is deny-write for agents; the + self-dogfood preset writes its control files atomically under lock, + then rebaselines and audits. +- **Fleet.** Request headers parse into a `Map` (CodeQL #84, prototype + pollution); package-boundary gaps closed (web→core and plugin→fleet + imports, inline `type` imports that still load at runtime). + +**Behaviour changes.** +- Headless turn metrics print only at `--log-level debug` or to + `--events jsonl`; default output is unchanged from before #120. +- `es-fleet start` asks for confirmation with `--foreground` too; a + background start waits for the daemon's pidfile before reporting. +- A seat whose configured model is missing on the host is refused on + every tool call, primaries included (#96). + +**New.** +- **#135: telemetry harvester over runs and evals.** Read-only + `es improve harvest --runs [,] [--evals [,]] --out ` + (agent-safe) producing the versioned `TelemetrySchema` dataset: phases + with replans and failure counts, halts with reasons, spend, gate + rejections by rule, audit findings by kind/severity, eval results with + `modelLimited`, and the audit-chain verification. A broken chain is + reported, never repaired; env-like values are scrubbed and state-dir + paths collapsed. Schemas render to `schemas/` and `docs/SCHEMAS.md`. +- **#136: distillation into reviewable proposals.** Deterministic + clustering keyed on (kind, label) with defaults of 3 occurrences across + 2 runs; each surviving cluster becomes one proposal under + `.factory/improve/proposals//` — `prompt-guidance` (a version-bumped + patch that passes `git apply --check` and the assets drift check), + `gate-tuning` (an explanation only; `gates.json` stays a human-applied + control file) or `domain-pack` (validated against `DomainPackSchema`). + The "code disposes" gate drops proposals whose evidence is not a + verbatim harvester quote or whose model numerals the telemetry never + recorded. `es improve distill --telemetry --out ` is + agent-safe; `--open-pr` is human-run (refused without a terminal) and + drafts from a topic branch without pushing the base. +- **#137: self-dogfood preset.** `es factory init --preset self-dogfood + --issue ` (human-only) seeds `.factory/` from versioned preset + assets — project config (`audit.phase: required`), the real merge gate + (`bun install --frozen-lockfile`, `bun run check`, `bun run docs:check`), + a roadmap seed pinning `baseBranch: rewrite`, and the issue as the idea + note — then reminds about `es trust`, the $15 spend ceiling and the + frontier/spec approvals. `docs/SELF-DOGFOOD.md` is the runbook + (prerequisites, flow, review, abort); one human-run validation session + to a draft PR is tracked on #137. +- **#138: release 1.6.0.** Version bump, changelog and status line; draft + release PR from `integration/phase-5` to `rewrite`. + +**Fixes.** +- **#122:** a plain `es-fleet start` now daemonizes; the detached child + used to fail the TTY check while the parent printed "starting". +- **#147:** a failed landing merge aborts only a real conflict and keeps + the original error otherwise. +- **Fleet worktrees:** `gc` can no longer remove a worktree mid-allocation, + and a second allocator of the same task refuses instead of dropping the + first one's record. +- **#137:** a preset run's release base is pinned end to end (the grill + cannot drop it, and the PR opener refuses `--base main`). +- **#120:** tool metrics carry `ms` and `seat`. +- **#130:** the web task page shows run liveness (headline, stage, seats, + research progress). +- **#136:** clusters key on (seat, failure kind, rule or category) with + evidence links; dropped proposals are reported with their reason. +- **#109:** the darkharvest grader reads the run the case produced; + loopback is allowed only for fixture-server eval cases. +- **#111:** `es research deep` and `/research deep` no longer send the + deep-researcher prompt twice. + +## 1.5.0 — 2026-10-09 + +Phase 4 web control plane: the fleet daemon serves a browser UI on +loopback behind a one-time URL — a fleet dashboard, frontier/spec +approvals in the browser (ADR 0002 option b), a preview-only config +editor, and an evidence explorer with range-highlighted quotes and seal +statuses. `packages/web` stays private and is served from the repo (the +fleet daemon is private too, so no asset-shipping decision is needed); +the published trio (core/CLI/plugin) release in lockstep as 1.5.0. + +**Security.** +- **#129: the web UI is localhost-only behind a single-use ticket + exchange.** `es-fleet web` (human-only, refused under `ES_SANDBOX`) + mints a 32-byte ticket (TTL 120 s, atomic-rename claim) that the first + load redeems for an `HttpOnly; SameSite=Strict` session cookie; wrong + Host (DNS-rebinding defense) and non-loopback Origin are refused, and + every web response carries a strict CSP (`default-src 'self'`, no + inline scripts, `frame-ancestors 'none'`). +- **#131: browser approvals sign only what was previewed.** Preview + returns the subject, files and hashes with a single-use ticket bound + to `hashJson(subject)`; submit needs the session cookie, a loopback + Origin and the session CSRF token, then re-derives the subject + (409 on mismatch), unlocks the sealed key in-process, records with + channel `web`, and destroys the signer. Five wrong passphrases lock + the web surface with an audited `approval.lockout`; the passphrase + lives in one page-local variable and appears in no log, event, + response or state file (probed). The ADR 0002 adversarial-review + checklist for the browser path passes 9/9 applicable probes (posted + on #131); I8 stays user-confirmable (option b implemented). + Trust, waive, resume, key seal, config set and export are untouched + and stay terminal-only. +- **#133: hostile source text is inert.** Quotes render through text + nodes split by verified ranges into `` spans (never HTML + parsing, probed with `click', + } + const hostileInput: DossierRenderInput = { + ...input, + claims: [ + { + id: sha("9"), + tag: "VERIFIED", + statement: '', + status: "LIVE", + source: { sha256: sha("a"), quote: ' "quoted"' }, + }, + ], + sources: [hostile], + } + + test("hostile text and urls are inert in html", () => { + const html = renderDossierHtml(hostileInput) + expect(html).not.toContain("') + expect(md).not.toContain("[click](javascript:") + }) +}) + +describe("renderResearchRun", () => { + test("reads the run (report, dossier, retractions, cache) in either mode", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-render-")) + try { + const { SourceCache, researchSourcesDir } = await import("../src/research/cache.ts") + const { buildDossier, writeDossier } = await import("../src/claims/dossier.ts") + const { normalizeClaim } = await import("../src/claims/claim.ts") + const cache = new SourceCache(researchSourcesDir(dir), undefined) + const source = await cache.put({ + url: "https://example.test/q", + text: "Queues decouple workers from producers durably.", + provider: "fetch", + }) + const claim = normalizeClaim({ + tag: "VERIFIED", + statement: "Queues decouple workers.", + source: { sha256: source.meta.sha256, quote: "decouple workers from producers" }, + }) + await mkdir(path.join(dir, ".factory/research"), { recursive: true }) + await writeFile(path.join(dir, ".factory/research/REPORT.md"), "# R\n\nQueues win.\n") + await writeDossier( + factoryLayout(dir).researchDossier, + buildDossier({ mode: "research", subject: "queues?", claims: [claim] }), + { cache }, + ) + const md = await renderResearchRun(dir, { format: "md" }) + expect(md).toContain("Queues win.") + expect(md).toContain("Queues decouple workers.") + expect(md).toContain(source.meta.sha256.slice(0, 12)) + const html = await renderResearchRun(dir, { format: "html" }) + expect(html).toContain("") + expect(html).toContain("Queues decouple workers.") + // A source pruned after the dossier was written is shown, never hidden. + const { SourceCache: Cache, researchSourcesDir: sourcesDir } = await import("../src/research/cache.ts") + await new Cache(sourcesDir(dir), undefined).prune(new Set()) + expect(await renderResearchRun(dir, { format: "md" })).toContain("not in the cache") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("missing report or dossier is a readable error", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-render-empty-")) + try { + await expect(renderResearchRun(dir, { format: "md" })).rejects.toThrow(/No research report/) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/research.test.ts b/packages/core/test/research.test.ts index 68831e7..818a1c1 100644 --- a/packages/core/test/research.test.ts +++ b/packages/core/test/research.test.ts @@ -4,14 +4,23 @@ import { tmpdir } from "node:os" import path from "node:path" import { auditMarkdown, + BackendError, braveProvider, canonicalUrl, + directBackend, + fetchPage, firecrawlProvider, + type HostResolver, htmlToText, isFresh, + locateQuote, + normalizeForQuote, + normalizeWithMap, parseTags, pruneClaims, researchTools, + runFetchChain, + type SourceBackend, SourceCache, searxngProvider, selectProvider, @@ -32,6 +41,18 @@ afterEach(() => Promise.all([rm(dir, { recursive: true, force: true }), rm(state const SOURCE = "Bun is a fast all-in-one JavaScript runtime.\nIt ships a test runner, a bundler, and a package manager — all built in." +/** + * Direct fetcher with DNS pinned to public answers. The SSRF guard fails + * closed on real lookups, so fake-host tests stay hermetic through this + * chain entry instead of the default direct backend. + */ +const hermeticDirect = (resolveHost: HostResolver = async () => ["93.184.216.34"]): SourceBackend => ({ + id: "direct", + capabilities: { search: false, fetch: true }, + available: () => true, + fetch: (url, options) => fetchPage(url, { ...options, resolveHost }), +}) + describe("verbatim quotes", () => { test("exact after whitespace and typography normalisation; ellipsis fragments in order", () => { expect(verifyQuote(SOURCE, "fast all-in-one JavaScript runtime").ok).toBe(true) @@ -51,6 +72,45 @@ describe("verbatim quotes", () => { expect(verifyQuote(SOURCE, "Bun is a fast ... manager").ok).toBe(false) expect(verifyQuote(SOURCE, "Bun is a fast all-in-one ... a package manager").ok).toBe(true) }) + + test("locateQuote returns original-text ranges that normalise to the fragments", () => { + const single = locateQuote(SOURCE, "fast all-in-one JavaScript runtime") + expect(single).toHaveLength(1) + expect(SOURCE.slice(single![0]!.start, single![0]!.end)).toBe("fast all-in-one JavaScript runtime") + const curly = locateQuote("He said “hello world, friends”", 'said "hello world, friends"') + expect(curly).toHaveLength(1) + const curlySlice = "He said “hello world, friends”".slice(curly![0]!.start, curly![0]!.end) + expect(curlySlice).toContain("“hello world, friends”") + expect(normalizeForQuote(curlySlice)).toBe('said "hello world, friends"') + const linked = locateQuote("[Bun](https://bun.sh) is fast and furious", "Bun is fast and furious") + expect(linked).toHaveLength(2) + expect("[Bun](https://bun.sh) is fast and furious".slice(linked![0]!.start, linked![0]!.end)).toBe("Bun") + expect("[Bun](https://bun.sh) is fast and furious".slice(linked![1]!.start, linked![1]!.end)).toBe( + " is fast and furious", + ) + const two = locateQuote(SOURCE, "fast all-in-one ... a package manager") + expect(two).toHaveLength(2) + expect(two![0]!.end).toBeLessThanOrEqual(two![1]!.start) + for (const range of two!) expect(SOURCE.slice(range.start, range.end).length).toBeGreaterThan(0) + }) + + test("locateQuote refuses what verifyQuote refuses", () => { + expect(locateQuote(SOURCE, "Bun")).toBeUndefined() + expect(locateQuote(SOURCE, "a slow runtime for JavaScript")).toBeUndefined() + expect(locateQuote(SOURCE, "a package manager ... fast all-in-one")).toBeUndefined() + }) + + test("normalizeWithMap agrees with normalizeForQuote character for character", () => { + const cases = [ + SOURCE, + "He said “hello” — yes… really", + "[Bun](https://bun.sh) is **fast** and `lean`", + " spaced\t\tout\n\nlines ", + "NBSP here and fullwidth", + "café filigree", + ] + for (const text of cases) expect(normalizeWithMap(text).text).toBe(normalizeForQuote(text)) + }) }) describe("source cache", () => { @@ -196,6 +256,7 @@ describe("providers", () => { stateDir: state, policy: async () => ({ root: dir }), fetch: (async () => new Response(html, { headers: { "content-type": "text/html" } })) as unknown as typeof fetch, + chain: [hermeticDirect()], }) const fetchTool = tools.find((tool) => tool.name === "es_research_fetch")! const text = await fetchTool.execute({ url: "https://zod.test" }, { agent: "es-research-alpha" }) @@ -248,6 +309,7 @@ describe("the webcache gate (d66328c:skills/epistemic_search/scripts/webcache.py headers: { "content-type": "text/html" }, }) }) as unknown as typeof fetch, + chain: [hermeticDirect()], }) const fetchTool = tools.find((tool) => tool.name === "es_research_fetch")! const first = await fetchTool.execute({ url: "https://Limits.test/api?b=2&a=1" }, { agent: "es-research-alpha" }) @@ -261,10 +323,15 @@ describe("the webcache gate (d66328c:skills/epistemic_search/scripts/webcache.py expect(second).toContain("Version 1 of the page") expect(calls).toBe(1) expect( - await fetchTool.execute({ url: "https://limits.test/api?a=1&b=2", refresh: true }, { agent: "x" }), + await fetchTool.execute( + { url: "https://limits.test/api?a=1&b=2", refresh: true }, + { agent: "es-research-alpha" }, + ), ).toContain("Version 2") now = new Date("2026-10-20T00:00:00Z") - expect(await fetchTool.execute({ url: "https://limits.test/api?a=1&b=2" }, { agent: "x" })).toContain("Version 3") + expect( + await fetchTool.execute({ url: "https://limits.test/api?a=1&b=2" }, { agent: "es-research-alpha" }), + ).toContain("Version 3") expect(calls).toBe(3) // A search result cached under the page's URL is a snippet, never served as the page. await new SourceCache(path.join(dir, ".factory/research/sources")).put({ @@ -272,6 +339,355 @@ describe("the webcache gate (d66328c:skills/epistemic_search/scripts/webcache.py text: "Snippet: limits are documented.", provider: "searxng", }) - expect(await fetchTool.execute({ url: "https://limits.test/api?a=1&b=2" }, { agent: "x" })).toContain("Version 4") + expect( + await fetchTool.execute({ url: "https://limits.test/api?a=1&b=2" }, { agent: "es-research-alpha" }), + ).toContain("Version 4") + }) +}) + +describe("research seat checks (#99: the registry grants, not the caller, decide)", () => { + const tools = () => + researchTools({ + root: dir, + env: {}, + stateDir: state, + policy: async () => ({ root: dir }), + }) + const tool = (name: string) => tools().find((item) => item.name === name)! + + test("search and fetch refuse seats the registry never grants; granted seats pass the check", async () => { + const search = tool("es_research_search") + const fetch = tool("es_research_fetch") + // The factory seat may audit but never search or fetch. + await expect(search.execute({ query: "x" }, { agent: "factory" })).rejects.toThrow( + "Only the research and scout seats", + ) + await expect(fetch.execute({ url: "https://x.test" }, { agent: "factory" })).rejects.toThrow( + "Only the research and scout seats", + ) + await expect(fetch.execute({ url: "https://x.test" }, { agent: "es-programmer" })).rejects.toThrow( + "Only the research and scout seats", + ) + // A granted seat passes the seat check (then fails on the missing provider, before any fetch). + await expect(search.execute({ query: "x" }, { agent: "es-research-alpha" })).rejects.toThrow("No search provider") + await expect(search.execute({ query: "x" }, { agent: "scout" })).rejects.toThrow("No search provider") + }) + + test("audit refuses seats the registry never grants; granted seats pass the check", async () => { + const audit = tool("es_research_audit") + await expect(audit.execute({}, { agent: "scout" })).rejects.toThrow("Only the research seats and factory") + await expect(audit.execute({}, { agent: "es-programmer" })).rejects.toThrow("Only the research seats and factory") + // A granted seat passes the seat check (then reports the missing report, not a refusal). + expect(await audit.execute({}, { agent: "factory" })).toContain("does not exist yet") + expect(await audit.execute({}, { agent: "es-research-beta" })).toContain("does not exist yet") + }) +}) + +describe("direct-backend SSRF guard (fail closed, never cached)", () => { + const countingFetch = (respond: (url: string) => Response, calls: { count: number }) => + (async (url: string) => { + calls.count++ + return respond(String(url)) + }) as unknown as typeof fetch + + test("fetchPage refuses loopback, link-local and RFC1918 URLs without fetching", async () => { + const internal = [ + "http://169.254.169.254/latest/meta-data/", + "http://127.0.0.1/admin", + "http://10.0.0.5/secret", + "http://192.168.1.10:8080/secret", + "http://172.16.4.9/secret", + "http://localhost/secret", + "http://[::1]/secret", + ] + for (const url of internal) { + const calls = { count: 0 } + const failure = await fetchPage(url, { fetch: countingFetch(() => new Response("x"), calls) }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect([url, (failure as { code?: string })?.code]).toEqual([url, "blocked"]) + expect([url, calls.count]).toEqual([url, 0]) + } + }) + + test("fetching the metadata URL via direct throws blocked and caches nothing", async () => { + const calls = { count: 0 } + const fetch = countingFetch( + () => new Response("

metadata

", { headers: { "content-type": "text/html" } }), + calls, + ) + const failure = await runFetchChain([directBackend], "http://169.254.169.254/latest/meta-data/", { + fetch, + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(BackendError) + expect((failure as BackendError).kind).toBe("blocked") + expect(calls.count).toBe(0) + }) + + test("the loopback allowance admits fixture servers but never the metadata address", async () => { + const ok = { count: 0 } + const page = await fetchPage("http://127.0.0.1:18381/a", { + fetch: countingFetch(() => new Response("

fixture

", { headers: { "content-type": "text/html" } }), ok), + env: { ES_RESEARCH_ALLOW_LOOPBACK: "1" }, + }) + expect(page.text).toContain("fixture") + expect(ok.count).toBe(1) + // Link-local stays refused even with the allowance on, and nothing is fetched. + const refused = { count: 0 } + const failure = await fetchPage("http://169.254.169.254/latest/meta-data/", { + fetch: countingFetch(() => new Response("

metadata

"), refused), + env: { ES_RESEARCH_ALLOW_LOOPBACK: "1" }, + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect((failure as { code?: string })?.code).toBe("blocked") + expect(refused.count).toBe(0) + }) + + test("a blocked refusal from an earlier backend stops the chain before direct is attempted", async () => { + const calls = { count: 0 } + const denyAll: SourceBackend = { + id: "deny", + capabilities: { search: false, fetch: true }, + available: () => true, + fetch: async () => { + throw Object.assign(new Error("denied by policy"), { code: "blocked" }) + }, + } + const failure = await runFetchChain([denyAll, directBackend], "https://example.com/page", { + fetch: countingFetch(() => new Response("

ok

", { headers: { "content-type": "text/html" } }), calls), + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(BackendError) + expect((failure as BackendError).kind).toBe("blocked") + expect(calls.count).toBe(0) + }) + + test("a redirect onto an internal address is refused and never fetched or cached", async () => { + const seen: string[] = [] + const fetch = (async (url: string) => { + seen.push(String(url)) + return new Response("

go inward

", { + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data/", "content-type": "text/html" }, + }) + }) as unknown as typeof fetch + const failure = await fetchPage("https://example.com/out", { + fetch, + resolveHost: async () => ["93.184.216.34"], + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect((failure as { code?: string })?.code).toBe("blocked") + expect(seen).toEqual(["https://example.com/out"]) + }) + + test("the fetch tool refuses a metadata URL without fetching or caching it", async () => { + const calls = { count: 0 } + const tools = researchTools({ + root: dir, + env: {}, + stateDir: state, + policy: async () => ({ root: dir }), + fetch: countingFetch(() => new Response("

metadata

", { headers: { "content-type": "text/html" } }), calls), + }) + const fetchTool = tools.find((tool) => tool.name === "es_research_fetch")! + await expect( + fetchTool.execute({ url: "http://169.254.169.254/latest/meta-data/" }, { agent: "es-research-alpha" }), + ).rejects.toThrow(/non-routable|blocked|SSRF/i) + expect(calls.count).toBe(0) + expect( + await new SourceCache(path.join(dir, ".factory/research/sources"), state).byUrl( + "http://169.254.169.254/latest/meta-data/", + ), + ).toBeUndefined() + }) +}) + +describe("DNS-resolving SSRF guard (S4.1: names, not just literal IPs)", () => { + const page = (text: string) => new Response(`

${text}

`, { headers: { "content-type": "text/html" } }) + const countingFetch = (respond: (url: string) => Response, calls: { count: number }) => + (async (url: string) => { + calls.count++ + return respond(String(url)) + }) as unknown as typeof fetch + const refused = (error: unknown) => (error as { code?: string })?.code === "blocked" + + test("a name resolving to link-local is refused before any fetch", async () => { + const calls = { count: 0 } + const failure = await fetchPage("http://metadata.test/latest/meta-data/", { + fetch: countingFetch(() => page("metadata"), calls), + resolveHost: async () => ["169.254.169.254"], + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect(refused(failure)).toBe(true) + expect(calls.count).toBe(0) + }) + + test("a name with any blocked address is refused, even beside a public one", async () => { + const calls = { count: 0 } + const failure = await fetchPage("http://mixed.test/", { + fetch: countingFetch(() => page("mixed"), calls), + resolveHost: async () => ["93.184.216.34", "10.0.0.1"], + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect(refused(failure)).toBe(true) + expect(calls.count).toBe(0) + }) + + test("a redirect onto a name resolving to RFC1918 is refused and never fetched", async () => { + const seen: string[] = [] + const fetch = (async (url: string) => { + seen.push(String(url)) + if (String(url) === "https://outer.test/out") + return new Response("

go inward

", { + status: 302, + headers: { location: "http://inner.test/secret", "content-type": "text/html" }, + }) + return page("inner") + }) as unknown as typeof fetch + const failure = await fetchPage("https://outer.test/out", { + fetch, + resolveHost: async (host: string) => (host === "inner.test" ? ["10.1.2.3"] : ["93.184.216.34"]), + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect(refused(failure)).toBe(true) + expect(seen).toEqual(["https://outer.test/out"]) + }) + + test("a public-only name is allowed", async () => { + const calls = { count: 0 } + const fetched = await fetchPage("https://public.test/page", { + fetch: countingFetch(() => page("public page body"), calls), + resolveHost: async () => ["93.184.216.34"], + }) + expect(fetched.text).toContain("public page body") + expect(calls.count).toBe(1) + }) + + test("a failed lookup fails closed without fetching", async () => { + const calls = { count: 0 } + const failure = await fetchPage("https://ghost.test/", { + fetch: countingFetch(() => page("ghost"), calls), + resolveHost: async () => { + throw new Error("ENOTFOUND ghost.test") + }, + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect(refused(failure)).toBe(true) + expect(calls.count).toBe(0) + }) + + test("the loopback allowance admits only loopback names", async () => { + const allow = { ES_RESEARCH_ALLOW_LOOPBACK: "1" } + const ok = { count: 0 } + const loopback = await fetchPage("http://loop.test/a", { + fetch: countingFetch(() => page("loopback fixture"), ok), + env: allow, + resolveHost: async () => ["127.0.0.1"], + }) + expect(loopback.text).toContain("loopback fixture") + expect(ok.count).toBe(1) + // RFC1918 stays refused with the allowance on; loopback is refused without it. + const lan = { count: 0 } + const lanFailure = await fetchPage("http://lan.test/a", { + fetch: countingFetch(() => page("lan"), lan), + env: allow, + resolveHost: async () => ["10.0.0.1"], + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect(refused(lanFailure)).toBe(true) + expect(lan.count).toBe(0) + const plain = { count: 0 } + const plainFailure = await fetchPage("http://loop.test/a", { + fetch: countingFetch(() => page("loopback fixture"), plain), + env: {}, + resolveHost: async () => ["127.0.0.1"], + }).then( + () => "no-throw", + (error: unknown) => error, + ) + expect(refused(plainFailure)).toBe(true) + expect(plain.count).toBe(0) + }) +}) + +describe("per-hop fetch budgets (S4.2: one timeout no longer spans every hop)", () => { + const delayed = (ms: number, respond: (url: string) => Response) => + (async (url: string, init?: RequestInit) => { + const signal = init?.signal + if (signal?.aborted) throw new DOMException("the operation was aborted", "AbortError") + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms) + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer) + reject(new DOMException("the operation was aborted", "AbortError")) + }, + { once: true }, + ) + }) + if (signal?.aborted) throw new DOMException("the operation was aborted", "AbortError") + return respond(String(url)) + }) as unknown as typeof fetch + const text = (body: string) => new Response(body, { headers: { "content-type": "text/plain" } }) + + test("a hop that exceeds its own budget aborts instead of borrowing the next hop's", async () => { + const fetch = delayed(200, () => text("slow page with enough text")) + await expect( + fetchPage("https://slow.test/page", { + fetch, + timeoutMs: 50, + resolveHost: async () => ["93.184.216.34"], + }), + ).rejects.toThrow(/abort|timeout/i) + }) + + test("a slow first hop doesn't starve the next hop's budget", async () => { + const fetch = (async (url: string, init?: RequestInit) => { + if (String(url).endsWith("/second")) return delayed(60, () => text("second hop page body"))(url, init) + return delayed( + 60, + () => new Response("go on", { status: 302, headers: { location: "https://slow.test/second" } }), + )(url, init) + }) as unknown as typeof fetch + // 60 ms + 60 ms exceeds a single 100 ms budget; each hop gets its own. + const fetched = await fetchPage("https://slow.test/first", { + fetch, + timeoutMs: 100, + resolveHost: async () => ["93.184.216.34"], + }) + expect(fetched.text).toContain("second hop page body") + }) + + test("a caller abort still aborts the fetch", async () => { + const controller = new AbortController() + controller.abort() + await expect( + fetchPage("https://public.test/page", { + fetch: delayed(10, () => text("never served")), + signal: controller.signal, + resolveHost: async () => ["93.184.216.34"], + }), + ).rejects.toThrow(/abort/i) }) }) diff --git a/packages/core/test/sandbox.test.ts b/packages/core/test/sandbox.test.ts index 3c0809d..43259bb 100644 --- a/packages/core/test/sandbox.test.ts +++ b/packages/core/test/sandbox.test.ts @@ -146,6 +146,9 @@ describe("sandboxArgv", () => { expect([kind, args.join(" ")]).toEqual([kind, expect.stringContaining("--tmpfs /h/.local/state/es")]) expect(args.join(" ")).toContain("--ro-bind /dev/null /h/.config/opencode/service.json") expect(args.join(" ")).toContain("--unsetenv OPENCODE_SERVER_PASSWORD") + // Provider and gateway API keys never reach a seat shell (#106, #107). + for (const secret of ["BRAVE_API_KEY", "FIRECRAWL_API_KEY", "ES_SCRAPER_SWARM_TOKEN"]) + expect([kind, secret, args.join(" ")]).toEqual([kind, secret, expect.stringContaining(`--unsetenv ${secret}`)]) expect([kind, args.includes("SSH_AUTH_SOCK")]).toEqual([kind, kind !== "user"]) } }) diff --git a/packages/core/test/scout.test.ts b/packages/core/test/scout.test.ts index 85622ba..1fa5304 100644 --- a/packages/core/test/scout.test.ts +++ b/packages/core/test/scout.test.ts @@ -209,7 +209,7 @@ describe("the scout tools", () => { test("a recorded claim must survive its witness; a fabricated quote refuses the record", async () => { const tool = tools() await planAll(tool) - const source = await new SourceCache(researchSourcesDir(root)).put({ + const source = await new SourceCache(researchSourcesDir(root), state).put({ url: "https://bench.test/mit", text: "mit handles ten million calls per second on a laptop.", provider: "fetch", diff --git a/packages/core/test/scraper-swarm.test.ts b/packages/core/test/scraper-swarm.test.ts new file mode 100644 index 0000000..ff15b1e --- /dev/null +++ b/packages/core/test/scraper-swarm.test.ts @@ -0,0 +1,244 @@ +// #107: Scraper-Swarm gateway contract tests. Every test replays a +// vendored golden fixture (test/fixtures/scraper-swarm/v1/, from +// scraper-swarm@74909b9) through a stub fetch: no gateway runs here. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { BackendError, getBackend, resolveBackendOrder } from "../src/research/backends/index.ts" +import { + SCRAPER_SWARM_BACKEND_ID, + SCRAPER_SWARM_CONTRACT_VERSION, + ScraperSwarmClient, + scraperSwarmBackend, +} from "../src/research/backends/scraper-swarm.ts" +import { SourceCache } from "../src/research/cache.ts" +import { researchTools } from "../src/research/ops.ts" + +const fixtures = path.join(import.meta.dir, "fixtures/scraper-swarm/v1") +const load = async (area: string, name: string) => + Bun.file(path.join(fixtures, area, `${name}.json`)).json() as Promise<{ + contract_version: number + request: unknown + response_status: number + response_headers: Record + response_body: unknown + }> + +/** A stub fetch replaying one fixture, asserting the request shape. */ +const replay = ( + fixture: { + request: unknown + response_status: number + response_headers: Record + response_body: unknown + }, + check?: (url: string, init: RequestInit) => void, + options: { skipName?: boolean } = {}, +) => + (async (url: string, init?: RequestInit) => { + check?.(String(url), init ?? {}) + const expected = fixture.request as { method?: string; params?: { name?: string } } + const body = JSON.parse(String((init as { body?: string })?.body ?? "{}")) as { + jsonrpc?: string + method?: string + params?: { name?: string } + } + expect(body.jsonrpc).toBe("2.0") + if (!options.skipName) { + expect(body.method).toBe(expected.method ?? "tools/call") + expect(body.params?.name).toBe(expected?.params?.name) + } + return new Response(JSON.stringify(fixture.response_body), { + status: fixture.response_status, + headers: { ...fixture.response_headers, "content-type": "application/json" }, + }) + }) as unknown as typeof fetch + +const env = { ES_SCRAPER_SWARM_URL: "https://swarm.test", ES_SCRAPER_SWARM_TOKEN: "swarm_sec_test" } +const client = ( + fixture: Awaited>, + check?: (url: string, init: RequestInit) => void, + options: { skipName?: boolean } = {}, +) => + new ScraperSwarmClient({ + url: env.ES_SCRAPER_SWARM_URL, + token: env.ES_SCRAPER_SWARM_TOKEN, + fetch: replay(fixture, check, options), + }) + +/** Exercise a gateway the way the backend would for the fixture's tool. */ +const exercise = (gateway: ScraperSwarmClient, tool: string): Promise => + tool === "web_search" + ? gateway.search("q", 3).then( + () => "no-throw", + (error: unknown) => error, + ) + : gateway.fetchPage("https://example.com/").then( + () => "no-throw", + (error: unknown) => error, + ) + +describe("fixture pin", () => { + test("every vendored fixture targets the client's contract major", async () => { + const { readdir } = await import("node:fs/promises") + const areas = await readdir(fixtures) + let count = 0 + for (const area of areas) { + if (area === "deep-research" || area === "stealth-scrape") continue + for (const file of await readdir(path.join(fixtures, area))) { + if (!file.endsWith(".json")) continue + const fixture = await load(area, file.replace(/\.json$/, "")) + expect([`${area}/${file}`, fixture.contract_version]).toEqual([ + `${area}/${file}`, + SCRAPER_SWARM_CONTRACT_VERSION, + ]) + count++ + } + } + expect(count).toBeGreaterThan(10) + }) +}) + +describe("gateway mappings", () => { + test("web_search maps structured results", async () => { + const results = await client(await load("web-search", "success")).search("recorded query", 2) + expect(results).toEqual([ + { url: "https://example.com/recorded-1", title: "Recorded One", snippet: "Recorded snippet one." }, + { url: "https://example.com/recorded-2", title: "Recorded Two", snippet: "Recorded snippet two." }, + ]) + }) + + test("fetch_page maps the page and verifies content_sha256 before returning", async () => { + const page = await client(await load("fetch-page", "md-success")).fetchPage("https://example.com/recorded") + expect(page).toMatchObject({ + url: "https://example.com/recorded", + title: "Recorded Page", + text: "# Recorded\n\nRecorded body text.", + contentType: "text/markdown", + }) + // A crawl fallback reports its final URL and a null content type. + const fallback = await client(await load("fetch-page", "crawl-fallback")).fetchPage( + "https://example.com/needs-crawl", + ) + expect(fallback).toMatchObject({ + url: "https://example.com/fallback-final", + title: "Fallback", + contentType: "text/markdown", + }) + // A tampered byte fails closed instead of caching. + const tampered = await load("fetch-page", "md-success") + const evil = { ...tampered, response_body: JSON.parse(JSON.stringify(tampered.response_body)) } as typeof tampered + const structured = (evil.response_body as { result: { structuredContent: { content: string } } }).result + .structuredContent + structured.content = `${structured.content}tampered` + await expect(client(evil).fetchPage("https://example.com/recorded")).rejects.toThrow(/content_sha256/) + }) + + test("the Authorization header carries the token; failures never leak it", async () => { + let seen = "" + await client(await load("web-search", "success"), (_url, init) => { + seen = String((init.headers as Record)?.Authorization ?? "") + }).search("q", 1) + expect(seen).toBe("Bearer swarm_sec_test") + const denied = client(await load("fetch-page", "ssrf-denied")) + const failure = await denied.fetchPage("http://169.254.169.254/").catch((error: Error) => error.message) + expect(failure).toContain("SSRF denied") + expect(failure).not.toContain("swarm_sec_test") + }) +}) + +describe("gateway errors", () => { + const kinds: Array<[string, string, string, boolean?]> = [ + ["fetch-page", "ssrf-denied", "blocked"], + ["fetch-page", "upstream-timeout", "timeout"], + ["fetch-page", "upstream-error", "upstream"], + ["fetch-page", "engine-unavailable", "upstream"], + ["web-search", "upstream-error", "upstream"], + ["tools-call", "scope-denied", "auth"], + ["tools-call", "invalid-params", "upstream"], + // The client never sends an unknown tool; the response mapping is what matters. + ["tools-call", "unknown-tool", "upstream", true], + ] + for (const [area, name, kind, skipName] of kinds) { + test(`${area}/${name} maps to ${kind}`, async () => { + const fixture = await load(area, name) + const tool = (fixture.request as { params?: { name?: string } }).params?.name ?? "fetch_page" + const failure = await exercise(client(fixture, undefined, { skipName }), tool) + expect(failure).toBeInstanceOf(BackendError) + expect((failure as BackendError).kind).toBe(kind) + }) + } + + test("429 maps to rate_limited with the Retry-After value", async () => { + const limited = await load("rate-limited", "rate-limited") + expect(limited.response_status).toBe(429) + expect(limited.response_headers["retry-after"]).toBeDefined() + const failure = await exercise(client(limited, undefined, { skipName: true }), "web_search") + expect(failure).toBeInstanceOf(BackendError) + expect((failure as BackendError).kind).toBe("rate_limited") + // The vendored header is normalized (presence, not value); a real value parses to ms. + const valued = { + ...limited, + response_headers: { ...limited.response_headers, "retry-after": "120" }, + } + const valuedFailure = (await exercise(client(valued, undefined, { skipName: true }), "web_search")) as BackendError + expect(valuedFailure.retryAfterMs).toBe(120_000) + }) + + test("an unknown contract major is refused with a clear message", async () => { + const fixture = await load("web-search", "success") + const future = { + ...fixture, + contract_version: 2, + response_headers: { ...fixture.response_headers, "x-swarm-contract": "2" }, + } + const failure = await client(future) + .search("q", 1) + .then( + () => "no-throw", + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(BackendError) + expect((failure as Error).message).toMatch(/contract/) + }) +}) + +describe("chain registration", () => { + test("the backend is available exactly with URL and token, and leads the default order", () => { + expect(scraperSwarmBackend.id).toBe(SCRAPER_SWARM_BACKEND_ID) + expect(scraperSwarmBackend.capabilities).toEqual({ search: true, fetch: true }) + expect(scraperSwarmBackend.available(env)).toBe(true) + expect(scraperSwarmBackend.available({ ...env, ES_SCRAPER_SWARM_TOKEN: "" })).toBe(false) + expect(scraperSwarmBackend.available({})).toBe(false) + expect(getBackend("scraper-swarm")?.id).toBe("scraper-swarm") + expect(resolveBackendOrder({})[0]).toBe("scraper-swarm") + expect(resolveBackendOrder({ provider: "brave" })).toEqual(["brave", "direct"]) + }) + + test("a seat's fetch through the gateway caches sealed provider scraper-swarm without the token", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-swarm-")) + const state = await mkdtemp(path.join(tmpdir(), "es-swarm-state-")) + try { + const tools = researchTools({ + root: dir, + env, + stateDir: state, + policy: async () => ({ root: dir }), + fetch: replay(await load("fetch-page", "md-success")), + }) + const fetchTool = tools.find((tool) => tool.name === "es_research_fetch")! + const text = await fetchTool.execute({ url: "https://example.com/recorded" }, { agent: "es-research-alpha" }) + const hash = /sha256:([0-9a-f]{64})/.exec(text)?.[1] + expect(hash).toBeDefined() + const stored = await new SourceCache(path.join(dir, ".factory/research/sources"), state).get(hash!) + expect(stored?.meta.provider).toBe("scraper-swarm") + expect(stored?.meta.seal).toMatch(/^[0-9a-f]{64}$/) + const metaRaw = await Bun.file(path.join(dir, ".factory/research/sources", `${hash}.json`)).text() + expect(metaRaw).not.toContain("swarm_sec_test") + } finally { + await rm(dir, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/source-seal.test.ts b/packages/core/test/source-seal.test.ts new file mode 100644 index 0000000..944d7ce --- /dev/null +++ b/packages/core/test/source-seal.test.ts @@ -0,0 +1,163 @@ +// #98 source-cache sealing: every cache construction goes through the sealed +// constructor, so planted unsealed entries are refused by scout witnessing, +// brief export and retract. Behavioural specs only (clean-room). +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { workspacePackages } from "../../../scripts/packages.ts" +import { type HumanSigner, sealHumanKey, unlockHumanKey } from "../src/approval/keystore.ts" +import { exportBrief } from "../src/claims/brief.ts" +import { normalizeClaim } from "../src/claims/claim.ts" +import { buildDossier, writeDossier } from "../src/claims/dossier.ts" +import { factoryLayout } from "../src/layout.ts" +import { normalizeSourceText, researchSourcesDir, SourceCache } from "../src/research/cache.ts" +import { scoutTools } from "../src/scout/ops.ts" +import { atomicWrite } from "../src/util/fs.ts" +import { sha256 } from "../src/util/hash.ts" + +let root: string +let state: string +let signer: HumanSigner +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "es-seal-")) + state = await mkdtemp(path.join(tmpdir(), "es-seal-state-")) + await sealHumanKey("test-passphrase-1234", state) + signer = await unlockHumanKey("test-passphrase-1234", state) +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) +}) + +const OSV = { + vulns: [ + { + id: "GHSA-test-0001", + summary: "Test advisory with enough detail to quote", + database_specific: { severity: "HIGH" }, + affected: [], + }, + ], +} +const osvFetch = (async () => Response.json(OSV)) as unknown as typeof fetch +const scout = { agent: "scout" } + +/** Plant an entry directly on disk with no engine seal (a pre-seeded forgery). */ +async function plantUnsealed(url: string, text: string): Promise { + const dir = researchSourcesDir(root) + await mkdir(dir, { recursive: true }) + const normal = normalizeSourceText(text) + const hash = sha256(normal) + await atomicWrite(path.join(dir, `${hash}.md`), normal) + await atomicWrite( + path.join(dir, `${hash}.json`), + `${JSON.stringify({ sha256: hash, url, retrieved: new Date().toISOString(), provider: "fetch", bytes: Buffer.byteLength(normal) })}\n`, + ) + return hash +} + +const sealed = () => new SourceCache(researchSourcesDir(root), state) + +describe("H1: scout advisories seal what they write", () => { + test("es_scout_advisories entries verify under a sealed reader", async () => { + const all = scoutTools({ root, stateDir: state, fetch: osvFetch }) + const tool = (name: string) => all.find((item) => item.name === name)! + await tool("es_scout_plan").execute( + { objective: "seal probe", candidates: [{ name: "probe", source: "npm:seal-probe" }] }, + scout, + ) + const out = await tool("es_scout_advisories").execute({ candidate: "probe" }, scout) + const hash = /sha256:([0-9a-f]{64})/.exec(String(out))![1]! + expect((await sealed().get(hash))?.meta.provider).toBe("osv") + }) +}) + +describe("H2: scout witnessing refuses planted entries", () => { + test("es_scout_record refuses a VERIFIED claim quoting a planted unsealed source", async () => { + const text = "The planted advisory claims ten million calls per second on a laptop." + const hash = await plantUnsealed("https://evil.test/planted", text) + expect(await sealed().get(hash)).toBeUndefined() + const all = scoutTools({ root, stateDir: state }) + const tool = (name: string) => all.find((item) => item.name === name)! + await tool("es_scout_plan").execute( + { objective: "seal probe", candidates: [{ name: "probe", source: "local:./candidate-a" }] }, + scout, + ) + await expect( + tool("es_scout_record").execute( + { + candidate: "probe", + maintenance: "stable", + proposal: "reject", + rationale: "planted evidence must not ground a record", + claims: [ + { + tag: "VERIFIED", + statement: "It is fast.", + source: { sha256: hash, quote: "ten million calls per second" }, + }, + ], + }, + scout, + ), + ).rejects.toThrow("failed their witness check") + }) +}) + +describe("H3: brief export refuses planted entries", () => { + test("exportBrief re-witnesses against the sealed cache: planted quotes fail", async () => { + const text = "Independent benchmark reached 9.5 GB/s throughput on reference hardware." + const hash = await plantUnsealed("https://evil.test/bench", text) + const legacy = new SourceCache(researchSourcesDir(root)) + const claim = normalizeClaim({ + tag: "VERIFIED", + statement: "The benchmark is reproducible.", + source: { sha256: hash, quote: "benchmark reached 9.5 GB/s throughput" }, + }) + await mkdir(factoryLayout(root).research, { recursive: true }) + await writeFile(factoryLayout(root).researchReport, "# R\n") + // A legacy dossier (written before sealing) witnesses fine unsealed. + await writeDossier( + factoryLayout(root).researchDossier, + buildDossier({ mode: "research", subject: "s", claims: [claim] }), + { + cache: legacy, + }, + ) + const { brief } = await exportBrief(root, { signer, stateDir: state }) + expect(brief.claims[0]!.witness.ok).toBe(false) + expect(brief.sources[hash]).toBeUndefined() + }) +}) + +describe("misuse guard: no unsealed SourceCache in shipped code", () => { + test("only research/cache.ts and researchCache construct the cache", async () => { + const repo = path.resolve(import.meta.dir, "../../..") + const hits: string[] = [] + const scanned: string[] = [] + const walk = async (dir: string) => { + scanned.push(path.relative(repo, dir)) + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) await walk(full) + else if (entry.name.endsWith(".ts")) { + const text = await readFile(full, "utf8") + if ( + text.includes("new SourceCache(") && + full !== path.join(repo, "packages/core/src/research/cache.ts") && + full !== path.join(repo, "packages/core/src/research/ops.ts") + ) + hits.push(path.relative(repo, full)) + } + } + } + // Every workspace package from scripts/packages.ts (#98): a hard-coded + // subset silently missed packages/fleet/src and packages/web/src. + const dirs = (await workspacePackages(repo)).map((pkg) => pkg.dir) + for (const dir of dirs) await walk(path.join(repo, "packages", dir, "src")).catch(() => undefined) + expect(dirs).toEqual(expect.arrayContaining(["core", "fleet", "web"])) + expect(scanned).toEqual(expect.arrayContaining(["packages/fleet/src", "packages/web/src"])) + expect(hits).toEqual([]) + }) +}) diff --git a/packages/fleet/bin/es-fleet.js b/packages/fleet/bin/es-fleet.js new file mode 100755 index 0000000..83a385d --- /dev/null +++ b/packages/fleet/bin/es-fleet.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node +// Launcher: Bun runs the TypeScript sources directly; Node runs the bundled build. +const entry = typeof Bun !== "undefined" ? "../src/bin.ts" : "../dist/bin.js" +const { main } = await import(new URL(entry, import.meta.url).href) +process.exitCode = await main(process.argv.slice(2)) diff --git a/packages/fleet/package.json b/packages/fleet/package.json new file mode 100644 index 0000000..27d7f0c --- /dev/null +++ b/packages/fleet/package.json @@ -0,0 +1,47 @@ +{ + "name": "@heretek-ai/es-fleet", + "version": "0.1.0", + "private": true, + "description": "Epistemic Swarm fleet daemon: concurrent task DAGs in isolated worktrees with per-task spend ceilings (human-only; ships from the repo only until its RPC is stable).", + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Heretek-AI/IUMBTEMS.git", + "directory": "packages/fleet" + }, + "bin": { + "es-fleet": "./bin/es-fleet.js" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "bun": "./src/index.ts", + "default": "./dist/index.js" + }, + "./*": { + "types": "./src/*.ts", + "bun": "./src/*.ts", + "default": "./dist/*.js" + } + }, + "files": [ + "bin", + "src", + "dist" + ], + "scripts": { + "build": "tsc -b", + "test": "bun test" + }, + "dependencies": { + "@heretek-ai/es-core": "workspace:*", + "zod": "4.6.5" + }, + "devDependencies": { + "@heretek-ai/es-testkit": "workspace:*" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/fleet/src/approve.ts b/packages/fleet/src/approve.ts new file mode 100644 index 0000000..1cda2f4 --- /dev/null +++ b/packages/fleet/src/approve.ts @@ -0,0 +1,63 @@ +// Browser approval tickets and CSRF binding (#131, ADR 0002 option b). +// Preview tickets are single-use and TTL-bound (I3): redeeming deletes the +// ticket whether it validates or not, so a failed attempt cannot be +// retried on the same ticket — the human previews again. CSRF tokens bind +// to the session cookie created by the #129 ticket exchange; the approve +// endpoint requires cookie + loopback Origin + matching CSRF (I6). +import { randomBytes, timingSafeEqual } from "node:crypto" + +/** Preview tickets live this long (I3: TTL ≤ 120 s). */ +export const APPROVE_TICKET_TTL_MS = 120_000 + +export interface ApproveTicket { + readonly repoDir: string + readonly stage: "frontier" | "spec" + readonly subjectHash: string +} + +export class ApproveTickets { + private readonly live = new Map() + + constructor(private readonly ttlMs: number = APPROVE_TICKET_TTL_MS) {} + + issue(entry: ApproveTicket): string { + this.purge() + const ticket = randomBytes(32).toString("hex") + this.live.set(ticket, { entry, exp: Date.now() + this.ttlMs }) + return ticket + } + + /** Single-use claim: the ticket dies on redeem, valid or not. */ + redeem(ticket: string): ApproveTicket | undefined { + const found = this.live.get(ticket) + this.live.delete(ticket) + if (!found || found.exp <= Date.now()) return undefined + return found.entry + } + + private purge(): void { + const now = Date.now() + for (const [ticket, record] of this.live) if (record.exp <= now) this.live.delete(ticket) + } +} + +/** Per-session CSRF tokens for the approve endpoint (I6). */ +export class CsrfTokens { + private readonly live = new Map() + + forSession(sessionId: string): string { + const existing = this.live.get(sessionId) + if (existing) return existing + const token = randomBytes(32).toString("hex") + this.live.set(sessionId, token) + return token + } + + check(sessionId: string, presented: string | undefined): boolean { + const expected = this.live.get(sessionId) + if (!expected || !presented) return false + const a = Buffer.from(presented) + const b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) + } +} diff --git a/packages/fleet/src/bin.ts b/packages/fleet/src/bin.ts new file mode 100644 index 0000000..2f19794 --- /dev/null +++ b/packages/fleet/src/bin.ts @@ -0,0 +1,400 @@ +// `es-fleet`: the human-only fleet daemon CLI (#122). `start` and `stop` +// refuse agent sandboxes outright and `start` additionally requires an +// interactive terminal plus an explicit confirmation, because a running +// fleet spends money. `status` is read-only and agent-safe. `web` (#129) +// prints a one-time browser URL for the local control plane. +import { spawn } from "node:child_process" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { createInterface } from "node:readline" +import { stateDir } from "@heretek-ai/es-core" +import { FleetDaemon } from "./daemon.ts" +import { assertHumanStart, FleetError, fleetStatus, startFleet, stopFleet } from "./lifecycle.ts" +import { reportTask } from "./scheduler.ts" +import { fleetPaths } from "./state.ts" +import { addTask, emptyDag, loadDag, saveDag, TERMINAL } from "./tasks.ts" +import { ensureToken, readTelemetryEndpoint, TelemetryServer } from "./telemetry.ts" +import { readFleetSnapshot, watchFleet } from "./watch.ts" +import { mintWebTicket } from "./web.ts" +import { defaultGateCheck } from "./worker.ts" +import { gcWorktrees } from "./worktree.ts" + +export const VERSION = "0.1.0" + +/** + * Start-gate decision (#122, repair-151 S5.2, pure and tested): the parent + * does `assertHumanStart`, the TTY check and the confirmation, then spawns + * the child with an IPC channel and a one-time start grant. A child without + * a TTY runs only with that grant — no env var or flag grants it. + */ +export type StartDecision = + | { readonly action: "run" } + | { readonly action: "daemonize" } + | { readonly action: "declined" } + | { readonly action: "refuse"; readonly reason: string } + +export function startDecision(input: { + readonly isTTY: boolean + readonly foreground: boolean + readonly grant: boolean + readonly confirm: boolean +}): StartDecision { + if (!input.isTTY && !input.grant) + return { + action: "refuse", + reason: + "Refusing to start the fleet without an interactive terminal: run `es-fleet start` yourself; agents cannot start it.", + } + // `--foreground` also asks for the confirmation: spending money always does. + if (input.isTTY && !input.confirm) return { action: "declined" } + if (!input.foreground && input.isTTY) return { action: "daemonize" } + return { action: "run" } +} + +/** IPC message type carrying the one-time parent-to-child start grant. */ +export const START_GRANT_TYPE = "es-fleet-start-grant" as const +/** IPC message type carrying the child's pidfile-written report to the parent. */ +export const STARTED_TYPE = "es-fleet-started" as const +/** How long a TTY-less child waits for the parent's grant. */ +export const START_GRANT_TIMEOUT_MS = 5_000 +/** How long the parent waits for the child's pidfile-written report. */ +export const STARTED_TIMEOUT_MS = 30_000 + +/** + * Await the parent's one-time start grant over the IPC channel. Rejects + * immediately without an IPC channel, and after START_GRANT_TIMEOUT_MS + * without a grant. Nothing else (no env var, no flag) grants a start. + */ +export function awaitStartGrant(timeoutMs = START_GRANT_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + if (!process.connected) + return reject( + new FleetError( + "Refusing to start the fleet without an interactive terminal: run `es-fleet start` yourself; agents cannot start it.", + ), + ) + const timer = setTimeout(() => { + process.removeListener("message", onMessage) + reject(new FleetError("Timed out waiting for the fleet start grant from the parent; refusing to start.")) + }, timeoutMs) + const onMessage = (message: unknown) => { + if ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === START_GRANT_TYPE + ) { + clearTimeout(timer) + process.removeListener("message", onMessage) + resolve() + } + } + process.on("message", onMessage) + }) +} + +/** True when the parent's one-time start grant arrives in time (never throws). */ +async function receiveGrant(): Promise { + try { + await awaitStartGrant() + return true + } catch { + return false + } +} + +/** Wait for the daemonized child's pidfile-written report (or its failure). */ +async function awaitChildStarted( + child: import("node:child_process").ChildProcess, + timeoutMs = STARTED_TIMEOUT_MS, +): Promise<{ ok: true; pid: number } | { ok: false; reason: string }> { + return new Promise((resolve) => { + const timer = setTimeout(() => { + cleanup() + resolve({ ok: false, reason: "timed out waiting for the daemon child to report it started" }) + }, timeoutMs) + const cleanup = () => { + clearTimeout(timer) + child.removeListener("message", onMessage) + child.removeListener("error", onError) + child.removeListener("exit", onExit) + } + const onMessage = (message: unknown) => { + if (typeof message === "object" && message !== null && (message as { type?: unknown }).type === STARTED_TYPE) { + const pid = (message as { pid?: unknown }).pid + cleanup() + resolve({ ok: true, pid: typeof pid === "number" ? pid : (child.pid ?? -1) }) + } + } + const onError = (error: Error) => { + cleanup() + resolve({ ok: false, reason: error.message }) + } + const onExit = (code: number | null, signal: string | null) => { + cleanup() + resolve({ + ok: false, + reason: `the daemon child exited (code ${code}, signal ${signal}) before reporting it started`, + }) + } + child.on("message", onMessage) + child.on("error", onError) + child.on("exit", onExit) + }) +} + +/** + * Read the bus bearer token for `es-fleet status --token` (#126, S5.5). + * Human-only through the TTY gate: agents run without one, and the state + * dir is masked inside their sandboxes anyway. + */ +export async function readStatusToken(stateRoot: string, options: { isTTY: boolean }): Promise { + if (!options.isTTY) + throw new FleetError( + "Refusing to print the fleet token without an interactive terminal: run `es-fleet status --token` yourself; agents cannot read it.", + ) + let token = "" + try { + token = (await readFile(fleetPaths(stateRoot).token, "utf8")).trim() + } catch { + // Missing file: reported below. + } + if (token.length < 32) throw new FleetError("No fleet token yet: start the fleet first with `es-fleet start`.") + return token +} + +const HELP = `es-fleet ${VERSION} — Epistemic Swarm fleet daemon (human-only, except status) + +Usage: + es-fleet start --max-usd [--concurrency ] [--foreground] start the daemon (TTY + confirmation) + [--repo ] [--es-bin ] [--port ] [--interval-ms ] + es-fleet stop stop the daemon + es-fleet status [--json] [--token] show daemon status (agent-safe, except --token) + es-fleet task add --file add a task from a JSON file (human-only) + es-fleet task list [--json] list tasks + es-fleet task cancel [--reason ] cancel a task (human-only) + es-fleet gc [--repo ] remove orphaned worktrees (human-only) + es-fleet watch live dashboard over the telemetry bus + es-fleet web print a one-time browser URL for the web UI (human-only) + +Options: + --state-dir override the user state dir (default: es state dir) + --help show this help +` + +const valueFlag = (args: string[], names: readonly string[]): string | undefined => { + for (let i = 0; i < args.length; i++) { + for (const name of names) { + if (args[i] === name) return args[i + 1] + if (args[i]?.startsWith(`${name}=`)) return args[i]!.slice(name.length + 1) + } + } + return undefined +} +const hasFlag = (args: string[], name: string): boolean => args.includes(name) + +const confirm = (question: string): Promise => + new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + rl.question(question, (answer) => { + rl.close() + resolve(/^y(es)?$/i.test(answer.trim())) + }) + }) + +export async function main(argv: readonly string[]): Promise { + const args = [...argv] + if (hasFlag(args, "--help") || args.length === 0) { + process.stdout.write(HELP) + return 0 + } + const [command, ...rest] = args + const root = valueFlag(args, ["--state-dir"]) ?? stateDir() + try { + switch (command) { + case "start": { + assertHumanStart(process.env) + const foreground = hasFlag(rest, "--foreground") + const isTTY = !!process.stdin.isTTY && !!process.stdout.isTTY + // A daemonized child carries no TTY: it runs only on the parent's + // one-time IPC grant (no env var or flag grants it). + const grant = isTTY ? false : await receiveGrant() + const maxUsd = Number(valueFlag(rest, ["--max-usd"])) + const concurrency = Number(valueFlag(rest, ["--concurrency"]) ?? "4") + // Spending money always asks: background and --foreground alike. + const confirmed = isTTY + ? await confirm( + `Start the fleet daemon (ceiling $${valueFlag(rest, ["--max-usd"]) ?? "?"}, concurrency ${Number.isFinite(concurrency) ? concurrency : "?"} Workers run headless factory runs that spend real money. [y/N] `, + ) + : false + const decision = startDecision({ isTTY, foreground, grant, confirm: confirmed }) + if (decision.action === "refuse") throw new FleetError(decision.reason) + if (decision.action === "declined") { + process.stdout.write("Not starting.\n") + return 1 + } + if (decision.action === "daemonize") { + // Daemonize: respawn detached with an IPC channel, grant the one + // start, and wait for the child's pidfile-written report before + // claiming anything — the child's error is printed instead. + const child = spawn(process.execPath, [process.argv[1]!, ...args, "--foreground"], { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + env: process.env, + }) + child.send({ type: START_GRANT_TYPE }) + const started = await awaitChildStarted(child) + child.disconnect() + child.unref() + if (!started.ok) throw new FleetError(`The fleet failed to start: ${started.reason}`) + process.stdout.write(`Fleet daemon started (pid ${started.pid}).\n`) + return 0 + } + const handle = await startFleet({ stateRoot: root, maxUsd, concurrency }) + process.send?.({ type: STARTED_TYPE, pid: handle.pid, startedAt: handle.startedAt }) + process.stdout.write( + `Fleet daemon running (pid ${handle.pid}, ceiling $${maxUsd}, started ${handle.startedAt}).\n`, + ) + // Own the task loop: recover workers, then tick until signalled. + const esBin = valueFlag(rest, ["--es-bin"]) ?? "es" + const token = await ensureToken(root) + const repoRoot = valueFlag(rest, ["--repo"]) ?? process.cwd() + const webRoot = valueFlag(rest, ["--web-root"]) ?? path.join(repoRoot, "packages", "web", "dist") + const bus = new TelemetryServer({ + stateRoot: root, + port: Number(valueFlag(rest, ["--port"]) ?? "0"), + token, + getSnapshot: () => readFleetSnapshot(root), + webRoot, + configRoot: repoRoot, + }) + const daemon = new FleetDaemon({ + repoRoot, + stateRoot: root, + esBin, + ...(Number.isFinite(concurrency) ? { concurrency } : {}), + fleetCeilingUsd: maxUsd, + checkGates: defaultGateCheck(esBin), + onTransition: (event) => + void bus.log.appendSync("changed", { taskId: event.taskId, kind: event.type, detail: event.detail ?? "" }), + }) + await daemon.recover() + await bus.start() + process.stdout.write(`Telemetry bus on 127.0.0.1:${bus.port} (token in /fleet/token).\n`) + await daemon.tick() + const everyMs = Math.max(1_000, Number(valueFlag(rest, ["--interval-ms"]) ?? "5000")) + const timer = setInterval( + () => void daemon.tick().catch((error) => process.stderr.write(`${String(error)}\n`)), + everyMs, + ) + timer.unref?.() + const shutdown = async () => { + clearInterval(timer) + await daemon.shutdown() + await bus.stop() + await handle.close() + process.exit(0) + } + process.once("SIGTERM", () => void shutdown()) + process.once("SIGINT", () => void shutdown()) + // Park the foreground daemon until it is signalled. + await new Promise(() => {}) + return 0 + } + case "stop": { + assertHumanStart(process.env) + const { stopped } = await stopFleet(root) + process.stdout.write(stopped ? "Fleet daemon stopped.\n" : "The fleet is not running.\n") + return 0 + } + case "status": { + if (hasFlag(rest, "--token")) { + const token = await readStatusToken(root, { + isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, + }) + process.stdout.write(`${token}\n`) + return 0 + } + const status = await fleetStatus(root) + if (hasFlag(rest, "--json")) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`) + else if (status.running) + process.stdout.write( + `Fleet running (pid ${status.pid}, ceiling $${status.maxUsd}, concurrency ${status.concurrency}, started ${status.startedAt}).\n`, + ) + else process.stdout.write("Fleet stopped.\n") + return 0 + } + case "task": { + const [sub, ...taskArgs] = rest + if (sub === "list") { + const dag = await loadDag(root) + const tasks = dag ? Object.values(dag.tasks).sort((a, b) => (a.id < b.id ? -1 : 1)) : [] + if (hasFlag(taskArgs, "--json")) process.stdout.write(`${JSON.stringify(tasks, null, 2)}\n`) + else if (tasks.length === 0) process.stdout.write("No tasks.\n") + else + for (const task of tasks) + process.stdout.write( + `${task.id}\t${task.status}\tdeps=[${task.deps.join(",")}]\t$${task.ceilingUSD}\t${task.title}\n`, + ) + return 0 + } + // Mutating task commands are human-only: the policy denies agents, + // and the binary refuses sandboxes too. + assertHumanStart(process.env, "change fleet tasks") + if (sub === "add") { + const file = valueFlag(taskArgs, ["--file"]) + if (!file) throw new FleetError("`es-fleet task add` needs --file .") + const raw = JSON.parse(await readFile(file, "utf8")) as unknown + const inputs = Array.isArray(raw) ? raw : [raw] + let dag = (await loadDag(root)) ?? emptyDag() + const now = new Date().toISOString() + for (const input of inputs) dag = addTask(dag, input as Record, now) + await saveDag(root, dag) + process.stdout.write(`Added ${inputs.length} task(s).\n`) + return 0 + } + if (sub === "cancel") { + const id = taskArgs[0] + if (!id) throw new FleetError("`es-fleet task cancel` needs a task id.") + const dag = await loadDag(root) + const task = dag?.tasks[id] + if (!task) throw new FleetError(`Unknown task ${JSON.stringify(id)}.`) + if (TERMINAL.has(task.status)) throw new FleetError(`Task ${id} is already ${task.status}.`) + const reason = valueFlag(taskArgs, ["--reason"]) ?? "cancelled by human" + const next = reportTask(dag!, id, { outcome: "cancelled", reason }, new Date().toISOString()) + await saveDag(root, next.dag) + process.stdout.write(`Cancelled ${id}.\n`) + return 0 + } + throw new FleetError(`Unknown task subcommand ${JSON.stringify(sub ?? "")}: want add, list or cancel.`) + } + case "gc": { + assertHumanStart(process.env, "collect fleet worktrees") + const repo = valueFlag(rest, ["--repo"]) ?? process.cwd() + const report = await gcWorktrees(repo, root) + process.stdout.write( + `gc: removed ${report.removed.length} worktree(s), salvaged ${report.salvaged.length} branch(es), dropped ${report.dropped.length} record(s).\n`, + ) + return 0 + } + case "watch": { + return watchFleet({ stateRoot: root }) + } + case "web": { + // Opening the UI is a human action: agents run sandboxed and are + // refused here, and the printed ticket redeems exactly once. + assertHumanStart(process.env, "open the fleet web UI") + const endpoint = await readTelemetryEndpoint(root) + if (!endpoint) throw new FleetError("The fleet is not running: start it first with `es-fleet start`.") + const ticket = await mintWebTicket(root) + process.stdout.write(`http://127.0.0.1:${endpoint.port}/?t=${ticket}\n`) + return 0 + } + default: + process.stderr.write(`Unknown command ${JSON.stringify(command ?? "")}.\n\n${HELP}`) + return 1 + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + return 1 + } +} diff --git a/packages/fleet/src/configview.ts b/packages/fleet/src/configview.ts new file mode 100644 index 0000000..09b1380 --- /dev/null +++ b/packages/fleet/src/configview.ts @@ -0,0 +1,181 @@ +// Read-only config preview (#132, preview-only mode): the browser edits +// project config and gates against strict-schema validation with exact +// hash diffs, but applying stays terminal-only (ADR 0002 I7) — nothing in +// this module writes. `get` reads the current layers, hashes and drift; +// `plan` validates proposed file content with the same schemas the terminal +// path uses and previews the new hash, changed keys and the exact `es` +// commands the human runs. There is deliberately no apply: the bus must +// keep refusing `fleet.config.apply` with "unknown method". +import { createHash } from "node:crypto" +import { readFile } from "node:fs/promises" +import { + EsConfigSchema, + factoryLayout, + GatesConfigSchema, + globalConfigFile, + mergeConfig, + PROJECT_FORBIDDEN_KEYS, + verifyControl, +} from "@heretek-ai/es-core" + +const sha256 = (text: string | Buffer): string => createHash("sha256").update(text).digest("hex") +/** Canonical bytes `writeJson` would emit: the previewed hash is exact. */ +const canon = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n` + +export interface ConfigDrift { + readonly clean: boolean + readonly violations: readonly string[] + readonly hasBaseline: boolean +} + +export interface ConfigView { + readonly config: Record | null + readonly configHash: string | null + readonly gates: Record | null + readonly gatesHash: string | null + readonly drift: ConfigDrift + readonly forbidden: readonly string[] +} + +const readBytes = (file: string): Promise => + readFile(file).catch((error: NodeJS.ErrnoException) => { + if (error?.code === "ENOENT") return null + throw error + }) + +const asObject = (bytes: Buffer, file: string): Record => { + let parsed: unknown + try { + parsed = JSON.parse(bytes.toString("utf8")) + } catch { + throw new Error(`${file} is not valid JSON; fix it by hand before previewing.`) + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + throw new Error(`${file} must hold a JSON object.`) + return parsed as Record +} + +const readLayerFile = async ( + file: string, +): Promise<{ content: Record | null; hash: string | null }> => { + const bytes = await readBytes(file) + if (!bytes) return { content: null, hash: null } + return { content: asObject(bytes, file), hash: sha256(bytes) } +} + +/** Read the project config and gates layers, their hashes and drift. Reads only. */ +export async function readConfigView(repoRoot: string): Promise { + const layout = factoryLayout(repoRoot) + const [config, gates, drift] = await Promise.all([ + readLayerFile(layout.config), + readLayerFile(layout.gates), + verifyControl(repoRoot), + ]) + return { + config: config.content, + configHash: config.hash, + gates: gates.content, + gatesHash: gates.hash, + drift: { clean: drift.clean, violations: [...drift.violations], hasBaseline: drift.hasBaseline }, + forbidden: [...PROJECT_FORBIDDEN_KEYS], + } +} + +export interface ConfigPlanInput { + readonly file: "config" | "gates" + readonly content: unknown +} + +export interface ConfigPlan { + readonly ok: boolean + readonly errors: readonly string[] + /** Hash of the current file bytes (null when the file is absent). */ + readonly oldHash: string | null + /** Exact hash the file would have after applying (null when invalid). */ + readonly newHash: string | null + readonly changedKeys: readonly string[] + /** Exact terminal commands that apply the preview (empty unless ok and changed). */ + readonly commands: readonly string[] +} + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const changedKeysOf = (before: Record, after: Record): string[] => + [...new Set([...Object.keys(before), ...Object.keys(after)])] + .filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key])) + .sort((a, b) => a.localeCompare(b)) + +const forbiddenHit = (key: string): string | undefined => + PROJECT_FORBIDDEN_KEYS.find((forbidden) => key === forbidden || key.startsWith(`${forbidden}.`)) + +/** + * Validate proposed file content and preview the change. Pure: reads the + * current layers for the diff, writes nothing. + */ +export async function planConfigFile( + repoRoot: string, + input: ConfigPlanInput, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const layout = factoryLayout(repoRoot) + const fail = (errors: readonly string[], oldHash: string | null): ConfigPlan => ({ + ok: false, + errors, + oldHash, + newHash: null, + changedKeys: [], + commands: [], + }) + if (!isObject(input.content)) return fail(["content must be a JSON object"], null) + const proposed = input.content + if (input.file === "gates") { + const [currentFile, oldBytes] = await Promise.all([readLayerFile(layout.gates), readBytes(layout.gates)]) + const current = currentFile.content ?? {} + const oldHash = oldBytes ? sha256(oldBytes) : null + const parsed = GatesConfigSchema.safeParse(proposed) + if (!parsed.success) + return fail( + parsed.error.issues.map((issue) => `${issue.path.join(".") || ""}: ${issue.message}`), + oldHash, + ) + const changed = changedKeysOf(current, proposed) + return { + ok: true, + errors: [], + oldHash: oldBytes ? sha256(oldBytes) : null, + newHash: sha256(canon(proposed)), + changedKeys: changed, + commands: changed.length ? ["# save the previewed JSON to .factory/gates.json", "es rebaseline"] : [], + } + } + const [project, globalRaw] = await Promise.all([readLayerFile(layout.config), readBytes(globalConfigFile(env))]) + const current = project.content ?? {} + let globalLayer: Record = {} + if (globalRaw) { + try { + const parsed: unknown = JSON.parse(globalRaw.toString("utf8")) + if (isObject(parsed)) globalLayer = parsed + } catch { + // An unreadable global layer fails validation below via the merge. + } + } + const changed = changedKeysOf(current, proposed) + const errors = changed + .map((key) => forbiddenHit(key)) + .filter((hit) => hit !== undefined) + .map((hit) => `"${hit}" may only be set in the global config, not the project file`) + const merged = mergeConfig(globalLayer, proposed) + const parsed = EsConfigSchema.safeParse(merged) + if (!parsed.success) + for (const issue of parsed.error.issues) errors.push(`${issue.path.join(".") || ""}: ${issue.message}`) + if (errors.length) return fail(errors, project.hash) + return { + ok: true, + errors: [], + oldHash: project.hash, + newHash: sha256(canon(proposed)), + changedKeys: changed, + commands: changed.map((key) => `es config set ${key} ${JSON.stringify(proposed[key])}`), + } +} diff --git a/packages/fleet/src/daemon.ts b/packages/fleet/src/daemon.ts new file mode 100644 index 0000000..b3b6826 --- /dev/null +++ b/packages/fleet/src/daemon.ts @@ -0,0 +1,277 @@ +// Fleet daemon loop (#125, clean-room): one `tick` folds the scheduler +// decision into worktree allocation, worker launches, event reports and +// gated landings. All DAG mutations serialize through a promise mutex; every +// collaborator the loop cannot own in tests (spawning, gates) is injected. + +import { applyDecision, markGateFailed, reportTask, schedule, type TaskReport } from "./scheduler.ts" +import { emptyDag, loadDag, saveDag } from "./tasks.ts" +import { + CircuitBreaker, + loadWorkerPids, + prepareTaskRun, + type SpendAcc, + saveWorkerPids, + Worker, + type WorkerOutcome, + type WorkerSpec, + WorkerSupervisor, +} from "./worker.ts" +import { allocateWorktree, type GateCheck, landTask, loadRegistry, releaseWorktree } from "./worktree.ts" + +export interface WorkerPort { + run(): Promise + stop(): Promise + snapshot(): SpendAcc & { taskId: string; pid: number | undefined } +} + +export interface TransitionEvent { + readonly type: "started" | "report" | "landed" | "released" | "refused" | "recovered" + readonly taskId: string + readonly detail?: string +} + +export interface DaemonOptions { + readonly repoRoot: string + readonly stateRoot: string + readonly esBin: string + readonly concurrency?: number + readonly fleetCeilingUsd: number + readonly checkGates: GateCheck + readonly spawnWorker?: (spec: WorkerSpec, onReport: (taskId: string, report: TaskReport) => void) => WorkerPort + readonly onTransition?: (event: TransitionEvent) => void +} + +/** + * The daemon: human-started (see lifecycle.ts), it owns worker processes and + * folds their reports back into the DAG. `tick` is idempotent: safe to call + * on a timer and after every worker report. + */ +export class FleetDaemon { + private readonly supervisor = new WorkerSupervisor() + private readonly breaker = new CircuitBreaker({ + maxRestarts: 3, + windowMs: 300_000, + cooldownMs: 300_000, + massThreshold: 5, + }) + private readonly ports = new Map() + /** Tasks whose landing conflicted: retried after human repair, else waiting. */ + private readonly conflicted = new Set() + private queue: Promise = Promise.resolve() + + constructor(private readonly options: DaemonOptions) {} + + private exclusive(fn: () => Promise): Promise { + const run = this.queue.then(fn) + this.queue = run.then( + () => undefined, + () => undefined, + ) + return run + } + + private emit(event: TransitionEvent): void { + this.options.onTransition?.(event) + } + + private caps() { + return { concurrency: this.options.concurrency ?? 4, fleetCeilingUsd: this.options.fleetCeilingUsd } + } + + private spawner(spec: WorkerSpec, onReport: (taskId: string, report: TaskReport) => void): WorkerPort { + if (this.options.spawnWorker) return this.options.spawnWorker(spec, onReport) + return new Worker({ ...spec, esBin: this.options.esBin, onReport }) + } + + /** Reconcile worker tracking after a daemon restart (see WorkerSupervisor). */ + async recover(workers?: ReadonlyArray<{ taskId: string; pid: number }>): Promise { + let records: ReadonlyArray<{ taskId: string; pid: number }> = workers ?? [] + if (workers === undefined) { + const pids = await loadWorkerPids(this.options.stateRoot) + records = Object.entries(pids).map(([taskId, pid]) => ({ taskId, pid })) + } + await this.exclusive(async () => { + const { reattached, failed } = this.supervisor.recover(records, Date.now()) + const now = new Date().toISOString() + let dag = (await loadDag(this.options.stateRoot)) ?? emptyDag() + for (const taskId of reattached) this.emit({ type: "recovered", taskId, detail: "reattached to live run" }) + for (const { taskId, reason } of failed) { + const next = reportTask(dag, taskId, { outcome: "failed", reason }, now) + dag = next.dag + if (next.changed) this.emit({ type: "recovered", taskId, detail: "marked failed: daemon restarted" }) + } + await saveDag(this.options.stateRoot, dag) + }) + } + + /** One reconcile pass: schedule, allocate, launch, land, release. */ + async tick(): Promise { + await this.exclusive(async () => { + const now = new Date().toISOString() + let dag = (await loadDag(this.options.stateRoot)) ?? emptyDag() + const decision = schedule(dag, this.caps()) + dag = applyDecision(dag, { unblock: decision.unblock, start: [], cancel: decision.cancel }, now) + const launched: string[] = [] + for (const id of decision.start) { + const task = dag.tasks[id] + if (!task || task.status === "running") continue + try { + this.supervisor.requireStartable(id) + } catch { + continue + } + const probe = this.breaker.probe(id, Date.now()) + if (probe !== "ok") { + this.emit({ type: "refused", taskId: id, detail: probe.refused }) + continue + } + const spec: WorkerSpec = { + taskId: id, + worktreeDir: "", + ceilingUSD: task.ceilingUSD, + kind: task.kind, + objective: task.input.objective, + esBin: this.options.esBin, + } + try { + const record = await allocateWorktree(this.options.repoRoot, this.options.stateRoot, id, { now }).catch( + async () => (await loadRegistry(this.options.stateRoot))?.worktrees[id], + ) + if (record?.status !== "allocated") continue + await prepareTaskRun({ ...spec, worktreeDir: record.dir }) + const port = this.spawner( + { ...spec, worktreeDir: record.dir }, + (taskId, report) => void this.foldReport(taskId, report), + ) + const snapshot = port.snapshot() + this.supervisor.track(id, snapshot.pid ?? -1, { + spendUsd: snapshot.spendUsd, + tokensIn: snapshot.tokensIn, + tokensOut: snapshot.tokensOut, + }) + this.ports.set(id, port) + await saveWorkerPids( + this.options.stateRoot, + Object.fromEntries(this.supervisor.entries().map((entry) => [entry.taskId, entry.pid])), + ) + void port + .run() + .then(async (outcome) => { + this.supervisor.untrack(id) + this.ports.delete(id) + await saveWorkerPids( + this.options.stateRoot, + Object.fromEntries(this.supervisor.entries().map((entry) => [entry.taskId, entry.pid])), + ).catch(() => undefined) + if (outcome.status === "failed") this.breaker.noteCrash(id, Date.now()) + }) + .catch((error) => { + const reason = `worker crashed: ${error instanceof Error ? error.message : String(error)}` + this.supervisor.untrack(id) + this.ports.delete(id) + this.breaker.noteCrash(id, Date.now()) + this.emit({ type: "refused", taskId: id, detail: reason }) + void this.foldReport(id, { outcome: "failed", reason }) + }) + launched.push(id) + this.emit({ type: "started", taskId: id, detail: record.dir }) + } catch (error) { + this.emit({ type: "refused", taskId: id, detail: error instanceof Error ? error.message : String(error) }) + } + } + dag = applyDecision(dag, { unblock: [], start: launched, cancel: [] }, now) + await saveDag(this.options.stateRoot, dag) + await this.reconcileTerminals() + }) + } + + /** Fold one worker report into the DAG (called from worker callbacks). */ + async foldReport(taskId: string, report: TaskReport): Promise { + await this.exclusive(async () => { + const now = new Date().toISOString() + const dag = (await loadDag(this.options.stateRoot)) ?? emptyDag() + // Late reports after teardown name unknown tasks: not an error, drop them. + if (!dag.tasks[taskId]) { + this.emit({ type: "refused", taskId, detail: "stale report for an unknown task" }) + return + } + const next = reportTask(dag, taskId, report, now) + if (next.changed) { + await saveDag(this.options.stateRoot, next.dag) + this.emit({ type: "report", taskId, detail: report.outcome }) + } + }) + } + + private async reconcileTerminals(): Promise { + const now = new Date().toISOString() + let dag = (await loadDag(this.options.stateRoot)) ?? emptyDag() + for (const id of Object.keys(dag.tasks).sort((a, b) => a.localeCompare(b))) { + const task = dag.tasks[id]! + const conflicted = task.status === "waiting-human" && this.conflicted.has(id) + if (task.status !== "done" && task.status !== "failed" && task.status !== "cancelled" && !conflicted) continue + const record = (await loadRegistry(this.options.stateRoot))?.worktrees[id] + if (record?.status !== "allocated") { + this.conflicted.delete(id) + continue + } + if (task.status === "done" || conflicted) { + const landed = await landTask( + this.options.repoRoot, + this.options.stateRoot, + id, + "fleet", + this.options.checkGates, + ) + if (landed.landed) { + this.conflicted.delete(id) + if (conflicted) { + const next = reportTask(dag, id, { outcome: "done", reason: "landed after human repair" }, now) + dag = next.dag + await saveDag(this.options.stateRoot, dag) + } + await releaseWorktree(this.options.repoRoot, this.options.stateRoot, id, "merged") + this.emit({ type: "released", taskId: id, detail: "landed and released" }) + } else if (landed.conflict) { + this.conflicted.add(id) + const next = reportTask(dag, id, { outcome: "waiting-human", reason: landed.reason }, now) + dag = next.dag + await saveDag(this.options.stateRoot, dag) + this.emit({ type: "report", taskId: id, detail: "waiting-human: merge conflict" }) + } else if (landed.gateFailed) { + // Gates rejected the work: the task failed, nothing was merged. + const next = markGateFailed(dag, id, landed.reason, now) + dag = next.dag + await saveDag(this.options.stateRoot, dag) + await releaseWorktree(this.options.repoRoot, this.options.stateRoot, id, "abandoned") + this.emit({ type: "report", taskId: id, detail: "failed: gates rejected the landing" }) + } + } else { + await releaseWorktree(this.options.repoRoot, this.options.stateRoot, id, "abandoned") + this.emit({ type: "released", taskId: id, detail: `released (${task.status})` }) + } + } + } + + /** + * Kill switch (`es-fleet stop`): SIGTERM every worker, wait, and record + * the cancellations so the runs stay resumable. + */ + async shutdown(): Promise { + for (const [taskId, port] of [...this.ports]) { + await port.stop().catch(() => undefined) + this.ports.delete(taskId) + this.supervisor.untrack(taskId) + } + await this.exclusive(async () => { + const now = new Date().toISOString() + let dag = (await loadDag(this.options.stateRoot)) ?? emptyDag() + for (const id of Object.keys(dag.tasks).sort((a, b) => a.localeCompare(b))) { + if (dag.tasks[id]!.status !== "running") continue + const next = reportTask(dag, id, { outcome: "cancelled", reason: "fleet stopped; run is resumable" }, now) + dag = next.dag + } + await saveDag(this.options.stateRoot, dag) + }) + } +} diff --git a/packages/fleet/src/evidence.ts b/packages/fleet/src/evidence.ts new file mode 100644 index 0000000..24bf552 --- /dev/null +++ b/packages/fleet/src/evidence.ts @@ -0,0 +1,209 @@ +// Read-only evidence reads (#133): claims, claim detail with quote +// ranges, sources with seal status, and renderer exports. Everything here +// reads: dossiers, the retraction ledger, the content-addressed source +// files and the engine key (verification only, never created). Payloads +// are capped so one giant source cannot wedge the browser. +import { createHash } from "node:crypto" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { + type Claim, + ClaimStore, + explainClaimRank, + locateQuote, + type QuoteRange, + type Retraction, + rankClaims, + readEngineKey, + readRetractions, + renderResearchRun, + researchSourcesDir, + type StatusEvent, + verifyMetaSeal, +} from "@heretek-ai/es-core" + +/** Claim list cap (the UI filters; the full ledger stays on disk). */ +export const MAX_CLAIMS = 200 +/** Source text cap per payload (characters). */ +export const SOURCE_TEXT_CAP = 200_000 +/** Export body cap (characters). */ +export const EXPORT_CAP = 2_000_000 + +export interface EvidenceClaimFilter { + readonly tag?: string + readonly status?: string + readonly tier?: string + readonly text?: string +} + +export interface EvidenceClaimSummary { + readonly id: string + readonly tag: string + readonly status: string + readonly statement: string + readonly sourceSha: string | null + readonly tier?: string +} + +export interface EvidenceQuote { + readonly quote: string + readonly verified: boolean + readonly ranges: readonly QuoteRange[] +} + +export interface EvidenceClaimDetail { + readonly claim: Claim + readonly quotes: readonly EvidenceQuote[] + readonly retractions: readonly Retraction[] + readonly events: readonly StatusEvent[] + readonly rankExplanation: string + readonly files: readonly string[] +} + +export type SealStatus = "sealed" | "unsealed" | "invalid" | "unknown" + +export interface EvidenceSourceView { + readonly meta: { + readonly sha256: string + readonly url: string + readonly title?: string + readonly retrieved: string + readonly provider: string + readonly bytes: number + readonly tier?: string + } + readonly seal: SealStatus + readonly text: string + readonly truncated: boolean +} + +const summaryOf = (claim: Claim): EvidenceClaimSummary => ({ + id: claim.id, + tag: claim.tag, + status: claim.status, + statement: claim.statement, + sourceSha: claim.source?.sha256 ?? null, + ...(claim.tier ? { tier: claim.tier } : {}), +}) + +/** List degraded claims with optional tag/status/tier/text narrowing. */ +export async function readEvidenceClaims( + repoDir: string, + filter: EvidenceClaimFilter, +): Promise<{ claims: EvidenceClaimSummary[]; truncated: boolean }> { + const store = await ClaimStore.load(repoDir) + let claims = store.all() + if (filter.tag) claims = claims.filter((claim) => claim.tag === filter.tag) + if (filter.status) claims = claims.filter((claim) => claim.status === filter.status) + if (filter.tier) { + const tier = filter.tier + claims = claims.filter((claim) => (claim.tier ?? "__default__") === tier) + } + const text = filter.text?.trim() + if (text) claims = store.search(text).filter((claim) => claims.includes(claim)) + const truncated = claims.length > MAX_CLAIMS + return { claims: claims.slice(0, MAX_CLAIMS).map(summaryOf), truncated } +} + +const sourceFile = (repoDir: string, sha: string, ext: "md" | "json"): string => + path.join(researchSourcesDir(repoDir), `${sha}.${ext}`) + +const readSourceText = async (repoDir: string, sha: string): Promise => { + if (!/^[0-9a-f]{64}$/.test(sha)) return undefined + const text = await readFile(sourceFile(repoDir, sha, "md"), "utf8").catch(() => undefined) + if (text === undefined) return undefined + // Content-addressed: bytes that no longer hash to their name were tampered with. + if (createHash("sha256").update(text).digest("hex") !== sha) return undefined + return text +} + +const readSourceMeta = async (repoDir: string, sha: string): Promise | undefined> => { + if (!/^[0-9a-f]{64}$/.test(sha)) return undefined + try { + const parsed: unknown = JSON.parse(await readFile(sourceFile(repoDir, sha, "json"), "utf8")) + if (typeof parsed === "object" && parsed !== null) return parsed as Record + return undefined + } catch { + return undefined + } +} + +/** One claim with its quote ranges, retractions, events and rank. */ +export async function readEvidenceClaim(repoDir: string, id: string): Promise { + const store = await ClaimStore.load(repoDir) + const claim = store.get(id) + if (!claim) return null + const quotes: EvidenceQuote[] = [] + if (claim.source) { + const text = await readSourceText(repoDir, claim.source.sha256) + const ranges = text === undefined ? undefined : locateQuote(text, claim.source.quote) + quotes.push({ quote: claim.source.quote, verified: ranges !== undefined, ranges: ranges ?? [] }) + } + const sha = claim.source?.sha256 + const retractions = sha + ? (await readRetractions(repoDir).catch(() => [] as Retraction[])).filter((entry) => entry.source === sha) + : [] + const ranked = rankClaims(store.all()).find((item) => item.claim.id === id) + return { + claim, + quotes, + retractions, + events: store.events.filter((event) => event.claim === id), + rankExplanation: ranked ? explainClaimRank(ranked) : `#? ${claim.tag} — ${claim.statement.slice(0, 80)}`, + files: [...store.files(id)], + } +} + +/** One source with its text (capped) and seal status. Reads only. */ +export async function readEvidenceSource( + repoDir: string, + stateRoot: string, + sha: string, +): Promise { + const text = await readSourceText(repoDir, sha) + if (text === undefined) return null + const meta = (await readSourceMeta(repoDir, sha)) ?? {} + const key = await readEngineKey(stateRoot).catch(() => undefined) + const seal: SealStatus = + key === undefined + ? "unknown" + : typeof meta.seal !== "string" + ? "unsealed" + : verifyMetaSeal(meta, key) + ? "sealed" + : "invalid" + const url = typeof meta.url === "string" ? meta.url : "" + return { + meta: { + sha256: sha, + url, + ...(typeof meta.title === "string" ? { title: meta.title } : {}), + ...(typeof meta.retrieved === "string" ? { retrieved: meta.retrieved } : { retrieved: "" }), + ...(typeof meta.provider === "string" ? { provider: meta.provider } : { provider: "unknown" }), + ...(typeof meta.bytes === "number" ? { bytes: meta.bytes } : { bytes: text.length }), + ...(typeof meta.tier === "string" ? { tier: meta.tier } : {}), + }, + seal, + text: text.length > SOURCE_TEXT_CAP ? text.slice(0, SOURCE_TEXT_CAP) : text, + truncated: text.length > SOURCE_TEXT_CAP, + } +} + +/** Render the run's dossier with the #112 renderers (pure read + render). */ +export async function exportEvidence( + repoDir: string, + stateRoot: string, + format: "markdown" | "html", +): Promise<{ format: string; body: string; truncated: boolean } | null> { + let body: string + try { + body = await renderResearchRun(repoDir, { format: format === "html" ? "html" : "md", stateDir: stateRoot }) + } catch { + return null + } + return { + format, + body: body.length > EXPORT_CAP ? body.slice(0, EXPORT_CAP) : body, + truncated: body.length > EXPORT_CAP, + } +} diff --git a/packages/fleet/src/index.ts b/packages/fleet/src/index.ts new file mode 100644 index 0000000..950b804 --- /dev/null +++ b/packages/fleet/src/index.ts @@ -0,0 +1,178 @@ +// Fleet package entrypoint (#122): daemon lifecycle, state, and (from later +// tickets) the scheduler, worktree coordinator, workers and telemetry bus. + +export { + APPROVE_TICKET_TTL_MS, + type ApproveTicket, + ApproveTickets, + CsrfTokens, +} from "./approve.ts" +export type { StartDecision } from "./bin.ts" +export { + awaitStartGrant, + readStatusToken, + START_GRANT_TIMEOUT_MS, + START_GRANT_TYPE, + STARTED_TIMEOUT_MS, + STARTED_TYPE, + startDecision, + VERSION, +} from "./bin.ts" +export { + type ConfigDrift, + type ConfigPlan, + type ConfigPlanInput, + type ConfigView, + planConfigFile, + readConfigView, +} from "./configview.ts" +export { type DaemonOptions, FleetDaemon, type TransitionEvent, type WorkerPort } from "./daemon.ts" +export { + type EvidenceClaimDetail, + type EvidenceClaimFilter, + type EvidenceClaimSummary, + type EvidenceQuote, + type EvidenceSourceView, + EXPORT_CAP, + exportEvidence, + MAX_CLAIMS, + readEvidenceClaim, + readEvidenceClaims, + readEvidenceSource, + type SealStatus, + SOURCE_TEXT_CAP, +} from "./evidence.ts" +export { + assertHumanStart, + FleetError, + type FleetHandle, + type FleetStatus, + fleetStatus, + isPidAlive, + type StartOptions, + startFleet, + stopFleet, +} from "./lifecycle.ts" +export { readTaskLiveness, type TaskLiveness } from "./liveness.ts" +export { + applyDecision, + type CancelEntry, + type Capacity, + type Decision, + markGateFailed, + reportTask, + schedule, + type TaskReport, +} from "./scheduler.ts" +export { acquireSocket, SocketBusyError, type SocketGuard } from "./socket-guard.ts" +export { + FLEET_STATE_VERSION, + type FleetPaths, + type FleetState, + FleetStateSchema, + fleetDir, + fleetPaths, + persistFleetState, + readFleetState, + stoppedState, + writeFleetState, +} from "./state.ts" +export { + addTask, + DAG_VERSION, + type DagState, + DagStateSchema, + emptyDag, + type FailurePolicy, + FailurePolicySchema, + loadDag, + saveDag, + type Task, + type TaskInput, + TaskInputSchema, + type TaskResult, + TaskResultSchema, + TaskSchema, + type TaskStatus, + TaskStatusSchema, + TERMINAL, + type Trigger, + TriggerSchema, + validateDag, +} from "./tasks.ts" +export { + type BusEvent, + EventLog, + ensureToken, + type FleetSnapshot, + field, + parseHeaders, + readTelemetryEndpoint, + type SnapshotTask, + scrubPayload, + type TelemetryOptions, + TelemetryServer, +} from "./telemetry.ts" +export { readFleetSnapshot, renderDashboard, type WatchOptions, watchFleet } from "./watch.ts" +export { + contentTypeFor, + loopbackOriginOk, + mintWebTicket, + parseSessionCookie, + redeemWebTicket, + resolveWebFile, + sessionSetCookie, + WEB_SECURITY_HEADERS, + WEB_SESSION_COOKIE, + WEB_SESSION_TTL_MS, + WEB_TICKET_TTL_MS, + type WebFile, + WebSessions, +} from "./web.ts" +export { + type BreakerOptions, + buildWorkerArgv, + CircuitBreaker, + defaultGateCheck, + emptyAcc, + type FoldedEvent, + foldWorkerEvent, + loadWorkerPids, + prepareTaskRun, + type SpendAcc, + saveWorkerPids, + Worker, + type WorkerEnvelope, + WorkerError, + type WorkerOptions, + type WorkerOutcome, + type WorkerSpec, + WorkerSupervisor, +} from "./worker.ts" +export { + ALLOCATING_STALE_MS, + type AllocateOptions, + allocateWorktree, + emptyRegistry, + fleetBranch, + fleetWorktreeDir, + type GateCheck, + type GcReport, + gcWorktrees, + integrationBranch, + type LandResult, + landTask, + loadRegistry, + mirrorDir, + type ReleaseKind, + rebaseSymlinks, + releaseWorktree, + salvageBranch, + type WorktreeRecord, + WorktreeRecordSchema, + type WorktreeRegistry, + WorktreeRegistrySchema, + type WorktreeStatus, + WorktreeStatusSchema, + withRepoLock, +} from "./worktree.ts" diff --git a/packages/fleet/src/lifecycle.ts b/packages/fleet/src/lifecycle.ts new file mode 100644 index 0000000..a2b2b8a --- /dev/null +++ b/packages/fleet/src/lifecycle.ts @@ -0,0 +1,171 @@ +// Daemon lifecycle (#122, clean-room): start/stop/status with a pidfile and +// a lock in the masked state dir. Starting spends money, so start refuses +// agent sandboxes (ES_SANDBOX) and a live double-start; a stale pidfile is +// recovered. The `es-fleet` binary adds the TTY + human-confirmation gate; +// the library takes an explicit `env` so tests can drive both sides. +import { mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { readJson, withLock } from "@heretek-ai/es-core" +import { acquireSocket, type SocketGuard } from "./socket-guard.ts" +import { type FleetState, fleetPaths, persistFleetState, readFleetState, stoppedState } from "./state.ts" + +export class FleetError extends Error { + constructor(message: string) { + super(message) + this.name = "FleetError" + } +} + +export interface StartOptions { + readonly stateRoot: string + /** Fleet-wide spend ceiling in USD; mandatory, caps the sum of task ceilings. */ + readonly maxUsd: number + readonly concurrency?: number + readonly env?: NodeJS.ProcessEnv +} + +export interface FleetHandle { + readonly pid: number + readonly startedAt: string + close(): Promise +} + +export interface FleetStatus extends FleetState {} + +const PidfileSchema = (raw: unknown): { pid: number; startedAt: string } | undefined => { + if (typeof raw !== "object" || raw === null) return undefined + const { pid, startedAt } = raw as Record + if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return undefined + if (typeof startedAt !== "string") return undefined + return { pid, startedAt } +} + +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error: any) { + // EPERM means a live process we may not signal; anything else is dead. + return error?.code === "EPERM" + } +} + +const sandboxOf = (env: NodeJS.ProcessEnv | undefined): string | undefined => + env?.ES_SANDBOX && env.ES_SANDBOX.length > 0 ? env.ES_SANDBOX : undefined + +/** Human-only gate for starting the fleet: agents run with ES_SANDBOX set. */ +export function assertHumanStart(env: NodeJS.ProcessEnv | undefined, what = "start the fleet"): void { + const sandbox = sandboxOf(env ?? process.env) + if (sandbox !== undefined) + throw new FleetError( + `Refusing to ${what} inside an agent sandbox (ES_SANDBOX=${sandbox}): the fleet spends money, so only a human may do it from an interactive terminal.`, + ) +} + +/** Start the daemon records: lock, pidfile, control socket, persisted state. */ +export async function startFleet(options: StartOptions): Promise { + assertHumanStart(options.env ?? process.env) + if (!(options.maxUsd > 0) || !Number.isFinite(options.maxUsd)) + throw new FleetError(`Refusing to start without a positive --max-usd ceiling (got ${options.maxUsd}).`) + const concurrency = options.concurrency ?? 4 + if (!Number.isInteger(concurrency) || concurrency < 1) + throw new FleetError(`Refusing to start with concurrency ${concurrency}: it must be an integer >= 1.`) + const paths = fleetPaths(options.stateRoot) + await mkdir(paths.dir, { recursive: true }) + const startedAt = new Date().toISOString() + await withLock(paths.lock, async () => { + const raw = await readJson(paths.pid).catch(() => undefined) + const pidfile = PidfileSchema(raw) + if (pidfile && isPidAlive(pidfile.pid) && pidfile.pid !== process.pid) + throw new FleetError( + `The fleet is already running as pid ${pidfile.pid} (started ${pidfile.startedAt}); refusing a second owner.`, + ) + // Our own pid in the file (a previous start in this process without + // close) is also a double start. + if (pidfile && pidfile.pid === process.pid) + throw new FleetError(`The fleet is already running in this process (pid ${process.pid}).`) + // Otherwise the pidfile is missing, corrupt, or stale: reclaim it. + await writeFile(paths.pid, `${JSON.stringify({ pid: process.pid, startedAt })}\n`) + await persistFleetState(paths, { + v: 1, + running: true, + pid: process.pid, + startedAt, + maxUsd: options.maxUsd, + concurrency, + }) + }) + // The socket binds after the lock is released: binding is quick, and a + // bind failure must not wedge the lock. A stale socket is reclaimed inside. + let socket: SocketGuard + try { + socket = await acquireSocket(paths.socket) + } catch (error) { + await withLock(paths.lock, async () => { + await rm(paths.pid, { force: true }) + await persistFleetState(paths, stoppedState(concurrency)) + }) + throw error + } + let closed = false + return { + pid: process.pid, + startedAt, + close: async () => { + if (closed) return + closed = true + await socket.close() + await withLock(paths.lock, async () => { + const raw = await readJson(paths.pid).catch(() => undefined) + // Only clear our own pidfile; a successor's file is not ours. + if (PidfileSchema(raw)?.pid === process.pid) await rm(paths.pid, { force: true }) + await persistFleetState(paths, stoppedState(concurrency)) + }) + }, + } +} + +/** Read the persisted daemon status (works for humans and agents alike). */ +export async function fleetStatus(stateRoot: string): Promise { + return (await readFleetState(stateRoot)) ?? stoppedState() +} + +/** + * Stop a daemon started elsewhere: SIGTERM its pid and wait for the pidfile + * to clear. `{ stopped: false }` when nothing was running. Never throws for + * a missing or stale pidfile; the stale record is reclaimed. + */ +export async function stopFleet( + stateRoot: string, + options: { readonly timeoutMs?: number } = {}, +): Promise<{ stopped: boolean }> { + const paths = fleetPaths(stateRoot) + const raw = await readJson(paths.pid).catch(() => undefined) + const pidfile = PidfileSchema(raw) + if (!pidfile || !isPidAlive(pidfile.pid)) { + await withLock(paths.lock, () => rm(paths.pid, { force: true })) + return { stopped: false } + } + try { + process.kill(pidfile.pid, "SIGTERM") + } catch { + return { stopped: false } + } + const deadline = Date.now() + (options.timeoutMs ?? 10_000) + for (;;) { + const current = await readFile(paths.pid, "utf8").catch(() => undefined) + if (current === undefined) return { stopped: true } + let cleared = false + try { + cleared = PidfileSchema(JSON.parse(current))?.pid !== pidfile.pid + } catch { + cleared = true + } + if (cleared) return { stopped: true } + if (!isPidAlive(pidfile.pid)) { + await withLock(paths.lock, () => rm(paths.pid, { force: true })) + return { stopped: true } + } + if (Date.now() > deadline) return { stopped: true } + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} diff --git a/packages/fleet/src/liveness.ts b/packages/fleet/src/liveness.ts new file mode 100644 index 0000000..afc403c --- /dev/null +++ b/packages/fleet/src/liveness.ts @@ -0,0 +1,56 @@ +// Per-task run liveness (#130, repair-151 S5.6, feeds S7): computed in fleet +// from the task worktree's factory state with core's factory/liveness.ts +// (`headline`, `seatLine`, `researchLine`). The payload is JSON-safe data +// exported as a *type* for the web control plane to `import type` +// (`import type { TaskLiveness } from "@heretek-ai/es-fleet"`); the bus +// serves it at `fleet.task.liveness`. +import { + type FactoryState, + FactoryStateSchema, + factoryLayout, + headline, + readJson, + readLiveness, + researchLine, + seatLine, +} from "@heretek-ai/es-core" + +/** + * Per-task liveness for one fleet task: the run's one-sentence headline, its + * stage, one line per seat, and research progress when the run is in + * RESEARCH. Served by the bus method `fleet.task.liveness {id}`. + */ +export interface TaskLiveness { + readonly taskId: string + readonly runId?: string + readonly stage: string + readonly headline: string + readonly seats: readonly string[] + readonly research?: string + readonly lastActivityAt?: string +} + +/** Read one task worktree's liveness (missing state reads as stage NONE). */ +export async function readTaskLiveness( + worktreeDir: string, + taskId: string, + now: Date = new Date(), +): Promise { + let state: FactoryState | undefined + const raw = await readJson(factoryLayout(worktreeDir).state) + if (raw !== undefined) { + const parsed = FactoryStateSchema.safeParse(raw) + if (parsed.success) state = parsed.data + } + const liveness = await readLiveness(worktreeDir, state, { now }) + const research = researchLine(liveness) + return { + taskId, + ...(liveness.runId ? { runId: liveness.runId } : {}), + stage: liveness.stage, + headline: headline(state, liveness), + seats: liveness.seats.map((seat) => seatLine(liveness, seat)), + ...(research ? { research } : {}), + ...(liveness.lastActivityAt ? { lastActivityAt: liveness.lastActivityAt } : {}), + } +} diff --git a/packages/fleet/src/scheduler.ts b/packages/fleet/src/scheduler.ts new file mode 100644 index 0000000..40573ff --- /dev/null +++ b/packages/fleet/src/scheduler.ts @@ -0,0 +1,255 @@ +// Deterministic DAG scheduler (#123, clean-room): `schedule` is pure — the +// same DAG and caps give the same decision, with no clock reads. Time enters +// only through `applyDecision`/`reportTask`, which stamp the transitions. +// Iteration is always id-sorted. +import { FleetError } from "./lifecycle.ts" +import { type DagState, type Task, TERMINAL, validateDag } from "./tasks.ts" + +export interface Capacity { + /** Max tasks with status `running` (`waiting-human` frees capacity). */ + readonly concurrency: number + /** Max summed `ceilingUSD` over running tasks. */ + readonly fleetCeilingUsd: number +} + +export interface CancelEntry { + readonly id: string + readonly reason: string +} + +export interface Decision { + /** Pending tasks whose deps are met: transition to `ready`. */ + readonly unblock: readonly string[] + /** Ready tasks to launch now, id-sorted, within concurrency and ceiling. */ + readonly start: readonly string[] + /** Tasks that can never run (dead deps): transition to `cancelled`. */ + readonly cancel: readonly CancelEntry[] +} + +export type TaskReport = + | { readonly outcome: "running"; readonly reason?: string } + | { readonly outcome: "done"; readonly reason?: string; readonly spendUsd?: number; readonly summary?: string } + | { readonly outcome: "failed"; readonly reason?: string; readonly spendUsd?: number } + | { readonly outcome: "waiting-human"; readonly reason: string } + | { readonly outcome: "cancelled"; readonly reason?: string } + +const TERMINAL_DEP: ReadonlySet = new Set(["done", "failed", "cancelled"]) + +/** + * Compute the next decision for a DAG. Throws FleetError on an invalid DAG. + * A task that can never run is cancelled here (transitively: every task is + * evaluated against the current statuses in one pass, id-sorted). + */ +export function schedule(dag: DagState, caps: Capacity): Decision { + validateDag(dag) + if (!Number.isInteger(caps.concurrency) || caps.concurrency < 1) + throw new FleetError(`Concurrency must be an integer >= 1 (got ${caps.concurrency}).`) + if (!(caps.fleetCeilingUsd > 0) || !Number.isFinite(caps.fleetCeilingUsd)) + throw new FleetError(`Fleet ceiling must be positive (got ${caps.fleetCeilingUsd}).`) + const unblock: string[] = [] + const cancel: CancelEntry[] = [] + for (const id of Object.keys(dag.tasks).sort((a, b) => a.localeCompare(b))) { + const task = dag.tasks[id]! + if (task.status !== "pending" && task.status !== "ready") continue + const depStatus = task.deps.map((dep) => dag.tasks[dep]!.status) + if (task.trigger === "all_success") { + const failedDep = task.deps.find((dep) => { + const status = dag.tasks[dep]!.status + return status === "failed" || status === "cancelled" + }) + if (failedDep !== undefined) { + cancel.push({ id, reason: `dependency ${failedDep} ${dag.tasks[failedDep]!.status}` }) + continue + } + if (depStatus.every((status) => status === "done")) { + if (task.status === "pending") unblock.push(id) + } + } else { + const anyDone = depStatus.includes("done") + if (anyDone) { + if (task.status === "pending") unblock.push(id) + continue + } + const allTerminal = depStatus.every((status) => TERMINAL_DEP.has(status)) + if (allTerminal && task.deps.length > 0) { + cancel.push({ id, reason: "no dependency succeeded" }) + continue + } + if (task.deps.length === 0 && task.status === "pending") unblock.push(id) + } + } + // Start within concurrency and ceiling, id-sorted. `waiting-human` tasks + // are already running elsewhere in human hands: they free both. + const running = Object.values(dag.tasks).filter((task) => task.status === "running") + let usedSlots = running.length + let usedCeiling = running.reduce((sum, task) => sum + task.ceilingUSD, 0) + const candidates = Object.keys(dag.tasks) + .sort((a, b) => a.localeCompare(b)) + .filter((id) => { + const task = dag.tasks[id]! + return task.status === "ready" || (task.status === "pending" && unblock.includes(id)) + }) + const start: string[] = [] + for (const id of candidates) { + const task = dag.tasks[id]! + if (usedSlots + 1 > caps.concurrency) break + if (usedCeiling + task.ceilingUSD > caps.fleetCeilingUsd) continue + start.push(id) + usedSlots += 1 + usedCeiling += task.ceilingUSD + } + return { unblock, start, cancel } +} + +/** Apply a decision, stamping with `now`. Transitions are idempotent. */ +export function applyDecision(dag: DagState, decision: Decision, now: string): DagState { + const tasks = { ...dag.tasks } + const set = (id: string, task: Task) => { + tasks[id] = task + } + for (const entry of decision.cancel) { + const task = tasks[entry.id] + if (task && !TERMINAL.has(task.status)) + set(entry.id, { ...task, status: "cancelled", reason: entry.reason, updatedAt: now }) + } + for (const id of decision.unblock) { + const task = tasks[id] + if (task && task.status === "pending") set(id, { ...task, status: "ready", updatedAt: now }) + } + for (const id of decision.start) { + const task = tasks[id] + if (task && (task.status === "pending" || task.status === "ready")) + set(id, { ...task, status: "running", attempts: task.attempts + 1, updatedAt: now }) + } + return { v: dag.v, tasks } +} + +/** + * Fold a worker report into the DAG, stamping with `now`. Reports for + * terminal tasks are ignored (at-least-once delivery). A failed task follows + * its own failure policy: `retry` re-queues while attempts remain, `replan` + * parks it for a human, otherwise it is terminal and the next `schedule` + * cancels its dependents. + */ +export function reportTask( + dag: DagState, + id: string, + report: TaskReport, + now: string, +): { dag: DagState; changed: boolean } { + const task = dag.tasks[id] + if (!task) throw new FleetError(`Unknown task ${JSON.stringify(id)}.`) + // Landing conflicts reopen a done task for human repair (#127): nothing + // else leaves a terminal state. + if (TERMINAL.has(task.status)) { + if (task.status === "done" && report.outcome === "waiting-human") + return { + dag: { + v: dag.v, + tasks: { ...dag.tasks, [id]: { ...task, status: "waiting-human", reason: report.reason, updatedAt: now } }, + }, + changed: true, + } + return { dag, changed: false } + } + const spend = report.outcome === "done" || report.outcome === "failed" ? report.spendUsd : undefined + const result = + spend === undefined ? task.result : { spendUsd: spend, summary: "summary" in report ? report.summary : undefined } + switch (report.outcome) { + case "running": + if (task.status === "running") return { dag, changed: false } + return { + dag: { + v: dag.v, + tasks: { ...dag.tasks, [id]: { ...task, status: "running", attempts: task.attempts + 1, updatedAt: now } }, + }, + changed: true, + } + case "done": + return { + dag: { + v: dag.v, + tasks: { ...dag.tasks, [id]: { ...task, status: "done", reason: report.reason, updatedAt: now, result } }, + }, + changed: true, + } + case "waiting-human": + return { + dag: { + v: dag.v, + tasks: { ...dag.tasks, [id]: { ...task, status: "waiting-human", reason: report.reason, updatedAt: now } }, + }, + changed: true, + } + case "cancelled": + return { + dag: { + v: dag.v, + tasks: { ...dag.tasks, [id]: { ...task, status: "cancelled", reason: report.reason, updatedAt: now } }, + }, + changed: true, + } + case "failed": { + if (task.policy.strategy === "retry" && task.attempts <= task.policy.maxAttempts) + return { + dag: { + v: dag.v, + tasks: { + ...dag.tasks, + [id]: { ...task, status: "pending", reason: report.reason, updatedAt: now, result: undefined }, + }, + }, + changed: true, + } + if (task.policy.strategy === "replan") + return { + dag: { + v: dag.v, + tasks: { + ...dag.tasks, + [id]: { + ...task, + status: "waiting-human", + reason: `replan: ${report.reason ?? "failed run"}`, + updatedAt: now, + result, + }, + }, + }, + changed: true, + } + return { + dag: { + v: dag.v, + tasks: { ...dag.tasks, [id]: { ...task, status: "failed", reason: report.reason, updatedAt: now, result } }, + }, + changed: true, + } + } + } +} + +/** + * Gate rejection fails a done task (#127, repair-151 S5.7): the work is + * complete but unlandable, so the task goes terminal-failed carrying the + * gate reason. Only done -> failed; any other status is unchanged. This is + * the one done-exit besides landing conflicts, and it is daemon-initiated — + * worker reports for terminal tasks stay ignored (at-least-once delivery). + */ +export function markGateFailed( + dag: DagState, + id: string, + reason: string, + now: string, +): { dag: DagState; changed: boolean } { + const task = dag.tasks[id] + if (!task) throw new FleetError(`Unknown task ${JSON.stringify(id)}.`) + if (task.status !== "done") return { dag, changed: false } + return { + dag: { + v: dag.v, + tasks: { ...dag.tasks, [id]: { ...task, status: "failed", reason, updatedAt: now } }, + }, + changed: true, + } +} diff --git a/packages/fleet/src/socket-guard.ts b/packages/fleet/src/socket-guard.ts new file mode 100644 index 0000000..426cabb --- /dev/null +++ b/packages/fleet/src/socket-guard.ts @@ -0,0 +1,84 @@ +// Inode-bound unix-socket ownership (#122, clean-room): the guard records the +// inode it bound, so a later `verifyOwner` fails when the path was replaced. +// A socket file with no listener is stale (crashed owner) and reclaimed; +// a socket with a live listener refuses a second owner. + +import { lstat, rm, stat } from "node:fs/promises" +import { createServer, type Server, Socket } from "node:net" + +export class SocketBusyError extends Error { + constructor(sockPath: string) { + super(`Another fleet daemon owns ${sockPath}; refusing a second owner.`) + this.name = "SocketBusyError" + } +} + +export interface SocketGuard { + readonly path: string + /** True while the path still resolves to the inode this guard bound. */ + verifyOwner(): Promise + close(): Promise +} + +const connectable = (sockPath: string): Promise => + new Promise((resolve) => { + const probe = new Socket() + probe.once("connect", () => { + probe.destroy() + resolve(true) + }) + probe.once("error", () => { + probe.destroy() + resolve(false) + }) + probe.connect(sockPath) + }) + +async function unlinkQuietly(sockPath: string): Promise { + await rm(sockPath, { force: true }) +} + +/** Bind `sockPath` exclusively and return its ownership guard. */ +export async function acquireSocket(sockPath: string): Promise { + const known = await lstat(sockPath).catch(() => undefined) + if (known !== undefined) { + if (await connectable(sockPath)) throw new SocketBusyError(sockPath) + // Present but nobody listens: a crashed owner's leftover. Reclaim it. + await unlinkQuietly(sockPath) + } + const server: Server = createServer() + try { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(sockPath, () => { + server.removeListener("error", reject) + resolve() + }) + }) + } catch (error: any) { + // Lost a race with another binder: whoever listens now owns it. + if (error?.code === "EADDRINUSE" && (await connectable(sockPath))) throw new SocketBusyError(sockPath) + throw error + } + // The inode we bound: ownership is pinned to it, not to the path string. + const bound = await stat(sockPath) + const identity = { ino: bound.ino, dev: bound.dev } + let closed = false + return { + path: sockPath, + verifyOwner: async () => { + if (closed) { + // After close the file is gone (or replaced): only "gone" verifies. + const now = await lstat(sockPath).catch(() => undefined) + return now === undefined + } + const now = await stat(sockPath).catch(() => undefined) + return now !== undefined && now.ino === identity.ino && now.dev === identity.dev + }, + close: async () => { + closed = true + await new Promise((resolve) => server.close(() => resolve())) + await unlinkQuietly(sockPath) + }, + } +} diff --git a/packages/fleet/src/state.ts b/packages/fleet/src/state.ts new file mode 100644 index 0000000..758c5ee --- /dev/null +++ b/packages/fleet/src/state.ts @@ -0,0 +1,90 @@ +// Fleet state (#122): every fleet file lives under `/fleet/`, so +// agent sandboxes mask it (trust/sandbox.ts tmpfs-mounts the state dir and +// sets ES_SANDBOX). All mutations go through core's advisory lock. +import path from "node:path" +import { readJson, withLock, writeJson } from "@heretek-ai/es-core" +import { z } from "zod" + +/** Directory holding all fleet runtime state for one user. */ +export const fleetDir = (stateRoot: string): string => path.join(stateRoot, "fleet") + +export interface FleetPaths { + readonly dir: string + /** Live-daemon pidfile (JSON). */ + readonly pid: string + /** Advisory-lock target for fleet mutations (lock lives at `${lock}.lock`). */ + readonly lock: string + /** Persisted daemon status (JSON). */ + readonly state: string + /** Persisted task DAG (JSON, #123). */ + readonly tasks: string + /** Worktree registry (JSON, #124). */ + readonly worktrees: string + /** Worker pid table (JSON, #125). */ + readonly workers: string + /** Daemon control socket. */ + readonly socket: string + /** Bearer token for the read-only telemetry bus (#126). */ + readonly token: string +} + +export function fleetPaths(stateRoot: string): FleetPaths { + const dir = fleetDir(stateRoot) + return { + dir, + pid: path.join(dir, "daemon.pid"), + lock: path.join(dir, "fleet.lock"), + state: path.join(dir, "state.json"), + tasks: path.join(dir, "tasks.json"), + worktrees: path.join(dir, "worktrees.json"), + workers: path.join(dir, "workers.json"), + socket: path.join(dir, "fleet.sock"), + token: path.join(dir, "token"), + } +} + +/** Version of the on-disk fleet state (bump on any incompatible change). */ +export const FLEET_STATE_VERSION = 1 as const + +export const FleetStateSchema = z.object({ + v: z.literal(FLEET_STATE_VERSION), + running: z.boolean(), + pid: z.number().int().positive().nullable(), + startedAt: z.string().datetime().nullable(), + maxUsd: z.number().positive().nullable(), + concurrency: z.number().int().min(1), +}) +export type FleetState = z.infer + +export const stoppedState = (concurrency = 4): FleetState => ({ + v: FLEET_STATE_VERSION, + running: false, + pid: null, + startedAt: null, + maxUsd: null, + concurrency, +}) + +/** Read fleet state; undefined when missing, corrupt, or from another version. */ +export async function readFleetState(stateRoot: string): Promise { + let raw: unknown + try { + raw = await readJson(fleetPaths(stateRoot).state) + } catch { + return undefined + } + if (raw === undefined) return undefined + const parsed = FleetStateSchema.safeParse(raw) + return parsed.success ? parsed.data : undefined +} + +/** Persist fleet state atomically (no lock: the caller must hold the fleet lock). */ +export async function persistFleetState(paths: FleetPaths, state: FleetState): Promise { + await writeJson(paths.state, state) +} + +/** Persist fleet state atomically under the fleet lock. */ +export async function writeFleetState(stateRoot: string, state: FleetState): Promise { + const paths = fleetPaths(stateRoot) + await withLock(paths.lock, () => persistFleetState(paths, state)) +} diff --git a/packages/fleet/src/tasks.ts b/packages/fleet/src/tasks.ts new file mode 100644 index 0000000..f8d88ce --- /dev/null +++ b/packages/fleet/src/tasks.ts @@ -0,0 +1,148 @@ +// Fleet task model and DAG validation (#123, clean-room): tasks are pure +// data with a zod schema; the DAG is validated on add (unknown deps, +// self-deps, cycles). Persistence lives in `/fleet/tasks.json` +// under the shared fleet lock. + +import { readJson, withLock, writeJson } from "@heretek-ai/es-core" +import { z } from "zod" +import { FleetError } from "./lifecycle.ts" +import { fleetPaths } from "./state.ts" + +export const TaskStatusSchema = z.enum(["pending", "ready", "running", "waiting-human", "done", "failed", "cancelled"]) +export type TaskStatus = z.infer + +export const TriggerSchema = z.enum(["all_success", "one_success"]) +export type Trigger = z.infer + +export const FailurePolicySchema = z.discriminatedUnion("strategy", [ + z.object({ strategy: z.literal("fail") }), + z.object({ strategy: z.literal("retry"), maxAttempts: z.number().int().min(1) }), + z.object({ strategy: z.literal("replan") }), +]) +export type FailurePolicy = z.infer + +export const TaskInputSchema = z.object({ + objective: z.string().min(1), + tier: z.string().min(1).optional(), +}) +export type TaskInput = z.infer + +export const TaskResultSchema = z.object({ + spendUsd: z.number().min(0), + summary: z.string().optional(), +}) +export type TaskResult = z.infer + +export const TaskSchema = z.object({ + id: z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9][a-z0-9-]*$/, "task ids are lowercase alphanumerics and dashes"), + title: z.string().min(1), + kind: z.enum(["factory-run", "research-run"]), + deps: z.array(z.string().min(1)).default([]), + trigger: TriggerSchema.default("all_success"), + input: TaskInputSchema, + ceilingUSD: z.number().positive(), + policy: FailurePolicySchema.default({ strategy: "fail" }), + status: TaskStatusSchema.default("pending"), + attempts: z.number().int().min(0).default(0), + reason: z.string().optional(), + result: TaskResultSchema.optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}) +export type Task = z.infer + +export const DAG_VERSION = 1 as const + +export const DagStateSchema = z.object({ + v: z.literal(DAG_VERSION), + tasks: z.record(z.string(), TaskSchema), +}) +export type DagState = z.infer + +export const emptyDag = (): DagState => ({ v: DAG_VERSION, tasks: {} }) + +export const TERMINAL: ReadonlySet = new Set(["done", "failed", "cancelled"]) + +/** + * Validate a DAG: every dep names a known task, no self-deps, no cycles. + * Throws FleetError naming the offender. Iteration is id-sorted, so the + * first error reported is deterministic. + */ +export function validateDag(dag: DagState): void { + const ids = Object.keys(dag.tasks).sort((a, b) => a.localeCompare(b)) + for (const id of ids) { + const task = dag.tasks[id]! + if (task.id !== id) throw new FleetError(`Task key ${JSON.stringify(id)} holds task ${JSON.stringify(task.id)}.`) + for (const dep of task.deps) { + if (!(dep in dag.tasks)) + throw new FleetError(`Task ${JSON.stringify(id)} depends on unknown task ${JSON.stringify(dep)}.`) + if (dep === id) throw new FleetError(`Task ${JSON.stringify(id)} depends on itself.`) + } + } + // Iterative DFS with explicit colors; neighbours in id order. + const color = new Map() + for (const root of ids) { + if (color.has(root)) continue + const stack: Array<{ id: string; next: number }> = [{ id: root, next: 0 }] + color.set(root, "gray") + while (stack.length > 0) { + const top = stack.at(-1)! + const deps = [...dag.tasks[top.id]!.deps].sort((a, b) => a.localeCompare(b)) + if (top.next >= deps.length) { + color.set(top.id, "black") + stack.pop() + continue + } + const dep = deps[top.next]! + top.next += 1 + const seen = color.get(dep) + if (seen === "gray") throw new FleetError(`Dependency cycle through ${JSON.stringify(dep)}.`) + if (seen === undefined) { + color.set(dep, "gray") + stack.push({ id: dep, next: 0 }) + } + } + } +} + +/** Add a task (stamping createdAt/updatedAt when absent); the result validates. */ +export function addTask(dag: DagState, input: Record, now = new Date().toISOString()): DagState { + const parsed = TaskSchema.parse({ + deps: [], + trigger: "all_success", + policy: { strategy: "fail" }, + status: "pending", + attempts: 0, + createdAt: now, + updatedAt: now, + ...input, + }) + if (parsed.id in dag.tasks) throw new FleetError(`Duplicate task id ${JSON.stringify(parsed.id)}.`) + const next: DagState = { v: DAG_VERSION, tasks: { ...dag.tasks, [parsed.id]: parsed } } + validateDag(next) + return next +} + +/** Load the persisted DAG; undefined when missing, corrupt, or versioned away. */ +export async function loadDag(stateRoot: string): Promise { + let raw: unknown + try { + raw = await readJson(fleetPaths(stateRoot).tasks) + } catch { + return undefined + } + if (raw === undefined) return undefined + const parsed = DagStateSchema.safeParse(raw) + return parsed.success ? parsed.data : undefined +} + +/** Persist the DAG atomically under the fleet lock. */ +export async function saveDag(stateRoot: string, dag: DagState): Promise { + validateDag(dag) + const paths = fleetPaths(stateRoot) + await withLock(paths.lock, () => writeJson(paths.tasks, dag)) +} diff --git a/packages/fleet/src/telemetry.ts b/packages/fleet/src/telemetry.ts new file mode 100644 index 0000000..4ee59a0 --- /dev/null +++ b/packages/fleet/src/telemetry.ts @@ -0,0 +1,968 @@ +// Read-only telemetry bus (#126, clean-room): a localhost HTTP server in the +// daemon with a strict-schema JSON-RPC endpoint (`/fleet/rpc`) and a +// coalesced WebSocket event stream (`/fleet/ws`). Token auth, Host and Origin +// checks, monotonic backlog replay, payload scrubbing. No dependencies +// beyond core: the WebSocket framing is a small purpose-built implementation +// (text frames only) rather than a new runtime dependency. +import { createHash, randomBytes, timingSafeEqual } from "node:crypto" +import { appendFile, mkdir, open, readFile, writeFile } from "node:fs/promises" +import { createServer, type Server, type Socket } from "node:net" +import path from "node:path" +import { + approvalSubject, + approveStage, + checkApprovalAttempts, + hashJson, + noteApprovalSuccess, + noteWrongPassphrase, + pendingApprovals, + unlockHumanKey, + withLock, +} from "@heretek-ai/es-core" +import { z } from "zod" +import { ApproveTickets, CsrfTokens } from "./approve.ts" +import { planConfigFile, readConfigView } from "./configview.ts" +import { exportEvidence, readEvidenceClaim, readEvidenceClaims, readEvidenceSource } from "./evidence.ts" +import { readTaskLiveness } from "./liveness.ts" +import { fleetPaths } from "./state.ts" +import { + loopbackOriginOk, + parseSessionCookie, + redeemWebTicket, + resolveWebFile, + sessionSetCookie, + WEB_SECURITY_HEADERS, + WebSessions, +} from "./web.ts" +import { loadRegistry } from "./worktree.ts" + +export interface BusEvent { + readonly seq: number + readonly at: string + readonly type: "changed" | "liveness" + readonly payload: Record +} + +type BusChannel = BusEvent["type"] + +/** + * Append-only event log with monotonic sequence numbers, persisted as JSONL + * (survives daemon restarts; replays from any `since`). Listeners fire + * synchronously per append for the live stream; persistence follows in the + * background, so a burst of appends never races a replay read. + */ +export class EventLog { + private seq = 0 + private readonly listeners = new Set<(event: BusEvent) => void>() + + constructor(private readonly file: string) {} + + /** Catch the sequence up with the persisted log (restarts). */ + async load(): Promise { + try { + const text = await readFile(this.file, "utf8") + for (const line of text.split("\n")) { + if (!line.trim()) continue + try { + const seq = (JSON.parse(line) as { seq?: unknown }).seq + if (typeof seq === "number" && seq > this.seq) this.seq = seq + } catch {} + } + } catch { + // Missing log starts at zero. + } + } + + static async open(stateRoot: string): Promise { + const log = new EventLog(path.join(fleetPaths(stateRoot).dir, "events.jsonl")) + await log.load() + return log + } + + /** All persisted events in seq order (restart replay). */ + async readAll(): Promise { + const found: BusEvent[] = [] + try { + const text = await readFile(this.file, "utf8") + for (const line of text.split("\n")) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) as Partial + if (typeof parsed.seq === "number" && (parsed.type === "changed" || parsed.type === "liveness")) + found.push({ + seq: parsed.seq, + at: typeof parsed.at === "string" ? parsed.at : "", + type: parsed.type, + payload: (parsed.payload ?? {}) as Record, + }) + } catch {} + } + } catch { + // Missing log replays as empty. + } + return found.sort((a, b) => a.seq - b.seq) + } + + onAppend(listener: (event: BusEvent) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + async append(type: BusChannel, payload: Record): Promise { + const event = this.emit(type, payload) + await mkdir(path.dirname(this.file), { recursive: true }).catch(() => undefined) + await appendFile(this.file, `${JSON.stringify(event)}\n`).catch(() => undefined) + return event + } + + /** Hot-path variant: listeners fire now, persistence follows unattended. */ + appendSync(type: BusChannel, payload: Record): BusEvent { + const event = this.emit(type, payload) + void mkdir(path.dirname(this.file), { recursive: true }) + .then(() => appendFile(this.file, `${JSON.stringify(event)}\n`)) + .catch(() => undefined) + return event + } + + private emit(type: BusChannel, payload: Record): BusEvent { + this.seq += 1 + const event: BusEvent = { seq: this.seq, at: new Date().toISOString(), type, payload } + for (const listener of [...this.listeners]) listener(event) + return event + } +} + +const SECRET_KEY = /(passphrase|secret|token|password|passwd|credential|private[_-]?key|seed|bearer|authorization)/i + +/** + * Scrub a payload: secret-named keys become "[redacted]", and any string + * containing a known secret value (>= 8 chars) is redacted wholesale. + */ +export function scrubPayload(value: unknown, secrets: readonly string[]): unknown { + const live = secrets.filter((secret) => secret.length >= 8) + const scrubString = (text: string): string => { + if (SECRET_KEY.test(text) && text.length < 64 && /^[A-Za-z0-9\-_=.+/]+$/.test(text) && live.includes(text)) + return "[redacted]" + return live.some((secret) => text.includes(secret)) ? "[redacted]" : text + } + const walk = (node: unknown): unknown => { + if (typeof node === "string") return scrubString(node) + if (Array.isArray(node)) return node.map(walk) + if (typeof node === "object" && node !== null) { + const out: Record = {} + for (const [key, entry] of Object.entries(node as Record)) + out[key] = SECRET_KEY.test(key) ? "[redacted]" : walk(entry) + return out + } + return node + } + return walk(value) +} + +/** Bearer token for the bus: created once at 0600 in the masked state dir. */ +export async function ensureToken(stateRoot: string): Promise { + const paths = fleetPaths(stateRoot) + return withLock(paths.lock, async () => { + try { + const current = (await readFile(paths.token, "utf8")).trim() + if (current.length >= 32) return current + } catch { + // Missing: create below. + } + await mkdir(path.dirname(paths.token), { recursive: true }) + const handle = await open(paths.token, "w", 0o600) + try { + const token = randomBytes(32).toString("hex") + await handle.writeFile(`${token}\n`) + return token + } finally { + await handle.close() + } + }) +} + +export interface SnapshotTask { + readonly id: string + readonly title: string + readonly status: string + readonly ceilingUSD: number + readonly spendUsd: number + /** Dependency ids: the edges of the fleet DAG (#130 draws them). */ + readonly deps: readonly string[] + readonly reason?: string +} + +export interface FleetSnapshot { + readonly daemon: { + readonly running: boolean + readonly pid: number | null + readonly maxUsd: number | null + readonly concurrency: number + } + readonly tasks: readonly SnapshotTask[] + readonly spendUsd: number + /** Tasks waiting on a human, with the stage to approve (#131 links here). */ + readonly pending: ReadonlyArray<{ readonly taskId: string; readonly reason: string; readonly stage: string }> +} + +const StrictObject = (shape: Record) => z.strictObject(shape) + +const RpcSchemas = { + "fleet.status": StrictObject({}), + "fleet.task": StrictObject({ id: z.string().min(1) }), + /** Per-task run liveness (#130, feeds the web dashboard): read-only. */ + "fleet.task.liveness": StrictObject({ id: z.string().min(1) }), + "fleet.pending": StrictObject({}), + "fleet.events": StrictObject({ since: z.number().int().min(0).default(0) }), + /** Read-only config preview (#132, preview-only): no apply method exists. */ + "fleet.config.get": StrictObject({}), + "fleet.config.plan": StrictObject({ file: z.enum(["config", "gates"]), content: z.unknown() }), + /** Read-only evidence reads (#133): claims, detail, sources, exports. */ + "evidence.claims": StrictObject({ + runId: z.string().min(1), + filter: StrictObject({ + tag: z.string().optional(), + status: z.string().optional(), + tier: z.string().optional(), + text: z.string().optional(), + }).optional(), + }), + "evidence.claim": StrictObject({ runId: z.string().min(1), id: z.string().min(1) }), + "evidence.source": StrictObject({ runId: z.string().min(1), sha: z.string().min(1) }), + "evidence.export": StrictObject({ runId: z.string().min(1), format: z.enum(["markdown", "html"]) }), +} as const + +type RpcMethod = keyof typeof RpcSchemas + +export interface TelemetryOptions { + readonly stateRoot: string + /** 0 picks a free port (recorded to telemetry.json for `es-fleet watch`). */ + readonly port: number + readonly token: string + readonly getSnapshot: () => FleetSnapshot | Promise + readonly secrets?: readonly string[] + /** + * Directory of the built web UI (#129). When set, `GET /` serves it + * behind the single-use ticket exchange; when unset, web loads 404. + */ + readonly webRoot?: string + /** + * Repo root the read-only config preview reads (#132). When unset, + * `fleet.config.*` reports that no repo is configured. + */ + readonly configRoot?: string + /** Preview-ticket TTL override for tests (default 120 s, ADR 0002 I3). */ + readonly approveTicketTtlMs?: number +} + +/** + * Parse request header lines into a Map keyed by lowercase name (CodeQL #84, + * repair-151 S5.4): remote names are never written as keys into a plain + * object, so a header named `__proto__` stays a plain entry and cannot land + * on the prototype. Names that are not RFC 9110 tokens are rejected. + */ +export function parseHeaders(lines: readonly string[]): Map { + const headers = new Map() + for (const line of lines) { + const at = line.indexOf(":") + if (at <= 0) continue + const name = line.slice(0, at).trim().toLowerCase() + if (!/^[!#$%&'*+\-.^_`|~0-9a-z]+$/.test(name)) continue + headers.set(name, line.slice(at + 1).trim()) + } + return headers +} + +/** Case-insensitive request header lookup (names with dashes need no literals at use sites). */ +export const field = (headers: Map, name: string): string | undefined => headers.get(name.toLowerCase()) + +interface HttpResponder { + writeHead(code: number, headers: Record): void + end(body?: string): void +} + +const jsonBody = (res: HttpResponder, code: number, value: unknown): void => { + const text = JSON.stringify(value) + res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }) + res.end(text) +} + +const contentLengthOf = (header: string): number => { + for (const line of header.split("\r\n").slice(1)) { + const at = line.indexOf(":") + if (at > 0 && line.slice(0, at).trim().toLowerCase() === "content-length") { + const length = Number(line.slice(at + 1).trim()) + return Number.isInteger(length) && length >= 0 ? length : 0 + } + } + return 0 +} + +/** + * The bus server. Bind, token, Host and Origin are all fail-closed. Every + * JSON-RPC method is read-only (no state is written anywhere in RPC + * handling); the browser approve endpoints are the deliberate exception — + * preview issues single-use tickets and submit writes approvals. + */ +export class TelemetryServer { + readonly log: EventLog + private server: Server | undefined + private actualPort = 0 + private readonly sessions = new WebSessions() + private readonly approveTickets: ApproveTickets + private readonly csrf = new CsrfTokens() + private pending: Record> = { changed: new Map(), liveness: new Map() } + private lastFlush: Record = { changed: 0, liveness: 0 } + private flushTimer: Record | undefined> = { + changed: undefined, + liveness: undefined, + } + private readonly sockets = new Set<{ socket: Socket; channels: Set; buffer: Buffer }>() + private events: BusEvent[] = [] + private closing = false + + constructor(private readonly options: TelemetryOptions) { + this.log = new EventLog(path.join(fleetPaths(options.stateRoot).dir, "events.jsonl")) + this.approveTickets = new ApproveTickets(options.approveTicketTtlMs) + } + + get port(): number { + return this.actualPort + } + + get host(): string { + return "127.0.0.1" + } + + private secrets(): string[] { + return [this.options.token, ...(this.options.secrets ?? [])] + } + + async start(): Promise { + await this.log.load() + this.events = await this.log.readAll() + this.log.onAppend((event) => { + this.events.push(event) + this.stage(event) + }) + this.server = createServer((socket) => void this.handleHttp(socket)) + this.server.on("clientError", (_error, socket) => socket.destroy()) + await new Promise((resolve) => this.server!.listen(this.options.port, "127.0.0.1", resolve)) + this.actualPort = (this.server.address() as { port: number }).port + const paths = fleetPaths(this.options.stateRoot) + await mkdir(paths.dir, { recursive: true }) + await withLock(paths.lock, () => + writeFile(path.join(paths.dir, "telemetry.json"), JSON.stringify({ port: this.actualPort })), + ) + } + + async stop(): Promise { + this.closing = true + for (const timer of Object.values(this.flushTimer)) if (timer) clearTimeout(timer) + for (const entry of this.sockets) entry.socket.destroy() + this.sockets.clear() + await new Promise((resolve) => { + if (!this.server) return resolve() + this.server.close(() => resolve()) + }) + } + + private bearerOk(header: string | undefined): boolean { + if (!header?.startsWith("Bearer ")) return false + const presented = Buffer.from(header.slice("Bearer ".length)) + const expected = Buffer.from(this.options.token) + return presented.length === expected.length && timingSafeEqual(presented, expected) + } + + private hostOk(header: string | undefined): boolean { + if (!header) return false + const match = /^(127\.0\.0\.1|localhost):(\d+)$/.exec(header.trim()) + return match !== null && Number(match[2]) === this.actualPort + } + + private handleHttp(socket: Socket): void { + let buffer = Buffer.alloc(0) + let routed = false + socket.on("data", (chunk: Buffer) => { + if (routed) return + buffer = Buffer.concat([buffer, chunk]) + if (buffer.length > 2_000_000) { + socket.destroy() + return + } + const head = buffer.indexOf("\r\n\r\n") + if (head === -1) return + const header = buffer.subarray(0, head).toString("latin1") + const length = contentLengthOf(header) + if (length > 1_000_000) { + socket.destroy() + return + } + // The body can trail the headers across packets: wait for all of it. + if (buffer.length < head + 4 + length) return + routed = true + const rest = buffer.subarray(head + 4, head + 4 + length) + void this.route(socket, header, rest) + }) + socket.on("error", () => undefined) + } + + private async route(socket: Socket, header: string, rest: Buffer): Promise { + const [requestLine, ...headerLines] = header.split("\r\n") + const [method, target] = (requestLine ?? "").split(" ") + const headers = parseHeaders(headerLines) + const res = { + writeHead: (code: number, responseHeaders: Record) => { + const lines = [ + `HTTP/1.1 ${code} ${code === 200 ? "OK" : code === 401 ? "Unauthorized" : code === 403 ? "Forbidden" : code === 101 ? "Switching Protocols" : "Error"}`, + ] + for (const [name, value] of Object.entries(responseHeaders)) lines.push(`${name}: ${value}`) + socket.write(`${lines.join("\r\n")}\r\n\r\n`) + }, + end: (body?: string) => { + if (body) socket.write(body) + socket.end() + }, + } + // Upgrade first: it carries its own auth below. + if ((field(headers, "upgrade") ?? "").toLowerCase() === "websocket" && target === "/fleet/ws") + return this.handleUpgrade(socket, headers, res) + if (!this.hostOk(field(headers, "host"))) { + res.writeHead(403, { "content-length": 0, connection: "close" }) + res.end() + return + } + if (target !== "/fleet/rpc" || method !== "POST") { + if (method === "GET" && !(target ?? "").startsWith("/fleet/")) return this.serveWeb(target ?? "/", headers, res) + // Browser approvals (#131, ADR 0002 option b): preview is a read + // (bearer or session), submit is browser-only (cookie + Origin + CSRF). + if (method === "POST" && target === "/fleet/approve/preview") return this.approvePreview(headers, rest, res) + if (method === "POST" && target === "/fleet/approve") return this.approveSubmit(headers, rest, res) + res.writeHead(404, { "content-length": 0, connection: "close" }) + res.end() + return + } + if (!this.bearerOk(field(headers, "authorization")) && !this.sessionRpcOk(headers)) { + // A presented session cookie that cannot authorize is a forbidden + // CSRF/origin failure (403); wholly missing credentials are 401. + const code = parseSessionCookie(field(headers, "cookie")) !== undefined ? 403 : 401 + res.writeHead(code, { "content-length": 0, connection: "close" }) + res.end() + return + } + const length = Number(field(headers, "content-length") ?? "0") + if (!(length >= 0) || length > 1_000_000) { + jsonBody(res, 200, { error: { code: -32700, message: "parse error" } }) + return + } + // The body may already be buffered (fetch sends it with the headers). + const body = rest.subarray(0, length).toString("utf8") + let call: unknown + try { + call = JSON.parse(body) + } catch { + jsonBody(res, 200, { error: { code: -32700, message: "parse error" } }) + return + } + const parsed = z.object({ method: z.string(), params: z.unknown() }).safeParse(call) + if (!parsed.success || !(parsed.data.method in RpcSchemas)) { + jsonBody(res, 200, { error: { code: -32601, message: "unknown method" } }) + return + } + const schema = RpcSchemas[parsed.data.method as RpcMethod] + const params = schema.safeParse(parsed.data.params) + if (!params.success) { + jsonBody(res, 200, { error: { code: -32602, message: "invalid params" } }) + return + } + try { + const result = await this.dispatch(parsed.data.method as RpcMethod, params.data as never) + jsonBody(res, 200, { result: scrubPayload(result, this.secrets()) }) + } catch (error) { + jsonBody(res, 200, { error: { code: -32603, message: error instanceof Error ? error.message : String(error) } }) + } + } + + /** + * Cookie authentication for read-only RPC (#129): the browser session may + * call the bus only when it also presents a loopback Origin. Bearer calls + * (CLI, watch, scripts) are unaffected. Fail-closed: no Origin, no cookie + * access — browsers always send Origin on a POST fetch. + */ + private sessionRpcOk(headers: Map): boolean { + return ( + loopbackOriginOk(field(headers, "origin")) && this.sessions.valid(parseSessionCookie(field(headers, "cookie"))) + ) + } + + /** + * The web UI (#129): `GET /?t=` redeems a single-use ticket for a + * session cookie and the entry page; later loads present the cookie. + * Anonymous loads are 401, bad tickets 403, everything carries the strict + * security headers. Without a configured `webRoot` every load 404s. + */ + private async serveWeb(target: string, headers: Map, res: HttpResponder): Promise { + const deny = (code: 401 | 403): void => { + res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) + res.end() + } + if (this.options.webRoot === undefined) { + res.writeHead(404, { "content-length": 0, connection: "close" }) + res.end() + return + } + if (!this.originOk(field(headers, "origin"))) { + deny(403) + return + } + const query = target.split("?", 2)[1] ?? "" + const ticket = query + .split("&") + .map((part) => part.split("=", 2) as [string, string?]) + .find(([name]) => name === "t")?.[1] + if (ticket !== undefined) { + const ok = await redeemWebTicket(this.options.stateRoot, ticket) + if (!ok) { + deny(403) + return + } + const session = this.sessions.create() + await this.sendWebFile(res, this.options.webRoot, "/", { + "set-cookie": sessionSetCookie(session), + }) + return + } + if (!this.sessions.valid(parseSessionCookie(field(headers, "cookie")))) { + deny(401) + return + } + await this.sendWebFile(res, this.options.webRoot, target) + } + + private async sendWebFile( + res: HttpResponder, + webRoot: string, + target: string, + extra: Record = {}, + ): Promise { + const found = await resolveWebFile(webRoot, target) + if (!found) { + res.writeHead(404, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) + res.end() + return + } + let body: string + try { + body = await readFile(found.file, "utf8") + } catch { + res.writeHead(404, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" }) + res.end() + return + } + res.writeHead(200, { + ...WEB_SECURITY_HEADERS, + ...extra, + "content-type": found.contentType, + "content-length": Buffer.byteLength(body), + connection: "close", + }) + res.end(body) + } + + /** + * Browser approval preview (#131, ADR 0002 I3/I6): a read — bearer or + * session — returning the subject, its hash and a single-use ticket. + */ + private async approvePreview(headers: Map, rest: Buffer, res: HttpResponder): Promise { + if (!this.bearerOk(field(headers, "authorization")) && !this.sessionRpcOk(headers)) { + // Same convention as read-only RPC: a presented session cookie that + // cannot authorize is a forbidden CSRF/origin failure (403). + const code = parseSessionCookie(field(headers, "cookie")) !== undefined ? 403 : 401 + res.writeHead(code, { "content-length": 0, connection: "close" }) + res.end() + return + } + let body: unknown + try { + body = this.readBody(headers, rest) + } catch { + this.fail(res, 400, "parse error") + return + } + const parsed = z + .object({ runId: z.string().min(1), stage: z.enum(["frontier", "spec"]) }) + .strict() + .safeParse(body) + if (!parsed.success) { + this.fail(res, 400, "want {runId, stage: frontier|spec}") + return + } + const record = (await loadRegistry(this.options.stateRoot))?.worktrees[parsed.data.runId] + if (record?.status !== "allocated") { + this.fail(res, 404, `unknown run ${JSON.stringify(parsed.data.runId)}`) + return + } + let subject: { subject: Array<{ path: string; sha256: string }>; summary: string[]; spendCeilingUSD?: number } + try { + subject = await approvalSubject(record.dir, parsed.data.stage) + } catch (error) { + this.fail(res, 400, error instanceof Error ? error.message : String(error)) + return + } + const pending = (await pendingApprovals(record.dir)).some((item) => item.stage === parsed.data.stage) + const subjectHash = hashJson(subject.subject) + const ticket = this.approveTickets.issue({ repoDir: record.dir, stage: parsed.data.stage, subjectHash }) + const session = parseSessionCookie(field(headers, "cookie")) + const csrf = session && this.sessions.valid(session) ? this.csrf.forSession(session) : "" + this.ok(res, { + ticket, + csrf, + subjectHash, + summary: subject.summary, + files: subject.subject, + ...(subject.spendCeilingUSD !== undefined ? { spendCeilingUSD: subject.spendCeilingUSD } : {}), + pending, + }) + } + + /** + * Browser approval submit (#131, ADR 0002 option b): browser-only by + * construction — session cookie, loopback Origin and session CSRF are + * all required before the ticket is even looked at. The passphrase + * unlocks the sealed key in-process; the signer is destroyed in a + * finally, and the passphrase is never stored, logged or returned. + */ + private async approveSubmit(headers: Map, rest: Buffer, res: HttpResponder): Promise { + const session = parseSessionCookie(field(headers, "cookie")) ?? "" + if (!this.sessions.valid(session)) { + res.writeHead(401, { "content-length": 0, connection: "close" }) + res.end() + return + } + if (!loopbackOriginOk(field(headers, "origin")) || !this.csrf.check(session, field(headers, "x-csrf"))) { + res.writeHead(403, { "content-length": 0, connection: "close" }) + res.end() + return + } + let body: unknown + try { + body = this.readBody(headers, rest) + } catch { + this.fail(res, 400, "parse error") + return + } + const parsed = z + .object({ ticket: z.string().min(1), passphrase: z.string().min(1).max(4096) }) + .strict() + .safeParse(body) + if (!parsed.success) { + this.fail(res, 400, "want {ticket, passphrase}") + return + } + const webDir = path.join(fleetPaths(this.options.stateRoot).dir, "web-approvals") + await mkdir(webDir, { recursive: true }) + const attempts = await checkApprovalAttempts(webDir) + if (!attempts.allowed) { + this.fail(res, 423, "too many wrong passphrases", { retryAfterSec: attempts.retryAfterSec }) + return + } + const entry = this.approveTickets.redeem(parsed.data.ticket) + if (!entry) { + this.fail(res, 403, "unknown or expired ticket; preview again") + return + } + let subject: { subject: Array<{ path: string; sha256: string }> } + try { + subject = await approvalSubject(entry.repoDir, entry.stage) + } catch (error) { + this.fail(res, 409, error instanceof Error ? error.message : String(error)) + return + } + if (hashJson(subject.subject) !== entry.subjectHash) { + this.fail(res, 409, "the preview is stale: the artifacts changed since the preview; preview again") + return + } + const { ticket: _ticket, passphrase } = parsed.data + void _ticket + let signer: Awaited> + try { + signer = await unlockHumanKey(passphrase, this.options.stateRoot) + } catch { + const check = await noteWrongPassphrase({ + dir: webDir, + root: entry.repoDir, + stage: entry.stage, + channel: "web", + }) + if (check.allowed) this.fail(res, 403, "wrong passphrase", { remaining: check.remaining }) + else this.fail(res, 423, "too many wrong passphrases", { retryAfterSec: check.retryAfterSec }) + return + } + try { + const result = await approveStage(entry.repoDir, { + stage: entry.stage, + channel: "web", + signer, + stateDir: this.options.stateRoot, + expectedSubjectHash: entry.subjectHash, + }) + await noteApprovalSuccess(webDir) + this.ok(res, { + ok: true, + stage: entry.stage, + alreadyApproved: result.alreadyApproved, + factoryStage: result.factoryStage ?? null, + }) + } catch (error) { + this.fail(res, 500, error instanceof Error ? error.message : String(error)) + } finally { + signer.destroy() + } + } + + private readBody(headers: Map, rest: Buffer): unknown { + const length = Number(field(headers, "content-length") ?? "0") + if (!Number.isInteger(length) || length < 0 || length > 1_000_000) throw new Error("bad length") + return JSON.parse(rest.subarray(0, length).toString("utf8")) + } + + private ok(res: HttpResponder, value: unknown): void { + const text = JSON.stringify(value) + res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }) + res.end(text) + } + + private fail(res: HttpResponder, code: number, message: string, extra: Record = {}): void { + const text = JSON.stringify({ error: message, ...extra }) + res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }) + res.end(text) + } + + /** Worktree dir for a run id, or a throw the bus reports as unknown-run. */ + private async runDir(runId: string): Promise { + const record = (await loadRegistry(this.options.stateRoot))?.worktrees[runId] + if (record?.status !== "allocated") throw new Error(`unknown run ${JSON.stringify(runId)}`) + return record.dir + } + + private async dispatch(method: RpcMethod, params: never): Promise { + const snapshot = await this.options.getSnapshot() + switch (method) { + case "fleet.status": + return snapshot + case "fleet.pending": + return { pending: snapshot.pending } + case "fleet.task": { + const id = (params as { id: string }).id + const task = snapshot.tasks.find((entry) => entry.id === id) + if (!task) throw new Error(`unknown task ${JSON.stringify(id)}`) + return { task } + } + case "fleet.task.liveness": { + const id = (params as { id: string }).id + return readTaskLiveness(await this.runDir(id), id) + } + case "fleet.events": { + const since = (params as { since?: number }).since ?? 0 + return { events: this.events.filter((event) => event.seq > since), now: this.events.at(-1)?.seq ?? 0 } + } + case "fleet.config.get": { + if (!this.options.configRoot) throw new Error("no repo is configured for the config preview") + return readConfigView(this.options.configRoot) + } + case "fleet.config.plan": { + if (!this.options.configRoot) throw new Error("no repo is configured for the config preview") + const input = params as { file: "config" | "gates"; content: unknown } + return planConfigFile(this.options.configRoot, { file: input.file, content: input.content }) + } + case "evidence.claims": { + const dir = await this.runDir((params as { runId: string }).runId) + const filter = (params as { filter?: Record }).filter ?? {} + return readEvidenceClaims(dir, { + ...(filter.tag ? { tag: filter.tag } : {}), + ...(filter.status ? { status: filter.status } : {}), + ...(filter.tier ? { tier: filter.tier } : {}), + ...(filter.text ? { text: filter.text } : {}), + }) + } + case "evidence.claim": { + const dir = await this.runDir((params as { runId: string }).runId) + const detail = await readEvidenceClaim(dir, (params as { id: string }).id) + if (!detail) throw new Error(`unknown claim ${JSON.stringify((params as { id: string }).id)}`) + return detail + } + case "evidence.source": { + const dir = await this.runDir((params as { runId: string }).runId) + const seen = await readEvidenceSource(dir, this.options.stateRoot, (params as { sha: string }).sha) + if (!seen) throw new Error("unknown or tampered source") + return seen + } + case "evidence.export": { + const dir = await this.runDir((params as { runId: string }).runId) + const format = (params as { format: "markdown" | "html" }).format + const rendered = await exportEvidence(dir, this.options.stateRoot, format) + if (!rendered) throw new Error("nothing to export yet (no dossier or report)") + return rendered + } + } + } + + private handleUpgrade(socket: Socket, headers: Map, res: HttpResponder): void { + const cookieOk = + loopbackOriginOk(field(headers, "origin")) && this.sessions.valid(parseSessionCookie(field(headers, "cookie"))) + if ( + !this.hostOk(field(headers, "host")) || + !this.originOk(field(headers, "origin")) || + (!this.bearerOk(field(headers, "authorization")) && !cookieOk) + ) { + const code = !this.hostOk(field(headers, "host")) || !this.originOk(field(headers, "origin")) ? 403 : 401 + res.writeHead(code, { "content-length": 0, connection: "close" }) + res.end() + return + } + const key = field(headers, "sec-websocket-key") ?? "" + // SHA-1 is mandated here by RFC 6455 §1.3 (the WebSocket accept hash); + // it authenticates nothing and protects no secret — the bus token check + // already ran before the upgrade. + const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64") // NOSONAR (typescript:S4790) + res.writeHead(101, { + Upgrade: "websocket", + Connection: "Upgrade", + "Sec-WebSocket-Accept": accept, + }) + const entry = { socket, channels: new Set(), buffer: Buffer.alloc(0) } + this.sockets.add(entry) + socket.on("data", (chunk: Buffer) => this.handleFrame(entry, chunk)) + socket.on("close", () => this.sockets.delete(entry)) + socket.on("error", () => this.sockets.delete(entry)) + } + + private originOk(origin: string | undefined): boolean { + if (!origin) return true + return /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(origin.trim()) + } + + private handleFrame(entry: { socket: Socket; channels: Set; buffer: Buffer }, chunk: Buffer): void { + entry.buffer = Buffer.concat([entry.buffer, chunk]) + for (;;) { + if (entry.buffer.length < 2) return + const first = entry.buffer[0]! + const second = entry.buffer[1]! + const opcode = first & 0x0f + const masked = (second & 0x80) !== 0 + let length = second & 0x7f + let offset = 2 + if (length === 126) { + if (entry.buffer.length < 4) return + length = entry.buffer.readUInt16BE(2) + offset = 4 + } else if (length === 127) { + if (entry.buffer.length < 10) return + length = Number(entry.buffer.readBigUInt64BE(2)) + offset = 10 + } + const maskSize = masked ? 4 : 0 + if (entry.buffer.length < offset + maskSize + length) return + let body = entry.buffer.subarray(offset + maskSize, offset + maskSize + length) + if (masked) { + const mask = entry.buffer.subarray(offset, offset + 4) + const out = Buffer.alloc(length) + for (let i = 0; i < length; i++) out[i] = body[i]! ^ mask[i % 4]! + body = out + } + entry.buffer = entry.buffer.subarray(offset + maskSize + length) + if (opcode === 0x8) { + entry.socket.end() + this.sockets.delete(entry) + return + } + if (opcode === 0x9) { + this.sendFrame(entry.socket, 0xa, body) + continue + } + if (opcode !== 0x1) continue + try { + const message = JSON.parse(body.toString("utf8")) as { subscribe?: unknown; since?: unknown } + const channels = Array.isArray(message.subscribe) + ? message.subscribe.filter((name): name is BusChannel => name === "changed" || name === "liveness") + : [] + for (const channel of channels) entry.channels.add(channel) + const since = typeof message.since === "number" ? message.since : 0 + for (const event of this.events.filter( + (candidate) => candidate.seq > since && entry.channels.has(candidate.type), + )) + this.sendFrame(entry.socket, 0x1, Buffer.from(JSON.stringify(scrubPayload(event, this.secrets())))) + } catch { + // Malformed subscribe: ignore, keep the socket. + } + } + } + + private sendFrame(socket: Socket, opcode: number, body: Buffer): void { + if (this.closing || socket.destroyed) return + if (socket.writableLength > 1024 * 1024) return + const head = + body.length < 126 + ? Buffer.from([0x80 | opcode, body.length]) + : body.length < 65536 + ? Buffer.from([0x80 | opcode, 126, (body.length >> 8) & 0xff, body.length & 0xff]) + : (() => { + const extended = Buffer.alloc(10) + extended[0] = 0x80 | opcode + extended[1] = 127 + extended.writeBigUInt64BE(BigInt(body.length), 2) + return extended + })() + socket.write(Buffer.concat([head, body])) + } + + /** Stage one event for coalesced broadcast (notifySoon: at most one emit per channel per second). */ + private stage(event: BusEvent): void { + const channel = event.type + // Drop to the latest per key (taskId): a burst for one task emits once. + const key = typeof event.payload.taskId === "string" ? event.payload.taskId : "" + this.pending[channel].set(key, event) + const now = Date.now() + if (now - this.lastFlush[channel] >= 1000) { + this.flush(channel) + return + } + if (this.flushTimer[channel] !== undefined) return + this.flushTimer[channel] = setTimeout( + () => { + this.flushTimer[channel] = undefined + this.flush(channel) + }, + Math.max(0, 1000 - (now - this.lastFlush[channel])), + ) + } + + private flush(channel: BusChannel): void { + this.lastFlush[channel] = Date.now() + const latest = [...this.pending[channel].values()] + this.pending[channel].clear() + for (const entry of this.sockets) { + if (!entry.channels.has(channel)) continue + for (const event of latest) + this.sendFrame(entry.socket, 0x1, Buffer.from(JSON.stringify(scrubPayload(event, this.secrets())))) + } + } +} + +/** Ports and tokens `es-fleet watch` reads to find a running daemon. */ +export async function readTelemetryEndpoint(stateRoot: string): Promise<{ port: number; token: string } | undefined> { + try { + const paths = fleetPaths(stateRoot) + const [endpoint, token] = await Promise.all([ + readFile(path.join(paths.dir, "telemetry.json"), "utf8"), + readFile(paths.token, "utf8"), + ]) + const port = (JSON.parse(endpoint) as { port?: unknown }).port + if (typeof port !== "number" || port <= 0) return undefined + return { port, token: token.trim() } + } catch { + return undefined + } +} diff --git a/packages/fleet/src/watch.ts b/packages/fleet/src/watch.ts new file mode 100644 index 0000000..6ee1aec --- /dev/null +++ b/packages/fleet/src/watch.ts @@ -0,0 +1,145 @@ +// `es-fleet watch` terminal dashboard (#126): a read-only view over the +// telemetry bus. Rendering is pure (`renderDashboard`, tested); the loop +// polls `fleet.status` and redraws until `q`. +import { pendingApprovals } from "@heretek-ai/es-core" +import { fleetStatus } from "./lifecycle.ts" +import { loadDag, type Task } from "./tasks.ts" +import { type BusEvent, type FleetSnapshot, readTelemetryEndpoint } from "./telemetry.ts" +import { loadRegistry } from "./worktree.ts" + +/** Read the full snapshot the bus serves (shared by the server and watch). */ +export async function readFleetSnapshot(stateRoot: string): Promise { + const [daemon, dag] = await Promise.all([fleetStatus(stateRoot), loadDag(stateRoot)]) + const tasks = dag + ? Object.values(dag.tasks) + .sort((a, b) => (a.id < b.id ? -1 : 1)) + .map((task) => ({ + id: task.id, + title: task.title, + status: task.status, + ceilingUSD: task.ceilingUSD, + spendUsd: task.result?.spendUsd ?? 0, + deps: [...task.deps].sort((a, b) => a.localeCompare(b)), + ...(task.reason ? { reason: task.reason } : {}), + })) + : [] + const spendUsd = tasks.reduce((sum, task) => sum + task.spendUsd, 0) + return { + daemon: { running: daemon.running, pid: daemon.pid, maxUsd: daemon.maxUsd, concurrency: daemon.concurrency }, + tasks, + spendUsd, + pending: await pendingEntries(stateRoot, tasks), + } +} + +/** + * Actionable approvals: every (task, stage) pair the worktrees actually + * hold pending (#131 links each pair to its approve page). A + * waiting-human task whose worktree is gone contributes nothing — its + * status row still reads waiting-human, but there is nothing to preview. + */ +async function pendingEntries( + stateRoot: string, + tasks: ReadonlyArray & { reason?: string }>, +): Promise> { + const registry = await loadRegistry(stateRoot).catch(() => undefined) + const found: Array<{ taskId: string; reason: string; stage: string }> = [] + for (const task of tasks) { + if (task.status !== "waiting-human") continue + const dir = registry?.worktrees[task.id]?.dir + const stages = dir ? await pendingApprovals(dir).catch(() => []) : [] + for (const entry of stages) found.push({ taskId: task.id, reason: task.reason ?? entry.stage, stage: entry.stage }) + } + return found +} + +/** Render one dashboard frame: headline, task table, pending approvals, recent events. */ +export function renderDashboard(snapshot: FleetSnapshot, recent: readonly BusEvent[]): string { + const lines: string[] = [] + const { daemon } = snapshot + lines.push( + daemon.running + ? `Fleet running (pid ${daemon.pid}, spend $${snapshot.spendUsd.toFixed(2)} / $${daemon.maxUsd}, concurrency ${daemon.concurrency})` + : "Fleet stopped.", + ) + lines.push("") + if (snapshot.tasks.length === 0) lines.push("No tasks.") + else { + lines.push(`${"TASK".padEnd(16)}${"STATUS".padEnd(14)}${"CEIL".padEnd(8)}${"SPENT".padEnd(8)}DETAIL`) + for (const task of snapshot.tasks) + lines.push( + `${task.id.padEnd(16)}${task.status.padEnd(14)}$${task.ceilingUSD.toFixed(0).padStart(6)} $${task.spendUsd.toFixed(2).padStart(6)} ${task.reason ?? ""}`.trimEnd(), + ) + } + if (snapshot.pending.length > 0) { + lines.push("") + lines.push("Waiting on a human:") + for (const item of snapshot.pending) lines.push(` ${item.taskId}: ${item.reason}`) + } + const tail = recent.slice(-5) + if (tail.length > 0) { + lines.push("") + lines.push("Recent events:") + for (const event of tail) lines.push(` #${event.seq} ${event.type} ${JSON.stringify(event.payload)}`) + } + return `${lines.join("\n")}\n` +} + +export interface WatchOptions { + readonly stateRoot: string + readonly intervalMs?: number +} + +const rpcCall = async (port: number, token: string, method: string, params: unknown): Promise => { + const response = await fetch(`http://127.0.0.1:${port}/fleet/rpc`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ method, params }), + }) + return (await response.json()) as unknown +} + +/** Poll the bus and redraw until `q`. Needs a TTY; refuses otherwise. */ +export async function watchFleet(options: WatchOptions): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + process.stderr.write("es-fleet watch needs an interactive terminal.\n") + return 1 + } + const endpoint = await readTelemetryEndpoint(options.stateRoot) + if (!endpoint) { + process.stderr.write("No fleet telemetry endpoint: is the daemon running?\n") + return 1 + } + let since = 0 + let quit = false + process.stdin.setRawMode(true) + process.stdin.resume() + const onKey = (chunk: Buffer) => { + if (chunk.toString().includes("q")) quit = true + } + process.stdin.on("data", onKey) + try { + while (!quit) { + try { + const [status, events] = await Promise.all([ + rpcCall(endpoint.port, endpoint.token, "fleet.status", {}), + rpcCall(endpoint.port, endpoint.token, "fleet.events", { since }), + ]) + const snapshot = (status as { result: FleetSnapshot }).result + const backlog = (events as { result: { events: BusEvent[] } }).result.events + if (backlog.length > 0) since = backlog.at(-1)!.seq + process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write(renderDashboard(snapshot, backlog)) + process.stdout.write("\nq quits.\n") + } catch (error) { + process.stdout.write(`telemetry unreachable: ${error instanceof Error ? error.message : String(error)}\n`) + } + await new Promise((resolve) => setTimeout(resolve, options.intervalMs ?? 2000)) + } + } finally { + process.stdin.removeListener("data", onKey) + process.stdin.setRawMode(false) + process.stdin.pause() + } + return 0 +} diff --git a/packages/fleet/src/web.ts b/packages/fleet/src/web.ts new file mode 100644 index 0000000..219048e --- /dev/null +++ b/packages/fleet/src/web.ts @@ -0,0 +1,203 @@ +// Web control plane serving (#129, clean-room): the fleet daemon serves the +// built UI from the bus port behind a single-use ticket exchange. `es-fleet +// web` (human-only) mints a 32-byte ticket and prints a one-time URL; the +// first load redeems it for an HttpOnly, SameSite=Strict session cookie. +// Tickets live as files under the masked state dir so the printing process +// and the daemon can share them without a new channel; sessions live only +// in the daemon's memory. `Secure` is deliberately absent from the cookie: +// the bus is plain HTTP on loopback, and a Secure cookie would never be +// sent back; the localhost bind plus Host/Origin checks are the transport +// defense instead. +import { randomBytes } from "node:crypto" +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises" +import path from "node:path" +import { fleetDir } from "./state.ts" + +/** How long a minted ticket stays redeemable. */ +export const WEB_TICKET_TTL_MS = 120_000 +/** How long a redeemed session stays valid (absolute, no rolling). */ +export const WEB_SESSION_TTL_MS = 12 * 3_600_000 +/** Session cookie name. */ +export const WEB_SESSION_COOKIE = "es_fleet_web" + +/** Strict headers on every web response: no inline scripts, no framing. */ +export const WEB_SECURITY_HEADERS: Record = { + "content-security-policy": + "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; " + + "img-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; " + + "form-action 'self'; frame-ancestors 'none'", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "referrer-policy": "no-referrer", + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", +} + +const TICKET_NAME = /^[0-9a-f]{64}$/ +const SESSION_ID = /^[0-9a-f]{64}$/ + +const ticketDir = (stateRoot: string): string => path.join(fleetDir(stateRoot), "web-tickets") + +/** Mint a single-use ticket (32 random bytes, hex) valid for WEB_TICKET_TTL_MS. */ +export async function mintWebTicket(stateRoot: string): Promise { + const ticket = randomBytes(32).toString("hex") + const dir = ticketDir(stateRoot) + await mkdir(dir, { recursive: true }) + await writeFile(path.join(dir, ticket), `${Date.now() + WEB_TICKET_TTL_MS}\n`, { mode: 0o600 }) + // Best-effort sweep of expired litter: never fails the mint. + try { + const now = Date.now() + for (const entry of await readdir(dir)) { + if (!TICKET_NAME.test(entry) || entry === ticket) continue + const file = path.join(dir, entry) + try { + if (Number((await readFile(file, "utf8")).trim()) <= now) await rm(file, { force: true }) + } catch { + // Raced with a redeem, or unreadable: leave it for the next sweep. + } + } + } catch { + // The sweep is hygiene, not correctness. + } + return ticket +} + +/** + * Redeem a ticket exactly once: malformed, missing and expired tickets are + * refused, and the claim is an atomic rename — two concurrent redeems cannot + * both win. The name is hex-only so no path traversal is possible. + */ +export async function redeemWebTicket(stateRoot: string, ticket: string): Promise { + if (!TICKET_NAME.test(ticket)) return false + const dir = ticketDir(stateRoot) + const claimed = path.join(dir, `${ticket}.claimed-${process.pid}-${randomBytes(4).toString("hex")}`) + try { + await rename(path.join(dir, ticket), claimed) + } catch { + return false + } + let raw: string + try { + raw = await readFile(claimed, "utf8") + } catch { + return false + } finally { + await rm(claimed, { force: true }) + } + return Number(raw.trim()) > Date.now() +} + +/** In-memory web sessions: created on ticket redeem, checked per request. */ +export class WebSessions { + private readonly live = new Map() + + create(): string { + this.purge() + const id = randomBytes(32).toString("hex") + this.live.set(id, Date.now() + WEB_SESSION_TTL_MS) + return id + } + + valid(id: string | undefined): boolean { + if (id === undefined || !SESSION_ID.test(id)) return false + const exp = this.live.get(id) + if (exp === undefined || exp <= Date.now()) { + this.live.delete(id) + return false + } + return true + } + + private purge(): void { + const now = Date.now() + for (const [id, exp] of this.live) if (exp <= now) this.live.delete(id) + } +} + +/** `Set-Cookie` value for a session: HttpOnly, SameSite=Strict, root path. */ +export function sessionSetCookie(id: string): string { + return `${WEB_SESSION_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Strict` +} + +/** Extract the session id from a `Cookie` header, if present. */ +export function parseSessionCookie(header: string | undefined): string | undefined { + if (!header) return undefined + for (const part of header.split(";")) { + const at = part.indexOf("=") + if (at > 0 && part.slice(0, at).trim() === WEB_SESSION_COOKIE) { + const value = part.slice(at + 1).trim() + if (SESSION_ID.test(value)) return value + } + } + return undefined +} + +/** Loopback origins only (the UI is served from 127.0.0.1, never the open net). */ +export function loopbackOriginOk(origin: string | undefined): boolean { + return origin !== undefined && /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(origin.trim()) +} + +const CONTENT_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".txt": "text/plain; charset=utf-8", + ".woff2": "font/woff2", +} + +export function contentTypeFor(file: string): string { + return CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream" +} + +export interface WebFile { + readonly file: string + readonly contentType: string +} + +/** + * Resolve a request target to a file under `webRoot`, fail-closed: decoded + * traversal that escapes the root, and anything but a regular file, falls + * back to `fallback` (the SPA entry) or refuses when even that is missing. + * Query strings are stripped before resolution. + */ +export async function resolveWebFile( + webRoot: string, + target: string, + fallback = "index.html", +): Promise { + const root = path.resolve(webRoot) + let pathname = target.split("?", 1)[0] ?? "/" + try { + pathname = decodeURIComponent(pathname) + } catch { + return undefined + } + const candidate = path.resolve(root, `.${pathname.startsWith("/") ? pathname : `/${pathname}`}`) + const inside = candidate === root || candidate.startsWith(`${root}${path.sep}`) + const direct = inside ? await regularFile(candidate) : undefined + if (direct) return { file: direct, contentType: contentTypeFor(direct) } + // Refuse escapes outright; ordinary misses fall back to the SPA entry. + if (!inside) return undefined + const entry = await regularFile(path.join(root, fallback)) + return entry ? { file: entry, contentType: contentTypeFor(entry) } : undefined +} + +async function regularFile(file: string): Promise { + try { + const info = await stat(file) + if (info.isDirectory()) { + const index = path.join(file, "index.html") + const nested = await stat(index) + return nested.isFile() ? index : undefined + } + return info.isFile() ? file : undefined + } catch { + return undefined + } +} diff --git a/packages/fleet/src/worker.ts b/packages/fleet/src/worker.ts new file mode 100644 index 0000000..ab77e17 --- /dev/null +++ b/packages/fleet/src/worker.ts @@ -0,0 +1,394 @@ +// Fleet workers (#125, clean-room): one headless run per task, spawned with +// argv only (no shell), each task's ceiling provisioned into its run state, +// headless JSONL events folded into task reports with spend/token tracking, +// StillDown pre-restart probes and a mass-death circuit breaker. +import { type ChildProcess, spawn } from "node:child_process" +import { createInterface } from "node:readline" +import { Factory, gateRunner, readJson, run, writeJson } from "@heretek-ai/es-core" +import { FleetError, isPidAlive } from "./lifecycle.ts" +import type { TaskReport } from "./scheduler.ts" +import { fleetPaths } from "./state.ts" + +export interface WorkerSpec { + readonly taskId: string + readonly worktreeDir: string + readonly ceilingUSD: number + readonly kind: "factory-run" | "research-run" + readonly objective: string + /** Path to the `es` CLI entry (bin/es.js). Tests inject a stub script. */ + readonly esBin: string +} + +/** + * Argv for the task's headless run. Argv-only: the caller spawns this array + * directly, never through a shell. Note there is deliberately no `--max-usd` + * here: `es factory run` has no such flag (verified against the CLI), so the + * ceiling is provisioned into the run state by `prepareTaskRun`, where the + * factory's own guard enforces it. + */ +export function buildWorkerArgv(spec: WorkerSpec): string[] { + if (spec.kind !== "factory-run") + throw new FleetError( + `Task ${spec.taskId} is a research-run: no headless research driver exists yet (research runs need one from #110/#111); refusing to guess.`, + ) + if (!(spec.ceilingUSD > 0)) throw new FleetError(`Task ${spec.taskId} needs a positive ceiling.`) + return [spec.esBin, "factory", "run", "--headless", "--events", "jsonl", "--cwd", spec.worktreeDir] +} + +/** + * Give a task worktree a runnable run with the task's own ceiling, using + * core's Factory API (the daemon runs as the human-started process, so this + * is the human provisioning the ceiling, never an agent). + */ +export async function prepareTaskRun(spec: WorkerSpec): Promise { + if (!(spec.ceilingUSD > 0)) throw new FleetError(`Task ${spec.taskId} needs a positive ceiling.`) + const factory = new Factory(spec.worktreeDir, { gates: gateRunner({}) }) + if (spec.kind === "factory-run") { + await factory.provisionRun("fleet-daemon", spec.ceilingUSD) + return + } + const objective = spec.objective.trim() + if (!objective) throw new FleetError(`Task ${spec.taskId} is a research-run without an objective.`) + await factory.beginResearchRun({ objective, ceilingUSD: spec.ceilingUSD }, "fleet-daemon") +} + +export interface SpendAcc { + spendUsd: number + tokensIn: number + tokensOut: number +} + +export const emptyAcc = (): SpendAcc => ({ spendUsd: 0, tokensIn: 0, tokensOut: 0 }) + +export interface WorkerEnvelope { + readonly v: number + readonly at: string + readonly runId: string + readonly kind: string + readonly event: { readonly type: string; readonly [key: string]: unknown } +} + +export interface FoldedEvent { + readonly acc: SpendAcc + readonly report?: TaskReport +} + +const textOf = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined + +const reasonOf = (event: WorkerEnvelope["event"], fallback: string): string => + textOf(event.reason) ?? textOf(event.message) ?? textOf(event.headline) ?? fallback + +/** + * Fold one headless envelope into spend/token accumulation and, for terminal + * or waiting events, a task report. Pure: same envelope and acc give the + * same fold. + */ +export function foldWorkerEvent(acc: SpendAcc, envelope: WorkerEnvelope): FoldedEvent { + const event = envelope.event + if (event.type === "turn-metrics") { + const numberOf = (value: unknown): number => (typeof value === "number" ? value : 0) + const cost = numberOf(event.costUSD) + const tokensIn = numberOf(event.tokensIn) + const tokensOut = numberOf(event.tokensOut) + return { + acc: { spendUsd: acc.spendUsd + cost, tokensIn: acc.tokensIn + tokensIn, tokensOut: acc.tokensOut + tokensOut }, + } + } + switch (event.type) { + case "waiting": + return { acc, report: { outcome: "waiting-human", reason: reasonOf(event, "run waits for a human") } } + case "done": + return { acc, report: { outcome: "done", spendUsd: acc.spendUsd } } + case "halted": + case "stalled": + case "turn-cap": + case "error": + return { + acc, + report: { outcome: "failed", reason: reasonOf(event, `run ${event.type}`), spendUsd: acc.spendUsd }, + } + case "cancelled": + return { acc, report: { outcome: "cancelled", reason: reasonOf(event, "run cancelled") } } + default: + return { acc } + } +} + +export interface WorkerOutcome { + /** Final task status from the run's terminal event (or the exit fallback). */ + readonly status: "done" | "failed" | "cancelled" | "waiting-human" + readonly reason?: string + readonly spendUsd: number +} + +export interface WorkerOptions extends WorkerSpec { + /** Spawn override (tests inject a stub script). Default: the es CLI argv. */ + readonly spawn?: { readonly command: string; readonly args: readonly string[] } + readonly onReport: (taskId: string, report: TaskReport) => void + /** Grace period for SIGTERM before SIGKILL (ms). */ + readonly stopGraceMs?: number +} + +/** + * Supervises one task's headless child: parses versioned JSONL envelopes off + * stdout, folds them into reports, and synthesizes a terminal outcome from + * the exit code when the run dies without a final event (130 → cancelled, + * anything else → failed). Never uses a shell. + */ +export class Worker { + private child: ChildProcess | undefined + private acc: SpendAcc = emptyAcc() + private terminal: TaskReport | undefined + private startedResolve: ((pid: number) => void) | undefined + readonly started: Promise + + constructor(private readonly options: WorkerOptions) { + this.started = new Promise((resolve) => { + this.startedResolve = resolve + }) + } + + snapshot(): SpendAcc & { taskId: string; pid: number | undefined } { + return { taskId: this.options.taskId, pid: this.child?.pid, ...this.acc } + } + + private emit(report: TaskReport): void { + if (report.outcome === "done" || report.outcome === "failed") + report = { ...report, spendUsd: report.spendUsd ?? this.acc.spendUsd } + if (this.terminal === undefined) { + this.terminal = report + this.options.onReport(this.options.taskId, report) + } + } + + async run(): Promise { + const spawnArgs = this.options.spawn ?? { + command: this.options.esBin, + args: buildWorkerArgv(this.options).slice(1), + } + const child = spawn(spawnArgs.command, [...spawnArgs.args], { stdio: ["ignore", "pipe", "pipe"], env: process.env }) + this.child = child + this.startedResolve?.(child.pid ?? -1) + const stderrTail: string[] = [] + child.stderr?.on("data", (chunk: Buffer) => { + stderrTail.push(chunk.toString()) + if (stderrTail.length > 20) stderrTail.shift() + }) + const lines = createInterface({ input: child.stdout! }) + const finished = new Promise<{ code: number | null; signal: string | null }>((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })) + child.once("error", () => resolve({ code: 1, signal: null })) + }) + try { + for await (const line of lines) { + let envelope: WorkerEnvelope | undefined + try { + const raw = JSON.parse(line) as { + kind?: unknown + event?: unknown + v?: unknown + at?: unknown + runId?: unknown + } + if (typeof raw.kind === "string" && typeof raw.event === "object" && raw.event !== null) + envelope = { + v: typeof raw.v === "number" ? raw.v : 0, + at: typeof raw.at === "string" ? raw.at : "", + runId: typeof raw.runId === "string" ? raw.runId : "", + kind: raw.kind, + event: raw.event as WorkerEnvelope["event"], + } + } catch { + continue + } + if (!envelope) continue + const folded = foldWorkerEvent(this.acc, envelope) + this.acc = folded.acc + if (folded.report) this.emit(folded.report) + } + } finally { + lines.removeAllListeners() + } + const { code, signal } = await finished + if (this.terminal) return toOutcome(this.terminal, this.acc.spendUsd) + // Killed by a signal (the daemon's SIGTERM stop): the run is cancelled + // and resumable, exactly like the headless exit-130 path. + if (signal !== null || code === 130) { + const report: TaskReport = { + outcome: "cancelled", + reason: signal !== null ? `run stopped by ${signal}; resumable` : "run exited 130 (SIGTERM); resumable", + } + this.emit(report) + return toOutcome(report, this.acc.spendUsd) + } + const tail = stderrTail.join("").trim().slice(-300) + const report: TaskReport = { + outcome: "failed", + reason: `run exited with code ${code} and no final event${tail ? `: ${tail}` : ""}`, + spendUsd: this.acc.spendUsd, + } + this.emit(report) + return toOutcome(report, this.acc.spendUsd) + } + + /** SIGTERM the child (the headless run records `cancelled`, exit 130). */ + async stop(graceMs = this.options.stopGraceMs ?? 5_000): Promise { + const child = this.child + if (!child || child.exitCode !== null || child.signalCode !== null) return + child.kill("SIGTERM") + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()) + }) + const timeout = new Promise((resolve) => setTimeout(resolve, graceMs)) + await Promise.race([exited, timeout]) + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") + await exited + } +} + +const toOutcome = (report: TaskReport, spendUsd: number): WorkerOutcome => { + if (report.outcome === "running") return { status: "failed", reason: "run ended while reporting running", spendUsd } + if (report.outcome === "cancelled") return { status: "cancelled", reason: report.reason, spendUsd } + return { status: report.outcome, reason: report.reason, spendUsd } +} + +export class WorkerError extends Error { + constructor(message: string) { + super(message) + this.name = "WorkerError" + } +} + +/** + * Tracks live workers across one daemon: starting an already-tracked task is + * refused (never double-start), and a daemon restart reattaches to live pids + * while dead ones are reported as failed for the failure policy to decide. + */ +export class WorkerSupervisor { + private readonly live = new Map() + + track(taskId: string, pid: number, acc: SpendAcc = emptyAcc()): void { + this.live.set(taskId, { pid, acc }) + } + + untrack(taskId: string): void { + this.live.delete(taskId) + } + + isTracked(taskId: string): boolean { + return this.live.has(taskId) + } + + entries(): Array<{ taskId: string; pid: number }> { + return [...this.live.entries()].map(([taskId, record]) => ({ taskId, pid: record.pid })) + } + + /** Throw when the task must not be (re)started: it is already tracked. */ + requireStartable(taskId: string): void { + if (this.live.has(taskId)) + throw new WorkerError(`Task ${taskId} is already running under this daemon; refusing a second worker.`) + } + + recover( + records: ReadonlyArray<{ taskId: string; pid: number }>, + _now: number, + ): { reattached: string[]; failed: Array<{ taskId: string; reason: string }> } { + const reattached: string[] = [] + const failed: Array<{ taskId: string; reason: string }> = [] + for (const record of records) { + if (isPidAlive(record.pid)) { + this.live.set(record.taskId, { pid: record.pid, acc: emptyAcc() }) + reattached.push(record.taskId) + } else { + failed.push({ taskId: record.taskId, reason: "daemon restarted while the run was gone; re-queue by policy" }) + } + } + return { reattached, failed } + } +} + +export interface BreakerOptions { + /** Crashes per task inside the window that refuse a restart. */ + readonly maxRestarts: number + /** Window (ms) crash counts are kept for. */ + readonly windowMs: number + /** How long a refusal lasts after the last crash (ms). */ + readonly cooldownMs: number + /** Total crashes inside the window that open the breaker for every task. */ + readonly massThreshold: number +} +/** + * StillDown circuit breaker (clean-room): a pre-restart probe refuses to + * relaunch a task that keeps crashing, and a mass-death probe refuses all + * launches while many workers die at once. Both recover after the cooldown. + */ +export class CircuitBreaker { + private readonly crashes = new Map() + private openUntil = 0 + + constructor(private readonly options: BreakerOptions) {} + + noteCrash(taskId: string, now: number): void { + const kept = [...(this.crashes.get(taskId) ?? []), now].filter((at) => now - at < this.options.windowMs) + this.crashes.set(taskId, kept) + const total = [...this.crashes.values()].flat().filter((at) => now - at < this.options.windowMs).length + if (total >= this.options.massThreshold) this.openUntil = Math.max(this.openUntil, now + this.options.cooldownMs) + } + + probe(taskId: string, now: number): "ok" | { refused: string } { + if (now < this.openUntil) + return { refused: `mass worker death: launches paused until ${new Date(this.openUntil).toISOString()}` } + const recent = (this.crashes.get(taskId) ?? []).filter((at) => now - at < this.options.windowMs) + if (recent.length >= this.options.maxRestarts) { + const retryAt = Math.max(...recent) + this.options.cooldownMs + if (now < retryAt) + return { + refused: `task ${taskId} crashed ${recent.length} times: StillDown probe refuses a restart until ${new Date(retryAt).toISOString()}`, + } + } + return "ok" + } +} + +const WorkerPidsSchema = (raw: unknown): Record => { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {} + const pids: Record = {} + for (const [taskId, pid] of Object.entries(raw as Record)) + if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) pids[taskId] = pid + return pids +} + +/** + * Worker pid table (`/fleet/workers.json`): the daemon is its sole + * writer (updated on track/untrack), so a restarted daemon can reattach to + * live runs or fail the dead ones. Unknown shapes read as empty. + */ +export async function loadWorkerPids(stateRoot: string): Promise> { + let raw: unknown + try { + raw = await readJson(fleetPaths(stateRoot).workers) + } catch { + return {} + } + return raw === undefined ? {} : WorkerPidsSchema(raw) +} + +export async function saveWorkerPids(stateRoot: string, pids: Record): Promise { + await writeJson(fleetPaths(stateRoot).workers, pids) +} + +/** + * Default gate check for landings: `es gates run` in the task worktree + * (agent-safe, argv-only, no shell). `{ ok: true }` only on exit 0. + */ +export const defaultGateCheck = + (esBin: string) => + async (worktreeDir: string): Promise<{ ok: true } | { ok: false; reason?: string }> => { + try { + const result = await run([esBin, "--cwd", worktreeDir, "gates", "run"], { cwd: worktreeDir, timeoutMs: 300_000 }) + return result.code === 0 ? { ok: true } : { ok: false, reason: `gates exited ${result.code}` } + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) } + } + } diff --git a/packages/fleet/src/worktree.ts b/packages/fleet/src/worktree.ts new file mode 100644 index 0000000..3e3e16c --- /dev/null +++ b/packages/fleet/src/worktree.ts @@ -0,0 +1,522 @@ +// Fleet worktree coordinator (#124, clean-room): one isolated git worktree +// per task on its own branch, a locked registry in the state dir, salvage +// before destructive cleanup, orphan gc, and gated transactional landing +// onto an integration branch. The base branch is never checked out, reset, +// or merged into: every assertion the suite makes is `tip(main)`-stable. +// +// Concurrency discipline: one lock order, repo -> fleet. The allocator +// pre-registers `allocating` under the fleet lock before `addWorktree` and +// flips it to `allocated` afterwards, never holding both locks at once; `gc` +// holds the fleet lock for its entire read -> decide -> persist nested inside +// the per-repo admin lock, so it can neither remove a just-allocated worktree +// as an orphan nor overwrite the registry without the new record. All other +// `git worktree` administration takes the per-repo lock so concurrent +// allocators cannot corrupt the worktree index. +import { lstat, mkdir, readdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises" +import path from "node:path" +import { + addWorktree, + branchExists, + GitError, + git as gitRun, + headCommit, + readJson, + removeWorktree, + revParse, + withLock, + writeJson, +} from "@heretek-ai/es-core" +import { z } from "zod" +import { FleetError } from "./lifecycle.ts" +import { fleetPaths } from "./state.ts" + +const SLUG = /^[a-z0-9][a-z0-9-]*$/ + +const checkSlug = (kind: string, value: string): void => { + if (!SLUG.test(value) || value.length > 64) + throw new FleetError(`Invalid ${kind} ${JSON.stringify(value)}: lowercase alphanumerics and dashes only.`) +} + +/** Branch carrying one task's work. Never the base branch. */ +export const fleetBranch = (taskId: string): string => { + checkSlug("task id", taskId) + return `fleet/${taskId}` +} + +/** Integration branch receiving a DAG's gated merges. */ +export const integrationBranch = (dagId: string): string => { + checkSlug("dag id", dagId) + return `fleet/integration/${dagId}` +} + +/** Worktree checkout dir for a task (inside the repo, gitignored). */ +export const fleetWorktreeDir = (repoRoot: string, taskId: string): string => { + checkSlug("task id", taskId) + return path.join(repoRoot, ".fleet", "worktrees", taskId) +} + +export const WorktreeStatusSchema = z.enum(["allocating", "allocated", "merged", "abandoned"]) +export type WorktreeStatus = z.infer + +/** An `allocating` record older than this with no dir or branch is stale: gc drops it. */ +export const ALLOCATING_STALE_MS = 5 * 60_000 + +export const WorktreeRecordSchema = z.object({ + taskId: z.string().min(1), + dir: z.string().min(1), + branch: z.string().min(1), + base: z.string().regex(/^[0-9a-f]{40}$/), + createdAt: z.string().datetime(), + status: WorktreeStatusSchema, +}) +export type WorktreeRecord = z.infer + +export const REGISTRY_VERSION = 1 as const + +export const WorktreeRegistrySchema = z.object({ + v: z.literal(REGISTRY_VERSION), + worktrees: z.record(z.string(), WorktreeRecordSchema), +}) +export type WorktreeRegistry = z.infer + +export const emptyRegistry = (): WorktreeRegistry => ({ v: REGISTRY_VERSION, worktrees: {} }) + +/** Load the registry; undefined when missing, corrupt, or versioned away. */ +export async function loadRegistry(stateRoot: string): Promise { + let raw: unknown + try { + raw = await readJson(fleetPaths(stateRoot).worktrees) + } catch { + return undefined + } + if (raw === undefined) return undefined + const parsed = WorktreeRegistrySchema.safeParse(raw) + return parsed.success ? parsed.data : undefined +} + +async function persistRegistry(paths: ReturnType, registry: WorktreeRegistry): Promise { + await writeJson(paths.worktrees, registry) +} + +/** Throwing git: like core's `git` but without the allow-fail escape hatch. */ +async function git(root: string, args: readonly string[]) { + return gitRun(root, args) +} + +/** + * Per-repo administration mutex: every `git worktree` add/remove/prune and + * every landing merge takes it, so concurrent fleet operations cannot corrupt + * the worktree index. + */ +export async function withRepoLock(repoRoot: string, fn: () => Promise): Promise { + return withLock(path.join(repoRoot, ".fleet", "admin"), fn) +} + +/** + * Recursive copy without a shell (files, dirs, symlinks-as-symlinks). + * Refuses to write into an existing destination. + */ +export async function mirrorDir(from: string, to: string): Promise { + await mkdir(path.dirname(to), { recursive: true }) + try { + await lstat(to) + throw new FleetError(`mirrorDir refuses to overwrite existing ${to}.`) + } catch (error) { + if (error instanceof FleetError) throw error + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error + } + await mkdir(to, { recursive: true }) + for (const entry of await readdir(from, { withFileTypes: true })) { + const src = path.join(from, entry.name) + const dst = path.join(to, entry.name) + if (entry.isSymbolicLink()) await symlink(await readlink(src), dst) + else if (entry.isDirectory()) await mirrorDir(src, dst) + else if (entry.isFile()) await writeFile(dst, await readFile(src)) + } +} + +const SKIP_DIRS = new Set([".git", "node_modules"]) + +/** + * Rewrite absolute symlinks pointing inside `repoRoot` to point at the same + * path under `worktreeDir` (monorepo symlink rebasing for fresh worktrees). + * Returns the rewritten link paths. + */ +export async function rebaseSymlinks(worktreeDir: string, repoRoot: string): Promise { + const rewritten: string[] = [] + const walk = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isSymbolicLink()) { + const target = await readlink(full) + if (path.isAbsolute(target)) { + const relative = path.relative(repoRoot, target) + if (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) { + await rm(full) + await symlink(path.join(worktreeDir, relative), full) + rewritten.push(full) + } + } + } else if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) { + await walk(full) + } + } + } + await walk(worktreeDir) + return rewritten.sort((a, b) => a.localeCompare(b)) +} + +export interface AllocateOptions { + /** Commit the branch starts from (recorded; defaults to HEAD). */ + readonly base?: string + /** Stamp override (tests, determinism). */ + readonly now?: string + /** + * Test-only interleaving hooks (repair-151 S5.1): `beforePreRegister` runs + * after the early check and before the `allocating` pre-register, so a test + * can make two allocators of one task pass the check together; `afterAdd` + * runs after `addWorktree` (repo lock released) and before the `allocated` + * commit, so a test can run `gc` in exactly that window. Production callers + * pass none. + */ + readonly hooks?: { readonly beforePreRegister?: () => Promise; readonly afterAdd?: () => Promise } +} + +/** + * Allocate a task worktree: pre-register `allocating` under the fleet lock, + * do the git work under the repo lock (never holding both at once), then + * flip to `allocated` under the fleet lock. A live record or an existing dir + * refuses with FleetError; a stale `allocating` record may be taken over. + */ +export async function allocateWorktree( + repoRoot: string, + stateRoot: string, + taskId: string, + options: AllocateOptions = {}, +): Promise { + checkSlug("task id", taskId) + const paths = fleetPaths(stateRoot) + const dir = fleetWorktreeDir(repoRoot, taskId) + const branch = fleetBranch(taskId) + const now = options.now ?? new Date().toISOString() + // Phase 1: an early check, so a taken task fails before any disk work. + await withLock(paths.lock, async () => { + refuseTaken(((await loadRegistry(stateRoot)) ?? emptyRegistry()).worktrees[taskId], taskId) + }) + try { + await lstat(dir) + throw new FleetError(`Worktree dir ${dir} already exists; refusing to reuse it.`) + } catch (error) { + if (error instanceof FleetError) throw error + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error + } + // Pre-register under the fleet lock, before any git work: gc treats + // `allocating` as live, so it can neither orphan the new worktree nor + // persist a registry without this record. The check is repeated in this + // same critical section: two allocators of one task can both pass phase 1, + // and the second must refuse here, before it can overwrite the first's + // record (and later drop it on its own failed `addWorktree`). + const base = options.base ?? (await git(repoRoot, ["rev-parse", "HEAD"])).stdout.trim() + await options.hooks?.beforePreRegister?.() + await withLock(paths.lock, async () => { + const registry = (await loadRegistry(stateRoot)) ?? emptyRegistry() + refuseTaken(registry.worktrees[taskId], taskId) + const record: WorktreeRecord = { taskId, dir, branch, base, createdAt: now, status: "allocating" } + await persistRegistry(paths, { v: REGISTRY_VERSION, worktrees: { ...registry.worktrees, [taskId]: record } }) + }) + // Git work under the per-repo admin lock (the fleet lock is not held). + try { + await withRepoLock(repoRoot, async () => { + await addWorktree(repoRoot, dir, branch, base) + }) + } catch (error) { + await dropAllocating(paths, stateRoot, taskId, dir) + throw error + } + await options.hooks?.afterAdd?.() + await rebaseSymlinks(dir, repoRoot) + // Phase 2: flip to `allocated` with a re-check (a concurrent allocator may have won). + let record: WorktreeRecord | undefined + await withLock(paths.lock, async () => { + const registry = (await loadRegistry(stateRoot)) ?? emptyRegistry() + if (registry.worktrees[taskId]?.status === "allocated") { + record = registry.worktrees[taskId] + return + } + record = { taskId, dir, branch, base, createdAt: now, status: "allocated" as const } + await persistRegistry(paths, { v: REGISTRY_VERSION, worktrees: { ...registry.worktrees, [taskId]: record } }) + }) + if (registryHadRaced(record!, taskId, dir)) { + // We lost the race after creating the worktree: tear ours down. + await withRepoLock(repoRoot, async () => { + await removeWorktree(repoRoot, dir) + }) + throw new FleetError(`Task ${JSON.stringify(taskId)} was allocated concurrently; refusing a second worktree.`) + } + return record! +} + +/** True when an `allocating` record is older than the stale threshold. */ +const allocatingStale = (record: WorktreeRecord, nowMs: number): boolean => + nowMs - Date.parse(record.createdAt) >= ALLOCATING_STALE_MS + +/** + * Refuse a task that is already taken: an `allocated` record, or a fresh + * `allocating` one (another allocator is in flight). A stale `allocating` + * record is abandoned and may be taken over. Callers hold the fleet lock. + */ +function refuseTaken(current: WorktreeRecord | undefined, taskId: string): void { + if (current?.status === "allocated") + throw new FleetError(`Task ${JSON.stringify(taskId)} already has a worktree allocated.`) + if (current?.status === "allocating" && !allocatingStale(current, Date.now())) + throw new FleetError(`Task ${JSON.stringify(taskId)} is already being allocated concurrently.`) +} + +/** Remove our own `allocating` pre-registration (failed `addWorktree`). */ +async function dropAllocating( + paths: ReturnType, + stateRoot: string, + taskId: string, + dir: string, +): Promise { + await withLock(paths.lock, async () => { + const registry = (await loadRegistry(stateRoot)) ?? emptyRegistry() + const current = registry.worktrees[taskId] + if (current?.status === "allocating" && current.dir === dir) { + const next = { ...registry.worktrees } + delete next[taskId] + await persistRegistry(paths, { v: REGISTRY_VERSION, worktrees: next }) + } + }).catch(() => undefined) +} + +const registryHadRaced = (record: WorktreeRecord, taskId: string, dir: string): boolean => + record.dir !== dir || record.taskId !== taskId + +export type ReleaseKind = "merged" | "abandoned" + +/** + * Release a task worktree: remove the checkout, then mark the record. An + * abandoned branch is salvaged (anchored at `refs/fleet-salvage/`) + * before its branch is deleted; a merged branch is kept. + */ +export async function releaseWorktree( + repoRoot: string, + stateRoot: string, + taskId: string, + kind: ReleaseKind, +): Promise<{ released: boolean }> { + const paths = fleetPaths(stateRoot) + const current = await withLock(paths.lock, () => loadRegistry(stateRoot)).then( + (registry) => registry?.worktrees[taskId], + ) + if (current?.status !== "allocated") return { released: false } + await withRepoLock(repoRoot, async () => { + try { + await removeWorktree(repoRoot, current.dir) + } catch (error) { + if (!(error instanceof GitError)) throw error + await git(repoRoot, ["worktree", "remove", "--force", current.dir]) + } + if (kind === "abandoned") { + await salvageBranch(repoRoot, current.branch, taskId) + // The daemon's own cleanup may have won the race and removed the + // branch already (CI flake: branch -D "not found" failing case 4). + // Salvage already anchored whatever was there; only delete when present. + if (await branchExists(repoRoot, current.branch)) await git(repoRoot, ["branch", "-D", current.branch]) + } + }) + await withLock(paths.lock, async () => { + const registry = (await loadRegistry(stateRoot)) ?? emptyRegistry() + const record = registry.worktrees[taskId] + if (record && record.status === "allocated") + await persistRegistry(paths, { + v: REGISTRY_VERSION, + worktrees: { ...registry.worktrees, [taskId]: { ...record, status: kind } }, + }) + }) + return { released: true } +} + +/** + * Anchor a branch tip at `refs/fleet-salvage/` before destructive + * cleanup, so abandoned or orphaned work is never silently lost. Returns the + * anchored tip, or undefined when the branch is gone. + */ +export async function salvageBranch(repoRoot: string, branch: string, taskId: string): Promise { + checkSlug("task id", taskId) + if (!(await branchExists(repoRoot, branch))) return undefined + const tip = await revParse(repoRoot, branch) + await git(repoRoot, ["update-ref", `refs/fleet-salvage/${taskId}`, tip]) + return tip +} + +export type GateCheck = (worktreeDir: string) => Promise<{ ok: true } | { ok: false; reason?: string }> + +export type LandResult = + | { readonly landed: true; readonly integration: string; readonly tip: string } + | { + readonly landed: false + readonly reason: string + readonly conflict?: boolean + /** Gates rejected the work: nothing was merged, nothing needs aborting. */ + readonly gateFailed?: boolean + } + +/** + * Land a task branch onto the integration branch, transactionally: + * gates first (no state change on failure), then a `--no-ff` merge in a + * throwaway worktree with the pre-merge tip captured; a conflict aborts the + * merge, verifies the rollback, and reports `conflict: true` so the caller + * can mark the task `waiting-human`. The base branch is never touched. + */ +export async function landTask( + repoRoot: string, + stateRoot: string, + taskId: string, + dagId: string, + checkGates: GateCheck, +): Promise { + checkSlug("task id", taskId) + const record = (await loadRegistry(stateRoot))?.worktrees[taskId] + if (record?.status !== "allocated") + return { landed: false, reason: `no allocated worktree for task ${JSON.stringify(taskId)}` } + const gates = await checkGates(record.dir) + if (!gates.ok) return { landed: false, reason: gates.reason ?? "gates failed", gateFailed: true } + const integration = integrationBranch(dagId) + return withRepoLock(repoRoot, async () => { + if (!(await branchExists(repoRoot, integration))) await git(repoRoot, ["branch", integration, record.base]) + const before = await revParse(repoRoot, integration) + const taskTip = await revParse(repoRoot, record.branch) + if (await isMerged(repoRoot, taskTip, integration)) return { landed: true, integration, tip: before } + // Merge in a throwaway worktree: no checkout in the repo ever moves. + const tmp = path.join(repoRoot, ".fleet", "tmp", `land-${taskId}-${process.pid}`) + await rm(tmp, { recursive: true, force: true }) + try { + await addWorktree(repoRoot, tmp, `_fleet-land-${taskId}`, integration) + const merged = await gitRun(tmp, ["merge", "--no-ff", "--no-edit", record.branch], { allowFail: true }) + if (merged.code !== 0) { + const reason = (merged.stderr || merged.stdout).trim() || `merge of ${record.branch} onto ${integration} failed` + // Classify the failure before touching anything: only an in-progress + // merge (MERGE_HEAD present) may be aborted, and only unmerged paths + // count as a conflict. A non-conflict failure (bad identity, hook, + // disk) reports its original reason and never throws from --abort. + const mergeHead = await gitRun(tmp, ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { allowFail: true }) + const unmerged = await gitRun(tmp, ["diff", "--name-only", "--diff-filter=U"], { allowFail: true }) + const conflicted = unmerged.stdout.trim().length > 0 + if (mergeHead.code === 0) await git(tmp, ["merge", "--abort"]) + const rolledBack = await revParse(repoRoot, integration) + if (rolledBack !== before) await git(repoRoot, ["update-ref", `refs/heads/${integration}`, before]) + if (conflicted) + return { + landed: false, + reason: `merge conflict landing ${record.branch} onto ${integration}`, + conflict: true, + } + return { landed: false, conflict: false, reason } + } + // The temp worktree's HEAD is the merge; fast-forward the real branch. + const mergedTip = await headCommit(tmp) + await git(repoRoot, ["update-ref", `refs/heads/${integration}`, mergedTip]) + return { landed: true, integration, tip: mergedTip } + } finally { + await git(repoRoot, ["worktree", "remove", "--force", tmp]) + } + }) +} + +async function isMerged(repoRoot: string, commit: string, into: string): Promise { + const base = await git(repoRoot, ["merge-base", commit, into]) + return base.stdout.trim() === commit +} + +export interface GcReport { + /** Worktree dirs removed. */ + readonly removed: readonly string[] + /** Branches anchored at refs/fleet-salvage/*. */ + readonly salvaged: readonly string[] + /** Stale registry records dropped. */ + readonly dropped: readonly string[] +} + +/** List current worktrees (porcelain): [[path, branch]]. */ +async function listedWorktrees(repoRoot: string): Promise> { + const out = (await git(repoRoot, ["worktree", "list", "--porcelain"])).stdout + const found: Array<{ dir: string; branch: string }> = [] + let dir = "" + for (const line of out.split("\n")) { + if (line.startsWith("worktree ")) dir = line.slice("worktree ".length).trim() + else if (line.startsWith("branch ")) { + const ref = line.slice("branch ".length).trim() + if (dir) found.push({ dir, branch: ref.replace(/^refs\/heads\//, "") }) + dir = "" + } else if (line.trim() === "") dir = "" + } + return found +} + +/** + * Collect orphans: worktree dirs under `.fleet/worktrees/` with no live + * (`allocated` or `allocating`) record are salvaged (when their branch has + * commits) and removed; stale records are dropped — `allocated` ones whose + * dir and branch are both gone, `allocating` ones older than + * ALLOCATING_STALE_MS whose dir and branch are both gone. The base branch + * never moves. + * + * Lock order is repo -> fleet: the fleet lock is held for the entire read + * -> decide -> persist nested inside the per-repo admin lock, so a + * concurrent allocator's pre-registered record is always seen and never + * overwritten by a stale copy. + */ +export async function gcWorktrees(repoRoot: string, stateRoot: string): Promise { + const paths = fleetPaths(stateRoot) + const removed: string[] = [] + const salvaged: string[] = [] + const dropped: string[] = [] + await withRepoLock(repoRoot, async () => { + await withLock(paths.lock, async () => { + const registry = (await loadRegistry(stateRoot)) ?? emptyRegistry() + const live = new Set( + Object.values(registry.worktrees) + .filter((record) => record.status === "allocated" || record.status === "allocating") + .map((record) => record.dir), + ) + const listed = await listedWorktrees(repoRoot) + for (const entry of listed) { + const inside = path.relative(path.join(repoRoot, ".fleet", "worktrees"), entry.dir) + if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside)) continue + if (!live.has(entry.dir)) { + const taskId = entry.branch.replace(/^fleet\//, "") + if (SLUG.test(taskId) && (await branchExists(repoRoot, entry.branch))) { + const tip = await salvageBranch(repoRoot, entry.branch, taskId) + if (tip) salvaged.push(entry.branch) + await git(repoRoot, ["branch", "-D", entry.branch]).catch(() => undefined) + } + await git(repoRoot, ["worktree", "remove", "--force", entry.dir]) + removed.push(entry.dir) + } + } + // Drop records whose dir is gone from git and disk and whose branch is gone. + const listedDirs = new Set(listed.map((entry) => entry.dir)) + const nowMs = Date.now() + const next = { ...registry.worktrees } + for (const [id, record] of Object.entries(registry.worktrees)) { + if (record.status !== "allocated" && record.status !== "allocating") continue + if (record.status === "allocating" && !allocatingStale(record, nowMs)) continue + const dirGone = !listedDirs.has(record.dir) + let onDisk = true + try { + await lstat(record.dir) + } catch { + onDisk = false + } + if (dirGone && !onDisk && !(await branchExists(repoRoot, record.branch))) { + delete next[id] + dropped.push(id) + } + } + await persistRegistry(paths, { v: REGISTRY_VERSION, worktrees: next }) + }) + }) + return { removed, salvaged, dropped } +} diff --git a/packages/fleet/test/approve.test.ts b/packages/fleet/test/approve.test.ts new file mode 100644 index 0000000..d6ec7e2 --- /dev/null +++ b/packages/fleet/test/approve.test.ts @@ -0,0 +1,413 @@ +// Browser approvals (#131, ADR 0002 option b): preview → single-use ticket +// bound to the subject hash → passphrase over loopback → in-process unlock, +// sign and zeroize. Each test maps to an adversarial-checklist probe; +// the probe results are posted on the ticket. +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { createConnection } from "node:net" +import { tmpdir } from "node:os" +import path from "node:path" +import { fleetPaths } from "../src/state.ts" +import { type FleetSnapshot, TelemetryServer } from "../src/telemetry.ts" +import { mintWebTicket } from "../src/web.ts" + +const PASS = "correct-horse-phrase-for-browser-tests" +const TOKEN = "approve-bus-token-with-enough-length" + +const frontier = { + version: "1.1", + idea: "A greeting library", + spendCeiling: { currency: "USD", maxAmount: 25 }, + settled: true, + nodes: [{ id: "lang", question: "Language?", answer: "TypeScript", status: "settled" }], +} + +async function rig() { + const state = await mkdtemp(path.join(tmpdir(), "es-approve-")) + const repo = await mkdtemp(path.join(tmpdir(), "es-approve-repo-")) + await mkdir(path.join(repo, ".factory"), { recursive: true }) + await writeFile(path.join(repo, ".factory", "frontier.json"), `${JSON.stringify(frontier, null, 2)}\n`) + const { writeJson } = await import("@heretek-ai/es-core") + const { factoryLayout } = await import("@heretek-ai/es-core") + await writeJson(factoryLayout(repo).pending, [ + { stage: "frontier", requestedBy: "factory", at: new Date().toISOString() }, + ]) + const { sealHumanKey } = await import("@heretek-ai/es-core") + await sealHumanKey(PASS, state) + await mkdir(fleetPaths(state).dir, { recursive: true }) + await writeFile(fleetPaths(state).token, TOKEN, { mode: 0o600 }) + await writeJson(fleetPaths(state).worktrees, { + v: 1, + worktrees: { + t1: { + taskId: "t1", + dir: repo, + branch: "fleet/t1", + base: "0".repeat(40), + createdAt: new Date().toISOString(), + status: "allocated", + }, + }, + }) + const site = path.join(state, "site") + await mkdir(site, { recursive: true }) + await writeFile(path.join(site, "index.html"), "") + const server = new TelemetryServer({ + stateRoot: state, + port: 0, + token: TOKEN, + getSnapshot: (): FleetSnapshot => ({ + daemon: { running: true, pid: 1, maxUsd: 10, concurrency: 1 }, + tasks: [], + spendUsd: 0, + pending: [], + }), + webRoot: site, + }) + await server.start() + return { state, repo, server } +} + +const raw = ( + port: number, + head: string, + body = "", +): Promise<{ status: number; headers: Record; body: string }> => + new Promise((resolve, reject) => { + const socket = createConnection(port, "127.0.0.1", () => socket.write(head + body)) + let buffer = Buffer.alloc(0) + socket.on("data", (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + }) + socket.on("close", () => { + const text = buffer.toString("latin1") + const at = text.indexOf("\r\n\r\n") + const [statusLine, ...lines] = text.slice(0, at).split("\r\n") + const headers: Record = {} + for (const line of lines) { + const colon = line.indexOf(":") + if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim() + } + resolve({ status: Number(statusLine!.split(" ")[1]), headers, body: text.slice(at + 4) }) + }) + socket.on("error", reject) + }) + +const post = ( + port: number, + target: string, + payload: unknown, + extra: Record = {}, + host = `127.0.0.1:${port}`, +) => { + const body = JSON.stringify(payload) + return raw( + port, + `POST ${target} HTTP/1.1\r\nHost: ${host}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n${Object.entries( + extra, + ) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join("")}\r\n`, + body, + ) +} + +async function session(port: number, state: string): Promise { + const ticket = await mintWebTicket(state) + const res = await raw(port, `GET /?t=${ticket} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\n\r\n`) + expect(res.status).toBe(200) + return (res.headers["set-cookie"] ?? "").split(";")[0]! +} + +const json = (body: string): Record => JSON.parse(body) as Record + +describe("approve preview", () => { + test("checklist: preview shows the subject, its hash and a ticket (read-only)", async () => { + const { state, server } = await rig() + try { + const cookie = await session(server.port, state) + const res = await post( + server.port, + "/fleet/approve/preview", + { runId: "t1", stage: "frontier" }, + { Cookie: cookie, Origin: `http://127.0.0.1:${server.port}` }, + ) + expect(res.status).toBe(200) + const body = json(res.body) + expect(typeof body.ticket).toBe("string") + expect(typeof body.csrf).toBe("string") + expect(typeof body.subjectHash).toBe("string") + expect(body.subjectHash as string).toMatch(/^[0-9a-f]{64}$/) + expect(body.summary as string[]).toEqual(expect.arrayContaining([expect.stringContaining("greeting library")])) + expect(body.pending).toBe(true) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("checklist 8/9: anonymous, foreign-Origin and foreign-Host previews are refused", async () => { + const { state, server } = await rig() + try { + const payload = { runId: "t1", stage: "frontier" } + expect((await post(server.port, "/fleet/approve/preview", payload)).status).toBe(401) + const cookie = await session(server.port, state) + expect( + (await post(server.port, "/fleet/approve/preview", payload, { Cookie: cookie, Origin: "https://evil.com" })) + .status, + ).toBe(403) + expect((await post(server.port, "/fleet/approve/preview", payload, { Cookie: cookie }, "evil.com")).status).toBe( + 403, + ) + expect( + ( + await post( + server.port, + "/fleet/approve/preview", + { runId: "nope", stage: "frontier" }, + { Cookie: cookie, Origin: `http://127.0.0.1:${server.port}` }, + ) + ).status, + ).toBe(404) + expect( + ( + await post( + server.port, + "/fleet/approve/preview", + { runId: "t1", stage: "bogus" }, + { Cookie: cookie, Origin: `http://127.0.0.1:${server.port}` }, + ) + ).status, + ).toBe(400) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("approve submit", () => { + const approveAs = ( + port: number, + cookie: string, + csrf: string, + payload: unknown, + extra: Record = {}, + ) => + post(port, "/fleet/approve", payload, { + Cookie: cookie, + Origin: `http://127.0.0.1:${port}`, + "X-CSRF": csrf, + ...extra, + }) + + test("checklist: the full browser path records channel web and clears pending", async () => { + const { state, repo, server } = await rig() + try { + const cookie = await session(server.port, state) + const origin = `http://127.0.0.1:${server.port}` + const preview = json( + ( + await post( + server.port, + "/fleet/approve/preview", + { runId: "t1", stage: "frontier" }, + { Cookie: cookie, Origin: origin }, + ) + ).body, + ) + const res = await approveAs(server.port, cookie, preview.csrf as string, { + ticket: preview.ticket, + passphrase: PASS, + }) + expect(res.status).toBe(200) + expect(res.body).not.toContain(PASS) + const { readApproval, pendingApprovals } = await import("@heretek-ai/es-core") + const record = await readApproval(repo, "frontier") + expect(record?.channel).toBe("web") + expect(await pendingApprovals(repo)).toEqual([]) + expect(json(res.body).alreadyApproved).toBe(false) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("checklist 1/8: bearer-only, missing CSRF and missing Origin submits are refused", async () => { + const { state, server } = await rig() + try { + const cookie = await session(server.port, state) + const origin = `http://127.0.0.1:${server.port}` + const preview = json( + ( + await post( + server.port, + "/fleet/approve/preview", + { runId: "t1", stage: "frontier" }, + { Cookie: cookie, Origin: origin }, + ) + ).body, + ) + const payload = { ticket: preview.ticket, passphrase: PASS } + // Bearer alone never authorizes the mutating endpoint. + expect( + (await post(server.port, "/fleet/approve", payload, { Authorization: `Bearer ${TOKEN}`, Origin: origin })) + .status, + ).toBe(401) + // No cookie at all. + expect( + (await post(server.port, "/fleet/approve", payload, { Origin: origin, "X-CSRF": preview.csrf as string })) + .status, + ).toBe(401) + // Cookie without CSRF, with a wrong CSRF, and without Origin. + expect((await post(server.port, "/fleet/approve", payload, { Cookie: cookie, Origin: origin })).status).toBe(403) + expect((await approveAs(server.port, cookie, "wrong-csrf", payload)).status).toBe(403) + expect( + (await post(server.port, "/fleet/approve", payload, { Cookie: cookie, "X-CSRF": preview.csrf as string })) + .status, + ).toBe(403) + expect( + (await approveAs(server.port, cookie, preview.csrf as string, payload, { Origin: "https://evil.com" })).status, + ).toBe(403) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("checklist 4: a ticket redeems once; an expired ticket fails", async () => { + const { state, server } = await rig() + try { + const cookie = await session(server.port, state) + const origin = `http://127.0.0.1:${server.port}` + const preview = json( + ( + await post( + server.port, + "/fleet/approve/preview", + { runId: "t1", stage: "frontier" }, + { Cookie: cookie, Origin: origin }, + ) + ).body, + ) + const payload = { ticket: preview.ticket, passphrase: "wrong-phrase" } + expect((await approveAs(server.port, cookie, preview.csrf as string, payload)).status).toBe(403) + // Same ticket again: already redeemed, even though the first failed auth. + expect((await approveAs(server.port, cookie, preview.csrf as string, payload)).status).toBe(403) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("checklist 5: an artifact changed after the preview refuses the sign", async () => { + const { state, repo, server } = await rig() + try { + const cookie = await session(server.port, state) + const origin = `http://127.0.0.1:${server.port}` + const preview = json( + ( + await post( + server.port, + "/fleet/approve/preview", + { runId: "t1", stage: "frontier" }, + { Cookie: cookie, Origin: origin }, + ) + ).body, + ) + await writeFile( + path.join(repo, ".factory", "frontier.json"), + `${JSON.stringify({ ...frontier, idea: "A different library" }, null, 2)}\n`, + ) + const res = await approveAs(server.port, cookie, preview.csrf as string, { + ticket: preview.ticket, + passphrase: PASS, + }) + expect(res.status).toBe(409) + const { readApproval } = await import("@heretek-ai/es-core") + expect(await readApproval(repo, "frontier")).toBeUndefined() + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("checklist 6: five wrong passphrases lock the surface with an audit entry", async () => { + const { state, repo, server } = await rig() + try { + const cookie = await session(server.port, state) + const origin = `http://127.0.0.1:${server.port}` + const headers = { Cookie: cookie, Origin: origin } + const freshTicket = async (): Promise<{ ticket: string; csrf: string }> => { + const preview = json( + (await post(server.port, "/fleet/approve/preview", { runId: "t1", stage: "frontier" }, headers)).body, + ) + return { ticket: preview.ticket as string, csrf: preview.csrf as string } + } + for (let attempt = 1; attempt <= 4; attempt++) { + const { ticket, csrf } = await freshTicket() + const res = await approveAs(server.port, cookie, csrf, { ticket, passphrase: `wrong-${attempt}` }) + expect(res.status).toBe(403) + expect(json(res.body).remaining).toBe(5 - attempt) + } + // The fifth wrong passphrase trips the lockout (core semantics: the + // trip itself is refused with the retry delay, and audited). + const { ticket, csrf } = await freshTicket() + const locked = await approveAs(server.port, cookie, csrf, { ticket, passphrase: "wrong-5" }) + expect(locked.status).toBe(423) + expect(typeof json(locked.body).retryAfterSec).toBe("number") + // Even the right passphrase is refused while locked. + const { ticket: t2, csrf: c2 } = await freshTicket() + expect((await approveAs(server.port, cookie, c2, { ticket: t2, passphrase: PASS })).status).toBe(423) + const { recentAuditEntries } = await import("@heretek-ai/es-core") + const actions = (await recentAuditEntries(repo, 20)).map((entry) => entry.action) + expect(actions).toContain("approval.lockout") + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("checklist 7: the passphrase appears in no response, event or state file", async () => { + const { state, repo, server } = await rig() + const probe = "probe-phrase-that-must-never-persist-12345" + try { + const cookie = await session(server.port, state) + const origin = `http://127.0.0.1:${server.port}` + const headers = { Cookie: cookie, Origin: origin } + const preview = json( + (await post(server.port, "/fleet/approve/preview", { runId: "t1", stage: "frontier" }, headers)).body, + ) + const res = await approveAs(server.port, cookie, preview.csrf as string, { + ticket: preview.ticket, + passphrase: probe, + }) + expect(res.status).toBe(403) + expect(res.body).not.toContain(probe) + const hits: string[] = [] + const grep = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === "node_modules") continue + await grep(full) + } else if (entry.isFile()) { + try { + if ((await readFile(full, "utf8")).includes(probe)) hits.push(full) + } catch {} + } + } + } + await grep(state) + await grep(path.join(repo, ".factory")) + expect(hits).toEqual([]) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/configview.test.ts b/packages/fleet/test/configview.test.ts new file mode 100644 index 0000000..1d1cbc6 --- /dev/null +++ b/packages/fleet/test/configview.test.ts @@ -0,0 +1,236 @@ +// Read-only config view (#132, preview-only mode): the browser previews +// project-config and gates edits with exact hash diffs, but nothing here +// writes — the no-write proof hashes every file around the calls, and +// `fleet.config.apply` does not exist on the bus. +import { describe, expect, test } from "bun:test" +import { createHash } from "node:crypto" +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { planConfigFile, readConfigView } from "../src/configview.ts" + +const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex") +const canon = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n` + +async function repo(header: { config?: unknown; gates?: unknown } = {}): Promise { + const root = await mkdtemp(path.join(tmpdir(), "es-configview-")) + await mkdir(path.join(root, ".factory"), { recursive: true }) + await writeFile(path.join(root, ".factory", "config.json"), canon(header.config ?? {})) + await writeFile(path.join(root, ".factory", "gates.json"), canon(header.gates ?? {})) + return root +} + +async function treeHash(root: string): Promise { + const files: string[] = [] + const walk = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) await walk(full) + else files.push(full) + } + } + await walk(root) + files.sort() + const hash = createHash("sha256") + for (const file of files) { + hash.update(path.relative(root, file)) + hash.update(await readFile(file)) + } + return hash.digest("hex") +} + +describe("fleet.config.get", () => { + test("it reads the project config, gates, hashes and drift state", async () => { + const root = await repo({ config: { licenseWhitelist: ["MIT"] }, gates: { topN: 5 } }) + try { + const view = await readConfigView(root) + expect(view.config).toEqual({ licenseWhitelist: ["MIT"] }) + expect(view.configHash).toBe(sha256(canon({ licenseWhitelist: ["MIT"] }))) + expect(view.gates).toMatchObject({ topN: 5 }) + expect(view.gatesHash).toBe(sha256(await readFile(path.join(root, ".factory", "gates.json"), "utf8"))) + expect(view.drift.hasBaseline).toBe(false) + expect(view.drift.clean).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("missing files read as absent (null), never as an error", async () => { + const root = await mkdtemp(path.join(tmpdir(), "es-configview-")) + try { + const view = await readConfigView(root) + expect(view.config).toBeNull() + expect(view.configHash).toBeNull() + expect(view.gates).toBeNull() + expect(view.gatesHash).toBeNull() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +describe("fleet.config.plan", () => { + test("a valid config edit previews the exact new hash, changed keys and commands", async () => { + const root = await repo({ config: { licenseWhitelist: ["MIT"] } }) + try { + const plan = await planConfigFile(root, { + file: "config", + content: { licenseWhitelist: ["MIT", "Apache-2.0"], research: { depth: 3 } }, + }) + expect(plan.ok).toBe(true) + expect(plan.errors).toEqual([]) + expect(plan.oldHash).toBe(sha256(canon({ licenseWhitelist: ["MIT"] }))) + expect(plan.newHash).toBe(sha256(canon({ licenseWhitelist: ["MIT", "Apache-2.0"], research: { depth: 3 } }))) + expect(plan.changedKeys).toEqual(["licenseWhitelist", "research"]) + expect(plan.commands).toEqual([ + 'es config set licenseWhitelist ["MIT","Apache-2.0"]', + 'es config set research {"depth":3}', + ]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("strict-schema violations are reported per key (unknown, wrong type, forbidden)", async () => { + const root = await repo() + try { + const unknown = await planConfigFile(root, { file: "config", content: { noSuchKey: 1 } }) + expect(unknown.ok).toBe(false) + expect(unknown.errors.join("\n")).toMatch(/noSuchKey/) + const wrongType = await planConfigFile(root, { file: "config", content: { research: { depth: "many" } } }) + expect(wrongType.ok).toBe(false) + expect(wrongType.errors.join("\n")).toMatch(/depth/) + const forbidden = await planConfigFile(root, { file: "config", content: { embeddings: {} } }) + expect(forbidden.ok).toBe(false) + expect(forbidden.errors.join("\n")).toMatch(/embeddings/) + for (const plan of [unknown, wrongType, forbidden]) expect(plan.commands).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("a valid gates edit previews the hash diff and the rebaseline command", async () => { + const root = await repo({ gates: { topN: 5 } }) + try { + const plan = await planConfigFile(root, { file: "gates", content: { topN: 7 } }) + expect(plan.ok).toBe(true) + expect(plan.changedKeys).toEqual(["topN"]) + expect(plan.commands).toEqual(["# save the previewed JSON to .factory/gates.json", "es rebaseline"]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("invalid gates and non-object content are refused", async () => { + const root = await repo() + try { + expect((await planConfigFile(root, { file: "gates", content: { topN: -3 } })).ok).toBe(false) + expect((await planConfigFile(root, { file: "config", content: [1, 2] })).ok).toBe(false) + expect((await planConfigFile(root, { file: "config", content: "nope" })).ok).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("unchanged content previews as a no-op with no commands", async () => { + const root = await repo({ config: { licenseWhitelist: ["MIT"] } }) + try { + const plan = await planConfigFile(root, { file: "config", content: { licenseWhitelist: ["MIT"] } }) + expect(plan.ok).toBe(true) + expect(plan.changedKeys).toEqual([]) + expect(plan.commands).toEqual([]) + expect(plan.newHash).toBe(plan.oldHash) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +describe("preview-only: nothing writes", () => { + test("get and plan leave every file under the repo and state dir untouched", async () => { + const root = await repo({ config: { licenseWhitelist: ["MIT"] }, gates: { topN: 5 } }) + const state = await mkdtemp(path.join(tmpdir(), "es-configview-state-")) + try { + const beforeRepo = await treeHash(root) + const beforeState = await treeHash(state) + await readConfigView(root) + await planConfigFile(root, { file: "config", content: { research: { depth: 4 } } }) + await planConfigFile(root, { file: "gates", content: { topN: 1 } }) + await planConfigFile(root, { file: "config", content: { bogus: true } }) + expect(await treeHash(root)).toBe(beforeRepo) + expect(await treeHash(state)).toBe(beforeState) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("fleet.config.* over the bus", () => { + const TOKEN = "configview-bus-token-with-enough-length" + + async function bus(repoRoot: string) { + const { TelemetryServer } = await import("../src/telemetry.ts") + const { fleetPaths } = await import("../src/state.ts") + const { mkdir } = await import("node:fs/promises") + const state = await mkdtemp(path.join(tmpdir(), "es-configview-bus-")) + await mkdir(fleetPaths(state).dir, { recursive: true }) + await writeFile(fleetPaths(state).token, TOKEN, { mode: 0o600 }) + const server = new TelemetryServer({ + stateRoot: state, + port: 0, + token: TOKEN, + getSnapshot: () => ({ + daemon: { running: true, pid: 1, maxUsd: 1, concurrency: 1 }, + tasks: [], + spendUsd: 0, + pending: [], + }), + configRoot: repoRoot, + }) + await server.start() + return { state, server } + } + + const call = (port: number, method: string, params: unknown) => + fetch(`http://127.0.0.1:${port}/fleet/rpc`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ method, params }), + }).then((response) => response.json()) as Promise<{ result?: unknown; error?: { code: number } }> + + test("get and plan round-trip; strict schemas refuse bad params", async () => { + const root = await repo({ config: { licenseWhitelist: ["MIT"] } }) + const { state, server } = await bus(root) + try { + const got = await call(server.port, "fleet.config.get", {}) + expect((got.result as { configHash?: unknown }).configHash).toBe(sha256(canon({ licenseWhitelist: ["MIT"] }))) + const planned = await call(server.port, "fleet.config.plan", { + file: "gates", + content: { topN: 3 }, + }) + expect((planned.result as { ok?: unknown }).ok).toBe(true) + // Strict schemas: unknown file values and extra params are refused. + expect((await call(server.port, "fleet.config.plan", { file: "bogus", content: {} })).error?.code).toBe(-32602) + expect( + (await call(server.port, "fleet.config.plan", { file: "config", content: {}, extra: 1 })).error?.code, + ).toBe(-32602) + } finally { + await server.stop() + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) + } + }) + + test("there is no apply method: preview-only is enforced by the bus", async () => { + const root = await repo() + const { state, server } = await bus(root) + try { + expect((await call(server.port, "fleet.config.apply", {})).error?.code).toBe(-32601) + } finally { + await server.stop() + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/daemon.test.ts b/packages/fleet/test/daemon.test.ts new file mode 100644 index 0000000..f3ef13e --- /dev/null +++ b/packages/fleet/test/daemon.test.ts @@ -0,0 +1,202 @@ +// Daemon loop (#125): schedule → allocate → launch → report → land → +// release, with injected spawning and gates, on a real temp git repo. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { git as coreGit } from "@heretek-ai/es-core" +import { FleetDaemon, type TransitionEvent, type WorkerPort } from "../src/daemon.ts" +import type { TaskReport } from "../src/scheduler.ts" +import { addTask, emptyDag, loadDag, saveDag } from "../src/tasks.ts" +import type { WorkerOutcome, WorkerSpec } from "../src/worker.ts" +import { loadRegistry } from "../src/worktree.ts" + +const STAMP = "2026-10-09T12:00:00.000Z" +const taskInput = (id: string, deps: string[] = []) => ({ + id, + title: `task ${id}`, + kind: "factory-run" as const, + deps, + input: { objective: `do ${id}` }, + ceilingUSD: 5, + createdAt: STAMP, + updatedAt: STAMP, +}) + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), "es-fleet-daemon-")) + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-daemon-state-")) + const git = (args: string[], cwd = root) => coreGit(cwd, args) + await git(["init", "-b", "main"]) + await git(["config", "user.name", "fleet-test"]) + await git(["config", "user.email", "fleet@test.local"]) + await git(["config", "commit.gpgsign", "false"]) + await writeFile(path.join(root, "file.txt"), "base\n") + await git(["add", "-A"]) + await git(["commit", "--no-verify", "-m", "base"]) + const base = (await git(["rev-parse", "HEAD"])).stdout.trim() + return { + root, + state, + base, + cleanup: () => + Promise.all([rm(root, { recursive: true, force: true }), rm(state, { recursive: true, force: true })]).then( + () => undefined, + ), + } +} + +interface Fake { + readonly started: string[] + readonly stopped: string[] + readonly reporters: Map void> + readonly spawner: (spec: WorkerSpec, onReport: (taskId: string, report: TaskReport) => void) => WorkerPort +} + +const fakeSpawner = (): Fake => { + const started: string[] = [] + const stopped: string[] = [] + const reporters = new Map void>() + return { + started, + stopped, + reporters, + spawner: (spec, onReport) => { + started.push(spec.taskId) + reporters.set(spec.taskId, (report) => onReport(spec.taskId, report)) + return { + run: () => new Promise(() => {}), + stop: () => { + stopped.push(spec.taskId) + return Promise.resolve() + }, + snapshot: () => ({ taskId: spec.taskId, pid: -1, spendUsd: 0, tokensIn: 0, tokensOut: 0 }), + } + }, + } +} + +const gatesOk = () => Promise.resolve({ ok: true as const }) + +describe("daemon tick", () => { + test("a one-task DAG runs to landed and released, base stable", async () => { + const { root, state, base, cleanup } = await fixture() + try { + await saveDag(state, addTask(emptyDag(), taskInput("a"), STAMP)) + const events: TransitionEvent[] = [] + const fake = fakeSpawner() + const daemon = new FleetDaemon({ + repoRoot: root, + stateRoot: state, + esBin: "/stub/es.js", + fleetCeilingUsd: 100, + checkGates: gatesOk, + spawnWorker: fake.spawner, + onTransition: (event) => void events.push(event), + }) + await daemon.tick() + expect(fake.started).toEqual(["a"]) + expect((await loadDag(state))?.tasks.a.status).toBe("running") + expect((await loadRegistry(state))?.worktrees.a.status).toBe("allocated") + // The worker reports done; the next tick lands and releases. + await daemon.foldReport("a", { outcome: "done", spendUsd: 1 }) + await daemon.tick() + expect((await loadDag(state))?.tasks.a.status).toBe("done") + expect((await loadRegistry(state))?.worktrees.a.status).toBe("merged") + const integration = (await coreGit(root, ["rev-parse", "fleet/integration/fleet"])).stdout.trim() + expect(integration.length).toBe(40) + expect((await coreGit(root, ["rev-parse", "main"])).stdout.trim()).toBe(base) + expect(events.map((event) => event.type)).toContain("started") + expect(events.map((event) => event.type)).toContain("released") + } finally { + await cleanup() + } + }) + + test("shutdown stops workers and records resumable cancellations", async () => { + const { root, state, base, cleanup } = await fixture() + try { + await saveDag(state, addTask(emptyDag(), taskInput("a"), STAMP)) + const fake = fakeSpawner() + const daemon = new FleetDaemon({ + repoRoot: root, + stateRoot: state, + esBin: "/stub/es.js", + fleetCeilingUsd: 100, + checkGates: gatesOk, + spawnWorker: fake.spawner, + }) + await daemon.tick() + expect(fake.started).toEqual(["a"]) + await daemon.shutdown() + expect(fake.stopped).toEqual(["a"]) + const dag = (await loadDag(state))! + expect(dag.tasks.a.status).toBe("cancelled") + expect(dag.tasks.a.reason).toMatch(/resumable/) + expect((await coreGit(root, ["rev-parse", "main"])).stdout.trim()).toBe(base) + } finally { + await cleanup() + } + }) + + test("recover marks dead runs failed and reattaches live ones", async () => { + const { root, state, cleanup } = await fixture() + try { + let dag = addTask(emptyDag(), taskInput("a"), STAMP) + dag = addTask(dag, taskInput("b"), STAMP) + await saveDag(state, dag) + const daemon = new FleetDaemon({ + repoRoot: root, + stateRoot: state, + esBin: "/stub/es.js", + fleetCeilingUsd: 100, + checkGates: gatesOk, + spawnWorker: fakeSpawner().spawner, + }) + await daemon.recover([ + { taskId: "a", pid: 1073741824 }, + { taskId: "b", pid: process.pid }, + ]) + const next = (await loadDag(state))! + expect(next.tasks.a.status).toBe("failed") + expect(next.tasks.a.reason).toMatch(/daemon restarted/i) + // b reattached to its live run: unblocked to ready but never spawned twice. + await daemon.tick() + expect((await loadDag(state))?.tasks.b.status).toBe("ready") + } finally { + await cleanup() + } + }) + + test("a gate failure marks the task failed and moves no branch", async () => { + const { root, state, base, cleanup } = await fixture() + try { + await saveDag(state, addTask(emptyDag(), taskInput("a"), STAMP)) + const daemon = new FleetDaemon({ + repoRoot: root, + stateRoot: state, + esBin: "/stub/es.js", + fleetCeilingUsd: 100, + checkGates: () => Promise.resolve({ ok: false as const, reason: "test gate failure" }), + spawnWorker: fakeSpawner().spawner, + }) + await daemon.tick() + await daemon.foldReport("a", { outcome: "done", spendUsd: 1 }) + await daemon.tick() + const dag = (await loadDag(state))! + expect(dag.tasks.a.status).toBe("failed") + expect(dag.tasks.a.reason).toMatch(/test gate failure/) + const integration = await coreGit( + root, + ["show-ref", "--verify", "--quiet", "refs/heads/fleet/integration/fleet"], + { + allowFail: true, + }, + ) + expect(integration.code).not.toBe(0) + expect((await coreGit(root, ["rev-parse", "main"])).stdout.trim()).toBe(base) + } finally { + await cleanup() + } + }) +}) diff --git a/packages/fleet/test/evidence.test.ts b/packages/fleet/test/evidence.test.ts new file mode 100644 index 0000000..b85eca4 --- /dev/null +++ b/packages/fleet/test/evidence.test.ts @@ -0,0 +1,386 @@ +// Evidence explorer reads (#133): claims, claim detail with quote ranges, +// sources with seal status, and renderer exports. Read-only throughout — +// the no-write proof hashes the trees — with size caps on every payload. +import { describe, expect, test } from "bun:test" +import { createHash } from "node:crypto" +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { + evidenceHash, + factoryLayout, + normalizeClaim, + normalizeForQuote, + SourceCache, + writeJson, +} from "@heretek-ai/es-core" +import { + exportEvidence, + MAX_CLAIMS, + readEvidenceClaim, + readEvidenceClaims, + readEvidenceSource, + SOURCE_TEXT_CAP, +} from "../src/evidence.ts" + +const SOURCE_TEXT = + "Bun is a fast all-in-one JavaScript runtime. It ships a test runner, a bundler, and a package manager — all built in. Researchers measured startup at twelve milliseconds on commodity hardware." + +const QUOTE = "Researchers measured startup at twelve milliseconds on commodity hardware" + +async function rig(withRetraction = false) { + const state = await mkdtemp(path.join(tmpdir(), "es-evidence-state-")) + const repo = await mkdtemp(path.join(tmpdir(), "es-evidence-repo-")) + const sources = path.join(repo, ".factory", "research", "sources") + await mkdir(sources, { recursive: true }) + const sealed = new SourceCache(sources, state) + const entry = await sealed.put({ url: "https://example.com/bun", text: SOURCE_TEXT, provider: "fetch" }) + const plain = new SourceCache(sources) + const unsealed = await plain.put({ + url: "https://example.com/plain", + text: `Plain source text. ${QUOTE}.`, + provider: "fetch", + }) + const claim = normalizeClaim({ + tag: "VERIFIED", + statement: "Bun starts in about twelve milliseconds.", + source: { sha256: entry.meta.sha256, quote: QUOTE }, + }) + const hypothesis = normalizeClaim({ + tag: "HYPOTHESIS", + statement: "Faster startups improve test throughput.", + falsification: "Measure suite times before and after.", + }) + await writeJson(path.join(repo, ".factory", "research", "dossier.json"), { + version: "1.1", + mode: "research", + subject: "demo", + createdAt: new Date().toISOString(), + claims: [claim, hypothesis], + evidenceHash: evidenceHash([claim, hypothesis]), + }) + await writeFile(path.join(repo, ".factory", "research", "REPORT.md"), "# Demo report\n\nBody.\n") + if (withRetraction) { + await mkdir(path.join(repo, ".factory", "claims"), { recursive: true }) + await writeJson(factoryLayout(repo).retractions, { + version: 1, + retractions: [ + { + source: entry.meta.sha256, + event: "RETRACTED", + at: new Date().toISOString(), + note: "withdrawn", + by: "human:tester", + }, + ], + }) + } + return { state, repo, sealedHash: entry.meta.sha256, unsealedHash: unsealed.meta.sha256, claim } +} + +async function treeHash(root: string): Promise { + const files: string[] = [] + const walk = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) await walk(full) + else files.push(full) + } + } + await walk(root).catch(() => undefined) + files.sort() + const hash = createHash("sha256") + for (const file of files) { + hash.update(path.relative(root, file)) + hash.update(await readFile(file)) + } + return hash.digest("hex") +} + +describe("evidence.claims", () => { + test("it lists claims with tag, status, statement and source", async () => { + const { state, repo } = await rig() + try { + const { claims, truncated } = await readEvidenceClaims(repo, {}) + expect(truncated).toBe(false) + expect(claims.map((entry) => entry.tag).sort()).toEqual(["HYPOTHESIS", "VERIFIED"]) + const verified = claims.find((entry) => entry.tag === "VERIFIED")! + expect(verified.status).toBe("LIVE") + expect(verified.statement).toContain("twelve milliseconds") + expect(verified.sourceSha).toMatch(/^[0-9a-f]{64}$/) + expect(claims.find((entry) => entry.tag === "HYPOTHESIS")!.sourceSha).toBeNull() + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("tag, status and text filters narrow the list", async () => { + const { state, repo } = await rig() + try { + expect((await readEvidenceClaims(repo, { tag: "VERIFIED" })).claims).toHaveLength(1) + expect((await readEvidenceClaims(repo, { status: "STALE" })).claims).toHaveLength(0) + expect((await readEvidenceClaims(repo, { text: "throughput" })).claims.map((entry) => entry.tag)).toEqual([ + "HYPOTHESIS", + ]) + expect((await readEvidenceClaims(repo, { text: "no such statement" })).claims).toHaveLength(0) + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test(`lists truncate at ${MAX_CLAIMS}`, async () => { + const { state, repo } = await rig() + try { + const extra = Array.from({ length: MAX_CLAIMS + 5 }, (_value, index) => + normalizeClaim({ + tag: "HYPOTHESIS", + statement: `Bulk claim number ${index} for truncation.`, + falsification: "Re-run the count.", + }), + ) + const { claims: current } = await readEvidenceClaims(repo, {}) + const dossierFile = path.join(repo, ".factory", "research", "dossier.json") + const dossier = JSON.parse(await readFile(dossierFile, "utf8")) as { claims: unknown[] } + dossier.claims.push(...extra) + await writeFile(dossierFile, JSON.stringify(dossier)) + const seen = await readEvidenceClaims(repo, {}) + expect(seen.claims).toHaveLength(MAX_CLAIMS) + expect(seen.truncated).toBe(true) + expect(current).toHaveLength(2) + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) +}) + +describe("evidence.claim", () => { + test("it returns quotes with original-text ranges, rank and files", async () => { + const { state, repo, claim } = await rig() + try { + const detail = await readEvidenceClaim(repo, claim.id) + expect(detail).not.toBeNull() + expect(detail!.quotes).toHaveLength(1) + const [quote] = detail!.quotes + expect(quote!.verified).toBe(true) + expect(quote!.ranges.length).toBeGreaterThan(0) + for (const range of quote!.ranges) expect(SOURCE_TEXT.slice(range.start, range.end).length).toBeGreaterThan(0) + expect( + normalizeForQuote(SOURCE_TEXT.slice(quote!.ranges[0]!.start, quote!.ranges.at(-1)!.end)).replace(/\s+/g, ""), + ).toBe(normalizeForQuote(QUOTE).replace(/\s+/g, "")) + expect(detail!.rankExplanation.length).toBeGreaterThan(0) + expect(detail!.retractions).toEqual([]) + expect(detail!.events).toEqual([]) + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("a retracted source degrades the claim and shows the retraction", async () => { + const { state, repo, claim } = await rig(true) + try { + const detail = await readEvidenceClaim(repo, claim.id) + expect(detail!.claim.status).toBe("STALE") + expect(detail!.retractions).toHaveLength(1) + expect(detail!.retractions[0]!.event).toBe("RETRACTED") + expect(detail!.events.length).toBeGreaterThan(0) + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("unknown claims are null", async () => { + const { state, repo } = await rig() + try { + expect(await readEvidenceClaim(repo, "no-such-claim")).toBeNull() + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) +}) + +describe("evidence.source", () => { + test("sealed and unsealed sources report their seal status", async () => { + const { state, repo, sealedHash, unsealedHash } = await rig() + try { + const sealed = await readEvidenceSource(repo, state, sealedHash) + expect(sealed!.seal).toBe("sealed") + expect(sealed!.text).toContain("twelve milliseconds") + expect(sealed!.truncated).toBe(false) + expect(sealed!.meta.url).toBe("https://example.com/bun") + const unsealed = await readEvidenceSource(repo, state, unsealedHash) + expect(unsealed!.seal).toBe("unsealed") + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("a forged seal reports invalid", async () => { + const { state, repo, sealedHash } = await rig() + try { + const sources = path.join(repo, ".factory", "research", "sources") + const metaFile = path.join(sources, `${sealedHash}.json`) + const meta = JSON.parse(await readFile(metaFile, "utf8")) as Record + meta.seal = "00".repeat(32) + await writeFile(metaFile, JSON.stringify(meta)) + expect((await readEvidenceSource(repo, state, sealedHash))!.seal).toBe("invalid") + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("tampered text and unknown hashes are null; long text is capped", async () => { + const { state, repo, sealedHash } = await rig() + try { + expect(await readEvidenceSource(repo, state, "ab".repeat(32))).toBeNull() + expect(await readEvidenceSource(repo, state, "not-hex")).toBeNull() + const sources = path.join(repo, ".factory", "research", "sources") + await writeFile(path.join(sources, `${sealedHash}.md`), "tampered") + expect(await readEvidenceSource(repo, state, sealedHash)).toBeNull() + const big = `Long source. ${"x".repeat(SOURCE_TEXT_CAP + 100)} end.` + const hash = createHash("sha256").update(big).digest("hex") + await writeFile(path.join(sources, `${hash}.md`), big) + await writeFile( + path.join(sources, `${hash}.json`), + JSON.stringify({ sha256: hash, url: "", retrieved: "", provider: "fetch", bytes: big.length }), + ) + const seen = await readEvidenceSource(repo, state, hash) + expect(seen!.truncated).toBe(true) + expect(seen!.text.length).toBeLessThanOrEqual(SOURCE_TEXT_CAP) + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) +}) + +describe("evidence.export", () => { + test("markdown and html exports reuse the dossier renderers", async () => { + const { state, repo } = await rig() + try { + const md = await exportEvidence(repo, state, "markdown") + expect(md?.format).toBe("markdown") + expect(md?.body).toContain("twelve milliseconds") + expect(md?.truncated).toBe(false) + const html = await exportEvidence(repo, state, "html") + expect(html?.body).toContain("<") + expect(html?.body).toContain("twelve milliseconds") + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) + + test("missing dossiers are null", async () => { + const { state, repo } = await rig() + try { + await rm(path.join(repo, ".factory", "research", "dossier.json")) + expect(await exportEvidence(repo, state, "markdown")).toBeNull() + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) +}) + +describe("evidence: read-only", () => { + test("claims, detail, source and export leave every file untouched", async () => { + const { state, repo, claim, sealedHash } = await rig(true) + try { + const beforeRepo = await treeHash(repo) + const beforeState = await treeHash(state) + await readEvidenceClaims(repo, {}) + await readEvidenceClaim(repo, claim.id) + await readEvidenceSource(repo, state, sealedHash) + await exportEvidence(repo, state, "markdown") + await exportEvidence(repo, state, "html") + expect(await treeHash(repo)).toBe(beforeRepo) + expect(await treeHash(state)).toBe(beforeState) + } finally { + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) +}) + +describe("evidence.* over the bus", () => { + const TOKEN = "evidence-bus-token-with-enough-length" + + async function bus(repoRoot: string, state: string) { + const { TelemetryServer } = await import("../src/telemetry.ts") + const { fleetPaths } = await import("../src/state.ts") + const { mkdir: makeDir } = await import("node:fs/promises") + await makeDir(fleetPaths(state).dir, { recursive: true }) + await writeFile(fleetPaths(state).token, TOKEN, { mode: 0o600 }) + await writeFile( + fleetPaths(state).worktrees, + JSON.stringify({ + v: 1, + worktrees: { + run1: { + taskId: "run1", + dir: repoRoot, + branch: "fleet/run1", + base: "0".repeat(40), + createdAt: new Date().toISOString(), + status: "allocated", + }, + }, + }), + ) + const server = new TelemetryServer({ + stateRoot: state, + port: 0, + token: TOKEN, + getSnapshot: () => ({ + daemon: { running: true, pid: 1, maxUsd: 1, concurrency: 1 }, + tasks: [], + spendUsd: 0, + pending: [], + }), + }) + await server.start() + return server + } + + const call = (port: number, method: string, params: unknown) => + fetch(`http://127.0.0.1:${port}/fleet/rpc`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ method, params }), + }).then((response) => response.json()) as Promise<{ result?: unknown; error?: { code: number; message: string } }> + + test("claims, detail, source and export round-trip; unknown runs and bad params fail", async () => { + const { state, repo, claim, sealedHash } = await rig() + const server = await bus(repo, state) + try { + const claims = await call(server.port, "evidence.claims", { runId: "run1", filter: { tag: "VERIFIED" } }) + expect((claims.result as { claims: unknown[] }).claims).toHaveLength(1) + const detail = await call(server.port, "evidence.claim", { runId: "run1", id: claim.id }) + expect((detail.result as { quotes: unknown[] }).quotes).toHaveLength(1) + const source = await call(server.port, "evidence.source", { runId: "run1", sha: sealedHash }) + expect((source.result as { seal: string }).seal).toBe("sealed") + const exported = await call(server.port, "evidence.export", { runId: "run1", format: "markdown" }) + expect((exported.result as { body: string }).body).toContain("twelve milliseconds") + expect((await call(server.port, "evidence.claims", { runId: "nope" })).error?.code).toBe(-32603) + expect((await call(server.port, "evidence.claim", { runId: "run1", id: "nope" })).error?.code).toBe(-32603) + expect((await call(server.port, "evidence.source", { runId: "run1", sha: "xyz" })).error?.code).toBe(-32603) + expect((await call(server.port, "evidence.export", { runId: "run1", format: "pdf" })).error?.code).toBe(-32602) + expect( + (await call(server.port, "evidence.claims", { runId: "run1", filter: { tag: "VERIFIED", extra: 1 } })).error + ?.code, + ).toBe(-32602) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/fixtures/no-grant-child.ts b/packages/fleet/test/fixtures/no-grant-child.ts new file mode 100644 index 0000000..e54bf1c --- /dev/null +++ b/packages/fleet/test/fixtures/no-grant-child.ts @@ -0,0 +1,12 @@ +// Helper for the S5.2 grant test: awaits the one-time IPC start grant with a +// short timeout. Spawned without an IPC channel (and without a TTY), it must +// exit non-zero. No env var or flag grants it. +import { awaitStartGrant } from "../../src/bin.ts" + +await awaitStartGrant(100).then( + () => process.exit(0), + (error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exit(1) + }, +) diff --git a/packages/fleet/test/gc-race.test.ts b/packages/fleet/test/gc-race.test.ts new file mode 100644 index 0000000..e9f93c2 --- /dev/null +++ b/packages/fleet/test/gc-race.test.ts @@ -0,0 +1,135 @@ +// S5.1 gc race (repair-151): the allocator pre-registers `allocating` under +// the fleet lock before `addWorktree`, and gc holds the fleet lock for its +// whole read -> decide -> persist inside the repo lock. A gc landing between +// `addWorktree` and the commit must keep the new worktree and its record. +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { git as coreGit } from "@heretek-ai/es-core" +import { allocateWorktree, type GcReport, gcWorktrees, loadRegistry, type WorktreeRecord } from "../src/worktree.ts" + +const git = (cwd: string, ...args: string[]) => coreGit(cwd, args, { allowFail: true }) + +async function fixture(): Promise<{ root: string; base: string; cleanup: () => Promise }> { + const root = await mkdtemp(path.join(tmpdir(), "es-fleet-gcrace-")) + const cleanup = () => rm(root, { recursive: true, force: true }) + await git(root, "init", "-b", "main") + await git(root, "config", "user.name", "fleet-test") + await git(root, "config", "user.email", "fleet@test.local") + await git(root, "config", "commit.gpgsign", "false") + await writeFile(path.join(root, "file.txt"), "base\n") + await git(root, "add", "-A") + await git(root, "commit", "--no-verify", "-m", "base") + const base = (await git(root, "rev-parse", "HEAD")).stdout.trim() + return { root, base, cleanup } +} + +const stateRoot = () => mkdtemp(path.join(tmpdir(), "es-fleet-gcrace-state-")) +const tip = (root: string, ref: string) => git(root, "rev-parse", ref).then((r) => r.stdout.trim()) + +describe("gc race (S5.1)", () => { + test("gc in the allocation window keeps the new worktree and its record", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + let hooked = false + const record = await allocateWorktree(root, state, "task-race", { + base, + hooks: { + afterAdd: async () => { + hooked = true + await gcWorktrees(root, state) + }, + }, + }) + expect(hooked).toBe(true) + expect(record.status).toBe("allocated") + const listed = (await git(root, "worktree", "list", "--porcelain")).stdout + expect(listed).toContain(record.dir) + const registry = await loadRegistry(state) + expect(registry?.worktrees["task-race"]?.status).toBe("allocated") + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) + + test("a stale allocating record with no dir or branch is dropped", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const dir = path.join(root, ".fleet", "worktrees", "task-stale") + await mkdir(path.join(state, "fleet"), { recursive: true }) + await writeFile( + path.join(state, "fleet", "worktrees.json"), + JSON.stringify({ + v: 1, + worktrees: { + "task-stale": { + taskId: "task-stale", + dir, + branch: "fleet/task-stale", + base, + createdAt: "2020-01-01T00:00:00.000Z", + status: "allocating", + }, + }, + }), + ) + const report = await gcWorktrees(root, state) + expect(report.dropped).toContain("task-stale") + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) + + test("two allocators of one task: the loser never drops the winner's record (gc in the window keeps it)", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + // Both allocators pass the early check before either pre-registers. + let arrived = 0 + let release!: () => void + const bothChecked = new Promise((resolve) => { + release = resolve + }) + const beforePreRegister = async () => { + arrived += 1 + if (arrived === 2) release() + await bothChecked + } + const calls: Array> = [] + let report: GcReport | undefined + // The winner waits for the loser to give up, then gc runs in its window. + const hooks = (self: number) => ({ + beforePreRegister, + afterAdd: async () => { + await calls[1 - self]!.then( + () => undefined, + () => undefined, + ) + report = await gcWorktrees(root, state) + }, + }) + calls.push(allocateWorktree(root, state, "task-twin", { base, hooks: hooks(0) })) + calls.push(allocateWorktree(root, state, "task-twin", { base, hooks: hooks(1) })) + const results = await Promise.allSettled(calls) + const won = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])) + const lost = results.flatMap((result) => (result.status === "rejected" ? [String(result.reason)] : [])) + expect(won).toHaveLength(1) + expect(lost).toHaveLength(1) + expect(lost[0]).toContain("is already being allocated concurrently") + expect(report?.removed).toEqual([]) + expect((await git(root, "worktree", "list", "--porcelain")).stdout).toContain(won[0]!.dir) + expect((await loadRegistry(state))?.worktrees["task-twin"]?.status).toBe("allocated") + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/headers.test.ts b/packages/fleet/test/headers.test.ts new file mode 100644 index 0000000..e929700 --- /dev/null +++ b/packages/fleet/test/headers.test.ts @@ -0,0 +1,32 @@ +// S5.4 (CodeQL #84) remote property injection (repair-151): request headers +// parse into a Map (never a plain `{}` keyed by remote names), non-token +// names are rejected, and `field()` is actually case-insensitive. +import { describe, expect, test } from "bun:test" +import { field, parseHeaders } from "../src/telemetry.ts" + +describe("header parsing (S5.4)", () => { + test("__proto__ stays a plain key, never the prototype", () => { + const headers = parseHeaders(["__proto__: polluted", "X-Ok: 1"]) + expect(headers.get("__proto__")).toBe("polluted") + expect(({} as Record).polluted).toBeUndefined() + expect((Object.prototype as Record).polluted).toBeUndefined() + delete (Object.prototype as Record).polluted + }) + + test("names are lowercased and non-token names are rejected", () => { + const headers = parseHeaders(["HoSt: 127.0.0.1:9", "Bad Name: x", "X-Ok: 1", ": empty", "NoColon"]) + expect(headers.get("host")).toBe("127.0.0.1:9") + expect(headers.has("bad name")).toBe(false) + expect(headers.get("x-ok")).toBe("1") + expect(headers.size).toBe(2) + }) + + test("field() is case-insensitive", () => { + const headers = parseHeaders(["Host: 127.0.0.1:9", "Authorization: Bearer abc"]) + expect(field(headers, "Host")).toBe("127.0.0.1:9") + expect(field(headers, "HOST")).toBe("127.0.0.1:9") + expect(field(headers, "authorization")).toBe("Bearer abc") + expect(field(headers, "AUTHORIZATION")).toBe("Bearer abc") + expect(field(headers, "missing")).toBeUndefined() + }) +}) diff --git a/packages/fleet/test/integration.test.ts b/packages/fleet/test/integration.test.ts new file mode 100644 index 0000000..cadb3b6 --- /dev/null +++ b/packages/fleet/test/integration.test.ts @@ -0,0 +1,484 @@ +// Fleet integration suite (#127, merge-blocking): concurrency without +// interference, proven on the real host with the fake model. One booted +// testkit host is the fleet repo: task work is scripted seat turns through +// @@CALL (real host tools, fake model) writing into real isolated git +// worktrees, driven by the real FleetDaemon tick loop, landed by the real +// gated landing, and observed over the real telemetry bus. +// +// Four cases plus break probes: +// 1. happy DAG (A, then B and C in parallel): ordering, overlap, ownership, +// gated merges, base stability, bus event order; +// 2. failure: error, retry(1), cancellation of dependents, merge conflict +// repair (conflict -> waiting-human, tip stable); +// 3. cancellation and cleanup: mid-run stop, resumable record, salvage, gc. +// 4. gate failure: gates reject the landing, the task fails, no branch moves. +// Probes prove the suite bites: a shared worktree and an early start both +// fail the ownership/ordering checks. +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { git as coreGit } from "@heretek-ai/es-core" +import { boot, directiveScript, type Harness } from "@heretek-ai/es-testkit" +import { FleetDaemon, type WorkerPort } from "../src/daemon.ts" +import type { TaskReport } from "../src/scheduler.ts" +import { addTask, type DagState, emptyDag, loadDag, saveDag } from "../src/tasks.ts" +import { type BusEvent, TelemetryServer } from "../src/telemetry.ts" +import { Worker, type WorkerOutcome, type WorkerSpec } from "../src/worker.ts" +import { loadRegistry } from "../src/worktree.ts" + +const STAMP = "2026-10-09T12:00:00.000Z" +const TICK_MS = 250 + +const git = (cwd: string, args: string[]) => coreGit(cwd, args) +const tip = async (cwd: string, ref: string) => (await git(cwd, ["rev-parse", ref])).stdout.trim() +const tree = async (cwd: string, branch: string) => + (await git(cwd, ["ls-tree", "-r", "--name-only", branch])).stdout.split("\n").filter(Boolean) + +const taskInput = (id: string, deps: string[] = [], extra: Record = {}) => ({ + id, + title: `task ${id}`, + kind: "factory-run" as const, + deps, + input: { objective: `do ${id}` }, + ceilingUSD: 5, + createdAt: STAMP, + updatedAt: STAMP, + ...extra, +}) + +const waitFor = async (condition: () => Promise, timeoutMs: number, what: string): Promise => { + const deadline = Date.now() + timeoutMs + for (;;) { + if (await condition()) return + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`) + await new Promise((resolve) => setTimeout(resolve, TICK_MS)) + } +} + +const dagStatus = async (state: string, id: string) => (await loadDag(state))?.tasks[id]?.status + +export type TurnBehavior = "ok" | "failOnce" | "failAlways" | "conflict" + +/** Scripted host turn for one task: timestamp, work, write, timestamp. */ +const turnPrompt = (id: string, dir: string, marks: string, behavior: TurnBehavior, attempt: number): string => { + if (behavior === "failAlways" || (behavior === "failOnce" && attempt <= 1)) + return [ + `fleet task ${id} attempt ${attempt}`, + `@@CALL shell {"command": "date +%s%N > ${marks}/started-${id}", "description": "mark start"}@@`, + // Unknown tools fail the turn deterministically (shell exit codes do not). + `@@CALL does-not-exist {"task": "${id}"}@@`, + ].join(" ") + const conflictStep = + behavior === "conflict" ? ` @@CALL write {"path": "${dir}/conflict.txt", "content": "${id}\\n"}@@` : "" + return [ + `fleet task ${id}`, + `@@CALL shell {"command": "date +%s%N > ${marks}/started-${id}", "description": "mark start"}@@`, + `@@CALL shell {"command": "sleep 1", "description": "do the work"}@@`, + `@@CALL write {"path": "${dir}/out-${id}.txt", "content": "${id}\\n"}@@`, + conflictStep, + `@@CALL shell {"command": "date +%s%N > ${marks}/finished-${id}", "description": "mark finish"}@@`, + ].join(" ") +} + +/** Spawner driving tasks as scripted seat turns on the shared host. */ +const hostSpawner = ( + h: Harness, + marks: string, + behaviors: Record = {}, +): ((spec: WorkerSpec, onReport: (taskId: string, report: TaskReport) => void) => WorkerPort) => { + const repo = h.directory + const attempts = new Map() + return (spec, onReport) => { + const rel = path.relative(repo, spec.worktreeDir) + const behavior = behaviors[spec.taskId] ?? "ok" + return { + run: async (): Promise => { + const attempt = (attempts.get(spec.taskId) ?? 0) + 1 + attempts.set(spec.taskId, attempt) + const result = await h.run(turnPrompt(spec.taskId, rel, marks, behavior, attempt)) + const bad = result.tools.find((tool) => tool.status !== "completed") + if (bad) { + const report: TaskReport = { outcome: "failed", reason: `${bad.name}: ${bad.text.slice(0, 200)}` } + onReport(spec.taskId, report) + return { status: "failed", reason: report.reason, spendUsd: 0 } + } + // Commit the work. .factory/runtime/ is gitignored (mirroring the + // real repo), so run state stays local; the ownership check below + // proves no branch tree ever carries it. + await git(spec.worktreeDir, ["add", "-A"]) + await git(spec.worktreeDir, [ + "-c", + "user.name=fleet-test", + "-c", + "user.email=fleet@test.local", + "commit", + "--no-verify", + "-q", + "-m", + `work by ${spec.taskId}`, + ]) + onReport(spec.taskId, { outcome: "done", spendUsd: 0 }) + return { status: "done", spendUsd: 0 } + }, + stop: () => Promise.resolve(), + snapshot: () => ({ taskId: spec.taskId, pid: -1, spendUsd: 0, tokensIn: 0, tokensOut: 0 }), + } + } +} + +const readNs = async (file: string): Promise => BigInt((await readFile(file, "utf8")).trim()) + +/** Release every still-allocated worktree (test hermeticity between cases). */ +const releaseAll = async (root: string, dagState: string): Promise => { + const { releaseWorktree } = await import("../src/worktree.ts") + const registry = await loadRegistry(dagState) + for (const [id, record] of Object.entries(registry?.worktrees ?? {})) + if (record.status === "allocated") await releaseWorktree(root, dagState, id, "abandoned") +} + +/** Run the daemon loop until `condition` holds (the loop stops on clear). */ +const drive = async ( + daemon: FleetDaemon, + condition: () => Promise, + timeoutMs: number, + what: string, +): Promise => { + let lastError = "" + const timer = setInterval( + () => + void daemon.tick().catch((error) => { + lastError = String(error).slice(0, 300) + }), + TICK_MS, + ) + try { + await daemon.tick().catch((error) => { + lastError = String(error).slice(0, 300) + }) + const deadline = Date.now() + timeoutMs + for (;;) { + if (await condition()) return + if (Date.now() > deadline) + throw new Error(`timed out waiting for ${what}${lastError ? ` (last tick error: ${lastError})` : ""}`) + await new Promise((resolve) => setTimeout(resolve, TICK_MS)) + } + } finally { + clearInterval(timer) + } +} + +const busFor = async (dagState: string): Promise<{ bus: TelemetryServer; seen: BusEvent[] }> => { + const bus = new TelemetryServer({ + stateRoot: dagState, + port: 0, + token: "x".repeat(40), + getSnapshot: () => ({ + daemon: { running: true, pid: 1, maxUsd: 100, concurrency: 4 }, + tasks: [], + spendUsd: 0, + pending: [], + }), + }) + await bus.start() + const seen: BusEvent[] = [] + bus.log.onAppend((event) => void seen.push(event)) + return { bus, seen } +} + +const daemonFor = ( + h: Harness, + dagState: string, + bus: TelemetryServer, + extra: { + behaviors?: Record + checkGates?: () => Promise<{ ok: true } | { ok: false; reason?: string }> + spawn?: (spec: WorkerSpec, onReport: (taskId: string, report: TaskReport) => void) => WorkerPort + marks?: string + } = {}, +): FleetDaemon => + new FleetDaemon({ + repoRoot: h.directory, + stateRoot: dagState, + esBin: "es", + fleetCeilingUsd: 100, + checkGates: extra.checkGates ?? (() => Promise.resolve({ ok: true as const })), + spawnWorker: extra.spawn ?? hostSpawner(h, extra.marks ?? dagState, extra.behaviors), + onTransition: (event) => void bus.log.appendSync("changed", { taskId: event.taskId, kind: event.type }), + }) + +describe("fleet integration (real host, fake model)", () => { + let h: Harness + let state: string + let base = "" + + beforeAll(async () => { + state = await mkdtemp(path.join(tmpdir(), "es-fleet-int-state-")) + h = await boot({ + git: true, + script: directiveScript, + files: { + "README.md": "# fleet integration\n", + // Mirror the real repo: run state and fleet checkouts never commit. + ".gitignore": ".factory/runtime/\n.fleet/\n", + }, + }) + base = await tip(h.directory, "HEAD") + }, 120_000) + + afterAll(async () => { + await h?.close() + await rm(state, { recursive: true, force: true }) + }) + + test("case 1: A, then B and C in parallel, isolated and ordered", async () => { + const root = h.directory + const dagState = await mkdtemp(path.join(tmpdir(), "es-fleet-int-1-")) + const marks = await mkdtemp(path.join(tmpdir(), "es-fleet-int-1-marks-")) + try { + let dag: DagState = emptyDag() + dag = addTask(dag, taskInput("int1-a"), STAMP) + dag = addTask(dag, taskInput("int1-b", ["int1-a"]), STAMP) + dag = addTask(dag, taskInput("int1-c", ["int1-a"]), STAMP) + await saveDag(dagState, dag) + const { bus, seen } = await busFor(dagState) + const daemon = daemonFor(h, dagState, bus, { marks }) + try { + await drive( + daemon, + async () => + (await dagStatus(dagState, "int1-b")) === "done" && (await dagStatus(dagState, "int1-c")) === "done", + 90_000, + "B and C to finish", + ) + await drive( + daemon, + async () => (await loadRegistry(dagState))?.worktrees["int1-c"]?.status === "merged", + 30_000, + "landing", + ) + } finally { + await bus.stop() + } + // Ordering from stable marks: B and C start only after A finishes... + const [aDone, bStart, cStart, bDone, cDone] = await Promise.all( + ["finished-int1-a", "started-int1-b", "started-int1-c", "finished-int1-b", "finished-int1-c"].map((file) => + readNs(path.join(marks, file)), + ), + ) + expect(bStart).toBeGreaterThan(aDone) + expect(cStart).toBeGreaterThan(aDone) + // ...and B and C overlap. + expect(bStart).toBeLessThan(cDone) + expect(cStart).toBeLessThan(bDone) + // The bus agrees: A starts first, then B and C. + const order = seen + .filter((event) => (event.payload.kind as string) === "started") + .map((event) => event.payload.taskId) + expect(order[0]).toBe("int1-a") + expect(order.slice(1).sort()).toEqual(["int1-b", "int1-c"]) + // Ownership: each branch tree carries only its own output — and never + // run state (.factory/), which would collide across branches on landing. + for (const id of ["int1-a", "int1-b", "int1-c"]) { + const files = await tree(root, `fleet/${id}`) + expect(files).toContain(`out-${id}.txt`) + expect(files.some((file) => file.startsWith(".factory/"))).toBe(false) + for (const other of ["int1-a", "int1-b", "int1-c"]) { + if (other !== id) expect(files).not.toContain(`out-${other}.txt`) + } + } + // Integration has every output; the base never moves. + const landed = await tree(root, "fleet/integration/fleet") + for (const id of ["int1-a", "int1-b", "int1-c"]) expect(landed).toContain(`out-${id}.txt`) + expect(await tip(root, "main")).toBe(base) + } finally { + await rm(dagState, { recursive: true, force: true }) + await rm(marks, { recursive: true, force: true }) + } + }, 150_000) + + test("case 2: failure, retry, cancellation of dependents, conflict repair", async () => { + const root = h.directory + const dagState = await mkdtemp(path.join(tmpdir(), "es-fleet-int-2-")) + const marks = await mkdtemp(path.join(tmpdir(), "es-fleet-int-2-marks-")) + try { + let dag: DagState = emptyDag() + dag = addTask(dag, taskInput("int2-f", [], { policy: { strategy: "retry", maxAttempts: 1 } }), STAMP) + dag = addTask(dag, taskInput("int2-g", ["int2-f"]), STAMP) + dag = addTask(dag, taskInput("int2-x", [], { policy: { strategy: "fail" } }), STAMP) + dag = addTask(dag, taskInput("int2-y", ["int2-x"]), STAMP) + dag = addTask(dag, taskInput("int2-p"), STAMP) + dag = addTask(dag, taskInput("int2-q"), STAMP) + await saveDag(dagState, dag) + const { bus, seen } = await busFor(dagState) + void seen + const daemon = daemonFor(h, dagState, bus, { + marks, + behaviors: { "int2-f": "failOnce", "int2-x": "failAlways", "int2-p": "conflict", "int2-q": "conflict" }, + }) + try { + await drive( + daemon, + async () => (await dagStatus(dagState, "int2-f")) === "done", + 90_000, + "F to be retried to done", + ) + await drive(daemon, async () => (await dagStatus(dagState, "int2-y")) === "cancelled", 60_000, "Y to cancel") + await drive( + daemon, + async () => (await dagStatus(dagState, "int2-q")) === "waiting-human", + 90_000, + "Q to wait on conflict", + ) + await drive( + daemon, + async () => (await dagStatus(dagState, "int2-g")) === "done", + 90_000, + "G to finish after F's retry", + ) + await drive( + daemon, + async () => (await loadRegistry(dagState))?.worktrees["int2-g"]?.status === "merged", + 60_000, + "G to land", + ) + const final = (await loadDag(dagState))! + // Retry(1): two attempts, then done; the dependent still runs. + expect(final.tasks["int2-f"]?.attempts).toBe(2) + expect(final.tasks["int2-f"]?.status).toBe("done") + expect(final.tasks["int2-g"]?.status).toBe("done") + // Terminal failure cancels dependents with the reason. + expect(final.tasks["int2-x"]?.status).toBe("failed") + expect(final.tasks["int2-y"]?.status).toBe("cancelled") + expect(final.tasks["int2-y"]?.reason).toMatch(/int2-x/) + // Conflict: P lands, Q waits for a human, the integration tip is stable. + expect(final.tasks["int2-p"]?.status).toBe("done") + expect(final.tasks["int2-q"]?.status).toBe("waiting-human") + expect(final.tasks["int2-q"]?.reason).toMatch(/conflict/i) + const landed = await tree(root, "fleet/integration/fleet") + expect(landed).toContain("out-int2-f.txt") + expect(landed).toContain("conflict.txt") + expect(landed).not.toContain("out-int2-y.txt") + const stableTip = await tip(root, "fleet/integration/fleet") + await daemon.tick() + await daemon.tick() + expect(await tip(root, "fleet/integration/fleet")).toBe(stableTip) + expect(await tip(root, "main")).toBe(base) + } finally { + await bus.stop().catch(() => undefined) + await releaseAll(root, dagState) + } + } finally { + await rm(dagState, { recursive: true, force: true }) + await rm(marks, { recursive: true, force: true }) + } + }, 240_000) + + test("case 3: mid-run stop cancels workers, then salvage and gc clean up", async () => { + const root = h.directory + const dagState = await mkdtemp(path.join(tmpdir(), "es-fleet-int-3-")) + try { + const stubDir = await mkdtemp(path.join(tmpdir(), "es-fleet-int-3-stub-")) + const stub = path.join(stubDir, "long.mjs") + await writeFile(stub, `await new Promise(() => {});\n`) + let dag: DagState = emptyDag() + dag = addTask(dag, taskInput("int3-long"), STAMP) + await saveDag(dagState, dag) + const { bus } = await busFor(dagState) + const daemon = daemonFor(h, dagState, bus, { + spawn: (spec, onReport) => + new Worker({ + ...spec, + esBin: stub, + spawn: { command: process.execPath, args: [stub] }, + onReport, + }), + }) + const timer = setInterval(() => void daemon.tick().catch(() => undefined), TICK_MS) + try { + await daemon.tick() + await waitFor(async () => (await dagStatus(dagState, "int3-long")) === "running", 30_000, "LONG to start") + await daemon.shutdown() + } finally { + clearInterval(timer) + } + // The cancellation is recorded as resumable... + const final = (await loadDag(dagState))! + expect(final.tasks["int3-long"]?.status).toBe("cancelled") + expect(final.tasks["int3-long"]?.reason).toMatch(/resumable/) + // ...the abandoned work is salvaged, and gc removes the worktree. + const { releaseWorktree, gcWorktrees } = await import("../src/worktree.ts") + await releaseWorktree(root, dagState, "int3-long", "abandoned") + const salvage = await tip(root, "refs/fleet-salvage/int3-long") + expect(salvage.length).toBe(40) + const report = await gcWorktrees(root, dagState) + expect(report.removed).toHaveLength(0) + const registry = await loadRegistry(dagState) + expect(registry?.worktrees["int3-long"]?.status).toBe("abandoned") + expect(await tip(root, "main")).toBe(base) + await bus.stop() + await rm(stubDir, { recursive: true, force: true }) + } finally { + await rm(dagState, { recursive: true, force: true }) + } + }, 120_000) + + test("case 4: a gate failure marks the task failed and moves no branch", async () => { + const root = h.directory + const dagState = await mkdtemp(path.join(tmpdir(), "es-fleet-int-4-")) + const marks = await mkdtemp(path.join(tmpdir(), "es-fleet-int-4-marks-")) + try { + let dag: DagState = emptyDag() + dag = addTask(dag, taskInput("int4-gated"), STAMP) + await saveDag(dagState, dag) + const intTip = async (): Promise => { + try { + return await tip(root, "fleet/integration/fleet") + } catch { + return undefined + } + } + const before = await intTip() + const { bus } = await busFor(dagState) + const daemon = daemonFor(h, dagState, bus, { + marks, + checkGates: () => Promise.resolve({ ok: false as const, reason: "int4 test gate failure" }), + }) + try { + await drive(daemon, async () => (await dagStatus(dagState, "int4-gated")) === "failed", 90_000, "GATED to fail") + const final = (await loadDag(dagState))! + expect(final.tasks["int4-gated"]?.status).toBe("failed") + expect(final.tasks["int4-gated"]?.reason).toMatch(/int4 test gate failure/) + expect(await intTip()).toBe(before) + expect(await tip(root, "main")).toBe(base) + } finally { + await bus.stop().catch(() => undefined) + await releaseAll(root, dagState) + } + } finally { + await rm(dagState, { recursive: true, force: true }) + await rm(marks, { recursive: true, force: true }) + } + }, 150_000) + + test("break probes: shared worktrees and early starts fail the checks", async () => { + const root = h.directory + // Probe 1: two tasks pointed at one worktree break output ownership. + const shared = await tree(root, "fleet/int1-a") + const owned = (files: string[], id: string, others: string[]) => + files.includes(`out-${id}.txt`) && others.every((other) => !files.includes(`out-${other}.txt`)) + expect(owned(shared, "int1-a", ["int1-b", "int1-c"])).toBe(true) + expect(owned([...shared, "out-int1-b.txt"], "int1-a", ["int1-b", "int1-c"])).toBe(false) + // Probe 2: a task started before its deps violates ordering. + const { applyDecision } = await import("../src/scheduler.ts") + let dag: DagState = emptyDag() + dag = addTask(dag, taskInput("probe-a"), STAMP) + dag = addTask(dag, taskInput("probe-b", ["probe-a"]), STAMP) + const rigged = applyDecision(dag, { start: ["probe-b"], unblock: [], cancel: [] }, STAMP) + const violations = Object.values(rigged.tasks).filter( + (task) => task.status === "running" && !task.deps.every((dep) => rigged.tasks[dep]?.status === "done"), + ) + expect(violations.map((task) => task.id)).toEqual(["probe-b"]) + expect(root.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/fleet/test/lifecycle.test.ts b/packages/fleet/test/lifecycle.test.ts new file mode 100644 index 0000000..bfb8de0 --- /dev/null +++ b/packages/fleet/test/lifecycle.test.ts @@ -0,0 +1,96 @@ +// Daemon lifecycle (#122): start/stop/status under a lock in the state dir. +// Starting or stopping from an agent sandbox is refused. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { FleetError, fleetStatus, startFleet, stopFleet } from "../src/lifecycle.ts" +import { fleetPaths } from "../src/state.ts" + +const tempRoot = () => mkdtemp(path.join(tmpdir(), "es-fleet-life-")) +const humanEnv = { ...process.env, ES_SANDBOX: undefined } as NodeJS.ProcessEnv +delete humanEnv.ES_SANDBOX + +describe("daemon lifecycle", () => { + test("start then status reports running; stop reports stopped", async () => { + const root = await tempRoot() + try { + const handle = await startFleet({ stateRoot: root, maxUsd: 10, env: humanEnv }) + try { + const status = await fleetStatus(root) + expect(status.running).toBe(true) + expect(status.pid).toBe(process.pid) + expect(status.maxUsd).toBe(10) + } finally { + await handle.close() + } + expect((await fleetStatus(root)).running).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("a double start is refused by the lock while the owner is live", async () => { + const root = await tempRoot() + try { + const handle = await startFleet({ stateRoot: root, maxUsd: 10, env: humanEnv }) + try { + await expect(startFleet({ stateRoot: root, maxUsd: 10, env: humanEnv })).rejects.toBeInstanceOf(FleetError) + } finally { + await handle.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("a stale pidfile is recovered", async () => { + const root = await tempRoot() + try { + const paths = fleetPaths(root) + const { mkdir } = await import("node:fs/promises") + await mkdir(paths.dir, { recursive: true }) + // A pid that is certainly dead (2^30) with no lock held. + await writeFile(paths.pid, JSON.stringify({ pid: 1073741824, startedAt: new Date(0).toISOString() })) + const handle = await startFleet({ stateRoot: root, maxUsd: 5, env: humanEnv }) + try { + expect((await fleetStatus(root)).pid).toBe(process.pid) + } finally { + await handle.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("start refuses inside an agent sandbox", async () => { + const root = await tempRoot() + try { + await expect( + startFleet({ stateRoot: root, maxUsd: 10, env: { ...process.env, ES_SANDBOX: "seat" } }), + ).rejects.toBeInstanceOf(FleetError) + expect((await fleetStatus(root)).running).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("start requires a positive spend ceiling", async () => { + const root = await tempRoot() + try { + await expect(startFleet({ stateRoot: root, maxUsd: 0, env: humanEnv })).rejects.toBeInstanceOf(FleetError) + await expect(startFleet({ stateRoot: root, maxUsd: -3, env: humanEnv })).rejects.toBeInstanceOf(FleetError) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("stopFleet on a stopped fleet reports stopped:false, never throws", async () => { + const root = await tempRoot() + try { + expect(await stopFleet(root)).toEqual({ stopped: false }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/liveness.test.ts b/packages/fleet/test/liveness.test.ts new file mode 100644 index 0000000..4a95e48 --- /dev/null +++ b/packages/fleet/test/liveness.test.ts @@ -0,0 +1,120 @@ +// S5.6 liveness for #130 (repair-151, feeds S7): per-task liveness computed +// in fleet with core's factory/liveness.ts, served on the bus as JSON-safe +// data exported as a *type* for the web control plane. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { readTaskLiveness, type TaskLiveness } from "../src/liveness.ts" + +const STATE = { + version: 2, + runId: "run-live-1", + stage: "BUILD", + mode: "build", + createdAt: "2026-10-09T10:00:00.000Z", + updatedAt: "2026-10-09T11:00:00.000Z", + activePhase: "phase-1", +} as const + +async function fixtureRun(): Promise<{ dir: string; cleanup: () => Promise }> { + const dir = await mkdtemp(path.join(tmpdir(), "es-fleet-live-")) + const { mkdir } = await import("node:fs/promises") + await mkdir(path.join(dir, ".factory", "runtime"), { recursive: true }) + await writeFile(path.join(dir, ".factory", "runtime", "state.json"), JSON.stringify(STATE)) + return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) } +} + +describe("readTaskLiveness (S5.6)", () => { + test("a fixture run yields headline, stage and seat lines", async () => { + const { dir, cleanup } = await fixtureRun() + try { + const live: TaskLiveness = await readTaskLiveness(dir, "task-live") + expect(live.taskId).toBe("task-live") + expect(live.stage).toBe("BUILD") + expect(live.runId).toBe("run-live-1") + expect(typeof live.headline).toBe("string") + expect(live.headline.length).toBeGreaterThan(0) + expect(Array.isArray(live.seats)).toBe(true) + } finally { + await cleanup() + } + }) + + test("a worktree without a run reports NONE", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-fleet-live-empty-")) + try { + const live = await readTaskLiveness(dir, "task-empty") + expect(live.stage).toBe("NONE") + expect(live.headline).toMatch(/no factory run/i) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("fleet.task.liveness bus method (S5.6)", () => { + test("the bus returns liveness for a task with a fixture run", async () => { + const { dir, cleanup } = await fixtureRun() + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-live-state-")) + try { + const { fleetPaths } = await import("../src/state.ts") + const { mkdir } = await import("node:fs/promises") + await mkdir(fleetPaths(state).dir, { recursive: true }) + const base = "a".repeat(40) + await writeFile( + fleetPaths(state).worktrees, + JSON.stringify({ + v: 1, + worktrees: { + "task-live": { + taskId: "task-live", + dir, + branch: "fleet/task-live", + base, + createdAt: "2026-10-09T10:00:00.000Z", + status: "allocated", + }, + }, + }), + ) + const { TelemetryServer } = await import("../src/telemetry.ts") + const server = new TelemetryServer({ + stateRoot: state, + port: 0, + token: "t".repeat(40), + getSnapshot: () => ({ + daemon: { running: true, pid: 1, maxUsd: 100, concurrency: 4 }, + tasks: [], + spendUsd: 0, + pending: [], + }), + }) + await server.start() + try { + const response = await fetch(`http://127.0.0.1:${server.port}/fleet/rpc`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${"t".repeat(40)}` }, + body: JSON.stringify({ method: "fleet.task.liveness", params: { id: "task-live" } }), + }) + expect(response.status).toBe(200) + const body = (await response.json()) as { result: TaskLiveness } + expect(body.result.stage).toBe("BUILD") + expect(body.result.headline.length).toBeGreaterThan(0) + const missing = (await ( + await fetch(`http://127.0.0.1:${server.port}/fleet/rpc`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${"t".repeat(40)}` }, + body: JSON.stringify({ method: "fleet.task.liveness", params: { id: "ghost" } }), + }) + ).json()) as { error: unknown } + expect(missing.error).toBeDefined() + } finally { + await server.stop() + } + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/scheduler.test.ts b/packages/fleet/test/scheduler.test.ts new file mode 100644 index 0000000..b650009 --- /dev/null +++ b/packages/fleet/test/scheduler.test.ts @@ -0,0 +1,241 @@ +// Deterministic DAG scheduler (#123): readiness, concurrency, the fleet +// ceiling, failure policies, waiting-human capacity, and determinism. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { applyDecision, markGateFailed, reportTask, schedule } from "../src/scheduler.ts" +import { addTask, type DagState, emptyDag, loadDag, saveDag, TaskSchema } from "../src/tasks.ts" + +const NOW = "2026-10-09T12:00:00.000Z" +const CAPS = { concurrency: 4, fleetCeilingUsd: 100 } + +const task = (id: string, deps: string[] = [], extra: Record = {}) => ({ + id, + title: `task ${id}`, + kind: "factory-run" as const, + deps, + input: { objective: `do ${id}` }, + ceilingUSD: 5, + createdAt: NOW, + updatedAt: NOW, + ...extra, +}) + +const dagOf = (specs: Array<{ id: string; deps?: string[]; extra?: Record }>): DagState => { + let dag = emptyDag() + for (const spec of specs) dag = addTask(dag, task(spec.id, spec.deps ?? [], spec.extra ?? {}), NOW) + return dag +} + +const runTo = (dag: DagState, ids: readonly string[], status: "done" | "failed" = "done"): DagState => { + let next = dag + for (const id of ids) { + const scheduled = applyDecision(next, schedule(next, CAPS), NOW) + next = reportTask( + applyDecision(scheduled, { start: [id], unblock: [], cancel: [] }, NOW), + id, + { outcome: "running" }, + NOW, + ).dag + next = reportTask(next, id, { outcome: status }, NOW).dag + } + return next +} + +describe("readiness and ordering", () => { + test("nothing starts before its deps are done", () => { + const dag = dagOf([{ id: "a" }, { id: "b", deps: ["a"] }, { id: "c", deps: ["a"] }]) + const first = schedule(dag, CAPS) + expect(first.start).toEqual(["a"]) + expect(first.unblock).toEqual(["a"]) + const afterA = runTo(dag, ["a"]) + const second = schedule(afterA, CAPS) + expect([...second.start].sort()).toEqual(["b", "c"]) + }) + + test("one_success starts on the first done dep", () => { + const dag = dagOf([{ id: "a" }, { id: "b" }, { id: "c", deps: ["a", "b"], extra: { trigger: "one_success" } }]) + const afterA = runTo(dag, ["a"]) + expect(schedule(afterA, CAPS).start).toContain("c") + }) + + test("one_success waits while deps are pending, cancels when all fail", () => { + const dag = dagOf([{ id: "a" }, { id: "b" }, { id: "c", deps: ["a", "b"], extra: { trigger: "one_success" } }]) + expect(schedule(dag, CAPS).start).not.toContain("c") + const afterFail = runTo(dag, ["a", "b"], "failed") + expect(schedule(afterFail, CAPS).cancel.map((c) => c.id)).toContain("c") + }) +}) + +describe("concurrency and ceilings", () => { + test("starts stop at the concurrency limit, in id order", () => { + const dag = dagOf([{ id: "a" }, { id: "b" }, { id: "c" }]) + const decision = schedule(dag, { concurrency: 2, fleetCeilingUsd: 100 }) + expect(decision.start).toEqual(["a", "b"]) + }) + + test("the fleet ceiling caps concurrent starts by summed task ceilings", () => { + const dag = dagOf([ + { id: "a", extra: { ceilingUSD: 60 } }, + { id: "b", extra: { ceilingUSD: 60 } }, + ]) + expect(schedule(dag, CAPS).start).toEqual(["a"]) + }) + + test("waiting-human tasks free capacity and ceiling", () => { + let dag = dagOf([{ id: "a" }, { id: "b" }]) + dag = applyDecision(dag, schedule(dag, { concurrency: 1, fleetCeilingUsd: 5 }), NOW) + dag = reportTask( + applyDecision(dag, { start: ["a"], unblock: [], cancel: [] }, NOW), + "a", + { outcome: "running" }, + NOW, + ).dag + dag = reportTask(dag, "a", { outcome: "waiting-human", reason: "frontier approval" }, NOW).dag + // a waits on a human: b may start despite concurrency 1 and ceiling 5. + expect(schedule(dag, { concurrency: 1, fleetCeilingUsd: 5 }).start).toEqual(["b"]) + }) +}) + +describe("failure policies", () => { + test("fail (default): dependents of a failed task are cancelled with a reason", () => { + const dag = dagOf([{ id: "a" }, { id: "b", deps: ["a"] }]) + const afterFail = runTo(dag, ["a"], "failed") + const decision = schedule(afterFail, CAPS) + expect(decision.cancel.map((c) => c.id)).toEqual(["b"]) + expect(decision.cancel[0]!.reason).toMatch(/a/) + }) + + test("retry(n): re-queued up to n times, then failed", () => { + let dag = dagOf([{ id: "a", extra: { policy: { strategy: "retry", maxAttempts: 1 } } }]) + dag = runTo(dag, ["a"], "failed") + expect(dag.tasks.a!.status).toBe("pending") + expect(dag.tasks.a!.attempts).toBe(1) + dag = runTo(dag, ["a"], "failed") + expect(dag.tasks.a!.status).toBe("failed") + expect(dag.tasks.a!.attempts).toBe(2) + }) + + test("replan: the task waits for a human with the reason recorded", () => { + let dag = dagOf([{ id: "a", extra: { policy: { strategy: "replan" } } }]) + dag = runTo(dag, ["a"], "failed") + expect(dag.tasks.a!.status).toBe("waiting-human") + expect(dag.tasks.a!.reason).toMatch(/replan/i) + }) + + test("a landing conflict reopens a done task as waiting-human (#127)", () => { + let dag = dagOf([{ id: "a" }]) + dag = runTo(dag, ["a"], "done") + expect(dag.tasks.a!.status).toBe("done") + const next = reportTask(dag, "a", { outcome: "waiting-human", reason: "merge conflict landing" }, NOW) + expect(next.changed).toBe(true) + expect(next.dag.tasks.a!.status).toBe("waiting-human") + // After human repair the task lands as done again. + const redone = reportTask(next.dag, "a", { outcome: "done", reason: "landed after human repair" }, NOW) + expect(redone.changed).toBe(true) + expect(redone.dag.tasks.a!.status).toBe("done") + }) +}) + +describe("determinism", () => { + test("replaying the same events gives an identical state", () => { + const events: Array<{ id: string; outcome: "done" | "failed" }> = [ + { id: "a", outcome: "done" }, + { id: "b", outcome: "done" }, + { id: "c", outcome: "failed" }, + ] + const replay = () => { + let dag = dagOf([{ id: "a" }, { id: "b", deps: ["a"] }, { id: "c", deps: ["a"] }]) + for (const event of events) dag = runTo(dag, [event.id], event.outcome) + return dag + } + expect(replay()).toEqual(replay()) + }) + + test("property: random DAGs never start early and never exceed capacity or ceiling", () => { + const rand = mulberry32(123123) + for (let trial = 0; trial < 50; trial++) { + const n = 2 + Math.floor(rand() * 6) + const ids = Array.from({ length: n }, (_, i) => `t${i}`) + let dag = emptyDag() + for (const [i, id] of ids.entries()) { + const deps = ids.slice(0, i).filter(() => rand() < 0.4) + dag = addTask( + dag, + task(id, deps, { + ceilingUSD: 1 + Math.floor(rand() * 10), + ...(rand() < 0.3 ? { trigger: "one_success" } : {}), + }), + ) + } + const concurrency = 1 + Math.floor(rand() * 3) + const fleetCeilingUsd = 5 + Math.floor(rand() * 20) + // Randomly complete some tasks, then check the next decision. + const done = new Set() + for (const id of ids) { + if (rand() < 0.3 && dag.tasks[id]!.deps.every((d) => done.has(d))) { + dag = { ...dag, tasks: { ...dag.tasks, [id]: { ...dag.tasks[id]!, status: "done" as const } } } + done.add(id) + } + } + const running = ids.filter((id) => dag.tasks[id]!.status === "running") + const decision = schedule(dag, { concurrency, fleetCeilingUsd }) + for (const id of decision.start) { + const t = dag.tasks[id]! + expect(t.status === "pending" || t.status === "ready").toBe(true) + // A task with no deps is immediately runnable under either trigger. + if (t.deps.length === 0) continue + if (t.trigger === "all_success") expect(t.deps.every((d) => done.has(d))).toBe(true) + else expect(t.deps.some((d) => done.has(d))).toBe(true) + } + expect(running.length + decision.start.length).toBeLessThanOrEqual(concurrency) + const spend = + running.reduce((sum, id) => sum + dag.tasks[id]!.ceilingUSD, 0) + + decision.start.reduce((sum, id) => sum + dag.tasks[id]!.ceilingUSD, 0) + expect(spend).toBeLessThanOrEqual(fleetCeilingUsd) + } + }) +}) + +describe("persistence", () => { + test("save/load round-trips under the fleet lock", async () => { + const root = await mkdtemp(path.join(tmpdir(), "es-fleet-dag-")) + try { + const dag = dagOf([{ id: "a" }, { id: "b", deps: ["a"] }]) + await saveDag(root, dag) + expect(await loadDag(root)).toEqual(dag) + expect(await loadDag(await mkdtemp(path.join(tmpdir(), "es-fleet-empty-")))).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +describe("markGateFailed", () => { + test("done becomes failed with the gate reason; other statuses are unchanged", () => { + const done = reportTask(dagOf([{ id: "a" }]), "a", { outcome: "done" }, NOW).dag + const failed = markGateFailed(done, "a", "gates failed", NOW) + expect(failed.changed).toBe(true) + expect(failed.dag.tasks.a.status).toBe("failed") + expect(failed.dag.tasks.a.reason).toBe("gates failed") + const untouched = markGateFailed(dagOf([{ id: "a" }]), "a", "gates failed", NOW) + expect(untouched.changed).toBe(false) + expect(untouched.dag.tasks.a.status).toBe("pending") + }) +}) + +/** Deterministic PRNG (mulberry32): the seed prints on failure, no dependency. */ +function mulberry32(seed: number) { + let state = seed + return () => { + state |= 0 + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Keep TaskSchema imported for the schema-level tests elsewhere. +void TaskSchema diff --git a/packages/fleet/test/socket-guard.test.ts b/packages/fleet/test/socket-guard.test.ts new file mode 100644 index 0000000..ce0ca62 --- /dev/null +++ b/packages/fleet/test/socket-guard.test.ts @@ -0,0 +1,78 @@ +// Inode-bound unix-socket ownership (#122): a second live owner is refused, +// a stale socket file is reclaimed. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { acquireSocket, SocketBusyError } from "../src/socket-guard.ts" + +const tempRoot = () => mkdtemp(path.join(tmpdir(), "es-fleet-sock-")) + +describe("socket guard", () => { + test("acquire then verifyOwner passes for the live owner", async () => { + const root = await tempRoot() + try { + const guard = await acquireSocket(path.join(root, "fleet.sock")) + try { + expect(await guard.verifyOwner()).toBe(true) + } finally { + await guard.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("a second acquire while the owner is live is refused", async () => { + const root = await tempRoot() + try { + const sock = path.join(root, "fleet.sock") + const guard = await acquireSocket(sock) + try { + await expect(acquireSocket(sock)).rejects.toBeInstanceOf(SocketBusyError) + } finally { + await guard.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("a stale socket file (no listener) is reclaimed", async () => { + const root = await tempRoot() + try { + const sock = path.join(root, "fleet.sock") + const first = await acquireSocket(sock) + await first.close() + // The listener is gone but nothing cleaned the file: simulate a crash + // by rebinding over an unlinked... here close() unlinks, so plant a + // dead regular file instead. + await writeFile(sock, "dead") + const second = await acquireSocket(sock) + try { + expect(await second.verifyOwner()).toBe(true) + } finally { + await second.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("verifyOwner fails after the path is replaced", async () => { + const root = await tempRoot() + try { + const sock = path.join(root, "fleet.sock") + const guard = await acquireSocket(sock) + try { + await guard.close() + await writeFile(sock, "impostor") + expect(await guard.verifyOwner()).toBe(false) + } finally { + await guard.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/start.test.ts b/packages/fleet/test/start.test.ts new file mode 100644 index 0000000..71814c4 --- /dev/null +++ b/packages/fleet/test/start.test.ts @@ -0,0 +1,97 @@ +// S5.2 (#122) start grant + S5.5 (#126) `status --token` (repair-151). +import { describe, expect, test } from "bun:test" +import { spawn } from "node:child_process" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { readStatusToken, startDecision } from "../src/bin.ts" + +describe("startDecision (S5.2)", () => { + test("a plain non-TTY start without a grant is refused", () => { + expect(startDecision({ isTTY: false, foreground: false, grant: false, confirm: false })).toMatchObject({ + action: "refuse", + }) + }) + + test("a granted child without a TTY runs", () => { + expect(startDecision({ isTTY: false, foreground: true, grant: true, confirm: false })).toMatchObject({ + action: "run", + }) + }) + + test("TTY + confirmation daemonizes in the background", () => { + expect(startDecision({ isTTY: true, foreground: false, grant: false, confirm: true })).toMatchObject({ + action: "daemonize", + }) + }) + + test("TTY without confirmation declines, background or foreground", () => { + for (const foreground of [false, true]) + expect(startDecision({ isTTY: true, foreground, grant: false, confirm: false })).toMatchObject({ + action: "declined", + }) + }) + + test("TTY + foreground + confirmation runs in this process", () => { + expect(startDecision({ isTTY: true, foreground: true, grant: false, confirm: true })).toMatchObject({ + action: "run", + }) + }) + + test("no env var or flag grants a start: grant comes only from the matrix input", () => { + // A caller that smuggles ES_FLEET_GRANT=1 in the environment still gets + // no grant unless the IPC handshake said so (grant: false here). + process.env.ES_FLEET_GRANT = "1" + try { + expect(startDecision({ isTTY: false, foreground: true, grant: false, confirm: false })).toMatchObject({ + action: "refuse", + }) + } finally { + delete process.env.ES_FLEET_GRANT + } + }) +}) + +describe("status --token (S5.5)", () => { + test("it refuses without a TTY", async () => { + await expect(readStatusToken("/nonexistent", { isTTY: false })).rejects.toThrow(/interactive terminal/) + }) + + test("it prints the token with a TTY", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-token-")) + try { + const { fleetPaths } = await import("../src/state.ts") + const { mkdir } = await import("node:fs/promises") + await mkdir(fleetPaths(state).dir, { recursive: true }) + await writeFile(fleetPaths(state).token, `${"t".repeat(40)}\n`, { mode: 0o600 }) + await expect(readStatusToken(state, { isTTY: true })).resolves.toBe("t".repeat(40)) + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("start grant integration (S5.2)", () => { + test("a child without the grant exits non-zero", async () => { + const helper = new URL("./fixtures/no-grant-child.ts", import.meta.url).pathname + const code = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [helper], { stdio: ["ignore", "pipe", "pipe"] }) + let stderr = "" + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.on("error", reject) + child.on("exit", (exitCode) => { + try { + expect(exitCode).not.toBe(0) + expect(stderr).toMatch(/grant|terminal/i) + } catch (error) { + reject(error) + return + } + resolve(exitCode ?? 1) + }) + }) + expect(code).not.toBe(0) + }) +}) diff --git a/packages/fleet/test/state.test.ts b/packages/fleet/test/state.test.ts new file mode 100644 index 0000000..fdf3232 --- /dev/null +++ b/packages/fleet/test/state.test.ts @@ -0,0 +1,68 @@ +// Fleet state helpers (#122): paths under the masked state dir, validated +// schemas, and locked persistence. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { fleetPaths, readFleetState, writeFleetState } from "../src/state.ts" + +const tempRoot = () => mkdtemp(path.join(tmpdir(), "es-fleet-state-")) + +describe("fleet paths", () => { + test("fleet state lives under /fleet", () => { + const paths = fleetPaths("/state") + expect(paths.dir).toBe(path.join("/state", "fleet")) + for (const file of [paths.pid, paths.lock, paths.state, paths.socket, paths.token]) + expect(file.startsWith(`${paths.dir}${path.sep}`)).toBe(true) + }) +}) + +describe("fleet state persistence", () => { + test("a missing state file reads as undefined", async () => { + const root = await tempRoot() + try { + expect(await readFleetState(root)).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("write then read round-trips under the lock", async () => { + const root = await tempRoot() + try { + await writeFleetState(root, { + v: 1, + running: true, + pid: 1234, + startedAt: "2026-10-09T00:00:00.000Z", + maxUsd: 25, + concurrency: 3, + }) + expect(await readFleetState(root)).toEqual({ + v: 1, + running: true, + pid: 1234, + startedAt: "2026-10-09T00:00:00.000Z", + maxUsd: 25, + concurrency: 3, + }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("a corrupt state file reads as undefined, never throws", async () => { + const root = await tempRoot() + try { + const { mkdir, writeFile } = await import("node:fs/promises") + await mkdir(fleetPaths(root).dir, { recursive: true }) + await writeFile(fleetPaths(root).state, "{not json") + expect(await readFleetState(root)).toBeUndefined() + // Wrong version or shape is also rejected, not trusted. + await writeFile(fleetPaths(root).state, JSON.stringify({ v: 999, running: true })) + expect(await readFleetState(root)).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/tasks.test.ts b/packages/fleet/test/tasks.test.ts new file mode 100644 index 0000000..c1ad7fc --- /dev/null +++ b/packages/fleet/test/tasks.test.ts @@ -0,0 +1,79 @@ +// Task model and DAG validation (#123): unknown deps, self-deps and cycles +// are rejected on add; ids are deterministic. +import { describe, expect, test } from "bun:test" +import { addTask, emptyDag, TaskSchema, validateDag } from "../src/tasks.ts" + +const STAMP = "2026-10-09T12:00:00.000Z" +const task = (id: string, deps: string[] = [], extra: Record = {}) => ({ + id, + title: `task ${id}`, + kind: "factory-run" as const, + deps, + input: { objective: `do ${id}` }, + ceilingUSD: 5, + createdAt: STAMP, + updatedAt: STAMP, + ...extra, +}) + +describe("task schema", () => { + test("defaults: all_success trigger, fail policy, pending status, zero attempts", () => { + const parsed = TaskSchema.parse(task("a")) + expect(parsed.trigger).toBe("all_success") + expect(parsed.policy).toEqual({ strategy: "fail" }) + expect(parsed.status).toBe("pending") + expect(parsed.attempts).toBe(0) + }) + + test("bad ids, empty titles and non-positive ceilings are rejected", () => { + expect(() => TaskSchema.parse(task("Bad ID!"))).toThrow() + expect(() => TaskSchema.parse(task("a", [], { title: "" }))).toThrow() + expect(() => TaskSchema.parse(task("a", [], { ceilingUSD: 0 }))).toThrow() + expect(() => TaskSchema.parse(task("a", [], { ceilingUSD: -1 }))).toThrow() + }) +}) + +describe("DAG validation", () => { + test("unknown deps and self-deps are rejected", () => { + expect(() => validateDag({ v: 1, tasks: { a: TaskSchema.parse(task("a", ["ghost"])) } })).toThrow(/ghost/) + expect(() => validateDag({ v: 1, tasks: { a: TaskSchema.parse(task("a", ["a"])) } })).toThrow() + }) + + test("cycles are rejected, however long", () => { + const two = { + v: 1 as const, + tasks: { a: TaskSchema.parse(task("a", ["b"])), b: TaskSchema.parse(task("b", ["a"])) }, + } + expect(() => validateDag(two)).toThrow(/cycle/i) + const three = { + v: 1 as const, + tasks: { + a: TaskSchema.parse(task("a", ["c"])), + b: TaskSchema.parse(task("b", ["a"])), + c: TaskSchema.parse(task("c", ["b"])), + }, + } + expect(() => validateDag(three)).toThrow(/cycle/i) + }) + + test("a diamond validates", () => { + const dag = { + v: 1 as const, + tasks: { + a: TaskSchema.parse(task("a")), + b: TaskSchema.parse(task("b", ["a"])), + c: TaskSchema.parse(task("c", ["a"])), + d: TaskSchema.parse(task("d", ["b", "c"])), + }, + } + expect(() => validateDag(dag)).not.toThrow() + }) + + test("addTask validates the result and refuses duplicate ids", () => { + let dag = emptyDag() + dag = addTask(dag, task("a")) + expect(Object.keys(dag.tasks)).toEqual(["a"]) + expect(() => addTask(dag, task("a"))).toThrow(/duplicate/i) + expect(() => addTask(dag, task("b", ["ghost"]))).toThrow(/ghost/) + }) +}) diff --git a/packages/fleet/test/telemetry.test.ts b/packages/fleet/test/telemetry.test.ts new file mode 100644 index 0000000..f6d662b --- /dev/null +++ b/packages/fleet/test/telemetry.test.ts @@ -0,0 +1,432 @@ +// Telemetry bus (#126): auth, Host/Origin checks, read-only RPC, strict +// schemas, payload scrubbing, backlog replay and coalesced WS streams. +import { describe, expect, test } from "bun:test" +import { createHash, randomBytes } from "node:crypto" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { createConnection } from "node:net" +import { tmpdir } from "node:os" +import path from "node:path" +import { ensureToken, type FleetSnapshot, scrubPayload, TelemetryServer } from "../src/telemetry.ts" + +const snapshot = (extra: Partial = {}): FleetSnapshot => ({ + daemon: { running: true, pid: 4242, maxUsd: 50, concurrency: 2 }, + tasks: [ + { + id: "a", + title: "Alpha", + status: "running", + ceilingUSD: 5, + spendUsd: 1.25, + deps: [], + reason: "waiting on frontier", + }, + { id: "b", title: "Beta", status: "pending", ceilingUSD: 5, spendUsd: 0, deps: ["a"] }, + ], + spendUsd: 1.25, + pending: [{ taskId: "a", reason: "frontier approval", stage: "frontier" }], + ...extra, +}) + +const TOKEN = `test-token-${randomBytes(8).toString("hex")}` + +async function serverFor(state: string, snap: FleetSnapshot = snapshot()) { + const { fleetPaths } = await import("../src/state.ts") + const { mkdir } = await import("node:fs/promises") + await mkdir(fleetPaths(state).dir, { recursive: true }) + await writeFile(fleetPaths(state).token, TOKEN, { mode: 0o600 }) + const server = new TelemetryServer({ + stateRoot: state, + port: 0, + token: TOKEN, + getSnapshot: () => snap, + }) + await server.start() + return server +} + +const rpc = (server: TelemetryServer, method: string, params: unknown, token: string | undefined) => + fetch(`http://127.0.0.1:${server.port}/fleet/rpc`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ method, params }), + }) + +describe("auth and binding", () => { + test("missing or bad token gives 401 on RPC and WS", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const server = await serverFor(state) + try { + expect((await rpc(server, "fleet.status", {}, undefined)).status).toBe(401) + expect((await rpc(server, "fleet.status", {}, "wrong")).status).toBe(401) + expect((await rpc(server, "fleet.status", {}, TOKEN)).status).toBe(200) + // WS upgrade without a token is refused before the handshake. + const wsStatus = await wsHandshake(server.port, {}) + expect(wsStatus).toBe(401) + const wsOk = await wsHandshake(server.port, { authorization: `Bearer ${TOKEN}` }) + expect(wsOk).toBe(101) + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("a wrong Host or Origin gives 403", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const server = await serverFor(state) + try { + expect( + await rawStatus(server.port, `GET /fleet/rpc HTTP/1.1\r\nHost: evil.com\r\nConnection: close\r\n\r\n`), + ).toBe(403) + expect(await wsHandshake(server.port, { authorization: `Bearer ${TOKEN}`, origin: "https://evil.com" })).toBe( + 403, + ) + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("the server binds 127.0.0.1 only", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const server = await serverFor(state) + try { + expect(server.host).toBe("127.0.0.1") + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("read-only RPC", () => { + test("every method leaves state files untouched and rejects extra fields", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const { fleetPaths } = await import("../src/state.ts") + const { mkdir, readFile } = await import("node:fs/promises") + await mkdir(fleetPaths(state).dir, { recursive: true }) + await writeFile(path.join(fleetPaths(state).dir, "tasks.json"), JSON.stringify({ v: 1, tasks: {} })) + const before = await readFile(path.join(fleetPaths(state).dir, "tasks.json"), "utf8") + const server = await serverFor(state) + try { + for (const [method, params] of [ + ["fleet.status", {}], + ["fleet.task", { id: "a" }], + ["fleet.task.liveness", { id: "a" }], + ["fleet.pending", {}], + ["fleet.events", { since: 0 }], + ] as const) { + const response = await rpc(server, method, params, TOKEN) + expect(response.status).toBe(200) + } + // Strict schemas: extra fields are rejected, unknown tasks 404-ish error. + const extra = await rpc(server, "fleet.task", { id: "a", evil: 1 }, TOKEN) + expect(extra.status).toBe(200) + expect(((await extra.json()) as { error: unknown }).error).toBeDefined() + const missing = (await (await rpc(server, "fleet.task", { id: "ghost" }, TOKEN)).json()) as { error: unknown } + expect(missing.error).toBeDefined() + expect(await readFile(path.join(fleetPaths(state).dir, "tasks.json"), "utf8")).toBe(before) + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("payload scrubbing", () => { + test("secret keys and planted secret values never appear", () => { + const scrubbed = scrubPayload( + { apiToken: "abc", nested: { password: "hunter2-hunter" }, ok: "fine", list: ["bearer xyzzy-plugh", "clean"] }, + ["hunter2-hunter", "xyzzy-plugh"], + ) + const text = JSON.stringify(scrubbed) + expect(text).not.toContain("abc") + expect(text).not.toContain("hunter2-hunter") + expect(text).not.toContain("xyzzy-plugh") + expect(text).toContain("fine") + expect(text).toContain("clean") + }) + + test("a token pasted into a task reason is redacted over RPC", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const snap = snapshot({ + tasks: [ + { + id: "a", + title: "Alpha", + status: "failed", + ceilingUSD: 5, + spendUsd: 2, + deps: [], + reason: `crashed with token ${TOKEN}`, + }, + ], + }) + const server = await serverFor(state, snap) + try { + const body = (await (await rpc(server, "fleet.status", {}, TOKEN)).json()) as { result: unknown } + expect(JSON.stringify(body)).not.toContain(TOKEN) + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("events and coalescing", () => { + test("backlog replays in monotonic order from since", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const server = await serverFor(state) + try { + server.log.append("changed", { taskId: "a", kind: "started" }) + server.log.append("changed", { taskId: "a", kind: "done" }) + server.log.append("liveness", { taskId: "b", headline: "working" }) + const body = (await (await rpc(server, "fleet.events", { since: 0 }, TOKEN)).json()) as { + result: { events: Array<{ seq: number }> } + } + expect(body.result.events.map((event) => event.seq)).toEqual([1, 2, 3]) + const tail = (await (await rpc(server, "fleet.events", { since: 2 }, TOKEN)).json()) as { + result: { events: Array<{ seq: number }> } + } + expect(tail.result.events.map((event) => event.seq)).toEqual([3]) + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("a burst of 100 events emits at most 2 per channel", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const server = await serverFor(state) + try { + const socket = await wsSubscribe(server.port) + // Let the server process the subscribe (empty replay) before the + // burst: otherwise the burst becomes backlog and replays one by one. + await new Promise((resolve) => setTimeout(resolve, 150)) + for (let i = 0; i < 100; i++) server.log.append("changed", { taskId: "a", kind: `tick-${i}` }) + const messages = await collectWs(socket, 1500) + const changed = messages.filter((message) => message.type === "changed") + expect(changed.length).toBeLessThanOrEqual(2) + // ...and the latest wins (drop to latest per key). + if (changed.length > 0) expect(JSON.stringify(changed.at(-1))).toContain("tick-99") + socket.destroy() + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("remote header names (S5.4)", () => { + const rpcRaw = (port: number, extra: string): Promise => { + const body = JSON.stringify({ method: "fleet.status", params: {} }) + return rawRequest( + port, + `POST /fleet/rpc HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nAuthorization: Bearer ${TOKEN}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n${extra}Connection: close\r\n\r\n${body}`, + ) + } + + test("a __proto__ header is answered normally and pollutes nothing", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const server = await serverFor(state) + try { + const raw = await rpcRaw(server.port, "__proto__: polluted\r\n") + expect(Number(/HTTP\/1\.1 (\d+)/.exec(raw)?.[1] ?? 0)).toBe(200) + expect(raw).toContain('"result"') + expect((Object.prototype as Record).polluted).toBeUndefined() + expect(({} as Record).polluted).toBeUndefined() + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("a mixed-case Host header still matches", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const server = await serverFor(state) + try { + const body = JSON.stringify({ method: "fleet.status", params: {} }) + const raw = await rawRequest( + server.port, + `POST /fleet/rpc HTTP/1.1\r\nHoSt: 127.0.0.1:${server.port}\r\nAuthorization: Bearer ${TOKEN}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`, + ) + expect(Number(/HTTP\/1\.1 (\d+)/.exec(raw)?.[1] ?? 0)).toBe(200) + expect(raw).toContain('"result"') + } finally { + await server.stop() + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("token file", () => { + test("ensureToken creates a 0600 token once and keeps it", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-bus-")) + try { + const first = await ensureToken(state) + const second = await ensureToken(state) + expect(first).toBe(second) + expect(first.length).toBeGreaterThanOrEqual(32) + const { stat } = await import("node:fs/promises") + const { fleetPaths } = await import("../src/state.ts") + expect((await stat(fleetPaths(state).token)).mode & 0o777).toBe(0o600) + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) + +// --- minimal test WebSocket client (dependency-free) --- + +const wsKey = () => createHash("sha1").update(randomBytes(16)).digest("base64") + +async function wsHandshake(port: number, headers: Record): Promise { + const lines = [ + "GET /fleet/ws HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${wsKey()}`, + "Sec-WebSocket-Version: 13", + ...Object.entries(headers).map(([name, value]) => `${name}: ${value}`), + "\r\n", + ] + const raw = await rawRequest(port, `${lines.join("\r\n")}`) + return Number(/HTTP\/1\.1 (\d+)/.exec(raw)?.[1] ?? 0) +} + +const rawRequest = (port: number, text: string): Promise => + new Promise((resolve, reject) => { + const socket = createConnection(port, "127.0.0.1", () => socket.write(text)) + let data = "" + socket.on("data", (chunk) => { + data += chunk.toString("latin1") + if (data.includes("\r\n\r\n")) { + socket.destroy() + resolve(data) + } + }) + socket.on("error", reject) + setTimeout(() => { + socket.destroy() + resolve(data) + }, 3000) + }) + +const rawStatus = async (port: number, text: string): Promise => + Number(/HTTP\/1\.1 (\d+)/.exec(await rawRequest(port, text))?.[1] ?? 0) + +const frame = (text: string): Buffer => { + const body = Buffer.from(text) + const mask = randomBytes(4) + const header = Buffer.from([0x81, 0x80 | body.length, ...mask]) + const masked = Buffer.alloc(body.length) + for (let i = 0; i < body.length; i++) masked[i] = body[i]! ^ mask[i % 4]! + return Buffer.concat([header, masked]) +} + +async function wsSubscribe(port: number) { + const socket = createConnection(port, "127.0.0.1") + await new Promise((resolve, reject) => { + socket.once("connect", resolve) + socket.once("error", reject) + }) + socket.write( + [ + "GET /fleet/ws HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${wsKey()}`, + "Sec-WebSocket-Version: 13", + `Authorization: Bearer ${TOKEN}`, + "\r\n", + ].join("\r\n"), + ) + // Wait for the 101, then subscribe from the backlog. + await new Promise((resolve) => { + let data = "" + const onData = (chunk: Buffer) => { + data += chunk.toString("latin1") + if (data.includes("\r\n\r\n")) { + socket.removeListener("data", onData) + resolve() + } + } + socket.on("data", onData) + }) + socket.write(frame(JSON.stringify({ subscribe: ["changed", "liveness"], since: 0 }))) + return socket +} + +async function collectWs( + socket: ReturnType, + ms: number, +): Promise> { + const messages: Array<{ type: string; seq?: number }> = [] + let buffer = Buffer.alloc(0) + socket.on("data", (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + for (;;) { + if (buffer.length < 2) return + const second = buffer[1]! + const length = second & 0x7f + let offset = 2 + if (length === 126) { + if (buffer.length < 4) return + offset = 4 + } else if (length === 127) { + if (buffer.length < 10) return + offset = 10 + } + const size = length === 126 ? buffer.readUInt16BE(2) : length === 127 ? Number(buffer.readBigUInt64BE(2)) : length + if (buffer.length < offset + size) return + const opcode = buffer[0]! & 0x0f + const body = buffer.subarray(offset, offset + size).toString("utf8") + buffer = buffer.subarray(offset + size) + if (opcode === 0x1) { + try { + messages.push(JSON.parse(body) as { type: string; seq?: number }) + } catch { + // Keep collecting: a replay array parses as one message below. + try { + for (const item of JSON.parse(body) as Array<{ type: string; seq?: number }>) messages.push(item) + } catch { + // ignore + } + } + } + } + }) + await new Promise((resolve) => setTimeout(resolve, ms)) + return messages +} diff --git a/packages/fleet/test/watch.test.ts b/packages/fleet/test/watch.test.ts new file mode 100644 index 0000000..85a3b62 --- /dev/null +++ b/packages/fleet/test/watch.test.ts @@ -0,0 +1,109 @@ +// Watch dashboard rendering (#126): pure text over a snapshot and events. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import type { FleetSnapshot } from "../src/telemetry.ts" +import { readFleetSnapshot, renderDashboard } from "../src/watch.ts" + +const snapshot: FleetSnapshot = { + daemon: { running: true, pid: 4242, maxUsd: 50, concurrency: 2 }, + tasks: [ + { id: "a", title: "Alpha", status: "running", ceilingUSD: 5, spendUsd: 1.25, deps: [], reason: "building" }, + { + id: "b", + title: "Beta", + status: "waiting-human", + ceilingUSD: 5, + spendUsd: 0, + deps: ["a"], + reason: "frontier approval", + }, + ], + spendUsd: 1.25, + pending: [{ taskId: "b", reason: "frontier approval", stage: "frontier" }], +} + +describe("dashboard rendering", () => { + test("one frame shows the headline, tasks, pending and recent events", () => { + const frame = renderDashboard(snapshot, [ + { seq: 7, at: "2026-10-09T12:00:00.000Z", type: "changed", payload: { taskId: "a", kind: "started" } }, + ]) + expect(frame).toContain("4242") + expect(frame).toContain("$1.25 / $50") + expect(frame).toContain("a") + expect(frame).toContain("running") + expect(frame).toContain("Waiting on a human:") + expect(frame).toContain("b: frontier approval") + expect(frame).toContain("#7 changed") + }) + + test("a stopped fleet with no tasks renders plainly", () => { + const frame = renderDashboard( + { daemon: { running: false, pid: null, maxUsd: null, concurrency: 4 }, tasks: [], spendUsd: 0, pending: [] }, + [], + ) + expect(frame).toContain("Fleet stopped.") + expect(frame).toContain("No tasks.") + }) + + test("readFleetSnapshot folds the DAG and daemon state", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-snap-")) + try { + const { addTask, emptyDag, saveDag } = await import("../src/tasks.ts") + const { reportTask } = await import("../src/scheduler.ts") + let dag = addTask(emptyDag(), { + id: "a", + title: "a", + kind: "factory-run", + input: { objective: "x" }, + ceilingUSD: 5, + createdAt: "2026-10-09T12:00:00.000Z", + updatedAt: "2026-10-09T12:00:00.000Z", + }) + dag = reportTask(dag, "a", { outcome: "waiting-human", reason: "frontier" }, "2026-10-09T12:00:00.000Z").dag + await saveDag(state, dag) + // No worktree registry: the task reads waiting-human, but no stage is + // actionable, so the pending list stays empty. + const bare = await readFleetSnapshot(state) + expect(bare.daemon.running).toBe(false) + expect(bare.tasks).toHaveLength(1) + expect(bare.pending).toEqual([]) + // With a registry pointing at a worktree that holds a pending + // frontier, the pair becomes an actionable approval. + const { writeJson, factoryLayout } = await import("@heretek-ai/es-core") + const { fleetPaths } = await import("../src/state.ts") + const { mkdir, writeFile } = await import("node:fs/promises") + const repo = await mkdtemp(path.join(tmpdir(), "es-fleet-snap-repo-")) + try { + await mkdir(path.join(repo, ".factory", "runtime"), { recursive: true }) + await writeJson(factoryLayout(repo).pending, [ + { stage: "frontier", requestedBy: "factory", at: new Date().toISOString() }, + ]) + await mkdir(fleetPaths(state).dir, { recursive: true }) + await writeFile( + fleetPaths(state).worktrees, + JSON.stringify({ + v: 1, + worktrees: { + a: { + taskId: "a", + dir: repo, + branch: "fleet/a", + base: "0".repeat(40), + createdAt: new Date().toISOString(), + status: "allocated", + }, + }, + }), + ) + const read = await readFleetSnapshot(state) + expect(read.pending).toEqual([{ taskId: "a", reason: "frontier", stage: "frontier" }]) + } finally { + await rm(repo, { recursive: true, force: true }) + } + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/web.test.ts b/packages/fleet/test/web.test.ts new file mode 100644 index 0000000..478f7b3 --- /dev/null +++ b/packages/fleet/test/web.test.ts @@ -0,0 +1,321 @@ +// Web control plane serving (#129): single-use URL tickets exchanged for +// an HttpOnly SameSite=Strict session cookie, static UI serving with a +// strict CSP, Host/Origin fail-closed checks, and a human-only `es-fleet +// web` that mints the one-time URL. +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { createConnection } from "node:net" +import { tmpdir } from "node:os" +import path from "node:path" +import { fleetPaths } from "../src/state.ts" +import { ensureToken, type FleetSnapshot, TelemetryServer } from "../src/telemetry.ts" +import { + mintWebTicket, + parseSessionCookie, + redeemWebTicket, + WEB_SECURITY_HEADERS, + WEB_SESSION_COOKIE, +} from "../src/web.ts" + +const snap = (): FleetSnapshot => ({ + daemon: { running: true, pid: 4242, maxUsd: 50, concurrency: 2 }, + tasks: [], + spendUsd: 0, + pending: [], +}) +async function rig() { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) + const webRoot = path.join(state, "site") + await mkdir(webRoot, { recursive: true }) + await writeFile(path.join(webRoot, "index.html"), "swarm") + await writeFile(path.join(webRoot, "app.js"), "console.log(1)") + await mkdir(fleetPaths(state).dir, { recursive: true }) + const token = await ensureToken(state) + const server = new TelemetryServer({ stateRoot: state, port: 0, token, getSnapshot: snap, webRoot }) + await server.start() + return { state, webRoot, server, token } +} + +const raw = (port: number, head: string): Promise<{ status: number; headers: Record; body: string }> => + new Promise((resolve, reject) => { + const socket = createConnection(port, "127.0.0.1", () => socket.write(head)) + let buffer = Buffer.alloc(0) + socket.on("data", (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + }) + socket.on("close", () => { + const text = buffer.toString("latin1") + const at = text.indexOf("\r\n\r\n") + const header = text.slice(0, at) + const [statusLine, ...lines] = header.split("\r\n") + const status = Number(statusLine!.split(" ")[1]) + const headers: Record = {} + for (const line of lines) { + const colon = line.indexOf(":") + if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim() + } + resolve({ status, headers, body: text.slice(at + 4) }) + }) + socket.on("error", reject) + }) + +const get = (port: number, target: string, headers: Record = {}) => + raw( + port, + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\n${Object.entries(headers) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join("")}\r\n`, + ) + +describe("web tickets", () => { + test("a ticket redeems once, then is dead", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) + try { + const ticket = await mintWebTicket(state) + expect(ticket).toMatch(/^[0-9a-f]{64}$/) + expect(await redeemWebTicket(state, ticket)).toBe(true) + expect(await redeemWebTicket(state, ticket)).toBe(false) + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("concurrent redeems grant exactly one winner", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) + try { + const ticket = await mintWebTicket(state) + const results = await Promise.all(Array.from({ length: 10 }, () => redeemWebTicket(state, ticket))) + expect(results.filter(Boolean)).toHaveLength(1) + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("malformed and expired tickets are refused", async () => { + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) + try { + expect(await redeemWebTicket(state, "not-hex")).toBe(false) + expect(await redeemWebTicket(state, "../token")).toBe(false) + expect(await redeemWebTicket(state, "ab".repeat(32))).toBe(false) + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("cookie parsing extracts the session id", () => { + const id = "ab".repeat(32) + expect(parseSessionCookie(`a=1; ${WEB_SESSION_COOKIE}=${id}; b=2`)).toBe(id) + expect(parseSessionCookie("a=1")).toBeUndefined() + // Non-hex values are never treated as sessions (fail-closed). + expect(parseSessionCookie(`${WEB_SESSION_COOKIE}=abc123`)).toBeUndefined() + }) +}) + +describe("web serving", () => { + test("the ticket exchange is single-use and sets a locked-down cookie", async () => { + const { state, server } = await rig() + try { + const ticket = await mintWebTicket(state) + const first = await get(server.port, `/?t=${ticket}`) + expect(first.status).toBe(200) + expect(first.body).toContain("swarm") + const setCookie = first.headers["set-cookie"] ?? "" + expect(setCookie).toContain(`${WEB_SESSION_COOKIE}=`) + expect(setCookie).toMatch(/httponly/i) + expect(setCookie).toMatch(/samesite=strict/i) + // Second use of the same ticket is refused. + const second = await get(server.port, `/?t=${ticket}`) + expect(second.status).toBe(403) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("a bad ticket is refused without a cookie", async () => { + const { state, server } = await rig() + try { + const res = await get(server.port, "/?t=ab".concat("cd".repeat(31))) + expect(res.status).toBe(403) + expect(res.headers["set-cookie"]).toBeUndefined() + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("anonymous loads are refused; the session cookie opens the UI", async () => { + const { state, server } = await rig() + try { + expect((await get(server.port, "/")).status).toBe(401) + const ticket = await mintWebTicket(state) + const login = await get(server.port, `/?t=${ticket}`) + const cookie = (login.headers["set-cookie"] ?? "").split(";")[0]! + const authed = await get(server.port, "/", { Cookie: cookie }) + expect(authed.status).toBe(200) + expect(authed.body).toContain("swarm") + const asset = await get(server.port, "/app.js", { Cookie: cookie }) + expect(asset.status).toBe(200) + expect(asset.headers["content-type"]).toContain("javascript") + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("every web response carries the strict security headers", async () => { + const { state, server } = await rig() + try { + const ticket = await mintWebTicket(state) + const login = await get(server.port, `/?t=${ticket}`) + const csp = login.headers["content-security-policy"] ?? "" + expect(csp).toContain("default-src 'self'") + expect(csp).toContain("frame-ancestors 'none'") + expect(login.headers["x-content-type-options"]).toBe("nosniff") + expect(login.headers["referrer-policy"]).toBe("no-referrer") + expect(WEB_SECURITY_HEADERS["content-security-policy"]).toContain("connect-src 'self'") + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("a wrong Host or a non-loopback Origin is refused", async () => { + const { state, server } = await rig() + try { + expect((await raw(server.port, `GET / HTTP/1.1\r\nHost: evil.com\r\nConnection: close\r\n\r\n`)).status).toBe(403) + const ticket = await mintWebTicket(state) + const cookie = ((await get(server.port, `/?t=${ticket}`)).headers["set-cookie"] ?? "").split(";")[0]! + expect((await get(server.port, "/", { Cookie: cookie, Origin: "https://evil.com" })).status).toBe(403) + expect((await get(server.port, "/", { Cookie: cookie, Origin: `http://127.0.0.1:${server.port}` })).status).toBe( + 200, + ) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("path traversal never escapes the web root", async () => { + const { state, server } = await rig() + try { + const ticket = await mintWebTicket(state) + const cookie = ((await get(server.port, `/?t=${ticket}`)).headers["set-cookie"] ?? "").split(";")[0]! + // /../token would be the bus bearer token if traversal worked. + expect((await get(server.port, "/../token", { Cookie: cookie })).status).toBe(404) + expect((await get(server.port, "/%2e%2e/token", { Cookie: cookie })).status).toBe(404) + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) + + test("the session cookie authorizes read-only RPC only with a loopback Origin", async () => { + const { state, server } = await rig() + try { + const ticket = await mintWebTicket(state) + const cookie = ((await get(server.port, `/?t=${ticket}`)).headers["set-cookie"] ?? "").split(";")[0]! + const body = JSON.stringify({ method: "fleet.status", params: {} }) + const rpc = (extra: Record) => + raw( + server.port, + `POST /fleet/rpc HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n${Object.entries( + extra, + ) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join("")}\r\n${body}`, + ) + expect((await rpc({ Cookie: cookie })).status).toBe(403) + const ok = await rpc({ Cookie: cookie, Origin: `http://127.0.0.1:${server.port}` }) + expect(ok.status).toBe(200) + expect(ok.body).toContain("4242") + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("es-fleet web", () => { + // The CI/dev shell runs with ES_SANDBOX set: act as the human caller the + // command requires by clearing it for the duration of each test. + const asHuman = async (fn: () => Promise): Promise => { + const previous = process.env.ES_SANDBOX + delete process.env.ES_SANDBOX + try { + return await fn() + } finally { + if (previous === undefined) delete process.env.ES_SANDBOX + else process.env.ES_SANDBOX = previous + } + } + + /** Capture everything a callback writes to stdout/stderr. */ + const capture = async (fn: () => Promise): Promise<{ code: number; out: string; err: string }> => { + const out: string[] = [] + const err: string[] = [] + const stdoutWrite = process.stdout.write.bind(process.stdout) + const stderrWrite = process.stderr.write.bind(process.stderr) + process.stdout.write = ((chunk: unknown) => { + out.push(String(chunk)) + return true + }) as typeof process.stdout.write + process.stderr.write = ((chunk: unknown) => { + err.push(String(chunk)) + return true + }) as typeof process.stderr.write + try { + const code = await fn() + return { code, out: out.join(""), err: err.join("") } + } finally { + process.stdout.write = stdoutWrite + process.stderr.write = stderrWrite + } + } + + test("it is refused inside an agent sandbox", async () => { + const { main } = await import("../src/bin.ts") + const previous = process.env.ES_SANDBOX + process.env.ES_SANDBOX = "seat" + try { + const seen = await capture(() => main(["web"])) + expect(seen.code).toBe(1) + expect(seen.err).toMatch(/agent sandbox/) + } finally { + if (previous === undefined) delete process.env.ES_SANDBOX + else process.env.ES_SANDBOX = previous + } + }) + + test("it prints a one-time URL for a running daemon", async () => { + const { main } = await import("../src/bin.ts") + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) + try { + const { fleetPaths } = await import("../src/state.ts") + const { mkdir } = await import("node:fs/promises") + await mkdir(fleetPaths(state).dir, { recursive: true }) + await writeFile(path.join(fleetPaths(state).dir, "telemetry.json"), JSON.stringify({ port: 4242 })) + await writeFile(fleetPaths(state).token, "test-token", { mode: 0o600 }) + const seen = await capture(() => asHuman(() => main(["web", "--state-dir", state]))) + expect(seen.code).toBe(0) + expect(seen.out).toMatch(/http:\/\/127\.0\.0\.1:4242\/\?t=[0-9a-f]{64}/) + // The printed ticket redeems exactly once. + const printed = /t=([0-9a-f]{64})/.exec(seen.out)![1]! + expect(await redeemWebTicket(state, printed)).toBe(true) + } finally { + await rm(state, { recursive: true, force: true }) + } + }) + + test("it refuses when no daemon is running", async () => { + const { main } = await import("../src/bin.ts") + const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-")) + try { + const seen = await capture(() => asHuman(() => main(["web", "--state-dir", state]))) + expect(seen.code).toBe(1) + expect(seen.err).toMatch(/not running/i) + } finally { + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/worker.test.ts b/packages/fleet/test/worker.test.ts new file mode 100644 index 0000000..c1b4632 --- /dev/null +++ b/packages/fleet/test/worker.test.ts @@ -0,0 +1,225 @@ +// Fleet workers (#125): argv construction, event mapping, spend/token +// folding, supervision, restart recovery and the StillDown circuit breaker. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { buildWorkerArgv, CircuitBreaker, foldWorkerEvent, prepareTaskRun, WorkerSupervisor } from "../src/worker.ts" + +const spec = (extra: Record = {}) => ({ + taskId: "task-a", + worktreeDir: "/repo/.fleet/worktrees/task-a", + ceilingUSD: 7, + kind: "factory-run" as const, + objective: "build the thing", + esBin: "/repo/packages/cli/bin/es.js", + ...extra, +}) + +describe("worker argv", () => { + test("argv-only spawn: headless factory run in the task worktree", () => { + const argv = buildWorkerArgv(spec()) + expect(argv[0]).toBe("/repo/packages/cli/bin/es.js") + expect(argv).toContain("factory") + expect(argv).toContain("run") + expect(argv).toContain("--headless") + expect(argv).toContain("--events") + expect(argv).toContain("jsonl") + expect(argv).toContain("--cwd") + expect(argv[argv.indexOf("--cwd") + 1]).toBe("/repo/.fleet/worktrees/task-a") + // A single argv array: no shell string joins it, so no quoting hazards. + expect(argv.every((word) => typeof word === "string")).toBe(true) + }) + + test("no --max-usd on the argv: factory run has no such flag (drift #125); the ceiling is provisioned into run state", () => { + expect(buildWorkerArgv(spec())).not.toContain("--max-usd") + }) + + test("research-run tasks refuse until a headless research driver exists", () => { + expect(() => buildWorkerArgv(spec({ kind: "research-run" }))).toThrow(/headless research/i) + }) +}) + +describe("event mapping and spend folding", () => { + const acc = () => ({ spendUsd: 0, tokensIn: 0, tokensOut: 0 }) + test.each([ + ["waiting", { type: "waiting", reason: "frontier approval" }, "waiting-human"], + ["done", { type: "done" }, "done"], + ["halted", { type: "halted", reason: "STOP" }, "failed"], + ["stalled", { type: "stalled", turns: 9, headline: "stuck" }, "failed"], + ["turn-cap", { type: "turn-cap", turns: 40 }, "failed"], + ["error", { type: "error", message: "boom" }, "failed"], + ["cancelled", { type: "cancelled", reason: "SIGTERM" }, "cancelled"], + ] as const)("headless %s maps to task %s", (_kind, event, status) => { + const folded = foldWorkerEvent(acc(), { + v: 1, + at: "2026-10-09T12:00:00.000Z", + runId: "r1", + kind: event.type, + event, + }) + expect(folded.report?.outcome).toBe(status) + }) + + test("turn-metrics add spend and tokens without a status report", () => { + const state = acc() + const folded = foldWorkerEvent(state, { + v: 1, + at: "2026-10-09T12:00:00.000Z", + runId: "r1", + kind: "turn-metrics", + event: { type: "turn-metrics", turn: 2, costUSD: 1.5, tokensIn: 100, tokensOut: 50, tools: [] }, + }) + expect(folded.report).toBeUndefined() + expect(folded.acc.spendUsd).toBe(1.5) + expect(folded.acc.tokensIn).toBe(100) + expect(folded.acc.tokensOut).toBe(50) + }) + + test("progress chatter produces no report and no spend", () => { + for (const kind of ["start", "turn", "progress", "driver"] as const) { + const folded = foldWorkerEvent(acc(), { + v: 1, + at: "2026-10-09T12:00:00.000Z", + runId: "r1", + kind, + event: { type: kind }, + }) + expect(folded.report).toBeUndefined() + expect(folded.acc.spendUsd).toBe(0) + } + }) +}) + +describe("run preparation", () => { + test("factory-run: the task ceiling is provisioned into the worktree run state", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-fleet-prep-")) + try { + await prepareTaskRun({ ...spec(), worktreeDir: dir }) + const { Factory, gateRunner } = await import("@heretek-ai/es-core") + const state = await new Factory(dir, { gates: gateRunner({}) }).read() + expect(state?.spendCeilingUSD).toBe(7) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("research-run: a research run opens with the objective and ceiling", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-fleet-prep-r-")) + try { + await prepareTaskRun({ ...spec(), kind: "research-run", worktreeDir: dir }) + const { Factory, gateRunner } = await import("@heretek-ai/es-core") + const state = await new Factory(dir, { gates: gateRunner({}) }).read() + expect(state?.mode).toBe("research") + expect(state?.objective).toBe("build the thing") + expect(state?.spendCeilingUSD).toBe(7) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("supervision", () => { + test("a live tracked worker cannot be double-started", async () => { + const supervisor = new WorkerSupervisor() + supervisor.track("task-a", 999999321, { spendUsd: 0, tokensIn: 0, tokensOut: 0 }) + expect(() => supervisor.requireStartable("task-a")).toThrow(/already running/) + }) + + test("restart recovery: live pids reattach, dead pids report failure", async () => { + const supervisor = new WorkerSupervisor() + // This process is alive; 2^30 is certainly dead. + const live = supervisor.recover([{ taskId: "task-a", pid: process.pid }], Date.now()) + expect(live.reattached).toEqual(["task-a"]) + expect(live.failed).toEqual([]) + expect(() => supervisor.requireStartable("task-a")).toThrow(/already running/) + const dead = supervisor.recover([{ taskId: "task-b", pid: 1073741824 }], Date.now()) + expect(dead.reattached).toEqual([]) + expect(dead.failed[0]).toMatchObject({ taskId: "task-b" }) + expect(dead.failed[0]!.reason).toMatch(/daemon restarted/i) + }) +}) + +describe("StillDown circuit breaker", () => { + test("repeated crashes refuse restarts until the cooldown passes", () => { + const breaker = new CircuitBreaker({ maxRestarts: 2, windowMs: 60_000, cooldownMs: 60_000, massThreshold: 10 }) + const t0 = 1_000_000 + expect(breaker.probe("task-a", t0)).toBe("ok") + breaker.noteCrash("task-a", t0) + breaker.noteCrash("task-a", t0 + 1_000) + const refused = breaker.probe("task-a", t0 + 2_000) + expect(typeof refused).not.toBe("string") + if (refused !== "ok") expect(refused.refused).toMatch(/crash/i) + expect(breaker.probe("task-a", t0 + 61_000)).toBe("ok") + }) + + test("mass death opens the breaker for every task", () => { + const breaker = new CircuitBreaker({ maxRestarts: 5, windowMs: 60_000, cooldownMs: 60_000, massThreshold: 3 }) + const t0 = 2_000_000 + breaker.noteCrash("a", t0) + breaker.noteCrash("b", t0 + 100) + breaker.noteCrash("c", t0 + 200) + const refused = breaker.probe("zzz-fresh", t0 + 300) + expect(refused).not.toBe("ok") + expect(breaker.probe("zzz-fresh", t0 + 61_000)).toBe("ok") + }) +}) + +describe("worker process (stub CLI)", () => { + const stub = async (script: string): Promise => { + const dir = await mkdtemp(path.join(tmpdir(), "es-fleet-stub-")) + const file = path.join(dir, "stub.mjs") + await writeFile(file, script) + return file + } + + test("scripted JSONL drives the task to done with accumulated spend", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-fleet-wrun-")) + try { + const stubFile = await stub(`const lines = [ + { v: 1, at: new Date().toISOString(), runId: "r1", kind: "turn-metrics", event: { type: "turn-metrics", turn: 1, costUSD: 2, tools: [] } }, + { v: 1, at: new Date().toISOString(), runId: "r1", kind: "done", event: { type: "done" } }, + ]; + for (const line of lines) console.log(JSON.stringify(line));`) + const { Worker } = await import("../src/worker.ts") + const reports: Array<{ taskId: string; report: unknown }> = [] + const worker = new Worker({ + taskId: "task-a", + worktreeDir: dir, + ceilingUSD: 7, + kind: "factory-run", + objective: "x", + esBin: stubFile, + spawn: { command: process.execPath, args: [stubFile] }, + onReport: (taskId, report) => void reports.push({ taskId, report }), + }) + const outcome = await worker.run() + expect(outcome).toMatchObject({ status: "done" }) + expect(worker.snapshot().spendUsd).toBe(2) + expect(reports.at(-1)).toMatchObject({ taskId: "task-a", report: { outcome: "done" } }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("exit 130 with no terminal event reports cancelled", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "es-fleet-wrun-")) + try { + const stubFile = await stub(`process.exit(130);`) + const { Worker } = await import("../src/worker.ts") + const worker = new Worker({ + taskId: "task-a", + worktreeDir: dir, + ceilingUSD: 7, + kind: "factory-run", + objective: "x", + esBin: stubFile, + spawn: { command: process.execPath, args: [stubFile] }, + onReport: () => {}, + }) + await expect(worker.run()).resolves.toMatchObject({ status: "cancelled" }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/test/worktree.test.ts b/packages/fleet/test/worktree.test.ts new file mode 100644 index 0000000..12415f5 --- /dev/null +++ b/packages/fleet/test/worktree.test.ts @@ -0,0 +1,238 @@ +// Fleet worktree coordinator (#124): isolated per-task worktrees, a locked +// registry, salvage + cleanup, gc, and gated transactional landing. Every +// case asserts the base branch never moves. +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { git as coreGit } from "@heretek-ai/es-core" +import { + allocateWorktree, + fleetBranch, + gcWorktrees, + integrationBranch, + landTask, + loadRegistry, + releaseWorktree, +} from "../src/worktree.ts" + +const git = (cwd: string, ...args: string[]) => coreGit(cwd, args, { allowFail: true }) + +/** A temp repo with one commit on `main`; returns { root, base }. */ +async function fixture(): Promise<{ root: string; base: string; cleanup: () => Promise }> { + const root = await mkdtemp(path.join(tmpdir(), "es-fleet-wt-")) + const cleanup = () => rm(root, { recursive: true, force: true }) + await git(root, "init", "-b", "main") + await git(root, "config", "user.name", "fleet-test") + await git(root, "config", "user.email", "fleet@test.local") + await git(root, "config", "commit.gpgsign", "false") + await writeFile(path.join(root, "file.txt"), "base\n") + await git(root, "add", "-A") + await git(root, "commit", "--no-verify", "-m", "base") + const base = (await git(root, "rev-parse", "HEAD")).stdout.trim() + return { root, base, cleanup } +} + +const stateRoot = () => mkdtemp(path.join(tmpdir(), "es-fleet-wt-state-")) +const tip = (root: string, ref: string) => git(root, "rev-parse", ref).then((r) => r.stdout.trim()) +const gatesOk = () => Promise.resolve({ ok: true as const }) +const gatesNo = () => Promise.resolve({ ok: false as const, reason: "lint failed" }) + +describe("naming", () => { + test("branches are namespaced and slugs are validated", () => { + expect(fleetBranch("task-a")).toBe("fleet/task-a") + expect(integrationBranch("dag-1")).toBe("fleet/integration/dag-1") + expect(() => fleetBranch("../escape")).toThrow() + expect(() => fleetBranch("Bad")).toThrow() + }) +}) + +describe("allocation", () => { + test("each task gets its own worktree on its own branch from base", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const before = await tip(root, "main") + const a = await allocateWorktree(root, state, "task-a", { base }) + const b = await allocateWorktree(root, state, "task-b", { base }) + expect(a.dir).not.toBe(b.dir) + expect(a.branch).not.toBe(b.branch) + expect((await git(root, "rev-parse", "HEAD")).stdout.trim()).toBe(before) + expect(await tip(root, "main")).toBe(before) + const registry = (await loadRegistry(state))! + expect(Object.keys(registry.worktrees).sort()).toEqual(["task-a", "task-b"]) + expect(registry.worktrees["task-a"]).toMatchObject({ branch: "fleet/task-a", base, status: "allocated" }) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) + + test("a double allocation is refused and the base never moves", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + await allocateWorktree(root, state, "task-a", { base }) + await expect(allocateWorktree(root, state, "task-a", { base })).rejects.toThrow(/already has a worktree/) + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("release and salvage", () => { + test("release after a merge removes the worktree and keeps the branch", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const record = await allocateWorktree(root, state, "task-a", { base }) + await writeFile(path.join(record.dir, "work.txt"), "work\n") + await git(record.dir, "add", "-A") + await git(record.dir, "commit", "--no-verify", "-m", "task work") + const landed = await landTask(root, state, "task-a", "dag-1", gatesOk) + expect(landed).toMatchObject({ landed: true }) + const workTip = await tip(record.dir, "HEAD") + await releaseWorktree(root, state, "task-a", "merged") + const registry = (await loadRegistry(state))! + expect(registry.worktrees["task-a"]!.status).toBe("merged") + expect(await tip(root, "fleet/task-a")).toBe(workTip) + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) + + test("abandoning anchors a salvage ref before removal", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const record = await allocateWorktree(root, state, "task-a", { base }) + await writeFile(path.join(record.dir, "work.txt"), "doomed\n") + await git(record.dir, "add", "-A") + await git(record.dir, "commit", "--no-verify", "-m", "doomed work") + const workTip = await tip(record.dir, "HEAD") + await releaseWorktree(root, state, "task-a", "abandoned") + expect(await tip(root, "refs/fleet-salvage/task-a")).toBe(workTip) + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) + + test("abandoning a record whose branch is already gone still releases (CI flake)", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const record = await allocateWorktree(root, state, "task-a", { base }) + // The daemon's own cleanup won the race: the branch ref is gone while + // the registry record still says allocated. + await git(root, "update-ref", "-d", `refs/heads/${record.branch}`) + await expect(releaseWorktree(root, state, "task-a", "abandoned")).resolves.toEqual({ released: true }) + const registry = (await loadRegistry(state))! + expect(registry.worktrees["task-a"]!.status).toBe("abandoned") + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("gated landing", () => { + test("no merge happens when gates fail", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const record = await allocateWorktree(root, state, "task-a", { base }) + await writeFile(path.join(record.dir, "work.txt"), "x\n") + await git(record.dir, "add", "-A") + await git(record.dir, "commit", "--no-verify", "-m", "x") + const result = await landTask(root, state, "task-a", "dag-1", gatesNo) + expect(result).toMatchObject({ landed: false }) + expect("reason" in result && result.reason).toMatch(/lint failed/) + const exists = await git(root, "show-ref", "--verify", "--quiet", "refs/heads/fleet/integration/dag-1") + expect(exists.code).not.toBe(0) + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) + + test("a conflicting landing rolls back and reports conflict", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const a = await allocateWorktree(root, state, "task-a", { base }) + const b = await allocateWorktree(root, state, "task-b", { base }) + for (const [record, text] of [ + [a, "from-a\n"], + [b, "from-b\n"], + ] as const) { + await writeFile(path.join(record.dir, "file.txt"), text) + await git(record.dir, "add", "-A") + await git(record.dir, "commit", "--no-verify", "-m", `change by ${record.taskId}`) + } + expect(await landTask(root, state, "task-a", "dag-1", gatesOk)).toMatchObject({ landed: true }) + const integrationTip = await tip(root, "fleet/integration/dag-1") + const result = await landTask(root, state, "task-b", "dag-1", gatesOk) + expect(result).toMatchObject({ landed: false, conflict: true }) + expect(await tip(root, "fleet/integration/dag-1")).toBe(integrationTip) + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) + + test("a non-conflict merge failure returns its reason and skips --abort", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const record = await allocateWorktree(root, state, "task-a", { base }) + await writeFile(path.join(record.dir, "work.txt"), "x\n") + await git(record.dir, "add", "-A") + await git(record.dir, "commit", "--no-verify", "-m", "x") + // Hermetic missing-identity: an empty repo-local identity makes the + // landing merge itself unable to commit (no global config is read for + // this: the empty local value wins over any global one). + await git(root, "config", "user.name", "") + await git(root, "config", "user.email", "") + const result = await landTask(root, state, "task-a", "dag-1", gatesOk) + expect(result).toMatchObject({ landed: false, conflict: false }) + expect("reason" in result && result.reason).toMatch(/ident/i) + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("gc", () => { + test("orphaned worktrees are salvaged and removed, records dropped", async () => { + const { root, base, cleanup } = await fixture() + const state = await stateRoot() + try { + const record = await allocateWorktree(root, state, "task-a", { base }) + await writeFile(path.join(record.dir, "work.txt"), "orphan\n") + await git(record.dir, "add", "-A") + await git(record.dir, "commit", "--no-verify", "-m", "orphan work") + // Simulate a lost registry (crashed daemon): wipe state, keep repo. + await rm(state, { recursive: true, force: true }) + const fresh = state + await mkdir(fresh, { recursive: true }) + const report = await gcWorktrees(root, fresh) + expect(report.removed.length).toBe(1) + expect(report.salvaged).toContain("fleet/task-a") + expect(await tip(root, "main")).toBe(base) + } finally { + await cleanup() + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/fleet/tsconfig.json b/packages/fleet/tsconfig.json new file mode 100644 index 0000000..6fcd57f --- /dev/null +++ b/packages/fleet/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist" }, + "include": ["src"] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e044c55..0844409 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,9 @@ { "name": "@heretek-ai/epistemic-swarm", - "version": "1.1.4", + "version": "1.6.0", + "esRelease": { + "order": 3 + }, "description": "Epistemic Swarm: an AI build factory for OpenCode v2 \u2014 grill, research, spec, build, QA and release, gated mechanically.", "type": "module", "license": "Apache-2.0", @@ -49,8 +52,8 @@ "@babel/preset-typescript": "7.27.1", "@heretek-ai/es-testkit": "workspace:*", "@opencode/sdk": "2.0.24", - "@opentui/core": "0.5.14", - "@opentui/solid": "0.5.14", + "@opentui/core": "0.5.17", + "@opentui/solid": "0.5.17", "babel-preset-solid": "1.9.12", "solid-js": "1.9.15" }, diff --git a/packages/opencode/src/agents.ts b/packages/opencode/src/agents.ts index 994c86c..0ea2ab7 100644 --- a/packages/opencode/src/agents.ts +++ b/packages/opencode/src/agents.ts @@ -2,7 +2,7 @@ // we add our own agents and never edit built-ins, default_agent or models. // Per-agent tool/skill/MCP scoping is expressed as wildcard-deny permission // rules (proven in M0: denied tools, MCP tools and skills are not advertised). -import { AGENTS, type AgentSpec, ALL_ES_TOOLS, loadPrompt } from "@heretek-ai/es-core" +import { AGENTS, type AgentSpec, ALL_ES_TOOLS, loadPrompt, parseModelRef } from "@heretek-ai/es-core" import type { PluginOptions } from "./runtime.ts" export interface CompiledAgent { @@ -47,11 +47,61 @@ export function permissionRules(spec: AgentSpec, mcpServers: readonly string[]): return rules } -function modelFor(spec: AgentSpec, options: PluginOptions) { +export function modelFor(spec: AgentSpec, options: PluginOptions) { const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier] - const slash = ref?.indexOf("/") ?? -1 - if (!ref || slash <= 0) return undefined - return { providerID: ref.slice(0, slash), id: ref.slice(slash + 1) } + if (!ref) return undefined + return parseModelRef(ref) +} + +/** A configured model ref the host does not have: the seat is refused at launch (#96). */ +export interface ModelProblem { + readonly agent: string + /** The config key to fix: models. or models.agents.. */ + readonly key: string + readonly ref: string + /** Remediation for the user (es config set is human-only). */ + readonly message: string +} + +/** Remediation text for an unusable seat model (es config set is human-only). */ +export function modelProblemMessage(agent: string, key: string, ref: string, wellFormed: boolean): string { + return ( + `Seat "${agent}" cannot launch: its model "${ref}" (${key}) is ` + + (wellFormed ? "not on this host." : 'malformed (want "provider/model").') + + ` Set ${key} to "provider/model" from the host model list with \`es config set\`, or unset it to use the session default.` + ) +} + +/** Every configured ref, per seat, that `exists` cannot find on the host. */ +export function modelProblems( + options: PluginOptions, + specs: readonly AgentSpec[], + exists: (providerID: string, modelID: string) => boolean, +): ModelProblem[] { + const problems: ModelProblem[] = [] + for (const spec of specs) { + const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier] + if (ref === undefined) continue + const key = options.models?.agents?.[spec.id] !== undefined ? `models.agents.${spec.id}` : `models.${spec.tier}` + const parsed = parseModelRef(ref) + if (parsed === undefined || !exists(parsed.providerID, parsed.id)) + problems.push({ + agent: spec.id, + key, + ref, + message: modelProblemMessage(spec.id, key, ref, parsed !== undefined), + }) + } + return problems +} + +/** es_status lines for the runtime's model problems (empty when healthy). */ +export function modelProblemLines(problems: ReadonlyMap): string[] { + if (problems.size === 0) return [] + return [ + `Model problems (${problems.size}): configured models missing on this host — those seats refuse to launch.`, + ...[...problems.values()].map((problem) => `- ${problem.message}`), + ] } export async function compileAgents(options: PluginOptions, mcpServers: readonly string[]): Promise { diff --git a/packages/opencode/src/approve-tui.ts b/packages/opencode/src/approve-tui.ts new file mode 100644 index 0000000..9b21b4e --- /dev/null +++ b/packages/opencode/src/approve-tui.ts @@ -0,0 +1,124 @@ +// In-TUI approval signing (#119, ADR 0002). The TUI process runs on the +// human's machine, so it unlocks the sealed key and signs in-process through +// the core approveStage service — no passphrase and no approval ever cross +// RPC (the #49 property still holds: there is no approve RPC). Trust, waive +// and resume keep routing to the terminal (ADR 0002 I7). +import { userInfo } from "node:os" +import { + type Approval, + type ApprovalStage, + approveStage, + checkApprovalAttempts, + stateDir as defaultStateDir, + exists, + factoryLayout, + HumanKeyError, + hasHumanKey, + noteApprovalSuccess, + noteWrongPassphrase, + unlockHumanKey, + zeroCodepoints, +} from "@heretek-ai/es-core" + +export interface ApproveTuiInput { + readonly root: string + readonly stage: ApprovalStage + /** ADR 0002 I3: hashJson of the previewed subject, from previewApproval. */ + readonly expectedSubjectHash: string + readonly approvedBy?: string + readonly stateDir?: string + /** + * ADR 0002 I3: the preview ticket redeemed exactly once before signing + * (single-use + 120 s TTL). Production passes the token from + * previewApproval and a redeem that calls the validate-only + * `redeemApproval` RPC (it signs nothing); tests script the ticket map. + */ + readonly previewToken?: string + /** `issuedAt` from previewApproval; a preview older than 120 s is refused. */ + readonly previewIssuedAt?: number + /** Validate-only redeem of the preview token (signs nothing). */ + readonly redeemPreview?: (token: string) => Promise + /** Clock override (tests). */ + readonly now?: () => number + /** + * The masked passphrase dialog (production: readMaskedPassphrase over + * dialog.show; tests: a scripted function). Resolves to the entered code + * points, or undefined on cancel. The array is zeroed by the caller. + */ + readonly readPassphrase: (title: string) => Promise +} + +export type ApproveTuiOutcome = + | { readonly ok: true; readonly record: Approval } + | { readonly ok: false; readonly fallback: true; readonly reason: string } + | { readonly ok: false; readonly locked: true; readonly retryAfterSec: number } + | { readonly ok: false; readonly cancelled: true } + +const TERMINAL_FALLBACK = (stage: ApprovalStage) => + `Run \`es approve ${stage}\` at a terminal with your passphrase (approvals need the sealed human key).` + +/** ADR 0002 I3: a preview older than this is refused instead of signed. */ +export const APPROVAL_PREVIEW_TTL_MS = 120_000 + +/** + * Complete a frontier/spec approval inside the TUI: throttle check, masked + * passphrase loop, unlock, preview-ticket redeem, approveStage with channel + * "tui" and the preview's subject hash, key destruction. Returns a fallback when this TUI cannot + * sign (remote attach without the project root or sealed key): the caller + * shows the terminal alert. ApprovalError (subject changed since preview), + * an expired or already-used preview, and unexpected failures propagate to + * the caller's guard. + */ +export async function approveInTui(input: ApproveTuiInput): Promise { + const dir = input.stateDir ?? defaultStateDir() + // A TUI that cannot see the project root and the sealed key (a remote + // attach) keeps the "approve in a terminal" alert instead of signing + // anywhere else. + if (!(await exists(factoryLayout(input.root).dir)) || !(await hasHumanKey(dir))) + return { ok: false, fallback: true, reason: TERMINAL_FALLBACK(input.stage) } + + const gate = await checkApprovalAttempts(dir) + if (!gate.allowed) return { ok: false, locked: true, retryAfterSec: gate.retryAfterSec ?? 0 } + + const title = `Approve ${input.stage} as ${input.approvedBy ?? userInfo().username}` + for (;;) { + const codepoints = await input.readPassphrase(title) + if (codepoints === undefined) return { ok: false, cancelled: true } + // The string cannot be zeroed in JS (same as the CLI's echo-off input): + // it lives in this local only, and the array is zeroed at once. + const passphrase = codepoints.join("") + zeroCodepoints(codepoints) + try { + const signer = await unlockHumanKey(passphrase, dir) + try { + // ADR 0002 I3: the preview ticket is redeemed exactly once, after + // the unlock (a typo must not consume it) and before anything is + // signed. An expired or already-used preview refuses here. + const now = input.now ?? Date.now + if (input.previewIssuedAt !== undefined && now() - input.previewIssuedAt > APPROVAL_PREVIEW_TTL_MS) + throw new Error("The approval preview expired (> 120 s old); preview again.") + if (input.previewToken !== undefined && input.redeemPreview !== undefined) + await input.redeemPreview(input.previewToken) + await noteApprovalSuccess(dir) + const { record } = await approveStage(input.root, { + stage: input.stage, + channel: "tui", + signer, + ...(input.approvedBy ? { approvedBy: input.approvedBy } : {}), + stateDir: dir, + expectedSubjectHash: input.expectedSubjectHash, + }) + return { ok: true, record } + } finally { + signer.destroy() + } + } catch (error) { + if (error instanceof HumanKeyError && error.message === "wrong passphrase") { + const check = await noteWrongPassphrase({ dir, root: input.root, stage: input.stage, channel: "tui" }) + if (!check.allowed) return { ok: false, locked: true, retryAfterSec: check.retryAfterSec ?? 0 } + continue + } + throw error + } + } +} diff --git a/packages/opencode/src/hooks.ts b/packages/opencode/src/hooks.ts index a3c8d54..96cf034 100644 --- a/packages/opencode/src/hooks.ts +++ b/packages/opencode/src/hooks.ts @@ -15,6 +15,7 @@ import { bwrapAvailable, fromOpenCode, HookEngine, + parseModelRef, permissionActionTool, shellQuote, toOpenCodeInput, @@ -100,8 +101,7 @@ export async function createHookBridge( root: runtime.root, stateDir: runtime.stateDir, generate: async (prompt, model) => { - const slash = model?.indexOf("/") ?? -1 - const ref = model && slash > 0 ? { providerID: model.slice(0, slash), id: model.slice(slash + 1) } : undefined + const ref = model ? parseModelRef(model) : undefined return (await ctx.generate.text({ prompt, ...(ref ? { model: ref } : {}) })).text }, }) diff --git a/packages/opencode/src/masked-input.tsx b/packages/opencode/src/masked-input.tsx new file mode 100644 index 0000000..c257c7e --- /dev/null +++ b/packages/opencode/src/masked-input.tsx @@ -0,0 +1,123 @@ +// Masked passphrase input for the OpenCode TUI (#119, from the #118 spike +// verdict GO). A custom dialog.show component: keystrokes are captured with +// no echo and plaintext lives only in a closure-scoped array (never in +// reactive state), zeroed on submit and on cancel. Only the bullet count is +// reactive (screen-equivalent: the bullets are visible anyway). + +import { zeroCodepoints } from "@heretek-ai/es-core" +import { useKeyboard, usePaste } from "@opentui/solid" +import { createSignal, onCleanup } from "solid-js" + +export interface MaskedInputOptions { + readonly title: string + readonly onSubmit: (codepoints: string[]) => void + readonly onCancel: () => void +} + +export function MaskedInput(props: MaskedInputOptions) { + // The ONLY place plaintext lives: a plain closure array, not reactive state. + const codepoints: string[] = [] + const [count, setCount] = createSignal(0) + const [cancelled, setCancelled] = createSignal(false) + let done = false + + const finish = (submit: boolean) => { + if (done) return + done = true + try { + if (submit) props.onSubmit(codepoints) + else props.onCancel() + } finally { + zeroCodepoints(codepoints) + setCount(0) + } + } + onCleanup(() => { + zeroCodepoints(codepoints) + setCount(0) + }) + + useKeyboard((key) => { + if (done) return + if (key.name === "return" || key.name === "enter") { + key.preventDefault() + key.stopPropagation() + finish(true) + return + } + if (key.name === "escape" || (key.name === "c" && key.ctrl)) { + key.preventDefault() + key.stopPropagation() + setCancelled(true) + finish(false) + return + } + if (key.name === "backspace") { + key.preventDefault() + key.stopPropagation() + if (codepoints.length > 0) { + codepoints.pop() + setCount(codepoints.length) + } + return + } + // Printable characters only; everything else is consumed so it never + // reaches the TUI keymap layer underneath the dialog. + if (!key.ctrl && !key.meta && key.sequence && [...key.sequence].length === 1) { + key.preventDefault() + key.stopPropagation() + for (const point of key.sequence) codepoints.push(point) + setCount(codepoints.length) + return + } + key.preventDefault() + key.stopPropagation() + }) + + usePaste((event) => { + if (done) return + event.preventDefault() + event.stopPropagation() + // Bracketed paste arrives as one event: accept it atomically, strip newlines. + const text = new TextDecoder().decode(event.bytes).replace(/[\r\n]+/g, "") + if (text) { + for (const point of text) codepoints.push(point) + setCount(codepoints.length) + } + }) + + return ( + + {props.title} + {cancelled() ? "Cancelled." : `Passphrase: ${"•".repeat(count())}`} + Enter submits · Esc cancels · paste accepted as one unit + + ) +} + +/** The dialog surface the masked input needs (structural: scriptable in tests). */ +export interface MaskedDialog { + show(render: () => unknown, onClose?: () => void): void + clear(): void +} + +/** + * Open the masked dialog and resolve to the entered code points, or + * undefined on cancel. The caller copies, uses and zeroes them; the + * component zeroes its own buffer on submit/cancel/unmount. + */ +export function readMaskedPassphrase(dialog: MaskedDialog, title: string): Promise { + return new Promise((resolve) => { + let settled = false + const done = (codepoints: string[] | undefined) => { + if (settled) return + settled = true + dialog.clear() + resolve(codepoints === undefined ? undefined : [...codepoints]) + } + dialog.show( + () => done(codepoints)} onCancel={() => done(undefined)} />, + () => done(undefined), + ) + }) +} diff --git a/packages/opencode/src/panels.tsx b/packages/opencode/src/panels.tsx index 363e1a4..da1f4f5 100644 --- a/packages/opencode/src/panels.tsx +++ b/packages/opencode/src/panels.tsx @@ -127,8 +127,8 @@ const FactoryPanel = (props: PanelProps) => { - Waiting on you: approve {data().pending.join(", ")}: run `es approve {data().pending[0]}` in a terminal - (/es-approve previews it) + Waiting on you: approve {data().pending.join(", ")} with /es-approve (or `es approve {data().pending[0]} + `) @@ -255,6 +255,11 @@ const BrainstormPanel = (props: PanelProps) => { {(data: () => any) => ( No brainstorm in .factory/brainstorm yet. Run /brainstorm.}> Brief: {data().brief} + 1}> + + Run {data().run} ({data().runs.length} runs: {data().runs.join(", ")} — `es brainstorm show [run]`) + + {data().ideas} idea(s) · {data().duplicates} duplicate(s) · {data().scored} scored ·{" "} {data().complete ? "complete" : "in progress"} diff --git a/packages/opencode/src/policy.ts b/packages/opencode/src/policy.ts index 5894bb6..9983281 100644 --- a/packages/opencode/src/policy.ts +++ b/packages/opencode/src/policy.ts @@ -74,6 +74,18 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) { const before = async (event: { tool: string; agent: string; id: string; sessionID?: string; input: unknown }) => { const seat = seatOf(event.agent) const input = (event.input ?? {}) as Record + // Fail-closed models (#96): a seat whose own configured model is missing + // on the host is refused on every tool call, with remediation. Primary + // seats are never launched through `subagent`, so the launch guard below + // never sees them; without this they would silently run on the default. + const ownProblem = runtime.modelProblems?.get(event.agent) + if (ownProblem) deny(ownProblem.message) + // Fail-closed models (#96): whoever launches a seat with an unusable + // model is refused, seat or user agent alike. + if (event.tool === "subagent" && typeof input.agent === "string") { + const problem = runtime.modelProblems?.get(input.agent) + if (problem) deny(problem.message) + } if (seat && event.tool === "subagent") guardSeatLaunch({ agent: event.agent, input }, seats) if (seat && event.sessionID) await seats?.onTool(event.sessionID, event.agent, event.tool) if (seat || event.tool.startsWith("es_")) { diff --git a/packages/opencode/src/pr.ts b/packages/opencode/src/pr.ts index b3009a3..a5c8fca 100644 --- a/packages/opencode/src/pr.ts +++ b/packages/opencode/src/pr.ts @@ -2,7 +2,7 @@ // integration branch (never the base branch) and opens a draft PR for a human // to review and merge. import type { PrOpener } from "@heretek-ai/es-core" -import { git, run } from "@heretek-ai/es-core" +import { buildPrArgs, git, run } from "@heretek-ai/es-core" export const ghPrOpener: PrOpener = { id: "gh", @@ -16,17 +16,17 @@ export const ghPrOpener: PrOpener = { const remote = await git(root, ["remote", "get-url", "origin"], { allowFail: true }) return remote.code === 0 }, - async open({ root, branch, base, title, body }) { + async open({ root, branch, base, title, body, preset }) { if (branch === base || !branch.startsWith("factory/")) throw new Error(`refusing to push "${branch}"`) + // One argv builder for every opener; it refuses an off-base preset run + // (e.g. --base main) before anything is pushed. + const argv = buildPrArgs({ branch, base, title, body, preset }) await git(root, ["push", "--set-upstream", "origin", `${branch}:${branch}`]) - const created = await run( - ["gh", "pr", "create", "--draft", "--head", branch, "--base", base, "--title", title, "--body", body], - { - cwd: root, - timeoutMs: 60_000, - passEnv: ["GH_TOKEN", "GITHUB_TOKEN", "GH_HOST"], - }, - ) + const created = await run(argv, { + cwd: root, + timeoutMs: 60_000, + passEnv: ["GH_TOKEN", "GITHUB_TOKEN", "GH_HOST"], + }) const url = created.stdout .trim() .split("\n") diff --git a/packages/opencode/src/research.ts b/packages/opencode/src/research.ts index 53f914a..b551f3a 100644 --- a/packages/opencode/src/research.ts +++ b/packages/opencode/src/research.ts @@ -4,7 +4,7 @@ // .factory/ directory: the user's own agents get plain results, and no // project gains a .factory/ because someone searched the web. import { stat } from "node:fs/promises" -import { factoryLayout, researchCache, seatOf, selectProvider } from "@heretek-ai/es-core" +import { factoryLayout, researchCache, SEARCH_PROVIDERS, seatOf, selectProvider } from "@heretek-ai/es-core" import type { Runtime } from "./runtime.ts" interface SearchHit { @@ -52,7 +52,14 @@ export function registerWebsearch( ) { const searxng = runtime.config.research?.searxngUrl const env = searxng ? { ...process.env, SEARXNG_URL: searxng } : process.env - const provider = selectProvider(runtime.options.searchProvider, env) + // A legacy searchProvider pin keeps its exact behaviour; otherwise the + // first configured backend with a host search provider wins (#106). + const ordered = runtime.config.research?.backends + ?.map((id) => SEARCH_PROVIDERS.find((provider) => provider.id === id)) + .find((provider) => provider?.available(env)) + const provider = runtime.options.searchProvider + ? selectProvider(runtime.options.searchProvider, env) + : (ordered ?? selectProvider(undefined, env)) if (!provider) return editor.add({ id: "epistemic-swarm", diff --git a/packages/opencode/src/rpc-def.ts b/packages/opencode/src/rpc-def.ts index a0d4352..076c69a 100644 --- a/packages/opencode/src/rpc-def.ts +++ b/packages/opencode/src/rpc-def.ts @@ -1,7 +1,9 @@ // RPC contract between the server plugin and the TUI. Previews are shown in -// the TUI; human-only mutations (approve, trust, resume) happen at a terminal -// with the passphrase (`es approve`, `es trust`, `es factory resume`), never -// through an agent tool or RPC. Only lspInstall still mutates via RPC. +// the TUI; approvals complete there through the masked dialog and in-process +// signing (ADR 0002), while trust and resume sign at a terminal with the +// passphrase (`es trust`, `es factory resume`). No approve, trust or resume +// call ever crosses RPC: there is no mutating approval RPC for an agent tool +// or a stolen server password to call. Only lspInstall still mutates via RPC. import { Rpc } from "@opencode/plugin/rpc" const obj = (properties: Record, required: string[] = []) => ({ @@ -12,13 +14,22 @@ const obj = (properties: Record, required: string[] = []) => ({ }) const str = { type: "string" } const strArray = { type: "array", items: str } +const num = { type: "number" } -const Preview = obj({ ok: { type: "boolean" }, title: str, lines: strArray, problems: strArray, token: str }, [ - "ok", - "title", - "lines", - "problems", -]) +const Preview = obj( + { + ok: { type: "boolean" }, + title: str, + lines: strArray, + problems: strArray, + token: str, + /** ADR 0002 I3: hashJson of the previewed approval subject (present on approval previews). */ + subjectHash: str, + /** ADR 0002 I3: when the preview was issued (present on approval previews). */ + issuedAt: num, + }, + ["ok", "title", "lines", "problems"], +) export const EsRpc = Rpc.define({ id: "epistemic-swarm", @@ -42,6 +53,16 @@ export const EsRpc = Rpc.define({ input: obj({ stage: { type: "string", enum: ["frontier", "spec"] } }, ["stage"]), output: Preview, }, + /** Validate-only redeem of an approval preview ticket: single-use + TTL, signs nothing. */ + redeemApproval: { + input: obj({ stage: { type: "string", enum: ["frontier", "spec"] }, token: str, subjectHash: str }, [ + "stage", + "token", + "subjectHash", + ]), + output: obj({ ok: { type: "boolean" } }, ["ok"]), + errors: { refused: obj({ reason: str }, ["reason"]) }, + }, previewTrust: { input: obj({}), output: Preview }, previewResume: { input: obj({}), output: Preview }, previewLspInstall: { input: obj({ id: str }, ["id"]), output: Preview }, diff --git a/packages/opencode/src/rpc.ts b/packages/opencode/src/rpc.ts index de86d34..f120c32 100644 --- a/packages/opencode/src/rpc.ts +++ b/packages/opencode/src/rpc.ts @@ -1,7 +1,9 @@ -// Server side of the TUI previews. Human-only mutations (approve, trust, -// resume) happen at a terminal with the passphrase-sealed key, never via RPC: -// the TUI previews, then points at `es approve`, `es trust`, `es factory -// resume`. Only lspInstall still mutates via a preview token. +// Server side of the TUI previews. Approvals complete in the TUI process +// (masked dialog, in-process signing, ADR 0002); trust and resume sign at a +// terminal with the passphrase-sealed key. The TUI previews, then either +// signs itself or points at `es trust` / `es factory resume`. No approve, +// trust or resume call ever crosses RPC: only lspInstall still mutates via +// a preview token. import { randomBytes } from "node:crypto" import { type ApprovalStage, @@ -19,6 +21,7 @@ import { headline, installServer, isTrusted, + listBrainstormRuns, loadGatesConfig, readFrontier, readIdeas, @@ -54,14 +57,14 @@ export function createRpcHandlers( tickets.set(token, { kind, subject: hashJson(subject), expires: Date.now() + TTL_MS }) return token } - const redeem = (token: string, kind: string, subject: unknown) => { + const redeemHash = (token: string, kind: string, subjectHash: string) => { const ticket = tickets.get(token) tickets.delete(token) if (!ticket || ticket.kind !== kind || ticket.expires < Date.now()) throw new Error("The confirmation expired or does not match; preview again.") - if (ticket.subject !== hashJson(subject)) - throw new Error("What you confirmed changed since the preview; preview again.") + if (ticket.subject !== subjectHash) throw new Error("What you confirmed changed since the preview; preview again.") } + const redeem = (token: string, kind: string, subject: unknown) => redeemHash(token, kind, hashJson(subject)) const refused = (context: any, error: unknown) => context.error("refused", error instanceof Error ? error.message : String(error), { reason: error instanceof Error ? error.message : String(error), @@ -181,14 +184,19 @@ export function createRpcHandlers( } }, brainstormState: async () => { - const plan = await readPlan(runtime.root).catch(() => undefined) - if (!plan) return { active: false } - const ideas = await readIdeas(runtime.root).catch(() => []) - const scores = await readScores(runtime.root).catch(() => []) - const result = await readResult(runtime.root).catch(() => undefined) + const runs = await listBrainstormRuns(runtime.root).catch(() => [] as string[]) + // The panel shows the default run, else the latest; every run stays one CLI call away. + const run = runs.includes("default") ? "default" : runs[runs.length - 1] + const plan = run ? await readPlan(runtime.root, run).catch(() => undefined) : undefined + if (!plan || !run) return { active: false, runs } + const ideas = await readIdeas(runtime.root, run).catch(() => []) + const scores = await readScores(runtime.root, run).catch(() => []) + const result = await readResult(runtime.root, run).catch(() => undefined) const survivors = ideas.filter((idea) => !idea.duplicateOf) return { active: true, + run, + runs, brief: plan.brief.idea, lenses: plan.lenses, ideas: survivors.length, @@ -221,6 +229,9 @@ export function createRpcHandlers( ], problems: [], token: issue(`approve:${stage}`, subject.subject), + // ADR 0002 I3: the TUI signs only this subject; approveStage refuses a mismatch. + subjectHash: hashJson(subject.subject), + issuedAt: Date.now(), } } catch (error) { return { @@ -231,6 +242,23 @@ export function createRpcHandlers( } } }, + /** + * Validate-only redeem of an approval preview ticket (ADR 0002 I3): the + * TUI calls this exactly once before signing in-process. It enforces the + * 120 s TTL and single-use and checks the subject hash, but signs + * nothing — there is still no approve RPC (#49). + */ + redeemApproval: async (input: unknown, context: any) => { + const { stage, token, subjectHash } = input as { stage: ApprovalStage; token: unknown; subjectHash: unknown } + try { + if (typeof token !== "string" || typeof subjectHash !== "string") + throw new Error("The confirmation expired or does not match; preview again.") + redeemHash(token, `approve:${stage}`, subjectHash) + return { ok: true } + } catch (error) { + return refused(context, error) + } + }, previewTrust: async () => { const subject = await trustSubject() const trusted = diff --git a/packages/opencode/src/runtime.ts b/packages/opencode/src/runtime.ts index f3ae323..12f069c 100644 --- a/packages/opencode/src/runtime.ts +++ b/packages/opencode/src/runtime.ts @@ -13,6 +13,7 @@ import { loadEsConfig, type PolicyContext, } from "@heretek-ai/es-core" +import type { ModelProblem } from "./agents.ts" import { ghPrOpener } from "./pr.ts" /** Plugin options from opencode.json, split into config keys and the rest. */ @@ -47,6 +48,8 @@ export interface Runtime { readonly stateDir: string readonly factory: Factory readonly lsp: LspManager + /** Seats whose configured model is missing on the host (fail-closed, #96). */ + readonly modelProblems: Map policy(): Promise } @@ -83,9 +86,18 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis stateDir, factory, lsp, + modelProblems: new Map(), async policy() { const worktree = await factory.activeWorktree().catch(() => undefined) - return { root, stateDir, ...(worktree ? { worktree } : {}) } + // S1.7: the mode-aware REPORT.md grant needs the sealed run mode. Read + // it from the sealed run state; when it cannot be read (no run, a + // missing or forged seal) leave it absent so both writers stay denied + // (fail closed, trust/policy.ts). + const runMode = await factory + .read() + .then((state) => state?.mode) + .catch(() => undefined) + return { root, stateDir, ...(worktree ? { worktree } : {}), ...(runMode ? { runMode } : {}) } }, } } diff --git a/packages/opencode/src/server.ts b/packages/opencode/src/server.ts index 03e2a22..38aba59 100644 --- a/packages/opencode/src/server.ts +++ b/packages/opencode/src/server.ts @@ -7,10 +7,12 @@ import { describeTarget, ensureEngineKey, factoryLayout, + flag, formatReport, git, livenessLines, loadSkills, + parseArgs, parseAuditTarget, readJson, readLiveness, @@ -18,7 +20,7 @@ import { runGates, } from "@heretek-ai/es-core" import { Plugin } from "@opencode/plugin" -import { compileAgents } from "./agents.ts" +import { compileAgents, modelProblems } from "./agents.ts" import { createFactoryContinuation } from "./continue.ts" import { createHookBridge } from "./hooks.ts" import { createPolicyHooks } from "./policy.ts" @@ -32,6 +34,19 @@ import { registerTools } from "./tools.ts" export const PLUGIN_ID = "epistemic-swarm" +/** `/research deep --max-usd N`, split with the core CLI grammar (no command-local regex). */ +export type DeepResearchArgs = { query: string; ceiling: string } | { error: "usage" | "ceiling" } + +export function parseDeepResearchArgs(text: string): DeepResearchArgs { + const argv = parseArgs(text.split(/\s+/).filter(Boolean)) + const [head, ...rest] = argv.positionals + const query = rest.join(" ").trim() + if (head?.toLowerCase() !== "deep" || query === "") return { error: "usage" } + const ceiling = flag(argv, "max-usd") + if (ceiling === undefined || !(Number(ceiling) > 0)) return { error: "ceiling" } + return { query, ceiling } +} + export default Plugin.define({ id: PLUGIN_ID, async setup(ctx) { @@ -47,6 +62,18 @@ export default Plugin.define({ () => [] as string[], ) const agents = await compileAgents(runtime.options, servers) + // Fail-closed models (#96): every configured ref is checked against the + // host model list here, once. A missing model refuses every tool call by + // that seat (the policy before-hook) and shows in es_status; healthy + // seats are unaffected. + await ctx.model.transform((editor) => { + for (const problem of modelProblems( + runtime.options, + agents.map((agent) => agent.spec), + (providerID, modelID) => editor.get(providerID, modelID) !== undefined, + )) + runtime.modelProblems.set(problem.agent, problem) + }) await ctx.agent.transform((editor) => { for (const compiled of agents) editor.update(compiled.spec.id, (agent) => { @@ -229,6 +256,59 @@ export default Plugin.define({ } as any) }, }) + editor.add({ + name: "research", + description: "Adversarial deep research to a grounded report: thesis, antithesis, synthesis (Epistemic Swarm)", + execute: async ({ sessionID, prompt, delivery }) => { + const say = async (text: string) => { + await ctx.session.synthetic({ sessionID, text } as any) + } + const text = prompt.text?.trim() ?? "" + const parsed = parseDeepResearchArgs(text) + if ("error" in parsed && parsed.error === "usage") { + return say("Usage: /research deep --max-usd N") + } + if ("error" in parsed) { + return say( + "Deep research needs a spend ceiling you set: /research deep --max-usd N. Seats halt when the spend reaches it.", + ) + } + const { query, ceiling } = parsed + const existing = await runtime.factory.read().catch(() => undefined) + if (existing && existing.mode !== "research") { + return say( + "This project holds a build run. Start deep research in a directory without one (`es research deep --output --max-usd N` at a terminal).", + ) + } + if (existing?.stage === "HALTED") { + return say( + "This research run is halted; resume it first (`es factory resume` at a terminal, or /es-resume to preview).", + ) + } + if (existing?.stage === "DONE") { + return say( + "This research run is done. For a new question, start one in a fresh directory (`es research deep --output --max-usd N` at a terminal).", + ) + } + if (!existing) { + try { + // The human typed /research deep: beginning the run is their action. + await runtime.factory.beginResearchRun({ objective: query, ceilingUSD: Number(ceiling) }, "human:tui") + } catch (error) { + return say(`Could not start the research run: ${error instanceof Error ? error.message : String(error)}`) + } + } + await ctx.session.switchAgent({ sessionID, agent: "deep-researcher" } as any) + await ctx.session.prompt({ + ...prompt, + sessionID, + // The coordinator flow is the deep-researcher seat's system prompt + // (single-sourced in core); this only adds the run state. + text: `${await runtime.factory.summary()}\nDeep research runs adversarially, with no human answering questions mid-flow; follow your Flow through es_research_complete.`, + delivery, + } as any) + }, + }) editor.add({ name: "audit", description: "Open a code audit of the active phase or a path and run the auditor pair (Epistemic Swarm)", diff --git a/packages/opencode/src/tools.ts b/packages/opencode/src/tools.ts index cbec88a..f72a975 100644 --- a/packages/opencode/src/tools.ts +++ b/packages/opencode/src/tools.ts @@ -14,6 +14,7 @@ import { scoutTools, } from "@heretek-ai/es-core" import { Error as ToolError } from "@opencode/plugin/promise/tool" +import { modelProblemLines } from "./agents.ts" import type { Runtime } from "./runtime.ts" export function registerTools(editor: { add(tool: any): void }, runtime: Runtime) { @@ -64,7 +65,10 @@ export function registerTools(editor: { add(tool: any): void }, runtime: Runtime }, execute: async (input: unknown, context: { agent: string; sessionID: string; signal: AbortSignal }) => { try { - return { content: await def.execute(input ?? {}, context) } + const content = await def.execute(input ?? {}, context) + if (def.name !== "es_status" || runtime.modelProblems.size === 0 || typeof content !== "string") + return { content } + return { content: [content, ...modelProblemLines(runtime.modelProblems)].join("\n") } } catch (error) { if (error instanceof ToolError) throw error throw new ToolError({ message: error instanceof Error ? error.message : String(error) }) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 159841a..e883d72 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -1,13 +1,25 @@ -// Epistemic Swarm TUI plugin: previews for the human-only actions. Approvals, -// trust and resume are previewed here, then recorded at a terminal with the -// passphrase-sealed human key (`es approve`, `es trust`, `es factory resume`), -// never through an agent tool or RPC. Only lspInstall still mutates via RPC. +// Epistemic Swarm TUI plugin: human-only actions. Approvals complete here, +// through a masked passphrase dialog and in-process signing with the +// passphrase-sealed human key (`channel: "tui"`); trust and resume are +// previewed here, then recorded at a terminal with the same key +// (`es trust`, `es factory resume`), never through an agent tool or RPC. +// Only lspInstall still mutates via RPC. import { userInfo } from "node:os" import { Plugin } from "@opencode/plugin/tui" +import { approveInTui } from "./approve-tui.ts" +import { readMaskedPassphrase } from "./masked-input.tsx" import { registerPanels } from "./panels.tsx" import { EsRpc } from "./rpc-def.ts" -type Preview = { ok: boolean; title: string; lines: string[]; problems: string[]; token?: string } +type Preview = { + ok: boolean + title: string + lines: string[] + problems: string[] + token?: string + subjectHash?: string + issuedAt?: number +} export default Plugin.define({ id: "epistemic-swarm.tui", @@ -67,10 +79,51 @@ export default Plugin.define({ const preview: Preview = await call("previewApproval", { stage }) const token = await confirm(preview, "Approve") if (!token) return - await context.ui.dialog.alert({ - title: preview.title, - message: `${preview.lines.join("\n")}\n\nRun \`es approve ${stage}\` at a terminal with your passphrase (1.1.1: approvals need the sealed human key).`, + // Approvals complete inside the TUI (ADR 0002): masked passphrase + // dialog, in-process signing through the core service, channel "tui". + // No passphrase and no approval cross RPC. Trust and resume keep the + // terminal alert below. + const terminalAlert = () => + context.ui.dialog.alert({ + title: preview.title, + message: `${preview.lines.join("\n")}\n\nRun \`es approve ${stage}\` at a terminal with your passphrase (approvals need the sealed human key).`, + }) + if (!preview.subjectHash) { + await terminalAlert() + return + } + const outcome = await approveInTui({ + root: location().directory, + stage, + expectedSubjectHash: preview.subjectHash, + // ADR 0002 I3: the preview ticket is redeemed exactly once inside + // approveInTui (single-use + 120 s TTL). The redeem is validate-only: + // signing stays in-process below (#49). + ...(token ? { previewToken: token } : {}), + ...(preview.issuedAt !== undefined ? { previewIssuedAt: preview.issuedAt } : {}), + redeemPreview: (previewToken) => + call("redeemApproval", { stage, token: previewToken, subjectHash: preview.subjectHash }), + approvedBy: user, + readPassphrase: (title) => readMaskedPassphrase(context.ui.dialog, title), }) + if (outcome.ok) { + toast( + `Approved ${stage} as ${outcome.record.approvedBy} (${outcome.record.subject.length} artifact(s)).`, + "success", + ) + return + } + if ("fallback" in outcome) { + await terminalAlert() + return + } + if ("locked" in outcome) { + await context.ui.dialog.alert({ + title: preview.title, + message: `Too many wrong passphrases. Try again in ${outcome.retryAfterSec}s (the lockout is audited).`, + }) + return + } }) const trust = guarded(async () => { diff --git a/packages/opencode/test/__snapshots__/fires-research.test.ts.snap b/packages/opencode/test/__snapshots__/fires-research.test.ts.snap new file mode 100644 index 0000000..df9e84d --- /dev/null +++ b/packages/opencode/test/__snapshots__/fires-research.test.ts.snap @@ -0,0 +1,28 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`research-fires degradation and export 5. export: the Markdown render matches its snapshot 1`] = ` +"# Research dossier: Do gates hold? + +Evidence \`ba13d7f2a3d0c525a5b1cf25b5350dae3f5eeeb3c6bf5af4c561ac3322f76bad\` · 1 claim(s) (1 stale) · 1 source(s). + +## Report + +# Report + +- Gates hold [VERIFIED: sha256:b58cf0ed82036c60779e94aebbb7d7bc8f69c481d9ceb3f80c18d0504fd13822 "keep agents honest under load"] + +## Claims + +| id | tag | status | claim | evidence | +| --- | --- | --- | --- | --- | +| cc3a6fcb85bb | VERIFIED | STALE | Gates hold | "keep agents honest under load" — [b58cf0ed8203](#source-b58cf0ed8203) | + +## Sources + +### \`b58cf0ed8203\` — http://127.0.0.1:18381/a + +- retrieved · provider direct · sealed + +> Alpha gates keep agents honest under load. +" +`; diff --git a/packages/opencode/test/approve-tui.test.ts b/packages/opencode/test/approve-tui.test.ts new file mode 100644 index 0000000..89c579a --- /dev/null +++ b/packages/opencode/test/approve-tui.test.ts @@ -0,0 +1,245 @@ +// In-TUI approval signing (#119): orchestration with a scripted masked input +// (the Solid component itself is proven by the #118 spike in the headless +// test renderer; here every step around it runs for real: throttle, unlock, +// approveStage, lockout audit). +import { describe, expect, test } from "bun:test" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { + approvalSubject, + checkApprovalAttempts, + Factory, + factoryLayout, + gateRunner, + hashJson, + readApproval, + recentAuditEntries, + sealHumanKey, +} from "@heretek-ai/es-core" +import { approveInTui } from "../src/approve-tui.ts" + +const PASSPHRASE = "test-passphrase-1234" + +const setup = async () => { + const root = await mkdtemp(path.join(tmpdir(), "es-tui-approve-")) + const state = await mkdtemp(path.join(tmpdir(), "es-tui-approve-state-")) + await sealHumanKey(PASSPHRASE, state) + const factory = new Factory(root, { gates: gateRunner({ stateDir: state }), stateDir: state }) + await factory.writeFrontier("grill", { + version: "1.1", + idea: "tui test", + spendCeiling: { currency: "USD", maxAmount: 3 }, + settled: true, + nodes: [{ id: "a", question: "q?", answer: "yes", status: "settled" }], + }) + return { + root, + state, + hash: async () => hashJson((await approvalSubject(root, "frontier")).subject), + cleanup: () => + Promise.all([rm(root, { recursive: true, force: true }), rm(state, { recursive: true, force: true })]), + } +} + +describe("approveInTui", () => { + test("a scripted masked input completes the approval with channel tui", async () => { + const fx = await setup() + try { + const outcome = await approveInTui({ + root: fx.root, + stage: "frontier", + expectedSubjectHash: await fx.hash(), + stateDir: fx.state, + readPassphrase: async () => [...PASSPHRASE], + }) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + expect(outcome.record).toMatchObject({ stage: "frontier", channel: "tui" }) + expect((await readApproval(fx.root, "frontier"))?.channel).toBe("tui") + const factory = new Factory(fx.root, { gates: gateRunner({ stateDir: fx.state }), stateDir: fx.state }) + expect((await factory.read())?.stage).toBe("RESEARCH") + // The passphrase reaches no persisted surface: the attempt file holds + // timestamps only, and the record holds no secret. + const attempts = await readFile(path.join(fx.state, "approval-attempts.json"), "utf8") + expect(attempts).not.toContain(PASSPHRASE.slice(4)) + expect(JSON.stringify(outcome.record)).not.toContain(PASSPHRASE.slice(4)) + } finally { + await fx.cleanup() + } + }) + + test("an artifact changed between preview and sign is refused", async () => { + const fx = await setup() + try { + const hash = await fx.hash() + const frontierFile = factoryLayout(fx.root).frontier + const frontier = JSON.parse(await readFile(frontierFile, "utf8")) as Record + await writeFile(frontierFile, JSON.stringify({ ...frontier, idea: "changed after preview" })) + await expect( + approveInTui({ + root: fx.root, + stage: "frontier", + expectedSubjectHash: hash, + stateDir: fx.state, + readPassphrase: async () => [...PASSPHRASE], + }), + ).rejects.toThrow(/changed since the preview/) + expect(await readApproval(fx.root, "frontier")).toBeUndefined() + } finally { + await fx.cleanup() + } + }) + + test("5 wrong passphrases lock out, and the lockout is audited", async () => { + const fx = await setup() + try { + let reads = 0 + const outcome = await approveInTui({ + root: fx.root, + stage: "frontier", + expectedSubjectHash: await fx.hash(), + stateDir: fx.state, + readPassphrase: async () => { + reads++ + return [..."wrong-passphrase-000"] + }, + }) + expect(reads).toBe(5) + expect(outcome).toMatchObject({ ok: false, locked: true }) + if (outcome.ok || !("locked" in outcome)) return + expect(outcome.retryAfterSec).toBeGreaterThan(0) + expect((await checkApprovalAttempts(fx.state)).allowed).toBe(false) + const entries = await recentAuditEntries(fx.root, 10) + const lockout = entries.find((entry) => entry.action === "approval.lockout") + expect(lockout?.payload).toMatchObject({ stage: "frontier", channel: "tui" }) + } finally { + await fx.cleanup() + } + }) + + test("a TUI without the root or the sealed key falls back to the terminal alert", async () => { + const fx = await setup() + try { + const missing = await approveInTui({ + root: path.join(fx.root, "no-such-project"), + stage: "frontier", + expectedSubjectHash: "0".repeat(64), + stateDir: fx.state, + readPassphrase: async () => { + throw new Error("must not ask") + }, + }) + expect(missing).toMatchObject({ ok: false, fallback: true }) + if (missing.ok || !("fallback" in missing)) return + expect(missing.reason).toContain("es approve frontier") + + const bare = await mkdtemp(path.join(tmpdir(), "es-tui-bare-")) + const keyless = await approveInTui({ + root: bare, + stage: "spec", + expectedSubjectHash: "0".repeat(64), + stateDir: await mkdtemp(path.join(tmpdir(), "es-tui-keyless-")), + readPassphrase: async () => { + throw new Error("must not ask") + }, + }) + expect(keyless).toMatchObject({ ok: false, fallback: true }) + await rm(bare, { recursive: true, force: true }) + } finally { + await fx.cleanup() + } + }) + + test("cancelling the dialog approves nothing", async () => { + const fx = await setup() + try { + const outcome = await approveInTui({ + root: fx.root, + stage: "frontier", + expectedSubjectHash: await fx.hash(), + stateDir: fx.state, + readPassphrase: async () => undefined, + }) + expect(outcome).toEqual({ ok: false, cancelled: true }) + expect(await readApproval(fx.root, "frontier")).toBeUndefined() + } finally { + await fx.cleanup() + } + }) + + test("an expired preview is refused and signs nothing (I3 TTL)", async () => { + const fx = await setup() + try { + let redeemed = 0 + await expect( + approveInTui({ + root: fx.root, + stage: "frontier", + expectedSubjectHash: await fx.hash(), + stateDir: fx.state, + previewToken: "tok-expired", + previewIssuedAt: Date.now() - 121_000, + redeemPreview: async () => { + redeemed++ + }, + readPassphrase: async () => [...PASSPHRASE], + }), + ).rejects.toThrow(/expired/) + expect(redeemed).toBe(0) + expect(await readApproval(fx.root, "frontier")).toBeUndefined() + } finally { + await fx.cleanup() + } + }) + + test("a reused preview token is refused and signs nothing (I3 single-use)", async () => { + const fx = await setup() + try { + // Single-use ticket map, like the server's: the first redeem consumes + // the token, the second refuses. + const live = new Set(["tok-1"]) + const redeemPreview = async (token: string) => { + if (!live.delete(token)) throw new Error("The confirmation expired or does not match; preview again.") + } + await redeemPreview("tok-1") + await expect( + approveInTui({ + root: fx.root, + stage: "frontier", + expectedSubjectHash: await fx.hash(), + stateDir: fx.state, + previewToken: "tok-1", + redeemPreview, + readPassphrase: async () => [...PASSPHRASE], + }), + ).rejects.toThrow(/expired or does not match/) + expect(await readApproval(fx.root, "frontier")).toBeUndefined() + } finally { + await fx.cleanup() + } + }) + + test("the preview token is redeemed exactly once on success", async () => { + const fx = await setup() + try { + const redeemed: string[] = [] + const outcome = await approveInTui({ + root: fx.root, + stage: "frontier", + expectedSubjectHash: await fx.hash(), + stateDir: fx.state, + previewToken: "tok-1", + previewIssuedAt: Date.now(), + redeemPreview: async (token) => { + redeemed.push(token) + }, + readPassphrase: async () => [...PASSPHRASE], + }) + expect(outcome.ok).toBe(true) + expect(redeemed).toEqual(["tok-1"]) + } finally { + await fx.cleanup() + } + }) +}) diff --git a/packages/opencode/test/brainstorm.test.ts b/packages/opencode/test/brainstorm.test.ts index d4b4015..be763aa 100644 --- a/packages/opencode/test/brainstorm.test.ts +++ b/packages/opencode/test/brainstorm.test.ts @@ -1,6 +1,7 @@ // Brainstorm on the real host: the brainstormer seat plans, records and -// completes; the critic (and only the critic) scores; other seats see none of -// these tools; the artifacts land under .factory/brainstorm/. +// completes; the critic (and only the critic) scores; the grill and the +// factory run callable brainstorms at depth 1; artifacts land under +// .factory/brainstorm/runs// (the human's /brainstorm is "default"). import { afterAll, beforeAll, describe, expect, test } from "bun:test" import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" @@ -10,6 +11,7 @@ import { boot, directiveScript, type Harness, lastAgentRequest } from "@heretek- const pluginDir = path.resolve(import.meta.dir, "..") const call = (name: string, args: Record = {}) => `@@CALL ${name} ${JSON.stringify(args)}@@` +const delegate = (agent: string, prompt: string) => call("subagent", { agent, description: `${agent} step`, prompt }) let state: string let h: Harness @@ -40,6 +42,13 @@ describe("brainstorm on the real host", () => { expect(tools).toContain("es_brainstorm_record") expect(tools).toContain("es_brainstorm_complete") expect(tools).not.toContain("es_brainstorm_score") + // The grill is advertised the callable tools (but never the critic's). + await h.run("hello", { agent: "grill" }) + const grill = (lastAgentRequest(h.llm.requests)?.tools ?? []).map((tool) => tool.function.name) + expect(grill).toContain("es_brainstorm_plan") + expect(grill).toContain("es_brainstorm_record") + expect(grill).toContain("es_brainstorm_complete") + expect(grill).not.toContain("es_brainstorm_score") }) test("plan → record → critic scores → complete, with artifacts on disk", async () => { @@ -87,10 +96,10 @@ describe("brainstorm on the real host", () => { const result = await readResult(h.directory) expect(result?.shortlist).toHaveLength(1) expect(result?.ranked[0]).toEqual({ id: "b001", total: 17 }) - expect(await Bun.file(path.join(h.directory, ".factory/brainstorm/BRAINSTORM.md")).exists()).toBe(true) + expect(await Bun.file(path.join(h.directory, ".factory/brainstorm/runs/default/BRAINSTORM.md")).exists()).toBe(true) }) - test("a lens subagent cannot record, and only the brainstormer may complete", async () => { + test("a lens subagent cannot record, and the critic cannot complete", async () => { const lens = await h.run( call("es_brainstorm_record", { lens: "inversion", @@ -102,4 +111,68 @@ describe("brainstorm on the real host", () => { const critic = await h.run(call("es_brainstorm_complete"), { agent: "es-brainstorm-critic" }) expect(critic.tools[0]?.status).toBe("error") }) + + test("#108: the grill runs a 2-lens brainstorm at depth 1 and gets the shortlist JSON", async () => { + const run = "grill-fanout" + const planned = await h.run( + call("es_brainstorm_plan", { + idea: "Make onboarding instant", + lenses: ["inversion", "scamper"], + ideasPerLens: 1, + shortlistSize: 2, + run, + }), + { agent: "grill" }, + ) + expect(planned.tools[0]?.status).toBe("completed") + expect(planned.tools[0]?.text).toContain(`run "${run}"`) + + // Fan-out: both lens subagents launched in one message, in the foreground. + const fanout = await h.run( + `@@PARALLEL@@ ${delegate("es-lens-inversion", "Diverge on instant onboarding: inversion lens, one idea.")} ${delegate("es-lens-scamper", "Diverge on instant onboarding: scamper lens, one idea.")}`, + { agent: "grill" }, + ) + expect(fanout.tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual([ + "subagent:completed", + "subagent:completed", + ]) + + for (const [lens, title, text] of [ + [ + "inversion", + "Delete onboarding entirely", + "Remove every onboarding step and let the product configure itself from the first user action, so there is nothing to complete and nothing to abandon.", + ], + [ + "scamper", + "Substitute the tutorial with a live template", + "Swap the click-through tutorial for a working project template the visitor already owns, so learning happens inside real usage instead of a sandbox.", + ], + ] as const) { + const recorded = await h.run(call("es_brainstorm_record", { lens, ideas: [{ title, text }], run }), { + agent: "grill", + }) + expect(recorded.tools[0]?.status).toBe("completed") + } + + // Only the critic scores, through its own seat. + const scored = await h.run( + delegate( + "es-brainstorm-critic", + `Score the surviving ideas @@CHILD es_brainstorm_score {"run":"${run}","scores":[{"id":"b001","novelty":4,"upside":4,"feasibility":4,"fit":4},{"id":"b002","novelty":4,"upside":4,"feasibility":4,"fit":4}]}@@ done`, + ), + { agent: "grill" }, + ) + expect(scored.tools[0]?.status).toBe("completed") + + const completed = await h.run(call("es_brainstorm_complete", { run }), { agent: "grill" }) + expect(completed.tools[0]?.status).toBe("completed") + const json = /--- shortlist \(JSON\) ---\n(\{[\s\S]*\})[\s]*$/.exec(completed.tools[0]?.text ?? "")?.[1] + expect(json).toBeDefined() + const parsed = JSON.parse(json!) as { run: string; shortlist: Array<{ id: string; outlier: boolean }> } + expect(parsed.run).toBe(run) + expect(parsed.shortlist.map((entry) => entry.id).sort()).toEqual(["b001", "b002"]) + // The human's default run is untouched by the grill's run. + expect(await readResult(h.directory, run)).toBeDefined() + }) }) diff --git a/packages/opencode/test/fires-gates.test.ts b/packages/opencode/test/fires-gates.test.ts index 9b972a3..48f9223 100644 --- a/packages/opencode/test/fires-gates.test.ts +++ b/packages/opencode/test/fires-gates.test.ts @@ -16,6 +16,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { + approveStage, Factory, factoryLayout, gateRunner, @@ -23,7 +24,6 @@ import { type HumanSigner, readFrontier, readHarvestResult, - recordApproval, researchSourcesDir, SourceCache, sealHumanKey, @@ -155,8 +155,13 @@ describe("grill-fires", () => { expect(fourth.tools[0]?.text).toContain("frozen") // A granted frontier approval starts RESEARCH; the fact gates its completion. - await recordApproval(h.directory, { stage: "frontier", channel: "cli", approvedBy: "tester", signer }) - await factory().beginResearch("human:tester") + await approveStage(h.directory, { + stage: "frontier", + channel: "cli", + approvedBy: "tester", + signer, + stateDir: state, + }) const cache = new SourceCache(researchSourcesDir(h.directory), state) const source = await cache.put({ url: "https://example.test/handoff", @@ -214,8 +219,13 @@ describe("factory-gate", () => { settled: true, nodes: [{ id: "scope", question: "Which harness first?", status: "settled", answer: "opencode", round: 1 }], }) - await recordApproval(h.directory, { stage: "frontier", channel: "cli", approvedBy: "tester", signer }) - await factory().beginResearch("human:tester") + await approveStage(h.directory, { + stage: "frontier", + channel: "cli", + approvedBy: "tester", + signer, + stateDir: state, + }) const cache = new SourceCache(researchSourcesDir(h.directory), state) const source = await cache.put({ url: "https://example.test/handoff", @@ -253,7 +263,7 @@ describe("factory-gate", () => { expect(existsSync(path.join(h.directory, ".factory/worktrees"))).toBe(false) // The human approves; the scripted factory seat can now build. - await recordApproval(h.directory, { stage: "spec", channel: "cli", approvedBy: "tester", signer }) + await approveStage(h.directory, { stage: "spec", channel: "cli", approvedBy: "tester", signer, stateDir: state }) const started = await h.run(call("es_build_start"), { agent: "factory" }) // [status, text]: the text names the refusal reason. expect([started.tools[0]?.status, started.tools[0]?.text]).toEqual([ diff --git a/packages/opencode/test/fires-research.test.ts b/packages/opencode/test/fires-research.test.ts new file mode 100644 index 0000000..6c416f0 --- /dev/null +++ b/packages/opencode/test/fires-research.test.ts @@ -0,0 +1,322 @@ +// research-fires (merge-blocking, deterministic): the whole research loop on +// the real in-process host with a fake model and loopback-only servers — +// grounded quotes, failover, sealing, retraction degradation and renders. +// No internet, no real model, no gateway stack. +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { + Factory, + factoryLayout, + gateRunner, + readDossier, + renderResearchRun, + researchCache, + SourceCache, +} from "@heretek-ai/es-core" +import { boot, directiveScript, type Harness } from "@heretek-ai/es-testkit" + +const pluginDir = path.resolve(import.meta.dir, "..") +const call = (name: string, args: Record = {}) => `@@CALL ${name} ${JSON.stringify(args)}@@` +const child = (name: string, args: Record = {}) => `@@CHILD ${name} ${JSON.stringify(args)}@@` +const delegate = (agent: string, prompt: string) => call("subagent", { agent, description: `${agent} step`, prompt }) +const write = async (root: string, file: string, text: string) => { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }) + await writeFile(path.join(root, file), text) +} + +const PAGE_A = "

Alpha gates keep agents honest under load.

" +const PAGE_B = "

Beta gates stay fast at scale.

" + +let state: string +// Fixed loopback ports: the source URL feeds claim ids and the evidence +// hash, so ephemeral ports would make every render unique. Loopback only; +// nothing leaves the machine. +const PAGES_PORT = 18381 +const SEARXNG_PORT = 18382 +const GATEWAY_PORT = 18383 +let pages: ReturnType +let searxng: ReturnType +let gateway: ReturnType + +beforeAll(async () => { + state = await mkdtemp(path.join(tmpdir(), "es-fires-research-")) + // The direct backend refuses loopback by default (SSRF guard); the + // loopback-only fixture servers below need the explicit test allowance. + // Nothing leaves the machine. Test 2b still proves a gateway blocked + // refusal halts the chain: the gateway denies before direct is reached. + process.env.ES_RESEARCH_ALLOW_LOOPBACK = "1" + pages = Bun.serve({ + port: PAGES_PORT, + hostname: "127.0.0.1", + fetch(req) { + const url = new URL(req.url) + if (url.pathname === "/a") return new Response(PAGE_A, { headers: { "content-type": "text/html" } }) + if (url.pathname === "/b") return new Response(PAGE_B, { headers: { "content-type": "text/html" } }) + return new Response("not found", { status: 404 }) + }, + }) + searxng = Bun.serve({ + port: SEARXNG_PORT, + hostname: "127.0.0.1", + fetch(req) { + const url = new URL(req.url) + if (url.pathname === "/search") + return Response.json({ + results: [ + { + url: `http://127.0.0.1:${PAGES_PORT}/a`, + title: "Alpha gates", + content: `About ${url.searchParams.get("q")}: gates keep agents honest.`, + }, + ], + }) + return new Response("not found", { status: 404 }) + }, + }) + gateway = Bun.serve({ + port: GATEWAY_PORT, + hostname: "127.0.0.1", + async fetch(req) { + const body = (await req.json().catch(() => undefined)) as { params?: { name?: string } } | undefined + const name = body?.params?.name ?? "" + if (name === "web_search") + return Response.json( + { + id: 1, + jsonrpc: "2.0", + result: { + content: [{ type: "text", text: "SearXNG search failed: engine down" }], + isError: true, + structuredContent: { code: "upstream_error", message: "SearXNG search failed: engine down" }, + }, + }, + { headers: { "x-swarm-contract": "1" } }, + ) + return Response.json( + { + id: 1, + jsonrpc: "2.0", + result: { + content: [{ type: "text", text: `SSRF denied (fail-closed): joyriding is not research` }], + isError: true, + structuredContent: { code: "ssrf_denied", message: "SSRF denied (fail-closed): joyriding is not research" }, + }, + }, + { headers: { "x-swarm-contract": "1" } }, + ) + }, + }) +}) + +afterAll(async () => { + pages?.stop(true) + searxng?.stop(true) + gateway?.stop(true) + delete process.env.ES_RESEARCH_ALLOW_LOOPBACK + await rm(state, { recursive: true, force: true }) +}) + +const factoryFor = (root: string) => new Factory(root, { gates: gateRunner({ stateDir: state }), stateDir: state }) + +async function bootHost(env: Record = {}) { + const saved = new Map(Object.keys(env).map((key) => [key, process.env[key]] as const)) + for (const [key, value] of Object.entries(env)) process.env[key] = value + const h = await boot({ + git: true, + script: directiveScript, + plugins: [{ path: pluginDir, options: { stateDir: state, pr: "off" } }], + files: { "README.md": "# fires research\n" }, + }) + return { + h, + async close() { + await h.close() + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }, + } +} + +/** A research run whose REPORT holds one grounded claim, through real seats. */ +async function completeGroundedRun(h: Harness, page: string, quote: string) { + await factoryFor(h.directory).beginResearchRun({ objective: "Do gates hold?", ceilingUSD: 5 }) + const fetched = await h.run(call("es_research_fetch", { url: page }), { agent: "es-research-alpha" }) + expect(fetched.tools[0]?.status).toBe("completed") + const sha = /sha256:([0-9a-f]{64})/.exec(fetched.tools[0]?.text ?? "")?.[1] + expect(sha).toBeDefined() + const line = `- Gates hold [VERIFIED: sha256:${sha} "${quote}"]` + const written = await h.run( + delegate( + "es-research-synthesizer", + `Resolve ${child("write", { path: ".factory/research/REPORT.md", content: `# Report\n\n${line}\n` })} done`, + ), + { agent: "deep-researcher" }, + ) + expect(written.tools[0]?.status).toBe("completed") + const completed = await h.run(call("es_research_complete"), { agent: "deep-researcher" }) + expect(completed.tools[0]?.status).toBe("completed") + expect((await factoryFor(h.directory).read())?.stage).toBe("DONE") + return sha! +} + +describe("research-fires", () => { + test("1. grounded run: only the verbatim quote survives the audit", async () => { + const { h, close } = await bootHost() + try { + const pageA = `http://127.0.0.1:${PAGES_PORT}/a` + const pageB = `http://127.0.0.1:${PAGES_PORT}/b` + await factoryFor(h.directory).beginResearchRun({ objective: "Do gates hold?", ceilingUSD: 5 }) + const fetchA = await h.run(call("es_research_fetch", { url: pageA }), { agent: "es-research-alpha" }) + const fetchB = await h.run(call("es_research_fetch", { url: pageB }), { agent: "es-research-alpha" }) + const shaA = /sha256:([0-9a-f]{64})/.exec(fetchA.tools[0]?.text ?? "")?.[1] + const shaB = /sha256:([0-9a-f]{64})/.exec(fetchB.tools[0]?.text ?? "")?.[1] + expect([shaA, shaB].every(Boolean)).toBe(true) + const report = [ + `- Gates stay honest [VERIFIED: sha256:${shaA} "keep agents honest under load"]`, + `- Gates stay honest [VERIFIED: sha256:${shaA} "keep agents"]`, + `- Gates stay fast [VERIFIED: sha256:${shaB} "stay slow at scale"]`, + ].join("\n") + const written = await h.run( + delegate( + "es-research-synthesizer", + `Resolve ${child("write", { path: ".factory/research/REPORT.md", content: `# Report\n\n${report}\n` })} done`, + ), + { agent: "deep-researcher" }, + ) + expect(written.tools[0]?.status).toBe("completed") + // Pruning drops the short and the altered quote; the verbatim one stays. + const pruned = await h.run(call("es_research_audit", { path: ".factory/research/REPORT.md", prune: true }), { + agent: "es-research-synthesizer", + }) + expect(pruned.tools[0]?.text).toContain("Pruned 2") + const completed = await h.run(call("es_research_complete"), { agent: "deep-researcher" }) + expect(completed.tools[0]?.status).toBe("completed") + const coverage = await Bun.file(path.join(h.directory, ".factory/research/coverage.json")).json() + // completeResearch re-audits the pruned file: one claim, grounded. + expect(coverage).toMatchObject({ claims: 1, grounded: 1 }) + const dossier = await readDossier(factoryLayout(h.directory).researchDossier) + expect(dossier?.claims).toHaveLength(1) + expect(dossier?.claims[0]?.statement).toContain("stay honest") + } finally { + await close() + } + }, 120_000) + + test("2a. failover: the gateway errors, SearXNG serves and records provenance", async () => { + const { h, close } = await bootHost({ + ES_SCRAPER_SWARM_URL: `http://127.0.0.1:${GATEWAY_PORT}`, + ES_SCRAPER_SWARM_TOKEN: "fires-token", + SEARXNG_URL: `http://127.0.0.1:${SEARXNG_PORT}`, + }) + try { + const searched = await h.run(call("es_research_search", { query: "alpha gates" }), { + agent: "es-research-alpha", + }) + expect(searched.tools[0]?.status).toBe("completed") + expect(searched.tools[0]?.text).toContain("searxng results for") + } finally { + await close() + } + }, 120_000) + + test("2b. blocked never fails over: the gateway denies a servable page", async () => { + // A fresh harness: the search failure above cools the gateway backend down, + // and cooldowns are per backend (failover test 2a must not mask this one). + const { h, close } = await bootHost({ + ES_SCRAPER_SWARM_URL: `http://127.0.0.1:${GATEWAY_PORT}`, + ES_SCRAPER_SWARM_TOKEN: "fires-token", + SEARXNG_URL: `http://127.0.0.1:${SEARXNG_PORT}`, + }) + try { + // The gateway denies even a servable page; direct fetch is never attempted. + const denied = await h.run(call("es_research_fetch", { url: `http://127.0.0.1:${PAGES_PORT}/a` }), { + agent: "es-research-alpha", + }) + expect(denied.tools[0]?.status).toBe("error") + expect(denied.tools[0]?.text).toContain("SSRF denied") + } finally { + await close() + } + }, 120_000) + + test("3. sealing: a planted unsealed source is refused", async () => { + const { h, close } = await bootHost() + try { + const cache = researchCache(h.directory, state) + const text = "Planted findings about gate latency." + const planted = await new SourceCache(`${h.directory}.planted`, undefined).put({ + url: "https://evil.test/planted", + text, + provider: "fetch", + }) + // Same bytes and sha, but no engine seal. + await write(h.directory, `.factory/research/sources/${planted.meta.sha256}.md`, text) + await write( + h.directory, + `.factory/research/sources/${planted.meta.sha256}.json`, + JSON.stringify({ ...planted.meta, seal: undefined }), + ) + expect(await cache.get(planted.meta.sha256)).toBeUndefined() + await write( + h.directory, + ".factory/research/alpha.md", + `# Alpha\n\n- Planted latency wins [VERIFIED: sha256:${planted.meta.sha256} "gate latency"]\n`, + ) + const audit = await h.run(call("es_research_audit", { path: ".factory/research/alpha.md" }), { + agent: "es-research-alpha", + }) + expect(audit.tools[0]?.text).not.toContain("Audit passed") + } finally { + await close() + } + }, 120_000) + + describe("degradation and export", () => { + let h: Harness + let sha: string + beforeAll(async () => { + ;({ h } = await bootHost()) + sha = await completeGroundedRun(h, `http://127.0.0.1:${PAGES_PORT}/a`, "keep agents honest under load") + }, 120_000) + afterAll(async () => { + await h?.close() + }) + + test("4. degradation: a retracted source reads STALE in the render", async () => { + await write( + h.directory, + ".factory/claims/retractions.json", + JSON.stringify({ + version: 1, + retractions: [ + { source: sha, event: "RETRACTED", at: new Date().toISOString(), note: "withdrawn", by: "tester" }, + ], + }), + ) + const md = await renderResearchRun(h.directory, { format: "md", stateDir: state }) + expect(md).toContain("STALE") + expect(md).toContain(sha.slice(0, 12)) + }) + + test("5. export: the Markdown render matches its snapshot", async () => { + const { main } = await import("../../cli/src/main.ts") + const printed: string[] = [] + const code = await main(["research", "render", "--format", "md"], { + print: (text: string) => printed.push(text), + confirm: { interactive: false, write: () => {}, readLine: async () => "", readSecret: async () => "" }, + cwd: h.directory, + stateDir: state, + }) + expect(code).toBe(0) + const out = printed.join("\n") + expect(out).toContain("# Research dossier: Do gates hold?") + expect(out).toContain("Gates hold") + // Retrieved timestamps move every run; the snapshot pins everything else. + expect(out.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, "")).toMatchSnapshot() + }) + }) +}) diff --git a/packages/opencode/test/harvest.test.ts b/packages/opencode/test/harvest.test.ts index dc17be5..feabe90 100644 --- a/packages/opencode/test/harvest.test.ts +++ b/packages/opencode/test/harvest.test.ts @@ -41,16 +41,27 @@ afterAll(async () => { }) describe("darkharvest on the real host", () => { - test("only the harvester seat is advertised the harvest tools", async () => { + test("only the harvester seat is advertised the teardown tools; callable seats get target and prior art", async () => { await h.run("hello") const plain = (lastAgentRequest(h.llm.requests)?.tools ?? []).map((tool) => tool.function.name) expect(plain).not.toContain("es_harvest_plan") + expect(plain).not.toContain("es_harvest_target") await h.run("hello", { agent: "harvester" }) const tools = (lastAgentRequest(h.llm.requests)?.tools ?? []).map((tool) => tool.function.name) expect(tools).toContain("es_harvest_plan") expect(tools).toContain("es_harvest_scan") expect(tools).toContain("es_harvest_matrix") expect(tools).toContain("es_harvest_complete") + expect(tools).toContain("es_harvest_target") + await h.run("hello", { agent: "grill" }) + const grill = (lastAgentRequest(h.llm.requests)?.tools ?? []).map((tool) => tool.function.name) + expect(grill).toContain("es_harvest_target") + expect(grill).toContain("es_harvest_prior_art") + expect(grill).not.toContain("es_harvest_plan") + await h.run("hello", { agent: "scout" }) + const scout = (lastAgentRequest(h.llm.requests)?.tools ?? []).map((tool) => tool.function.name) + expect(scout).toContain("es_harvest_target") + expect(scout).not.toContain("es_harvest_prior_art") }) test("the loop enforces licences and writes the report", async () => { @@ -94,15 +105,40 @@ describe("darkharvest on the real host", () => { expect((await readProfile(h.directory, "gizmo"))?.license).toMatchObject({ spdx: "GPL-3.0", verified: true }) }) + test("#109: the grill gets a clean-room verdict for a GPL target, as JSON", async () => { + await write(h.directory, "gpl-target/LICENSE", GPL3) + await write(h.directory, "gpl-target/src/b.ts", "export const b = 2\n") + const { tools } = await h.run( + call("es_harvest_target", { objective: "can we depend on the widget", targets: ["local:gpl-target"] }), + { agent: "grill" }, + ) + expect(tools[0]?.status).toBe("completed") + const json = /--- verdicts \(JSON\) ---\n(\[[\s\S]*\])[\s]*$/.exec(tools[0]?.text ?? "")?.[1] + expect(json).toBeDefined() + const verdicts = JSON.parse(json!) as Array<{ + target: string + license: { spdx: string; family: string; verified: boolean } + verdict: string + provenance: { origin: string } + }> + expect(verdicts).toHaveLength(1) + expect(verdicts[0]).toMatchObject({ + target: "local:gpl-target", + license: { spdx: "GPL-3.0", family: "copyleft", verified: true }, + verdict: "clean-room", + }) + expect(typeof verdicts[0]?.provenance.origin).toBe("string") + }) + test("the harvester cannot hand-edit engine state or plan outside the project", async () => { - const before = await Bun.file(path.join(h.directory, ".factory/harvest/matrix.json")).text() + const before = await Bun.file(path.join(h.directory, ".factory/harvest/runs/default/matrix.json")).text() const edit = await h.run( - `${call("write", { path: ".factory/harvest/matrix.json", content: "{}" })} ${call("write", { path: ".factory/harvest/notes/teardown.md", content: "notes" })}`, + `${call("write", { path: ".factory/harvest/runs/default/matrix.json", content: "{}" })} ${call("write", { path: ".factory/harvest/notes/teardown.md", content: "notes" })}`, { agent: "harvester" }, ) expect(edit.tools[0]?.status).toBe("error") expect(edit.tools[1]?.status).toBe("completed") - expect(await Bun.file(path.join(h.directory, ".factory/harvest/matrix.json")).text()).toBe(before) + expect(await Bun.file(path.join(h.directory, ".factory/harvest/runs/default/matrix.json")).text()).toBe(before) const outside = await h.run( call("es_harvest_plan", { objective: "x", candidates: [{ name: "s", source: `local:${state}` }], force: true }), { agent: "harvester" }, diff --git a/packages/opencode/test/models.test.ts b/packages/opencode/test/models.test.ts new file mode 100644 index 0000000..b51ec2c --- /dev/null +++ b/packages/opencode/test/models.test.ts @@ -0,0 +1,160 @@ +// Fail-closed seat model mapping (#96): malformed refs and ghost agent ids +// fail config parsing; a configured model missing on the host refuses that +// seat's launch with remediation (per-seat, never the whole plugin); valid +// mappings apply; es_status surfaces problems. +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { AGENTS, loadEsConfig } from "@heretek-ai/es-core" +import { boot, directiveScript, type Harness } from "@heretek-ai/es-testkit" +import { compileAgents, modelFor, modelProblems } from "../src/agents.ts" + +const pluginDir = path.resolve(import.meta.dir, "..") +const call = (name: string, args: Record = {}) => `@@CALL ${name} ${JSON.stringify(args)}@@` +const factory = { id: "factory", seat: "factory", tier: "deep" } as const +const programmer = { id: "es-programmer", seat: "programmer", tier: "balanced" } as const + +describe("model mapping units", () => { + test("modelFor resolves agent overrides, then tiers, and parses the ref", () => { + expect(modelFor(factory as any, {} as any)).toBeUndefined() + expect(modelFor(factory as any, { models: { deep: "acme/strong" } } as any)).toEqual({ + providerID: "acme", + id: "strong", + }) + expect( + modelFor(programmer as any, { models: { balanced: "acme/ok", agents: { "es-programmer": "acme/fast" } } } as any), + ).toEqual({ providerID: "acme", id: "fast" }) + expect(modelFor(factory as any, { models: { deep: "noslash" } } as any)).toBeUndefined() + }) + + test("modelProblems reports configured refs missing on the host, keyed by seat", () => { + const exists = (providerID: string, id: string) => providerID === "fake" && id === "scripted" + expect(modelProblems({} as any, AGENTS, exists)).toEqual([]) + const problems = modelProblems( + { models: { deep: "fake/ghost", agents: { "es-programmer": "fake/scripted", factory: "bad" } } } as any, + AGENTS, + exists, + ) + const byAgent = new Map(problems.map((problem) => [problem.agent, problem])) + expect(byAgent.get("factory")).toMatchObject({ key: "models.agents.factory", ref: "bad" }) + expect(byAgent.get("es-manager")?.key).toBe("models.deep") + expect(byAgent.has("es-programmer")).toBe(false) + expect(problems.every((problem) => /es config set|unset/.test(problem.message))).toBe(true) + }) + + test("malformed refs fail the config load, naming the key", async () => { + const root = await mkdtemp(path.join(tmpdir(), "es-models-")) + try { + await expect(loadEsConfig(root, { overrides: { models: { deep: "noslash" } } })).rejects.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("a valid mapping leaves no seat on the host default", async () => { + const agents = await compileAgents({ models: { deep: "fake/scripted" } } as any, []) + const deep = agents.filter((agent) => agent.spec.tier === "deep") + expect(deep.length).toBeGreaterThan(0) + for (const agent of deep) expect(agent.model).toEqual({ providerID: "fake", id: "scripted" }) + }) + + test("a seat with a model problem is refused on any tool call, not just at launch", async () => { + const { createPolicyHooks } = await import("../src/policy.ts") + const { modelProblemMessage } = await import("../src/agents.ts") + const message = modelProblemMessage("deep-researcher", "models.deep", "fake/ghost", true) + const hooks = createPolicyHooks({ + modelProblems: new Map([ + ["deep-researcher", { agent: "deep-researcher", key: "models.deep", ref: "fake/ghost", message }], + ]), + } as any) + for (const tool of ["es_status", "es_research_search", "read"]) + await expect( + hooks.before({ tool, agent: "deep-researcher", id: "t1", input: tool === "read" ? { path: "x" } : {} }), + ).rejects.toThrow("fake/ghost") + }) +}) + +describe("model mapping on the real host (fake provider: fake/scripted)", () => { + let h: Harness + let state: string + beforeAll(async () => { + state = await mkdtemp(path.join(tmpdir(), "es-models-state-")) + h = await boot({ + git: true, + script: directiveScript, + plugins: [ + { + path: pluginDir, + options: { + stateDir: state, + pr: "off", + models: { deep: "fake/scripted", agents: { "es-programmer": "fake/ghost-model" } }, + }, + }, + ], + files: { "README.md": "# models\n" }, + }) + }, 120_000) + afterAll(async () => { + await h?.close() + await rm(state, { recursive: true, force: true }) + }) + + test("a seat whose model is missing is refused at launch with remediation", async () => { + for (const launcher of ["factory", "build"]) { + const { tools } = await h.run( + `go ${call("subagent", { agent: "es-programmer", description: "x", prompt: "build it" })}`, + { agent: launcher }, + ) + expect([launcher, ...tools.map((tool) => `${tool.name}:${tool.status}`)]).toEqual([launcher, "subagent:error"]) + expect(tools[0]?.text).toContain("fake/ghost-model") + expect(tools[0]?.text).toContain("models.agents.es-programmer") + expect(tools[0]?.text).toContain("es config set") + } + }) + + test("es_status surfaces the model problem", async () => { + const { tools } = await h.run(`status ${call("es_status", {})}`, { agent: "factory" }) + expect(tools[0]?.text).toContain("fake/ghost-model") + }) + + test("a seat with a valid mapping still launches", async () => { + const { tools } = await h.run( + `go ${call("subagent", { agent: "es-manager", description: "x", prompt: "say done" })}`, + { agent: "factory" }, + ) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["subagent:completed"]) + }) +}) + +describe("a primary seat with a missing model is refused on any tool call (#96)", () => { + let primary: Harness + let primaryState: string + beforeAll(async () => { + primaryState = await mkdtemp(path.join(tmpdir(), "es-models-primary-state-")) + primary = await boot({ + git: true, + script: directiveScript, + plugins: [ + { + path: pluginDir, + options: { stateDir: primaryState, pr: "off", models: { deep: "fake/ghost-deep" } }, + }, + ], + files: { "README.md": "# models primary\n" }, + }) + }, 120_000) + afterAll(async () => { + await primary?.close() + await rm(primaryState, { recursive: true, force: true }) + }) + + test("its first tool call is refused with remediation, never run on the default model", async () => { + const { tools } = await primary.run(`status ${call("es_status", {})}`, { agent: "factory" }) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["es_status:error"]) + expect(tools[0]?.text).toContain("fake/ghost-deep") + expect(tools[0]?.text).toContain("models.deep") + expect(tools[0]?.text).toContain("es config set") + }) +}) diff --git a/packages/opencode/test/pipeline.test.ts b/packages/opencode/test/pipeline.test.ts index a96c9a5..967380a 100644 --- a/packages/opencode/test/pipeline.test.ts +++ b/packages/opencode/test/pipeline.test.ts @@ -12,6 +12,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { + approveStage, Factory, factoryLayout, gateRunner, @@ -148,8 +149,7 @@ describe("pipeline A", () => { { id: "compat", kind: "fact", question: "Does bun test run a single file?", status: "deferred", round: 1 }, ], }) - await recordApproval(h.directory, { stage: "frontier", channel: "cli", approvedBy: "tester", signer }) - await factory().beginResearch("human:tester") + await approveStage(h.directory, { stage: "frontier", channel: "cli", signer, stateDir: state }) const cache = new SourceCache(researchSourcesDir(h.directory), state) const source = await cache.put({ url: "https://example.test/bun-test", diff --git a/packages/opencode/test/plugin.test.ts b/packages/opencode/test/plugin.test.ts index d257c81..1d27ac8 100644 --- a/packages/opencode/test/plugin.test.ts +++ b/packages/opencode/test/plugin.test.ts @@ -5,22 +5,17 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { + approveStage, bwrapAvailable, commandSetHash, - Factory, - factoryLayout, - gateRunner, HookEngine, type HumanSigner, loadGatesConfig, - pendingApprovals, - recordApproval, researchSourcesDir, SourceCache, sealHumanKey, trustProject, unlockHumanKey, - writeJson, } from "@heretek-ai/es-core" import { boot, directiveScript, type Harness, lastAgentRequest, systemText } from "@heretek-ai/es-testkit" import { EsRpc } from "../src/rpc-def.ts" @@ -338,16 +333,10 @@ describe("a factory run end to end on the real host", () => { const preview = await rpc.previewApproval({ stage: "frontier" }, where(h)) expect(preview.ok).toBe(true) expect(preview.lines.join("\n")).toContain("$5 USD") - // S4: approve/trust/resume RPCs removed — TUI previews, terminal signs with the sealed key. + // S4: approve/trust/resume RPCs removed — the TUI signs approvals + // in-process through the core service; trust/resume sign at a terminal. expect(typeof (rpc as any).approve).not.toBe("function") - await recordApproval(h.directory, { stage: "frontier", channel: "cli", signer }) - await writeJson( - factoryLayout(h.directory).pending, - (await pendingApprovals(h.directory)).filter((item) => item.stage !== "frontier"), - ) - const fac = new Factory(h.directory, { gates: gateRunner({ stateDir: state }), stateDir: state }) - if (!(await fac.read())) await fac.begin("human:tester") - if ((await fac.read())?.stage === "GRILL") await fac.beginResearch("human:tester") + await approveStage(h.directory, { stage: "frontier", channel: "cli", signer, stateDir: state }) expect((await rpc.status({}, where(h))).stage).toBe("RESEARCH") const cache = new SourceCache(researchSourcesDir(h.directory), state) @@ -388,11 +377,7 @@ describe("a factory run end to end on the real host", () => { expect(early.tools[0]?.text).toContain("no spec approval") const spec = await rpc.previewApproval({ stage: "spec" }, where(h)) expect(spec.ok).toBe(true) - await recordApproval(h.directory, { stage: "spec", channel: "cli", signer }) - await writeJson( - factoryLayout(h.directory).pending, - (await pendingApprovals(h.directory)).filter((item) => item.stage !== "spec"), - ) + await approveStage(h.directory, { stage: "spec", channel: "cli", signer, stateDir: state }) const build = await h.run(call("es_build_start"), { agent: "factory" }) expect(build.tools[0]?.status).toBe("completed") expect(build.tools[0]?.text).toContain(`worktree:.factory/worktrees/${phase}`) diff --git a/packages/opencode/test/research.test.ts b/packages/opencode/test/research.test.ts index 4fac483..c5a3c61 100644 --- a/packages/opencode/test/research.test.ts +++ b/packages/opencode/test/research.test.ts @@ -1,10 +1,12 @@ // Research stack on the real host: the configured provider is the host's // websearch (results cached), research seats fetch and audit cited evidence. import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { boot, directiveScript, type Harness } from "@heretek-ai/es-testkit" +import { parseDeepResearchArgs } from "../src/server.ts" const pluginDir = path.resolve(import.meta.dir, "..") const call = (name: string, args: Record = {}) => `@@CALL ${name} ${JSON.stringify(args)}@@` @@ -15,6 +17,7 @@ let state: string let h: Harness let server: ReturnType let previous: string | undefined +let previousLoopback: string | undefined beforeAll(async () => { state = await mkdtemp(path.join(tmpdir(), "es-research-host-")) @@ -39,6 +42,10 @@ beforeAll(async () => { }) previous = process.env.SEARXNG_URL process.env.SEARXNG_URL = `http://127.0.0.1:${server.port}` + // The direct backend refuses loopback by default (SSRF guard); the + // loopback-only fixture server above needs the explicit test allowance. + previousLoopback = process.env.ES_RESEARCH_ALLOW_LOOPBACK + process.env.ES_RESEARCH_ALLOW_LOOPBACK = "1" h = await boot({ git: true, script: directiveScript, @@ -52,6 +59,8 @@ afterAll(async () => { server?.stop(true) if (previous === undefined) delete process.env.SEARXNG_URL else process.env.SEARXNG_URL = previous + if (previousLoopback === undefined) delete process.env.ES_RESEARCH_ALLOW_LOOPBACK + else process.env.ES_RESEARCH_ALLOW_LOOPBACK = previousLoopback await rm(state, { recursive: true, force: true }) }) @@ -145,4 +154,252 @@ describe("research on the real host", () => { const { tools } = await h.run(call("es_research_fetch", { url: "http://127.0.0.1/" })) expect(tools[0]?.status).toBe("error") }) + + test("#110: a research run's seats use the host web cache, because .factory/ exists", async () => { + const { Factory } = await import("@heretek-ai/es-core") + const research = new Factory(h.directory, { + gates: async () => ({ passed: true, findings: [], summary: "stub" }), + stateDir: state, + }) + // A real research-only run (no git needed, no frontier), not a mkdir. + const begun = await research.beginResearchRun({ objective: "Do gates keep agents honest?", ceilingUSD: 5 }) + expect(begun).toMatchObject({ mode: "research", stage: "RESEARCH" }) + const { tools } = await h.run(call("websearch", { query: "factory gates" }), { agent: "factory" }) + expect(tools[0]?.status).toBe("completed") + expect(tools[0]?.text).toContain("cached sha256:") + expect(tools[0]?.text).toContain("gates keep agents honest") + }) + + test("#106: search fails over from a failing backend to the next one in order", async () => { + // Brave looks configured but its key is invalid, so the chain must move + // on to the local SearXNG instance and report its id. + const saved = process.env.BRAVE_API_KEY + process.env.BRAVE_API_KEY = "test-invalid-key" + const failover = await boot({ + git: true, + script: directiveScript, + plugins: [{ path: pluginDir, options: { stateDir: state, pr: "off" } }], + files: { "README.md": "# failover\n" }, + }) + try { + const { tools } = await failover.run(call("es_research_search", { query: "factory gates" }), { + agent: "es-research-alpha", + }) + expect(tools[0]?.status).toBe("completed") + expect(tools[0]?.text).toContain("searxng results for") + expect(tools[0]?.text).toContain("gates keep agents honest") + } finally { + await failover.close() + if (saved === undefined) delete process.env.BRAVE_API_KEY + else process.env.BRAVE_API_KEY = saved + } + }, 60_000) +}) + +describe("#107: the gateway backend on the real host", () => { + // Vendored golden fixtures replayed by a fake gateway (no stack runs here). + const gatewayFixture = (area: string, name: string) => + JSON.parse( + readFileSync( + path.resolve(import.meta.dir, "../../core/test/fixtures/scraper-swarm/v1", area, `${name}.json`), + "utf8", + ), + ) as { response_body: unknown } + const searchError = gatewayFixture("web-search", "upstream-error").response_body + const fetchOk = gatewayFixture("fetch-page", "md-success").response_body + const ssrfDenied = gatewayFixture("fetch-page", "ssrf-denied").response_body + + let gateway: ReturnType + let mode = "mixed" + let gatewayCalls: string[] = [] + const auths: Array = [] + + beforeAll(async () => { + gateway = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const body = (await req.json().catch(() => undefined)) as + | { method?: string; params?: { name?: string } } + | undefined + auths.push(req.headers.get("authorization")) + const name = body?.params?.name ?? "" + gatewayCalls.push(name) + const toolBody = name === "web_search" ? searchError : mode === "ssrf" ? ssrfDenied : fetchOk + return Response.json(toolBody, { headers: { "x-swarm-contract": "1" } }) + }, + }) + }) + + afterAll(() => gateway?.stop(true)) + + const withGateway = async (work: (harness: Harness) => Promise) => { + const savedUrl = process.env.ES_SCRAPER_SWARM_URL + const savedToken = process.env.ES_SCRAPER_SWARM_TOKEN + process.env.ES_SCRAPER_SWARM_URL = `http://127.0.0.1:${gateway.port}` + process.env.ES_SCRAPER_SWARM_TOKEN = "test-token" + const harness = await boot({ + git: true, + script: directiveScript, + plugins: [{ path: pluginDir, options: { stateDir: state, pr: "off" } }], + files: { "README.md": "# swarm\n" }, + }) + try { + await work(harness) + } finally { + await harness.close() + if (savedUrl === undefined) delete process.env.ES_SCRAPER_SWARM_URL + else process.env.ES_SCRAPER_SWARM_URL = savedUrl + if (savedToken === undefined) delete process.env.ES_SCRAPER_SWARM_TOKEN + else process.env.ES_SCRAPER_SWARM_TOKEN = savedToken + } + } + + test("gateway search fails over to the next backend", async () => { + mode = "mixed" + gatewayCalls = [] + await withGateway(async (swarm) => { + const searched = await swarm.run(call("es_research_search", { query: "factory gates" }), { + agent: "es-research-alpha", + }) + expect(searched.tools[0]?.status).toBe("completed") + // The gateway errored, so the local SearXNG instance served instead. + expect(searched.tools[0]?.text).toContain("searxng results for") + expect(gatewayCalls).toContain("web_search") + }) + }, 60_000) + + test("gateway fetch caches sealed under provider scraper-swarm", async () => { + mode = "mixed" + gatewayCalls = [] + auths.length = 0 + await withGateway(async (swarm) => { + const fetched = await swarm.run(call("es_research_fetch", { url: "https://example.com/recorded" }), { + agent: "es-research-alpha", + }) + expect(fetched.tools[0]?.status).toBe("completed") + expect(fetched.tools[0]?.text).toContain("Cached https://example.com/recorded") + expect(fetched.tools[0]?.text).toContain( + "sha256:0cb130bc2c0a6fe07c14366f51c96b2cae6b12a12777767524aa49e199c6876f", + ) + expect(gatewayCalls).toContain("fetch_page") + // The token authenticates every call and never anything else. + expect(auths.length).toBeGreaterThan(0) + for (const auth of auths) expect(auth).toBe("Bearer test-token") + }) + }, 60_000) + + test("a gateway SSRF denial stops the chain: direct fetch is never attempted", async () => { + mode = "ssrf" + await withGateway(async (swarm) => { + const { tools } = await swarm.run(call("es_research_fetch", { url: "http://169.254.169.254/" }), { + agent: "es-research-alpha", + }) + expect(tools[0]?.status).toBe("error") + // The gateway's refusal surfaces, not a direct-fetch error: nothing failed over. + expect(tools[0]?.text).toContain("SSRF denied") + }) + mode = "mixed" + }, 60_000) +}) + +describe("#111: the deep-research coordinator on the real host", () => { + const child = (name: string, args: Record = {}) => `@@CHILD ${name} ${JSON.stringify(args)}@@` + const delegate = (agent: string, prompt: string) => call("subagent", { agent, description: `${agent} step`, prompt }) + + test("plan, alpha+beta in one message, synthesizer writes the report, complete reaches DONE", async () => { + const deep = await boot({ + git: true, + script: directiveScript, + plugins: [{ path: pluginDir, options: { stateDir: state, pr: "off" } }], + files: { "README.md": "# deep\n" }, + }) + try { + const { Factory } = await import("@heretek-ai/es-core") + const factory = new Factory(deep.directory, { + gates: async () => ({ passed: true, findings: [], summary: "stub" }), + stateDir: state, + }) + await factory.beginResearchRun({ objective: "Do gates keep agents honest?", ceilingUSD: 5 }) + + // The coordinator is advertised its tools; the synthesizer stays hidden but callable. + await deep.run("hello", { agent: "deep-researcher" }) + const { lastAgentRequest } = await import("@heretek-ai/es-testkit") + const advertised = (lastAgentRequest(deep.llm.requests)?.tools ?? []).map((tool) => tool.function.name) + for (const tool of ["es_research_search", "es_research_fetch", "es_research_audit", "es_research_complete"]) + expect(advertised).toContain(tool) + + // Thesis gathers citable evidence first, so the notes can quote a hash. + const gathered = await deep.run(call("es_research_fetch", { url: `http://127.0.0.1:${server.port}/page` }), { + agent: "deep-researcher", + }) + expect(gathered.tools[0]?.status).toBe("completed") + const sha = /sha256:([0-9a-f]{64})/.exec(gathered.tools[0]?.text ?? "")?.[1] + expect(sha).toBeDefined() + const line = `- Mechanical gates catch regressions before review [VERIFIED: sha256:${sha} "catch regressions before review"]` + + // Fan-out: alpha and beta launched in one message, in the foreground. + const fanout = await deep.run( + `@@PARALLEL@@ ${delegate("es-research-alpha", `Gather the thesis ${child("write", { path: ".factory/research/alpha.md", content: `# Alpha\n\n${line}\n` })} done`)} ${delegate("es-research-beta", `Attack it ${child("write", { path: ".factory/research/beta.md", content: `# Beta\n\n- No counter-evidence cached [NEGATIVE_KNOWLEDGE: searched for gate failures]\n` })} done`)}`, + { agent: "deep-researcher" }, + ) + expect(fanout.tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual([ + "subagent:completed", + "subagent:completed", + ]) + expect(await Bun.file(path.join(deep.directory, ".factory/research/alpha.md")).text()).toContain(sha!) + expect(await Bun.file(path.join(deep.directory, ".factory/research/beta.md")).text()).toContain( + "NEGATIVE_KNOWLEDGE", + ) + + // The synthesizer resolves into the report; the coordinator cannot write it. + const synthesized = await deep.run( + delegate( + "es-research-synthesizer", + `Resolve the dispute ${child("write", { path: ".factory/research/REPORT.md", content: `# Report\n\n${line}\n` })} done`, + ), + { agent: "deep-researcher" }, + ) + expect(synthesized.tools[0]?.status).toBe("completed") + const refused = await deep.run(call("write", { path: ".factory/research/REPORT.md", content: "forged" }), { + agent: "deep-researcher", + }) + expect(refused.tools[0]?.status).toBe("error") + + const completed = await deep.run(call("es_research_complete"), { agent: "deep-researcher" }) + expect(completed.tools[0]?.status).toBe("completed") + expect((await factory.read())?.stage).toBe("DONE") + expect(await Bun.file(path.join(deep.directory, ".factory/research/REPORT.md")).text()).toContain( + "catch regressions before review", + ) + } finally { + await deep.close() + } + }, 120_000) +}) + +describe("the /research deep argument grammar (core parseArgs, not a regex)", () => { + test("--max-usd N and --max-usd=N both parse; the flag never leaks into the question", () => { + expect(parseDeepResearchArgs("deep Do gates hold? --max-usd 5")).toEqual({ + query: "Do gates hold?", + ceiling: "5", + }) + expect(parseDeepResearchArgs("deep Do gates hold? --max-usd=5")).toEqual({ + query: "Do gates hold?", + ceiling: "5", + }) + }) + + test("a missing or unusable ceiling asks for one", () => { + expect(parseDeepResearchArgs("deep Do gates hold? --max-usd")).toEqual({ error: "ceiling" }) + expect(parseDeepResearchArgs("deep Do gates hold?")).toEqual({ error: "ceiling" }) + expect(parseDeepResearchArgs("deep Do gates hold? --max-usd lots")).toEqual({ error: "ceiling" }) + expect(parseDeepResearchArgs("deep Do gates hold? --max-usd 0")).toEqual({ error: "ceiling" }) + }) + + test("without deep and a question it is a usage error", () => { + expect(parseDeepResearchArgs("deep --max-usd 5")).toEqual({ error: "usage" }) + expect(parseDeepResearchArgs("Do gates hold? --max-usd 5")).toEqual({ error: "usage" }) + expect(parseDeepResearchArgs("")).toEqual({ error: "usage" }) + }) }) diff --git a/packages/opencode/test/runtime.test.ts b/packages/opencode/test/runtime.test.ts new file mode 100644 index 0000000..04ce253 --- /dev/null +++ b/packages/opencode/test/runtime.test.ts @@ -0,0 +1,50 @@ +// runtime.policy() carries the sealed run mode (S1.7 follow-through): the +// plugin path must populate PolicyContext.runMode from the sealed run state, +// or the mode-aware REPORT.md grant denies both writers (fail closed) and the +// deep-research coordinator can never write its report on the real host. +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { Factory, gateRunner } from "@heretek-ai/es-core" +import { createRuntime, parseOptions } from "../src/runtime.ts" + +async function setup() { + const root = await mkdtemp(path.join(tmpdir(), "es-runtime-runmode-")) + const state = await mkdtemp(path.join(tmpdir(), "es-runtime-runmode-state-")) + return { root, state } +} + +describe("runtime policy runMode (S1.7)", () => { + test("no run: runMode is absent (fail closed)", async () => { + const { root, state } = await setup() + try { + const runtime = await createRuntime(root, parseOptions({ stateDir: state })) + try { + expect("runMode" in (await runtime.policy())).toBe(false) + } finally { + await runtime.lsp.stopAll().catch(() => undefined) + } + } finally { + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) + } + }) + + test("research run: runMode is research", async () => { + const { root, state } = await setup() + try { + const factory = new Factory(root, { gates: gateRunner({ stateDir: state }), stateDir: state }) + await factory.beginResearchRun({ objective: "Does the mode carry?", ceilingUSD: 5 }) + const runtime = await createRuntime(root, parseOptions({ stateDir: state })) + try { + expect((await runtime.policy()).runMode).toBe("research") + } finally { + await runtime.lsp.stopAll().catch(() => undefined) + } + } finally { + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/test/scoping.test.ts b/packages/opencode/test/scoping.test.ts new file mode 100644 index 0000000..17bb8cc --- /dev/null +++ b/packages/opencode/test/scoping.test.ts @@ -0,0 +1,383 @@ +// Permission and tool-scoping probe suite (#100, replaces #76): a permission +// matrix GENERATED from the canonical registry (a new seat is covered +// automatically), plus real-host bypass probes, one per restriction class. +// Rule evaluation uses the vendored host semantics (testkit/permission.ts: +// OpenCode v2 Permission.evaluate + Wildcard.match, MIT), so a matrix verdict +// means what the host enforces. +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { + AGENTS, + type AgentSpec, + ALL_ES_TOOLS, + approveStage, + bwrapAvailable, + Factory, + factoryLayout, + gateRunner, + type HumanSigner, + researchSourcesDir, + SourceCache, + sealHumanKey, + stringifyFrontmatter, + unlockHumanKey, +} from "@heretek-ai/es-core" +import { boot, directiveScript, evaluatePermission, type Harness, matchWildcard } from "@heretek-ai/es-testkit" +import { createMcpServer } from "../../cli/src/mcp.ts" +import { HOST_WEB_TOOLS, permissionRules } from "../src/agents.ts" +import { createRuntime, parseOptions } from "../src/runtime.ts" +import { registerTools } from "../src/tools.ts" + +const pluginDir = path.resolve(import.meta.dir, "..") +const call = (name: string, args: Record = {}) => `@@CALL ${name} ${JSON.stringify(args)}@@` + +/** MCP servers on a representative host: no seat lists any of them. */ +const SERVERS = ["probe", "memory"] as const + +const effectOf = (spec: AgentSpec, action: string, resource = "*") => + evaluatePermission(action, resource, permissionRules(spec, SERVERS)).effect + +describe("vendored host rule semantics (OpenCode v2, MIT)", () => { + test("Wildcard.match: * spans separators, ? is one character", () => { + expect(matchWildcard("es_status", "es_*")).toBe(true) + expect(matchWildcard("status", "es_*")).toBe(false) + expect(matchWildcard("probe_alpha", "probe_*")).toBe(true) + expect(matchWildcard("/tmp/x", "/tmp/*")).toBe(true) + expect(matchWildcard("abc", "a?c")).toBe(true) + expect(matchWildcard("ac", "a?c")).toBe(false) + }) + + test("Permission.evaluate: the last matching rule wins, silence asks", () => { + const rules = [ + { action: "es_*", resource: "*", effect: "deny" as const }, + { action: "es_status", resource: "*", effect: "allow" as const }, + ] + expect(evaluatePermission("es_status", "*", rules).effect).toBe("allow") + expect(evaluatePermission("es_gates_run", "*", rules).effect).toBe("deny") + expect(evaluatePermission("question", "*", rules).effect).toBe("ask") + }) +}) + +describe("permission matrix generated from the registry", () => { + for (const spec of AGENTS) { + describe(spec.id, () => { + test("es_* tools: allowed exactly when the registry grants them", () => { + for (const tool of [...ALL_ES_TOOLS, "es_imaginary"]) + expect([tool, effectOf(spec, tool)]).toEqual([tool, spec.tools.includes(tool) ? "allow" : "deny"]) + }) + + test("host web tools are denied if and only if web is cached", () => { + for (const tool of HOST_WEB_TOOLS) + expect([tool, effectOf(spec, tool)]).toEqual([tool, spec.web === "cached" ? "deny" : "ask"]) + }) + + test("edit is denied if and only if the seat writes nothing", () => { + expect(effectOf(spec, "edit")).toBe(spec.writes.length === 0 ? "deny" : "ask") + }) + + test("question and external_directory follow the subagent line", () => { + if (spec.mode === "subagent") { + expect(effectOf(spec, "question")).toBe("deny") + expect(evaluatePermission("external_directory", "/tmp/x", permissionRules(spec, SERVERS)).effect).toBe( + "allow", + ) + expect(evaluatePermission("external_directory", "/etc/x", permissionRules(spec, SERVERS)).effect).toBe("deny") + } else { + expect(effectOf(spec, "question")).toBe("ask") + } + }) + + test("subagent launches are allowed exactly for listed spawns", () => { + for (const target of [...spec.spawns, "es-ghost-xyz"]) + expect([target, evaluatePermission("subagent", target, permissionRules(spec, SERVERS)).effect]).toEqual([ + target, + spec.spawns.includes(target) ? "allow" : "deny", + ]) + }) + + test("skills and MCP servers are allowed exactly when listed", () => { + for (const skill of [...spec.skills, "ghost-skill"]) + expect([skill, evaluatePermission("skill", skill, permissionRules(spec, SERVERS)).effect]).toEqual([ + skill, + spec.skills.includes(skill) ? "allow" : "deny", + ]) + for (const server of SERVERS) + for (const tool of [`${server}_alpha`, `${server.replace(/[^\w-]/g, "_")}_alpha`]) + expect([tool, effectOf(spec, tool)]).toEqual([tool, spec.mcp.includes(server) ? "ask" : "deny"]) + }) + + test("Code Mode execute is always denied; LSP follows its mode", () => { + expect(effectOf(spec, "execute")).toBe("deny") + expect(effectOf(spec, "lsp")).toBe(spec.lsp === "none" ? "deny" : "ask") + expect(effectOf(spec, "lsp_rename")).toBe(spec.lsp === "full" ? "ask" : "deny") + }) + }) + } +}) + +describe("bypass probes on the real host (fake model)", () => { + let h: Harness + let state: string + + beforeAll(async () => { + state = await mkdtemp(path.join(tmpdir(), "es-scoping-")) + h = await boot({ + git: true, + script: directiveScript, + plugins: [{ path: pluginDir, options: { stateDir: state, pr: "off" } }], + files: { "README.md": "# scoping\n" }, + }) + }, 120_000) + afterAll(async () => { + await h?.close() + await rm(state, { recursive: true, force: true }) + }) + + test("a cached-web seat cannot call host webfetch", async () => { + const { tools } = await h.run(`fetch ${call("webfetch", { url: "https://example.com" })}`, { + agent: "es-research-alpha", + }) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["webfetch:error"]) + expect(tools[0]?.text).toContain("only through es_research_search and es_research_fetch") + }) + + test("a subagent seat cannot ask questions", async () => { + const { tools } = await h.run( + `ask ${call("question", { questions: [{ question: "Proceed?", header: "P", options: [{ label: "Yes", description: "go" }] }] })}`, + { agent: "es-qa-functional" }, + ) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["question:error"]) + }) + + test("a read-only seat cannot edit", async () => { + const { tools } = await h.run(`edit ${call("edit", { path: "README.md", content: "hijacked" })}`, { + agent: "es-qa-functional", + }) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["edit:error"]) + expect(tools[0]?.text).toContain("is read-only") + }) + + test("the programmer cannot write outside its worktree", async () => { + const { tools } = await h.run(`edit ${call("edit", { path: "PROBE-NOTES.md", content: "x" })}`, { + agent: "es-programmer", + }) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["edit:error"]) + expect(tools[0]?.text).toContain("no active phase worktree") + }) + + test("a seat shell cannot read the state dir", async () => { + const { tools } = await h.run(`read ${call("shell", { command: `cat ${state}/engine.key` })}`, { + agent: "es-qa-functional", + }) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["shell:error"]) + expect(tools[0]?.text).toContain("private state dir") + }) + + test("the server password never reaches a seat shell", async () => { + // End to end: the secret sits in the host's own environment, and even a + // seat's sandboxed shell must not see it (hook strips it, bwrap unsets it). + const saved = process.env.OPENCODE_SERVER_PASSWORD + process.env.OPENCODE_SERVER_PASSWORD = "probe-secret" + try { + const { tools } = await h.run(`leak ${call("shell", { command: "echo pw=$OPENCODE_SERVER_PASSWORD" })}`, { + agent: "es-programmer", + }) + if (!bwrapAvailable()) { + // Without the sandbox a seat shell never runs at all: still no leak. + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["shell:error"]) + expect(tools[0]?.text).toContain("bubblewrap") + expect(tools[0]?.text).not.toContain("probe-secret") + return + } + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["shell:completed"]) + expect(tools[0]?.text).toContain("pw=") + expect(tools[0]?.text).not.toContain("probe-secret") + } finally { + if (saved === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = saved + } + }) + + test("the shellEnv hook strips password variables and keeps the rest", async () => { + const { createPolicyHooks } = await import("../src/policy.ts") + const hooks = createPolicyHooks({ root: h.directory } as any) + const env: Record = { + OPENCODE_SERVER_PASSWORD: "s", + OPENCODE_PASSWORD: "s", + PATH: "/bin", + } + hooks.shellEnv({ env }) + expect(env).toEqual({ PATH: "/bin" }) + }) + + test("an offline seat shell has no network", async () => { + if (!bwrapAvailable()) { + const { tools } = await h.run(`net ${call("shell", { command: "cat /proc/net/dev" })}`, { + agent: "es-research-alpha", + }) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["shell:error"]) + expect(tools[0]?.text).toContain("bubblewrap") + return + } + // /proc/net/dev is net-namespace aware (only lo: under --unshare-net); + // /sys/class/net leaks host interfaces through the --ro-bind / / (#100). + const { tools } = await h.run(`net ${call("shell", { command: "cat /proc/net/dev" })}`, { + agent: "es-research-alpha", + }) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["shell:completed"]) + const ifaces = tools[0]?.text + .split("\n") + .filter((line) => line.includes(":")) + .map((line) => line.split(":")[0]?.trim() ?? "") + .filter(Boolean) + expect(ifaces).toEqual(["lo"]) + }) + + test("a seat cannot launch a seat outside its spawns", async () => { + const { tools } = await h.run( + `delegate ${call("subagent", { agent: "es-programmer", description: "x", prompt: "escape" })}`, + { agent: "es-qa-functional" }, + ) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["subagent:error"]) + }) +}) + +describe("programmer worktree scoping with an active phase worktree (#100)", () => { + let work: Harness + let workState: string + let signer: HumanSigner + + const write = async (root: string, file: string, text: string) => { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }) + await writeFile(path.join(root, file), text) + } + const workFactory = () => + new Factory(work.directory, { gates: gateRunner({ stateDir: workState }), stateDir: workState }) + + beforeAll(async () => { + workState = await mkdtemp(path.join(tmpdir(), "es-scoping-work-")) + await sealHumanKey("test-passphrase-1234", workState) + signer = await unlockHumanKey("test-passphrase-1234", workState) + work = await boot({ + git: true, + script: directiveScript, + plugins: [{ path: pluginDir, options: { stateDir: workState, pr: "off" } }], + files: { "README.md": "# scoping worktree\n" }, + }) + // Walk the pipeline to BUILD so a phase worktree exists. + const factory = workFactory() + await factory.provisionRun("tester", 10) + await factory.writeFrontier("grill", { + version: "1.1", + idea: "a session handoff feature", + spendCeiling: { currency: "USD", maxAmount: 10 }, + round: 1, + settled: true, + nodes: [{ id: "scope", question: "Which harness first?", status: "settled", answer: "opencode", round: 1 }], + }) + await approveStage(work.directory, { + stage: "frontier", + channel: "cli", + approvedBy: "tester", + signer, + stateDir: workState, + }) + const cache = new SourceCache(researchSourcesDir(work.directory), workState) + const source = await cache.put({ + url: "https://example.test/handoff", + text: "Session handoff writes a file the next agent reads.", + provider: "fetch", + }) + const layout = factoryLayout(work.directory) + await write( + work.directory, + path.relative(work.directory, layout.researchReport), + `- Handoff is file-based [VERIFIED: sha256:${source.meta.sha256} "file the next agent reads"]\n`, + ) + await factory.completeResearch("factory") + await write( + work.directory, + ".factory/roadmap.json", + JSON.stringify({ version: 1, title: "Handoff", phases: [{ id: "alpha", title: "Phase alpha" }] }, null, 2), + ) + await write( + work.directory, + ".factory/specs/alpha/GOAL.md", + stringifyFrontmatter( + { + phase: "alpha", + title: "Phase alpha", + acceptance: [{ kind: "file", id: "impl", description: "implementation exists", path: "src/alpha.ts" }], + }, + "Implement alpha as a vertical slice.\n", + ), + ) + await approveStage(work.directory, { + stage: "spec", + channel: "cli", + approvedBy: "tester", + signer, + stateDir: workState, + }) + const started = await work.run(call("es_build_start", {}), { agent: "factory" }) + expect(started.tools[0]?.status).toBe("completed") + expect((await workFactory().read())?.stage).toBe("BUILD") + }, 180_000) + afterAll(async () => { + await work?.close() + await rm(workState, { recursive: true, force: true }) + }) + + test("the programmer cannot write outside its worktree when one is active", async () => { + // `write` (not `edit`) takes raw content, so a removed guard lets the + // call through instead of failing on arguments. + const { tools } = await work.run(`write ${call("write", { path: "PROBE-OUTSIDE.md", content: "x" })}`, { + agent: "es-programmer", + }) + expect(tools.map((tool) => `${tool.name}:${tool.status}`)).toEqual(["write:error"]) + expect(tools[0]?.text).toContain("may only write inside its phase worktree") + }) +}) + +describe("MCP probe (#99): a seat reaches no out-of-spec tool through es mcp", () => { + let root: string + let state: string + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "es-scoping-mcp-")) + state = await mkdtemp(path.join(tmpdir(), "es-scoping-mcp-state-")) + }) + afterAll(() => Promise.all([rm(root, { recursive: true, force: true }), rm(state, { recursive: true, force: true })])) + + test("a QA-pinned server refuses the programmer-only tool; the piped agent is ignored", async () => { + const server = createMcpServer({ root, stateDir: state, env: { ES_MCP_AGENT: "es-qa-functional" } }) + const denied = await server.handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "complete", arguments: { agent: "es-programmer" } }, + }) + expect(denied?.result.isError).toBe(true) + expect(denied?.result.content[0].text).toContain("called by es-qa-functional") + }) +}) + +describe("item 1 (#101): registerTools registers every registry tool exactly once", () => { + test("the registered es_* names equal ALL_ES_TOOLS with no duplicates", async () => { + const root = await mkdtemp(path.join(tmpdir(), "es-scoping-reg-")) + const state = await mkdtemp(path.join(tmpdir(), "es-scoping-reg-state-")) + try { + const runtime = await createRuntime(root, parseOptions({ stateDir: state })) + const names: string[] = [] + registerTools({ add: (tool: any) => void names.push(tool.name) }, runtime) + await runtime.lsp.stopAll().catch(() => undefined) + const esNames = names.filter((name) => name.startsWith("es_")) + expect([...new Set(esNames)].sort()).toEqual([...ALL_ES_TOOLS].sort()) + expect(esNames.length).toBe(new Set(esNames).size) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(state, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/test/tui-pack.test.ts b/packages/opencode/test/tui-pack.test.ts index 3a39fbb..f4c84d5 100644 --- a/packages/opencode/test/tui-pack.test.ts +++ b/packages/opencode/test/tui-pack.test.ts @@ -10,7 +10,7 @@ // with either and must not throw with neither (commands keep working). import { expect, test } from "bun:test" -import { copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs" +import { copyFileSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { pathToFileURL } from "node:url" @@ -28,6 +28,13 @@ async function loadPackedTui(): Promise { mkdirSync(destDir, { recursive: true }) copyFileSync(built, path.join(destDir, "tui.js")) writeFileSync(path.join(pluginDir, "package.json"), '{"type":"module"}') + // A real install ships the plugin's dependencies beside it: tui.js keeps + // @heretek-ai/es-core external (the host may rewrite it; otherwise normal + // resolution finds the installed copy), so the fixture links it the way + // npm would install it. + const vendor = path.join(fixture, "node_modules", "@heretek-ai") + mkdirSync(vendor, { recursive: true }) + symlinkSync(path.resolve(import.meta.dir, "..", "..", "core"), path.join(vendor, "es-core"), "dir") return (await import(pathToFileURL(path.join(destDir, "tui.js")).href)).default } diff --git a/packages/testkit/package.json b/packages/testkit/package.json index f0dc2c4..4a3aaa4 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -1,7 +1,8 @@ { "name": "@heretek-ai/es-testkit", "version": "1.0.0", - "description": "Test harness for OpenCode v2 in-process host with scripted fake models", + "private": true, + "description": "Test harness for OpenCode v2 in-process host with scripted fake models (private, never published)", "type": "module", "license": "Apache-2.0", "exports": { diff --git a/packages/testkit/src/host.ts b/packages/testkit/src/host.ts index 1349508..4ac979c 100644 --- a/packages/testkit/src/host.ts +++ b/packages/testkit/src/host.ts @@ -82,6 +82,11 @@ export async function boot(options: HarnessOptions = {}): Promise { } if (options.git) { await git(directory, "init", "-q", "-b", "main") + // Repo-local identity: merges (fleet landings) must work without any + // ambient git config, as in CI. The initial commit keeps its own -c + // flags; this pins the repo for everything after it. + await git(directory, "config", "user.name", "test") + await git(directory, "config", "user.email", "test@test") await git(directory, "add", "-A") await git( directory, diff --git a/packages/testkit/src/index.ts b/packages/testkit/src/index.ts index 204b6f0..fa9ea05 100644 --- a/packages/testkit/src/index.ts +++ b/packages/testkit/src/index.ts @@ -1,3 +1,4 @@ export * from "./fake-llm.ts" export * from "./host.ts" +export * from "./permission.ts" export * from "./script.ts" diff --git a/packages/testkit/src/permission.ts b/packages/testkit/src/permission.ts new file mode 100644 index 0000000..09b9edc --- /dev/null +++ b/packages/testkit/src/permission.ts @@ -0,0 +1,40 @@ +// Rule-evaluation semantics vendored from OpenCode v2 (MIT License, +// Copyright (c) 2025 opencode), pinned at v2 HEAD 2026-10-09: +// packages/core/src/util/wildcard.ts → matchWildcard (Wildcard.match) +// packages/core/src/permission.ts → evaluatePermission (Permission.evaluate) +// Pure functions only: no Effect, no schemas, no host imports. The probe suite +// (#100) evaluates the COMPILED registry permission rules with the identical +// logic the host applies, so a matrix assertion means what the host enforces. +// Re-pin by re-reading those two files and updating VENDORED_AT. +export const VENDORED_AT = "opencode v2 HEAD 2026-10-09" + +export interface PermissionRule { + readonly action: string + readonly resource: string + readonly effect: "allow" | "deny" | "ask" +} + +/** Wildcard.match: `*` spans separators, `?` is one character, backslashes normalize. */ +export function matchWildcard(input: string, pattern: string): boolean { + const normalized = input.replaceAll("\\", "/") + let escaped = pattern + .replaceAll("\\", "/") + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, ".") + + if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?" + + return new RegExp(`^${escaped}$`, process.platform === "win32" ? "si" : "s").test(normalized) +} + +/** Permission.evaluate: the LAST matching rule wins; nothing matching asks. */ +export function evaluatePermission(action: string, resource: string, rules: readonly PermissionRule[]): PermissionRule { + return ( + rules.findLast((rule) => matchWildcard(action, rule.action) && matchWildcard(resource, rule.resource)) ?? { + action, + resource: "*", + effect: "ask", + } + ) +} diff --git a/packages/testkit/test/testkit.test.ts b/packages/testkit/test/testkit.test.ts index 02e695e..f81d372 100644 --- a/packages/testkit/test/testkit.test.ts +++ b/packages/testkit/test/testkit.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import path from "node:path" import { boot } from "../src/index.ts" describe("testkit", () => { @@ -14,4 +16,15 @@ describe("testkit", () => { await harness.close() } }, 30000) + + test("a git repo boots with a repo-local identity (merges must not need ambient config)", async () => { + const harness = await boot({ git: true }) + try { + const config = await readFile(path.join(harness.directory, ".git", "config"), "utf8") + expect(config).toContain("email = test@test") + expect(config).toContain("name = test") + } finally { + await harness.close() + } + }, 30000) }) diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..fc55a97 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Epistemic Swarm + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..1f679dc --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "@heretek-ai/es-web", + "version": "0.1.0", + "private": true, + "description": "Epistemic Swarm web control plane (private, never published): fleet dashboard, browser approvals, config editor and evidence explorer, served by es-fleet on loopback.", + "esTest": { + "conditions": [ + "browser" + ] + }, + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Heretek-AI/IUMBTEMS.git", + "directory": "packages/web" + }, + "scripts": { + "build": "vite build", + "test": "bun --conditions=browser test" + }, + "dependencies": { + "@heretek-ai/es-fleet": "workspace:*", + "solid-js": "1.9.15" + }, + "devDependencies": { + "happy-dom": "20.14.6", + "vite": "7.3.6", + "vite-plugin-solid": "2.11.14" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/web/src/api.test.ts b/packages/web/src/api.test.ts new file mode 100644 index 0000000..c71244f --- /dev/null +++ b/packages/web/src/api.test.ts @@ -0,0 +1,220 @@ +// Bus client (#129): strict response parsing, auth failure mapping. The +// fetch transport is stubbed per test; no DOM is touched. +import { afterEach, describe, expect, test } from "bun:test" +import { + exportEvidenceDossier, + fetchApprovePreview, + fetchConfigView, + fetchEvidenceClaim, + fetchEvidenceClaims, + fetchEvidenceSource, + fetchStatus, + fetchTask, + fetchTaskLiveness, + parseStatus, + previewConfigPlan, + RpcError, + rpc, + submitApproval, +} from "./api.ts" + +const realFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = realFetch +}) + +const stubFetch = (status: number, body: unknown) => { + globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch +} + +describe("parseStatus", () => { + test("it accepts a well-formed snapshot", () => { + const snapshot = parseStatus({ + daemon: { running: true, pid: 7, maxUsd: 50, concurrency: 2 }, + tasks: [{ id: "a", title: "Alpha", status: "running", ceilingUSD: 5, spendUsd: 1, deps: [] }], + spendUsd: 1, + pending: [{ taskId: "a", reason: "frontier approval", stage: "frontier" }], + }) + expect(snapshot.daemon.pid).toBe(7) + expect(snapshot.tasks).toHaveLength(1) + }) + + test("it rejects malformed payloads (fail-closed, never a partial render)", () => { + for (const bad of [ + undefined, + {}, + { daemon: {}, tasks: [], spendUsd: 0, pending: [] }, + { + daemon: { running: true, pid: 7, maxUsd: 50, concurrency: 2 }, + tasks: [{ id: "a" }], + spendUsd: 0, + pending: [], + }, + ]) + expect(() => parseStatus(bad)).toThrow(RpcError) + }) +}) + +describe("rpc", () => { + test("401 maps to a sign-in hint, 403 to a loopback hint", async () => { + stubFetch(401, {}) + await expect(rpc("fleet.status", {})).rejects.toThrow(/one-time URL/) + stubFetch(403, {}) + await expect(rpc("fleet.status", {})).rejects.toThrow(/loopback/) + }) + + test("an RPC error envelope throws with its code", async () => { + stubFetch(200, { error: { code: -32601, message: "unknown method" } }) + let failure: RpcError | undefined + try { + await rpc("fleet.status", {}) + } catch (error) { + failure = error as RpcError + } + expect(failure).toBeInstanceOf(RpcError) + expect(failure?.code).toBe(-32601) + }) + + test("fetchStatus returns the parsed snapshot", async () => { + stubFetch(200, { + result: { + daemon: { running: true, pid: 7, maxUsd: 50, concurrency: 2 }, + tasks: [], + spendUsd: 0, + pending: [], + }, + }) + expect((await fetchStatus()).spendUsd).toBe(0) + }) + + test("fetchTask returns the parsed task, or throws when it is missing", async () => { + stubFetch(200, { + result: { task: { id: "a", title: "Alpha", status: "done", ceilingUSD: 5, spendUsd: 5, deps: [] } }, + }) + expect((await fetchTask("a")).title).toBe("Alpha") + stubFetch(200, { error: { code: -32603, message: "unknown task" } }) + await expect(fetchTask("a")).rejects.toThrow(RpcError) + }) + + test("fetchTaskLiveness parses the liveness payload and rejects malformed ones", async () => { + stubFetch(200, { + result: { + taskId: "a", + stage: "SPEC", + headline: "programmer is editing the UI", + seats: ["manager: waiting on programmer"], + research: "alpha 3/5 sources, beta 2/5", + }, + }) + const live = await fetchTaskLiveness("a") + expect(live.headline).toBe("programmer is editing the UI") + expect(live.seats).toEqual(["manager: waiting on programmer"]) + expect(live.research).toBe("alpha 3/5 sources, beta 2/5") + stubFetch(200, { result: { taskId: "a", stage: "SPEC" } }) + await expect(fetchTaskLiveness("a")).rejects.toThrow(RpcError) + }) + + test("parseStatus rejects a pending entry with an unknown stage", () => { + expect(() => + parseStatus({ + daemon: { running: true, pid: 7, maxUsd: 50, concurrency: 2 }, + tasks: [], + spendUsd: 0, + pending: [{ taskId: "a", reason: "frontier approval", stage: "council" }], + }), + ).toThrow(RpcError) + }) + + test("fetchConfigView parses the view and rejects malformed payloads", async () => { + stubFetch(200, { + result: { + config: { licenseWhitelist: ["MIT"] }, + configHash: "abc", + gates: { topN: 5 }, + gatesHash: null, + drift: { clean: true, violations: [], hasBaseline: false }, + forbidden: ["embeddings"], + }, + }) + const seen = await fetchConfigView() + expect(seen.config).toEqual({ licenseWhitelist: ["MIT"] }) + expect(seen.gatesHash).toBeNull() + stubFetch(200, { result: { config: {}, drift: {} } }) + await expect(fetchConfigView()).rejects.toThrow(RpcError) + }) + + test("previewConfigPlan returns the plan", async () => { + stubFetch(200, { + result: { ok: true, errors: [], oldHash: "a", newHash: "b", changedKeys: ["topN"], commands: ["x"] }, + }) + const plan = await previewConfigPlan("gates", { topN: 3 }) + expect(plan.ok).toBe(true) + expect(plan.changedKeys).toEqual(["topN"]) + }) + + test("fetchApprovePreview parses the preview and rejects malformed payloads", async () => { + stubFetch(200, { + ticket: "t", + csrf: "c", + subjectHash: "ab".repeat(32), + summary: ["Idea: x"], + files: [{ path: ".factory/frontier.json", sha256: "cd".repeat(32) }], + spendCeilingUSD: 25, + pending: true, + }) + const seen = await fetchApprovePreview("t1", "frontier") + expect(seen.summary).toEqual(["Idea: x"]) + expect(seen.files).toHaveLength(1) + stubFetch(200, { ticket: "t" }) + await expect(fetchApprovePreview("t1", "frontier")).rejects.toThrow(RpcError) + }) + + test("submitApproval returns the result and surfaces refusal details", async () => { + stubFetch(200, { ok: true, stage: "frontier", alreadyApproved: false, factoryStage: "RESEARCH" }) + const done = await submitApproval("t", "c", "pass") + expect(done.factoryStage).toBe("RESEARCH") + stubFetch(403, { error: "wrong passphrase", remaining: 3 }) + const failure = await submitApproval("t", "c", "nope").then( + () => undefined, + (error: unknown) => error as RpcError, + ) + expect(failure).toBeInstanceOf(RpcError) + expect(failure?.details.remaining).toBe(3) + }) + + test("evidence calls parse their payloads and reject malformed ones", async () => { + stubFetch(200, { + result: { + claims: [{ id: "c", tag: "VERIFIED", status: "LIVE", statement: "s", sourceSha: null }], + truncated: false, + }, + }) + const listed = await fetchEvidenceClaims("run1", { tag: "VERIFIED" }) + expect(listed.claims).toHaveLength(1) + stubFetch(200, { result: { claims: [{ id: "c" }] } }) + await expect(fetchEvidenceClaims("run1")).rejects.toThrow(RpcError) + stubFetch(200, { + result: { + claim: { id: "c", tag: "VERIFIED", status: "LIVE", statement: "s", sourceSha: null }, + quotes: [], + retractions: [], + events: [], + rankExplanation: "r", + files: [], + }, + }) + expect((await fetchEvidenceClaim("run1", "c")).rankExplanation).toBe("r") + stubFetch(200, { + result: { + meta: { sha256: "x", url: "u", retrieved: "r", provider: "p", bytes: 1 }, + seal: "sealed", + text: "t", + truncated: false, + }, + }) + expect((await fetchEvidenceSource("run1", "x")).seal).toBe("sealed") + stubFetch(200, { result: { format: "markdown", body: "# x", truncated: false } }) + expect((await exportEvidenceDossier("run1", "markdown")).body).toBe("# x") + }) +}) diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts new file mode 100644 index 0000000..669e133 --- /dev/null +++ b/packages/web/src/api.ts @@ -0,0 +1,500 @@ +// Bus client (#129, ticket #130 builds the dashboard on it): reads go over +// the same `POST /fleet/rpc` the CLI uses, authenticated by the session +// cookie the ticket exchange set (same-origin fetch carries it; no token +// ever touches client state). Strict-schema responses only. The single +// mutating call is the approval submit (#131, ADR 0002 option b): the +// passphrase travels only over loopback, bound to a single-use ticket. +import type { + ApproveTicket, + ConfigDrift, + ConfigPlan, + ConfigView, + EvidenceClaimSummary, + EvidenceQuote, + FleetSnapshot, + TaskLiveness, +} from "@heretek-ai/es-fleet" + +export type { ConfigDrift, ConfigPlan, ConfigView, EvidenceClaimSummary, EvidenceQuote, TaskLiveness } + +/** Approval stages the browser can preview and submit (fleet ApproveTicket). */ +export type ApproveStage = ApproveTicket["stage"] + +/** Fail-closed stage check for fleet snapshot rows (typed `string` upstream). */ +export const isApproveStage = (raw: unknown): raw is ApproveStage => raw === "frontier" || raw === "spec" + +/** Claim tag vocabulary, shared by the evidence filters and layout. */ +export type Tag = "VERIFIED" | "INFERRED" | "HYPOTHESIS" | "NEGATIVE_KNOWLEDGE" + +export class RpcError extends Error { + constructor( + readonly code: number, + message: string, + /** Extra machine-readable fields the server sent (remaining, retryAfterSec). */ + readonly details: Record = {}, + ) { + super(message) + this.name = "RpcError" + } +} + +const SnapshotTask = (raw: unknown): FleetSnapshot["tasks"][number] | undefined => { + if (typeof raw !== "object" || raw === null) return undefined + const task = raw as Record + if (typeof task.id !== "string" || typeof task.title !== "string" || typeof task.status !== "string") return undefined + if (typeof task.ceilingUSD !== "number" || typeof task.spendUsd !== "number") return undefined + if (!Array.isArray(task.deps) || task.deps.some((dep) => typeof dep !== "string")) return undefined + return { + id: task.id, + title: task.title, + status: task.status, + ceilingUSD: task.ceilingUSD, + spendUsd: task.spendUsd, + deps: [...(task.deps as string[])], + ...(typeof task.reason === "string" ? { reason: task.reason } : {}), + } +} + +/** Validate an untrusted `fleet.status` payload into a FleetSnapshot. */ +export function parseStatus(raw: unknown): FleetSnapshot { + if (typeof raw !== "object" || raw === null) throw new RpcError(-32603, "fleet.status returned no snapshot") + const root = raw as Record + const daemon = root.daemon as Record | undefined + const tasks = Array.isArray(root.tasks) ? root.tasks : undefined + const pending = Array.isArray(root.pending) ? root.pending : undefined + if ( + typeof daemon !== "object" || + daemon === null || + typeof daemon.running !== "boolean" || + (typeof daemon.pid !== "number" && daemon.pid !== null) || + (typeof daemon.maxUsd !== "number" && daemon.maxUsd !== null) || + typeof daemon.concurrency !== "number" || + tasks === undefined || + typeof root.spendUsd !== "number" || + pending === undefined + ) + throw new RpcError(-32603, "fleet.status returned a malformed snapshot") + const cleanTasks: FleetSnapshot["tasks"][number][] = [] + for (const entry of tasks) { + const task = SnapshotTask(entry) + if (!task) throw new RpcError(-32603, "fleet.status returned a malformed task") + cleanTasks.push(task) + } + const cleanPending: { taskId: string; reason: string; stage: ApproveStage }[] = [] + for (const entry of pending) { + const item = entry as Record + if (typeof item?.taskId !== "string" || typeof item?.reason !== "string" || !isApproveStage(item?.stage)) + throw new RpcError(-32603, "fleet.status returned a malformed pending entry") + cleanPending.push({ taskId: item.taskId, reason: item.reason, stage: item.stage }) + } + return { + daemon: { + running: daemon.running, + pid: daemon.pid as number | null, + maxUsd: daemon.maxUsd as number | null, + concurrency: daemon.concurrency, + }, + tasks: cleanTasks, + spendUsd: root.spendUsd, + pending: cleanPending, + } +} + +/** One JSON-RPC call against the daemon bus (same-origin, cookie-authenticated). */ +export async function rpc(method: string, params: Record, base = ""): Promise { + const response = await fetch(`${base}/fleet/rpc`, { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ method, params }), + }) + if (response.status === 401) throw new RpcError(401, "not signed in: open the one-time URL from `es-fleet web`") + if (response.status === 403) throw new RpcError(403, "refused: the UI must be opened on loopback") + if (!response.ok) throw new RpcError(response.status, `bus returned HTTP ${response.status}`) + const envelope = (await response.json()) as { result?: unknown; error?: { code?: unknown; message?: unknown } } + if (envelope.error !== undefined) + throw new RpcError( + typeof envelope.error.code === "number" ? envelope.error.code : -32603, + typeof envelope.error.message === "string" ? envelope.error.message : "bus error", + ) + return envelope.result as T +} + +/** + * One call against the browser-approval endpoints (same-origin; the + * session cookie and CSRF token ride along). Server refusals carry + * machine-readable details (remaining attempts, retry delay). + */ +async function approveCall( + target: "/fleet/approve/preview" | "/fleet/approve", + payload: Record, + csrf: string, + base = "", +): Promise> { + const response = await fetch(`${base}${target}`, { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json", ...(csrf ? { "x-csrf": csrf } : {}) }, + body: JSON.stringify(payload), + }) + if (response.status === 401) throw new RpcError(401, "not signed in: open the one-time URL from `es-fleet web`") + const body = (await response.json().catch(() => undefined)) as Record | undefined + if (!response.ok) { + const message = + typeof body?.error === "string" ? body.error : `the daemon refused the call (HTTP ${response.status})` + const { error: _dropped, ...details } = body ?? {} + void _dropped + throw new RpcError(response.status, message, details) + } + if (!body || typeof body !== "object") throw new RpcError(-32603, "the daemon returned no approval payload") + return body +} + +/** Fleet overview for the hello page (#130 grows this into the dashboard). */ +export async function fetchStatus(base = ""): Promise { + return parseStatus(await rpc("fleet.status", {}, base)) +} + +/** One task for the detail page (#130). */ +export async function fetchTask(id: string, base = ""): Promise { + const result = await rpc<{ task?: unknown }>("fleet.task", { id }, base) + const task = SnapshotTask(result.task) + if (!task) throw new RpcError(-32603, `the daemon returned no task ${id}`) + return task +} + +export interface ApproveFile { + readonly path: string + readonly sha256: string +} + +export interface ApprovePreview { + readonly ticket: string + readonly csrf: string + readonly subjectHash: string + readonly summary: string[] + readonly files: readonly ApproveFile[] + readonly spendCeilingUSD?: number + readonly pending: boolean +} + +export interface ApproveResult { + readonly ok: boolean + readonly stage: string + readonly alreadyApproved: boolean + readonly factoryStage: string | null +} + +const str = (raw: unknown, what: string): string => { + if (typeof raw !== "string" || raw.length === 0) throw new RpcError(-32603, `the daemon returned no ${what}`) + return raw +} + +const strArray = (raw: unknown, what: string): string[] => { + if (!Array.isArray(raw) || raw.some((entry) => typeof entry !== "string")) + throw new RpcError(-32603, `the daemon returned no ${what}`) + return [...raw] +} + +/** One task's run liveness for the detail page (#130, `fleet.task.liveness`). */ +export async function fetchTaskLiveness(id: string, base = ""): Promise { + const result = await rpc("fleet.task.liveness", { id }, base) + if (typeof result !== "object" || result === null) throw new RpcError(-32603, "the daemon returned no task liveness") + const seen = result as Record + if (typeof seen.headline !== "string" || typeof seen.stage !== "string" || !Array.isArray(seen.seats)) + throw new RpcError(-32603, "the daemon returned no task liveness") + return { + taskId: str(seen.taskId, "task liveness"), + stage: seen.stage, + headline: seen.headline, + seats: strArray(seen.seats, "task liveness"), + ...(typeof seen.runId === "string" ? { runId: seen.runId } : {}), + ...(typeof seen.research === "string" ? { research: seen.research } : {}), + ...(typeof seen.lastActivityAt === "string" ? { lastActivityAt: seen.lastActivityAt } : {}), + } +} + +/** Preview a frontier or spec approval: subject, hash and single-use ticket (#131). */ +export async function fetchApprovePreview(runId: string, stage: ApproveStage, base = ""): Promise { + const body = await approveCall("/fleet/approve/preview", { runId, stage }, "", base) + if (!Array.isArray(body.files)) throw new RpcError(-32603, "the daemon returned no approval preview") + const files: ApproveFile[] = [] + for (const entry of body.files) { + if (typeof entry !== "object" || entry === null) + throw new RpcError(-32603, "the daemon returned no approval preview") + const file = entry as Record + files.push({ path: str(file.path, "approval preview"), sha256: str(file.sha256, "approval preview") }) + } + return { + ticket: str(body.ticket, "approval preview"), + csrf: str(body.csrf, "approval preview"), + subjectHash: str(body.subjectHash, "approval preview"), + summary: strArray(body.summary, "approval preview"), + files, + ...(typeof body.spendCeilingUSD === "number" ? { spendCeilingUSD: body.spendCeilingUSD } : {}), + pending: body.pending === true, + } +} + +/** Submit the passphrase for a previewed ticket (#131, ADR 0002 option b). */ +export async function submitApproval( + ticket: string, + csrf: string, + passphrase: string, + base = "", +): Promise { + const body = await approveCall("/fleet/approve", { ticket, passphrase }, csrf, base) + return { + ok: body.ok === true, + stage: typeof body.stage === "string" ? body.stage : "", + alreadyApproved: body.alreadyApproved === true, + factoryStage: typeof body.factoryStage === "string" ? body.factoryStage : null, + } +} + +const isRecord = (raw: unknown): raw is Record => + typeof raw === "object" && raw !== null && !Array.isArray(raw) + +const malformed = (): never => { + throw new RpcError(-32603, "the daemon returned a malformed config view") +} + +const strings = (raw: unknown): string[] => { + if (Array.isArray(raw) && raw.every((entry) => typeof entry === "string")) return [...raw] + return malformed() +} + +const recordOrNull = (raw: unknown): Record | null => { + if (raw === null) return null + if (isRecord(raw)) return raw + return malformed() +} + +const hashOrNull = (raw: unknown): string | null => { + if (raw === null || typeof raw === "string") return raw + return malformed() +} + +/** Current project config, gates, hashes and drift (#132, preview-only). */ +export async function fetchConfigView(base = ""): Promise { + const result = await rpc("fleet.config.get", {}, base) + if (!isRecord(result)) throw new RpcError(-32603, "the daemon returned a malformed config view") + const drift = result.drift + if (!isRecord(drift) || typeof drift.clean !== "boolean" || typeof drift.hasBaseline !== "boolean") + throw new RpcError(-32603, "the daemon returned a malformed config view") + return { + config: recordOrNull(result.config), + configHash: hashOrNull(result.configHash), + gates: recordOrNull(result.gates), + gatesHash: hashOrNull(result.gatesHash), + drift: { clean: drift.clean, violations: strings(drift.violations), hasBaseline: drift.hasBaseline }, + forbidden: strings(result.forbidden), + } +} + +/** Validate proposed file content and preview the exact diff (#132). */ +export async function previewConfigPlan(file: "config" | "gates", content: unknown, base = ""): Promise { + const result = await rpc("fleet.config.plan", { file, content }, base) + if (!isRecord(result) || typeof result.ok !== "boolean") + throw new RpcError(-32603, "the daemon returned no config plan") + return { + ok: result.ok, + errors: strings(result.errors), + oldHash: typeof result.oldHash === "string" || result.oldHash === null ? result.oldHash : null, + newHash: typeof result.newHash === "string" || result.newHash === null ? result.newHash : null, + changedKeys: strings(result.changedKeys), + commands: strings(result.commands), + } +} + +/** Claim list narrowing for a run (#133): the tag is the closed Tag vocabulary. */ +export interface EvidenceFilter { + readonly tag?: Tag + readonly status?: string + readonly tier?: string + readonly text?: string +} + +/** One quote with its verified source ranges (fleet EvidenceQuote). */ +export type EvidenceQuoteRange = EvidenceQuote["ranges"][number] + +/** One claim in full: same row shape as the fleet claim summary. */ +export type EvidenceClaim = EvidenceClaimSummary + +export interface EvidenceRetraction { + readonly source: string + readonly event: string + readonly at: string + readonly note: string + readonly by: string +} + +export interface EvidenceClaimDetail { + readonly claim: EvidenceClaim + readonly quotes: readonly EvidenceQuote[] + readonly retractions: readonly EvidenceRetraction[] + readonly events: ReadonlyArray<{ claim: string; from: string; to: string; reason: string; at: string }> + readonly rankExplanation: string + readonly files: readonly string[] +} + +export interface EvidenceSource { + readonly meta: { + readonly sha256: string + readonly url: string + readonly title?: string + readonly retrieved: string + readonly provider: string + readonly bytes: number + readonly tier?: string + } + readonly seal: string + readonly text: string + readonly truncated: boolean +} + +export interface EvidenceExport { + readonly format: string + readonly body: string + readonly truncated: boolean +} + +const optStr = (raw: unknown): string | undefined => (typeof raw === "string" ? raw : undefined) + +const reqRec = (raw: unknown, what: string): Record => { + if (!isRecord(raw)) throw new RpcError(-32603, `the daemon returned no ${what}`) + return raw +} + +/** Claim list for a run, narrowed by tag/status/tier/text (#133). */ +export async function fetchEvidenceClaims( + runId: string, + filter: EvidenceFilter = {}, + base = "", +): Promise<{ claims: EvidenceClaimSummary[]; truncated: boolean }> { + const params: Record = { runId } + const clean: Record = {} + for (const [key, value] of Object.entries(filter)) if (value !== undefined && value !== "") clean[key] = value + if (Object.keys(clean).length) params.filter = clean + const result = await rpc<{ claims?: unknown; truncated?: unknown }>("evidence.claims", params, base) + if (!Array.isArray(result.claims)) throw new RpcError(-32603, "the daemon returned no evidence claims") + return { + claims: result.claims.map((entry) => { + const claim = reqRec(entry, "evidence claim") + const tier = optStr(claim.tier) + return { + id: str(claim.id, "evidence claim"), + tag: str(claim.tag, "evidence claim"), + status: str(claim.status, "evidence claim"), + statement: str(claim.statement, "evidence claim"), + sourceSha: claim.sourceSha === null ? null : str(claim.sourceSha, "evidence claim"), + ...(tier !== undefined ? { tier } : {}), + } + }), + truncated: result.truncated === true, + } +} + +/** One claim with quotes, retractions, events and rank (#133). */ +export async function fetchEvidenceClaim(runId: string, id: string, base = ""): Promise { + const result = await rpc("evidence.claim", { runId, id }, base) + const root = reqRec(result, "evidence claim") + const claim = reqRec(root.claim, "evidence claim") + const tier = optStr(claim.tier) + const source = claim.source === undefined ? undefined : reqRec(claim.source, "evidence claim") + const sourceSha = source === undefined ? null : str(source.sha256, "evidence claim") + if ( + !Array.isArray(root.quotes) || + !Array.isArray(root.retractions) || + !Array.isArray(root.events) || + !Array.isArray(root.files) + ) + throw new RpcError(-32603, "the daemon returned no evidence claim") + return { + claim: { + id: str(claim.id, "evidence claim"), + tag: str(claim.tag, "evidence claim"), + status: str(claim.status, "evidence claim"), + statement: str(claim.statement, "evidence claim"), + sourceSha, + ...(tier !== undefined ? { tier } : {}), + }, + quotes: root.quotes.map((entry) => { + const quote = reqRec(entry, "evidence quote") + if (!Array.isArray(quote.ranges)) throw new RpcError(-32603, "the daemon returned no evidence quote") + return { + quote: str(quote.quote, "evidence quote"), + verified: quote.verified === true, + ranges: quote.ranges.map((range) => { + const span = reqRec(range, "evidence range") + if (typeof span.start !== "number" || typeof span.end !== "number") + throw new RpcError(-32603, "the daemon returned no evidence range") + return { start: span.start, end: span.end } + }), + } + }), + retractions: root.retractions.map((entry) => { + const retraction = reqRec(entry, "evidence retraction") + return { + source: str(retraction.source, "evidence retraction"), + event: str(retraction.event, "evidence retraction"), + at: str(retraction.at, "evidence retraction"), + note: typeof retraction.note === "string" ? retraction.note : "", + by: str(retraction.by, "evidence retraction"), + } + }), + events: root.events.map((entry) => { + const event = reqRec(entry, "evidence event") + return { + claim: str(event.claim, "evidence event"), + from: str(event.from, "evidence event"), + to: str(event.to, "evidence event"), + reason: str(event.reason, "evidence event"), + at: str(event.at, "evidence event"), + } + }), + rankExplanation: str(root.rankExplanation, "evidence claim"), + files: strArray(root.files, "evidence claim"), + } +} + +/** One source with capped text and seal status (#133). */ +export async function fetchEvidenceSource(runId: string, sha: string, base = ""): Promise { + const result = await rpc("evidence.source", { runId, sha }, base) + const root = reqRec(result, "evidence source") + const meta = reqRec(root.meta, "evidence source") + const title = optStr(meta.title) + const tier = optStr(meta.tier) + return { + meta: { + sha256: str(meta.sha256, "evidence source"), + url: str(meta.url, "evidence source"), + ...(title !== undefined ? { title } : {}), + retrieved: str(meta.retrieved, "evidence source"), + provider: str(meta.provider, "evidence source"), + bytes: typeof meta.bytes === "number" ? meta.bytes : 0, + ...(tier !== undefined ? { tier } : {}), + }, + seal: str(root.seal, "evidence source"), + text: str(root.text, "evidence source"), + truncated: root.truncated === true, + } +} + +/** Rendered dossier export through the #112 renderers (#133). */ +export async function exportEvidenceDossier( + runId: string, + format: "markdown" | "html", + base = "", +): Promise { + const result = await rpc<{ format?: unknown; body?: unknown; truncated?: unknown }>( + "evidence.export", + { runId, format }, + base, + ) + return { + format: typeof result.format === "string" ? result.format : format, + body: typeof result.body === "string" ? result.body : "", + truncated: result.truncated === true, + } +} diff --git a/packages/web/src/approvals.test.ts b/packages/web/src/approvals.test.ts new file mode 100644 index 0000000..744e7db --- /dev/null +++ b/packages/web/src/approvals.test.ts @@ -0,0 +1,154 @@ +// Browser approvals UI (#131, ADR 0002 option b): the approvals list and +// the approve page. The passphrase lives in one local variable between the +// input read and the submit — never in a signal, the store, logs or the +// DOM after submit (I1, probed below). The preview ticket and CSRF token +// stay in the page closure, never in shared state. +import { describe, expect, test } from "bun:test" +import { render } from "solid-js/web" +import type { ApprovePreview, ApproveResult } from "./api.ts" +import { RpcError } from "./api.ts" +import { ApprovalsPage, ApprovePage } from "./approvals.ts" +import { installDom } from "./test-dom.ts" + +const setup = () => void installDom() + +const preview: ApprovePreview = { + ticket: "ticket-hex", + csrf: "csrf-hex", + subjectHash: "ab".repeat(32), + summary: ["Idea: A greeting library", "Decisions settled: 1 (0 deferred)"], + files: [{ path: ".factory/frontier.json", sha256: "cd".repeat(32) }], + spendCeilingUSD: 25, + pending: true, +} + +describe("approvals page", () => { + test("it lists every pending pair with links to its approve page", () => { + setup() + render( + () => + ApprovalsPage({ + pending: () => [ + { taskId: "t1", reason: "frontier approval", stage: "frontier" }, + { taskId: "t2", reason: "spec approval", stage: "spec" }, + ], + error: () => undefined, + }) as unknown as Node, + document.body, + ) + expect(document.body.querySelector('a[href="#/approve/t1/frontier"]')).not.toBeNull() + expect(document.body.querySelector('a[href="#/approve/t2/spec"]')).not.toBeNull() + expect(document.body.textContent).toContain("frontier approval") + }) + + test("an empty list and errors render plainly", () => { + setup() + render(() => ApprovalsPage({ pending: () => [], error: () => undefined }) as unknown as Node, document.body) + expect(document.body.textContent).toMatch(/nothing waiting/i) + document.body.innerHTML = "" + render(() => ApprovalsPage({ pending: () => [], error: () => "boom" }) as unknown as Node, document.body) + expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("boom") + }) +}) + +describe("approve page", () => { + const show = ( + submit: (ticket: string, csrf: string, passphrase: string) => Promise, + load: () => Promise = async () => preview, + ) => { + render( + () => ApprovePage({ taskId: "t1", stage: "frontier", loadPreview: load, submit }) as unknown as Node, + document.body, + ) + } + + test("it previews the subject, its hash and files before asking for a passphrase", async () => { + setup() + show(async () => { + throw new Error("must not submit without a click") + }) + await Bun.sleep(0) + const text = document.body.textContent ?? "" + expect(text).toContain("greeting library") + expect(text).toContain(preview.subjectHash.slice(0, 16)) + expect(text).toContain(".factory/frontier.json") + const input = document.body.querySelector('input[type="password"]') + expect(input).not.toBeNull() + expect(input?.getAttribute("autocomplete")).toBe("off") + }) + + test("submit sends the passphrase once, then clears it and keeps no trace (I1)", async () => { + setup() + const bodies: Array<{ ticket: string; csrf: string; passphrase: string }> = [] + show(async (ticket, csrf, passphrase) => { + bodies.push({ ticket, csrf, passphrase }) + return { ok: true, stage: "frontier", alreadyApproved: false, factoryStage: "RESEARCH" } + }) + await Bun.sleep(0) + const input = document.body.querySelector('input[type="password"]') as HTMLInputElement | null + expect(input).not.toBeNull() + input!.value = "the-human-passphrase" + const button = [...document.body.querySelectorAll("button")].find((entry) => + entry.textContent?.includes("Approve"), + )! + button.click() + await Bun.sleep(0) + // Sent exactly once, over the submit call… + expect(bodies).toEqual([{ ticket: "ticket-hex", csrf: "csrf-hex", passphrase: "the-human-passphrase" }]) + // …then gone: the input is cleared and no DOM or page state holds it. + expect(input!.value).toBe("") + expect(document.body.innerHTML).not.toContain("the-human-passphrase") + expect(document.body.textContent).toContain("RESEARCH") + }) + + test("a wrong passphrase shows the remaining attempts without clearing the preview", async () => { + setup() + show(async () => { + throw new RpcError(403, "wrong passphrase", { remaining: 3 }) + }) + await Bun.sleep(0) + const input = document.body.querySelector('input[type="password"]') as HTMLInputElement | null + input!.value = "nope" + const button = [...document.body.querySelectorAll("button")].find((entry) => + entry.textContent?.includes("Approve"), + )! + button.click() + await Bun.sleep(0) + const alert = document.body.querySelector('[role="alert"]') + expect(alert?.textContent).toMatch(/3 attempts left/) + // The preview survives the failure: no re-preview round-trip needed. + expect(document.body.textContent).toContain("greeting library") + expect(input!.value).toBe("") + }) + + test("a lockout shows the retry delay", async () => { + setup() + show(async () => { + throw new RpcError(423, "too many wrong passphrases", { retryAfterSec: 420 }) + }) + await Bun.sleep(0) + const input = document.body.querySelector('input[type="password"]') as HTMLInputElement | null + input!.value = "nope" + const button = [...document.body.querySelectorAll("button")].find((entry) => + entry.textContent?.includes("Approve"), + )! + button.click() + await Bun.sleep(0) + expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/420/) + }) + + test("a missing preview renders the bus error, never a passphrase prompt", async () => { + setup() + show( + async () => { + throw new Error("unreachable") + }, + async () => { + throw new Error("unknown run") + }, + ) + await Bun.sleep(0) + expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("unknown run") + expect(document.body.querySelector('input[type="password"]')).toBeNull() + }) +}) diff --git a/packages/web/src/approvals.ts b/packages/web/src/approvals.ts new file mode 100644 index 0000000..3bf9e58 --- /dev/null +++ b/packages/web/src/approvals.ts @@ -0,0 +1,184 @@ +// Browser approvals UI (#131, ADR 0002 option b): the pending list and the +// approve page. Invariant I1 is structural here: the passphrase is read +// from the masked input into one local `const` at submit time, the input +// is cleared before the request leaves, and nothing — no signal, no +// store, no log line — ever holds it. The preview ticket and CSRF token +// live in the page closure for the same reason. +import { createSignal } from "solid-js" +import type { ApprovePreview, ApproveResult, ApproveStage } from "./api.ts" +import { RpcError } from "./api.ts" +import { type Child, h } from "./dom.ts" + +export interface PendingPair { + readonly taskId: string + readonly reason: string + readonly stage: ApproveStage +} + +export interface ApprovalsPageProps { + readonly pending: () => readonly PendingPair[] + readonly error: () => string | undefined +} + +export function ApprovalsPage(props: ApprovalsPageProps): Element { + return h( + "main", + null, + h("h1", null, "Approvals"), + () => (props.error() !== undefined ? h("p", { role: "alert" }, `The bus refused the call: ${props.error()}`) : ""), + () => { + const pairs = props.pending() + if (pairs.length === 0) return h("p", null, "Nothing waiting on you.") + return h( + "ul", + null, + ...pairs.map((pair) => + h( + "li", + null, + h("a", { href: `#/approve/${pair.taskId}/${pair.stage}` }, `${pair.taskId} — approve ${pair.stage}`), + ` — ${pair.reason}`, + ), + ), + ) + }, + ) +} + +export interface ApprovePageProps { + readonly taskId: string + readonly stage: ApproveStage + readonly loadPreview: () => Promise + readonly submit: (ticket: string, csrf: string, passphrase: string) => Promise +} + +const INPUT_ID = "approve-passphrase" + +const shortHash = (hash: string): string => `${hash.slice(0, 16)}…` + +const submitErrorEl = (failure: unknown): Element => { + const message = failure instanceof RpcError ? failure.message : String(failure) + const details = failure instanceof RpcError ? failure.details : {} + const extra: Child = + typeof details.retryAfterSec === "number" + ? ` Try again in ${details.retryAfterSec}s — the lockout is audited.` + : typeof details.remaining === "number" + ? ` ${details.remaining} attempts left before a lockout.` + : "" + return h("div", { role: "alert" }, h("p", null, `Not approved: ${message}`), extra === "" ? "" : h("p", null, extra)) +} + +export function ApprovePage(props: ApprovePageProps): Element { + const [preview, setPreview] = createSignal(undefined) + const [loadError, setLoadError] = createSignal(undefined) + const [result, setResult] = createSignal(undefined) + const [failure, setFailure] = createSignal(undefined) + const [busy, setBusy] = createSignal(false) + let ticket = "" + let csrf = "" + + void (async (): Promise => { + try { + const seen = await props.loadPreview() + ticket = seen.ticket + csrf = seen.csrf + setPreview(seen) + } catch (error) { + setLoadError(error instanceof Error ? error.message : String(error)) + } + })() + + const onApprove = async (): Promise => { + const input = document.getElementById(INPUT_ID) as HTMLInputElement | null + // I1: the passphrase lives in this local only; the input is cleared + // before anything awaits, and no signal ever holds it. + const passphrase = input?.value ?? "" + if (input) input.value = "" + setBusy(true) + setFailure(undefined) + try { + setResult(await props.submit(ticket, csrf, passphrase)) + } catch (error) { + setFailure(error) + } finally { + setBusy(false) + } + } + + return h( + "main", + null, + h("p", null, h("a", { href: "#/approvals" }, "← Approvals")), + h("h1", null, `Approve ${props.stage} on ${props.taskId}`), + () => { + const problem = loadError() + if (problem !== undefined) return h("p", { role: "alert" }, problem) + const seen = preview() + if (!seen) return h("p", null, "Loading the preview…") + return h( + "div", + null, + h("ul", null, ...seen.summary.map((line) => h("li", null, line))), + h( + "table", + null, + h("thead", null, h("tr", null, h("th", null, "File"), h("th", null, "SHA-256"))), + h( + "tbody", + null, + ...seen.files.map((file) => + h("tr", null, h("td", null, file.path), h("td", null, h("code", null, shortHash(file.sha256)))), + ), + ), + ), + h( + "p", + null, + `Subject hash ${shortHash(seen.subjectHash)}.`, + seen.pending + ? "" + : " Not currently pending — approving still records, but check why it is not pending first.", + ), + h( + "form", + { + onsubmit: (event: Event) => { + event.preventDefault() + void onApprove() + }, + }, + h( + "p", + null, + h("label", { for: INPUT_ID }, "Passphrase "), + h("input", { id: INPUT_ID, type: "password", autocomplete: "off", "aria-label": "Passphrase" }), + ), + h( + "p", + null, + h("button", { type: "button", onclick: () => void onApprove() }, () => (busy() ? "Approving…" : "Approve")), + ), + ), + ) + }, + () => { + const done = result() + if (!done) return "" + return h( + "section", + null, + h("h2", null, "Approved"), + h( + "p", + null, + `${done.stage} approved in the browser.${done.alreadyApproved ? " (The record already existed and was re-used.)" : ""}`, + ), + h("p", null, done.factoryStage ? `Factory stage now ${done.factoryStage}.` : ""), + ) + }, + () => { + const problem = failure() + return problem === undefined ? "" : submitErrorEl(problem) + }, + ) +} diff --git a/packages/web/src/bus.ts b/packages/web/src/bus.ts new file mode 100644 index 0000000..eded998 --- /dev/null +++ b/packages/web/src/bus.ts @@ -0,0 +1,93 @@ +// Bus event client (#130, clean-room): a WebSocket over `/fleet/ws` that +// replays the backlog on connect, delivers live `changed`/`liveness` +// events, and reconnects with backoff (refetching on every reconnect). +// In the browser the session cookie authenticates the handshake; tests pass +// a Bearer header through `openSocket` (browsers cannot set headers). +import type { BusEvent } from "@heretek-ai/es-fleet" + +export type BusChannel = "changed" | "liveness" + +/** Structural socket: the browser WebSocket and the test fakes alike. */ +export interface BusSocket { + send(text: string): void + close(): void + set onopen(fn: ((event: unknown) => void) | undefined) + set onmessage(fn: ((event: unknown) => void) | undefined) + set onclose(fn: ((event: unknown) => void) | undefined) + set onerror(fn: ((event: unknown) => void) | undefined) +} + +export interface BusClientOptions { + readonly url: string + readonly channels?: readonly BusChannel[] + readonly since?: () => number + readonly onEvent: (event: BusEvent) => void + /** Called on every (re)connect: the caller refetches the snapshot. */ + readonly onConnect?: () => void + readonly backoffMs?: (attempt: number) => number + readonly openSocket?: (url: string) => BusSocket +} + +const defaultBackoff = (attempt: number): number => Math.min(30_000, 1_000 * 2 ** attempt) + +const asEvent = (raw: unknown): BusEvent | undefined => { + if (typeof raw !== "object" || raw === null) return undefined + const event = raw as Record + if (!Number.isInteger(event.seq) || (event.seq as number) <= 0) return undefined + if (typeof event.at !== "string") return undefined + if (event.type !== "changed" && event.type !== "liveness") return undefined + if (typeof event.payload !== "object" || event.payload === null) return undefined + return event as unknown as BusEvent +} + +/** + * Open the stream. Returns a disconnect function (idempotent): it closes + * the live socket and cancels any pending reconnect. + */ +export function connectBus(options: BusClientOptions): () => void { + const channels = options.channels ?? (["changed", "liveness"] as const) + const since = options.since ?? (() => 0) + const backoff = options.backoffMs ?? defaultBackoff + const open = options.openSocket ?? ((url) => new WebSocket(url) as unknown as BusSocket) + let stopped = false + let socket: BusSocket | undefined + let timer: ReturnType | undefined + let attempt = 0 + + const connect = (): void => { + if (stopped) return + const current = open(options.url) + socket = current + current.onopen = () => { + attempt = 0 + current.send(JSON.stringify({ subscribe: [...channels], since: since() })) + options.onConnect?.() + } + current.onmessage = (event) => { + const data = (event as { data?: unknown }).data + if (typeof data !== "string") return + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return // hostile or corrupt frames never reach the store + } + const busEvent = asEvent(parsed) + if (busEvent) options.onEvent(busEvent) + } + const schedule = () => { + if (stopped) return + const wait = backoff(attempt) + attempt += 1 + timer = setTimeout(connect, wait) + } + current.onclose = schedule + current.onerror = schedule + } + connect() + return () => { + stopped = true + if (timer !== undefined) clearTimeout(timer) + socket?.close() + } +} diff --git a/packages/web/src/components.test.ts b/packages/web/src/components.test.ts new file mode 100644 index 0000000..40d6200 --- /dev/null +++ b/packages/web/src/components.test.ts @@ -0,0 +1,178 @@ +// Dashboard components (#130): overview and task pages over fixture data. +// Rendered with the client Solid build under happy-dom; assertions read the +// live DOM, including one reactive update (the WS re-render path) and one +// hostile-content probe (model output is never raw HTML). +import { describe, expect, test } from "bun:test" +import type { FleetSnapshot, TaskLiveness } from "@heretek-ai/es-fleet" +import { createSignal } from "solid-js" +import { render } from "solid-js/web" +import { Overview, TaskDetail } from "./components.ts" +import { installDom } from "./test-dom.ts" + +const setup = () => void installDom() + +type Task = FleetSnapshot["tasks"][number] + +const task = (id: string, status: string, extra: Partial = {}): Task => ({ + id, + title: `Task ${id}`, + status, + ceilingUSD: 5, + spendUsd: 1, + deps: [], + ...extra, +}) + +const snapshot = (extra: Partial = {}): FleetSnapshot => ({ + daemon: { running: true, pid: 7, maxUsd: 50, concurrency: 2 }, + tasks: [ + task("a", "running", { spendUsd: 1.5, reason: "building the UI" }), + task("b", "waiting-human", { spendUsd: 0.25, deps: ["a"], reason: "frontier approval" }), + ], + spendUsd: 1.75, + pending: [{ taskId: "b", reason: "frontier approval", stage: "frontier" }], + ...extra, +}) + +const show = (node: Element): void => { + render(() => node, document.body) +} + +describe("overview", () => { + test("it renders the headline, dag, table, spend and pending list", () => { + setup() + const snap = snapshot() + show(Overview({ snapshot: () => snap, stale: () => false, error: () => undefined })) + const text = document.body.textContent ?? "" + expect(text).toContain("Task a") + expect(text).toContain("working") + expect(text).toContain("waiting on you") + expect(text).toContain("$1.75 / $50") + expect(text).toContain("frontier approval") + // The DAG draws both nodes and the a→b edge. + expect(document.body.querySelectorAll("svg .dag-node")).toHaveLength(2) + expect(document.body.querySelectorAll("svg line.dag-edge")).toHaveLength(1) + // Keyboard path: every task is a link to its detail page. + expect(document.body.querySelector('a[href="#/task/a"]')).not.toBeNull() + expect(document.body.querySelector('a[href="#/task/b"]')).not.toBeNull() + }) + + test("it renders every status in words", () => { + setup() + const statuses = ["pending", "ready", "running", "waiting-human", "done", "failed", "cancelled"] + const snap = snapshot({ tasks: statuses.map((status, index) => task(`t${index}`, status)) }) + show(Overview({ snapshot: () => snap, stale: () => false, error: () => undefined })) + const text = document.body.textContent ?? "" + for (const word of ["queued", "ready", "working", "waiting on you", "done", "failed", "cancelled"]) + expect(text).toContain(word) + }) + + test("an empty fleet, the stale banner and errors all render", () => { + setup() + const empty = snapshot({ tasks: [], spendUsd: 0, pending: [] }) + show(Overview({ snapshot: () => empty, stale: () => false, error: () => undefined })) + expect(document.body.textContent).toContain("No tasks yet") + expect(document.body.querySelector("svg")).toBeNull() + + document.body.innerHTML = "" + show(Overview({ snapshot: () => empty, stale: () => true, error: () => undefined })) + const banner = document.body.querySelector('[role="status"]') + expect(banner?.textContent).toMatch(/stale/i) + + document.body.innerHTML = "" + show(Overview({ snapshot: () => undefined, stale: () => false, error: () => "boom" })) + expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("boom") + }) + + test("a snapshot update re-renders the right node (the WS path)", async () => { + setup() + const [snap, setSnap] = createSignal(snapshot()) + show(Overview({ snapshot: () => snap(), stale: () => false, error: () => undefined })) + expect(document.body.textContent).toContain("working") + setSnap(snapshot({ tasks: [task("a", "done", { spendUsd: 2 }), task("b", "running", { deps: ["a"] })] })) + await Bun.sleep(0) + expect(document.body.textContent).toContain("done") + expect(document.body.querySelectorAll("svg .dag-node")).toHaveLength(2) + }) + + test("hostile task text renders as text, never as markup", () => { + setup() + const evil = task("e", "running", { + title: "", + reason: "", + }) + show( + Overview({ + snapshot: () => snapshot({ tasks: [evil] }), + stale: () => false, + error: () => undefined, + }), + ) + expect(document.body.querySelector("script")).toBeNull() + expect(document.body.querySelectorAll("img")).toHaveLength(0) + expect(document.body.textContent).toContain("") + }) +}) + +describe("task detail", () => { + const events = [ + { seq: 1, at: "2026-10-09T12:00:00.000Z", type: "changed", payload: { taskId: "a", kind: "started" } }, + { seq: 2, at: "2026-10-09T12:01:00.000Z", type: "changed", payload: { taskId: "b", kind: "report" } }, + ] as const + + test("it shows the task, its spend, deps and only its own events", () => { + setup() + const found = task("a", "running", { spendUsd: 1.5, reason: "building the UI" }) + show(TaskDetail({ task: () => found, events: () => events })) + const text = document.body.textContent ?? "" + expect(text).toContain("Task a") + expect(text).toContain("working") + expect(text).toContain("$1.50 of $5.00") + expect(text).toContain("building the UI") + expect(text).toContain("started") + expect(text).not.toContain("report") + expect(document.body.querySelector('a[href="#/"]')).not.toBeNull() + }) + + test("an unknown task renders plainly", () => { + setup() + show(TaskDetail({ task: () => undefined, events: () => [] })) + expect(document.body.textContent).toMatch(/unknown task/i) + }) + + test("a pending stage links to its approve page", () => { + setup() + const found = task("a", "waiting-human", { reason: "frontier approval" }) + show(TaskDetail({ task: () => found, events: () => [], pendingStages: () => ["frontier"] })) + expect(document.body.querySelector('a[href="#/approve/a/frontier"]')).not.toBeNull() + document.body.innerHTML = "" + show(TaskDetail({ task: () => found, events: () => [] })) + expect(document.body.querySelector('a[href^="#/approve/"]')).toBeNull() + }) + + test("the task links to its evidence page", () => { + setup() + const found = task("a", "running", {}) + show(TaskDetail({ task: () => found, events: () => [] })) + expect(document.body.querySelector('a[href="#/evidence/a"]')).not.toBeNull() + }) + + test("it renders run liveness: headline, stage, seats and research", () => { + setup() + const found = task("a", "running", {}) + const liveness: TaskLiveness = { + taskId: "a", + stage: "SPEC", + headline: "programmer is editing the UI", + seats: ["manager: waiting on programmer", "programmer: editing since 12:00"], + research: "alpha 3/5 sources, beta 2/5", + } + show(TaskDetail({ task: () => found, events: () => [], liveness: () => liveness })) + const text = document.body.textContent ?? "" + expect(text).toContain("programmer is editing the UI") + expect(text).toContain("SPEC") + expect(text).toContain("manager: waiting on programmer") + expect(text).toContain("programmer: editing since 12:00") + expect(text).toContain("alpha 3/5 sources, beta 2/5") + }) +}) diff --git a/packages/web/src/components.ts b/packages/web/src/components.ts new file mode 100644 index 0000000..cc58fdb --- /dev/null +++ b/packages/web/src/components.ts @@ -0,0 +1,225 @@ +// Dashboard components (#130, clean-room): fleet overview (DAG + table + +// spend + pending) and task detail, over the read-only bus snapshot. Built +// with the local DOM builders (`dom.ts`, no JSX transform, no runtime +// eval): every dynamic region is an `insert` thunk over accessor props, so +// WS updates re-render without a router or store subscription. Status is +// always words beside a tone class, and every task is a link (the keyboard +// path). Interpolated data goes through text nodes, never HTML parsing +// (probed in components.test.ts): model output is never raw markup. +import type { BusEvent, FleetSnapshot, TaskLiveness } from "@heretek-ai/es-fleet" +import type { ApproveStage } from "./api.ts" +import { layoutDag, statusWord } from "./dag.ts" +import { type Child, h, svgEl } from "./dom.ts" + +type Task = FleetSnapshot["tasks"][number] + +const money = (value: number): string => `$${value.toFixed(2)}` + +const eventKind = (event: BusEvent): string => { + const kind = (event.payload as Record).kind + return typeof kind === "string" ? kind : event.type +} + +const NODE_W = 160 +const NODE_H = 48 + +const dagEl = (tasks: readonly Task[]): Element => { + const dag = layoutDag(tasks) + const byId = new Map(dag.nodes.map((node) => [node.id, node])) + return svgEl( + "svg", + { width: dag.width, height: dag.height, role: "img", "aria-label": "Task dependency graph" }, + ...dag.edges.map((edge) => { + const from = byId.get(edge.from)! + const to = byId.get(edge.to)! + return svgEl("line", { + class: "dag-edge", + x1: from.x + NODE_W, + y1: from.y + NODE_H / 2, + x2: to.x, + y2: to.y + NODE_H / 2, + stroke: "#94a3b8", + "stroke-width": 2, + }) + }), + ...dag.nodes.map((node) => { + const word = statusWord(node.status) + return svgEl( + "g", + { class: "dag-node", transform: `translate(${node.x},${node.y})` }, + svgEl("title", null, `${node.id} — ${node.title}: ${word.word}`), + svgEl("rect", { + width: NODE_W, + height: NODE_H, + rx: 8, + fill: word.colour, + "fill-opacity": 0.18, + stroke: word.colour, + "stroke-width": 2, + }), + svgEl("text", { x: 10, y: 20, "font-size": 13, "font-weight": "bold" }, `${node.id} — ${node.title}`), + svgEl("text", { x: 10, y: 38, "font-size": 12 }, word.word), + ) + }), + ) as unknown as Element +} + +const taskRow = (entry: Task): Element => { + const word = statusWord(entry.status) + return h( + "tr", + null, + h("td", null, h("a", { href: `#/task/${entry.id}` }, `${entry.id} — ${entry.title}`)), + h("td", null, h("span", { class: `tone-${word.tone}`, "aria-hidden": "true" }, "●"), ` ${word.word}`), + h("td", null, `${money(entry.spendUsd)} of ${money(entry.ceilingUSD)}`), + h("td", null, entry.reason ?? ""), + ) +} + +const tasksEl = (snap: FleetSnapshot): Element => { + if (snap.tasks.length === 0) return h("p", null, "No tasks yet.") + return h( + "div", + null, + h("h2", null, "Tasks"), + dagEl(snap.tasks), + h( + "table", + null, + h( + "thead", + null, + h("tr", null, h("th", null, "Task"), h("th", null, "Status"), h("th", null, "Spend"), h("th", null, "Detail")), + ), + h("tbody", null, ...snap.tasks.map(taskRow)), + ), + ) +} + +const pendingEl = (snap: FleetSnapshot): Child => { + if (snap.pending.length === 0) return "" + return h( + "section", + null, + h("h2", null, "Waiting on you"), + h( + "ul", + null, + ...snap.pending.map((item) => + h("li", null, h("a", { href: `#/task/${item.taskId}` }, item.taskId), ` — ${item.reason}`), + ), + ), + ) +} + +const sectionEl = (snap: FleetSnapshot): Element => + h( + "section", + null, + h( + "p", + null, + snap.daemon.running + ? `Fleet running · spend ${money(snap.spendUsd)} / $${snap.daemon.maxUsd} · ${snap.tasks.length} task(s)` + : "Fleet stopped.", + ), + tasksEl(snap), + pendingEl(snap), + ) + +export interface OverviewProps { + readonly snapshot: () => FleetSnapshot | undefined + readonly stale: () => boolean + readonly error: () => string | undefined +} + +export function Overview(props: OverviewProps): Element { + return h( + "main", + null, + h("h1", null, "Epistemic Swarm"), + () => (props.error() !== undefined ? h("p", { role: "alert" }, `The bus refused the call: ${props.error()}`) : ""), + () => (props.stale() ? h("p", { role: "status" }, "Data may be stale — reconnecting…") : ""), + () => { + const snap = props.snapshot() + return snap ? sectionEl(snap) : h("p", null, "Asking the daemon…") + }, + ) +} + +export interface TaskDetailProps { + readonly task: () => Task | undefined + readonly events: () => readonly BusEvent[] + /** Stages of this task currently waiting on a human (#131 links). */ + readonly pendingStages?: () => readonly ApproveStage[] + /** Per-task run liveness from `fleet.task.liveness` (#130, S5 handoff). */ + readonly liveness?: () => TaskLiveness | undefined +} + +const livenessEl = (live: TaskLiveness | undefined): Child => + h( + "section", + null, + h("h2", null, "Run liveness"), + live === undefined + ? h("p", null, "No liveness yet.") + : h( + "div", + null, + h("p", null, live.headline), + h("p", null, `Stage ${live.stage}`), + live.seats.length > 0 + ? h("ul", null, ...live.seats.map((seat) => h("li", null, seat))) + : h("p", null, "No seats reporting yet."), + live.research !== undefined ? h("p", null, live.research) : "", + ), + ) + +export function TaskDetail(props: TaskDetailProps): Element { + return h( + "main", + null, + h("p", null, h("a", { href: "#/" }, "← Fleet"), () => { + const found = props.task() + return found ? h("span", null, " · ", h("a", { href: `#/evidence/${found.id}` }, "Evidence")) : "" + }), + () => { + const found = props.task() + if (!found) return h("p", null, "Unknown task.") + const word = statusWord(found.status) + const mine = props.events().filter((event) => (event.payload as Record).taskId === found.id) + return h( + "article", + null, + h("h1", null, `${found.id} — ${found.title}`), + h("p", null, h("span", { class: `tone-${word.tone}`, "aria-hidden": "true" }, "●"), ` ${word.word}`), + h("p", null, `${money(found.spendUsd)} of ${money(found.ceilingUSD)}`), + found.reason !== undefined ? h("p", null, found.reason) : "", + ...(props.pendingStages?.() ?? []).map((stage) => + h("p", null, h("a", { href: `#/approve/${found.id}/${stage}` }, `Approve ${stage} in the browser`)), + ), + livenessEl(props.liveness?.()), + found.deps.length > 0 + ? h( + "section", + null, + h("h2", null, "Waits for"), + h("ul", null, ...found.deps.map((dep) => h("li", null, h("a", { href: `#/task/${dep}` }, dep)))), + ) + : "", + h( + "section", + null, + h("h2", null, "Recent events"), + mine.length > 0 + ? h( + "ol", + null, + ...mine.slice(-10).map((entry) => h("li", null, `#${entry.seq} ${eventKind(entry)} · ${entry.at}`)), + ) + : h("p", null, "No events yet."), + ), + ) + }, + ) +} diff --git a/packages/web/src/config.test.ts b/packages/web/src/config.test.ts new file mode 100644 index 0000000..7f3c095 --- /dev/null +++ b/packages/web/src/config.test.ts @@ -0,0 +1,165 @@ +// Config editor (#132, preview-only mode): strict-schema validation with +// exact hash diffs and terminal commands, and no apply path anywhere. The +// preview function is injected, so these tests run without a server; the +// preview-only audit (no mutation endpoint on the bus) lives in +// configview.test.ts. +import { describe, expect, test } from "bun:test" +import { render } from "solid-js/web" +import type { ConfigPlan, ConfigView } from "./api.ts" +import { ConfigEditor } from "./config.ts" +import { installDom } from "./test-dom.ts" + +const setup = () => void installDom() + +const view: ConfigView = { + config: { licenseWhitelist: ["MIT"] }, + configHash: "oldhash-config-00000000000000000000000000000001", + gates: { topN: 5 }, + gatesHash: "oldhash-gates-00000000000000000000000000000002", + drift: { clean: true, violations: [], hasBaseline: false }, + forbidden: ["embeddings", "estimate"], +} + +const show = (preview: (file: "config" | "gates", content: unknown) => Promise) => { + render(() => ConfigEditor({ view: () => view, error: () => undefined, preview }) as unknown as Node, document.body) +} + +const clickPreview = async (): Promise => { + const buttons = [...document.body.querySelectorAll("button")].filter((button) => + button.textContent?.includes("Preview"), + ) + expect(buttons).toHaveLength(1) + buttons[0]!.click() + await Bun.sleep(0) +} + +describe("config editor", () => { + test("it shows the current layers, hashes and the terminal-only note", async () => { + setup() + show(async () => { + throw new Error("must not preview without a click") + }) + const text = document.body.textContent ?? "" + expect(text).toContain("MIT") + expect(text).toContain("oldhash-config") + expect(text).toMatch(/terminal/i) + // The other tab previews the other file. + const tabs = [...document.body.querySelectorAll("button")].filter((button) => + ["Project config", "Gates"].includes(button.textContent ?? ""), + ) + tabs[1]!.click() + await Bun.sleep(0) + expect(document.body.textContent).toContain("topN") + // Preview-only: there is no apply button, only preview and copy. + const labels = [...document.body.querySelectorAll("button")].map((button) => button.textContent ?? "") + expect(labels.some((label) => /apply|submit|save/i.test(label))).toBe(false) + }) + + test("a valid preview shows the exact hash diff, changed keys and commands", async () => { + setup() + show(async (_file, _content) => ({ + ok: true, + errors: [], + oldHash: view.configHash, + newHash: "newhash-config-00000000000000000000000000000009", + changedKeys: ["licenseWhitelist"], + commands: ['es config set licenseWhitelist ["MIT","Apache-2.0"]'], + })) + await clickPreview() + const text = document.body.textContent ?? "" + expect(text).toContain("oldhash-config") + expect(text).toContain("newhash-config") + expect(text).toContain("licenseWhitelist") + expect(text).toContain('es config set licenseWhitelist ["MIT","Apache-2.0"]') + expect(document.body.querySelector("button.copy")).not.toBeNull() + }) + + test("schema errors render inline with no commands", async () => { + setup() + show(async () => ({ + ok: false, + errors: ["noSuchKey: Unrecognized key"], + oldHash: view.configHash, + newHash: null, + changedKeys: [], + commands: [], + })) + await clickPreview() + expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("noSuchKey") + expect(document.body.textContent).not.toContain("es config set") + }) + + test("drift violations render a rebaseline warning", () => { + setup() + const dirty: ConfigView = { + ...view, + drift: { + clean: false, + violations: [".factory/config.json changed (expected abc, found def)"], + hasBaseline: true, + }, + } + render( + () => + ConfigEditor({ + view: () => dirty, + error: () => undefined, + preview: async () => { + throw new Error("unreachable") + }, + }) as unknown as Node, + document.body, + ) + const text = document.body.textContent ?? "" + expect(text).toMatch(/drift/i) + expect(text).toContain("es rebaseline") + }) + + test("switching tabs previews the other file", async () => { + setup() + const seen: string[] = [] + show(async (file, _content) => { + seen.push(file) + return { ok: true, errors: [], oldHash: "a", newHash: "a", changedKeys: [], commands: [] } + }) + const tabs = [...document.body.querySelectorAll("button")].filter((button) => + ["Project config", "Gates"].includes(button.textContent ?? ""), + ) + expect(tabs).toHaveLength(2) + tabs[1]!.click() + await clickPreview() + expect(seen).toEqual(["gates"]) + }) + + test("the edited text, not the displayed layer, is what gets previewed", async () => { + setup() + let received: unknown + show(async (_file, content) => { + received = content + return { ok: true, errors: [], oldHash: "a", newHash: "b", changedKeys: ["licenseWhitelist"], commands: [] } + }) + const area = document.body.querySelector("textarea") + expect(area).not.toBeNull() + // Preview reads the live DOM value (no input handler to drive). + area!.value = JSON.stringify({ licenseWhitelist: ["MIT", "ISC"] }) + await clickPreview() + expect(received).toEqual({ licenseWhitelist: ["MIT", "ISC"] }) + }) +}) + +describe("config editor input handling", () => { + test("unparseable JSON reports a local error without calling preview", async () => { + setup() + let calls = 0 + show(async () => { + calls += 1 + return { ok: true, errors: [], oldHash: "a", newHash: "a", changedKeys: [], commands: [] } + }) + const area = document.body.querySelector("textarea") + expect(area).not.toBeNull() + area!.value = "{ not json" + await clickPreview() + expect(calls).toBe(0) + expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/json/i) + }) +}) diff --git a/packages/web/src/config.ts b/packages/web/src/config.ts new file mode 100644 index 0000000..1dde0dc --- /dev/null +++ b/packages/web/src/config.ts @@ -0,0 +1,166 @@ +// Config editor (#132, preview-only mode): strict-schema validation with +// exact hash diffs and the terminal commands that apply them. There is no +// apply path: no button, no RPC beyond `fleet.config.get`/`fleet.config.plan` +// (the bus refuses `fleet.config.apply`), and applying stays terminal-only +// per ADR 0002 I7 until the user explicitly extends it (after #131). The +// textarea is uncontrolled — Preview reads the live DOM value, so +// in-progress edits are never clobbered by background refetches; switching +// tabs rebuilds the pane from the last fetched view. +import { createSignal } from "solid-js" +import type { ConfigPlan, ConfigView } from "./api.ts" +import { type Child, h } from "./dom.ts" + +export interface ConfigEditorProps { + readonly view: () => ConfigView | undefined + readonly error: () => string | undefined + readonly preview: (file: "config" | "gates", content: unknown) => Promise +} + +type Pane = "config" | "gates" + +const AREA_ID = "config-editor-text" + +const pretty = (value: Record | null): string => + value === null ? "{}" : `${JSON.stringify(value, null, 2)}\n` + +const shortHash = (hash: string | null): string => (hash === null ? "(absent)" : `${hash.slice(0, 16)}…`) + +const copyCommand = async (command: string): Promise => { + try { + await navigator.clipboard?.writeText(command) + } catch { + // Clipboard needs a secure context and a gesture: the command text + // stays visible for manual copy when it is unavailable. + } +} + +const planEl = (pane: Pane, plan: ConfigPlan): Element => { + if (!plan.ok) + return h( + "div", + { role: "alert" }, + h("p", null, "Invalid — fix the errors and preview again:"), + h("ul", null, ...plan.errors.map((issue) => h("li", null, issue))), + ) + const hashLine = + plan.changedKeys.length === 0 + ? h("p", null, `No changes (hash ${shortHash(plan.newHash)}).`) + : h( + "div", + null, + h( + "p", + null, + `Hash ${shortHash(plan.oldHash)} → ${shortHash(plan.newHash)}, changed keys: ${plan.changedKeys.join(", ")}.`, + ), + h("p", null, "Run these in a terminal to apply:"), + h( + "ul", + null, + ...plan.commands.map((command) => + h( + "li", + null, + h("code", null, command), + " ", + h("button", { class: "copy", type: "button", onclick: () => void copyCommand(command) }, "Copy"), + ), + ), + ), + ) + void pane + return hashLine +} + +export function ConfigEditor(props: ConfigEditorProps): Element { + const [pane, setPane] = createSignal("config") + const [plan, setPlan] = createSignal(undefined) + const [localError, setLocalError] = createSignal(undefined) + const [busy, setBusy] = createSignal(false) + + const onPreview = async (): Promise => { + const area = document.getElementById(AREA_ID) as HTMLTextAreaElement | null + let content: unknown + try { + content = JSON.parse(area?.value ?? "null") + } catch { + setPlan(undefined) + setLocalError("The edited text is not valid JSON.") + return + } + setBusy(true) + setLocalError(undefined) + try { + setPlan(await props.preview(pane(), content)) + } catch (failure) { + setPlan(undefined) + setLocalError(failure instanceof Error ? failure.message : String(failure)) + } finally { + setBusy(false) + } + } + + const tab = (name: Pane, label: string): Element => + h( + "button", + { + type: "button", + "aria-pressed": String(pane() === name), + onclick: () => { + setPane(name) + setPlan(undefined) + setLocalError(undefined) + }, + }, + label, + ) + + return h( + "main", + null, + h("h1", null, "Config"), + h( + "p", + null, + "Preview-only: edit, validate and diff here; applying happens in a terminal. ", + "Browser apply needs your explicit ADR 0002 extension (after #131).", + ), + () => (props.error() !== undefined ? h("p", { role: "alert" }, `The bus refused the call: ${props.error()}`) : ""), + () => { + const seen = props.view() + if (!seen) return h("p", null, "Loading…") + const current = pane() === "config" ? seen.config : seen.gates + const drift: Child = seen.drift.clean + ? "" + : h( + "section", + null, + h("h2", null, "Drift warning"), + h("p", null, "Control files changed outside a human action — run es rebaseline after applying:"), + h("ul", null, ...seen.drift.violations.map((violation) => h("li", null, violation))), + ) + return h( + "div", + null, + h("div", { role: "tablist" }, tab("config", "Project config"), tab("gates", "Gates")), + drift, + h("h2", null, pane() === "config" ? "Project config (.factory/config.json)" : "Gates (.factory/gates.json)"), + h("p", null, `Current hash ${shortHash(pane() === "config" ? seen.configHash : seen.gatesHash)}.`), + h("pre", null, pretty(current)), + h("p", null, "Edit below, then preview the exact diff and commands:"), + h("textarea", { id: AREA_ID, rows: "14", cols: "72" }, pretty(current)), + h( + "p", + null, + h("button", { type: "button", onclick: () => void onPreview() }, () => (busy() ? "Previewing…" : "Preview")), + ), + () => { + const failure = localError() + if (failure !== undefined) return h("p", { role: "alert" }, failure) + const done = plan() + return done ? planEl(pane(), done) : "" + }, + ) + }, + ) +} diff --git a/packages/web/src/dag.test.ts b/packages/web/src/dag.test.ts new file mode 100644 index 0000000..792616c --- /dev/null +++ b/packages/web/src/dag.test.ts @@ -0,0 +1,93 @@ +// Dashboard DAG logic (#130): status vocabulary, columns-by-depth layout, +// edges from deps. Pure: no Solid, no DOM. +import { describe, expect, test } from "bun:test" +import type { FleetSnapshot } from "@heretek-ai/es-fleet" +import { layoutDag, statusWord } from "./dag.ts" + +const task = ( + id: string, + status: string, + deps: string[] = [], + extra: Record = {}, +): FleetSnapshot["tasks"][number] => ({ + id, + title: `Task ${id}`, + status, + ceilingUSD: 5, + spendUsd: 0, + deps, + ...extra, +}) + +describe("status vocabulary (1.3: working, waiting, stuck or done)", () => { + test("every bus status maps to a human word (text, never colour-only)", () => { + expect(statusWord("running").word).toBe("working") + expect(statusWord("waiting-human").word).toBe("waiting on you") + expect(statusWord("pending").word).toBe("queued") + expect(statusWord("ready").word).toBe("ready") + expect(statusWord("done").word).toBe("done") + expect(statusWord("failed").word).toBe("failed") + expect(statusWord("cancelled").word).toBe("cancelled") + for (const status of ["pending", "ready", "running", "waiting-human", "done", "failed", "cancelled"]) + expect(statusWord(status).word.length).toBeGreaterThan(0) + }) + + test("unknown statuses render as their own name (fail-open display)", () => { + expect(statusWord("mystery").word).toBe("mystery") + }) + + test("every status has a colour distinct from plain text", () => { + const colours = new Set( + ["pending", "ready", "running", "waiting-human", "done", "failed", "cancelled"].map( + (status) => statusWord(status).colour, + ), + ) + expect(colours.size).toBeGreaterThan(3) + }) +}) + +describe("dag layout", () => { + test("roots sit left, dependents right, edges follow deps", () => { + const { nodes, edges } = layoutDag([task("a", "done"), task("b", "running", ["a"]), task("c", "queued", ["b"])]) + const byId = Object.fromEntries(nodes.map((node) => [node.id, node])) + expect(byId.a!.depth).toBe(0) + expect(byId.b!.depth).toBe(1) + expect(byId.c!.depth).toBe(2) + expect(byId.a!.x).toBeLessThan(byId.b!.x) + expect(edges).toContainEqual({ from: "a", to: "b" }) + expect(edges).toContainEqual({ from: "b", to: "c" }) + expect(edges).toHaveLength(2) + }) + + test("diamond deps take the longest path (no node left of its deps)", () => { + const { nodes } = layoutDag([ + task("a", "done"), + task("b", "done", ["a"]), + task("c", "done", ["a"]), + task("d", "running", ["b", "c"]), + ]) + const depth = Object.fromEntries(nodes.map((node) => [node.id, node.depth])) + expect(depth.d).toBe(2) + expect( + nodes + .filter((node) => node.depth === 1) + .map((node) => node.id) + .sort(), + ).toEqual(["b", "c"]) + }) + + test("dangling deps and cycles never hang the layout", () => { + const { nodes, edges } = layoutDag([ + task("a", "running", ["ghost"]), + task("b", "running", ["c"]), + task("c", "running", ["b"]), + ]) + expect(nodes).toHaveLength(3) + // The ghost dep is not a node, so it draws no edge. + expect(edges.every((edge) => edge.from !== "ghost")).toBe(true) + }) + + test("an empty fleet lays out to nothing", () => { + expect(layoutDag([])).toEqual({ nodes: [], edges: [], width: 0, height: 0 }) + }) +}) diff --git a/packages/web/src/dag.ts b/packages/web/src/dag.ts new file mode 100644 index 0000000..d5c7645 --- /dev/null +++ b/packages/web/src/dag.ts @@ -0,0 +1,95 @@ +// Dashboard DAG logic (#130, clean-room): the 1.3 status vocabulary in +// words (status is always text, never colour-only) and a columns-by-depth +// node-link layout over the bus snapshot's task deps. Pure: no Solid, no +// DOM, so the layout is unit-tested without a browser. +import type { FleetSnapshot } from "@heretek-ai/es-fleet" + +export type Tone = "work" | "wait" | "queue" | "done" | "bad" | "idle" + +export interface StatusWord { + readonly word: string + readonly tone: Tone + /** Fill colour for the DAG node; the word is always rendered beside it. */ + readonly colour: string +} + +const WORDS: Record = { + running: { word: "working", tone: "work", colour: "#2f9e6e" }, + "waiting-human": { word: "waiting on you", tone: "wait", colour: "#c07c14" }, + pending: { word: "queued", tone: "queue", colour: "#64748b" }, + ready: { word: "ready", tone: "queue", colour: "#64748b" }, + done: { word: "done", tone: "done", colour: "#3d7a8c" }, + failed: { word: "failed", tone: "bad", colour: "#c0392b" }, + cancelled: { word: "cancelled", tone: "idle", colour: "#8a8a8a" }, +} + +/** Human words for a bus status; unknown statuses render as their own name. */ +export function statusWord(status: string): StatusWord { + return WORDS[status] ?? { word: status, tone: "idle", colour: "#8a8a8a" } +} + +export interface DagNode { + readonly id: string + readonly title: string + readonly status: string + readonly depth: number + readonly x: number + readonly y: number +} + +export interface DagEdge { + readonly from: string + readonly to: string +} + +export interface DagLayout { + readonly nodes: readonly DagNode[] + readonly edges: readonly DagEdge[] + readonly width: number + readonly height: number +} + +const DX = 200 +const DY = 72 +const PAD = 16 + +/** + * Columns by depth: a task sits right of its deepest dep (longest path). + * Dangling deps are ignored and dependency cycles are cut, so hostile or + * corrupt snapshots cannot hang the layout. + */ +export function layoutDag(tasks: readonly FleetSnapshot["tasks"][number][]): DagLayout { + if (tasks.length === 0) return { nodes: [], edges: [], width: 0, height: 0 } + const known = new Set(tasks.map((task) => task.id)) + const depsOf = new Map(tasks.map((task) => [task.id, task.deps.filter((dep) => known.has(dep))])) + const depth = new Map() + const visit = (id: string, trail: readonly string[]): number => { + const settled = depth.get(id) + if (settled !== undefined) return settled + if (trail.includes(id)) return trail.length // cycle: cut, keep it placed + const deps = depsOf.get(id) ?? [] + const own = deps.length === 0 ? 0 : Math.max(...deps.map((dep) => visit(dep, [...trail, id]) + 1)) + depth.set(id, own) + return own + } + for (const task of tasks) visit(task.id, []) + const lanes = new Map() + const nodes: DagNode[] = tasks.map((task) => { + const taskDepth = depth.get(task.id) ?? 0 + const lane = lanes.get(taskDepth) ?? 0 + lanes.set(taskDepth, lane + 1) + return { + id: task.id, + title: task.title, + status: task.status, + depth: taskDepth, + x: PAD + taskDepth * DX, + y: PAD + lane * DY, + } + }) + const edges: DagEdge[] = [] + for (const task of tasks) for (const dep of depsOf.get(task.id) ?? []) edges.push({ from: dep, to: task.id }) + const width = PAD * 2 + (Math.max(...nodes.map((node) => node.depth)) + 1) * DX + const height = PAD * 2 + Math.max(...[...lanes.values()]) * DY + return { nodes, edges, width, height } +} diff --git a/packages/web/src/dom.ts b/packages/web/src/dom.ts new file mode 100644 index 0000000..fe45670 --- /dev/null +++ b/packages/web/src/dom.ts @@ -0,0 +1,90 @@ +// Minimal DOM builders (#130, clean-room): `h`/`svgEl` plus Solid's +// `insert` for reactive children. Two toolchain facts force this shape: +// bun's test runner cannot apply the Solid JSX transform (so JSX components +// are untestable here), and `solid-js/html` compiles templates with +// `new Function`, which the daemon's strict CSP (`script-src 'self'`, #129) +// refuses in a real browser (caught by the screenshot run). Direct DOM calls +// are transform-free, eval-free, and render identically under test and in +// the production Vite build. +import { createEffect } from "solid-js" +import { insert } from "solid-js/web" + +export type Child = Node | string | number | boolean | null | undefined | (() => Child) | readonly Child[] + +const SVG_NS = "http://www.w3.org/2000/svg" + +export type Attrs = Record string) | ((event: Event) => void)> + +const setAttr = (node: Element, name: string, value: string): void => { + if (name === "className") node.setAttribute("class", value) + else node.setAttribute(name, value) +} + +const wireAttr = (node: Element, name: string, value: Attrs[string]): void => { + if (typeof value === "function") { + if (name.startsWith("on")) node.addEventListener(name.slice(2), value as EventListener) + else createEffect(() => setAttr(node, name, (value as () => string)())) + } else setAttr(node, name, value) +} + +/** + * Append one child: static strings become text nodes, and thunks are + * wrapped so they always resolve to nodes. (Solid's `insert` with a string + * *replaces* the parent's content — feeding it a raw string or a thunk + * that resolves to one wipes previously appended siblings. `insert` is + * only used for functions and arrays, which need its reactivity.) + */ +const toNode = (value: Child): Node => { + if (value instanceof Node) return value + if (typeof value === "string" || typeof value === "number") return document.createTextNode(String(value)) + if (Array.isArray(value)) { + const fragment = document.createDocumentFragment() + for (const entry of value) fragment.appendChild(toNode(entry)) + return fragment + } + return document.createTextNode("") +} + +const append = (node: Element, kid: Child): void => { + if (kid === null || kid === undefined || typeof kid === "boolean") return + if (kid instanceof Node) node.appendChild(kid) + else if (typeof kid === "string" || typeof kid === "number") node.appendChild(document.createTextNode(String(kid))) + else if (typeof kid === "function") { + // Dynamic regions need an explicit marker: marker-less inserts on one + // parent clobber each other's ranges on update (order breaks, stale + // nodes survive). The marker pins this region's start/end. + const marker = document.createTextNode("") + node.appendChild(marker) + insert(node, (() => toNode(kid())) as never, marker) + } else insert(node, kid as never) +} + +/** An element with static, reactive or listener attributes and inserted children. */ +export function h( + tag: K, + attrs: Attrs | null, + ...kids: readonly Child[] +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (attrs) for (const [name, value] of Object.entries(attrs)) wireAttr(node, name, value) + for (const kid of kids) append(node, kid) + return node +} + +/** An SVG element: same contract as `h`, in the SVG namespace. */ +export function svgEl( + tag: string, + attrs: Record string)> | null, + ...kids: readonly Child[] +): SVGElement { + const node = document.createElementNS(SVG_NS, tag) + if (attrs) + for (const [name, value] of Object.entries(attrs)) { + if (typeof value === "function") { + const read = value + createEffect(() => node.setAttribute(name, read())) + } else node.setAttribute(name, String(value)) + } + for (const kid of kids) append(node as unknown as Element, kid) + return node +} diff --git a/packages/web/src/evidence.test.ts b/packages/web/src/evidence.test.ts new file mode 100644 index 0000000..142a87c --- /dev/null +++ b/packages/web/src/evidence.test.ts @@ -0,0 +1,197 @@ +// Evidence explorer UI (#133): claim graph, filters, inspector with +// range-highlighted quotes, and exports. Source text is hostile: it is +// rendered through text nodes only, split by verified ranges into +// elements — never HTML parsing (probed below). +import { describe, expect, test } from "bun:test" +import { render } from "solid-js/web" +import type { + EvidenceClaimDetail, + EvidenceClaimSummary, + EvidenceExport, + EvidenceFilter, + EvidenceSource, +} from "./api.ts" +import { EvidencePage, layoutEvidence } from "./evidence.ts" +import { installDom } from "./test-dom.ts" + +const setup = () => void installDom() + +const claims: EvidenceClaimSummary[] = [ + { + id: "c1", + tag: "VERIFIED", + status: "LIVE", + statement: "Bun starts fast.", + sourceSha: "aa".repeat(32), + tier: "quant", + }, + { id: "c2", tag: "HYPOTHESIS", status: "STALE", statement: "Speed wins.", sourceSha: null }, +] + +const detail: EvidenceClaimDetail = { + claim: { id: "c1", tag: "VERIFIED", status: "LIVE", statement: "Bun starts fast.", sourceSha: "aa".repeat(32) }, + quotes: [{ quote: "Bun starts fast indeed", verified: true, ranges: [{ start: 0, end: 21 }] }], + retractions: [], + events: [], + rankExplanation: "#1 VERIFIED LIVE — Bun starts fast.", + files: ["research/dossier.json"], +} + +const source: EvidenceSource = { + meta: { sha256: "aa".repeat(32), url: "https://example.com/x", retrieved: "", provider: "fetch", bytes: 100 }, + seal: "sealed", + text: "Bun starts fast indeed, says the benchmark.", + truncated: false, +} + +interface Harness { + filters: EvidenceFilter[] + exports: string[] + downloads: Array<{ name: string; text: string; type: string }> +} + +const show = (harness: Harness, overrides: Partial[0]> = {}) => { + render( + () => + EvidencePage({ + runId: "run1", + loadClaims: async (filter) => { + harness.filters.push(filter) + return { claims, truncated: false } + }, + loadClaim: async () => detail, + loadSource: async () => source, + exportDoc: async (format): Promise => { + harness.exports.push(format) + return { format, body: `# export ${format}`, truncated: false } + }, + download: (name, text, type) => void harness.downloads.push({ name, text, type }), + ...overrides, + }) as unknown as Node, + document.body, + ) +} + +const harness = (): Harness => ({ filters: [], exports: [], downloads: [] }) + +describe("evidence layout", () => { + test("claims and cited sources become nodes, citations become edges", () => { + const graph = layoutEvidence(claims, ["aa".repeat(32)]) + expect(graph.nodes.filter((node) => node.kind === "claim")).toHaveLength(2) + expect(graph.nodes.filter((node) => node.kind === "source")).toHaveLength(1) + expect(graph.edges).toEqual([{ from: "c1", to: "aa".repeat(32) }]) + expect(graph.nodes.find((node) => node.id === "c1")!.colour).not.toBe( + graph.nodes.find((node) => node.id === "c2")!.colour, + ) + }) + + test("empty input lays out to nothing", () => { + expect(layoutEvidence([], [])).toEqual({ nodes: [], edges: [], width: 0, height: 0 }) + }) +}) + +describe("evidence page", () => { + test("it renders the graph, the claim list and the tier chips from data", async () => { + setup() + const seen = harness() + show(seen) + await Bun.sleep(0) + expect(document.body.querySelectorAll("svg .ev-claim")).toHaveLength(2) + expect(document.body.querySelectorAll("svg .ev-source")).toHaveLength(1) + expect(document.body.textContent).toContain("Bun starts fast.") + // Tier chips exist because a claim carries a tier. + expect(document.body.textContent).toContain("quant") + }) + + test("tag chips filter the list through the loader", async () => { + setup() + const seen = harness() + show(seen) + await Bun.sleep(0) + const chip = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent === "VERIFIED")! + chip.click() + await Bun.sleep(0) + expect(seen.filters.at(-1)).toMatchObject({ tag: "VERIFIED" }) + }) + + test("selecting a claim shows its inspector, rank and retractions", async () => { + setup() + const seen = harness() + show(seen, { + loadClaim: async () => ({ + ...detail, + claim: { ...detail.claim, status: "STALE" }, + retractions: [{ source: "aa".repeat(32), event: "RETRACTED", at: "", note: "withdrawn", by: "human:x" }], + }), + }) + await Bun.sleep(0) + const button = [...document.body.querySelectorAll("button")].find((entry) => + entry.textContent?.includes("Bun starts fast."), + )! + button.click() + await Bun.sleep(0) + expect(document.body.textContent).toContain("#1 VERIFIED LIVE") + expect(document.body.textContent).toContain("withdrawn") + expect(document.body.textContent).toContain("STALE") + }) + + test("quotes highlight by range; hostile source text stays inert", async () => { + setup() + const seen = harness() + const evil: EvidenceSource = { + meta: { sha256: "bb".repeat(32), url: "", retrieved: "", provider: "fetch", bytes: 10 }, + seal: "unsealed", + text: `Prefix suffix.`, + truncated: false, + } + show(seen, { + loadClaim: async () => ({ + ...detail, + quotes: [ + { + quote: "Prefix suffix", + verified: true, + ranges: [ + { start: 0, end: 7 }, + { start: 58, end: 66 }, + ], + }, + ], + }), + loadSource: async () => evil, + }) + await Bun.sleep(0) + const claimButton = [...document.body.querySelectorAll("button")].find((entry) => + entry.textContent?.includes("Bun starts fast."), + )! + claimButton.click() + await Bun.sleep(0) + const quoteButton = [...document.body.querySelectorAll("button")].find((entry) => + entry.textContent?.includes("show in source"), + )! + quoteButton.click() + await Bun.sleep(0) + expect(document.body.querySelector("script")).toBeNull() + expect(document.body.querySelectorAll("img")).toHaveLength(0) + const marks = document.body.querySelectorAll("mark.quote-mark") + expect(marks.length).toBe(2) + expect(document.body.textContent).toContain("") + expect(seen.filters.length).toBeGreaterThan(0) + }) + + test("export buttons download both formats", async () => { + setup() + const seen = harness() + show(seen) + await Bun.sleep(0) + const md = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent === "Markdown")! + md.click() + await Bun.sleep(0) + const html = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent === "HTML")! + html.click() + await Bun.sleep(0) + expect(seen.exports).toEqual(["markdown", "html"]) + expect(seen.downloads.map((entry) => entry.name)).toEqual(["dossier-run1.md", "dossier-run1.html"]) + expect(seen.downloads[0]!.text).toContain("# export markdown") + }) +}) diff --git a/packages/web/src/evidence.ts b/packages/web/src/evidence.ts new file mode 100644 index 0000000..70d7b42 --- /dev/null +++ b/packages/web/src/evidence.ts @@ -0,0 +1,401 @@ +// Evidence explorer UI (#133, clean-room): claim/source graph, tag/status +// filters, inspector with range-highlighted quotes, and renderer exports. +// Source text is hostile: it flows through text nodes only, segmented by +// verified ranges into elements — the component never parses source +// HTML (probed in evidence.test.ts). Read-only: the only requests are the +// evidence.* reads plus a blob download of rendered exports. +import { createSignal } from "solid-js" +import type { + EvidenceClaimDetail, + EvidenceClaimSummary, + EvidenceExport, + EvidenceFilter, + EvidenceSource, + Tag, +} from "./api.ts" +import { h, svgEl } from "./dom.ts" + +export type { Tag } + +const TAG_COLOUR: Record = { + VERIFIED: "#2f9e6e", + INFERRED: "#3d7a8c", + HYPOTHESIS: "#c07c14", + NEGATIVE_KNOWLEDGE: "#64748b", +} + +const TAGS = ["VERIFIED", "INFERRED", "HYPOTHESIS", "NEGATIVE_KNOWLEDGE"] as const +const STATUSES = ["LIVE", "STALE", "SUSPECT", "REJECTED"] as const + +export interface EvidenceNode { + readonly id: string + readonly kind: "claim" | "source" + readonly label: string + readonly sub: string + readonly colour: string + readonly x: number + readonly y: number +} + +export interface EvidenceLayout { + readonly nodes: readonly EvidenceNode[] + readonly edges: ReadonlyArray<{ from: string; to: string }> + readonly width: number + readonly height: number +} + +const CLAIM_X = 16 +const SOURCE_X = 400 +const ROW_DY = 88 +const PAD = 16 + +/** + * Two columns: claims left, cited sources right, edges for citations. + * Extra source shas (cited by claims outside the current filter) still + * draw so edges never dangle. + */ +export function layoutEvidence( + claims: readonly Pick[], + extraSources: readonly string[] = [], +): EvidenceLayout { + if (claims.length === 0 && extraSources.length === 0) return { nodes: [], edges: [], width: 0, height: 0 } + const shas = [...new Set([...claims.map((claim) => claim.sourceSha).filter((sha) => sha !== null), ...extraSources])] + const nodes: EvidenceNode[] = [ + ...claims.map((claim, index) => ({ + id: claim.id, + kind: "claim" as const, + label: `${claim.id.slice(0, 12)}… — ${claim.tag}`, + sub: `${claim.status} · ${claim.statement.slice(0, 30)}`, + colour: TAG_COLOUR[claim.tag] ?? "#8a8a8a", + x: CLAIM_X, + y: PAD + index * ROW_DY, + })), + ...shas.map((sha, index) => ({ + id: sha, + kind: "source" as const, + label: `source ${sha.slice(0, 12)}…`, + sub: `${claims.filter((claim) => claim.sourceSha === sha).length} citation(s)`, + colour: "#7c6aa8", + x: SOURCE_X, + y: PAD + index * ROW_DY, + })), + ] + const edges = claims + .filter((claim) => claim.sourceSha !== null) + .map((claim) => ({ from: claim.id, to: claim.sourceSha as string })) + const rows = Math.max(nodes.length, 1) + return { nodes, edges, width: SOURCE_X + 200, height: PAD * 2 + rows * ROW_DY } +} + +const graphEl = (claims: readonly EvidenceClaimSummary[]): Element => { + const graph = layoutEvidence(claims) + if (graph.nodes.length === 0) return h("p", null, "No claims yet.") + const byId = new Map(graph.nodes.map((node) => [node.id, node])) + return svgEl( + "svg", + { width: graph.width, height: graph.height, role: "img", "aria-label": "Claim and source graph" }, + ...graph.edges.map((edge) => { + const from = byId.get(edge.from)! + const to = byId.get(edge.to)! + return svgEl("line", { + class: "ev-edge", + x1: from.x + 220, + y1: from.y + 30, + x2: to.x, + y2: to.y + 30, + stroke: "#94a3b8", + "stroke-width": 2, + }) + }), + ...graph.nodes.map((node) => + svgEl( + "g", + { class: node.kind === "claim" ? "ev-claim" : "ev-source", transform: `translate(${node.x},${node.y})` }, + svgEl("title", null, `${node.label}: ${node.sub}`), + svgEl("rect", { + width: 220, + height: 60, + rx: 8, + fill: node.colour, + "fill-opacity": 0.18, + stroke: node.colour, + "stroke-width": 2, + ...(node.kind === "claim" && node.sub.startsWith("LIVE") ? {} : { "stroke-dasharray": "6 3" }), + }), + svgEl("text", { x: 10, y: 22, "font-size": 12, "font-weight": "bold" }, node.label), + svgEl("text", { x: 10, y: 42, "font-size": 11 }, node.sub.slice(0, 34)), + ), + ), + ) as unknown as Element +} + +/** Split hostile text by verified ranges into plain and marked spans. */ +const segmentsOf = ( + text: string, + ranges: readonly { start: number; end: number }[], +): Array<{ mark: boolean; text: string }> => { + const spans = ranges + .filter((range) => range.start < range.end) + .map((range) => ({ + start: Math.max(0, Math.min(range.start, text.length)), + end: Math.max(0, Math.min(range.end, text.length)), + })) + .filter((range) => range.start < range.end) + .sort((a, b) => a.start - b.start) + const out: Array<{ mark: boolean; text: string }> = [] + let at = 0 + for (const span of spans) { + if (span.start < at) continue + if (span.start > at) out.push({ mark: false, text: text.slice(at, span.start) }) + out.push({ mark: true, text: text.slice(span.start, span.end) }) + at = Math.max(at, span.end) + } + if (at < text.length) out.push({ mark: false, text: text.slice(at) }) + return out +} + +const viewerEl = (source: EvidenceSource, ranges: readonly { start: number; end: number }[]): Element => + h( + "section", + null, + h("h3", null, "Source"), + h( + "p", + null, + source.meta.url ? h("span", null, source.meta.url) : h("span", null, "(no recorded URL)"), + ` · seal: ${source.seal}`, + source.meta.tier ? ` · tier ${source.meta.tier}` : "", + source.truncated ? " · text capped" : "", + ), + h( + "pre", + { class: "source-text" }, + ...segmentsOf(source.text, ranges).map((segment) => + segment.mark ? h("mark", { class: "quote-mark" }, segment.text) : segment.text, + ), + ), + ) + +export interface EvidencePageProps { + readonly runId: string + readonly loadClaims: (filter: EvidenceFilter) => Promise<{ claims: EvidenceClaimSummary[]; truncated: boolean }> + readonly loadClaim: (id: string) => Promise + readonly loadSource: (sha: string) => Promise + readonly exportDoc: (format: "markdown" | "html") => Promise + readonly download?: (name: string, text: string, type: string) => void +} + +const saveBlob = (name: string, text: string, type: string): void => { + const url = URL.createObjectURL(new Blob([text], { type })) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = name + anchor.click() + URL.revokeObjectURL(url) +} + +export function EvidencePage(props: EvidencePageProps): Element { + const download = props.download ?? saveBlob + const [filter, setFilter] = createSignal({}) + const [claims, setClaims] = createSignal([]) + const [tiers, setTiers] = createSignal([]) + const [truncated, setTruncated] = createSignal(false) + const [error, setError] = createSignal(undefined) + const [selectedId, setSelectedId] = createSignal(undefined) + const [detail, setDetail] = createSignal(undefined) + const [source, setSource] = createSignal< + { sha: string; view: EvidenceSource; ranges: { start: number; end: number }[] } | undefined + >(undefined) + const [busy, setBusy] = createSignal(false) + + const reload = async (next: EvidenceFilter): Promise => { + setFilter(next) + setError(undefined) + try { + const seen = await props.loadClaims(next) + setClaims(seen.claims) + setTruncated(seen.truncated) + setTiers([...new Set(seen.claims.map((claim) => claim.tier).filter((tier) => tier !== undefined))]) + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + const select = async (id: string): Promise => { + setSelectedId(id) + setSource(undefined) + try { + setDetail(await props.loadClaim(id)) + setError(undefined) + } catch (failure) { + setDetail(undefined) + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + const showQuote = async (sha: string, ranges: { start: number; end: number }[]): Promise => { + try { + setSource({ sha, view: await props.loadSource(sha), ranges }) + setError(undefined) + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + const exportAs = async (format: "markdown" | "html"): Promise => { + setBusy(true) + try { + const rendered = await props.exportDoc(format) + download( + `dossier-${props.runId}.${format === "html" ? "html" : "md"}`, + rendered.body, + format === "html" ? "text/html" : "text/markdown", + ) + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)) + } finally { + setBusy(false) + } + } + + void reload({}) + + const chip = (group: "tag" | "status" | "tier", value: string): Element => { + const active = (filter()[group] ?? "") === value + return h( + "button", + { + type: "button", + "aria-pressed": String(active), + onclick: () => void reload({ ...filter(), [group]: active ? undefined : value }), + }, + `${active ? "✓ " : ""}${value}`, + ) + } + + const detailEl = (seen: EvidenceClaimDetail): Element => + h( + "section", + null, + h("h2", { title: seen.claim.id }, `${seen.claim.id.slice(0, 12)}… — ${seen.claim.tag}`), + h("p", null, seen.claim.statement), + h("p", null, `Status ${seen.claim.status} · ${seen.rankExplanation}`), + seen.retractions.length + ? h( + "section", + null, + h("h3", null, "Retractions"), + h( + "ul", + null, + ...seen.retractions.map((retraction) => + h("li", null, `${retraction.event} by ${retraction.by}: ${retraction.note || "(no note)"}`), + ), + ), + ) + : "", + seen.events.length + ? h( + "section", + null, + h("h3", null, "History"), + h("ul", null, ...seen.events.map((event) => h("li", null, `${event.from} → ${event.to}: ${event.reason}`))), + ) + : "", + h( + "section", + null, + h("h3", null, "Quotes"), + ...seen.quotes.map((quote, index) => + h( + "div", + null, + h("p", null, h("code", null, quote.quote)), + h( + "p", + null, + quote.verified + ? `Verified in source (${quote.ranges.length} range(s)). ` + : "Not currently verifiable in source. ", + seen.claim.sourceSha + ? h( + "button", + { + type: "button", + onclick: () => void showQuote(seen.claim.sourceSha as string, [...quote.ranges]), + }, + `show in source #${index + 1}`, + ) + : "", + ), + ), + ), + ), + ) + + return h( + "main", + null, + h("h1", null, `Evidence for ${props.runId}`), + () => (error() !== undefined ? h("p", { role: "alert" }, error()!) : ""), + h( + "section", + null, + h("h2", null, "Filters"), + h("div", null, ...TAGS.map((tag) => chip("tag", tag))), + h("div", null, ...STATUSES.map((status) => chip("status", status))), + () => { + const present = tiers() + return present.length === 0 ? "" : h("div", null, ...present.map((tier) => chip("tier", tier))) + }, + ), + h( + "section", + null, + h("h2", null, "Claims"), + () => (truncated() ? h("p", null, "List capped — narrow the filters.") : ""), + () => { + const list = claims() + if (list.length === 0) return h("p", null, "No claims yet.") + return h( + "ul", + null, + ...list.map((claim) => + h( + "li", + null, + h( + "button", + { type: "button", title: claim.id, onclick: () => void select(claim.id) }, + `${claim.id.slice(0, 12)}… — ${claim.tag} — ${claim.status}: ${claim.statement}`, + ), + ), + ), + ) + }, + ), + () => graphEl(claims()), + () => { + const seen = detail() + if (selectedId() === undefined) return "" + return seen ? detailEl(seen) : h("p", null, "Loading the claim…") + }, + () => { + const shown = source() + return shown ? viewerEl(shown.view, shown.ranges) : "" + }, + h( + "section", + null, + h("h2", null, "Export"), + h( + "p", + null, + h("button", { type: "button", onclick: () => void exportAs("markdown") }, "Markdown"), + " ", + h("button", { type: "button", onclick: () => void exportAs("html") }, "HTML"), + () => (busy() ? " Rendering…" : ""), + ), + ), + ) +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx new file mode 100644 index 0000000..6226bb0 --- /dev/null +++ b/packages/web/src/main.tsx @@ -0,0 +1,209 @@ +// Dashboard app (#130) with the preview-only config editor (#132) and +// browser approvals (#131, ADR 0002 option b): hash-routed fleet overview, +// task, approvals, approve and config pages. The only mutating requests +// are the approval submit (passphrase over loopback, ticket-bound); every +// other request is a read over the bus plus the WS subscribe. + +import type { BusEvent, FleetSnapshot } from "@heretek-ai/es-fleet" +import { createSignal, onCleanup } from "solid-js" +import { render } from "solid-js/web" +import { + type ApproveStage, + type ConfigView, + exportEvidenceDossier, + fetchApprovePreview, + fetchConfigView, + fetchEvidenceClaim, + fetchEvidenceClaims, + fetchEvidenceSource, + fetchStatus, + fetchTask, + fetchTaskLiveness, + isApproveStage, + previewConfigPlan, + RpcError, + submitApproval, + type TaskLiveness, +} from "./api.ts" +import { ApprovalsPage, ApprovePage, type PendingPair } from "./approvals.ts" +import { connectBus } from "./bus.ts" +import { Overview, TaskDetail } from "./components.ts" +import { ConfigEditor } from "./config.ts" +import { h } from "./dom.ts" +import { EvidencePage } from "./evidence.ts" +import { createFleetStore } from "./store.ts" + +type Route = + | { name: "overview" } + | { name: "task"; id: string } + | { name: "config" } + | { name: "approvals" } + | { name: "approve"; taskId: string; stage: ApproveStage } + | { name: "evidence"; runId: string } + +const routeOf = (hash: string): Route => { + if (hash === "#/config") return { name: "config" } + if (hash === "#/approvals") return { name: "approvals" } + const evidence = /^#\/evidence\/([^/]+)$/.exec(hash) + if (evidence?.[1]) return { name: "evidence", runId: decodeURIComponent(evidence[1]) } + const approve = /^#\/approve\/([^/]+)\/([^/]+)$/.exec(hash) + if (approve?.[1] && approve?.[2]) { + // Fail-closed: only the two approval stages route; anything else is the overview. + const stage = decodeURIComponent(approve[2]) + if (stage === "frontier" || stage === "spec") + return { name: "approve", taskId: decodeURIComponent(approve[1]), stage } + return { name: "overview" } + } + const task = /^#\/task\/([^/]+)$/.exec(hash) + return task?.[1] ? { name: "task", id: decodeURIComponent(task[1]) } : { name: "overview" } +} + +function App(): Element { + const store = createFleetStore() + const [route, setRoute] = createSignal(routeOf(window.location.hash)) + const [task, setTask] = createSignal(undefined) + const [liveness, setLiveness] = createSignal(undefined) + const [configView, setConfigView] = createSignal(undefined) + const [configError, setConfigError] = createSignal(undefined) + const [error, setError] = createSignal(undefined) + const [tick, setTick] = createSignal(0) + + const refresh = async (): Promise => { + try { + const snapshot = await fetchStatus() + store.setSnapshot(snapshot) + setError(undefined) + const current = route() + if (current.name === "task") { + try { + setTask(await fetchTask(current.id)) + } catch { + setTask(undefined) + } + try { + setLiveness(await fetchTaskLiveness(current.id)) + } catch { + setLiveness(undefined) + } + } + if (current.name === "config") { + try { + setConfigView(await fetchConfigView()) + setConfigError(undefined) + } catch (failure) { + setConfigError(failure instanceof RpcError ? failure.message : String(failure)) + } + } + } catch (failure) { + setError(failure instanceof RpcError ? failure.message : String(failure)) + } + } + + const onHash = (): void => { + setRoute(routeOf(window.location.hash)) + setTask(undefined) + setLiveness(undefined) + void refresh() + } + window.addEventListener("hashchange", onHash) + + const timer = window.setInterval(() => setTick(tick() + 1), 1_000) + const disconnect = connectBus({ + url: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/fleet/ws`, + onEvent: (event: BusEvent) => { + store.pushEvent(event) + void refresh() + }, + onConnect: () => { + void refresh() + }, + }) + + onCleanup(() => { + window.removeEventListener("hashchange", onHash) + window.clearInterval(timer) + disconnect() + }) + + void refresh() + + // Snapshot rows type the stage as string (fleet FleetSnapshot); narrow + // fail-closed here so approve links only ever carry frontier|spec. + const pendingPairs = (): PendingPair[] => { + const out: PendingPair[] = [] + for (const item of store.state.snapshot?.pending ?? []) { + if (!isApproveStage(item.stage)) continue + out.push({ taskId: item.taskId, reason: item.reason, stage: item.stage }) + } + return out + } + + const nav = h( + "nav", + null, + h("a", { href: "#/" }, "Fleet"), + " · ", + h("a", { href: "#/approvals" }, "Approvals"), + " · ", + h("a", { href: "#/config" }, "Config"), + ) + + return h("div", null, nav, () => { + const current = route() + if (current.name === "task") { + return TaskDetail({ + task: () => (route().name === "task" ? task() : undefined), + events: () => store.state.events, + pendingStages: () => + pendingPairs() + .filter((item) => route().name === "task" && item.taskId === (route() as { id: string }).id) + .map((item) => item.stage), + liveness: () => (route().name === "task" ? liveness() : undefined), + }) + } + if (current.name === "approve") { + const { taskId, stage } = current + return ApprovePage({ + taskId, + stage, + loadPreview: () => fetchApprovePreview(taskId, stage), + submit: (ticket, csrf, passphrase) => submitApproval(ticket, csrf, passphrase), + }) + } + if (current.name === "approvals") { + return ApprovalsPage({ + pending: pendingPairs, + error: () => error(), + }) + } + if (current.name === "evidence") { + const { runId } = current + return EvidencePage({ + runId, + loadClaims: (filter) => fetchEvidenceClaims(runId, filter), + loadClaim: (id) => fetchEvidenceClaim(runId, id), + loadSource: (sha) => fetchEvidenceSource(runId, sha), + exportDoc: (format) => exportEvidenceDossier(runId, format), + }) + } + if (current.name === "config") { + return ConfigEditor({ + view: () => configView(), + error: () => configError(), + preview: (file, content) => previewConfigPlan(file, content), + }) + } + return Overview({ + snapshot: () => store.state.snapshot, + stale: () => { + tick() + return store.isStale() + }, + error: () => error(), + }) + }) +} + +const root = document.getElementById("root") +if (!root) throw new Error("missing #root") +render(App, root) diff --git a/packages/web/src/store.test.ts b/packages/web/src/store.test.ts new file mode 100644 index 0000000..c1ea120 --- /dev/null +++ b/packages/web/src/store.test.ts @@ -0,0 +1,73 @@ +// Fleet store (#130): snapshot state, recent-event tail, and the stale +// flag (5 s without data). Time is injected so the stale logic is +// deterministic without timers. +import { describe, expect, test } from "bun:test" +import type { BusEvent, FleetSnapshot } from "@heretek-ai/es-fleet" +import { createRoot } from "solid-js" +import { createFleetStore, MAX_EVENTS, STALE_AFTER_MS } from "./store.ts" + +const snapshot = (extra: Partial = {}): FleetSnapshot => ({ + daemon: { running: true, pid: 7, maxUsd: 50, concurrency: 2 }, + tasks: [], + spendUsd: 0, + pending: [], + ...extra, +}) + +const event = (seq: number): BusEvent => ({ + seq, + at: "2026-10-09T12:00:00.000Z", + type: "changed", + payload: { taskId: "a", kind: "started" }, +}) + +describe("fleet store", () => { + test("it starts empty and fresh", () => { + createRoot((dispose) => { + const store = createFleetStore({ now: () => 1_000 }) + expect(store.state.snapshot).toBeUndefined() + expect(store.state.events).toEqual([]) + expect(store.isStale()).toBe(false) + dispose() + }) + }) + + test("setting a snapshot clears the stale flag", () => { + createRoot((dispose) => { + let now = 1_000 + const store = createFleetStore({ now: () => now }) + store.setSnapshot(snapshot()) + expect(store.isStale()).toBe(false) + now += STALE_AFTER_MS + 1 + expect(store.isStale()).toBe(true) + store.setSnapshot(snapshot({ spendUsd: 3 })) + expect(store.isStale()).toBe(false) + expect(store.state.snapshot?.spendUsd).toBe(3) + dispose() + }) + }) + + test("events append in order and the tail is bounded", () => { + createRoot((dispose) => { + const store = createFleetStore({ now: () => 1_000 }) + for (let seq = 1; seq <= MAX_EVENTS + 5; seq++) store.pushEvent(event(seq)) + expect(store.state.events).toHaveLength(MAX_EVENTS) + expect(store.state.events[0]!.seq).toBe(6) + expect(store.state.events.at(-1)!.seq).toBe(MAX_EVENTS + 5) + // An event is data too: it clears the stale flag. + expect(store.isStale()).toBe(false) + dispose() + }) + }) + + test("stale events (seq at or below the known tip) are dropped", () => { + createRoot((dispose) => { + const store = createFleetStore({ now: () => 1_000 }) + store.pushEvent(event(2)) + store.pushEvent(event(2)) + store.pushEvent(event(1)) + expect(store.state.events.map((entry) => entry.seq)).toEqual([2]) + dispose() + }) + }) +}) diff --git a/packages/web/src/store.ts b/packages/web/src/store.ts new file mode 100644 index 0000000..98a15f9 --- /dev/null +++ b/packages/web/src/store.ts @@ -0,0 +1,53 @@ +// Fleet store (#130): the dashboard's reactive state. A snapshot or any +// event counts as data (clears the stale flag); the event tail is bounded +// and reordered/duplicated deliveries are dropped by sequence number. + +import type { BusEvent, FleetSnapshot } from "@heretek-ai/es-fleet" +import { createStore } from "solid-js/store" + +/** Without data for this long, the UI shows the stale banner. */ +export const STALE_AFTER_MS = 5_000 +/** Recent-event tail per view (the bus replays the full backlog on demand). */ +export const MAX_EVENTS = 50 + +export interface FleetStoreOptions { + readonly now?: () => number +} + +export interface FleetStore { + readonly state: { + readonly snapshot: FleetSnapshot | undefined + readonly events: readonly BusEvent[] + } + setSnapshot(snapshot: FleetSnapshot): void + pushEvent(event: BusEvent): void + isStale(at?: number): boolean +} + +const wellFormed = (event: BusEvent): boolean => + Number.isInteger(event.seq) && + event.seq > 0 && + typeof event.at === "string" && + (event.type === "changed" || event.type === "liveness") && + typeof event.payload === "object" && + event.payload !== null + +export function createFleetStore(options: FleetStoreOptions = {}): FleetStore { + const now = options.now ?? (() => Date.now()) + const [state, setState] = createStore<{ snapshot: FleetSnapshot | undefined; events: BusEvent[]; lastSync: number }>({ + snapshot: undefined, + events: [], + lastSync: now(), + }) + return { + state, + setSnapshot: (snapshot) => setState({ snapshot, lastSync: now() }), + pushEvent: (event) => { + if (!wellFormed(event)) return + const tip = state.events.at(-1)?.seq ?? 0 + if (event.seq <= tip) return + setState({ events: [...state.events, event].slice(-MAX_EVENTS), lastSync: now() }) + }, + isStale: (at) => (at ?? now()) - state.lastSync > STALE_AFTER_MS, + } +} diff --git a/packages/web/src/style.css b/packages/web/src/style.css new file mode 100644 index 0000000..441386f --- /dev/null +++ b/packages/web/src/style.css @@ -0,0 +1,43 @@ +/* Dashboard tones (#130): status colour lives in a same-origin stylesheet + because the daemon's CSP (`style-src 'self'`) refuses inline styles. The + status word is always rendered beside the dot. */ +.tone-work { + color: #2f9e6e; +} +.tone-wait { + color: #c07c14; +} +.tone-queue { + color: #64748b; +} +.tone-done { + color: #3d7a8c; +} +.tone-bad { + color: #c0392b; +} +.tone-idle { + color: #8a8a8a; +} + +body { + font-family: system-ui, sans-serif; + margin: 2rem; +} +table { + border-collapse: collapse; +} +th, +td { + border: 1px solid #cbd5e1; + padding: 0.35rem 0.7rem; + text-align: left; +} + +/* Verified quote spans in hostile source text (#133). */ +.quote-mark { + background: #fef08a; +} +.source-text { + white-space: pre-wrap; +} diff --git a/packages/web/src/test-dom.ts b/packages/web/src/test-dom.ts new file mode 100644 index 0000000..d740359 --- /dev/null +++ b/packages/web/src/test-dom.ts @@ -0,0 +1,24 @@ +// Test-only DOM setup (#130): happy-dom provides the browser globals the +// client Solid build needs (`document` for builders, `Node` for type +// checks inside `insert`). Every global the window owns and the test realm +// lacks is installed, so components render exactly as in a browser. +import { Window } from "happy-dom" + +/** Install a fresh happy-dom window onto globalThis. */ +export function installDom(): Window { + const window = new Window() + const scope = globalThis as Record + scope.window = window + scope.document = window.document + for (const key of Object.getOwnPropertyNames(window)) { + if (!(key in globalThis)) { + try { + scope[key] = (window as unknown as Record)[key] + } catch { + // Read-only or exotic globals are skipped. + } + } + } + document.body.innerHTML = "" + return window +} diff --git a/packages/web/test/bus.contract.test.ts b/packages/web/test/bus.contract.test.ts new file mode 100644 index 0000000..4868a86 --- /dev/null +++ b/packages/web/test/bus.contract.test.ts @@ -0,0 +1,148 @@ +// Bus event client contract (#130): live WS delivery, replay on connect, +// refetch on reconnect, malformed frames dropped. This is the test-only +// exception to the web leaf-surface rule (AGENTS.md, docs/FLEET.md): it +// runtime-imports the fleet server harness, so it lives in `test/` — +// `src/` holds only shipped code with type-only fleet imports. The live +// test runs against a real TelemetryServer; the reconnect test uses an +// injected socket. +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { type BusEvent, type FleetSnapshot, fleetPaths, TelemetryServer } from "@heretek-ai/es-fleet" +import { type BusSocket, connectBus } from "../src/bus.ts" + +const TOKEN = "bus-test-token-with-enough-length" + +const fixture = (): FleetSnapshot => ({ + daemon: { running: true, pid: 7, maxUsd: 50, concurrency: 2 }, + tasks: [{ id: "a", title: "Alpha", status: "running", ceilingUSD: 5, spendUsd: 1, deps: [], reason: "building" }], + spendUsd: 1, + pending: [], +}) + +async function rig() { + const state = await mkdtemp(path.join(tmpdir(), "es-web-bus-")) + await mkdir(fleetPaths(state).dir, { recursive: true }) + await writeFile(fleetPaths(state).token, TOKEN, { mode: 0o600 }) + const server = new TelemetryServer({ stateRoot: state, port: 0, token: TOKEN, getSnapshot: fixture }) + await server.start() + return { state, server } +} + +const authed = (token: string) => (url: string) => { + const socket = new WebSocket(url, { headers: { authorization: `Bearer ${token}` } } as unknown as string[]) + return socket as unknown as BusSocket +} + +describe("bus client against a real server", () => { + test("backlog replays on connect and live events arrive", async () => { + const { state, server } = await rig() + try { + await server.log.append("changed", { taskId: "a", kind: "started" }) + const seen: BusEvent[] = [] + let connects = 0 + const disconnect = connectBus({ + url: `ws://127.0.0.1:${server.port}/fleet/ws`, + openSocket: authed(TOKEN), + onEvent: (event) => { + seen.push(event) + }, + onConnect: () => { + connects += 1 + }, + }) + try { + await Bun.sleep(300) + expect(connects).toBe(1) + expect(seen.map((event) => event.seq)).toEqual([1]) + await server.log.append("liveness", { note: "tick" }) + await Bun.sleep(1200) + expect(seen.map((event) => event.seq)).toEqual([1, 2]) + expect(seen[1]!.type).toBe("liveness") + } finally { + disconnect() + } + } finally { + await server.stop() + await rm(state, { recursive: true, force: true }) + } + }) +}) + +describe("bus client reconnect", () => { + test("a dropped socket reconnects and refetches", async () => { + const sockets: Array<{ + handlers: Record void) | undefined> + sent: string[] + closed: boolean + }> = [] + const openSocket = (url: string): BusSocket => { + const fake = { + handlers: {} as Record void) | undefined>, + sent: [] as string[], + closed: false, + send: (text: string) => { + fake.sent.push(text) + }, + close: () => { + fake.closed = true + }, + } + sockets.push(fake) + void url + return { + send: fake.send, + close: fake.close, + set onopen(fn: ((event: unknown) => void) | undefined) { + fake.handlers.open = fn + }, + set onmessage(fn: ((event: unknown) => void) | undefined) { + fake.handlers.message = fn + }, + set onclose(fn: ((event: unknown) => void) | undefined) { + fake.handlers.close = fn + }, + set onerror(fn: ((event: unknown) => void) | undefined) { + fake.handlers.error = fn + }, + } + } + const seen: BusEvent[] = [] + let connects = 0 + const disconnect = connectBus({ + url: "ws://127.0.0.1:1/fleet/ws", + openSocket, + backoffMs: () => 5, + onEvent: (event) => { + seen.push(event) + }, + onConnect: () => { + connects += 1 + }, + }) + try { + expect(sockets).toHaveLength(1) + // Malformed frames never reach onEvent and never kill the client. + sockets[0]!.handlers.open?.({}) + sockets[0]!.handlers.message?.({ data: "not json" }) + sockets[0]!.handlers.message?.({ data: JSON.stringify({ type: "bogus" }) }) + expect(seen).toEqual([]) + // A well-formed event flows through. + sockets[0]!.handlers.message?.({ + data: JSON.stringify({ seq: 1, at: "2026-10-09T12:00:00.000Z", type: "changed", payload: { taskId: "a" } }), + }) + expect(seen.map((event) => event.seq)).toEqual([1]) + // The socket drops: the client reconnects and refetches. + sockets[0]!.handlers.close?.({}) + await Bun.sleep(50) + expect(sockets).toHaveLength(2) + sockets[1]!.handlers.open?.({}) + expect(connects).toBe(2) + expect(JSON.parse(sockets[1]!.sent[0]!).subscribe).toEqual(["changed", "liveness"]) + } finally { + disconnect() + } + expect(sockets.at(-1)!.closed).toBe(true) + }) +}) diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..1b7c81b --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "lib": ["ES2024", "DOM"], + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts new file mode 100644 index 0000000..c9e405d --- /dev/null +++ b/packages/web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite" +import solid from "vite-plugin-solid" + +// Web control plane (#129): no runtime CDN — every byte is bundled locally +// and served by the fleet daemon under its strict CSP (`script-src 'self'`). +export default defineConfig({ + plugins: [solid()], + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: true, + }, + server: { + // Local dev only: point ES_FLEET_PROXY at a live daemon (e.g. + // http://127.0.0.1:8471) so the UI can run against it. Unset by + // default: there is no dead proxy to 127.0.0.1:0. + proxy: process.env.ES_FLEET_PROXY ? { "/fleet": process.env.ES_FLEET_PROXY } : undefined, + }, +}) diff --git a/schemas/approval.schema.json b/schemas/approval.schema.json index ccd2d1c..3650013 100644 --- a/schemas/approval.schema.json +++ b/schemas/approval.schema.json @@ -53,7 +53,8 @@ "type": "string", "enum": [ "cli", - "tui" + "tui", + "web" ] }, "auditHead": { diff --git a/schemas/audit-chain-telemetry.schema.json b/schemas/audit-chain-telemetry.schema.json new file mode 100644 index 0000000..17d17dd --- /dev/null +++ b/schemas/audit-chain-telemetry.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "entries": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "valid": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "trail": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "hash": { + "type": "string", + "minLength": 64, + "maxLength": 64 + }, + "action": { + "type": "string", + "minLength": 1 + }, + "phase": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "seq", + "hash", + "action" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entries", + "valid", + "trail" + ], + "additionalProperties": false +} diff --git a/schemas/audit-finding-count.schema.json b/schemas/audit-finding-count.schema.json new file mode 100644 index 0000000..c35d0d3 --- /dev/null +++ b/schemas/audit-finding-count.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "severity": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sources": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "kind", + "severity", + "count", + "sources" + ], + "additionalProperties": false +} diff --git a/schemas/brief.schema.json b/schemas/brief.schema.json index d3bc27e..1012a0e 100644 --- a/schemas/brief.schema.json +++ b/schemas/brief.schema.json @@ -47,6 +47,10 @@ "REJECTED" ] }, + "tier": { + "type": "string", + "minLength": 1 + }, "source": { "type": "object", "properties": { diff --git a/schemas/chain-trail-entry.schema.json b/schemas/chain-trail-entry.schema.json new file mode 100644 index 0000000..54516ee --- /dev/null +++ b/schemas/chain-trail-entry.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "seq": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "hash": { + "type": "string", + "minLength": 64, + "maxLength": 64 + }, + "action": { + "type": "string", + "minLength": 1 + }, + "phase": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "seq", + "hash", + "action" + ], + "additionalProperties": false +} diff --git a/schemas/approval-channel.schema.json b/schemas/channel.schema.json similarity index 84% rename from schemas/approval-channel.schema.json rename to schemas/channel.schema.json index 4914add..4ef602a 100644 --- a/schemas/approval-channel.schema.json +++ b/schemas/channel.schema.json @@ -3,6 +3,7 @@ "type": "string", "enum": [ "cli", - "tui" + "tui", + "web" ] } diff --git a/schemas/claim.schema.json b/schemas/claim.schema.json index 4e74cc5..86ff4c5 100644 --- a/schemas/claim.schema.json +++ b/schemas/claim.schema.json @@ -29,6 +29,10 @@ "REJECTED" ] }, + "tier": { + "type": "string", + "minLength": 1 + }, "source": { "type": "object", "properties": { diff --git a/schemas/cluster-kind.schema.json b/schemas/cluster-kind.schema.json new file mode 100644 index 0000000..5f8ec77 --- /dev/null +++ b/schemas/cluster-kind.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "enum": [ + "gate", + "audit", + "qa", + "replan", + "eval" + ] +} diff --git a/schemas/cluster.schema.json b/schemas/cluster.schema.json new file mode 100644 index 0000000..263971a --- /dev/null +++ b/schemas/cluster.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "gate", + "audit", + "qa", + "replan", + "eval" + ] + }, + "label": { + "type": "string", + "minLength": 1 + }, + "seat": { + "type": "string", + "minLength": 1 + }, + "occurrences": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "runIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "key", + "kind", + "label", + "occurrences", + "runIds", + "evidence" + ], + "additionalProperties": false +} diff --git a/schemas/domain-pack.schema.json b/schemas/domain-pack.schema.json index 70f7d76..0f0fc1d 100644 --- a/schemas/domain-pack.schema.json +++ b/schemas/domain-pack.schema.json @@ -74,6 +74,20 @@ "zero_tolerance" ] }, + "tierDomains": { + "description": "Pack host overrides by tier for source classification.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, "exportTemplate": { "type": "string" } diff --git a/schemas/dossier.schema.json b/schemas/dossier.schema.json index 8106236..829032d 100644 --- a/schemas/dossier.schema.json +++ b/schemas/dossier.schema.json @@ -53,6 +53,10 @@ "REJECTED" ] }, + "tier": { + "type": "string", + "minLength": 1 + }, "source": { "type": "object", "properties": { diff --git a/schemas/es-config.schema.json b/schemas/es-config.schema.json index 6c785cc..185260f 100644 --- a/schemas/es-config.schema.json +++ b/schemas/es-config.schema.json @@ -7,14 +7,17 @@ "properties": { "fast": { "type": "string", + "pattern": "^[^\\s/]+\\/\\S+$", "description": "Model for fast-tier seats (lenses), \"provider/model\"." }, "balanced": { "type": "string", + "pattern": "^[^\\s/]+\\/\\S+$", "description": "Model for balanced-tier seats (programmer, QA, research)." }, "deep": { "type": "string", + "pattern": "^[^\\s/]+\\/\\S+$", "description": "Model for deep-tier seats (factory, grill, managers, critics)." }, "agents": { @@ -23,7 +26,8 @@ "type": "string" }, "additionalProperties": { - "type": "string" + "type": "string", + "pattern": "^[^\\s/]+\\/\\S+$" }, "description": "Per-agent model overrides by agent id." } @@ -137,6 +141,21 @@ "type": "string", "pattern": "^https?:\\/\\/[^\\s/]+\\S*$", "description": "SearXNG instance (else SEARXNG_URL). Global config or plugin options only." + }, + "backends": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "scraper-swarm", + "brave", + "firecrawl", + "searxng", + "direct" + ] + }, + "description": "Ordered source backends for search and fetch failover (a safety refusal never fails over); unset keeps the searchProvider behaviour." } }, "required": [ diff --git a/schemas/eval-telemetry.schema.json b/schemas/eval-telemetry.schema.json new file mode 100644 index 0000000..a59308e --- /dev/null +++ b/schemas/eval-telemetry.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "case": { + "type": "string", + "minLength": 1 + }, + "pass": { + "type": "boolean" + }, + "failures": { + "type": "array", + "items": { + "type": "string" + } + }, + "steps": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "model": { + "type": "string" + }, + "modelLimited": { + "type": "boolean" + }, + "source": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "case", + "pass", + "failures", + "steps", + "source" + ], + "additionalProperties": false +} diff --git a/schemas/failure-entry.schema.json b/schemas/failure-entry.schema.json new file mode 100644 index 0000000..da4d87d --- /dev/null +++ b/schemas/failure-entry.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "seat": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "minLength": 1 + }, + "rule": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "kind", + "reason" + ], + "additionalProperties": false +} diff --git a/schemas/gate-rejection.schema.json b/schemas/gate-rejection.schema.json new file mode 100644 index 0000000..b87a5c0 --- /dev/null +++ b/schemas/gate-rejection.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "rule": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "logs": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "rule", + "count", + "logs" + ], + "additionalProperties": false +} diff --git a/schemas/model-ref.schema.json b/schemas/model-ref.schema.json new file mode 100644 index 0000000..4dcc537 --- /dev/null +++ b/schemas/model-ref.schema.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "pattern": "^[^\\s/]+\\/\\S+$" +} diff --git a/schemas/prior-art-search.schema.json b/schemas/prior-art-search.schema.json new file mode 100644 index 0000000..61af747 --- /dev/null +++ b/schemas/prior-art-search.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "idea": { + "type": "string" + }, + "query": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "ok", + "failed" + ] + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "title", + "url" + ], + "additionalProperties": false + } + }, + "searchedAt": { + "type": "string" + }, + "run": { + "type": "string" + } + }, + "required": [ + "idea", + "query", + "status", + "results", + "searchedAt" + ], + "additionalProperties": false +} diff --git a/schemas/proposal-kind.schema.json b/schemas/proposal-kind.schema.json new file mode 100644 index 0000000..4b31168 --- /dev/null +++ b/schemas/proposal-kind.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "enum": [ + "prompt-guidance", + "gate-tuning", + "domain-pack" + ] +} diff --git a/schemas/proposal.schema.json b/schemas/proposal.schema.json new file mode 100644 index 0000000..fab6de0 --- /dev/null +++ b/schemas/proposal.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "prompt-guidance", + "gate-tuning", + "domain-pack" + ] + }, + "cluster": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "gate", + "audit", + "qa", + "replan", + "eval" + ] + }, + "label": { + "type": "string", + "minLength": 1 + }, + "seat": { + "type": "string", + "minLength": 1 + }, + "occurrences": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "runIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "key", + "kind", + "label", + "occurrences", + "runIds", + "evidence" + ], + "additionalProperties": false + }, + "rationale": { + "type": "string", + "minLength": 1 + }, + "files": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "id", + "kind", + "cluster", + "rationale", + "files" + ], + "additionalProperties": false +} diff --git a/schemas/run-phase-telemetry.schema.json b/schemas/run-phase-telemetry.schema.json new file mode 100644 index 0000000..4876b20 --- /dev/null +++ b/schemas/run-phase-telemetry.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "attempts": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "replans": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seat": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "minLength": 1 + }, + "rule": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "kind", + "reason" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "status", + "attempts", + "replans", + "failures" + ], + "additionalProperties": false +} diff --git a/schemas/run-state-seal.schema.json b/schemas/run-state-seal.schema.json new file mode 100644 index 0000000..804e1bf --- /dev/null +++ b/schemas/run-state-seal.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "error": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "valid" + ], + "additionalProperties": false +} diff --git a/schemas/run-telemetry.schema.json b/schemas/run-telemetry.schema.json new file mode 100644 index 0000000..5373aaf --- /dev/null +++ b/schemas/run-telemetry.schema.json @@ -0,0 +1,276 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string" + }, + "stage": { + "type": "string" + }, + "halt": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "from": { + "type": "string" + } + }, + "required": [ + "reason", + "from" + ], + "additionalProperties": false + }, + "spend": { + "type": "object", + "properties": { + "usd": { + "type": "number", + "minimum": 0 + }, + "estimated": { + "type": "boolean" + }, + "events": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "usd", + "estimated", + "events" + ], + "additionalProperties": false + }, + "phases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "attempts": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "replans": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seat": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "minLength": 1 + }, + "rule": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "kind", + "reason" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "status", + "attempts", + "replans", + "failures" + ], + "additionalProperties": false + } + }, + "gateRejections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rule": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "logs": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "rule", + "count", + "logs" + ], + "additionalProperties": false + } + }, + "auditFindings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "severity": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sources": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "kind", + "severity", + "count", + "sources" + ], + "additionalProperties": false + } + }, + "auditChain": { + "type": "object", + "properties": { + "entries": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "valid": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "trail": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "hash": { + "type": "string", + "minLength": 64, + "maxLength": 64 + }, + "action": { + "type": "string", + "minLength": 1 + }, + "phase": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "seq", + "hash", + "action" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entries", + "valid", + "trail" + ], + "additionalProperties": false + }, + "seal": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "error": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "valid" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "mode", + "stage", + "spend", + "phases", + "gateRejections", + "auditFindings", + "auditChain", + "seal" + ], + "additionalProperties": false +} diff --git a/schemas/scout-assessment.schema.json b/schemas/scout-assessment.schema.json index 6261aae..babb045 100644 --- a/schemas/scout-assessment.schema.json +++ b/schemas/scout-assessment.schema.json @@ -116,6 +116,10 @@ "REJECTED" ] }, + "tier": { + "type": "string", + "minLength": 1 + }, "source": { "type": "object", "properties": { diff --git a/schemas/telemetry.schema.json b/schemas/telemetry.schema.json new file mode 100644 index 0000000..a55d219 --- /dev/null +++ b/schemas/telemetry.schema.json @@ -0,0 +1,343 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 2 + }, + "harvestedAt": { + "type": "string", + "minLength": 1 + }, + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string" + }, + "stage": { + "type": "string" + }, + "halt": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "from": { + "type": "string" + } + }, + "required": [ + "reason", + "from" + ], + "additionalProperties": false + }, + "spend": { + "type": "object", + "properties": { + "usd": { + "type": "number", + "minimum": 0 + }, + "estimated": { + "type": "boolean" + }, + "events": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "usd", + "estimated", + "events" + ], + "additionalProperties": false + }, + "phases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "attempts": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "replans": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seat": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "minLength": 1 + }, + "rule": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "kind", + "reason" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "status", + "attempts", + "replans", + "failures" + ], + "additionalProperties": false + } + }, + "gateRejections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rule": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "logs": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "rule", + "count", + "logs" + ], + "additionalProperties": false + } + }, + "auditFindings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "severity": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sources": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "kind", + "severity", + "count", + "sources" + ], + "additionalProperties": false + } + }, + "auditChain": { + "type": "object", + "properties": { + "entries": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "valid": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "trail": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "hash": { + "type": "string", + "minLength": 64, + "maxLength": 64 + }, + "action": { + "type": "string", + "minLength": 1 + }, + "phase": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "seq", + "hash", + "action" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entries", + "valid", + "trail" + ], + "additionalProperties": false + }, + "seal": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "error": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "valid" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "mode", + "stage", + "spend", + "phases", + "gateRejections", + "auditFindings", + "auditChain", + "seal" + ], + "additionalProperties": false + } + }, + "evals": { + "type": "array", + "items": { + "type": "object", + "properties": { + "case": { + "type": "string", + "minLength": 1 + }, + "pass": { + "type": "boolean" + }, + "failures": { + "type": "array", + "items": { + "type": "string" + } + }, + "steps": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "model": { + "type": "string" + }, + "modelLimited": { + "type": "boolean" + }, + "source": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "case", + "pass", + "failures", + "steps", + "source" + ], + "additionalProperties": false + } + } + }, + "required": [ + "version", + "harvestedAt", + "runs", + "evals" + ], + "additionalProperties": false +} diff --git a/schemas/waiver.schema.json b/schemas/waiver.schema.json index c37e868..94a00b0 100644 --- a/schemas/waiver.schema.json +++ b/schemas/waiver.schema.json @@ -41,7 +41,8 @@ "type": "string", "enum": [ "cli", - "tui" + "tui", + "web" ] }, "auditHead": { diff --git a/scripts/deps.ts b/scripts/deps.ts index 8c4edb2..dbd4e28 100644 --- a/scripts/deps.ts +++ b/scripts/deps.ts @@ -1,10 +1,13 @@ // The plan's minimal-dependency rule, enforced on this repository: core's // runtime dependencies must stay within the allowlist, and anything else needs // a recorded justification in .factory/dependencies.json. Run by `bun run check` -// in CI (AGENTS.md: the allowlist is "enforced by our own gate"). -import { readFile } from "node:fs/promises" +// in CI (AGENTS.md: the allowlist is "enforced by our own gate"). It also holds +// the package boundaries from docs/adr/0001-monorepo.md. +import { readdir, readFile } from "node:fs/promises" import path from "node:path" import { CORE_DEPENDENCY_ALLOWLIST, checkDependencies } from "../packages/core/src/gates/minimal-deps.ts" +import { importsOf } from "./imports.ts" +import { workspacePackages } from "./packages.ts" const root = path.resolve(import.meta.dir, "..") const pkg = JSON.parse(await readFile(path.join(root, "packages/core/package.json"), "utf8")) as { @@ -23,6 +26,85 @@ if (!check.ok) { console.error("Add the dependency to .factory/dependencies.json with a reason, or remove it.") process.exit(1) } + +// Package boundaries (docs/adr/0001-monorepo.md): core stays harness-neutral, +// so it may not import the CLI or the plugin, and the CLI may not import the +// plugin. Both bare specifiers and relative paths into another package count. +// Every workspace package needs an entry: a new surface fails the gate until +// it declares which packages it may not import, and core and cli may never +// import a new surface (their entries must ban it). +const BOUNDARIES = [ + { pkg: "core", banned: ["cli", "opencode", "testkit", "fleet", "web"] }, + { pkg: "cli", banned: ["opencode", "testkit", "fleet", "web"] }, + // The plugin never imports the fleet daemon or the web UI (AGENTS.md: core, + // CLI and plugin never import fleet; nothing imports web). + { pkg: "opencode", banned: ["fleet", "web"] }, + // The test harness never imports the fleet daemon or the web UI either. + { pkg: "testkit", banned: ["fleet", "web"] }, + // Fleet depends on core only (#122): never on the CLI or the plugin. + // It serves the built web UI as static files (#129), never as an import. + { pkg: "fleet", banned: ["cli", "opencode", "web"] }, + // Web is a leaf surface (#129): nothing imports it, and it imports + // nothing at runtime except the fleet API's schema types (type-only). + // `fleet` stays banned with a type-only exemption below: a top-level + // `import type` (or `export type`) is erased, so only a value import of + // fleet from web fails the gate. Inline `{ type A }` members are not + // erased under verbatimModuleSyntax (scripts/imports.ts). + // The test-only exception lives in `test/`, outside this gate's `src/` + // scan: packages/web/test/bus.contract.test.ts runtime-imports the fleet + // server harness for the WS contract test. + { pkg: "web", banned: ["core", "cli", "opencode", "testkit", "fleet"] }, +] as const + +const crossings: string[] = [] + +// The workspace is the universe: a package with no BOUNDARIES entry fails +// until it declares its bans, and core/cli entries must ban every other +// surface, so a new package cannot be silently imported by (or silently +// importable from) the harness-neutral layers. Bare-specifier names come +// from the same list (AGENTS.md one-list rule, #102): no second hand map +// that could miss a surface (a bare `@heretek-ai/es-core` import from web +// used to pass because the old map had no `core` key). +const workspace = await workspacePackages(root) +const NPM_NAME: Record = Object.fromEntries(workspace.map((pkg) => [pkg.dir, pkg.name])) +const universe = workspace.map((pkg) => pkg.dir) +for (const pkg of universe) + if (!BOUNDARIES.some((entry) => entry.pkg === pkg)) + crossings.push(`packages/${pkg} has no BOUNDARIES entry: declare which packages ${pkg} may not import`) +for (const outer of ["core", "cli"] as const) { + const bans = BOUNDARIES.find((entry) => entry.pkg === outer)?.banned ?? [] + for (const pkg of universe) + if (pkg !== outer && pkg !== "core" && pkg !== "cli" && !(bans as readonly string[]).includes(pkg)) + crossings.push(`packages/${outer} may import packages/${pkg}: new surfaces stay out of core and the CLI`) +} +for (const { pkg, banned } of BOUNDARIES) { + const src = path.join(root, "packages", pkg, "src") + const files = (await readdir(src, { recursive: true })).filter((file) => /\.tsx?$/.test(file)) + for (const file of files) { + const absolute = path.join(src, file) + const text = await readFile(absolute, "utf8") + for (const { specifier, typeOnly } of importsOf(text)) { + const target = specifier.startsWith(".") ? path.resolve(path.dirname(absolute), specifier) : undefined + const hit = banned.find((other) => + target + ? target.startsWith(path.join(root, "packages", other) + path.sep) + : specifier === NPM_NAME[other] || specifier.startsWith(`${NPM_NAME[other]}/`), + ) + if (!hit) continue + // Web's fleet exemption: a type-only import carries no runtime + // dependency, so only a value import of fleet from web crosses. + if (pkg === "web" && hit === "fleet" && typeOnly) continue + crossings.push(`packages/${pkg}/src/${file} imports ${specifier} (${hit})`) + } + } +} +if (crossings.length) { + console.error("Package boundary gate failed (docs/adr/0001-monorepo.md):") + for (const crossing of crossings) console.error(` - ${crossing}`) + console.error("Core may not import the CLI or the plugin; the CLI may not import the plugin.") + process.exit(1) +} + console.log( - `deps: ok (${Object.keys(dependencies).length} core runtime dependencies; all allowlisted or justified)`, + `deps: ok (${Object.keys(dependencies).length} core runtime dependencies; all allowlisted or justified; package boundaries hold)`, ) diff --git a/scripts/docs.ts b/scripts/docs.ts index 57eaa3c..ddeebce 100644 --- a/scripts/docs.ts +++ b/scripts/docs.ts @@ -125,6 +125,26 @@ const capabilitiesDoc = [ ].join("\n") // ------------------------------------------------- config doc +// Nested models.* keys (#96): the generated JSON Schema carries them under +// properties.models.properties, so document them in their own table. +function nestedConfigDoc(configJson: { + properties?: Record }> +}): string[] { + const nested = configJson.properties?.models?.properties ?? {} + const names = Object.keys(nested) + if (names.length === 0) return [] + return [ + "Nested `models.*` keys (fail-closed: malformed refs and unknown agent ids are rejected at load; a", + "configured model missing on the host refuses that seat at launch):", + "", + "| field | description |", + "| --- | --- |", + ...names.map( + (name) => `| \`models.${name}\` | ${(nested[name]?.description ?? "").replaceAll("\\", "\\\\").replaceAll("|", "\\|")} |`, + ), + "", + ] +} const configSchema = outputs.find((output) => output.file === "schemas/es-config.schema.json") if (!configSchema) throw new Error("docs: schemas/es-config.schema.json missing; is EsConfigSchema exported?") const configJson = JSON.parse(configSchema.content) as { @@ -146,6 +166,7 @@ const configDoc = [ return `| \`${name}\` | ${type}${property.enum ? ` (${property.enum.join(", ")})` : ""} | ${fallback} | ${(property.description ?? "").replaceAll("\\", "\\\\").replaceAll("|", "\\|")} |` }), "", + ...nestedConfigDoc(configJson), "`es config show` and `/config` print the effective config and which files contributed.", "", ].join("\n") diff --git a/scripts/evals.ts b/scripts/evals.ts index 24a7ea1..2858fbf 100644 --- a/scripts/evals.ts +++ b/scripts/evals.ts @@ -15,7 +15,7 @@ import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" import { gradeFireCase } from "../packages/core/src/evals/fires.ts" import { caseStepCap, type EvalCase, evalWorkspace, runEvalCase } from "../packages/core/src/evals/run.ts" -import { renderEvalPrompt, startServeFixtures } from "../packages/core/src/evals/serve.ts" +import { renderEvalPrompt, startServeFixtures, usesFixtureServer } from "../packages/core/src/evals/serve.ts" import { exists } from "../packages/core/src/util/fs.ts" import { sealHumanKey, unlockHumanKey } from "../packages/core/src/approval/keystore.ts" import { detectGates } from "../packages/core/src/gates/detect.ts" @@ -127,7 +127,19 @@ for (const file of files) { } } try { - const ran = await runEvalCase(testCase, workspace, { binary, maxSteps, timeoutMs, budgetUSD: remaining }) + // Fixture-server cases fetch loopback pages the SSRF guard refuses by + // default: allow loopback for just this case's seat environment, never + // globally and never for cases that reach the public web. + const env = usesFixtureServer(testCase.prompt) + ? { ...process.env, ES_RESEARCH_ALLOW_LOOPBACK: "1" } + : undefined + const ran = await runEvalCase(testCase, workspace, { + binary, + maxSteps, + timeoutMs, + budgetUSD: remaining, + ...(env ? { env } : {}), + }) spentUSD += ran.transcript.costUSD // A fire is scored by the same deterministic grader the CI suites use. const fire = testCase.fire ? await gradeFireCase(testCase.fire, workspace.dir) : undefined diff --git a/scripts/imports.ts b/scripts/imports.ts new file mode 100644 index 0000000..93bcdad --- /dev/null +++ b/scripts/imports.ts @@ -0,0 +1,34 @@ +// Import scanning for the package-boundary gate (scripts/deps.ts), split out +// so the type-only rule is testable without running the gate. + +// One static import/export statement (`from "specifier"`), a dynamic +// `import("specifier")`, or a side-effect `import "specifier"`. The clause +// decides whether a static import is type-only. +const IMPORT_STMT = /(import|export)\s+([^;]*?)\s*from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)|import\s*["']([^"']+)["']/g + +export interface ImportRef { + readonly specifier: string + /** Erased at compile time, so it carries no runtime dependency. */ + readonly typeOnly: boolean +} + +/** + * Whether a static import/export clause is erased under the repo's + * `verbatimModuleSyntax: true` (tsconfig.base.json). Only a top-level + * `import type …` / `export type …` is: inline members + * (`import { type A } from "x"`) compile to `import {} from "x"`, which still + * loads the module. A default import named `type` (`import type from "x"`, + * `import type, { A } from "x"`) is a value import, so `type` must be followed + * by a binding, a brace or `*`. + */ +const isTypeOnlyClause = (clause: string): boolean => /^type\s+[{*\w$]/.test(clause.trim()) + +/** Every import or re-export specifier in a source file, with whether it is type-only. */ +export function importsOf(text: string): ImportRef[] { + const refs: ImportRef[] = [] + for (const match of text.matchAll(IMPORT_STMT)) { + const specifier = match[3] ?? match[4] ?? match[5]! + refs.push({ specifier, typeOnly: match[3] !== undefined && isTypeOnlyClause(match[2]!) }) + } + return refs +} diff --git a/scripts/pack-smoke.sh b/scripts/pack-smoke.sh index 93524e9..8468e84 100755 --- a/scripts/pack-smoke.sh +++ b/scripts/pack-smoke.sh @@ -12,26 +12,31 @@ root="$(cd "$(dirname "$0")/.." && pwd)" out="${1:-$(mktemp -d)}" mkdir -p "$out" out="$(cd "$out" && pwd)" +# The release packages and their tarball slugs, in publish order, from the +# single source of truth (scripts/packages.ts reads packages/*/package.json). +release="$(bun "$root/scripts/packages.ts" --release)" # Drop tarballs from previous runs: a stale same-name version would shadow the # fresh pack when npm resolves the workspace dependencies between tarballs. -rm -f "$out"/heretek-ai-es-core-*.tgz "$out"/heretek-ai-es-cli-*.tgz "$out"/heretek-ai-epistemic-swarm-*.tgz +while read -r pkg slug; do rm -f "$out"/${slug}-*.tgz; done <<< "$release" -(cd "$root/packages/core" && rm -rf dist tsconfig.tsbuildinfo && bunx tsc -b) -(cd "$root/packages/cli" && rm -rf dist && bun run build >/dev/null) -# The TUI entry ships pre-compiled: the host Solid transform skips -# node_modules, where npm-installed plugins live, so raw .tsx would fall back -# to react-jsx and fail on 'react' at load. -(cd "$root/packages/opencode" && rm -rf dist && bun run build >/dev/null) -for pkg in core cli opencode; do +# Build every release package from the same single source of truth (no hand +# list to drift): each manifest's `build` script knows how (`tsc -b` for core, +# `bun build` for the CLI; the TUI entry ships pre-compiled because the host +# Solid transform skips node_modules, where npm-installed plugins live, so +# raw .tsx would fall back to react-jsx and fail on 'react' at load). +while read -r pkg slug; do + (cd "$root/packages/$pkg" && rm -rf dist tsconfig.tsbuildinfo && bun run build >/dev/null) +done <<< "$release" +while read -r pkg slug; do (cd "$root/packages/$pkg" && bun pm pack --destination "$out" >/dev/null) -done +done <<< "$release" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT cd "$work" npm init -y >/dev/null npm install --ignore-scripts --no-audit --no-fund \ - "$out"/heretek-ai-es-core-*.tgz "$out"/heretek-ai-es-cli-*.tgz "$out"/heretek-ai-epistemic-swarm-*.tgz >/dev/null + $(while read -r pkg slug; do echo "$out"/${slug}-*.tgz; done <<< "$release") >/dev/null if grep -l '"workspace:' node_modules/@heretek-ai/*/package.json; then echo "pack-smoke: a workspace: protocol leaked into a packed manifest" >&2 diff --git a/scripts/packages.ts b/scripts/packages.ts new file mode 100644 index 0000000..a0fe93b --- /dev/null +++ b/scripts/packages.ts @@ -0,0 +1,93 @@ +// One source of truth for the workspace and release package lists (#102). +// Shell scripts and workflows call the CLI (`bun scripts/packages.ts +// [--release] [--tsconfig] [--browser] [--no-browser]`); deps.ts imports the +// functions. Reads packages/*/package.json: release order comes from each +// manifest's `esRelease.order` (a public package without one is a bug, so +// `releasePackages` throws), and the `bun --conditions=browser` test split +// comes from each manifest's `esTest.conditions` (private packages never +// release). +import { readdir, readFile } from "node:fs/promises" +import path from "node:path" + +export interface WorkspacePackage { + /** Directory under packages/ (also the short name scripts use). */ + readonly dir: string + readonly name: string + readonly private: boolean + /** Publish order for release packages; undefined when not released. */ + readonly order: number | undefined + /** Bun test conditions (e.g. `browser` for the client Solid build). */ + readonly conditions: readonly string[] + readonly hasTsconfig: boolean +} + +const rootOf = (root?: string) => root ?? path.resolve(import.meta.dir, "..") + +export async function workspacePackages(root?: string): Promise { + const base = path.join(rootOf(root), "packages") + const entries = await readdir(base, { withFileTypes: true }) + const found: WorkspacePackage[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + const manifest = await readFile(path.join(base, entry.name, "package.json"), "utf8") + .then((text) => JSON.parse(text) as Record) + .catch(() => undefined) + if (!manifest || typeof manifest.name !== "string") continue + const esRelease = manifest.esRelease as { order?: unknown } | undefined + const order = typeof esRelease?.order === "number" ? esRelease.order : undefined + const esTest = manifest.esTest as { conditions?: unknown } | undefined + const conditions = Array.isArray(esTest?.conditions) + ? esTest.conditions.filter((entry): entry is string => typeof entry === "string") + : [] + const hasTsconfig = await readFile(path.join(base, entry.name, "tsconfig.json"), "utf8") + .then(() => true) + .catch(() => false) + found.push({ + dir: entry.name, + name: manifest.name, + private: manifest.private === true, + order, + conditions, + hasTsconfig, + }) + } + return found.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)) +} + +/** Published packages in publish order: private unset, esRelease.order set. */ +export async function releasePackages(root?: string): Promise { + const all = await workspacePackages(root) + // A public package without an order would silently drop out of the release: + // fail loudly instead, so it declares `esRelease.order` or goes private. + for (const pkg of all) + if (!pkg.private && pkg.order === undefined) + throw new Error( + `packages/${pkg.dir} (${pkg.name}) is public but has no esRelease.order: declare its publish order or mark it "private": true`, + ) + const ordered = all.filter( + (pkg): pkg is WorkspacePackage & { order: number } => !pkg.private && pkg.order !== undefined, + ) + return ordered.sort((a, b) => a.order - b.order || (a.dir < b.dir ? -1 : 1)) +} + +/** Tarball slug for a package name: @heretek-ai/es-core → heretek-ai-es-core. */ +export const tarballSlug = (name: string) => name.replace(/^@/, "").replace(/\//g, "-") + +if (import.meta.main) { + const args = process.argv.slice(2) + const root = rootOf() + if (args.includes("--release")) + for (const pkg of await releasePackages(root)) console.log(`${pkg.dir} ${tarballSlug(pkg.name)}`) + else { + const all = await workspacePackages(root) + // The `bun --conditions=browser` test split: `--browser` lists only the + // packages whose manifest declares that condition, `--no-browser` the rest. + const conditioned = args.includes("--browser") + ? all.filter((pkg) => pkg.conditions.includes("browser")) + : args.includes("--no-browser") + ? all.filter((pkg) => !pkg.conditions.includes("browser")) + : all + const list = args.includes("--tsconfig") ? conditioned.filter((pkg) => pkg.hasTsconfig) : conditioned + for (const pkg of list) console.log(pkg.dir) + } +} diff --git a/spikes/P2-masked-input/RESULTS.md b/spikes/P2-masked-input/RESULTS.md new file mode 100644 index 0000000..5a5310f --- /dev/null +++ b/spikes/P2-masked-input/RESULTS.md @@ -0,0 +1,105 @@ +# Spike: masked passphrase input for the OpenCode TUI (P2, #118) + +Date: 2026-10-09. Verdict: **GO** for #119. All commands run in +`.worktrees/ticket-118` (Phase 1 HEAD `477d607` + #116 merge); nothing here +is inferred. The prototype lives on the scratch branch +`spike/P2-masked-input-proto` (commit "Spike prototype: masked passphrase +input (SCRATCH, never merge)"); only this file merges. + +## Drift from the ticket's "Current state" + +- OpenTUI is **0.5.17**, not 0.5.14 (`packages/opencode/package.json` + requires `0.5.17`; installed `packages/opencode/node_modules/@opentui/*` + report `0.5.17`). The APIs below are verified against 0.5.17. +- `@opencode/plugin` is 2.0.24 as stated; `Dialog.show(render, onClose?)`, + `clear()`, and `DialogPromptOptions` without a mask option are confirmed in + `dist/tui/context.d.ts`. +- `InputRenderable` (single-line input) **exists** in OpenTUI 0.5.17 but has + **no mask/password option** (`InputRenderableOptions`: `value`, + `minLength`, `maxLength`, `placeholder` only) and stores plaintext in its + text buffer. It cannot be used for passphrases. A custom component is + required, as the ticket suspected. + +## Answers + +1. **Can a custom `dialog.show(render)` component capture keys with no echo? + YES.** The prototype (`spike-masked-input/masked.tsx`) subscribes with + `useKeyboard` (exported from `@opentui/solid`) and renders only bullets. + Evidence — captured frames from the headless run + (`bun --conditions=browser --preload @opentui/solid/preload + spike-masked-input/proto.tsx`, exit 0): + - typing `s3cr3t-p`, backspace, `P`, Enter → mid-entry frame shows + `Passphrase: ••••••••` (8 bullets), submit delivers exactly `s3cr3t-P`; + - after submit the frame shows a bare `Passphrase:` (buffer cleared); + - every consumed key calls `preventDefault()` + `stopPropagation()`, so + nothing reaches the TUI keymap layer underneath the dialog. +2. **Where does each character live? In a closure-scoped `string[]`, never in + reactive state.** The component's only signals are `count: number` (the + bullet count, screen-equivalent — the bullets are visible anyway) and + `cancelled: boolean`. Evidence: + - a frame audit over all 9 captured frames asserts none contains any of + the 4 probe secrets (`s3cr3t-P`, `pasted-passphrase-99`, `abc`, + `hello`): `leakedFrames: []`; + - the live buffer reference kept by the test is `{length: 0, joined: ""}` + after submit (zeroed in a `finally`, and again in `onCleanup`); + - the host dialog holds only the render closure; `onSubmit` receives the + array, so #119 must copy-then-unlock-then-zero without storing it. + - Residual, accepted: the bullet count reveals passphrase *length* (the + CLI's echo-off input reveals nothing). Masking keystrokes satisfies I1; + length-hiding would cost backspace feedback and is out of scope. +3. **Paste: bracketed paste arrives as ONE `usePaste` event.** Verified with + `mockInput.pasteBracketedText("line1\nline2-secret")` → a single event + whose bytes decode to the full text. The prototype accepts it atomically + (newlines stripped) — pasting `pasted-pass\nphrase-99` renders 20 bullets + and submits exactly `pasted-passphrase-99`. Refusing paste would be a + one-line change (`preventDefault` only); accepted-paste is recommended + for usability, since the bytes never touch reactive state either way. +4. **Backspace, Esc/cancel, resize all work in the test renderer + (`testRender` + `mockInput`, same harness as `test/render/panels.tsx`). + - backspace deletes one code point (`pressBackspace` → `name: + "backspace"`); + - Esc cancels: `pressEscape()` emits `name: "escape"` **after the + lone-ESC disambiguation timeout** (~150 ms in the renderer; the real + host parses the same way, so Esc feels slightly delayed — cosmetic + only). Outcome `cancelled: true`, frame shows `Cancelled.`, no submit. + Ctrl-C is also bound to cancel-and-clear (consumed, so the host stays + alive); + - `resize(100, 30)` mid-entry keeps the 5-bullet mask. + - Host retention after `dialog.clear()`: plaintext references exist only + in the component closure, which `finish`/`onCleanup` zero before the + dialog closes; after `clear()` the closure is unreferenced and + GC-eligible. The host retains at most the render function and the + (already zeroed) outcome. Not dump-verified against the host binary — + stated as the residual integration probe for #119 (checklist probe 7 + covers it: grep state/logs/snapshots for the probe passphrase). +5. **Limitations; no fallback needed (GO, no degraded path).** + - Every key must be consumed while the dialog is open, or it leaks to the + keymap layer below. The prototype consumes all keys, printable or not. + - The component assumes the host focuses the custom dialog the way it + focuses built-in ones; #119 must verify focus in the real TUI (new + probe below). + - Multi-codepoint sequences are appended per code point; IME composition + is untested and out of scope (terminal approvals have the same shape). + - Ctrl-C-as-cancel changes the host's default Ctrl-C meaning while the + dialog is open; acceptable for a short-lived approval dialog. + +## Final API for #119 + +```tsx +maskedInput({ + title: string, + onSubmit(codepoints: string[]): void, // copy, unlock, use, zero — never store + onCancel(): void, +}): void +``` + +Implementation: `context.ui.dialog.show(() => )`; +`onSubmit`/`onCancel` call `dialog.clear()` after zeroing. Attempt limiting +(I4) wraps this component (counter file in the state dir), it is not part of +the input itself. + +## Extra probe for the #119 checklist + +- Focus check in the real TUI: open `/es-approve`, type a probe passphrase, + confirm only bullets render, confirm no keystroke reaches the session + prompt or command layer underneath, and confirm Esc/Enter behave as above.