The internal deployment exists (deployments repo, `mirror/`: quadlet, nginx edge, certbot, route53, VM SOP), so the docs that said "the real fix is HTTPS" were describing a future that has arrived from a different direction — TLS terminates in nginx on the service VM, not in this process, and there is no `--https` flag here. SECURITY.md's trust model said "trusted: anyone who can open a TCP connection". With an allow list in production that sentence was simply no longer true, so the table now names the address space, and a new section says what the list is and what it is not: one wall (network/nginx) and one belt (the app, for the path that bypasses nginx at 127.0.0.1:8080), plus the thing neither can do — once two browsers are in the room their media never asks the server again, and it can never tell you *who*, only *where from*. Saying "defence in depth" about that would have been the dishonest version. PLAN.md gains A19 (TLS + admission in production, LOCKED) and A18 becomes deprioritised rather than deleted — standalone `mirror serve --https` is still worth having for offline/ad-hoc use, it just stopped being the thing blocking cross-device sharing, and candidate phase 1 is now share-page resilience. AGENTS.md gains the two rules most likely to be broken by a well-meaning edit: trust XFF only from a trusted proxy, and change the nginx and app lists in the same commit. LLM-Generated: true
146 lines
8.2 KiB
Markdown
146 lines
8.2 KiB
Markdown
# mirror — Agent Operating Rules
|
||
|
||
**Read `.agents/PLAN.md` before touching anything here.** It holds the locked
|
||
architectural decisions; this file holds the rules for working inside them.
|
||
|
||
`mirror` is browser-only screen mirroring for a local network: `/mirror` shares a
|
||
window/screen/tab via the OS-native picker, `/watch` shows every live mirror and
|
||
lets anyone take one fullscreen. Media flows peer-to-peer over WebRTC; the server
|
||
is a roster and a signaling relay. Linux is the reference platform.
|
||
|
||
---
|
||
|
||
## 1. The order of reading
|
||
|
||
1. `.agents/PLAN.md` — architecture, locked anchors, verification ladder.
|
||
2. `README.md` — the user-facing contract.
|
||
3. `SECURITY.md` — why there is no authentication, and what that costs.
|
||
4. `src/mirror/registry.py` — the whole policy lives here. Read it before
|
||
changing behavior; it is short and it is the product.
|
||
|
||
## 2. Phased execution
|
||
|
||
```
|
||
.agents/
|
||
├── PLAN.md ← locked design (do not edit without an explicit decision)
|
||
└── phases/
|
||
├── todo/ ← pending phases: NN_name/00_phase.md + NN_task.md
|
||
└── complete/ ← finished phases, read-only history
|
||
```
|
||
|
||
* New work enters through the **`phase-authoring` skill**, never as a direct
|
||
implementation of a vague request.
|
||
* Phases run through the **`phased-execution` skill**.
|
||
* Never edit anything in `phases/complete/`. Ask before editing `phases/todo/`.
|
||
* Never edit `PLAN.md` unless the user explicitly authorises a change to a locked
|
||
decision, and record the reason in the same change.
|
||
* A phase is not done until every gate in §4 passes.
|
||
|
||
*(The roadmap is intentionally empty at the prototype stage: a working prototype
|
||
came first, and the candidate phase list is §7 of `PLAN.md`.)*
|
||
|
||
## 3. Locked decisions that are easy to break by accident
|
||
|
||
| Rule | Why it exists |
|
||
|---|---|
|
||
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestClientScripts::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
|
||
| **No framework, no bundler** in `src/mirror/static/` | Two pages and one WebSocket. `node --check` is the entire frontend toolchain |
|
||
| **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) — 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 `<video>` elements `muted playsinline`** | Autoplay policy on iOS/Safari otherwise blocks the wall entirely |
|
||
|
||
## 4. Gates — run all four before calling anything done
|
||
|
||
```bash
|
||
uv run ruff check . && uv run ruff format --check .
|
||
uv run pyright # must be 0 errors
|
||
uv run pytest # unit + API + browser E2E (~20s)
|
||
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
|
||
```
|
||
|
||
Coverage is ~94%; the media path is covered by browser assertions, not by unit
|
||
tests pretending to be WebRTC.
|
||
|
||
## 5. Browser testing rules
|
||
|
||
Non-negotiable, all learned the hard way (full notes in `PLAN.md` §5):
|
||
|
||
* Launch with **`channel="chromium"`**. The headless shell has no media stack —
|
||
`navigator.mediaDevices` does not exist there.
|
||
* Serve pages over **`http://127.0.0.1:<port>`**. On `about:blank` or a plain
|
||
LAN IP, `mediaDevices` is undefined and the test proves nothing.
|
||
* Deterministic capture: `--use-fake-ui-for-media-stream
|
||
--use-fake-device-for-media-stream` → a real, *moving* 1280×720 monitor stream.
|
||
* Real capture (the `realdisplay` suite): private **Xvfb**, headed Chromium,
|
||
`--auto-select-desktop-capture-source="Entire screen"`, `--ozone-platform=x11`,
|
||
and **never** `--use-fake-ui-for-media-stream` (it approves the request without
|
||
selecting a source and the capturer dies with `NotReadableError`).
|
||
* **Strip `WAYLAND_DISPLAY` and `DBUS_SESSION_BUS_ADDRESS`** from any headed
|
||
browser env (`tests/e2e/test_x11_capture.py::browser_env`). Otherwise the
|
||
portal captures the developer's actual desktop and pops a permission dialog on
|
||
their real screen.
|
||
* Assert media on the **viewer's** `<video>` — decoded size and *frames that
|
||
differ*. The sharer's preview proves nothing crossed the wire.
|
||
* Two browser **processes**, not two contexts, when a test means "two machines".
|
||
|
||
## 6. JavaScript rules
|
||
|
||
* Plain ES modules loaded with `<script type="module">`. No globals beyond
|
||
`window.__probe` in test scaffolding.
|
||
* Every element a script looks up (`$("#id")`) must exist in its page — enforced
|
||
by `test_every_element_the_script_reaches_for_exists`.
|
||
* Element ids must not collide **across** pages (one shared stylesheet):
|
||
`test_the_two_pages_share_no_element_ids_but_the_shared_chrome`. The only
|
||
allowed shared ids are `net`, `notice`, `toast`.
|
||
* WebRTC: the **sharer always offers, the viewer always answers**. Queue ICE
|
||
candidates until `remoteDescription` exists; serialise offers per peer to avoid
|
||
glare. Reuse `net.js` rather than duplicating socket logic.
|
||
* Never move a `<video>` element's `srcObject` to "switch views" — move the
|
||
element (that is how `/watch` staging works) so the peer connection survives.
|
||
|
||
## 7. Python rules
|
||
|
||
* `from __future__ import annotations`, full type hints, Google-style docstrings
|
||
on public functions.
|
||
* Starlette ≥1.10 raises **`WebSocketDisconnected`** (a `RuntimeError`) when the
|
||
app has already closed a socket, and `WebSocketDisconnect` when the peer does.
|
||
Catch both; read a socket you closed and you will crash the connection handler.
|
||
* Registry errors are `RegistryError(code, message, fatal)`; `fatal` means the
|
||
socket closes after the error frame is delivered. New error codes get a test.
|
||
* No bare `except:`; swallow only what you can name, and say why in a comment.
|
||
|
||
## 8. What to do when something behaves mysteriously
|
||
|
||
Reproduce at the lowest layer that can fail:
|
||
|
||
1. `curl -s localhost:8055/api/streams | python -m json.tool` — server truth.
|
||
2. `websocat ws://localhost:8055/ws` — speak the protocol by hand (see PLAN §3).
|
||
3. `uv run pytest tests/test_registry.py` — is the policy wrong, or the wiring?
|
||
4. Only then reach for a browser, and print `pageerror`/`console` from the first
|
||
line of the test.
|
||
|
||
## 9. Commits
|
||
|
||
* Conventional Commits (`feat:`, `fix:`, `test:`, `docs:`, `chore:`).
|
||
* Always `--no-gpg-sign`, always append the trailer `LLM-Generated: true`
|
||
(the `git-commit` skill does this).
|
||
* Never commit `.env`, keys, or `.agents/phase-sessions/`.
|
||
|
||
## 10. Quick reference
|
||
|
||
| I want to… | Do |
|
||
|---|---|
|
||
| Run it | `uv run mirror serve` → `:8055` |
|
||
| Share / watch | `/mirror` · `/watch` |
|
||
| Debug with an editor | `DEBUGPY=1 uv run mirror serve` (port 5678) |
|
||
| Server truth about mirrors | `GET /api/streams` |
|
||
| What the browser may know | `GET /api/config` |
|
||
| Everything fast, no browser | `uv run pytest -m "not e2e"` |
|
||
| Only the real-desktop test | `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` |
|