diff --git a/.dockerignore b/.dockerignore index 1d81d13e..a3b11f57 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,5 +15,13 @@ tests benchmarks skills proposals +# webapp/ is copied by demo/Dockerfile — ship only its source, never the +# host's node_modules (breaks cross-arch: npm ci reinstalls in-image) or the +# generated dist/. The root vouch image ignores webapp entirely. +webapp/node_modules +webapp/dist +webapp/test-results +webapp/playwright-report +webapp/.superpowers **/__pycache__ **/*.py[cod] diff --git a/demo/.env.example b/demo/.env.example new file mode 100644 index 00000000..f4d675ba --- /dev/null +++ b/demo/.env.example @@ -0,0 +1,18 @@ +# vouch demo — environment (optional). Copy to .env to override defaults. +# +# cp .env.example .env + +# Bearer token vouch requires on its HTTP transport. The console injects it +# server-side, so the browser never sees it. Any non-empty value works for the +# local demo; change it if you like. +VOUCH_HTTP_TOKEN=vouch-demo + +# Optional — your own Anthropic API key. Set it to turn on the two LLM-backed +# actions in the console: "Compile" (approved claims -> topic pages) and +# "Summarize session". Leave it blank and the demo still runs; those two just +# report "not configured". The key stays in this container, calls go straight +# to Anthropic, and nothing is committed. +ANTHROPIC_API_KEY= + +# Optional — override the model the shim uses (defaults to a current Sonnet). +# ANTHROPIC_MODEL=claude-sonnet-4-5 diff --git a/demo/Dockerfile b/demo/Dockerfile new file mode 100644 index 00000000..6baacf9f --- /dev/null +++ b/demo/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 +# +# vouch demo — one image, one command. Bundles the vouch server (built from +# THIS checkout, so it carries the newest kb.* surface incl. delete / archive / +# supersede), the vouch-ui web console, and seeds a starter knowledge base on +# first run. `docker compose up` in this folder, open the browser, and explore +# a populated, review-gated KB. +# +# Two runtimes in one image on purpose: python runs `vouch serve`, node serves +# the console via `vite preview` (which reuses the console's own /proxy/* +# middleware, pinned at the in-container vouch endpoint). + +# ---- stage 1: build the console to static (keep the tree for vite preview) --- +FROM node:22-slim AS web +WORKDIR /web +COPY webapp/package.json webapp/package-lock.json ./ +RUN npm ci +COPY webapp/ ./ +RUN npm run build + +# ---- stage 2: runtime = node (console) + python (vouch from source) --------- +FROM node:22-slim +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + LANG=C.UTF-8 \ + NODE_ENV=production \ + VOUCH_UI_ALLOW_REMOTE=1 \ + VOUCH_TARGET=http://127.0.0.1:8731 \ + VOUCH_HTTP_TOKEN=vouch-demo \ + VOUCH_DATA_DIR=/data \ + ANTHROPIC_MODEL=claude-sonnet-4-5 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-venv curl \ + && rm -rf /var/lib/apt/lists/* + +# vouch from this checkout — the [web] extra brings the HTTP transport in. +COPY pyproject.toml README.md /src/ +COPY src /src/src +COPY adapters /src/adapters +RUN python3 -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir "/src[web]" +ENV PATH="/opt/venv/bin:$PATH" + +# the built console tree (vite preview needs vite + plugins + dist/ at runtime) +COPY --from=web /web /app/webapp + +# bring-your-own-key LLM shim: reads a prompt on stdin, calls the Anthropic +# Messages API from ANTHROPIC_API_KEY. Wired in as compile.llm_cmd by the +# entrypoint only when a key is present. Stdlib only — runs on the venv python. +COPY demo/vouch-llm.py /usr/local/bin/vouch-llm +RUN chmod +x /usr/local/bin/vouch-llm + +COPY demo/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +VOLUME ["/data"] +EXPOSE 5173 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 00000000..e47eaaa7 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,121 @@ +# vouch demo — try it in one command + +A self-contained Docker demo of [vouch](https://github.com/vouchdev/vouch), the +git-native, **review-gated** knowledge base for LLM agents. One image bundles +the vouch server and the vouch-ui web console, and seeds a starter knowledge +base on first run — so you open the browser and immediately have something to +explore. + +## Run it (no clone needed) + +One command pulls the published image and starts everything: + +```bash +docker run --rm -p 127.0.0.1:5173:5173 -v vouch-demo-data:/data \ + ghcr.io/plind-junior/vouch-demo +``` + +Then open **http://localhost:5173** and connect with the pre-filled endpoint. + +That's it. The first run seeds a starter KB (a claim, a page, a source), so the +console opens onto a populated, review-gated knowledge base — not an empty one. +Your data persists in the `vouch-demo-data` volume between runs; `Ctrl-C` stops +the demo (the `--rm` removes only the container, never the volume). + +## Update to the latest + +The image is updated in place, so pulling gets you the newest build (and any +fixes). Stop the demo, then: + +```bash +docker pull ghcr.io/plind-junior/vouch-demo # fetch the latest image +``` + +Re-run the command from "Run it" — Docker now starts the updated image, and +your `vouch-demo-data` volume carries over. To start completely fresh instead, +reset the data with `docker volume rm vouch-demo-data` before running. + +## Build from source (to hack on it) + +Working in a clone of this repo? Build the image from the checkout instead of +pulling — it picks up your local changes to `webapp/` and `src/`: + +```bash +cd demo +cp .env.example .env # optional: edit VOUCH_HTTP_TOKEN, add ANTHROPIC_API_KEY +docker compose up --build +``` + +## Turn on the LLM features (optional) + +Two console actions call a language model: **Compile** (turn approved claims +into topic pages) and **Summarize session**. They're off by default because +the demo ships no API key. Everything else — browsing, propose → approve, +delete / archive / supersede, clear queue — works without one. + +To switch them on, give the demo *your own* Anthropic key. With the pulled +image, pass it in with `-e`: + +```bash +docker run --rm -p 127.0.0.1:5173:5173 -v vouch-demo-data:/data \ + -e ANTHROPIC_API_KEY=sk-ant-... \ + ghcr.io/plind-junior/vouch-demo +``` + +Building from source? Put it in `.env` instead: + +```bash +cp .env.example .env # then set ANTHROPIC_API_KEY=sk-ant-... +docker compose up --build +``` + +The key never reaches the browser — vouch runs a tiny stdlib shim +(`vouch-llm`) inside the container that calls the Anthropic Messages API +directly. Override the model with `ANTHROPIC_MODEL` if you want a newer Sonnet. +Without a key, Compile / Summarize simply report "not configured" — that's the +review gate telling you the step is unavailable, not a crash. + +## What you can do in the console + +- **Browse / Claims** — the seeded knowledge, with citations and provenance + ("why does this claim exist?"). +- **Pending** — the propose → approve review gate, made visible. +- **Delete / Archive / Supersede** — retire a claim through the gate: delete + files a review-gated proposal (refused if other pages still cite it, which is + the point), archive hides it from retrieval, supersede replaces it. +- **Clear queue** — reject the whole pending queue at once. + +## How it works + +One container runs two processes (managed by `entrypoint.sh`): + +1. `vouch serve --transport http` on `127.0.0.1:8731` inside the container, with + a bearer token. +2. the console via `vite preview`, whose `/proxy/*` middleware forwards to the + in-container vouch endpoint. The token is **pinned and injected server-side** + (`VOUCH_TARGET` / `VOUCH_HTTP_TOKEN`), so the browser never sees it. + +Only the console port is published, and only on `127.0.0.1` — nothing leaves +your machine. Your KB lives in the `vouch-demo-data` volume: + +```bash +# pulled image (docker run): +Ctrl-C # stop (keeps your data) +docker volume rm vouch-demo-data # reset the demo KB + +# built from source (docker compose): +docker compose down # stop (keeps your data) +docker compose down -v # stop and reset the demo KB +``` + +## Notes + +- The published image (`ghcr.io/plind-junior/vouch-demo`) carries the newest + `kb.*` surface, including delete / archive / supersede — a released `vouch` + from PyPI or `ghcr.io/vouchdev/vouch` would not yet advertise those. Building + from source picks up whatever is in your checkout. +- The LLM-backed actions (Compile, Summarize session) need an + `ANTHROPIC_API_KEY` — see "Turn on the LLM features" above. The rest of the + console is fully functional without one. +- The console's "Chat / Claude Code" mode is a dev-server feature and is not + wired up in this preview build; everything else works. diff --git a/demo/docker-compose.yml b/demo/docker-compose.yml new file mode 100644 index 00000000..8b116b13 --- /dev/null +++ b/demo/docker-compose.yml @@ -0,0 +1,39 @@ +# Try vouch in one command: +# +# cd demo +# docker compose up --build # then open http://localhost:5173 +# +# A starter, review-gated knowledge base is seeded on first run. Your data +# persists in the `vouch-demo-data` volume; `docker compose down -v` resets it. +# Only the console port is published, and only on 127.0.0.1 — nothing leaves +# your machine. +name: vouch-demo + +services: + vouch-demo: + build: + context: .. + dockerfile: demo/Dockerfile + image: vouch-demo:latest + ports: + - "127.0.0.1:5173:5173" + environment: + # in-container bearer token; injected server-side, never sent to the browser + VOUCH_HTTP_TOKEN: ${VOUCH_HTTP_TOKEN:-vouch-demo} + # optional: your own Anthropic key turns on page compile & session + # summaries (Claude). Left empty, the demo still runs — those two actions + # just report "not configured". Never commit a real key. + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-5} + volumes: + - vouch-demo-data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "-m", "3", "http://127.0.0.1:5173/proxy/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + restart: unless-stopped + +volumes: + vouch-demo-data: diff --git a/demo/entrypoint.sh b/demo/entrypoint.sh new file mode 100755 index 00000000..4a850d78 --- /dev/null +++ b/demo/entrypoint.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# vouch demo entrypoint: seed a starter KB on first run, start the vouch server +# on loopback inside this container, then serve the console. Both processes live +# in one container; killing the container stops both. +set -euo pipefail + +DATA="${VOUCH_DATA_DIR:-/data}" +TOKEN="${VOUCH_HTTP_TOKEN:-vouch-demo}" +# actor recorded in the seeded KB's audit log (avoids getpass in a bare image) +export VOUCH_USER="${VOUCH_USER:-demo}" + +# First run: an empty /data volume gets a seeded, review-gated starter KB so the +# console has something to show. Idempotent — skipped once .vouch/ exists. +if [ ! -d "$DATA/.vouch" ]; then + echo "[demo] seeding a starter knowledge base in $DATA ..." + vouch init --path "$DATA" +fi + +# vouch's LLM features (page compile, session summaries) shell out to the +# command in `compile.llm_cmd`. Point it at the demo shim only when the user +# supplied a key — otherwise leave it unset so those actions return vouch's +# clean "not configured" message instead of a dead command. Re-run every start +# so an existing volume lights up the moment a key appears (and goes dark if it +# is removed). Everything else — browse, propose→approve, delete/archive — works +# with no key at all. +CONFIG="$DATA/.vouch/config.yaml" +if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + LLM_CMD="vouch-llm" python3 - "$CONFIG" <<'PY' +import os, sys, yaml +path = sys.argv[1] +with open(path) as f: + cfg = yaml.safe_load(f) or {} +cfg.setdefault("compile", {})["llm_cmd"] = os.environ["LLM_CMD"] +with open(path, "w") as f: + yaml.safe_dump(cfg, f, sort_keys=False) +PY + echo "[demo] LLM features ENABLED — Claude via ANTHROPIC_API_KEY (model=${ANTHROPIC_MODEL:-claude-sonnet-4-5})." +else + # Drop any llm_cmd a previous keyed run wrote, so state matches the missing key. + python3 - "$CONFIG" <<'PY' +import sys, yaml +path = sys.argv[1] +with open(path) as f: + cfg = yaml.safe_load(f) or {} +if isinstance(cfg.get("compile"), dict): + cfg["compile"].pop("llm_cmd", None) + if not cfg["compile"]: + cfg.pop("compile") +with open(path, "w") as f: + yaml.safe_dump(cfg, f, sort_keys=False) +PY + echo "[demo] LLM features DISABLED — set ANTHROPIC_API_KEY to enable page compile & session summaries; everything else works without it." +fi + +# vouch on loopback (same container). A token is set so the console's proxy can +# inject it server-side; the browser never sees it. +echo "[demo] starting vouch server on 127.0.0.1:8731 ..." +( cd "$DATA" && exec vouch serve --transport http --host 127.0.0.1 --port 8731 --token "$TOKEN" ) & +VOUCH_PID=$! +trap 'kill "$VOUCH_PID" 2>/dev/null || true' EXIT INT TERM + +# Wait for vouch to answer its public liveness probe before starting the UI. +for _ in $(seq 1 30); do + if curl -fsS -m 2 "http://127.0.0.1:8731/health" >/dev/null 2>&1; then break; fi + sleep 1 +done + +echo "[demo] starting the vouch console on :5173 — open http://localhost:5173" +cd /app/webapp +exec npm run preview -- --host 0.0.0.0 --port 5173 diff --git a/demo/vouch-llm.py b/demo/vouch-llm.py new file mode 100755 index 00000000..ead3a180 --- /dev/null +++ b/demo/vouch-llm.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Minimal Anthropic Messages shim for the vouch demo image. + +vouch's LLM-backed features (page compile, session summaries) don't call an +API directly — they shell out to a deployment-configured command +(`compile.llm_cmd` in .vouch/config.yaml) with the prompt on stdin, and read +the model's reply from stdout. In a normal install that command is the local +`claude` CLI. The demo image has no CLI and no baked-in key, so this shim is +the `llm_cmd`: it reads the prompt on stdin and calls the Anthropic Messages +API using a key the *user* supplies via ANTHROPIC_API_KEY. + +Stdlib only (urllib) — no extra pip dependency, mirroring vouch's own client +in src/vouch/pr_cache.py. Emits only the model's text on stdout so vouch's +`parse_drafts` sees a clean JSON array; all diagnostics go to stderr, and a +non-zero exit lets vouch surface a clean "compile.llm_cmd failed" message. + +Env: + ANTHROPIC_API_KEY required — user's key; absent => exit 3, features off. + ANTHROPIC_MODEL default claude-sonnet-4-5 (override for a newer Sonnet). + ANTHROPIC_BASE_URL default https://api.anthropic.com + ANTHROPIC_MAX_TOKENS default 8192 (compile/split return multi-page JSON). + ANTHROPIC_TIMEOUT default 150 (seconds; below vouch's own subprocess cap). +""" +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request + + +def main() -> int: + key = os.environ.get("ANTHROPIC_API_KEY", "").strip() + if not key: + sys.stderr.write( + "ANTHROPIC_API_KEY is not set — this demo's LLM features " + "(page compile, session summaries) are disabled. Set the key and " + "restart to enable Claude.\n" + ) + return 3 + + model = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5").strip() + base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com").rstrip("/") + try: + max_tokens = int(os.environ.get("ANTHROPIC_MAX_TOKENS", "8192")) + timeout = float(os.environ.get("ANTHROPIC_TIMEOUT", "150")) + except ValueError as e: + sys.stderr.write(f"invalid ANTHROPIC_MAX_TOKENS/ANTHROPIC_TIMEOUT: {e}\n") + return 2 + + prompt = sys.stdin.read() + payload = json.dumps({ + "model": model, + "max_tokens": max_tokens, + "messages": [{"role": "user", "content": prompt}], + }).encode() + req = urllib.request.Request( + f"{base}/v1/messages", + data=payload, + method="POST", + headers={ + "content-type": "application/json", + "x-api-key": key, + "anthropic-version": "2023-06-01", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + except urllib.error.HTTPError as e: + detail = (e.read().decode("utf-8", "replace") or "").strip()[:400] + sys.stderr.write(f"anthropic API {e.code}: {detail}\n") + return 1 + except (urllib.error.URLError, TimeoutError) as e: + sys.stderr.write(f"anthropic API call failed: {e}\n") + return 1 + + try: + data = json.loads(body) + except json.JSONDecodeError: + sys.stderr.write(f"anthropic API returned non-JSON: {body[:200]!r}\n") + return 1 + + text = "".join( + block.get("text", "") + for block in (data.get("content") or []) + if isinstance(block, dict) and block.get("type") == "text" + ) + if not text.strip(): + sys.stderr.write(f"anthropic API returned no text content: {body[:200]!r}\n") + return 1 + + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/webapp/plugins/vouch-proxy.test.ts b/webapp/plugins/vouch-proxy.test.ts index 3b1e0f0c..081288b3 100644 --- a/webapp/plugins/vouch-proxy.test.ts +++ b/webapp/plugins/vouch-proxy.test.ts @@ -155,3 +155,45 @@ test('normal loopback requests still pass through the proxy (no VOUCH_UI_ALLOW_R }) expect(res.status).toBe(200) }) + +test('pinned target forwards regardless of X-Vouch-Target and injects the token', async () => { + const mw = proxyMiddleware({ target: upstreamUrl, token: 'pinned-token' }) + const pinned = http.createServer((req, res) => + mw(req, res, () => { + res.statusCode = 404 + res.end('nope') + }), + ) + await new Promise((r) => pinned.listen(0, '127.0.0.1', r)) + const pinnedUrl = `http://127.0.0.1:${(pinned.address() as AddressInfo).port}` + try { + // a bogus X-Vouch-Target must be ignored: pinned mode always uses opts.target + const res = await fetch(`${pinnedUrl}/proxy/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-vouch-target': 'http://example.invalid:9' }, + body: JSON.stringify({ method: 'kb.status' }), + }) + expect(res.status).toBe(200) + const echoed = await res.json() + expect(echoed.path).toBe('/rpc') + expect(echoed.auth).toBe('Bearer pinned-token') // token injected server-side + expect(JSON.parse(echoed.body).method).toBe('kb.status') + } finally { + pinned.close() + } +}) + +test('pinned token does not clobber a client-supplied Authorization', async () => { + const mw = proxyMiddleware({ target: upstreamUrl, token: 'pinned-token' }) + const pinned = http.createServer((req, res) => mw(req, res, () => res.end())) + await new Promise((r) => pinned.listen(0, '127.0.0.1', r)) + const pinnedUrl = `http://127.0.0.1:${(pinned.address() as AddressInfo).port}` + try { + const res = await fetch(`${pinnedUrl}/proxy/health`, { + headers: { authorization: 'Bearer client-supplied' }, + }) + expect((await res.json()).auth).toBe('Bearer client-supplied') + } finally { + pinned.close() + } +}) diff --git a/webapp/plugins/vouch-proxy.ts b/webapp/plugins/vouch-proxy.ts index 8e2b9442..043a79c1 100644 --- a/webapp/plugins/vouch-proxy.ts +++ b/webapp/plugins/vouch-proxy.ts @@ -32,7 +32,21 @@ export function isLoopback(addr: string | undefined): boolean { * in X-Vouch-Target. Third-party pages cannot drive this: a custom request * header forces a CORS preflight, which this middleware never answers. */ -export function proxyMiddleware(): (req: IncomingMessage, res: ServerResponse, next: NextFn) => void { +/** + * Options for a deployed (non-dev) proxy — e.g. the Docker image. + * `target` pins one upstream and ignores X-Vouch-Target so the browser never + * chooses the endpoint; `token` is injected as a bearer server-side so the + * secret never reaches the browser. Both empty = the dev behavior (per-request + * X-Vouch-Target, no injected token). + */ +export interface ProxyOptions { + target?: string + token?: string +} + +export function proxyMiddleware( + opts: ProxyOptions = {}, +): (req: IncomingMessage, res: ServerResponse, next: NextFn) => void { return (req, res, next) => { if (!req.url || !(req.url === '/proxy' || req.url.startsWith('/proxy/'))) return next() @@ -40,7 +54,9 @@ export function proxyMiddleware(): (req: IncomingMessage, res: ServerResponse, n return fail(res, 403, 'forbidden', 'proxy is only available to loopback clients') } - const raw = req.headers['x-vouch-target'] + // Pinned upstream (deploy) wins over the per-request header (dev). Pinning + // also removes the SSRF surface: a client can no longer aim the proxy. + const raw = opts.target && opts.target.length > 0 ? opts.target : req.headers['x-vouch-target'] if (typeof raw !== 'string' || raw.length === 0) { return fail(res, 400, 'bad_target', 'missing X-Vouch-Target header') } @@ -59,6 +75,7 @@ export function proxyMiddleware(): (req: IncomingMessage, res: ServerResponse, n const headers: Record = {} if (req.headers['content-type']) headers['content-type'] = String(req.headers['content-type']) if (req.headers.authorization) headers.authorization = String(req.headers.authorization) + else if (opts.token && opts.token.length > 0) headers.authorization = `Bearer ${opts.token}` const upstream = mod.request( { @@ -110,7 +127,12 @@ export function vouchProxy(): Plugin { server.middlewares.use(proxyMiddleware()) }, configurePreviewServer(server) { - server.middlewares.use(proxyMiddleware()) + // `vite preview` is the container's production server. Pin the upstream + // + token from env when set (the Docker/compose deploy), else keep the + // dev X-Vouch-Target behavior for a local `npm run preview`. + server.middlewares.use( + proxyMiddleware({ target: process.env.VOUCH_TARGET, token: process.env.VOUCH_HTTP_TOKEN }), + ) }, } }