Files
mirror/AGENTS.md
T
ducoterra 1e816da5a8
Build and Push Containers / test (push) Successful in 35s
Build and Push Containers / build-and-push-app (push) Successful in 10s
phase: 01_keyboard_kiosk_control
**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`.
2026-11-01 20:53:28 -05:00

8.1 KiB
Raw Blame History

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.

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.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