Files
mirror/AGENTS.md
T
ducoterra bf027672b5
Build and Push Containers / test (push) Successful in 47s
Build and Push Containers / build-and-push-app (push) Successful in 10s
phase: 04_reachable_share_cta
All verification is complete. The pending working-tree changes are exactly the phase 04 work (CSS, tests, the README narrow-screen line, the AGENTS.md "Two→Three pages" fix) — all accurate.

## Final report

**Verified (final pass — all 4 task files present in `complete/`):**
- Gate 1: `uv run ruff check . && uv run ruff format --check .` → all checks passed, 29 files formatted
- Gate 2: `uv run pyright` → 0 errors
- Gate 3: `uv run pytest` → **269 passed, 4 skipped** (realdisplay-gated), coverage 95%
- Gate 4: `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **3 passed, 1 skipped** (host delivers no audio source — documented in module docstring)

**Completion criteria:**
1. `#start` elementFromPoint at the 11 required sizes — ✅ `TestIdleShareButtonReachable` sweeps 12 viewports (superset); 12/12 passed
2. `#stop` clickable while sharing — ✅ `TestLiveStopButtonReachable` (320×568, 390×844, 1280×450) passed, incl. placeholder-hidden + livetag assertions
3. Single-cell stage guard — ✅ `test_the_share_stage_is_one_cell` + `test_the_stage_has_a_floor_and_a_ceiling` passed; CSS has `grid-template: 1fr / 1fr`, `.stage > * { grid-area: 1 / 1 }`, `min-height: 264px`, `max-height: min(70svh, 640px)` + vh fallback, `.placeholder { max-height: 100%; overflow: auto }`, `align-content: safe center`
4. Live video fills stage, rounded corners, `#livetag` top-left — ✅ verified by live-browser probe: video fills stage (decoded), stage radius 14px, livetag top-left, placeholder hidden
5. README accurate, AGENTS.md current, suite 100% green — ✅
6. Phases 01–03 unchanged — ✅ `test_keyboard_wall` + `test_audio_share` + `test_sender_fidelity`: 48 passed

**Documentation:** README.md reviewed in full (page table, quickstart, all 12 config defaults vs `config.py`, CLI flags, secure-context/wayland/audio sections, kiosk key table vs `watch.js`/`share.js`, dev commands) — **consistent, no fixes needed**. AGENTS.md verified statement-by-statement (commands, routes, test names, browser/realdisplay flags) — **no stale statements; no changes needed** (the pending "Two→Three pages" fix is correct).

**Decisions:** criterion 4 had no dedicated test, so verified directly with a throwaway probe reusing the e2e harness (script in /tmp, not committed). No live infrastructure touched. No defects found in prior work.

**Next pending phase:** none — `todo/` is empty of other phases.
2026-11-01 23:26:36 -05:00

145 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/` | Three 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 (~50s)
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* monitor stream
(1280×720 by default; clamped to the capturing window when a size is
requested).
* 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` |