diff --git a/.agents/PLAN.md b/.agents/PLAN.md index a62792c..1fdc66b 100644 --- a/.agents/PLAN.md +++ b/.agents/PLAN.md @@ -62,7 +62,8 @@ on a phone, and a person on a work machine can all join by opening a URL.** | A15 | CLI | `argparse`, single `serve` subcommand, flags also accepted bare | One command; no dependency for a two-option CLI | **LOCKED** | | A16 | Quality gates | `ruff check` + `ruff format --check` + `pyright` (0 errors) + `pytest` green, always | Non-negotiable before a phase is called complete | **LOCKED** | | A17 | Scale-out beyond a mesh | *Proposed*: WebSocket/MSE relay or WebCodecs SFU when a sharer's uplink, not the CPU, is the limit | Revisit only with a measured problem. Status: **PROPOSED** | Proposed | -| A18 | TLS | *Proposed*: `mirror serve --https` with an auto-generated self-signed cert | Solves the secure-context problem (§6) for LAN IPs without a CA. Status: **PROPOSED** | Proposed | +| A18 | TLS (standalone) | *Proposed*: `mirror serve --https` with an auto-generated self-signed cert | Still open for a bare `mirror serve`. The problem it existed to solve is solved in production by A19's deployment, which terminates TLS in nginx. Status: **PROPOSED** (deprioritised) | +| A19 | TLS + admission in production | TLS terminates in **nginx on the service VM** (no gateway, no `proxy_protocol`); access is decided by **source address** at both edges — nginx `allow`/`deny` and `MIRROR_ALLOW_CIDRS`, with `X-Forwarded-For` believed only from `MIRROR_TRUSTED_PROXIES` (loopback) | There are no accounts, so the address *is* the access control; the app repeats the decision because a container port can be reached without the proxy. Over HTTPS every device gets `getDisplayMedia()`, which is what the fleet needs. Deployment lives in `deployments/mirror/` | **LOCKED** | ## 3. Architecture @@ -198,7 +199,8 @@ MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native capture | Constraint | Why | Handling | |---|---|---| -| `getDisplayMedia` needs a secure context | Browser rule. `http://` is not one | `/mirror` detects it and prints the three ways out (localhost, HTTPS, the per-origin Chrome flag). Real fix is A18 | +| `getDisplayMedia` needs a secure context | Browser rule. `http://` is not one | `/mirror` detects it and prints the three ways out (localhost, HTTPS, the per-origin Chrome flag). Production is HTTPS at the edge (A19), so this only bites a bare `mirror serve` on a LAN IP | +| No accounts means the address list is the whole gate | Product promise (§1) | Enforced twice (nginx + app, A19). `/healthz` is the one exempt route, because the container health probe comes from loopback. See `SECURITY.md` §4 | | Mesh upload = N × bitrate per sharer | WebRTC mesh | Per-leg `maxBitrate` cap (default 12 Mbit/s). A17 when it bites | | Wayland capture depends on the portal | Compositor policy | Each share is confirmed by the user; X11 needs no such prompt. Documented in `SECURITY.md` | | No reconnection of a *sharer* identity | Peer ids are per-socket | Viewer clears the tile and the sharer re-publishes on reconnect; the wall recovers by itself | @@ -210,12 +212,17 @@ Ordered by what a user would notice first. Each would become `.agents/phases/todo/NN_name/{00_phase.md,NN_task.md}` via the `phase-authoring` skill, with its own independently-runnable test suite. -1. **HTTPS mode** — self-signed cert + a "how to trust it" screen (A18). Unblocks - capture from `http://` on other devices, the single biggest papercut. -2. **Share-page resilience** — reconnect with roster resync, "source lost" +> HTTPS is no longer first, because the deployment solved it: TLS terminates in +> nginx (A19) and every device on the LAN can share. `mirror serve --https` (A18) +> stays on the list for the standalone case, ranked where its real cost lands. + +1. **Share-page resilience** — reconnect with roster resync, "source lost" states, `surfaceSwitching` UX, per-leg health on the sharer panel. -3. **Wall UX** — keyboard navigation (j/k/Enter/Esc), per-tile mute, layout +2. **Wall UX** — keyboard navigation (j/k/Enter/Esc), per-tile mute, layout memory, "auto-stage newest mirror", PiP. +3. **HTTPS for `mirror serve`** — A18: self-signed cert, the "why is this + untrusted" screen, and a hint that a real deployment should terminate TLS in + nginx instead. For ad-hoc and offline use only now. 4. **Capacity & fairness** — adaptive bitrate per leg by watcher count, `MIRROR_MAX_MIRRORS` surfaced in the UI, graceful degradation for a weak sharer uplink. diff --git a/AGENTS.md b/AGENTS.md index 213c7b2..ee48b53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,9 @@ came first, and the candidate phase list is §7 of `PLAN.md`.)* | **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 | | **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage | | **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test | -| **No authentication** (A11) | Product brief. If you think it needs auth, that is a `PLAN.md` discussion first — and see `SECURITY.md` | +| **No authentication** (A11) — but the **address is the access control** (A19) | Product brief: no accounts. What decides who gets in is `MIRROR_ALLOW_CIDRS` (`src/mirror/access.py`), enforced in the HTTP middleware *and* before the socket accepts. If you think it needs auth, that is a `PLAN.md` discussion first — and see `SECURITY.md` | +| **Trust `X-Forwarded-For` only from `MIRROR_TRUSTED_PROXIES`, left-most value only** | Otherwise a browser writes its own pass. The deployment's nginx must *overwrite* the header (`$remote_addr`, never `$proxy_add_x_forwarded_for`) — and the nginx `allow`/`deny` list and `MIRROR_ALLOW_CIDRS` change in the same commit | +| **`/healthz` stays exempt from the address gate** | The podman `HealthCmd` probes it from loopback, which the production list otherwise excludes — an exempt probe is why the container is not permanently unhealthy | | **No STUN/TURN by default** | A LAN tool must not announce internal addresses to a third party | | **Teardown is broadcast to every viewer**, not just subscribers | Tiles are drawn on `stream_added`; a non-subscribed viewer still has a stale tile to clear | | **Keep both `