Initial import: keyrelay, a WiFi-controlled USB HID keyboard
Browser or terminal controller drives an ESP32-S3 that acts as a Logitech G815 into a target machine running no software. Disarmed by default; arming requires the passphrase, the board button, or the serial console, and every fail-safe path cancels in-flight work. - src/: firmware with pure, host-testable modules (keystate, proto, text, ws_codec, sha1), the WebSocket control server, and the served page - tests/: host suites for the pure modules, relayctl parity, and page JS - tools/: relayctl terminal controller, serial console, bootloader helper, and an evdev target watcher - platformio.ini: bridge (USB HID) and console (no-HID) environments - README.md: single source of truth for behaviour, protocol and safety LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# AGENTS.md — working on keyrelay
|
||||
|
||||
A WiFi-controlled USB HID keyboard: laptop browser → ESP32-S3 board → USB into the target.
|
||||
The target runs no software and sees a Logitech G815. Everything the board does is gated on
|
||||
one idea: **the difference between a remote keyboard and an unattended injection device is a
|
||||
human deciding, live, with the arming light on.** Changes that quietly move that line are the
|
||||
ones to catch here.
|
||||
|
||||
The README is the single source of truth for behaviour, protocol, safety and measurement.
|
||||
This file is the operating manual: what must not break, how to build and verify, and where
|
||||
the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy
|
||||
drifts, and two paths that can disagree about arming is the exact failure this project keeps
|
||||
having to fix (see README, "Nine bugs").
|
||||
|
||||
## The invariants
|
||||
|
||||
1. **State, not events.** `S=<mods>:<usages>` restates the *entire* desired key state. Applying
|
||||
the same state twice is a no-op; any one message resynchronises the target completely. Never
|
||||
introduce key-up/key-down events, sequence numbers, or a resynchronisation timer. Every
|
||||
stuck-key horror story in HID injection tooling is an event-protocol bug.
|
||||
2. **Disarmed is the only default.** Boot, reload, reconnect, focus loss, socket loss, target
|
||||
detach, bus suspend, queue overflow and 8 s of controller silence all disarm, and disarming
|
||||
releases every key. Arming needs the passphrase, the board button, or the serial console —
|
||||
nothing else may gain the ability.
|
||||
3. **Every fail-safe cancels work in flight; nothing "pauses".** `releaseEverything()` in
|
||||
`main.cpp` is the single funnel: it clears the report queue, raises the keys, ends the chord
|
||||
and discards a pasted run. Any new state that can outlive a disconnect (a queue, a timer, a
|
||||
run) must be cancelled there too, or it will keep acting after the operator stopped asking.
|
||||
4. **Nothing is stored.** Counters only. No keystrokes, characters, key names or text in NVS,
|
||||
logs, `Serial.printf`, error messages or status frames — `src/main.cpp` prints *lengths* for
|
||||
text runs, never content. No macro store, no schedule, no replay, no auto-arm.
|
||||
5. **Reject, never guess.** A malformed frame leaves the previous state in force. A character
|
||||
the map cannot express is `E9` and *none* of that frame is typed — a paste that quietly loses
|
||||
a character is worse than one that refuses. Silent sanitising is never the default.
|
||||
6. **`Ctrl+Alt+Del` is fixed on the device.** It is never composed from wire bytes; on a Linux
|
||||
VT it reboots the target. Same reasoning for the VT-switch chords.
|
||||
7. **One frame is one command.** ASCII, no JSON inbound, ≤ `kWsMessageCap`. Long payloads are
|
||||
the *controller's* problem to split, with the board reporting how much it still holds.
|
||||
8. **The keyboard is exclusive; the board is not.** A second `/ws` upgrade is refused (`E3`);
|
||||
read-only HTTP is served even while a controller holds the bridge. Keep both routes going
|
||||
through `serveReadonly()` so they cannot drift apart.
|
||||
|
||||
## Three tables that must agree
|
||||
|
||||
| table | where | names | pinned by |
|
||||
|---|---|---|---|
|
||||
| `CODE_TO_USAGE` | `src/web_page.h` | physical position → usage | `tests/test_relayctl.py` |
|
||||
| `CODE_TO_USAGE` | `tools/relayctl.py` | same, for the terminal | `tests/test_relayctl.py` |
|
||||
| `strokeFor()` | `src/text.h` | character → modifier + usage | `tests/test_text.cpp` (whole printable range walked) + tool/board agreement on all 128 ASCII codes |
|
||||
|
||||
Chord buttons carry their **wire bytes** in `data-chord`, not a name — which is why
|
||||
`tests/test_proto.cpp` asserts every labelled chord against the combination its label promises.
|
||||
A mislabelled button parses cleanly, is correctly gated, arrives intact and is the wrong key;
|
||||
Alt+Tab was `05:04` (Ctrl+Alt+A) until that test existed. `TEXT_FRAME_MAX` / `TEXT_JOB_MAX` in
|
||||
the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copied from
|
||||
`config.h` and are asserted equal in tests — change one, the suite fails, that is the point.
|
||||
|
||||
## Build, test, flash
|
||||
|
||||
```bash
|
||||
tests/run_tests.sh # 955 checks, no hardware
|
||||
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers
|
||||
/tmp/piovenv/bin/python -m platformio run -e bridge -e console
|
||||
python3 tools/to_bootloader.py # 1200-baud touch → ROM download mode
|
||||
/tmp/piovenv/bin/python -m platformio run -e bridge -t upload
|
||||
```
|
||||
|
||||
Environment facts that will otherwise cost you a cycle:
|
||||
|
||||
- **`pio` on PATH is broken** here (`ModuleNotFoundError: platformio`). Use the venv above. If
|
||||
it is gone (`/tmp`), find another interpreter with the package:
|
||||
`find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`.
|
||||
- **No `g++` on the host.** `run_tests.sh` falls back to a local podman image listed in its
|
||||
`images[]`. The page suite deliberately runs on the *host* so it works without node in there.
|
||||
- **Build `console` too.** `ARDUINO_USB_MODE=1` compiles `KR_HAVE_HID == 0`, which is the only
|
||||
thing that checks the no-HID `#else` branches.
|
||||
- **The page is served from flash.** A change to `src/web_page.h` is invisible on the network
|
||||
until the board is reflashed — and a tab that is already open keeps the old page in memory
|
||||
until it is reloaded. Confirm with `curl -s http://keyrelay.local/ | wc -c` against the size
|
||||
of the raw string in `src/web_page.h`, and grep the served bytes for the element you added.
|
||||
- This directory is **not a git repository**, so there is no diff or revert here. Back up
|
||||
before sweeping edits.
|
||||
|
||||
## Verification standard
|
||||
|
||||
A feature that lives on the device is not done when the source builds — it is done when the
|
||||
device is running it and something observed it. Concretely:
|
||||
|
||||
- Protocol/page claims → the host suites, plus `curl` against the served page.
|
||||
- Keystroke delivery → `tools/watch_target.py` on the target (evdev, focus-independent); it is
|
||||
the only thing that says what the target actually received, including *which* keys a chord
|
||||
really was.
|
||||
- Text runs → check the `typed:` summary and press count, not the scrolling stream.
|
||||
|
||||
When you write a check, name the mechanism it exercises. "Silence disarms" sat in the README's
|
||||
safety list while nothing compared `lastInboundMs()` to `kNoClientTimeoutMs`; the documented
|
||||
verification passed because killing the controller process closes the socket, and the
|
||||
socket-close path disarms. The check proved a different property from the one it was named after
|
||||
and nobody noticed, until a run that keeps typing on its own made the difference matter. It is
|
||||
wired now — but ask, of every green check, *what would still pass if this were broken?*
|
||||
|
||||
## Editing rules
|
||||
|
||||
- **Pure modules** (`keystate.h`, `proto.h`, `text.h`, `ws_codec.h`, `sha1.h`) include no
|
||||
Arduino or ESP-IDF headers. That is what lets the identical code run on a host under
|
||||
sanitisers. Keep it.
|
||||
- **`web_page.h`** is a C++ raw string: no compiler looks at the HTML or JS. Run
|
||||
`tests/test_page.js` after touching it (it executes the page against a DOM shim and checks
|
||||
that every element id the script reaches for exists in the markup).
|
||||
- **A protocol change is four edits, minimum**: `proto.h` (parser + `format*` + its header
|
||||
comment), `web_page.h`, `relayctl.py`, and the README Wire protocol table + error-code list.
|
||||
Tests that pin the wire format are `test_proto.cpp` and `test_relayctl.py`.
|
||||
- **Docs move with the code.** Update in the same change: README intro bullets and check count,
|
||||
Safety model, Security model trust-boundary table, Controller page, Wire protocol,
|
||||
Architecture file map, Tests table, Backlog, and the docstring at the top of whichever module
|
||||
you changed. This README documents *why* decisions were made — keep the reasoning, don't just
|
||||
restate the behaviour.
|
||||
- Secrets: never in a file, a URL, a log or a shell history. The serial `token` command does not
|
||||
echo it; keep it that way. `/status` takes the token as a query parameter because `curl` is
|
||||
the point of that endpoint — do not add the token to any other URL.
|
||||
- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the
|
||||
README: a fixed bug belongs in "Nine bugs this verification caught" with its root cause and
|
||||
how it was proven, a new capability in the section that describes it, and a rule worth
|
||||
repeating here. A TODO that has accumulated resolved items tells the next reader nothing about
|
||||
what is left to do.
|
||||
|
||||
## Current defaults worth knowing
|
||||
|
||||
Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP
|
||||
`keyrelay-ctl` / `keyrelay-ctl-pass`; `http://keyrelay.local` or `192.168.4.1`; paste pace
|
||||
12 ms/character, 120 characters per frame, 2048 per run; silence disarm 8 s; NVS holds the
|
||||
station credentials and passphrase and survives reflashing.
|
||||
Reference in New Issue
Block a user