# pragent pilot — central dashboard service A read-only overview + per-repo / per-PR drilldown over the same SQLite feedback DB the webhook writes, plus a small form to mutate `.pr-review.json` on a covered repo via the Gitea contents API. Companion to the [webhook service](README-webhook.md); reuses the webhook image (`pragent-webhook:dashboard`) — the pilot modules are baked into `/app/pilot/`, and the dashboard is just `python3 -m pilot.dashboard`. ## Architecture ``` Browser │ ▼ Caddy (TLS, wildcard cert via Cloudflare DNS-01) │ https://pragent-dashboard.marcospaulo.dev.br → 100.74.17.70:31541 ▼ Service oauth2-proxy-dashboard.pragent.svc.cluster.local (NodePort 31541, ns pragent) │ │ oauth2-proxy fronts the dashboard, enforces Logto SSO + email allowlist │ sets X-Forwarded-User / X-Forwarded-Email on accepted requests ▼ Service pragent-dashboard.pragent.svc.cluster.local (ClusterIP, ns pragent) │ ▼ pragent-dashboard pod (uid 10001, /data RO, no subprocess fan-out) │ ├── read /data/feedback.db (PVC pragent-feedback-data, RO) ├── GET .../repos/{o}/{r}/... (Gitea contents API, bot token) └── PUT .../repos/{o}/{r}/contents/.pr-review.json (edit form submit; Gitea commits a new sha) ``` Fail-soft. Nothing is ever written to local disk by the dashboard — the SQLite file is read-only and `.pr-review.json` mutations go through Gitea's contents API so the commit history records who changed what. The dashboard `Service` is **ClusterIP** — only oauth2-proxy can reach it. Public access is gated by Caddy (TLS termination) → oauth2-proxy (Logto SSO + allowlist) → dashboard. ## What it does - **Overview** (`GET /`): summary stats across all onboarded repos — total reviews, distinct PRs, finding counts by severity, false-positive / accepted-pattern scores (see "Feedback loop" in README-webhook.md), and a sparkline of review activity. - **Repo drilldown** (`GET /r//`): per-repo PRs with their last-review status, finding counts, and links to PR-level drilldowns. - **PR drilldown** (`GET /r///`): the bot's review(s) on that PR, inline findings, and reaction / resolved status harvested by `feedback_harvest.py`. - **Raw review** (`GET /r////raw`): the markdown body of the most recent review, for copy-paste / diff-with-prose workflows. - **Edit form** (`POST /r///edit`): a small HTML page that loads the current `.pr-review.json` from the repo's default branch and lets the operator edit the JSON (validated, then PUT to Gitea contents API). This is how repo-local `focus` / `instructions` / `reviewers` / `severity_threshold` get tuned per-repo after seeing the feedback roll-up. All routes return HTML (or plain text for `/raw`) with the same stylesheet (`/static/style.css`). ## Routes | method | path | auth | description | |--------|-----------------------------------|------|----------------------------------------------| | GET | `/` | yes | Overview | | GET | `/static/style.css` | no | Stylesheet | | GET | `/r//` | yes | Repo drilldown | | GET | `/r///` | yes | PR drilldown | | GET | `/r////raw` | yes | Most recent review body as markdown | | POST | `/r///edit` | yes | Edit `.pr-review.json` on the default branch | Auth is enforced by oauth2-proxy upstream; the dashboard itself only checks the `X-Forwarded-User` header that oauth2-proxy sets after a successful Logto login + email allowlist match. There is no `/health` route — don't add one to the k8s probes without updating `pilot/dashboard.py` (the handler returns 404 on unknown paths, so a probe would loop forever). ## Mutations flow through Gitea, not local fs The edit endpoint reads the current `.pr-review.json` from `GET /repos/{o}/{r}/contents/.pr-review.json?ref=`, lets the operator edit it in a form (validated as JSON, length-capped per field, no schema migration), and PUTs the new content back via the contents API with a commit message like `pragent dashboard: update .pr-review.json`. Every edit is a real Gitea commit on the default branch, attributable to `pragent-bot`, and the next webhook fire picks up the new config — no Pod restart, no image rebuild, no pod-level state. The `/data` mount is **read-only** (see the `readOnly: true` on the volumeMount in `~/k8s/pragent-dashboard.yaml`): the dashboard never writes the SQLite file, only the webhook + the daily cronjob do, and keeping it RO means a buggy deploy can't corrupt the harvested feedback. ## Auth (Logto SSO via oauth2-proxy) Authentication is delegated to oauth2-proxy, which fronts the dashboard in-cluster. The dashboard never sees a cookie or a token — it only inspects `X-Forwarded-User` (set by oauth2-proxy after a successful Logto login + email allowlist match). Missing header → 401 with `WWW-Authenticate: Basic realm="pragent-dashboard"`, which lets oauth2-proxy intercept and bounce the browser to Logto. Email allowlist lives in the ConfigMap `oauth2-proxy-dashboard-emails` in namespace `pragent`: ```yaml data: authenticated-emails: | marcos.paulodasilva.mp@gmail.com thiago@marcospaulo.dev.br ``` Edit the ConfigMap to add/remove users; oauth2-proxy hot-reloads the file (it logs `watching ... for updates`), no restart needed. This is the same isolation pattern as the minecraft-sso / code-server allowlists — see `~/.claude/memory/minecraft-sso.md`. The Logto app is `pragent-dashboard` (tenant `default`, type `Traditional`), created by direct INSERT into Logto Postgres mirroring the proven `minecraft-sso` pattern. Credentials live in `~/k8s/oauth2-proxy-dashboard-secret.yaml` (mode 600, NOT in git). Public URL: **https://pragent-dashboard.marcospaulo.dev.br** (Caddy TLS termination via wildcard cert → Tailscale → NodePort 31541 → oauth2-proxy → dashboard ClusterIP). ### Emergency bypass (cookie) If Logto goes down and you need to access the dashboard before the oauth2-proxy restart dance (see `~/.claude/memory/logto-fix.md`), `pilot/dashboard.py` can be patched to accept a fallback cookie by re-adding the `PRAGENT_DASHBOARD_TOKEN` env path — the route gate is isolated in `_is_authed` and the logic is straightforward. The current commit intentionally has no bypass because Logto SSO is the single source of truth for "who can touch `.pr-review.json`". ## Deploy The dashboard shares the webhook image, so there's nothing to rebuild beyond what the webhook already does. After editing `pilot/dashboard.py` or `pilot/dashboard_data.py`, redo the webhook image rebuild + containerd import (see `README-webhook.md` § "K8s deployment") and roll both deployments. ```bash K="microk8s kubectl" # 1. (one-time) create the Logto app + cookie secret + oauth2-proxy # See ~/.claude/memory/minecraft-sso.md for the SQL INSERT recipe # and ~/k8s/oauth2-proxy-dashboard*.yaml for the manifests. # 2. apply all pragent-dashboard manifests (dashboard + oauth2-proxy) $K apply -f ~/k8s/oauth2-proxy-dashboard.yaml $K apply -f ~/k8s/pragent-dashboard.yaml # 3. roll on image / code changes $K -n pragent rollout restart deploy/pragent-dashboard $K -n pragent rollout status deploy/pragent-dashboard --timeout=120s $K -n pragent logs -f deploy/pragent-dashboard ``` K8s manifests: - `~/k8s/pragent-dashboard.yaml` — Deployment + ClusterIP Service. - `image: pragent-webhook:dashboard` + `imagePullPolicy: Never` — local containerd only, same image as the webhook. - `nodeSelector: kubernetes.io/hostname: kubernets` — pinned to the node holding the `/data` PVC. - `securityContext: runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001` — matches the image's USER directive; fsGroup makes the RO hostpath volume readable. - `volumeMounts.feedback-data.readOnly: true` — dashboard is read-only over `/data`; mutations go through Gitea, not local fs. - No `readinessProbe` / `livenessProbe` — the dashboard has no `/health` route. If you add one to `pilot/dashboard.py`, add a probe here too. - `resources.requests: {cpu: 100m, memory: 256Mi}` / `limits: {cpu: 500m, memory: 512Mi}` — read-heavy + tiny writes, no opencode subprocess fan-out, much smaller than the webhook. - `Service.type: ClusterIP` — only oauth2-proxy can reach it. - `~/k8s/oauth2-proxy-dashboard.yaml` — Deployment + ConfigMap + NodePort Service (`oauth2-proxy-dashboard`, NodePort 31541, namespace `pragent`). Same shape as the code-server / minecraft-sso oauth2-proxy. NodePort 31541 was chosen because 31540 was the old dashboard NodePort and the 30096..30969 media range + 30350-30351 (other oauth2-proxy NodePorts) were taken. - `~/k8s/oauth2-proxy-dashboard-secret.yaml` — client-id / client-secret / cookie-secret (mode 600, NOT in git). ## Smoke test ```bash # 1. anonymous request → 302 redirect to Logto curl -I https://pragent-dashboard.marcospaulo.dev.br/ # 2. pod logs microk8s kubectl logs -n pragent -l app=oauth2-proxy-dashboard --tail=50 microk8s kubectl logs -n pragent -l app=pragent-dashboard --tail=50 # 3. in-cluster direct probe (should 401 without X-Forwarded-User) microk8s kubectl port-forward -n pragent svc/pragent-dashboard 8181:80 & sleep 2 curl -I http://localhost:8181/ # expect 401 + WWW-Authenticate: Basic curl -I -H "X-Forwarded-User: marcos@example.com" http://localhost:8181/ # expect 200 kill %1 ``` The HTML returned with a valid `X-Forwarded-User` should contain a `` (whatever the dashboard renders) and **never** `Traceback` or any Python exception output. A 401 on the unauthenticated GET is the expected behaviour — oauth2-proxy catches it and redirects to Logto. ## Threat model / security notes - **Behind Logto SSO.** Anonymous traffic gets 302 → Logto. Allowed emails (marcos, thiago) reach the dashboard after Logto login; all others see oauth2-proxy's "not authorized" page. Adding a user is a one-line ConfigMap edit; oauth2-proxy hot-reloads the allowlist. - **`PRAGENT_BOT_TOKEN` is Gitea Write scoped** to onboarded repos, so a successful auth bypass on the dashboard is Gitea repo write access, not just read. oauth2-proxy's email allowlist is the only authentication factor — there is no second factor. If this becomes a concern, swap oauth2-proxy for an IdP that supports TOTP/WebAuthn and the dashboard needs no further changes (it just reads the forwarded headers). - **CSRF on the edit form.** Per-process random secret embedded as a hidden input + double-submit via the `X-Forwarded-User` context. An attacker would need to (a) steal the user's Logto session cookie from oauth2-proxy and (b) read the rendered HTML to harvest the CSRF token. Both have to happen in the same browser. - **Read-only `/data` mount.** The dashboard can't corrupt the harvested SQLite file even if it's compromised. The webhook and the daily cronjob are the only writers. - **ClusterIP dashboard Service.** Even if a malicious actor discovered the dashboard's container port, they cannot reach it from outside the cluster — only oauth2-proxy can. NetworkPolicy is the cluster default deny. - **`uid 10001` + `runAsNonRoot: true`.** No host-level escalation if the dashboard is popped — it has no caps, no `/proc` mounts. - **No author-controlled input is `eval`-ed.** The edit form parses the JSON, validates types / lengths, and re-serialises before the Gitea PUT. The review-side hostile-input concerns from `README-webhook.md` § "Threat model" do **not** apply to the dashboard — the dashboard is a read-mostly viewer over already-harvested, already-posted data. ## Known limitations (pilot) - Logto SSO is the only auth factor — no per-user sessions, no CSRF token tied to a per-user identity (the per-process CSRF secret is global). Adequate for a single-operator dashboard; not adequate for multi-tenant. - No `/health` route — if the dashboard process wedges on a Gitea hang, k8s won't restart it. Add a `/health` route to `pilot/dashboard.py` + a probe here before relying on this in production. - The overview is a single-process render over a SQLite file that the daily cronjob also writes. A long Gitea hang during a page render can stall the dashboard until the client request times out (30 s). The underlying SQLite reader is read-only and concurrent-safe, so no data corruption — just a slow page.