**Phase 01 final verification pass — complete, 100% green** - Verified all 4 tasks done (files in `complete/`); implementation review: key router in `watch.js`, share-page router, CSS focus/selection affordances, `?` overlay, pointer-free E2E suite — all sound. - Gates: `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` → 223 passed, 2 skipped (realdisplay), 1 pre-existing FastAPI deprecation warning · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` → 2 passed ✅ · `pytest -m e2e` → 51 passed (incl. `test_keyboard_wall.py`, which a meta-test keeps pointer-free) · `node --check` on all static JS ✅. - Completion criteria: all 6 ✅ (j/k+Enter, f/g/n/p/1–9/m/?/Esc keyboard-only E2E; no hover-only controls via CSS guard + focus-ring E2E; `#name` text-safe; e2e/realdisplay green; key map documented in README "Kiosk client" and `docs/kiosk-silverblue.md`). - README fixed: added missing env vars `MIRROR_MAX_BITRATE`, `MIRROR_PING_INTERVAL`, `DEBUGPY_WAIT` to the config table; added the one-time `rpm-ostree` layering note before the kiosk install snippet. - AGENTS.md fixed: removed stale "roadmap is intentionally empty" parenthetical (3 phases pending); coverage ~94% → ~95%. No content added. - **Unresolved, needs your call:** README/SECURITY.md say `mirror.reeseapps.com`; `docs/kiosk-silverblue.md` (reference-client naming table with real IPs) says `mirror.reeselink.com`. I couldn't verify which is the live deployment domain, so I changed neither — say the word and I'll unify. - Next pending phase: `02_sharp_capture`.
8.1 KiB
8.1 KiB
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
.agents/PLAN.md— architecture, locked anchors, verification ladder.README.md— the user-facing contract.SECURITY.md— why there is no authentication, and what that costs.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-authoringskill, never as a direct implementation of a vague request. - Phases run through the
phased-executionskill. - Never edit anything in
phases/complete/. Ask before editingphases/todo/. - Never edit
PLAN.mdunless 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.
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
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 ~95%; 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.mediaDevicesdoes not exist there. - Serve pages over
http://127.0.0.1:<port>. Onabout:blankor a plain LAN IP,mediaDevicesis 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
realdisplaysuite): 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 withNotReadableError). - Strip
WAYLAND_DISPLAYandDBUS_SESSION_BUS_ADDRESSfrom 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 beyondwindow.__probein test scaffolding. - Every element a script looks up (
$("#id")) must exist in its page — enforced bytest_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 arenet,notice,toast. - WebRTC: the sharer always offers, the viewer always answers. Queue ICE
candidates until
remoteDescriptionexists; serialise offers per peer to avoid glare. Reusenet.jsrather than duplicating socket logic. - Never move a
<video>element'ssrcObjectto "switch views" — move the element (that is how/watchstaging 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(aRuntimeError) when the app has already closed a socket, andWebSocketDisconnectwhen the peer does. Catch both; read a socket you closed and you will crash the connection handler. - Registry errors are
RegistryError(code, message, fatal);fatalmeans 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:
curl -s localhost:8055/api/streams | python -m json.tool— server truth.websocat ws://localhost:8055/ws— speak the protocol by hand (see PLAN §3).uv run pytest tests/test_registry.py— is the policy wrong, or the wiring?- Only then reach for a browser, and print
pageerror/consolefrom the first line of the test.
9. Commits
- Conventional Commits (
feat:,fix:,test:,docs:,chore:). - Always
--no-gpg-sign, always append the trailerLLM-Generated: true(thegit-commitskill 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 |