docs: plan, agent operating rules, and an honest security model

PLAN.md locks the architectural decisions (WebRTC mesh with the sharer as
sole offerer, JSON-only signaling, in-memory roster with no database, no
auth, no CDN, Linux as the reference platform) and records the roster rules,
the verification ladder, and the constraints that are accepted rather than
hidden: the secure-context requirement, mesh upload scaling with watcher
count, and the Wayland portal's per-share prompt.

Phases are deliberately not written yet -- the prototype and its tests came
first, so .agents/phases/todo/ holds only a README pointing at the candidate
phase list in PLAN.md section 7.

SECURITY.md answers the question a no-auth tool has to answer: not "who is
allowed in" but "where is this allowed to run". It separates what an
unauthenticated LAN visitor can do from the guarantees that are enforced and
named to a test, and tells users the two things they will actually hit --
that http://<lan-ip> hides getDisplayMedia, and that on Wayland the
compositor asks every time and mirror must not work around it.

LLM-Generated: true
This commit is contained in:
2026-11-01 16:54:28 -05:00
parent 7359ee4e07
commit 896cca2f23
6 changed files with 596 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
# mirror — Master Plan
> **This file is the source of truth.** Anything marked `LOCKED` may not be
> changed without an explicit decision recorded here first. Phases implement
> this plan; they do not revise it.
## 1. Vision
Mirror a screen from any device that has a browser, and watch every screen that
anyone is mirroring, from any other device — with nothing to install and nothing
to sign in to.
Day one, one command:
```bash
uv run mirror serve
```
* `/mirror` — pick a window, screen, or tab using the **operating system's own**
picker, and mirror it.
* `/watch` — see every live mirror as a wall of tiles; click one to take it over
the whole screen.
* N sharers and M watchers at the same time, in any combination.
The single thing that must never regress: **a person on a Linux laptop, a person
on a phone, and a person on a work machine can all join by opening a URL.**
### Design principles
1. **The browser is the whole client.** No native helper, no extension, no
WebAssembly to fetch, no build step for the frontend.
2. **Media never touches the server.** The server is a roster and a signaling
relay. It handles JSON, always small, never frame data.
3. **Linux is the reference platform.** X11/Xorg is the guaranteed path; the
Wayland portal path is supported where the compositor provides it. Every
capture-related change is verified on Linux before anything else.
4. **Zero-configuration on a LAN.** STUN/TURN are opt-in, not defaults: peers on
one network reach each other with host candidates, and shipping STUN by
default would leak internal addresses to a third party.
5. **The prototype is the seed, not a spike.** Code in `src/mirror/` is
production-shaped: typed, linted, covered, and gated by the same test suite
future phases must keep green.
## 2. Architectural anchors
| # | Component | Decision | Rationale | Status |
|---|-----------|----------|-----------|--------|
| A1 | Language / runtime | Python **3.13+** (developed on 3.14), `uv` for everything | Matches the estate; `uv` is the only installer | **LOCKED** |
| A2 | HTTP + signaling | **FastAPI + uvicorn[standard]**, single process | Native WebSocket support, typed boundary, one dependency tree | **LOCKED** |
| A3 | Media transport | **WebRTC, mesh, one `RTCPeerConnection` per viewer** | Hardware codecs, sub-100ms LAN latency, no server media handling; server stays a roster | **LOCKED** |
| A4 | Who negotiates | The **sharer is always the offerer**; the viewer only answers | Removes renegotiation glare; the sharer owns the track set | **LOCKED** |
| A5 | Wire format | **JSON over one WebSocket** at `/ws`, camelCase keys, discriminated on `type` | One connection per peer, trivially debuggable with `websocat` | **LOCKED** |
| A6 | Validation | **Pydantic models with `alias_generator=to_camel`** and `extra="ignore"` | camelCase on the wire, snake_case in Python. Explicit aliases are mandatory: silent field-dropping is a bug we already shipped once | **LOCKED** |
| A7 | State | **In-memory roster only** (`mirror.registry.Registry`). No database, no ORM, no Alembic | A mirror that outlives the process is a lie: the peers are gone. Persistence would add a failure mode and remove none | **LOCKED** |
| A8 | Domain/transport split | `Registry` is pure and returns `list[Envelope]`; `Hub` owns sockets | The whole admission/teardown policy is unit-testable without a socket | **LOCKED** |
| A9 | Frontend | **Hand-written ES modules**, no framework, no bundler | Two pages and one WebSocket do not earn a build pipeline; `node --check` is the whole toolchain | **LOCKED** |
| A10 | Assets | **No external CDNs, fonts, or analytics.** Everything served from `/static` | Works offline on a LAN; nothing phones home; nothing to time out | **LOCKED** |
| A11 | Authentication | **None**, deliberately | Trusted-LAN tool, per the product brief. Threat model lives in `SECURITY.md` | **LOCKED** |
| A12 | Configuration | Environment variables (`MIRROR_*`) + optional `.env` | Twelve-factor, container-friendly, no config file format to invent | **LOCKED** |
| A13 | E2E testing | **Playwright + full Chromium** (`channel="chromium"`), headless, with a real frame-content assertion | See §5. The assertion is on decoded pixels, never on log lines | **LOCKED** |
| A14 | Linux-native capture test | Separate `realdisplay` marked suite: private **Xvfb** + headed Chromium pinned to `--ozone-platform=x11` | Proves the real capture backend without touching the developer's session | **LOCKED** |
| 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 |
## 3. Architecture
```
sharer browser watcher browser(s)
┌───────────────┐ ┌───────────────────────┐
│ /mirror │ │ /watch │
│ getDisplayMedia│ │ tile per mirror │
│ ↓ track │ │ ↑ ontrack → <video> │
│ sendonly │◄════════ WebRTC (SRTP) ═════►│ recvonly │
│ transceivers │ LAN, direct, per-viewer │ transceivers │
└───────┬───────┘ └───────────┬───────────┘
│ ws /ws (JSON only) ws /ws │
▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ FastAPI ── Hub (sockets) ── Registry (pure roster) │
│ /api/streams · /api/config · /healthz · /static │
└────────────────────────────────────────────────────────────────┘
```
### Signaling sequence (the whole protocol)
```
sharer → hello{role:"sharer",name} server → welcome{peerId,streams[]}
sharer → publish{label,width,height,displaySurface,audio}
server → stream_added (to every viewer)
viewer → subscribe{streamId} server → peer_joined{viewerId} (to sharer)
sharer → signal{to:viewer,sdp:"offer"} server → signal{from:sharer}
viewer → signal{to:sharer,sdp:"answer"} server → signal{from:viewer}
… ICE candidates both ways, same envelope …
sharer → unpublish server → stream_removed (to every viewer)
```
`streamId == sharer peerId`: one browser tab captures one surface, so a stream
and its publisher share a lifetime and an identity. Teardown therefore cannot
leak.
### Roster rules (enforced in `registry.py`, tested in `tests/test_registry.py`)
* A stream exists exactly as long as its sharer's socket does.
* A `signal` frame is relayed **only** between a viewer and the sharer of a
mirror that viewer subscribed to — in either direction. Viewer↔viewer and
unsolicited sharer→viewer traffic is refused (`not_subscribed`, `peer_to_peer`).
* Teardown is broadcast to **every** viewer, not merely the subscribers: a tile
is drawn on `stream_added`, so a viewer that has not yet subscribed still has
something on screen that must be cleared.
* Every exit path (explicit `unpublish`, clean disconnect, crash, replaced
stream) emits the same notification set. No path may leak an
`RTCPeerConnection`.
* A repeat `hello` renames the peer and refreshes the mirrors that display it.
## 4. Data model
No schema, no migration: the state is a live graph.
```
Peer { id, role ∈ {sharer, viewer}, name, device, joined_at }
Stream { id == sharer_id, label, display_surface, width, height, audio,
viewers: set[viewer_id], created_at }
```
Stream/viewer state transitions:
```
mirror: (absent) ──publish──► advertised ──subscribe──► advertised+watching
▲ │
republish(same) republish(changed)
│ │
unchanged: metadata only re-offer to each viewer
any of: unpublish · sharer disconnect · capacity eviction ──► (absent)
viewer leg: idle ──subscribe──► negotiating ──► connected ──► closed
│ failed (×3, backoff) ──► unreachable
```
Roles are **exclusive per socket** in the prototype: a tab is either sharing or
watching. Making a peer able to do both is a phase, not an accident waiting to
happen.
## 5. Verification workflow
The gate, in order, always:
```bash
uv run ruff check . && uv run ruff format --check .
uv run pyright # 0 errors
uv run pytest # unit + API + browser E2E
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native capture
```
### The E2E ladder — why each rung exists
| Rung | Command | What it can prove nothing else can |
|------|---------|------------------------------------|
| 1. Roster | `pytest tests/test_registry.py` | Admission, fan-out, authorization and teardown policy, without any I/O |
| 2. Wire | `pytest tests/test_protocol.py tests/test_app.py` | camelCase round-trip, malformed-frame handling, the WS lifecycle |
| 3. Media | `pytest -m e2e` | **Real capture → encode → SRTP → decode → painted pixels**, two Chromium processes |
| 4. Real desktop | `MIRROR_REALDISPLAY=1 pytest -m realdisplay` | The **actual X11 capture backend** on a private Xvfb display |
### Browser-harness findings (do not rediscover these)
* `navigator.mediaDevices` is **undefined on `about:blank` and on plain-HTTP
non-localhost origins**. E2E must go through `http://127.0.0.1:<port>`, which
*is* a secure context.
* Playwright's default `chromium` **headless shell has no media stack at all** —
`navigator.mediaDevices` is absent. Use `channel="chromium"` (full binary,
new headless).
* `--use-fake-ui-for-media-stream --use-fake-device-for-media-stream` gives a
deterministic **1280×720 `displaySurface: monitor` stream with genuinely
moving pixels** — no stubbing, and `distinct frames > 1` is a real assertion.
* For **real** capture: `--auto-select-desktop-capture-source="Entire screen"`
plus `--ozone-platform=x11`. Passing `--use-fake-ui-for-media-stream` *instead*
breaks the X11 capturer (instant `NotReadableError`), because it approves the
request without ever selecting a source.
* Chromium scales a monitor capture to the capturing window, so real-capture
tests assert **aspect ratio**, not absolute pixels (`1280×800 → 1200×750`).
* A headed Chromium that inherits `WAYLAND_DISPLAY` or
`DBUS_SESSION_BUS_ADDRESS` will route capture through **xdg-desktop-portal**:
it mirrors the developer's real desktop and raises a permission dialog on their
actual screen. `browser_env()` strips both so that is structurally impossible.
### Rules for any new assertion about media
1. Assert on the **viewer's** `<video>` (`videoWidth`, advancing pixel hashes,
`framesDecoded`), never on the sharer's preview — the preview proves nothing
crossed the wire.
2. Prefer a content assertion (frames differ) over an event assertion
(`ontrack` fired).
3. Where a test drives motion, drive it deliberately (mutate the captured page)
rather than hoping a desktop animates on its own.
## 6. Known constraints (accepted, documented, not bugs)
| Constraint | Why | Handling |
|---|---|---|
| `getDisplayMedia` needs a secure context | Browser rule. `http://<lan-ip>` is not one | `/mirror` detects it and prints the three ways out (localhost, HTTPS, the per-origin Chrome flag). Real fix is A18 |
| 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 |
| Single process, in-memory | A11/A7 | One roster per process. Multi-process needs a shared roster + a way to pin a stream to its peer — a real phase, not a config flag |
## 7. Candidate phases (deliberately **not** yet written as phases)
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://<lan-ip>` on other devices, the single biggest papercut.
2. **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
memory, "auto-stage newest mirror", PiP.
4. **Capacity & fairness** — adaptive bitrate per leg by watcher count,
`MIRROR_MAX_MIRRORS` surfaced in the UI, graceful degradation for a weak
sharer uplink.
5. **Scale-out media path** — the relay/SFU evaluation in A17, decided with
measurements from a 1-sharer/20-watcher LAN test.
6. **Roles beyond sharer/viewer** — one socket that shares and watches; a device
naming/consent layer that keeps A11's threat model intact.
7. **Ops** — `/metrics` (Prometheus text), structured logging, `Containerfile`
hardening, systemd unit for the always-on kiosk box.
## 8. Definition of done (per phase)
* All four gates in §5 green, including `-m e2e`.
* No new external asset, no CDN, no new dependency without a row in §2.
* Any new media behaviour has an assertion on the **viewer** side.
* `AGENTS.md` and this file updated if an anchor moved — with the reason.
View File
+14
View File
@@ -0,0 +1,14 @@
# Pending phases
Intentionally empty at the prototype stage. The prototype was built and verified
first; the candidate phase list is §7 of `.agents/PLAN.md`.
Add phases here with the **`phase-authoring` skill** — one directory per phase:
```
NN_phase_name/
├── 00_phase.md # intent, scope, out-of-scope, acceptance, verification
└── NN_task.md # independently runnable tasks
```
Every phase must leave the four gates in `AGENTS.md` §4 green on its own.
+143
View File
@@ -0,0 +1,143 @@
# 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) | Product brief. If you think it needs auth, that is a `PLAN.md` discussion first — and see `SECURITY.md` |
| **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` |
+103
View File
@@ -0,0 +1,103 @@
# mirror
**Device-agnostic screen mirroring that lives entirely in the browser.**
`mirror` runs a small HTTP server on your machine. Open it from any device on
your network:
| Page | URL | What it does |
| ------------ | ------------------ | ----------------------------------------------------------------------- |
| **Share** | `/mirror` | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
| **Index** | `/` | Signpost + live count of mirrors. |
Any number of people can mirror at the same time and any number of people can
watch. There is **no authentication** — this is built for a trusted local
network, and that assumption is deliberate and documented in
[SECURITY.md](SECURITY.md).
## Quickstart
```bash
uv sync --extra dev
uv run mirror serve # http://0.0.0.0:8055
```
Then, from another machine on the LAN:
```
http://<this-machine>:8055/mirror # to share
http://<this-machine>:8055/watch # to watch
```
Linux is the primary target. On Linux the native picker is X11/Wayland's own
(Chromium uses `xdg-desktop-portal` on Wayland/GNOME/KDE, and its built-in X11
picker on Xorg), so nothing has to be installed on the client side.
## How it works
Media never touches the server. Each mirror is a WebRTC peer connection
established directly between the sharer's browser and each watcher's browser
(a "mesh" fan-out). The server is a **signaling relay and roster only**: it
tracks who is publishing what, forwards SDP/ICE between peers, and pushes
roster deltas to viewers.
```
sharer browser ──ws──► ┌──────────────────────┐ ◄──ws── watcher browser
│ │ FastAPI │ ▲
│ offer/ICE ───►│ /ws (signalling) │◄─── offer/ICE │
│ │ registry (in-mem) │ │
└────── WebRTC (SRTP, LAN, p2p) ─────────┴─────────────────┘
```
## Configuration
Environment variables (all optional; a `.env` file is loaded if present):
| Variable | Default | Meaning |
| ---------------------- | ------- | ---------------------------------------------------- |
| `MIRROR_HOST` | `0.0.0.0` | Bind address for `mirror serve`. |
| `MIRROR_PORT` | `8055` | Bind port. |
| `MIRROR_ICE_SERVERS` | `[]` | JSON array of RTCIceServer objects, if peers cannot reach each other directly. |
| `MIRROR_MAX_MIRRORS` | `0` | Cap on concurrent mirrors (`0` = unlimited). |
| `DEBUGPY` | `0` | `1` opens a debugpy listener on `DEBUGPY_PORT`. |
| `DEBUGPY_PORT` | `5678` | debugpy port. |
## Platform notes
**Screen capture needs a secure context.** `http://localhost` is one; a plain
`http://192.168.x.x` opened from another device is not, and the browser hides
`getDisplayMedia()` completely. The `/mirror` page detects this and prints the
three ways forward (share from localhost, serve HTTPS, or mark that one origin as
secure on the sharing device). Watching never needs permission.
**Linux.** On X11/Xorg Chromium uses its native capturer — the picker appears and
that is the end of it. On Wayland (GNOME/KDE) Chromium goes through
`xdg-desktop-portal`, so the compositor shows its **own permission dialog for
each share**, which mirror neither controls nor tries to bypass. Chrome's picker
offers whole screens and individual windows; on Wayland what the portal will
agree to depends on the compositor.
## Development
```bash
uv run pytest # unit + API + browser E2E (~20s)
uv run pytest -m "not e2e" # everything except the browser (~0.5s)
uv run ruff check . && uv run ruff format --check . && uv run pyright
```
The browser suite drives **real capture**: Chromium's fake media device yields a
moving 1280×720 monitor stream, so the assertions are on decoded, changing pixels
at the watcher — not on log lines.
There is a second, opt-in rung that exercises Chromium's **real X11 capture
backend** on a private Xvfb display, isolated from your own session so it can
never prompt you or mirror your desktop:
```bash
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay
```
See [AGENTS.md](AGENTS.md) for the operating rules,
[.agents/PLAN.md](.agents/PLAN.md) for the locked architectural decisions, and
[SECURITY.md](SECURITY.md) for why there is no login.
+102
View File
@@ -0,0 +1,102 @@
# mirror — security model
**mirror has no authentication. That is a design decision, not an oversight.**
It changes the question from "who is allowed in?" to "where is this allowed to
run?". This file answers the second question honestly.
## 1. Trust model
| | |
|---|---|
| **Trusted** | Anyone who can open a TCP connection to the server's port, and every host on the same L2/L3 network as them |
| **Untrusted** | Anyone who can reach that port and is not in the above set — including anything on the LAN that should not be (guest Wi-Fi, IoT VLAN, a cafe) |
| **Not a threat model** | Malicious JavaScript served from this origin (there is only one origin and you run the server), supply-chain of a machine you already control |
The product promise is *"open a URL, share your screen"*. Every gate we add
attacks that promise, so the boundary is drawn at the network instead of the
application.
**Run it only on a network you control.** Do not port-forward it. Do not put it
behind a reverse proxy that is reachable from the internet. It has no rate
limiting, no accounts, and no audit trail.
## 2. What an unauthenticated visitor can do
* Mirror any surface their own browser's picker offers them — to everyone else
watching.
* Watch any mirror anyone else is sharing.
* Learn which device names and window titles are being shared (window titles can
be sensitive: mail, tickets, filenames).
* Consume a sharer's upstream bandwidth (bounded by `MIRROR_MAX_BITRATE`, per
leg).
## 3. What they cannot do (enforced and tested)
| Guarantee | Enforcement | Test |
|---|---|---|
| **No unsolicited media negotiation.** A sharer cannot push an offer to a viewer who did not subscribe; a viewer cannot reach a sharer it never subscribed to | `Registry.signal()` allows traffic only along an existing subscription, in either direction | `test_sharer_may_not_blindly_push_to_a_stranger`, `test_viewer_may_not_reach_a_sharer_it_never_subscribed_to`, `test_signalling_a_peer_that_never_subscribed_is_refused` |
| **No peer-to-peer chatter between watchers.** Viewer↔viewer signaling is refused outright | `peer_to_peer` refusal | `test_peers_may_not_talk_to_each_other_directly` |
| **No identity spoofing of the roster.** A viewer cannot publish a mirror; a peer cannot claim another's stream | role checks (`not_sharer`, `not_viewer`), stream ids are server-assigned | `test_one_peer_cannot_impersonate_another`, `test_a_viewer_cannot_publish` |
| **No oversized signaling.** Frames above 64 KiB are dropped before parsing | `MAX_MESSAGE_BYTES` | `test_an_oversized_frame_is_refused` |
| **No protocol fuzzing into a crash.** Bad JSON, non-object frames, unknown types and unknown roles produce an error frame; only fatal ones close the socket | `Hub.serve`/`_dispatch` | `TestSignalingSocket` |
| **No leaked peer connections.** Every teardown path notifies every viewer | `Registry._drop_stream`, `leave()` | `test_sharer_disconnect_removes_the_mirror`, `test_unpublish_clears_the_tile_for_every_viewer` |
| **Nothing leaves the network.** No CDN, no remote font, no analytics, no STUN by default | A10/A4 + a build-failing test | `test_nothing_is_loaded_from_a_cdn`, `test_config_does_not_leak_server_secrets` |
| **The server cannot see your screen.** Media is SRTP between peers; the server relays JSON only | A3 | Architectural: no media code exists in Python |
## 4. Platform realities you should tell users about
### Screen capture requires a secure context
`getDisplayMedia()` exists only in a secure context. `http://localhost` qualifies;
**`http://192.168.x.x` does not** — the browser hides the API entirely. The
`/mirror` page detects this and prints the three ways forward:
1. Share from the machine running the server, via `http://localhost:8055/mirror`.
2. Serve over HTTPS (planned: `mirror serve --https` with a self-signed cert).
3. Per-device and explicit: `chrome://flags/#unsafely-treat-insecure-origin-as-secure`
with the exact origin, then reload.
Option 3 is a *deliberate, per-device, user-granted* exception. Do not automate it
on someone else's machine without telling them what it does: it marks that origin
as trusted for powerful APIs, permanently, on that profile.
### Wayland asks every time; X11 does not
On **X11/Xorg**, Chromium uses its native capturer: the picker appears and that is
all.
On **Wayland** (GNOME/KDE), Chromium routes capture through `xdg-desktop-portal`.
The compositor shows its own permission dialog and, depending on the portal, may
ask for **each** share rather than remembering. Expect a native prompt — that is
the compositor protecting the user, and mirror must not try to work around it.
`/watch` is unaffected: receiving never needs permission.
### Audio
Sharing tab/window audio is opt-in in the browser's own picker. mirror falls back
to silent capture when the source has no audio rather than failing the share, and
viewers start muted (autoplay policy) and must tap to unmute — a deliberate,
user-initiated action.
## 5. Operational guidance
* **Bind narrowly.** `MIRROR_HOST=0.0.0.0` is the default because the point is
other devices. If only this machine should reach it, `MIRROR_HOST=127.0.0.1`.
* **Firewall the port.** Restrict 8055 to the LAN range you actually want:
`firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.168.1.0/24 port port=8055 protocol=tcp accept'`
* **Cap the blast radius.** `MIRROR_MAX_MIRRORS` limits concurrent mirrors;
`MIRROR_MAX_BITRATE` bounds per-viewer upload.
* **Egress stays closed.** The container image makes no outbound connections; do
not add STUN unless you are deliberately bridging VLANs.
* **Assume window titles are public.** Say so before you mirror a desktop full of
private windows.
* **Rotating peer ids are not anonymity.** Peer ids are random per socket, but a
sharer's IP is visible to every watcher on the network by design (that is how
peer-to-peer works).
## 6. Reporting
Treat anything that breaks a guarantee in §3 as a security bug, even if it "only"
lets one watcher see a mirror they did not subscribe to. Add a failing test first —
the guarantees above are tests, not prose.