Commit Graph
14 Commits
Author SHA1 Message Date
ducoterra 1e816da5a8 phase: 01_keyboard_kiosk_control
Build and Push Containers / test (push) Successful in 35s
Build and Push Containers / build-and-push-app (push) Successful in 10s
**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
ducoterra f3de5b70ff docs(agent): phase 04 - reachable share CTA at narrow widths
The Share button on /mirror is unclickable below ~700px and below the
fold in short windows. Measured with elementFromPoint on Chromium:

  320x568  stage 288x180  button 187px past the stage bottom  hit=H2
  390x844  stage 358x224  button 113px past                   hit=LABEL
  480x800  stage 448x280  button  36px past                   hit=#share-link
  900x480  stage 868x543  inside the stage, below the viewport hit=null

Root cause: .stage is display:grid with place-items:center, so <video>,
.placeholder and .livetag occupy three stacked rows rather than one
overlay cell. An idle <video> is intrinsically 300x150, so the
placeholder always starts at stage.top + 151 and runs out of the
overflow:hidden box; aspect-ratio 16/10 with no min/max height then
clips it when narrow and overshoots the viewport when short.

#stop measures clickable everywhere (the placeholder is hidden when
live), so the fix is scoped to the idle stage.

Phase: one-cell stage, floor + ceiling + scroll escape, a 12-viewport
clickability sweep (idle and live), and stylesheet guards.

LLM-Generated: true
2026-11-01 19:55:54 -05:00
ducoterra 7d4bcf7e23 fix(kiosk): stop GDM covering the wall with a greeter after auto-login
Auto-login succeeded and the wall kept streaming, but about ten seconds
after the kiosk session came up GDM autospawned a greeter on the same GPU
and a login screen covered it. gnome-kiosk ships X-GDM-SessionRegisters=false,
so nothing ever registers and GDM concludes the display is still free — which
is why every remote check (loginctl, the mirror roster) stayed green while
the physical screen showed a password prompt.

Override that session file with the flag set to true, keeping the file name
and session id identical: inventing an id makes GDM quietly fall back to the
plain GNOME session, top bar and all.

Add a per-minute guard that reclaims the foreground VT, because this class
of failure is invisible from the outside, and have the checker assert both
the foreground VT and the greeter-session count. That count had a silent
false green: journalctl has no --no-legend, so the check read an empty
stream and passed unconditionally.

Also keep a copy of the payload in /usr/local/share/mirror-kiosk. /tmp is
cleared at every boot, which deletes the checker just after the one event
you are most likely to want to re-run it for.

Verified on the reference client: a cold boot now creates no greeter session
at all, the kiosk compositor and Firefox come up undisturbed, and a console
stolen with chvt is taken back within a minute.

LLM-Generated: true
2026-11-01 19:46:17 -05:00
ducoterra 32c6bc272b chore(agent): phase roadmap from TODO.md, 3 phases
Convert the three open TODO items into .agents/phases/todo/ directories
(Protocol B append — PLAN.md, AGENTS.md and existing phases untouched, so
PLAN.md's roadmap table does not list these phases):

- 01_keyboard_kiosk_control (TODO.md:3) — mouse-free /watch and /mirror:
  key router, j/k/Enter/f/g/n/p/1-9/m/?, focus-visible controls, help overlay.
- 02_sharp_capture (TODO.md:4) — capture screen pixels instead of window
  pixels, maintain-resolution per leg, 12 -> 25 Mbit/s default cap,
  real-desktop size assertion on the viewer.
- 03_audio_share (TODO.md:5) — one accumulating MediaStream per tile (the
  ontrack overwrite dropped the first track), staged-only unmute policy,
  honest audio states on /mirror, viewer-side inbound-audio assertion.

Every task cites its TODO source line. Locked with the user: 25 Mbit/s
default, only the staged mirror can be audible, full key map.

Note for the user: PLAN.md section 6 still says the per-leg cap defaults to
12 Mbit/s; phase 02 task 03 changes that and this commit does not edit PLAN.md.

LLM-Generated: true
2026-11-01 19:41:24 -05:00
ducoterra c5da9461a2 docs: the Silverblue kiosk client, installed and checked over SSH
A wall display is now a target client: Fedora Silverblue running GNOME
Kiosk, auto-logged in as kiosk, showing /watch fullscreen in Firefox.
docs/kiosk-silverblue/ carries the idempotent installer, a read-only
checker and a probe that measures what the browser actually renders, so
a second client is a copy-paste and nothing about it needs a keyboard.

The runbook records what the upstream tutorial leaves out: gsettings
only commits from inside the session (gnome-kiosk sets DCONF_PROFILE,
so a system dconf db is never read at all), window-config.ini wins by
being found first and therefore silently loses the shipped notification
rules, AccountsService caches Session= until its daemon restarts, and
/watch survives a bare LAN IP while /mirror cannot.

Verified on the reference client: cold boot back to a subscribed wall in
under 60s, viewport == window == screen at 3840x2160, and the sharer's
own peer connection to it reports connected.

LLM-Generated: true
2026-11-01 19:11:53 -05:00
ducoterra 269b64bace fix(cli): mirror --version printed an error instead of a version
Build and Push Containers / test (push) Successful in 1m20s
Build and Push Containers / build-and-push-app (push) Successful in 9s
`main()` rewrites a bare invocation into `serve`, which is what makes
`mirror --port 8055` work at all — but it did that for every argument that
starts with a dash, so `mirror --version` became `mirror serve --version`, the
subparser has no such flag, and the command exited with "unrecognized
arguments". Found by pulling the image CI published and asking it what it was.

The rewrite rule is now `with_serve()`, extracted so the eight interesting
inputs are asserted directly — including `--port 9999` still becoming a serve
and `serve --version` staying as typed. `serve` also grew its own --version,
because typing it there is just as reasonable and argparse does not inherit
flags from a parent parser.

The extraction is not cosmetic: the first version of this test monkeypatched
`cmd_serve` to prove the rewrite, which did nothing, because argparse binds
`func` at parse time — the test would have started a real uvicorn on 9999 and
hung the suite. Asserting the pure function is both correct and safe.

187 tests pass, 2 skipped; ruff and pyright clean.

LLM-Generated: true
2026-11-01 18:14:45 -05:00
ducoterra 28dfc26500 ci: name the Containerfile in the build smoke step
Build and Push Containers / test (push) Successful in 22s
Build and Push Containers / build-and-push-app (push) Successful in 8s
Run #2 got through Checkout, the runtime probe and the whole suite (157 passed,
3 skipped — the `node --check` structural tests skip with no node in a python
image, which is the graceful degradation they were written to have) and then
failed on `docker build -t mirror:ci .`: with no -f, docker looks for
"Dockerfile", finds none, and says "failed to read dockerfile" after
transferring a 2-byte context. The publish job never hit this because
docker/build-push-action passes `file: Containerfile` on its own.

The gate is worth keeping rather than dropping: it is the only step that builds
the image on a push, so without it a broken Containerfile surfaces only as a red
publish job that quietly leaves :main stale.

LLM-Generated: true
2026-11-01 18:08:47 -05:00
ducoterra 7257fc3c0c ci: run the suite where the runner can actually start it
Build and Push Containers / test (push) Failing after 40s
Build and Push Containers / build-and-push-app (push) Skipped
Run #1 died in Checkout, before a single command of ours ran: actions/checkout
is a JavaScript action, and the job's `container: python:3.13-slim` has no Node
runtime for it. The same actions succeed on this instance with no `container:`
key at all (Experimental/brain-of-reese run 264 is the proof), so the job stays
on the runner and shells into the interpreter it needs instead.

The suite now runs inside a pinned python:3.13-slim via whichever of podman or
docker the runner has — the same runtime the publish job builds with, which is
why "no runtime found" is a hard failure rather than a quiet fallback to
whatever python the runner image happens to ship. Falling back there would make
the gate depend on an interpreter nobody chose.

3.13 rather than the 3.14 of the dev venv: it is the floor declared in
pyproject's requires-python, so CI checks the floor. The image-build smoke step
lost its apt-get install podman for the same reason.

LLM-Generated: true
2026-11-01 18:07:00 -05:00
ducoterra 2e79430ac7 docs: what actually gates access, now that there is a deployment
Build and Push Containers / test (push) Failing after 8s
Build and Push Containers / build-and-push-app (push) Skipped
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
2026-11-01 17:47:31 -05:00
ducoterra 7988da2b93 feat: decide who may connect at all, by source address
mirror has no accounts, so on a real network the address a connection arrives
from is not a nicety — it is the entire access control. Before this change the
answer was "everyone who can open a TCP connection", which is fine for a laptop
demo and alarming for a box someone leaves running.

`mirror serve --allow-cidr 10.2.0.0/22` (or MIRROR_ALLOW_CIDRS) now restricts
pages, the JSON API and the signaling socket to the networks named, and it is
strict where it needs to be: a malformed CIDR exits 2 with one line instead of
starting a server whose list silently failed to parse, because the two
outcomes of that typo are "nobody can connect" and "everybody can connect".
Empty remains the only permissive state, on purpose.

The rules that matter live in src/mirror/access.py and are the ones that get
either outcome:

  * X-Forwarded-For is believed only from MIRROR_TRUSTED_PROXIES (loopback by
    default), and only its left-most value. An untrusted peer that sends the
    header gets judged on its own address — there is a test for exactly that,
    because it is the shape of a bypass.
  * The socket is closed before it accepts, so a refused address never creates
    a roster entry to clean up behind it.
  * An address that cannot be parsed fails closed, and a v6 address is never
    "inside" a v4 network (comparing the two raises TypeError, which would be
    a 500 on a dual-stack host).
  * /healthz is exempt: the container health probe comes from loopback, which
    a production list otherwise excludes.

The refused client is told why, and which address it was judged as — "403" in
front of a proxy is undiagnosable otherwise. Browsers get a page, fetches get
JSON, and the page is self-contained because a refused address cannot load
/static/app.css any more than it can load a page. net.js keys its own notice on
the /api/config 403 and stops: without that the pill reads "offline,
reconnecting in 32s" forever for a decision that will never change.

The static files drop their only inline script and style attribute (the landing
page's counter is /static/home.js, its fine print is .fineprint) so the
deployment can send `script-src 'self'; style-src 'self'` with no
'unsafe-inline'. That policy was verified through a real nginx edge carrying the
exact header set — capture through SRTP to decoded, painted frames at
1280x720, eight distinct, zero securitypolicyviolation events — which also
caught the edge sending `Cache-Control: no-store, no-store` and needed
proxy_hide_header, since nginx appends rather than replaces.

24 tests added; 176 pass, 2 skipped, pyright and ruff clean.

LLM-Generated: true
2026-11-01 17:47:31 -05:00
ducoterra 24cb10ee5c ci: publish the image, but only past the test suite
One image (gitea.reeseapps.com/experimental/mirror), tagged :main on main and
a version tag on release, exactly like brain-of-reese — plus a `test` job the
publish job `needs`. A red pipeline therefore leaves :main on the last good
build, which is what podman auto-update keeps serving: red means nothing
shipped, not that production broke.

The test job runs unit + API only and --ignores tests/e2e rather than
deselecting it: -m filtering happens after collection, and tests/e2e/conftest.py
imports playwright, so a marker alone would not keep the browser suite out of a
container that has no browser. The e2e and realdisplay rungs stay local gates
(AGENTS.md), and pyright is not in CI either, since the python container has no
node to run it with — that asymmetry is written down rather than pretended
away.

The image sheds curl and its HEALTHCHECK: health belongs to the deployment
(HealthCmd in the quadlet, which probes with the python already in the image),
so the runtime image no longer ships a package that exists only to answer
/healthz — and no longer contains a way to make outbound requests, which is
consistent with a server that dials nothing. 171 MB to 157 MB. The image
source label points at the real repo now instead of example.invalid.

Verified locally with podman 5.8: the image builds, `mirror --help` exits 0,
and it serves on 8080 when MIRROR_PORT says 8080.

LLM-Generated: true
2026-11-01 17:46:58 -05:00
ducoterra 896cca2f23 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
2026-11-01 16:54:28 -05:00
ducoterra 7359ee4e07 test: four-rung suite that asserts on decoded pixels, not log lines
130 tests, ~20s, 94% coverage. Four rungs, each proving something the one
below it cannot:

  registry   admission, fan-out, signal authorization and teardown, no I/O
  wire       camelCase round-trip, malformed frames, WebSocket lifecycle
  e2e        real capture -> encode -> SRTP -> decode -> painted frames
  realdisplay the actual X11 capture backend on a private Xvfb display

The e2e rung drives Chromium's fake media device, which produces a genuine
moving 1280x720 monitor stream, so the assertion is that a watcher's <video>
has a size and that its pixels change over time -- a frozen or never-started
peer connection fails the suite, which a "signal arrived" assertion would
not have caught.

Findings encoded as flags and comments, because each cost a debugging cycle:
the headless shell has no media stack at all (use channel="chromium");
mediaDevices is undefined off a secure context (serve over 127.0.0.1); and
--use-fake-ui-for-media-stream breaks real X11 capture by approving the
request without ever selecting a source, so the realdisplay suite selects
the source explicitly instead.

The realdisplay fixture strips WAYLAND_DISPLAY and DBUS_SESSION_BUS_ADDRESS
from the browser environment. Without that, a headed Chromium reaches
xdg-desktop-portal, mirrors the developer's actual desktop and raises a
permission dialog on their real screen -- which is exactly what happened
before this was isolated. Skipped unless MIRROR_REALDISPLAY=1.

Also guards the frontend's structure: scripts must parse as ES modules,
every id a script looks up must exist in its page, ids must not collide
across pages that share one stylesheet, and nothing may load from a CDN.

LLM-Generated: true
2026-11-01 16:54:28 -05:00
ducoterra 555fc1c3b4 feat: browser-only screen mirroring prototype
A FastAPI server with two pages and one WebSocket. /mirror captures a
window, screen or tab through the OS-native getDisplayMedia picker; /watch
shows every live mirror as a wall and takes one fullscreen on click. Any
number of sharers and watchers, at the same time.

Media never touches the server: each watcher gets its own RTCPeerConnection
to the sharer, and the Python process only relays JSON. The roster lives in
registry.py, which is transport-free by design -- every operation returns
the envelopes to deliver, so admission, fan-out and teardown policy are all
unit-testable without a socket.

Registry rules worth stating: a signal frame is relayed only along an
existing subscription in either direction; viewer-to-peer traffic is
refused; and teardown is broadcast to every viewer rather than only the
subscribers, because a tile is drawn on stream_added and a viewer that has
not yet subscribed still has a stale tile to clear.

Frontend is hand-written ES modules with no build step and no external
asset. Chromium scales a monitor capture to the capturing window and
requires a secure context, so /mirror detects an insecure origin and prints
the three ways out rather than failing on a button click.

LLM-Generated: true
2026-11-01 16:54:03 -05:00