diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af0d55b..aed8e20 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -286,6 +286,7 @@ writer + concurrent readers). | `ledger.rs` | (external only: `rusqlite`, `serde_json`, `sha2`) — callers pass `db_path: &str` + `retain_days`, so ledger stays decoupled from `config` | `strategies`, `proxy::*` | | `config.rs` | `error` | Everything else | | `error.rs` | – | – | +| `admin/*` *(POC: cockpit control API)* | `config`, `ledger`, `fsperm`, `proxy::*` read-only handles (external: `hyper`, `http-body-util`, `serde_json`, `getrandom`, `hex`) | `proxy::gateway` internals, `strategies::*` mutation — reads config/ledger only; **must not** share a surface with the OAuth-token-bearing gateway listener | | `summarizer/mod.rs` | `config`, `proxy::upstream`, `strategies`, `reprune`, `ledger`, `summarizer::{api,harm_check,slice}` | `proxy::gateway` | | `summarizer/api.rs` | `config`, `proxy::upstream`, `summarizer` (sibling constants) | `strategies`, `pairing`, `ledger` | | `summarizer/harm_check.rs` | `summarizer` (sibling constants) | Everything else | diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..476ed73 --- /dev/null +++ b/app/README.md @@ -0,0 +1,62 @@ +# trimwire Flightdeck — desktop app shell (Tauri 2, POC scaffold) + +A minimal **Tauri 2** shell that wraps the **same web cockpit** the trimwire binary +serves on its loopback control API. This is the "multi-platform app" layer of the +cockpit POC (see [`../docs/cockpit/05-multiplatform-app.md`](../docs/cockpit/05-multiplatform-app.md)). + +> **Scaffold only — not wired into the Rust workspace or CI.** The trimwire repo +> root is a single Rust package (no `[workspace]`), so this nested `src-tauri/` +> crate is ignored by `cargo build`/`fmt`/`clippy` at the root. Building the app +> needs the Tauri toolchain (`npm`, `tauri-cli`) and is intentionally out of scope +> for the daemon's CI. It exists to show the shape. + +## What it demonstrates + +- **Shape A** from the plan (`05-multiplatform-app.md` §1): the app is a *thin + webview shell* pointed at the already-running daemon's cockpit + (`http://127.0.0.1:8766`). No second UI — it loads the exact frontend the + browser does. +- The Rust core is tiny (Tauri's value): `src-tauri/src/main.rs` is the standard + builder. The daemon is a *separate process* the app talks to over the loopback + control API — so there's no sidecar to notarize (sidestepping the worst signing + pitfalls, per `05-multiplatform-app.md` §3/§6). +- One frontend, many shells: browser PWA + this desktop app + a future mobile/ + remote client are the same bundle with different transports. + +## Run (requires the Tauri toolchain) + +```bash +# 1. Start the daemon's cockpit (from the repo root): +trimwire cockpit # serves the control API + web UI on 127.0.0.1:8766 + +# 2. In another terminal, run the desktop shell: +cd app +npm install +npm run tauri dev # opens a native window onto the cockpit +``` + +## Layout + +``` +app/ + package.json # tauri-cli dev/build scripts + dist/index.html # offline fallback shown if the daemon isn't up yet + src-tauri/ + Cargo.toml # independent crate (NOT a root workspace member) + build.rs # tauri-build + tauri.conf.json # window points at the loopback cockpit (shape A) + src/main.rs # standard Tauri 2 builder +``` + +## Production notes (from the plan, not done here) + +- Swap the hardcoded `127.0.0.1:8766` window URL for a discovered/configured base + URL once the control API's `[admin] listen` is read by the app. +- Offer **shape B** (bundle + manage the daemon as a sidecar) as an opt-in mode. +- Add desktop signing via `tauri-action`; defer mobile/iOS signing to the mobile + phase. Remote control (phone -> laptop) is the deferred, ToS-gated v3 phase + (`06-remote-control.md`) — the app just points the same window at a remote URL. +- **Lock Tauri's CSP + capabilities.** `src-tauri/tauri.conf.json` now ships a **restrictive + CSP** (not `null`), but capabilities still need scoping to deny-by-default before this ships — + the Aug-2024 Tauri audit found any-origin IPC + an unauthenticated dev-server disk exposure. + See `../docs/cockpit/10-security-fresh-sources.md` G5. diff --git a/app/dist/index.html b/app/dist/index.html new file mode 100644 index 0000000..a9f6cdc --- /dev/null +++ b/app/dist/index.html @@ -0,0 +1,36 @@ + + + + + +trimwire · Flightdeck + + + +
+

trimwire · Flightdeck

+

Connecting to the local trimwire cockpit…

+

+ If this persists, start the daemon's cockpit with + trimwire cockpit, then reopen this window. + The app loads the same control UI the binary serves on + 127.0.0.1:8766. +

+
+ + + diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..046c4a2 --- /dev/null +++ b/app/package.json @@ -0,0 +1,14 @@ +{ + "name": "trimwire-flightdeck", + "version": "0.1.0", + "private": true, + "description": "Tauri 2 desktop shell for the trimwire cockpit (POC scaffold)", + "scripts": { + "tauri": "tauri", + "dev": "tauri dev", + "build": "tauri build" + }, + "devDependencies": { + "@tauri-apps/cli": "^2" + } +} diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml new file mode 100644 index 0000000..39d54e5 --- /dev/null +++ b/app/src-tauri/Cargo.toml @@ -0,0 +1,21 @@ +# Independent crate — NOT a member of the trimwire root package (the root has no +# [workspace], so `cargo build` at the repo root ignores this). Build it with the +# Tauri toolchain (`npm run tauri build`), never as part of the daemon's CI. +[package] +name = "trimwire-flightdeck" +version = "0.1.0" +edition = "2021" +description = "Tauri 2 desktop shell for the trimwire cockpit (POC scaffold)" +publish = false + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[features] +# Keep the default Tauri custom-protocol feature for production bundles. +custom-protocol = ["tauri/custom-protocol"] diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs new file mode 100644 index 0000000..660862a --- /dev/null +++ b/app/src-tauri/src/main.rs @@ -0,0 +1,18 @@ +// trimwire Flightdeck — desktop shell (POC scaffold). +// +// Deliberately tiny: the window (configured in tauri.conf.json) points at the +// daemon's loopback cockpit (http://127.0.0.1:8766), so this shell reuses the +// exact web frontend the trimwire binary serves. The daemon is a *separate* +// process reached over the loopback control API — there is no sidecar here, so +// nothing to notarize beyond the app itself. See ../README.md and +// ../../docs/cockpit/05-multiplatform-app.md. +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +fn main() { + tauri::Builder::default() + .run(tauri::generate_context!()) + .expect("error while running trimwire Flightdeck"); +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..68164a0 --- /dev/null +++ b/app/src-tauri/tauri.conf.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "trimwire Flightdeck", + "version": "0.1.0", + "identifier": "dev.trimwire.flightdeck", + "build": { + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "trimwire · Flightdeck", + "width": 980, + "height": 740, + "minWidth": 720, + "minHeight": 520, + "url": "http://127.0.0.1:8766" + } + ], + "security": { + "comment": "Scaffold CSP — restrictive by default (the Aug-2024 Tauri audit found any-origin IPC + unauthenticated dev-server disk exposure). Tighten/scope capabilities before shipping; see ../docs/cockpit/10-security-fresh-sources.md G5.", + "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:8766; img-src 'self'; style-src 'self'" + } + }, + "bundle": { + "active": true, + "targets": "all" + } +} diff --git a/docs/cockpit/01-research-grounding.md b/docs/cockpit/01-research-grounding.md new file mode 100644 index 0000000..214dc6f --- /dev/null +++ b/docs/cockpit/01-research-grounding.md @@ -0,0 +1,124 @@ +# 01 — Research & Grounding: what the cockpit builds on + +> What trimwire exposes **today**, and the gaps a cockpit must fill. All claims are +> code-grounded (file:line) from a read-only exploration pass. This is the factual base +> the rest of the plan stands on. + +## 1. Control surface (what you can drive today) + +trimwire runs as a daemon: `trimwire serve` (alias `daemon`), binding **`127.0.0.1:8765`** by +default (`src/config.rs` `ServerConfig::default`), upstream `https://api.anthropic.com`. +Service lifecycle auto-detects **systemd** (Linux), **launchd** (macOS), or a **supervisor** +fallback (WSL2, pidfile `~/.trimwire/daemon.pid`) — `src/cli/service.rs`. systemd/launchd use +**socket activation**. + +**The entire HTTP surface today is two endpoints** (`src/proxy/gateway.rs`): + +- `GET /healthz` → `{"ok":true,"version":"x.y.z"}`, answered locally, never forwarded. +- `POST /v1/messages` → the Anthropic proxy passthrough (this connection carries the OAuth + `Authorization: Bearer` token). + +**There is no management/control API.** Everything else is CLI: + +| Group | Commands | +|---|---| +| Lifecycle | `install`, `uninstall`, `on`, `off`, `status`, `doctor`, `update`/`upgrade` | +| Inspect | `stats` (`--json`), `recall` (`--json`), `preview` (`--json`), `dashboard` (HTML report) | +| Summarizer | `summarizer setup/status/benchmark/probe` | +| Share | `share enable/disable/stats/benchmark` | +| Maintenance | `sweep list/all/file/undo`, `config show/edit` | +| Shell | `statusline`, `completions`, `man` | +| Hidden | `serve`/`daemon`, `run`, `hook` | + +**Config** is a figment merge of global `~/.config/trimwire.toml` + project `./.trimwire.toml` ++ `TRIMWIRE_*` env. Crucially, **`[server] upstream` is never read from a project file** — +that key decides where the OAuth token is sent, so honoring it from a cloned repo would let a +project redirect your token (`src/config.rs`, with a regression test). **The daemon reads +config only at startup** — any change needs an `off → on` restart. There is no config *write* +API; only `config edit` ($EDITOR). + +### Gaps the cockpit must fill + +1. **No local control API** — only `/healthz`. No config read/write, no service toggle over + HTTP, no strategy tuning. +2. **No live event stream** — state can only be polled. +3. **No hot-reload** — config changes require a restart. +4. **No config-validation endpoint** (only `doctor`, CLI-only). +5. **No structured live metrics export** beyond `stats --json` snapshots. + +## 2. Data surface (what a dashboard can show) + +The **ledger** is a local SQLite DB (`~/.trimwire/ledger.db`, `src/ledger.rs`) with three +tables: `requests` (one row per `POST /v1/messages`), `summarizer_events`, `upstream_errors`. + +**Content-free guarantee (confirmed in code):** the ledger stores only byte counts, token +counts (input / cache_read / cache_creation / output), **prefix hashes** (with `messages` +removed), timestamps, `session_id`, model name, strategy names + per-strategy elided bytes, +TTFT (µs), and status codes. **Never** message content, prompts, tool results, or file paths. +*This is structural: the data to leak simply isn't there.* + +Already-available metrics (all via existing `--json`): + +- **`stats --json`**: totals, `bytes_saved`, `reduction_pct`, `est_tokens_removed`, `per_day` + timeseries, `per_strategy` (count + bytes), `cache_stability` ratio, `response_metrics` + (avg TTFT, token buckets, `cache_hit_pct`), summarizer outcomes, upstream errors. +- **`stats --session --json`**: per-session, per-model breakdown. +- **`recall --json`**: recent sessions (id, last_day, requests, bytes, tokens, model). +- **`preview --json`**: deterministic what-if prune estimate per profile — powers a + live "what would be pruned" pane *without touching the daemon* (strategies are pure fns). +- **`trimwire dashboard`**: already emits a **self-contained HTML report** + (`src/cli/dashboard.rs` + `src/cli/dashboard_template.html`). +- **`statusline`**: a live per-session reduction signal Claude Code renders after each turn. + +**The 9 strategies** (`src/strategies/mod.rs`): `failed_input_purge`, `stale_input_cap`, +`cross_turn_dedup`, `stale_reads`, `simhash_dedup` (opt-in), `bloat_cap`, `sliding_window`, +`image_strip`, `thinking_strip`. **Two profiles**: `default` (aggressive) and `gentle` +(conservative). The product guardrails forbid a third profile and any intensity dial. + +### Useful data that does NOT exist yet (dashboard opportunities, not v1 blockers) + +Per-strategy latency cost; strategy fire-rate trend; cache-busting root cause; compression by +model; summarizer cost/latency; reprune checkpoint timeline; sub-agent (sidechain) metrics; +ledger growth/retention pressure. All are additive ledger columns or queries — defer until a +pane proves it needs them. + +## 3. Web & telemetry surface (what to align with / reuse) + +**Site** (`site/`): **Astro 6 + Starlight 0.40**, TypeScript, **vanilla DOM (no React/Vue)**, +Vitest+jsdom, Playwright. Deployed static (trimwire.dev). It already has interactive, +dependency-free components built exactly like the cockpit needs: + +- `CommunityDashboard.astro` + `dashboard.ts` — sortable sticky tables, in-cell bars, KPI + strip, expandable detail rows. +- `BenchmarkTable.astro`, `Hero.astro` (flow diagram + before/after bars). + +**Design system** (`site/src/styles/custom.css`): accent teal **`#2aa39c`** (dark `#178a83` / +light `#9fe7e2`); semantic good `#22c87a` / warn `#e0a000` / bad `#e85d3f`; radius `0.55rem`; +3px top-accent cap; tight headings (`-0.02em`); `tabular-nums` data tables; dark/light parity +via `:root[data-theme]`; `prefers-reduced-motion` respected. + +**Collector** (`collector/`): a Cloudflare Worker + D1 that ingests **k-anonymous, content-free +community aggregates** (`POST /ingest`, `GET /aggregates.json`, benchmark equivalents). This is +the *community* backend — **the local cockpit reads the local `ledger.db`, not the collector.** +The collector is a model for privacy discipline, not a data source for the cockpit. + +**Brand voice:** restrained, technical, honest ("headroom, not dollars"), data-first, minimal +ornament. The cockpit should honor the *cockpit metaphor* but pick an on-brand, non-hype +visible name (see doc 04). + +## 4. Constraints inherited from AGENTS.md (carried into every later doc) + +- Single static binary, **no heavy runtime deps**; latency is acceptable, weight is not. +- Transparent **ToS-compliant** proxy: never originate model calls on the subscription token; + **no detection-evasion** ("transparent" = faithful, not undetectable). +- `main` is protected; every change is a PR through green CI: `fmt + clippy + test`, + MSRV 1.85, **Python parity oracle**, cargo-deny/audit, 3 cross-platform builds. +- Layer rules: `gateway.rs` must not contain mutation logic; `strategies/*` are pure (no I/O); + `ledger.rs` is the only SQLite I/O; new modules require an `ARCHITECTURE.md` update. + +## Takeaway + +The cockpit is **~80% presentation over data and verbs that already exist**, plus **one new +backend component** (the control API, doc 03). That framing — reuse the HTML-report data, the +vanilla-DOM components, the design system, and the CLI verbs — is what keeps the build +proportionate to trimwire's size and ethos. diff --git a/docs/cockpit/02-framework-decision.md b/docs/cockpit/02-framework-decision.md new file mode 100644 index 0000000..641def0 --- /dev/null +++ b/docs/cockpit/02-framework-decision.md @@ -0,0 +1,103 @@ +# 02 — Framework Decision: the app stack + +> The maintainer asked the agents to decide. We ran a **3-member council** (independent agents, +> same question, web-research-grounded for the 2026 state of each framework) plus a +> disagree-seeking pass. **The council was unanimous.** + +## Verdict: **Tauri 2** — build one web frontend, ship it as a browser PWA *and* a Tauri shell + +All three council members independently scored **Tauri 2** the winner (weighted ~4.5–4.6 / 5), +named **PWA-only / shared web frontend** the runner-up, and **Flutter** the flip-case if mobile +ever becomes a v1 priority. They independently derived the *same* architecture: + +> Build the cockpit UI **once** as a conservative web app. Serve it from the trimwire binary on +> loopback for the **browser web UI**. Wrap the **same bundle** in a **Tauri 2** shell — whose +> core is Rust and can link trimwire's own crates / run the daemon as a sidecar — for the +> **multi-platform app**. The daemon exposes a plain HTTP control API, so the browser, the +> desktop app, and a future phone client are *one frontend with three transports*. + +> **Refinement (maintainer steer): PWA is the *primary* multi-platform vehicle; Tauri is the +> *desktop convenience wrapper*.** The runner-up and the winner are not in tension — the cockpit +> is one installable web app (the PWA) that covers desktop browsers and **iOS + Android via +> Add-to-Home-Screen, no store, no fee**, and Tauri simply gives that same app a nicer desktop +> shell (tray, autostart, sidecar daemon, OS-keychain token). With Android tightening toward +> iOS-style verification (Sept 2026, doc 05 §0), the PWA is also the *durable* store-free path on +> both mobile OSes. So the "framework decision" is really **PWA + Tauri-for-desktop**, not "a +> native app per platform." Native mobile builds are de-prioritized (doc 05 §0/§4). + +## Scored trade-off (representative; the three councils agreed within rounding) + +Weights reflect the brief's priorities. Scores 1–5 (5 = best fit). + +| Criterion | Weight | **Tauri 2** | Electron | Flutter | Wails v3 | PWA-only | +|---|---:|:--:|:--:|:--:|:--:|:--:| +| Lightweight / single-binary ethos | 0.20 | **5** | 1 | 3 | 5 | 5 | +| Rust-native synergy (share crates / API client) | 0.20 | **5** | 1 | 3 | 1 | 1 | +| Code-sharing with the vanilla-DOM web UI | 0.16 | **5** | 5 | 1 | 5 | 5 | +| Desktop now (mac/win/linux) | 0.14 | 4 | 5 | 5 | 4 | 4 | +| Mobile later (iOS/Android) | 0.12 | 3 | 1 | **5** | 1 | 3 | +| Security / attack surface | 0.10 | **5** | 2 | 4 | 4 | 4 | +| Build/CI + solo-maintainer burden | 0.08 | 3 | 4 | 3 | 3 | **5** | +| **Weighted total** | 1.00 | **≈4.55** | ≈2.5 | ≈3.2 | ≈3.5 | ≈3.7 | + +## Why Tauri 2 wins (the decisive axes) + +- **It's the only Rust-native option.** trimwire is Rust; a Tauri core is Rust. The cockpit can + depend on trimwire's **own crates** — the ledger reader, the `preview` engine, the figment + config types, a typed control-API client — instead of reimplementing them, and can run + trimwire as a **sidecar**. No other framework shares code at the Rust level (Flutter bridges + via FFI, Electron/Wails/PWA share nothing in Rust). +- **It honors the single-binary ethos.** Tauri uses the OS WebView: ~3–15 MB installers, + ~30–50 MB RAM, vs Electron's 80–150 MB / 200–300 MB. A context-*pruning* tool shipping a + 150 MB Chromium runtime would be self-parody — Electron is disqualified on principle. +- **One frontend powers both web and app.** The web UI is already Astro + vanilla DOM; that + exact bundle drops into a Tauri WebView. Build once, ship twice. Flutter/KMP throw the web UI + away and rebuild it in Dart/Compose — two frontends for a solo maintainer. +- **Best security posture.** Tauri's capability model is deny-by-default — the right default for + a tool controlling a privileged, token-bearing local daemon. +- **The phasing maps cleanly.** v1: Tauri desktop hitting `127.0.0.1`. Remote: same frontend, + remote base URL + token. Mobile: add Tauri's iOS/Android targets to the same project. + +## Runner-up: PWA-only — and the recommended hybrid + +A PWA scores highest on *burden* (zero native build, the web UI *is* the product) and is the +runner-up. **Pick PWA-only over Tauri if** mobile is soft/unlikely or solo bandwidth can't +absorb a native release pipeline this quarter. + +But the councils' actual recommendation is **not either/or** — it's **both**: the same web +frontend is *already* a browser PWA (the "web UI" deliverable) **and** the content of a Tauri +shell (the "app" deliverable). PWA-now and Tauri-later is a coherent sequence, not a rewrite, +because both start from one clean web frontend talking to the `/api/v1` control surface. + +**Flutter is ruled out** (maintainer preference: no Flutter/Dart) — and it lost on the merits +anyway (it throws away the shared web UI for a separate Dart frontend). So the mobile story does +**not** fall back to Flutter. The mobile fallback, if Tauri-mobile ever disappoints, is the +**same web frontend as a PWA / Capacitor wrapper** — never a second UI in another language. See +doc 05 for mobile, including distribution **without paid app-store accounts**. + +## Top risks of Tauri 2 + mitigations + +| # | Risk | Mitigation | +|---|---|---| +| 1 | **Linux WebKitGTK rendering drift / instability** (the strongest counter; a Tauri maintainer says they "cannot fully recommend Tauri for Linux"). **Fresh-sources update (doc 10): materially understated for a data-table-heavy cockpit** — 2025 reports include rendering glitches + DOM-heavy lag, not just cosmetic drift. | Keep the UI deliberately plain; run **Playwright against WebKit**. **The real mitigation is the HTTP-first / PWA hedge** (the same frontend survives as a PWA if the shell disappoints) — keep it load-bearing. Also lock Tauri capabilities deliberately (the Aug-2024 audit found any-origin IPC + an unauthenticated dev-server disk exposure). See doc 10 G5. | +| 2 | **Tauri 2 mobile is "a foundation, not finished"** — plugin parity gaps | Mobile is *deferred*; the mobile client is a webview control panel (Tauri's strong case), not deep-native. Re-evaluate at mobile-phase kickoff; Capacitor-wrapping the same web UI is a cheap fallback. | +| 3 | **iOS codesigning / multi-toolchain CI burden** for a solo maintainer | Don't pay it until the mobile phase. Desktop signing is incremental on the existing 3-platform CI; use Tauri's GitHub Action. | +| 4 | **A JS/Node build chain enters a pure-Rust repo** | Keep the cockpit a separate workspace member; the frontend build is the *same* one the web UI already needs — not net-new tooling. The daemon's MSRV/cargo-deny gates stay isolated. | +| 5 | **Bus factor on a younger framework** | The HTTP-first design means the web/PWA UI keeps working even if the native shell is abandoned — low regret. | + +## A note on the unanimity + +Three independent agents agreeing is a groupthink flag (per the repo's own working +conventions). We treated it as such — but the convergence is *forced by a hard constraint*: the +brief requires reusing the existing vanilla-DOM web UI **and** honoring the Rust single-binary +ethos, and exactly one framework maxes both. The disagree-seeking review (doc 07) was pointed at +the broader *concept* (should we build this at all, and how much), not at re-litigating the +framework — which is where the real dissent lives. + +## Sources + +Tauri 2.0 stable & mobile/sidecar docs (v2.tauri.app); Tauri-vs-Electron 2026 comparisons +(pkgpulse, tech-insider, buildmvpfast); WebKitGTK instability discussion (tauri-apps GitHub +#8524); Tauri iOS feedback (#10197); Playwright/WebKit pitfall notes; `flutter_rust_bridge` +(pub.dev/GitHub); Wails v3 alpha status; KMP-2026 readiness. Full URLs are in the session +transcript. diff --git a/docs/cockpit/03-control-api.md b/docs/cockpit/03-control-api.md new file mode 100644 index 0000000..c1a30a0 --- /dev/null +++ b/docs/cockpit/03-control-api.md @@ -0,0 +1,245 @@ +# 03 — Local Control API + +> The central missing piece. Today the daemon serves only `GET /healthz` and the +> `/v1/messages` passthrough. The cockpit (browser + app) needs a control plane. This is the +> implementation-ready design, grounded against `src/proxy/{gateway,listener}.rs`, +> `src/cli/{serve,service,stats,recall,preview,sweep}.rs`, and `src/config.rs`. + +## TL;DR + +1. **Transport:** a **separate admin listener on its own loopback port** (default + `127.0.0.1:8766`) — *not* extra routes on the gateway (8765), *not* a UDS in v1. New module + tree `src/admin/`; `gateway.rs` stays mutation-free. +2. **Endpoints:** REST-ish under `/api/v1/...`. Every mutating endpoint is a thin HTTP wrapper + over the **same library function the CLI already calls** — no logic duplicated. Read + endpoints reuse the existing `stats`/`recall`/`preview` JSON verbatim. +3. **Live events:** **SSE** at `/api/v1/events` — one content-free broadcast channel. Never + touches or buffers the proxy stream. +4. **Auth:** loopback-only + a 256-bit **bearer token** at `~/.trimwire/control.token` (0600) + + an **Origin/Host allowlist** (DNS-rebind guard). Remote is a documented seam, not built. +5. **Config writes:** validate (reuse every existing `config.rs` check) → atomic write (reuse + `sweep.rs`'s temp+fsync+rename) → apply. **Hot-reload** for strategy/profile/summarizer/share + knobs via an `ArcSwap`; **restart-required** for `[server] listen`/`upstream`. +6. **Build:** 5 PRs, each green through the existing CI. The control plane never touches + `strategies/`/`pairing/`, so the **Python parity oracle is unaffected**. +7. **Stability:** the cockpit speaks **only** this versioned `/api/v1` contract — it never + shells out to the CLI or parses CLI output. The CLI and the API are two consumers of one + library, and contract tests fail CI on any shape drift, so **CLI commands can change freely + without breaking the cockpit**. See [doc 11](11-api-stability.md). + +## 1. Why a separate loopback admin listener + +| Option | Verdict | +|---|---| +| (a) Extra routes on gateway `:8765` | **Rejected.** That connection carries the Anthropic OAuth `Bearer` token; co-mingling control verbs there means a routing bug or confused-deputy could expose control to the credential path, and any client pointed at 8765 (all of Claude Code) could hit control verbs. | +| **(b) Separate admin listener `:8766`** | **Chosen.** Physically distinct socket; the proxy path can never route to it; loopback bind; its own token. The OAuth-bearing path and the control path share **zero** surface. | +| (c) Unix domain socket | Best raw security, but **a browser cannot speak AF_UNIX** — the web-UI half is dead on arrival (plus Windows/WSL2 friction). Could be added later for the native app behind the same router. | + +Credential isolation is the hard constraint; browser reachability is a hard product +requirement. (b) satisfies both. Loopback-only means no remote caller in v1 — defense *before* +auth runs. Socket activation reuses `listener.rs::obtain()`, generalized to +`obtain_indexed(addr, fd_index, name)` for a second inherited fd / named `Sockets` key. + +## 2. Module placement (layer rules respected) + +`gateway.rs` must not contain mutation logic, so the control plane gets its own tree: + +``` +src/admin/ + mod.rs run(listener, AdminState) -> Result<()> + router.rs method+path dispatch (hyper service_fn, like gateway.rs) + auth.rs token load/verify + Origin/Host allowlist; Authenticator trait (remote seam) + state.rs AdminState { config: ArcSwap, ledger, events, reprune_cache, paths } + handlers/ service.rs, config.rs, ledger.rs, preview.rs, sweep.rs, summarizer.rs, share.rs + events.rs EventBus (tokio::sync::broadcast) + content-free Event enum + reload.rs validate -> atomic write -> ArcSwap publish (hot) / restart signal +``` + +The only gateway change: read config via `ArcSwap::load()` **per request** (a *read* +change, within its existing allowance) so a hot-reload publishes new config without a restart. +The admin listener is **opt-in-by-presence**: absent/disabled → the daemon runs exactly as +today (zero new surface for users who never open the cockpit). `ARCHITECTURE.md` gains an +`admin/*` layer-table row and a decision-log entry. + +## 3. Endpoint surface (`/api/v1`) + +All requests require `Authorization: Bearer ` (except `GET /health` and the SSE +`GET /events`, which the browser `EventSource` cannot authenticate — it stays Host/Origin/ +Sec-Fetch-guarded and content-free). Errors use the +Anthropic-shaped envelope already in `gateway.rs`. Every endpoint maps to existing code +(Appendix A). + +``` +# Health / version +GET /health {"ok":true,"version":"x.y.z"} (unauth ok) +GET /version {version, profile, control_api, upstream} + +# Service lifecycle (wraps cli::service) +GET /service ServiceStatus {manager, listening, serving, pid, uptime_secs, ...} +POST /service/on | /off | /restart 202 {action, ok} + +# Config (wraps cli::config + config.rs) +GET /config {toml, effective, source_map} +GET /config/effective == config show --json +PUT /config {toml} {applied:"hot"|"restart_required", restart_fields?, diff} +POST /config/validate {toml} {valid:true} | 422 {valid:false, errors:[...]} + +# Profile + per-strategy (convenience over config write; hot-reloadable) +GET/PUT /profile {active, available:["default","gentle"]} +GET /strategies {strategies:{name:{enabled, ...knobs}}} +PUT /strategies/{name} partial knobs -> {applied:"hot"} + +# Ledger (READ-ONLY, content-free; reuse existing --json shapes) +GET /stats[?since=&until=] == stats --json +GET /stats/session/{id|last} == stats --session --json +GET /sessions[?query=&limit=] == recall --json + +# Preview / what-if (wraps cli::preview; pure, read-only) +POST /preview {path, profile, with_summarizer} == preview --json +GET /preview/last[?profile=] + +# Summarizer (wraps cli::summarizer) +GET /summarizer status +POST /summarizer/probe {runs, confirm} (paid API -> confirm:true required) + +# Sweep (wraps trimwire::sweep) +GET /sweep list candidates +POST /sweep/run {path, dry_run} +POST /sweep/run-all {dry_run, confirm} +POST /sweep/undo {path} + +# Share opt-in (wraps cli::share) +GET/PUT /share {enabled} +POST /share/stats | /share/benchmark {confirm} + +# Live events +GET /events text/event-stream (SSE) +``` + +Paid actions map the CLI's `--yes` to a `"confirm":true` body flag. When the ledger is +disabled, read endpoints return `200 {"available":false,...}` (a valid state, like the CLI). + +## 4. Live events — SSE + +v1 is one-directional (daemon→UI; control *actions* go through REST), so **SSE** beats +WebSocket (no upgrade/framing/client channel) and poll. A single +`tokio::sync::broadcast::Sender` lives in `AdminState`. The gateway already computes +everything at ledger-write time; it calls `events.publish(Event::Request{..})` there with the +**same content-free fields** it writes to the ledger — a non-blocking send that never +backpressures and is fully decoupled from consumers. **The proxy SSE body is never tee'd** — we +emit a *summary event after* the response is metered, not the response bytes. + +Event types (all content-free): `request` (per-request savings), `strategy_fire` (optional), +`daemon` (reloaded / upstream_error), `summarizer` (outcome), `resync` (on `broadcast` lag → UI +refetches `/stats`). A unit test mirroring `audit.rs`'s `capture_never_leaks_content` asserts +the `Event` serializer can only emit allowlisted fields. + +## 5. Auth & security + +> **Fresh-sources update (doc 10):** Host-pin alone is necessary-but-not-sufficient. +> Add two more independent gates, ordered **before** the token compare and any side +> effect: (a) **`Sec-Fetch-Site`** enforcement (browser-set, unforgeable — reject +> non-`same-origin`/`none`), and (b) a **custom non-simple header** on mutating +> endpoints (e.g. `X-Trimwire-Control`) to force a CORS preflight that default-deny +> CORS fails. Note Chrome 142 **Local Network Access does NOT cover localhost→localhost**, +> so do not rely on browser prompts for sibling-localhost threats. See doc 10. + +- **Loopback-only bind** (`127.0.0.1`/`[::1]`); a non-loopback admin bind is **rejected** at + bind time (analogous to `config.rs`'s `is_unsafe_listen`). +- **Bearer token:** 256-bit random, `~/.trimwire/control.token` mode `0600` (reuse + `fsperm.rs`). Required on every request incl. SSE. Constant-time compare. **Not a cookie** → + not auto-attached cross-site → CSRF is structurally prevented. +- **DNS-rebinding guard:** reject any request whose `Host` isn't the literal loopback authority + (`127.0.0.1:8766` / `localhost:8766` / `[::1]:8766`) and whose `Origin` (when present) isn't + allowlisted. +- **Same-origin UI:** the daemon serves the cockpit static bundle under `GET /` on the admin + port, so the UI never needs cross-origin; CORS stays default-deny. The browser UI is handed + the token via a same-origin bootstrap injected at page load (never in a URL). +- **`X-Content-Type-Options: nosniff`, `Cache-Control: no-store`** on API responses. + +**Remote seam (designed, not built):** `auth.rs` exposes `trait Authenticator { fn +authenticate(&Request) -> Result }` with one v1 impl `LoopbackToken`. Non-loopback +bind is hard-rejected unless a strong Authenticator is active — so token-only auth can never be +accidentally exposed to a network. See doc 06 for the full remote requirement list (R1–R10). + +## 6. Config write safety + +- **Validate before write:** run the *same* figment merge `Config::load` uses, with the + submitted TOML as the global layer, then every existing `config.rs` check (summarizer + provider/style/`accept_ratio`, `is_unsafe_listen`, profile/mode recognition, the upstream + credential-routing guard). Surface the existing user-facing messages verbatim in a `422`. + **Refactor:** extract `Config::validate()` / `load_from_str()` shared by `load()` and admin. +- **Atomic write:** temp + fsync + rename + dir-fsync — **reuse `sweep.rs`'s** hardened + primitive. Keep one `.bak` so the UI can offer "revert". Whole-file replace (matches + `config edit`) avoids partial-merge/comment-loss footguns. +- **Apply (hot-reload vs restart):** the gateway reads an `ArcSwap`. + +| Hot-reloadable (gateway reads per request) | Restart-required | +|---|---| +| `profile`, all `[strategies.*]`, `[reprune]`, `[summarizer]`, `[share]`, `[ledger] retain_days` | `[server] listen` (bind), `[server] upstream` (credential routing — deliberately restart-only), `[ledger] db_path` (open handle) | + +- **In-flight requests** snapshot `config.load()` at entry → a mid-flight swap never affects a + request already past that point; the proxy stream is never interrupted. +- **Reprune cache:** clear (mark-dirty) the reprune `DashMap` when a *pruning-affecting* section + changes (profile/strategies/reprune/summarizer), so the next turn re-checkpoints under the new + config. Reprune is self-correcting, so a clear is safe. Non-pruning toggles (`[share]`, + `retain_days`) don't clear it. +- `[server] upstream` is restart-only even though the gateway reads it per request — changing + where the OAuth token goes should be a conscious restart, never a config-editor keystroke. + +## 7. Phased build plan (mapped to PR/CI) + +Each PR is green through `fmt + clippy + test` (MSRV 1.85), the **Python parity oracle** +(untouched — control plane doesn't touch the prune path), cargo-deny/audit, and 3 +cross-platform builds. Only new dep: `arc-swap` (tiny); `tokio` broadcast already in tree; +`getrandom` for the token. + +1. **PR 1 — Plumbing + read-only API.** `ArcSwap` refactor (behavior-identical); admin + listener (loopback + socket-activation); `src/admin/` skeleton + auth (token + Host pin); + `GET /health`/`/version`/`/service`(status)/`/stats`/`/sessions`/`/config`. Extract the + `serde_json::Value` builders out of the `stats`/`recall` `println!` wrappers so CLI + HTTP + share them. Tests: 401/200 auth, Host-pin rejection, JSON shape == CLI `--json`. +2. **PR 2 — Live events (SSE).** `EventBus`; publish content-free `Event::Request` at + ledger-write time; `GET /events` (keepalive, lagged→resync). Tests: `event_never_leaks_content`, + SSE framing, proxy stream path unchanged. +3. **PR 3 — Config write + hot-reload.** Extract `Config::validate`; `reload.rs` (validate → + atomic write → diff → ArcSwap publish / restart-required; clear reprune cache); `PUT /config`, + `/config/validate`, `/profile`, `/strategies`, `/share`. Tests: bad TOML→422, hot-reload + visible to next request, restart classification, crash-safe atomic write. +4. **PR 4 — Service + preview/sweep/summarizer/share actions.** `POST /service/{on,off,restart}`; + `/preview`; `/sweep/*`; `/summarizer/probe`; `/share/*`. Reuse `cli::*` bodies + `trimwire::sweep`. + Tests: self-`off` 202-then-teardown, `confirm`-gated paid calls, sweep run-all aborts on active file. +5. **PR 5 — Static UI hosting + docs + remote seam.** Serve the cockpit bundle under `GET /`; + finalize the `Authenticator` seam; update `ARCHITECTURE.md`/`CONFIGURATION.md`/`SECURITY.md`/README; + add the global-only `[admin]` config section. + +```toml +[admin] +enabled = true # spawn the control listener (off -> daemon == today) +listen = "127.0.0.1:8766" # loopback-only; non-loopback rejected in v1 +# token lives in ~/.trimwire/control.token (0600), not in config +``` +`[admin]` is **global-only** (never read from a project `./.trimwire.toml`) — a cloned repo +must not be able to open or relocate a control port. + +## Appendix A — endpoint → reuse map (no duplicated logic) + +| Endpoint | Backed by | +|---|---| +| `/health` `/version` | `gateway::health_response` + `Config` | +| `/service*` | `cli::service::{detect,tcp_open,healthz_ok,on,off}` (+ new `restart`) | +| `/config*` `/profile` `/strategies` | `config::{load_from_str,validate,PROFILES}`, `sweep.rs` atomic-write | +| `/stats*` | `cli::stats` JSON builder (extracted) | +| `/sessions` | `cli::recall` JSON builder (extracted) | +| `/preview*` | `cli::preview` JSON builder (extracted) | +| `/sweep*` | `trimwire::sweep` library module | +| `/summarizer*` | `cli::summarizer` status/probe | +| `/share*` | `cli::share` enable/stats/benchmark | +| `/events` | `EventBus` fed by gateway/summarizer/ledger (content-free) | + +## Appendix B — deliberately NOT in v1 + +No WebSocket (SSE suffices); no UDS (browser can't use it); no remote exposure (loopback hard- +enforced; seam present); no interactive `summarizer setup` raw endpoint (UI drives config +primitives); no partial-merge config PUT; no content in events/queries (structural — the ledger +is content-free). diff --git a/docs/cockpit/04-web-cockpit-ui.md b/docs/cockpit/04-web-cockpit-ui.md new file mode 100644 index 0000000..a737145 --- /dev/null +++ b/docs/cockpit/04-web-cockpit-ui.md @@ -0,0 +1,160 @@ +# 04 — Web Cockpit UI ("Flightdeck") + +> The browser web UI that opens locally and gives **full control** of the trimwire daemon over +> the control API (doc 03). Local-first for v1; remote-ready by construction. + +## 1. Delivery model — served by the binary + +A new command serves the cockpit from the same process that owns the control API and the +ledger: + +``` +trimwire cockpit # start control API + static UI on loopback [IMPLEMENTED in the POC] +# Proposed (not in the POC — doc 09 ships only `trimwire cockpit`): +# trimwire cockpit --no-open # headless (for the app webview / remote phase) +# trimwire dashboard --serve # alias upgrading the static HTML report into the live cockpit +``` + +- Static assets are **embedded into the Rust binary** at build time (`rust-embed` / + `include_dir!`) and served from the admin loopback handler alongside `/api/v1/*`. One origin → + no CORS, no second port, no separate install. +- Bound to `127.0.0.1` only; same-origin → the control API's token/Host-pin auth covers the UI. +- **Fully offline, zero CDN.** Vendor every asset; system font stacks; no analytics, no remote + calls — consistent with the content-free, transparent ethos. + +**Rejected:** a separate Node dev-server (adds a runtime dep to *run* the product — Node/Vite +is a build-time tool only); folding into the public trimwire.dev site (that's a static, +public, community-aggregate site reading the collector — wrong on security, offline, and +lifecycle; the cockpit ships with the binary and must version-match it). + +**Migration to remote** is a transport swap only: bind beyond loopback (opt-in, TLS + token), +and the UI's API base URL becomes configurable instead of same-origin. Because the UI talks to +`/api/v1` (never to SQLite directly), nothing in the frontend changes except the fetch base + +auth header. + +## 2. Frontend stack — vanilla DOM vs Svelte (OPEN DECISION) + +> **Maintainer input:** *"we'll install Svelte+Astro on another flow for page rebrand — I like +> Svelte, but if vanilla is enough for subagents, ok."* + +This is a genuine fork, and the site rebrand changes the calculus. Both options are viable; +here's the honest trade-off so the maintainer can decide: + +| | **Vanilla DOM + tiny store** | **Svelte** | +|---|---|---| +| Aligns with *current* site (`dashboard.ts`) | ✅ direct lift of existing components | ⚠️ would rebuild them | +| Aligns with *rebranded* site (Svelte+Astro) | ⚠️ diverges from where the site is going | ✅ **shares idioms/components with the new site** | +| Bundle size in the binary | Smallest (a few KB) | Small — Svelte compiles to vanilla JS, no VDOM runtime (~a few KB more) | +| Live updates (counters tick, rows stream) | Manual DOM patches via a ~150-line store | First-class reactivity (`$state`/stores) — *better fit for the live monitor* | +| Config forms / validation / dialogs | Hand-rolled | Componentized, less boilerplate | +| Solo-maintainer ergonomics | Lean but more glue code | More structure; one more compile step (already present once Astro+Svelte lands) | +| Tauri WebView compatibility | Perfect (plain DOM) | Perfect (compiles to plain DOM/JS) | + +**Recommendation:** **If the site is being rebranded to Svelte+Astro anyway, build the cockpit +in Svelte too.** The original "reuse the vanilla-DOM components" argument was the main case for +vanilla — and it weakens precisely because the site is moving off vanilla. Svelte keeps the +cockpit and the rebranded site on **one idiom and one shared component library**, gives +first-class reactivity for the live session monitor (the cockpit's most dynamic screen), and +still compiles to a tiny, VDOM-free bundle that satisfies the lightweight ethos and embeds +cleanly in both the browser and the Tauri shell. The **design tokens** (`custom.css`) port +verbatim regardless of framework. + +Use **vanilla DOM only if** the cockpit ships *before* the Svelte rebrand and you want zero new +build steps in the interim — in which case the components below are a direct lift from +`dashboard.ts`, and a later port to Svelte is mechanical (the data contracts and tokens don't +change). Either way, **screens consume the control API through a typed `api.*` client + a small +reactive store**, so the rendering layer is swappable without touching data flow. + +*(The screen specs below are framework-agnostic — they describe data, actions, and states, not +DOM construction. They hold whether rendered by Svelte components or vanilla render functions.)* + +## 3. Information architecture + +Single-page app, left-rail nav, persistent global status header. Ten destinations in four +groups: + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ trimwire · Flightdeck ● running default 127.0.0.1:8765 ◑ ⏻ │ ← global header (always live) +├────────────┬─────────────────────────────────────────────────────────┤ +│ MONITOR │ Live · Savings · Strategies · Sessions │ +│ TUNE │ Preview/What-if · Config │ +│ OPERATE │ Daemon · Sweep · Summarizer │ +│ SHARE │ Telemetry │ +└────────────┴─────────────────────────────────────────────────────────┘ +``` + +The **global header** carries the four highest-frequency controls — power toggle (on/off), +profile pill, listen address, theme toggle — always live via the control-API health poll / SSE. + +## 4. Reuse map (design system + components) + +Lift the token layer from `site/src/styles/custom.css` verbatim (teal `#2aa39c`, semantic +good/warn/bad, `0.55rem` radius, 3px cap, dark/light parity, `tabular-nums`, reduced-motion) so +the cockpit and site read as one brand. Component patterns to reuse (as components if Svelte, as +render fns if vanilla): KPI strip, sortable sticky table (`twd` + `Col` model), in-cell mini-bar, +labelled detail bar, expandable detail row, summary tiles, segmented token bar, per-day rows, +honest empty-state, banner, semantic helpers (`fmtBytes` **must stay 1024-based** to match +`stats::human_bytes`), `STRATEGY_LABELS`. + +New components: left-rail nav, global live status header, reactive store + SSE/poll-fallback +client + platform adapter, live ticker, validated config form controls, confirm dialog, profile +switcher, preview two-column diff, telemetry payload-preview. + +## 5. The ten screens (purpose · data · actions · states) + +1. **Live Session Monitor** — watch per-request savings as you work. Data: SSE `request` stream + + rolling KPIs. Actions: pause, clear, jump to session. Empty: "Idle — run a `claude` turn + and rows appear live." API: `GET /events` (live) + `GET /stats` (backfill). +2. **Savings** — the `trimwire dashboard` report, live. Data: `stats --json` (totals, per_day, + cache_stability, response_metrics). Actions: window selector, export the self-contained HTML + snapshot. Keeps the **"headroom, not dollars"** framing verbatim. +3. **Strategies** — per-strategy bytes/fire-rate for the active profile. Data: `stats` + per_strategy + `config` profile. Disabled strategies dimmed with "enabled in default only". + Deep-links each strategy to its Config knob. +4. **Sessions** — `recall` list → per-session per-model drilldown (`stats --session`). Actions: + sort/filter by model, "Preview this session", copy id (truncated, full on hover). +5. **Preview / What-if** — deterministic default-vs-gentle compare on a captured request. + **Content-free: shows byte deltas + per-strategy elided bytes + block *types*, never block + *content*.** Pure/read-only (`POST /preview`). The safe sandbox before any Config change. +6. **Config editor** — validated per-strategy knobs + profile switch, with **per-field source + attribution** (global/project/env). Mandatory honesty banner: *"Saved. Restart to apply + (off → on)"* with a one-click restart, since the daemon reads config only at startup. + Project-scoped `upstream` shown **read-only/ignored** with the token-guard tooltip. Validate + live (`POST /config/validate`); write (`PUT /config`). +7. **Daemon** — lifecycle + health + doctor. Status card (running/stopped, version, addr, + service manager), Doctor panel (pass/warn/fail), on/off/restart (confirm-dialog'd; restart + warns "in-flight requests complete"). Driven by `service` SSE events. +8. **Sweep** — list → **dry-run by default** → review → Apply (confirm) → Undo always offered. + `trimwire::sweep` library; partial-apply state made explicit with Undo available. +9. **Summarizer** — backend status, benchmark, probe. Copy states the boundary: **never + originates model calls on the user's Claude OAuth token**. Switch backend routes to Config. +10. **Telemetry / Share** — opt-in with a **payload preview** (show the literal content-free + k-anon JSON before sending — making the privacy claim *inspectable*, not just asserted), + k-anon explainer (K=10 stats / K=5 bench), link to the public community dashboard. + +Every screen has loading (skeleton + "Connecting to daemon…"), empty (honest CTA, no fake +data), and error (banner naming the failed endpoint + Retry; daemon-down → "Start daemon") +states. If the SSE feed drops, fall back to a 2s `?since=` poll with exponential backoff. + +## 6. Brand / naming + +Honor the cockpit *metaphor*, avoid marketing-speak. Proposals: + +1. **trimwire Flightdeck** *(recommended)* — a flight deck = full instrumentation + control; + reads as a place, not a pitch; pairs with the "wire" word. Visible name "trimwire · + Flightdeck"; command verb `trimwire cockpit` (metaphor in the verb, restrained noun in the + UI). Wordmark reuses the split-color treatment: **trim**wire (teal) · Flightdeck. +2. **trimwire Console** — maximally plain fallback if any metaphor feels too cute. +3. **trimwire Panel** — instrument-panel nod without the word. + +## 7. Shared frontend (build once, ship many) + +The cockpit `dist/` *is* the app's UI. The multi-platform app (doc 05) is a thin webview shell +that either embeds the trimwire binary serving loopback and points the webview at it, or loads +the embedded `dist/` and talks to the local control API. The only shell-specific layer is a +tiny **platform adapter** behind the `api.*` client: same-origin fetch (browser/served), +`tauri::invoke`/native bridge (app), or remote base-URL + token (deferred remote). Screens never +know which transport they're on. Because all state flows through `/api/v1` + the event stream, +**the remote-control phase is a transport swap, not a rewrite** — browser cockpit, desktop app, +and future phone client are one frontend with three adapters. diff --git a/docs/cockpit/05-multiplatform-app.md b/docs/cockpit/05-multiplatform-app.md new file mode 100644 index 0000000..88a6ef4 --- /dev/null +++ b/docs/cockpit/05-multiplatform-app.md @@ -0,0 +1,185 @@ +# 05 — Multi-platform App: PWA-primary, Tauri-for-desktop + +> The "multi-platform app" that controls an installed trimwire. **Strategy (updated): +> PWA-first.** The cockpit is one installable web app — that single artifact *is* the app on +> every platform: desktop browsers, and **iOS + Android via Add-to-Home-Screen**, with **no app +> store and no developer fee**. **Tauri 2** (doc 02) is the optional *desktop convenience wrapper* +> around that same PWA (native window, tray, autostart, manage-the-daemon). Native mobile builds +> are **deliberately de-prioritized** — see §4. + +## 0. Why PWA-first (and why native mobile is the wrong bet) + +- **Maximal code-sharing:** the PWA *is* the shared artifact — one build, every surface (§6). + A native mobile app would, at best, wrap the same web view; at worst, fork the UI. PWA gets the + reach with zero extra UI. +- **No paid stores, on both OSes** (maintainer constraint): add-to-home-screen is free on iOS and + Android; the binary already serves the manifest + icon (the POC, doc 09). +- **Android is converging toward iOS.** Google's **developer-verification** requirement begins + **Sept 2026** (Brazil/Indonesia/Singapore/Thailand first, then global): all apps — *including + sideloaded ones* — must be registered by a verified developer on certified devices + ([Android Developers Blog](https://android-developers.googleblog.com/2026/03/android-developer-verification-rolling-out-to-all-developers.html), + [Gadget Hacks](https://android.gadgethacks.com/news/googles-new-android-sideloading-rules-start-august-2026/)). + So a "free APK sideload" path is becoming as gated as iOS — **the durable store-free path on + both is the PWA.** +- **The cockpit doesn't need native capabilities.** It's a foreground control panel over a live + local daemon. The known PWA gaps are all *background* features — no background sync / data-only + push, ~70–85% iOS push delivery + ([MagicBell](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide)) — + none of which a foreground panel uses. (Caveat: EU DMA can downgrade standalone PWAs to a Safari + tab; acceptable for self-hosted dev tooling.) + +## 1. Process model — every shell is a thin client over the daemon + +The app does **not** reimplement trimwire. Two viable shapes (pick per-platform; they're not +exclusive): + +- **A — Talk to the running daemon.** The app webview loads the shared `dist/` and calls the + local control API (`127.0.0.1:8766`) exactly as the browser cockpit does. The daemon is the + one the user already installed/`on`'d. Simplest; the app is "a nicer window onto the daemon." +- **B — Sidecar.** The Tauri core bundles the trimwire binary as a **sidecar**, starting/managing + it. Good for users who want the app to *be* the install. Tauri's Rust core can also link + trimwire's crates directly (`config`, `ledger` read, `preview`) for actions that don't need + the daemon running. + +**Recommendation:** ship **A** first (lowest risk, mirrors the browser path), offer **B** as an +opt-in "manage the daemon for me" mode once stable. Either way the **frontend bundle is +identical** — no second UI. + +The only app-specific code is the **platform adapter** behind the `api.*` client (doc 04 §7): +same-origin fetch in the browser; a Tauri command/native bridge in the app; remote base-URL + +token in the deferred remote phase. Screens are transport-agnostic. + +## 2. Why Tauri here (recap of doc 02, app lens) + +- Rust core → can depend on trimwire's own crates and run it as a sidecar; no logic re-impl. +- OS WebView → ~3–15 MB installer, ~30–50 MB RAM; honors the single-binary ethos. +- Same web frontend in browser and app — build once. +- Deny-by-default capability model — right for an app controlling a token-bearing daemon. +- Phasing: desktop now → remote (point the same frontend at a remote URL) → mobile (add Tauri + iOS/Android targets to the same project). + +## 3. Build, signing & CI + +Keep the app as a **separate workspace member / sub-crate** so it doesn't entangle the daemon's +MSRV-1.85 / cargo-deny / parity-oracle gates. The frontend build is the *same* one the web UI +needs (doc 04), so it's not net-new tooling. + +| Platform | Packaging | Signing | CI note | +|---|---|---|---| +| Linux | AppImage / DEB / RPM | optional | incremental on the existing 3-platform matrix; WebKitGTK is the rendering risk (doc 02 R1) — test with Playwright/WebKit | +| macOS | DMG / `.app` | notarization (Apple Developer acct) | add when shipping publicly; avoid `externalBin` notarization pitfalls by talking to a *separate* daemon process (shape A) rather than bundling, where possible | +| Windows | NSIS / MSI | Authenticode | Tauri GitHub Action handles it | +| iOS / Android | **see §4 — no paid store required** | self-sign / sideload / PWA | **deferred to the mobile phase**; the no-store distribution paths below avoid the App Store / Play fees entirely | + +Use Tauri's official `tauri-action` GitHub workflow. **Defer all mobile pipeline cost** to the +mobile phase. Early internal builds can be unsigned/ad-hoc. + +## 4. Mobile — without paying Google Play or the App Store + +**Maintainer constraint:** *won't pay for Google Play / Apple Developer just for this app.* That +is fine — none of trimwire's other distribution (the binary ships via GitHub releases / crates.io +/ binstall) goes through an app store, and mobile here is a **remote-control panel** (phone → +laptop's trimwire), not a consumer app. Distribution options, by platform, that need **no paid +store account**: + +| Platform | No-store option(s) | Cost | Notes | +|---|---|---|---| +| **Android** | (a) **PWA / "Add to Home Screen"** of the cockpit web UI; (b) **direct APK** from GitHub Releases (sideload); (c) **F-Droid** (free, open-source store) | **$0** | Android allows sideloading by default. A Tauri-Android or Capacitor APK built in CI and attached to a release is installable with no Play account. F-Droid is the free "real store" path. | +| **iOS** | (a) **PWA / "Add to Home Screen"** (Safari) — *recommended*; (b) free **sideload** (AltStore/SideStore, 7-day re-sign on a free Apple ID) | **$0** | iOS has no free public store, but an installable PWA covers the remote-control use case with zero fee and zero signing. Native sideload exists but is higher-friction (7-day expiry). | +| **Desktop** | GitHub Releases (DMG/MSI/AppImage), Homebrew, etc. | $0–low | Optional code-signing only when shipping publicly. | + +**Recommendation: PWA-first for mobile.** Because the cockpit is one web frontend (doc 04), the +phone "app" can simply be the **installable PWA** — add-to-home-screen on both iOS and Android, +zero store, zero fee, zero second codebase. Layer a **Tauri-Android / Capacitor APK** (distributed +via GitHub Releases + F-Droid) on top *only if* a native Android shell is wanted later. This keeps +the maintainer's "no paid stores" constraint as a first-class design choice, not a compromise. + +> Note: a **remote** PWA controlling a laptop over the network interacts with the deferred remote +> architecture (doc 06) and Chrome's Local Network Access (doc 10 G2/G4) — the phone reaches the +> daemon over the user's **overlay/LAN** (a loopback-equivalent origin via the tunnel), not a +> public page hitting `127.0.0.1`. Same-machine use stays trivial; cross-device is the v3 gate. + +Tauri 2 mobile itself is *stable-API but maturing* — fine for this webview-driven panel, weak for +deep-native; re-evaluate at the mobile-phase kickoff. **Flutter is not the fallback** (maintainer +preference, and it would mean a second UI); the fallback is always **the same web frontend** as a +PWA or Capacitor wrapper. + +## 5. Code & component sharing (app ↔ web cockpit) — the core of the strategy + +The cockpit is **one codebase, one build artifact**. The browser PWA, the Tauri desktop shell, +and any future remote/mobile client are the **same `dist/`** plus a thin per-platform adapter. +That is the whole reason this is cheap for a solo maintainer. + +**Shared — ~100% of the frontend (single source of truth):** + +- **UI components** — KPI cards, sortable/sticky tables, in-cell bars, config forms, dialogs, + the live-monitor list (doc 04 inventory). +- **Design tokens** — `tokens.css` (teal palette, `0.55rem` radius, dark/light, `tabular-nums`), + lifted verbatim from the site so cockpit + site read as one brand. +- **The typed API client** for `/api/v1` — the *only* way any shell talks to the daemon (doc 11). +- **The reactive store** + screen/route definitions + formatting/validation helpers + (`fmtBytes` stays 1024-based to match `stats::human_bytes`). + +**Not shared — a thin platform adapter (tens of lines), behind one interface:** + +| Concern | Browser **PWA** | **Tauri** desktop | Remote/mobile (deferred) | +|---|---|---|---| +| transport (`api.*`) | same-origin `fetch` / `EventSource` | same-origin to loopback/sidecar, or `invoke` | base URL + device token over TLS/overlay | +| token / secure store | server-injected same-origin bootstrap | **OS keychain** | OS keychain + pairing (doc 06) | +| shell chrome | browser tab / installed PWA window | native window, tray, autostart, manage-daemon | mobile home-screen PWA | + +**Adding a platform = writing one small adapter, never a new UI.** Screens are +transport-agnostic; they call typed `api.*` methods and never know which shell they're in. + +**Build once, serve/embed everywhere:** a single Vite (or SvelteKit) build emits `dist/` + the +PWA `manifest`/icon (and later a service worker). The trimwire binary `include_str!`/`rust-embed`s +and serves it — **the POC already serves the HTML + `manifest.webmanifest` + `icon.svg` this way** +(doc 09) — and the Tauri shell bundles the *same* `dist/`. One build feeds the browser, the PWA, +and the desktop app. + +**An extra sharing layer on desktop only:** because Tauri's core is Rust, the desktop shell can +*additionally* reuse trimwire's own crates (ledger read, config types, the typed control-API +client) — a second layer of sharing no other framework offers (doc 02). The PWA can't link Rust +and doesn't need to: it speaks the same `/api/v1` contract. + +**Net:** sharing isn't just "some components" — it's the **entire frontend artifact + the API +contract**, with platform differences quarantined to a tiny adapter, plus a bonus Rust-crate layer +on desktop. That is what lets "one app" mean *every* platform without a second team. + +### Offline / service-worker note (additional finding) + +A control panel needs the *live* daemon for data, so "offline" is limited by nature: a service +worker can cache the **app shell** (HTML/CSS/JS/manifest/icon) so the window opens instantly and +shows a clean "daemon unreachable" state, but the data panes still require the daemon. The POC +ships the manifest + icon (installable today); the **service worker for app-shell caching is a +tracked follow-up** — it must be served with a permissive-enough CSP (`worker-src 'self'`) and +must never cache `/api/*` responses (always network for live, content-free data). + +### Secure-context / remote tie-in (additional finding) + +PWA install + service workers require a **secure context** (HTTPS *or* `localhost`). Same-machine +use is `localhost` → secure → works with no TLS (the common case). A **remote** PWA (phone → +laptop) therefore inherits the **v3 TLS/overlay requirement** (doc 06): the daemon must be reached +over an HTTPS overlay/LAN origin, not a public page hitting `127.0.0.1` (which also trips Chrome +Local Network Access, doc 10 G2/G4). So "PWA-first" and "remote is gated on TLS" are consistent, +not in tension. + +## 6. Risks specific to the app + +- **WebKitGTK drift on Linux** — keep the UI plain; CI against WebKit. (doc 02 R1) +- **Notarization/signing burden** — defer mobile; reuse `tauri-action`; prefer shape A to dodge + sidecar-notarization bugs. +- **Daemon coupling** — the app is a *client* of the control API; if the daemon is old/missing, + the app shows a clear "daemon not found / version mismatch" state and offers to install/start + (shape B) rather than failing opaquely. +- **Scope/regret** — the HTTP-first design means if Tauri ever disappoints, the same frontend + survives as a PWA. The app is the lowest-regret native option. + +## 7. Open questions for the maintainer + +- Shape **A vs B** as the default (talk-to-daemon vs sidecar)? (Recommended: A first.) +- Does the desktop app ship in the **same repo** (workspace member) or a sibling repo? (Workspace + member keeps the shared frontend + Rust client in one place; sibling keeps the daemon repo + pure. Recommended: workspace member, gated so it doesn't touch the daemon's CI matrix.) +- Is mobile genuinely wanted, or is "multi-platform" satisfied by cross-OS **desktop** + a + browser PWA on phones? (Decides whether the Apple/Play tax is ever paid — see doc 06.) diff --git a/docs/cockpit/06-remote-control.md b/docs/cockpit/06-remote-control.md new file mode 100644 index 0000000..5ff7aad --- /dev/null +++ b/docs/cockpit/06-remote-control.md @@ -0,0 +1,122 @@ +# 06 — Remote Control (designed-for, deferred) + +> **v1 is local-loopback only.** Remote control (phone → laptop's trimwire) is a later phase. +> This doc designs that phase **and** specifies the cheap seams v1 must include now so remote is +> purely **additive**, never a rewrite. + +**Hard invariant (the whole ballgame, repeated everywhere):** +> The Anthropic subscription **OAuth token never leaves the host** and is never exposed by the +> control API. The control surface is **daemon control + content-free stats only**. `[server] +> upstream` is **never remotely settable**. Enforced structurally (layer boundary) *and* by a CI +> leak test. + +## 1. Transport options + +| Option | What it is | Lightweight | ToS-safe | Solo-maintainer | Secure-by-default | Reach | Verdict | +|---|---|---|---|---|---|---|---| +| **(a) Direct LAN** | daemon binds LAN iface, mDNS `_trimwire._tcp`, self-signed TLS | excellent | neutral | excellent | good *if* opt-in + TLS + pairing | same subnet only | **opt-in primary, same-WiFi** | +| **(b) BYO overlay** (Tailscale/WireGuard) | **daemon stays loopback**; user's overlay forwards to `127.0.0.1` | excellent (ships nothing) | neutral | excellent (no infra) | **best — bind never widens** | full NAT traversal | **primary, cross-network** | +| (c) trimwire-hosted relay | rendezvous/TURN brokers both ends | poor (24/7 infra, cost) | caution | poor | only if zero-knowledge | full | **v3 last resort** | +| (d) Reverse tunnel | daemon dials out to a user-owned endpoint | good | neutral | medium | good | full (outbound) | v3 alternative | + +### Recommendation + +- **Cross-network primary: (b) bring-your-own overlay.** The daemon stays bound to loopback + exactly as in v1; the user runs Tailscale/WireGuard and the overlay forwards a private address + to `127.0.0.1:8766`. trimwire ships **nothing extra** and runs **no infrastructure**, and the + token-bearing process never widens its bind. Auth is doubled (overlay keys + trimwire pairing + token). **Do NOT embed `tsnet`/`tailscale-rs`** — too heavy for the single-binary ethos; + recommend BYO in docs. +- **Same-LAN convenience: (a) Direct LAN, opt-in.** For "phone on the same WiFi," offer explicit + opt-in LAN exposure with mDNS + TLS + pairing. This is the only mode where trimwire changes its + bind, so it is **gated hard** (§4, §5). +- **Relay/reverse-tunnel: v3 only**, off by default, **zero-knowledge** (relay brokers an + encrypted channel; never terminates TLS, never sees the pairing token or any stats). Build only + if BYO-overlay proves too high-friction. + +## 2. Pairing & auth — trust-on-first-use + +1. **Host generates a pairing offer** (`trimwire pair` or "Add device" in the local cockpit): + a short-lived (~120s), single-use, high-entropy pairing code + the daemon's TLS cert + fingerprint + reachable address, rendered as a **QR** (typeable fallback): + `trimwire-pair://?fp=&pc=&v=1`. +2. **App scans / types**, connects, **pins the fingerprint** (TOFU — rejects on mismatch + thereafter), and presents the pairing code over the cert-pinned channel. +3. **Token exchange:** daemon validates the code (single-use, unexpired), issues a **per-device + bearer token** (256-bit, stored hashed server-side), app stores it in the platform secure + store (Keychain/Keystore/DPAPI/libsecret). Pairing code is burned. +4. **Subsequent calls** present `Authorization: Bearer ` over the pinned channel. + +This is the **OAuth 2.0 Device Authorization Grant shape** but fully self-contained — the local +daemon *is* the authorization server; no external IdP, no trimwire-hosted auth. TOFU cert +pinning + a pairing code is the documented best practice for local daemons that can't run PKI; +the code defeats a same-LAN attacker who reaches the port but can't see the host's screen. + +**Per-device tokens** live in a `devices` table (separate from the content-free ledger): +`device_id, label, token_hash, created_at, last_seen, expires_at, capabilities, revoked_at`. +Revocation is immediate (`trimwire devices revoke `) — the server-side token store *is* the +revocation list (no CRL). Tokens carry `expires_at` (30–90 days, configurable); optionally +issue short-lived (5–60 min) access tokens derived from the device token to bound replay. + +## 3. Threat model + +| # | Threat | Mitigation | +|---|---|---| +| T1 | OAuth token theft | token never readable via control API (structural); `upstream` not remotely settable; control responses schema-tested to exclude it | +| T2 | Daemon hijack to redirect upstream | `upstream` immutable from any remote/control path; `config:write` hard deny-lists `server.upstream`; re-pointing stays a local-file + restart op | +| T3 | MITM on control channel | TLS required for any non-loopback bind; cert-fingerprint pinning (TOFU); mismatch = hard reject | +| T4 | Replay | short-lived derived access tokens; TLS; optional per-request nonce/timestamp on writes | +| T5 | Malicious LAN peer | bind opt-in (off by default); pairing code required to mint a token; rate-limit + lockout | +| T6 | Exposed-port scanning | default loopback (unreachable); when exposed, TLS + valid token (scanner gets 401); `doctor` warns on non-loopback bind | +| T7 | DNS rebinding / cross-origin (browser cockpit) | strict `Host`/`Origin` allowlist; same-origin token, no ambient auth | +| T8 | Relay compromise (v3) | zero-knowledge relay — never sees plaintext/token/stats; can DoS but not read | +| T9 | Pairing-code brute force | ≥40-bit code, ~120s TTL, single-use, attempt lockout, valid only on the pinned channel | +| T10 | Stolen/lost device | per-device revocation; expiry; secure-store at rest; remote revoke from host | +| T11 | Scope confusion (read token used for control) | capability scope checked per-endpoint, deny-by-default | +| T12 | Detection-evasion creep | **forbidden** — remote control is faithful proxy control only; no obfuscation, no upstream spoofing | + +## 4. What v1 MUST NOT preclude — requirements for the v1 control API (doc 03) + +Each is cheap now, expensive to retrofit. The control-API design (doc 03) already commits to +these: + +- **R1 — Auth-token abstraction.** Every request flows through an `Authenticator` trait; + handlers receive an authenticated `Principal`, never raw trust. v1 impl = `LoopbackToken`. +- **R2 — Bind-address config + explicit opt-in.** Bind is a config field; v1 ships loopback-only + and **refuses** any non-loopback bind (error points at the future opt-in). +- **R3 — TLS-readiness.** The listener is constructed through an abstraction that can wrap a + rustls acceptor (config-driven branch). Cert/key paths reserved in config (unused in v1). +- **R4 — Per-request device identity.** Handlers operate on `Principal { device_id, scopes, + channel }`, even when v1 fills a synthetic "local" principal. +- **R5 — Capability scoping.** Define caps now (`stats:read`, `ledger:read`, `service:toggle`, + `profile:switch`, `config:read`, `config:write` [upstream-excluded], `sweep:run`, + `summarizer:manage`); each endpoint declares its required cap; deny-by-default. +- **R6 — Upstream-credential firewall.** Control handlers live in a module that *cannot* import + the proxy credential; `config:write` hard deny-lists `server.upstream`; **a CI test asserts no + control response field derives from the upstream credential.** +- **R7 — Host/Origin validation hook** (defeats DNS-rebinding) — built now, even loopback. +- **R8 — Versioned, content-free, token-free API contract** (`/api/v1`); pairing/`devices` + endpoints stubbed/absent in v1 but the version prefix + content-free discipline hold. +- **R9 — Rate-limit/lockout seam** on the auth layer (no-op/generous in v1). +- **R10 — Secure local-credential storage** (`0600` token in `~/.trimwire/`) — the convention the + `devices` table extends. + +## 5. Phasing & the gate at each step + +| Phase | Transport / bind | Auth | Gate to ship | +|---|---|---|---| +| **v1 — local loopback** | `127.0.0.1` only; refuses non-loopback | local principal (R1) + `0600` token (R10) | seams R1–R10 present & tested; **token-leak CI test is the merge gate** (R6) | +| **v2a — Direct LAN (opt-in)** | LAN bind + mDNS + self-signed TLS | per-device tokens via QR/pairing TOFU; scopes (R5); revocation/expiry | off by default; refuses to expose without TLS+pairing; Host/Origin active; pairing rate-limit; `doctor` warns | +| **v2b — BYO overlay (recommended)** | **daemon stays loopback**; user's overlay forwards | same per-device-token layer (defense in depth atop overlay keys) | bind unchanged from v1 (best posture); docs only; pairing still required | +| **v3 — relay / reverse tunnel** | zero-knowledge rendezvous OR user-owned tunnel; off by default | E2E; per-device tokens unchanged; relay token/content-blind | build only if BYO friction demands it; provably content/token-blind; abuse/DoS plan | + +**Gate principle:** every phase is off by default, requires explicit opt-in, and cannot regress +the token invariant. Each step up the ladder widens *reachability*, never *trust* — the +auth/scope/credential model is identical from v2 onward; only the transport changes. + +## Sources + +Tailscale `tsnet` docs + `tailscale-rs` preview; mDNS/DNS-SD references; OAuth 2.0 Device +Authorization Flow (Descope) + IETF cross-device security BCP; self-signed TLS client-auth / +fingerprint-pinning patterns; bearer-token best-practice guides. Full URLs in the session +transcript. diff --git a/docs/cockpit/07-security-tos-redlines.md b/docs/cockpit/07-security-tos-redlines.md new file mode 100644 index 0000000..6140964 --- /dev/null +++ b/docs/cockpit/07-security-tos-redlines.md @@ -0,0 +1,116 @@ +# 07 — Security, ToS & Red Lines (the disagree-seeking review) + +> This doc is deliberately adversarial. While the other docs design an exciting cockpit, this +> one steelmans the case **against** building it — or against building parts of it — and where +> it drifts from trimwire's identity. It is anchored in trimwire's *own* code, not speculation. +> **The red lines here are non-negotiable constraints on everything else in this folder.** + +## The dissent in five sentences + +1. trimwire's whole value is "one small static binary, no heavy runtime deps, transparent and + ToS-compliant." A full-control local API + remote stack + native multi-platform app is the + single biggest scope expansion the project could make — driven by a *metaphor* ("cockpit"), + not measured user demand. +2. The codebase already encodes, in `config.rs`, that **the most dangerous thing in the system is + "where does the Bearer token get sent"** — a mutating control API is a new write path into + exactly that surface, and a remote port is a network-reachable handle on a process holding a + Claude OAuth token. +3. "Remote control of your Claude proxy" reads, to an enforcement team, like the redistributed- + token pattern that **already got OpenClaw/OpenCode/Cline killed in 2026** — even if + implemented innocently, it moves trimwire from *passive-gray* toward something that *looks* + active. +4. The content-free guarantee holds today because the ledger *literally cannot* hold content; + several proposed panes ("preview what's pruned," "inspect sessions") create pressure to read + **raw transcripts** into a UI — the one move that breaks it. +5. The existing `trimwire dashboard` HTML + statusline + CLI already deliver ~90% of the + cockpit's read value at ~0% of its risk. + +## Risk register + +| Risk | Likelihood | Severity | Mitigation | +|---|---|---|---| +| Control API exposes a config-write path that can redirect the OAuth token (`upstream`/summarizer URL) | Med | **Critical** | R1: forbid those keys from the API; reuse the existing global-only guard + its regression test | +| Localhost control port hit by a malicious web page (CSRF → config write / info disclosure) | **High** if unguarded | Critical | R4: per-install bearer secret + Host/Origin checks; loopback bind | +| "Remote control of your Claude proxy" pattern-matches enforcement → ToS/account action | Med | **Critical** | R6: defer + gate on ToS re-review; v1 loopback-only; keep the "compliant" brand | +| Preview/inspect pane renders transcript content → breaks content-free guarantee | **High** (UX pull) | High | R7+R8: counts/structure only; ledger-and-`--json`-only data sources; never read live transcripts | +| Native multi-platform + mobile build/signing burden sinks solo velocity | High | High | ship a PWA; no native app / mobile in the minimal v1 | +| JS SPA + node_modules reverses the lightweight ethos / adds supply-chain surface | Med | Med | reuse the existing dashboard front-end machinery; no heavy SPA framework; JS is build-time only | +| Remote relay creates recurring infra cost + uptime/abuse obligation | Med (if built) | High | don't build it in v1; BYO overlay (doc 06) needs no infra | +| Mutation logic creeps into `gateway.rs`, eroding layer discipline | Med | Med | R9: separate `admin/` module/router; gateway stays non-mutating | +| Scope absorbs bandwidth AGENTS.md reserves for CODE > DX | High | Med | decouple the cheap read view from the expensive control/remote/app; ship the cheap half first | + +## RED LINES (must not be crossed — bind every other doc) + +- **R1** — The control API can **never** write `server.upstream` or any summarizer + `base_url`/`full_url`. Route any config write through the same global-only guard, or forbid + those keys outright. +- **R2** — The control API **stays bound to `127.0.0.1`** in v1; **no** `0.0.0.0`/LAN bind option + ships in v1. +- **R3** — The API **never** returns the OAuth token, `Authorization` header, or any derived + value, and **never** exposes a "test/ping upstream" endpoint that originates a call on the + subscription token. +- **R4** — The API **defends against drive-by browser requests**: a per-install bearer secret + + Host/Origin checks on every call. A localhost port is **not** a security boundary against local + web pages. +- **R5** — **No detection-evasion, ever** — no client-fingerprint spoofing, header-order mimicry, + or proxy-hiding. +- **R6** — **Remote control is deferred AND gated on an explicit, written ToS re-review** before + any relay/exposure code. "Do not design the relay until we've re-checked enforcement posture." + (Designing the *API shape* for remote-additivity, per doc 06 R1–R10, is fine; standing up + network infrastructure is not.) +- **R7** — **No UI pane renders message content, prompts, tool-result bodies, or file paths.** + Preview shows counts, byte deltas, strategy names, block *types* — never the bytes. +- **R8** — The API's data sources are **the ledger + content-free `--json` outputs only.** No + reading of `~/.claude/**` transcript bodies into any UI surface. +- **R9** — The control API lives in its **own module/router**, not inside `gateway.rs` (which the + layer rules say must not contain mutation). Don't erode the layer discipline that keeps the + core testable. + +## Fresh-sources update (doc 10) + +A second adversarial pass against 2026 sources added these constraints (full detail + +citations in [doc 10](10-security-fresh-sources.md)): + +- **R4 strengthened:** Host-pin is necessary-but-not-sufficient. Require a **`Sec-Fetch-Site`** + gate **and** a **custom preflight-forcing header** on writes, ordered before the token check. +- **Token-in-HTML is a tradeoff, not solved:** any XSS exfiltrates the control token → require + a **strict CSP** (nonces, not `'unsafe-inline'`) or move to an **`HttpOnly` cookie handshake**; + native client reads the token from the **OS keychain**. +- **New ToS note (R6 reinforced):** the 2026-02-20 clarification has **no carve-out for local + proxies/middleware**, and the **base passthrough proxy itself** (which modifies request bodies) + is grayer now — pre-write the affirmative compliance argument; keep remote hard-gated. +- **Browser LNA (Chrome 142) does NOT cover localhost→localhost** — don't lean on it. + +## The safest minimal viable cockpit (the dissent's counter-proposal) + +If trimwire wanted the *smallest* responsible step: + +1. A **local-only, read-mostly page served by the binary**, reusing `dashboard.rs` + + `dashboard_template.html`. Data: ledger + content-free `--json`. ~90% of the value, ~0% new + risk. +2. **Control limited to three verbs:** on/off; switch profile (`default`⇄`gentle`); a + **whitelisted** subset of strategy knobs applied via restart — **never** `upstream`/summarizer + endpoints. No general config-write; no sweep-from-UI in the minimal cut (sweep mutates on-disk + transcripts — keep it auditable in the CLI initially). +3. **Ship as a PWA, not a native app.** Zero native build matrix, zero signing, zero app-store + tax. No mobile in the minimal v1. +4. **No remote, at all, in the minimal v1.** Loopback only; design the data model for a future + remote phase but don't build (or design the relay for) it until the ToS re-review (R6). + +## How the rest of this folder reconciles with the dissent + +The plan does **not** ship the maximal version all at once. The reconciliation (see +[README](README.md) and [roadmap](08-roadmap.md)): + +- The **roadmap's v0/v1 ≈ this minimal proposal** — local, content-free, binary-served, bounded + control. That's what gets built and proven first. +- **Full config control, the native app, LAN/overlay remote, and mobile are later phases**, each + behind the red lines above and an explicit security/ToS gate. +- The control-API design (doc 03) and the remote design (doc 06) already adopt R1–R9 as + load-bearing requirements — the separate loopback admin listener, the upstream-credential + firewall with a CI leak test, Host/Origin validation, content-free events, and the + "designed-for-but-deferred" remote seams are direct responses to this review. + +So this doc isn't an objection the plan ignores — it's the **constraint system the plan is built +inside.** If a future change conflicts with a red line here, the red line wins, or the change +needs explicit maintainer + ToS sign-off. diff --git a/docs/cockpit/08-roadmap.md b/docs/cockpit/08-roadmap.md new file mode 100644 index 0000000..d057882 --- /dev/null +++ b/docs/cockpit/08-roadmap.md @@ -0,0 +1,116 @@ +# 08 — Roadmap + +> The phased plan that ties docs 03–07 together. Each phase is **off by default until the prior +> one is proven**, ships through the existing PR/CI flow, and is bounded by the red lines in +> [doc 07](07-security-tos-redlines.md). The destination is the cockpit the maintainer asked for +> (full control, multi-platform app, phased remote); the sequencing is what makes getting there +> safe. + +## Phase map at a glance + +| Phase | Deliverable | Reach | Risk | Gate to start | +|---|---|---|---|---| +| **v0** | Read-only cockpit (live, served by the binary) | local loopback | very low | none — it's a reskin of `dashboard` | +| **v1** | Full **local** control (the control API + Flightdeck UI), **installable as a PWA** | local loopback | low–med | v0 shipped; red lines R1–R9 enforced; token-leak CI test green | +| **v2** | **Tauri desktop wrapper** of the same PWA (tray, autostart, sidecar) | local | med | v1 shipped; shell is a thin client of the v1 API | +| **v3** | **Remote** control (BYO overlay + opt-in LAN) — the **PWA over TLS/overlay** | cross-network, opt-in | high | **written ToS re-review (R6)**; remote seams from v1 in place; pairing/devices built | +| **v4** | **Mobile = the PWA** (Add-to-Home-Screen, no store/fee); native APK only if ever needed | remote | low–med | v3 shipped (for cross-device); **no Apple/Play pipeline required** | + +## v0 — Read-only cockpit (the cheap, near-free win) + +**What:** upgrade `trimwire dashboard` into a live, locally-served page. Reuse `dashboard.rs` + +`dashboard_template.html` + the site's dashboard components + design tokens. Data: ledger + +`stats`/`recall`/`preview --json`. **No control actions**, **no remote**, content-free. + +**Why first:** it's ~90% of the perceived value at ~0% new risk, validates the UI/design, and +ships before any of the contentious surface exists. Maps to the disagree-seeking review's +"safest minimal" read view (doc 07). + +**Backend need:** minimal — could even be the static HTML report plus a tiny read-only loopback +serve. If building toward v1, start the `admin/` read endpoints here (control-API PR 1, read-only +subset). + +## v1 — Full local control + +**What:** the control API (doc 03, PRs 1–5) + the Flightdeck UI (doc 04) with full control: +on/off, profile switch, validated config + per-strategy knobs (restart-or-hot-reload), preview, +sweep (dry-run-first), summarizer, share opt-in, live SSE monitor. **Loopback only.** + +**Non-negotiables (doc 07):** separate `127.0.0.1:8766` admin listener (R2, R9); bearer token + +Host/Origin guard (R4); `config:write` deny-lists `server.upstream`/summarizer URLs (R1, R3); +content-free panes & data sources only (R7, R8); no detection-evasion (R5). **The token-leak CI +test (doc 06 R6) is the merge gate.** + +**Frontend stack decision:** vanilla DOM vs **Svelte** (doc 04 §2). Given the site is being +rebranded to Svelte+Astro, the recommendation is to build the cockpit in **Svelte** so it shares +one idiom + component library with the new site — *unless* the cockpit ships before the rebrand, +in which case start vanilla (a direct lift) and port later (mechanical; data contracts + tokens +don't change). **Open for maintainer confirmation.** + +**PWA in v1:** the binary serves a Web App Manifest + icon (already in the POC), so v1 is +**installable** — desktop browsers get "Install app", and phones on the same machine/overlay get +Add-to-Home-Screen. This is the *multi-platform app* for most users; v2 is just a nicer desktop +shell on top. + +**Exit:** people actually use it locally. That usage is the evidence (AGENTS.md: "measure, don't +guess") that justifies climbing to v2+. + +## v2 — Tauri desktop wrapper (optional convenience) + +**What:** a Tauri 2 desktop shell wrapping the **same** PWA bundle (doc 05). Shape **A** +(talk-to-running-daemon) first; optional sidecar mode (B) later. Adds desktop packaging to the +existing 3-platform CI via `tauri-action`. **Still local** — the shell talks to `127.0.0.1:8766`. +It adds tray/autostart/sidecar + OS-keychain token storage; it does **not** add a new UI. + +**Why after v1, and why optional:** the shell is a thin client of the v1 API, and the PWA already +covers "an installable app on every platform." Tauri is a desktop *enhancement*, not the thing +that makes the cockpit multi-platform. The HTTP-first design means v2 is low-regret (if Tauri +disappoints, the PWA still works). + +## v3 — Remote control (the gated leap) + +**What:** reach a daemon on another machine. **Primary: BYO overlay (Tailscale/WireGuard)** — the +daemon stays loopback, the user's overlay forwards (trimwire ships no infra). **Plus opt-in +Direct LAN** (mDNS + self-signed TLS + QR/pairing-code TOFU). Per-device tokens, capability +scopes, revocation/expiry (doc 06). + +**Hard gate (doc 07 R6):** a **written ToS re-review before any exposure/relay code.** "Remote +control of the process holding your Claude OAuth token" is the feature most likely to spend +trimwire's compliance moat — it must be deliberate, off by default, and never expose the token or +make `upstream` remotely settable. The v1 control API already ships the additive seams (doc 06 +R1–R10) so this is new endpoints (`/pair`, `/devices`) + a transport, not a v2 API. + +**Relay (c)/reverse-tunnel (d) are v3.x last resorts** — zero-knowledge, off by default, built +only if BYO-overlay friction demands it. + +## v4 — Mobile (no paid app store) + +**What:** a phone client (remote controller) = the **same web frontend as an installable PWA** +(add-to-home-screen on iOS + Android, $0, no store) — the recommended default. Optionally a +**Tauri-Android / Capacitor APK** distributed via **GitHub Releases + F-Droid** (also $0, no Play +account) if a native Android shell is wanted. **No Apple Developer / Google Play fee is required** +(maintainer constraint), and **Flutter is not the fallback** — it's always the same web frontend +(doc 05 §4). Depends on v3 (remote) being in place for cross-device control. + +## Cross-cutting workstreams + +- **Docs:** update `ARCHITECTURE.md` (new `admin/` module + layer rows + decision log), + `CONFIGURATION.md` (`[admin]`), `SECURITY.md` (loopback+token+Host-pin model, content-free + events) as the relevant phase lands. +- **Tests:** content-free event test (mirrors `audit.rs`); token-leak CI test; **API contract + tests** so the cockpit can't break when CLI commands change (`/api/v1` shape pinned; byte-equal + to `--json` from the shared builder — doc 11); atomic-write crash-safety; hot-reload visibility. +- **Parity oracle:** untouched throughout — the control plane never touches `strategies/` or + `pairing/`. + +## Decision log (open questions for the maintainer) + +1. **Frontend stack:** Svelte (recommended given the site rebrand) vs vanilla-now-port-later? +2. **How far is v1's config control?** Full validated editor (doc 03) vs the disagree-seeking + review's bounded "on/off + profile + whitelisted knobs" (doc 07)? Recommendation: ship the + bounded version in v1, expand to the full editor once the write path + restart UX are proven. +3. **App home:** workspace member vs sibling repo (doc 05 §7)? +4. **Is mobile (v4) actually wanted,** or is multi-platform satisfied by desktop + PWA? +5. **Visible name:** Flightdeck / Console / Panel (doc 04 §6)? + +These don't block v0/v1 — they shape v2+. None requires answering tonight. diff --git a/docs/cockpit/09-poc.md b/docs/cockpit/09-poc.md new file mode 100644 index 0000000..fef1b99 --- /dev/null +++ b/docs/cockpit/09-poc.md @@ -0,0 +1,91 @@ +# 09 — Proof of Concept (small, but touches every layer) + +> A deliberately small but **end-to-end vertical slice** of the cockpit, built on +> this branch. It compiles clean (`fmt` / `clippy -D warnings` / full test suite) +> and runs. It is **off by default** — the daemon is byte-for-byte unchanged unless +> you opt in via `[admin] enabled = true` or run `trimwire cockpit`. + +![trimwire Flightdeck cockpit (POC)](assets/cockpit-poc.png) + +*The running cockpit: teal Flightdeck brand, a live "gateway serving" indicator, +content-free KPI cards from the local ledger, and a working SSE feed — served by +the trimwire binary on a loopback-only control API.* + +## What the POC includes (every layer, minimally) + +| Layer (plan doc) | POC artifact | State | +|---|---|---| +| **Control API** (doc 03) | `src/admin/mod.rs` — separate **loopback admin listener** (`127.0.0.1:8766`), kept off the token-bearing gateway port | real | +| ↳ Auth seam (doc 06 R1) | `Authenticator` trait + `LoopbackToken` impl; 256-bit bearer token at `~/.trimwire/control.token` (`0600`), constant-time compare | real | +| ↳ DNS-rebind / CSRF guard (doc 03 §5, doc 06 R7) | authority (`Host`/h2 `:authority`) allowlist + same-origin `Origin` + `Sec-Fetch-Site` — three gates, ordered before auth | real | +| ↳ Token-page XSS hardening (doc 10 G3) | per-render **nonce CSP** (no `'unsafe-inline'`, `worker-src 'none'`) + `X-Frame-Options: DENY` on the token-bearing HTML | real | +| ↳ Read endpoints | `GET /api/v1/{health,version,service,stats}` — `stats` reuses the content-free ledger `Report`; `version` omits `upstream` | real | +| ↳ Live events (doc 03 §4) | `GET /api/v1/events` — SSE, content-free aggregate snapshot | real (one-shot; prod uses a broadcast channel) | +| **Web cockpit** (doc 04) | `src/admin/cockpit.html` — embedded single-file UI, teal design tokens, same-origin token bootstrap, KPIs + SSE log | real (vanilla; prod may use Svelte) | +| ↳ **PWA install** (doc 05 — PWA-first) | served `GET /manifest.webmanifest` + `GET /icon.svg`, linked from the HTML → **installable** ("Install app" / Add-to-Home-Screen, no store) | real (manifest+icon; service worker is a follow-up) | +| ↳ CLI surface | `trimwire cockpit` subcommand + `[admin]` config section | real | +| **Multi-platform app** (doc 05) | **PWA-primary** (the page above is the app on every platform) + `app/` Tauri 2 desktop-shell scaffold | PWA real; Tauri scaffold (not in CI) | +| **Remote** (doc 06) | non-loopback bind **refused** at startup; auth/identity seam present | seam only (deferred, by design) | +| **Security red lines** (doc 07) | loopback-only, token never exposed, `upstream` never written **nor returned**, content-free responses, separate module (not in `gateway.rs`) | enforced | + +## Run it + +```bash +# Easiest: one command starts the gateway + the control API + the web UI. +trimwire cockpit +# [cockpit] open http://127.0.0.1:8766 in your browser + +# Or enable it permanently for the always-on daemon: +# ~/.config/trimwire.toml +# [admin] +# enabled = true +# listen = "127.0.0.1:8766" +``` + +The control token is printed/located at `~/.trimwire/control.token`. The browser UI +is handed it same-origin at page load; CLI/curl callers pass +`Authorization: Bearer `. + +## Verified behaviour (smoke test) + +``` +GET /api/v1/health → 200 {"ok":true,"version":"0.3.16"} (unauth) +GET /api/v1/stats (no token) → 401 unauthorized +GET /api/v1/stats (Bearer token) → 200 full content-free ledger Report +GET /api/v1/version (Bearer token) → 200 {version, control_api, profile, listen addrs} + (NO `upstream` — credential routing never crosses the wire) +GET /api/v1/service (Bearer token) → 200 {serving:true, ...} (live gateway probe) +GET /api/v1/health (Host: evil.com) → 403 forbidden: bad Host (DNS-rebind guard) +GET /api/v1/health (Origin: evil) → 403 forbidden: bad Origin (cross-origin guard) +GET / → HTML, token injected, nonce-CSP (no `'unsafe-inline'`) +``` + +Plus 13 unit tests in `src/admin/mod.rs` (constant-time compare, authority/Origin/ +`Sec-Fetch-Site` guards, bearer extraction, token generation = 256-bit hex + stable + +concurrency-safe, authenticator accept/reject, `/version` contract incl. **`upstream` +absent**, `/stats` content-free, PWA manifest, nonce-CSP, HTML-placeholder guard) and a +`[admin]`-global-only regression test in `src/config.rs`. Full suite stays green: +`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --all-features`. + +## What is deliberately NOT in the POC + +- No config **write** / hot-reload (read + control-shape only); no `ArcSwap` refactor. +- No socket-activation for the admin port (plain loopback bind). +- No real streaming SSE (one-shot snapshot; `EventSource` reconnects make it live-ish — and + each reconnect re-runs a ledger `report()` query, so an open tab polls ~every 2s. Production + replaces this with a broadcast channel fed at ledger-write time, doc 03 §4). +- No custom **`X-Trimwire-Control`** preflight-forcing header — not needed yet (the POC is + read-only + token-gated), but **required before any mutating endpoint** (`POST /service/*`, + `PUT /config`) lands, per doc 10 G1. +- No remote transport, pairing, or `devices` table (deferred phase — only the seams). +- The Tauri `app/` is a scaffold: it shows the shape but is not built by CI and needs + the Tauri toolchain to run. + +These are the exact follow-ups itemized in docs 03/06/08 — the POC is the first +slice of that plan, not a shortcut around it. + +## New dependencies + +**None.** The control API reuses `hyper` / `http-body-util` / `tokio` / `serde_json` +/ `getrandom` / `hex` already in the dependency tree — honoring the single-static- +binary, no-heavy-runtime-deps ethos. diff --git a/docs/cockpit/10-security-fresh-sources.md b/docs/cockpit/10-security-fresh-sources.md new file mode 100644 index 0000000..7fadf10 --- /dev/null +++ b/docs/cockpit/10-security-fresh-sources.md @@ -0,0 +1,133 @@ +# 10 — Security: Fresh-Sources Addendum (2026 review) + +> A second, adversarial research pass against the plan using sources the other docs +> do **not** cite (independent / 2025–2026-dated). It found four things the plan got +> right, and several real gaps. The corrections below are folded back into docs 03, +> 04, 07, 02 by reference, and the cheap ones are already implemented in the POC +> (doc 09). Citations are inline. + +## What the plan got right (confirmed) + +1. **Treating localhost as hostile is correct.** The Ollama DNS-rebinding CVE + (CVE-2024-28224) is a localhost daemon with *no* Host pinning getting full remote + API access from a malicious web page — exactly the attack the Host-pin guards. + ([NCC Group](https://www.nccgroup.com/research/technical-advisory-ollama-dns-rebinding-attack-cve-2024-28224/)) +2. **"Bearer token, not a cookie → CSRF structurally prevented" is the strongest part + of the design** — a token in an `Authorization` header is not ambient/auto-attached. + ([OWASP CSRF](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)) +3. **Serving the UI same-origin on the admin port** (CORS stays default-deny) is right, + and `http://localhost` / `127.0.0.0/8` are "potentially trustworthy" secure contexts, + so the page can use SubtleCrypto and isn't mixed-content-blocked. + ([W3C Secure Contexts](https://www.w3.org/TR/secure-contexts/)) +4. **"Defer remote, stay loopback, no detection-evasion" (R5/R6) is vindicated by 2026 + events** — Anthropic's Jan–Apr 2026 crackdown killed exactly the named tools. + ([The Register, 2026-02-20](https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/)) + +## Gaps and corrections + +### G1 — Host-pin alone is too thin; add two more independent gates *(highest priority)* + +DNS rebinding **defeats Origin checks** (the attacker's page origin is unchanged; only +the resolved IP flips), so Host-pin is the *only* barrier in the original design — and a +single allowlist bug collapses it. A cross-site **simple request** (`GET`/form `POST`) +reaches localhost with **no preflight**, so a *blind write* needs no response-read. + +**Mitigations (defense-in-depth, independent of Host-pin correctness):** +- **`Sec-Fetch-Site` enforcement** — browser-set, page-unforgeable; reject anything not + `same-origin`/`none`. **Implemented in the POC** (`src/admin/mod.rs` `sec_fetch_site_ok`). +- **A custom non-simple header on mutating endpoints** (e.g. `X-Trimwire-Control: 1`) to + force a CORS preflight that default-deny CORS fails. The POC is read-only + token-gated, + so this is a **production requirement** for when `POST /service/*` / `PUT /config` land. +- **Order all gates before the token compare and before any side effect** — a rebinding + caller never reaches auth even if the token check had a bug. **Done in the POC.** + +([OWASP CSRF](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html), +[Mixmax: CORS-as-CSRF](https://www.mixmax.com/engineering/modern-csrf)) + +### G2 — Browser Local Network Access does NOT cover localhost→localhost + +Chrome shipped **Local Network Access** (LNA, ex-PNA) in **Chrome 142 (2025-10-28)**, +gating *public-site → loopback* behind a permission prompt — good for the public drive-by +case. **But the same announcement says localhost→localhost is NOT yet gated**, and the old +PNA CORS preflight was **withdrawn**. So: a malicious *other localhost app* is unchanged by +LNA, and the plan must **not** lean on browser network-access prompts — the G1 gates carry +the sibling-localhost threat. ([Chrome for Developers](https://developer.chrome.com/blog/local-network-access), +[chromestatus](https://chromestatus.com/feature/5152728072060928)) + +### G3 — "Token injected into served HTML" is a tradeoff, not "solved" + +Any **XSS in the cockpit UI exfiltrates the control token** (then full control). Precedent +is mixed (VS Code's connection-token is widely seen as the weak link). Corrections: +- **Mandatory strict CSP** (nonces, not `'unsafe-inline'`) on the token-bearing page. + **Done in the POC:** the cockpit HTML is served with a **per-render nonce CSP** + (`script-src 'nonce-…'; style-src 'nonce-…'; worker-src 'none'`, no `'unsafe-inline'`) via + `html_response` in `src/admin/mod.rs`; an injected ` + + diff --git a/src/admin/cockpit.html b/src/admin/cockpit.html new file mode 100644 index 0000000..705dca0 --- /dev/null +++ b/src/admin/cockpit.html @@ -0,0 +1,166 @@ + + + + + + +trimwire · Flightdeck (POC) + + + + + + + + + + + +
+ trimwire · Flightdeck POC + checking… + profile — + v— + + + +
+
+
+
Requests
+
Bytes trimmed
+
Reduction
+
Cache hit
+
+ +

Live feed (SSE · content-free)

+
connecting to /api/v1/events…
+ +

+ This is a proof-of-concept cockpit served by the trimwire binary on a + loopback-only control API. It shows only content-free counts from the local + ledger — never prompts, tool results, or file paths. The control token was + injected same-origin at page load. Full plan: + docs/cockpit/. +

+
+ + + diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..00c6325 --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,794 @@ +//! POC: the local control API ("cockpit") — a **separate loopback admin +//! listener**, kept off the gateway port that transits the Anthropic OAuth token. +//! +//! This is a deliberately small vertical slice of the design in +//! [`docs/cockpit/03-control-api.md`](../../docs/cockpit/03-control-api.md): it +//! demonstrates every layer (loopback bind + rejection of non-loopback, bearer- +//! token auth behind an [`Authenticator`] seam, a `Host`/`Origin` DNS-rebind +//! guard, content-free read endpoints reusing the ledger `Report`, a one-shot SSE +//! event stream, and an embedded same-origin web UI) without the full router / +//! hot-reload / write surface. It is **off by default** (`[admin] enabled = +//! false`); when disabled the daemon behaves exactly as before. +//! +//! Hard invariants honored (see `docs/cockpit/07-security-tos-redlines.md`): +//! - **loopback-only** in this POC — a non-loopback bind is refused at startup; +//! - the OAuth token / upstream credential is **never read or returned** here — +//! the only data sources are the content-free ledger `Report` and config +//! metadata (version, listen addresses, profile); +//! - no message content, prompts, tool results, or file paths are ever exposed. + +use std::convert::Infallible; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use http_body_util::Full; +use hyper::HeaderMap; +use hyper::body::{Bytes, Incoming}; +use hyper::header::{AUTHORIZATION, CONTENT_TYPE, HOST, ORIGIN}; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder as ServerBuilder; + +use crate::config::Config; +use crate::ledger::Ledger; + +/// The running binary version, surfaced on `/api/v1/health` and `/version`. +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The embedded single-file web cockpit. `__TRIMWIRE_TOKEN__` is substituted with +/// the per-install control token at serve time (the same-origin "token bootstrap" +/// from the design) so the page can authenticate its `fetch` calls. +const COCKPIT_HTML: &str = include_str!("cockpit.html"); + +/// Web App Manifest — makes the cockpit an installable PWA (browser "Install app", +/// iOS/Android "Add to Home Screen"). PWA-first is the multi-platform strategy +/// (doc 05): one installable web app covers desktop browsers and both mobile OSes +/// with no app-store account or fee. Served unauthenticated (non-sensitive static). +const COCKPIT_MANIFEST: &str = r##"{ + "name": "trimwire Flightdeck", + "short_name": "Flightdeck", + "description": "Control panel for a local trimwire daemon (content-free).", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0f1417", + "theme_color": "#0f1417", + "icons": [ + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" } + ] +}"##; + +/// A tiny scalable app icon (teal rounded square + "t" glyph), echoing the site logo. +const COCKPIT_ICON: &str = r##" + + + +"##; + +// ---- auth seam ------------------------------------------------------------- + +/// Authentication seam (doc 06, R1): handlers receive a yes/no verdict from an +/// `Authenticator`, never raw trust. v1 has a single implementation, +/// [`LoopbackToken`]; the deferred remote phase swaps in a per-device-token / +/// mTLS implementation **without touching the handlers**. +pub trait Authenticator: Send + Sync { + /// Returns `true` iff the presented bearer token is valid. + fn authenticate(&self, presented: Option<&str>) -> bool; +} + +/// The only v1 authenticator: a constant 256-bit bearer token, loopback-scoped. +struct LoopbackToken { + token: String, +} + +impl Authenticator for LoopbackToken { + fn authenticate(&self, presented: Option<&str>) -> bool { + match presented { + Some(t) => constant_time_eq(t.as_bytes(), self.token.as_bytes()), + None => false, + } + } +} + +/// Length-checked constant-time byte comparison — avoids leaking the token via +/// early-exit timing. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +// ---- state ----------------------------------------------------------------- + +/// Shared, read-only handles for the admin handlers. Cheap to clone (everything +/// behind it is `Arc`/`String`). +struct AdminState { + auth: Arc, + /// The plaintext token, injected into the served HTML bootstrap. + token: String, + /// Loopback port the admin listener bound — drives the `Host`/`Origin` guard. + port: u16, + db_path: String, + gateway_listen: String, + admin_listen: String, + profile: String, +} + +// ---- server ---------------------------------------------------------------- + +/// Bind the loopback admin listener and serve the control API + cockpit UI until +/// fatal I/O error. **Refuses** any non-loopback bind (remote exposure is a +/// deferred, opt-in phase — see `docs/cockpit/06-remote-control.md`). +pub async fn run(addr: SocketAddr, config: Arc, gateway_listen: String) -> Result<()> { + if !addr.ip().is_loopback() { + bail!( + "[cockpit] refusing to bind {addr}: the control API is loopback-only in this POC \ + (remote control is a deferred, opt-in phase — see docs/cockpit/06-remote-control.md)" + ); + } + + let token = load_or_create_token(&config.ledger.db_path)?; + let state = Arc::new(AdminState { + auth: Arc::new(LoopbackToken { + token: token.clone(), + }), + token, + port: addr.port(), + db_path: config.ledger.db_path.clone(), + gateway_listen, + admin_listen: addr.to_string(), + profile: config + .profile + .clone() + .unwrap_or_else(|| "default".to_owned()), + }); + + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind admin listener {addr}"))?; + let actual = listener.local_addr().unwrap_or(addr); + eprintln!( + "[cockpit] control API + web UI on http://{actual} (token: {})", + token_path(&state.db_path).display() + ); + + let server = ServerBuilder::new(TokioExecutor::new()); + loop { + let (stream, _peer) = match listener.accept().await { + Ok(s) => s, + Err(e) => { + eprintln!("[cockpit] accept error: {e}"); + continue; + } + }; + let io = TokioIo::new(stream); + let server = server.clone(); + let state = state.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req| { + let state = state.clone(); + async move { Ok::<_, Infallible>(handle(req, state).await) } + }); + if let Err(e) = server.serve_connection(io, svc).await { + tracing::debug!(error = %e, "cockpit connection error"); + } + }); + } +} + +/// Route one request. Every response is a fully-buffered `Full` (the admin +/// surface is small JSON / one static page / a one-shot SSE snapshot — it never +/// touches or buffers the proxy stream). +async fn handle(req: Request, state: Arc) -> Response> { + let headers = req.headers(); + + // DNS-rebinding / drive-by-browser guard (doc 03 §5, doc 06 R7): the request + // authority must be the literal loopback authority, and any `Origin` must be + // same-origin. Use the URI authority (HTTP/2 `:authority`) when present, else + // the `Host` header (HTTP/1.1) — checking only `Host` would mis-handle h2. + let authority = req + .uri() + .authority() + .map(|a| a.as_str().to_owned()) + .or_else(|| { + headers + .get(HOST) + .and_then(|h| h.to_str().ok()) + .map(str::to_owned) + }); + if !authority_allowed(authority.as_deref(), state.port) { + return text(StatusCode::FORBIDDEN, "forbidden: bad Host"); + } + if !origin_allowed(headers, state.port) { + return text(StatusCode::FORBIDDEN, "forbidden: bad Origin"); + } + // Third independent gate: the browser-set, page-unforgeable `Sec-Fetch-Site` + // fetch-metadata (OWASP CSRF defense). Reject anything a cross-site context + // initiated, regardless of Host/Origin. These checks run BEFORE the token + // compare and before any side effect, so a DNS-rebinding caller never reaches + // the auth path even if the token check had a bug. + if !sec_fetch_site_ok(headers) { + return text(StatusCode::FORBIDDEN, "forbidden: cross-site request"); + } + + let method = req.method().clone(); + let path = req.uri().path().to_owned(); + + // Same-origin web UI (token injected at load). Host/Origin-guarded above. + if method == Method::GET && (path == "/" || path == "/index.html") { + return serve_cockpit(&state); + } + // PWA install assets (static, non-sensitive). Make the cockpit installable on + // desktop browsers and mobile home screens with no app store (doc 05). + if method == Method::GET && path == "/manifest.webmanifest" { + return build( + StatusCode::OK, + "application/manifest+json", + COCKPIT_MANIFEST.as_bytes().to_vec(), + ); + } + if method == Method::GET && path == "/icon.svg" { + return build( + StatusCode::OK, + "image/svg+xml", + COCKPIT_ICON.as_bytes().to_vec(), + ); + } + + // Unauthenticated, content-free endpoints (parity with `/healthz`): + // - /health: liveness + version. + // - /events: a one-shot SSE snapshot of aggregate, content-free counts. It + // is token-free because the browser `EventSource` API cannot send an + // `Authorization` header; it is still Host/Origin-guarded and exposes only + // aggregate counts (never content). See the POC notes in docs/cockpit. + if method == Method::GET && path == "/api/v1/health" { + return json( + StatusCode::OK, + &serde_json::json!({ "ok": true, "version": VERSION }), + ); + } + if method == Method::GET && path == "/api/v1/events" { + return events_response(&state); + } + + // Everything else under /api/v1 requires the bearer token. + if let Some(rest) = path.strip_prefix("/api/v1/") { + if !state.auth.authenticate(bearer(headers).as_deref()) { + return json( + StatusCode::UNAUTHORIZED, + &serde_json::json!({ + "type": "error", + "error": { "type": "unauthorized", "message": "missing or invalid bearer token" } + }), + ); + } + return match (method, rest) { + (Method::GET, "version") => json(StatusCode::OK, &version_payload(&state)), + (Method::GET, "service") => json(StatusCode::OK, &service_payload(&state).await), + (Method::GET, "stats") => json(StatusCode::OK, &stats_payload(&state)), + _ => json( + StatusCode::NOT_FOUND, + &serde_json::json!({ + "type": "error", + "error": { "type": "not_found", "message": "no such endpoint" } + }), + ), + }; + } + + text(StatusCode::NOT_FOUND, "not found") +} + +// ---- handlers -------------------------------------------------------------- + +fn serve_cockpit(state: &AdminState) -> Response> { + let nonce = gen_nonce(); + let html = COCKPIT_HTML + .replace("__TRIMWIRE_TOKEN__", &state.token) + .replace("__TRIMWIRE_NONCE__", &nonce); + html_response(&nonce, html.into_bytes()) +} + +fn version_payload(state: &AdminState) -> serde_json::Value { + // Deliberately does NOT include `[server] upstream`: it's the field that decides + // where the OAuth token is forwarded, and a *custom* upstream URL can embed + // credentials (userinfo/query). Even the destination is "derived" credential- + // routing info (doc 07 R3), and the UI doesn't need it — so it never crosses the + // control surface. (Cross-model PR review consensus.) + serde_json::json!({ + "version": VERSION, + "control_api": "v1-poc", + "profile": state.profile, + "gateway_listen": state.gateway_listen, + "admin_listen": state.admin_listen, + }) +} + +async fn service_payload(state: &AdminState) -> serde_json::Value { + serde_json::json!({ + "gateway_listen": state.gateway_listen, + "admin_listen": state.admin_listen, + "serving": probe(&state.gateway_listen).await, + "version": VERSION, + }) +} + +/// Read the content-free savings ledger and return the same shape as +/// `stats --json`. The `Report` is content-free by construction (counts, hashes, +/// timestamps, names) — there is no content to leak. +fn stats_payload(state: &AdminState) -> serde_json::Value { + if !crate::ledger::resolve_path(&state.db_path).exists() { + return serde_json::json!({ "available": false, "reason": "ledger not created" }); + } + match Ledger::report(&state.db_path) { + Ok(report) => match serde_json::to_value(&report) { + Ok(mut v) => { + if let Some(obj) = v.as_object_mut() { + obj.insert("available".to_owned(), serde_json::Value::Bool(true)); + // Content-free guarantee (doc 07 R7/R8): `Report` carries the + // local ledger `db_path` (an absolute filesystem path leaking + // the OS username/layout). Never expose it on the network + // surface — the CLI `stats --json` keeps it, the control API + // strips it. + obj.remove("db_path"); + } + v + } + Err(e) => serde_json::json!({ "available": false, "reason": e.to_string() }), + }, + Err(e) => serde_json::json!({ "available": false, "reason": e.to_string() }), + } +} + +/// One-shot SSE snapshot of aggregate, content-free counts. The browser +/// `EventSource` reconnects on stream end (~2s), so this behaves as a light live +/// feed for the POC. +/// +/// COST (cross-model PR review): each reconnect re-runs `Ledger::report()` (a +/// SQLite aggregate over the `requests` table), so an open tab issues one ledger +/// read every ~2s. Fine for a POC on a single loopback client; production replaces +/// this with a `tokio::sync::broadcast` channel fed at ledger-write time (doc 03 +/// §4) so there is zero per-connection query. +fn events_response(state: &AdminState) -> Response> { + let stats = stats_payload(state); + let snapshot = serde_json::json!({ + "requests": stats.get("total_requests").cloned().unwrap_or(serde_json::Value::Null), + "bytes_saved": stats.get("bytes_saved").cloned().unwrap_or(serde_json::Value::Null), + "reduction_pct": stats.get("reduction_pct").cloned().unwrap_or(serde_json::Value::Null), + }); + let body = format!( + "retry: 2000\nevent: snapshot\ndata: {}\n\n", + serde_json::to_string(&snapshot).unwrap_or_else(|_| "{}".to_owned()) + ); + build(StatusCode::OK, "text/event-stream", body.into_bytes()) +} + +// ---- header guards --------------------------------------------------------- + +/// The loopback authorities we accept for this port. +fn authorities(port: u16) -> [String; 3] { + [ + format!("127.0.0.1:{port}"), + format!("localhost:{port}"), + format!("[::1]:{port}"), + ] +} + +/// Default-deny: a missing/unparseable authority is rejected; otherwise it must +/// be one of the literal loopback authorities for our port (defeats DNS-rebinding, +/// which keeps the attacker's `Host`/authority even when the name resolves to +/// 127.0.0.1). +fn authority_allowed(authority: Option<&str>, port: u16) -> bool { + match authority { + Some(a) => authorities(port).iter().any(|allowed| allowed == a), + None => false, + } +} + +fn origin_allowed(headers: &HeaderMap, port: u16) -> bool { + match headers.get(ORIGIN).and_then(|h| h.to_str().ok()) { + // A same-origin browser page sends our own loopback origin; a malicious + // cross-origin page sends its own (rejected). Non-browser clients (curl, + // top-level navigation) send no Origin — allowed. + Some(o) => authorities(port).iter().any(|a| format!("http://{a}") == o), + None => true, + } +} + +/// `Sec-Fetch-Site` is set by the browser and cannot be forged by page JS. A +/// same-origin `fetch`/`EventSource` sends `same-origin`; a typed/bookmarked +/// navigation sends `none`; a cross-site page sends `cross-site`/`same-site`. +/// Allow only `same-origin`/`none`; absent header (curl, non-browser) is allowed. +fn sec_fetch_site_ok(headers: &HeaderMap) -> bool { + match headers.get("sec-fetch-site").and_then(|h| h.to_str().ok()) { + Some(v) => v == "same-origin" || v == "none", + None => true, + } +} + +fn bearer(headers: &HeaderMap) -> Option { + let v = headers.get(AUTHORIZATION)?.to_str().ok()?; + v.strip_prefix("Bearer ").map(|s| s.to_owned()) +} + +// ---- helpers --------------------------------------------------------------- + +async fn probe(addr: &str) -> bool { + let Ok(sa) = addr.parse::() else { + return false; + }; + matches!( + tokio::time::timeout( + Duration::from_millis(300), + tokio::net::TcpStream::connect(sa) + ) + .await, + Ok(Ok(_)) + ) +} + +fn json(status: StatusCode, v: &serde_json::Value) -> Response> { + let bytes = serde_json::to_vec(v).unwrap_or_else(|_| b"{}".to_vec()); + build(status, "application/json", bytes) +} + +fn text(status: StatusCode, msg: &str) -> Response> { + build(status, "text/plain; charset=utf-8", msg.as_bytes().to_vec()) +} + +/// Response builder for the non-HTML surface (JSON / manifest / SVG). These carry +/// no inline script or style, so the CSP is maximally strict — no `script-src`, +/// no `'unsafe-inline'`. The token-bearing HTML uses [`html_response`] instead. +fn build(status: StatusCode, content_type: &str, body: Vec) -> Response> { + Response::builder() + .status(status) + .header(CONTENT_TYPE, content_type) + .header("Cache-Control", "no-store") + .header("X-Content-Type-Options", "nosniff") + .header("X-Frame-Options", "DENY") + .header( + "Content-Security-Policy", + "default-src 'none'; img-src 'self'; connect-src 'self'; manifest-src 'self'; \ + base-uri 'none'; form-action 'none'", + ) + .body(Full::new(Bytes::from(body))) + .unwrap_or_else(|_| Response::new(Full::new(Bytes::from_static(b"{}")))) +} + +/// Response builder for the token-bearing cockpit HTML. Uses a **per-render CSP +/// nonce** instead of `'unsafe-inline'` so an injected `