@vibelord/lanes 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +55 -15
  2. package/bin/lanes-install.mjs +8 -2
  3. package/lib/plan.mjs +34 -1
  4. package/package.json +2 -2
  5. package/payload/lanes/README.md +91 -33
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +994 -49
  8. package/payload/lanes/client/lanes-launch +160 -14
  9. package/payload/lanes/client/ssh.stanza.in +11 -2
  10. package/payload/lanes/docs/ARCHITECTURE.md +88 -3
  11. package/payload/lanes/docs/INSTALL.md +297 -43
  12. package/payload/lanes/docs/RUNBOOK.md +458 -31
  13. package/payload/lanes/docs/USERGUIDE.md +1612 -0
  14. package/payload/lanes/install/enable-phone.sh +8 -20
  15. package/payload/lanes/install/enable-web.sh +2 -2
  16. package/payload/lanes/install/host-install.sh +6 -0
  17. package/payload/lanes/install.sh +888 -52
  18. package/payload/lanes/libexec/enrol.py +695 -0
  19. package/payload/lanes/libexec/enrold.py +1264 -0
  20. package/payload/lanes/libexec/keys.py +644 -0
  21. package/payload/lanes/libexec/lanes_lib.py +14 -6
  22. package/payload/lanes/libexec/members.py +297 -0
  23. package/payload/lanes/libexec/notify.py +146 -21
  24. package/payload/lanes/libexec/qr.py +217 -0
  25. package/payload/lanes/libexec/site_edit.py +237 -0
  26. package/payload/lanes/libexec/web_tokens.py +315 -0
  27. package/payload/lanes/site/README.md +13 -1
  28. package/payload/lanes/site/beyondwork/site.conf +3 -0
  29. package/payload/lanes/site/site.example.conf +5 -0
  30. package/payload/lanes/sudo/one-time-root.sh +1 -1
  31. package/payload/lanes/systemd/lanes-enrold.service +22 -0
  32. package/payload/lanes/systemd/lanes-notify.service +4 -4
  33. package/payload/lanes/systemd/lanes-web.service +4 -0
  34. package/payload/lanes/web/Caddyfile +10 -0
  35. package/payload/lanes/web/README.md +8 -2
  36. package/payload/lanes/workspaces.example/README.md +6 -1
  37. package/payload/manifest.json +7 -4
@@ -1,9 +1,10 @@
1
1
  # lanes — runbook
2
2
 
3
3
  `lanes` is the host-level workspace server for a deployment's container terminals. The canonical source is
4
- `bw-tools/lanes`; [INSTALL.md](INSTALL.md) covers the full setup and [ARCHITECTURE.md](ARCHITECTURE.md) explains
5
- its parts. Run host commands as the workspace account. Deployment-specific incidents below retain their
6
- original names and measurements; substitute your account, containers and paths in operational commands.
4
+ `bw-tools/lanes`; [INSTALL.md](INSTALL.md) covers the full setup, [ARCHITECTURE.md](ARCHITECTURE.md) explains
5
+ its parts, and the [user guide](USERGUIDE.md) is the day-to-day reference for every surface, key and command.
6
+ Run host commands as the workspace account. Deployment-specific incidents below retain their original names and
7
+ measurements; substitute your account, containers and paths in operational commands.
7
8
 
8
9
  ## Daily use (client)
9
10
 
@@ -15,7 +16,7 @@ original names and measurements; substitute your account, containers and paths i
15
16
  | One lane, no multiplexer, native Ghostty scrollback | `lanes-launch lane <lane-id>` (ids: `lanes-launch ls`) |
16
17
  | Switch workspace | `Ctrl-Alt-←/→` (zellij tabs) |
17
18
  | Next / previous lane | `Ctrl-Alt-↓` / `Ctrl-Alt-↑` (works from inside the lane: `lanes next\|prev --focused` in a floating pane that closes at once points the FOCUSED viewport — the first one when the sidebar has the focus — at the next / previous lane of its workspace in registry order, wrapping; nothing is typed and the focus stays where it was; on a tab without a workspace, e.g. Host, it does nothing). In the sidebar `J` / `K` still step through the list as it is drawn |
18
- | Lock the view | `Ctrl-Alt-g` locks (every key reaches the lane; the mode ribbon shows LOCKED in on-accent-urgent) and unlocks. `Ctrl-g` is never bound: it always reaches the lane |
19
+ | Lock the view | `Ctrl-Alt-g` locks (the lane gets every key except the `shared` chords that stay live while locked — the Ctrl-Alt arrows, `/`, `u`, `c`, `s`, PageUp/PageDown/End; the mode ribbon shows LOCKED in on-accent-urgent) and unlocks. `Ctrl-g` is never bound: it always reaches the lane |
19
20
  | Find a lane by name | `Ctrl-Alt-/`, type a few letters, `Enter` |
20
21
  | Browse the sidebar | click it (or `Ctrl-Alt-p` then `←`), `↑/↓` or `j/k`, `Enter`; selecting a lane focuses it again |
21
22
  | Go to the next lane that needs you | `Tab` in the sidebar opens the next lane in **Needs you** (`Shift-Tab` the previous), cycling from the lane this pane shows — or from the cursor lane when the shown one does not need you; ` nothing needs you` when the section is empty |
@@ -111,6 +112,41 @@ statusd no longer reads `~/.local/state/lanes/legacy-sessions/` and the sidebar
111
112
  shells. `lanes doctor` names any record still there (`⚠ legacy-sessions/ holds N records …`); end that shell
112
113
  inside its container by hand, then delete the record.
113
114
 
115
+ ## Client install and update
116
+
117
+ A new machine joins a site with one command: `npx @vibelord/lanes --invite <code>` (the code from a member's
118
+ `lanes invite`), or without a code an access request a member approves ("Invite, approve, offboard" below). The user's
119
+ steps and every installer message are in the user guide's [Join a site](USERGUIDE.md#2-join-a-site); the enrolment
120
+ service behind it is "Enrolment service" below. A machine whose key the server does not know, and that has neither,
121
+ ends at exit 3 with its public key printed: `lanes keys add <device> <file.pub>` on the server ("Who may log in").
122
+
123
+ ### Remembered install options
124
+
125
+ Every client install writes `~/.config/lanes/install-options` (`$XDG_CONFIG_HOME/lanes/`; plain `KEY=VALUE`, mode
126
+ 0600, read with `sed`, never executed; `DM-LANES-INSTALL-OPTIONS`), and the next install reads it, so a plain re-run —
127
+ every update — repeats the last install:
128
+
129
+ | Key | Flag | Value |
130
+ | --- | --- | --- |
131
+ | `SCHEMA` | — | `1`; a file with a higher one is read by neither `install.sh` nor `lanes-launch update` (written by a newer installer) |
132
+ | `DEVICE` | `--device` | This machine's view name |
133
+ | `KEY` | `--key` | The SSH private key, an absolute or `~/` path |
134
+ | `SITE` | `--site` | A bundled profile by name (`beyondwork`: an update reads the new package's copy and pinned host key), or your own profile by absolute path; absent when the default chain picked it |
135
+ | `DESKTOP`, `UPLOADS`, `PALETTE`, `CODER_LAUNCH` | `--desktop`/`--no-desktop`, `--uploads`/`--no-uploads`, `--palette`/`--no-palette`, `--coder-launch`/`--no-coder-launch` | `1` or `0` |
136
+
137
+ A flag on the command line wins over the file and is remembered from then on; `--invite`, `--resume`, `--link` and
138
+ `--yes` are never remembered. `./install.sh --check` reads the file and writes nothing; `--uninstall` removes it.
139
+ `lanes-launch update` turns each line into its flag (a value `install.sh` would ignore is not passed), puts the
140
+ arguments you give after them (so yours win), prints the command, and runs `npx --yes @vibelord/lanes@latest` with
141
+ it; it refuses `--server` and a client that runs from a checkout (update that with `git pull` and `./install.sh`).
142
+
143
+ | Symptom | Do |
144
+ | --- | --- |
145
+ | An update brought back something you had turned off, or kept something you want back | `cat ~/.config/lanes/install-options`; run the installer once with the flag you want (`--no-uploads`, `--desktop`, …): it is remembered |
146
+ | `the remembered site profile (<name>) is not here: using the default profile` | The package no longer bundles that profile, or your own profile file moved: give `--site` once |
147
+ | `lanes-launch update: … was written by a newer installer: not replayed` | A newer client wrote the file: `npx @vibelord/lanes@latest` with your flags, which rewrites it |
148
+ | Forget every remembered choice | `rm ~/.config/lanes/install-options`; the next install reads the choices once from what is installed (as for a 1.0.x client), so give it the flags you want |
149
+
114
150
  ## Server (as the workspace account)
115
151
 
116
152
  ```
@@ -118,8 +154,14 @@ lanes doctor full health check lanes status per-wor
118
154
  lanes sync re-read registries now lanes ls [--json] lanes with live status
119
155
  lanes rebuild <device> recreate a view session lanes ensure recreate missing view sessions
120
156
  lanes invariant save|check prove no live pane changed (pid + window size)
121
- lanes device list the devices ensure keeps lanes device forget <name> stop recreating one
122
- systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web
157
+ lanes device list the devices ensure keeps, with owners lanes device forget <name> stop recreating one
158
+ lanes keys list who may log in (members/) lanes keys add <device> <file.pub|-> authorize a machine
159
+ lanes keys revoke <device> remove one machine lanes keys adopt [--dry-run] move hand-added lanes: keys in
160
+ lanes invite <name> --email ADDR one-time join code (own terminal) lanes invite list | revoke <id>
161
+ lanes enrol list open access requests lanes enrol approve <id> | deny <id> decide one
162
+ lanes offboard <device> --yes key, device, view (own terminal) lanes token revoke <view> sign a browser out
163
+ lanes onboard <login> --container <name> [--workspace <id> --repo <path>] [--write] a user's container, dry run first
164
+ systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web lanes-enrold
123
165
  lanes studio-host status [--check] the optional co-located Studio Host
124
166
  lanes studio-host re-key --yes after the owner revoked the Host: a new machine key for the next deploy
125
167
  (refused while lanes-sharing-host or throttle-rc-studio-host still runs)
@@ -145,7 +187,46 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
145
187
  `devices/<name>.json`, so `lanes ensure` (`lanes-ensure.timer`) stops recreating its view session and `sync` /
146
188
  `upgrade-session` stop updating it. A view session that is running is left alone and says so; once it ends, nothing
147
189
  brings it back. `lanes attach <name>` / `lanes prepare <name>` creates the device again. `lanes device list` shows
148
- each device, phone or desktop (viewports), and whether its view session runs.
190
+ each device, phone or desktop (viewports), its owner's login (blank for none) and whether its view session runs.
191
+ - **Who a device belongs to (W8 A2):** `~/.local/state/lanes/members/<device>.json` records, beside the key, the
192
+ owner `{login, name, email}` (the tailnet login; `null` for a key added with `lanes keys add|adopt`), `via`
193
+ (`invite`|`request`|`manual`), `approved_by`, the `invite_id` or `request_id` and the `workspaces`. A device's view
194
+ session records that owner in `devices/<device>.json` when it is created (`lanes attach`, `prepare`, `web`, or
195
+ `ensure`/`rebuild`) and `lanes attach` brings it up to date — for a window such as `mac-2`, whose base `mac` attach
196
+ learnt, the owner of `members/mac.json`. Whenever such a member record exists it decides, also when it names nobody:
197
+ a machine handed to someone else (`lanes keys revoke mac`, then the new person enrols as `mac`) shows the new owner
198
+ at once in `lanes device list`, which therefore always agrees with `lanes keys list`, and the device file follows at
199
+ the next attach or recreation (DEF-1702). After a revoke with no new record, the last owner is kept like `base`
200
+ (`lanes device forget` clears it with the file); a member file that cannot be read is no evidence of another
201
+ owner, so the recorded one stays too. Writing a member never creates `devices/*`: `lanes ensure` would build a view
202
+ session for it. A member file whose owner, `via`, ids or workspaces do not validate (a control or format character
203
+ such as a bidi mark or a zero-width space, a lone surrogate, a blank in a login, an `invite_id` on a request) is not
204
+ rendered into `authorized_keys` and is named, like a bad key.
205
+ - **Who may log in (`lanes keys`, W8 A1):** client access is a projection of `~/.local/state/lanes/members/<device>.json`
206
+ into ONE block of `~/.ssh/authorized_keys`, between `# lanes-managed BEGIN` and `# lanes-managed END`: one
207
+ `restrict,pty ssh-ed25519 <key> lanes:<device>` line per member. Every byte outside the markers (admin keys, deploy
208
+ keys, comments) is kept exactly. `restrict` turns off port, agent and X11 forwarding and `~/.ssh/rc`; `pty` gives back
209
+ the terminal `lanes-launch` forces; commands and stdin pipes (uploads, terminfo, `hello`, `client-config`) are
210
+ unaffected; there is no `from=`, so the `lanes-public` fallback still logs in. `lanes keys add <device> <file.pub>`
211
+ (or `-` and paste the line) authorizes the key a new machine's installer printed — ed25519 only, one key per device and
212
+ one device per key; from an enrolled client, `ssh -o RequestTTY=no lanes '$HOME/.local/bin/lanes keys add <device> -'
213
+ < their.pub` (the `lanes` alias forces a TTY, which would swallow stdin). `lanes keys revoke <device>` removes it:
214
+ the line leaves the block first and `members/<device>.json` only after that write succeeded. A revoke refused by
215
+ malformed markers writes nothing; one whose write fails (a read-only or full file system) exits 1, keeps the member
216
+ file and says whether the block still lets the device in; either way the same `revoke` run again finishes it. A
217
+ `lanes:<device>` line found in the block without a valid member file is removed too. A device with neither is a
218
+ no-op (exit 0); exit 1 names the line when a line OUTSIDE the block still lets that key or `lanes:<device>` in
219
+ (revoke never edits those — remove it by hand); an ssh session already open keeps running until it ends.
220
+ `lanes keys add` writes the member file before its line, so an add whose write fails leaves a new device NOT
221
+ authorized and says so; the same `add` again renders the line. `lanes keys list` shows each member with its
222
+ fingerprint (the `SHA256:` form sshd logs on `Accepted publickey`), whether the block is in sync with `members/`, and
223
+ hand-added `lanes:<device>` lines outside it. `lanes keys adopt [--dry-run]` moves those plain hand-added lines into
224
+ `members/` and the block, and re-renders the block; a line with options, another key type, or a device/key conflict
225
+ stays where it is and is named, and the first adopt keeps `authorized_keys.bak-<UTC>`. Malformed markers refuse every
226
+ verb before anything, `members/` included, is written (fix the file by hand); a member file that does not validate is
227
+ not rendered (fail closed) and is named on
228
+ stderr. Writes are atomic and only on a change; the file stays 0600 and `~/.ssh` 0700. A non-default sshd
229
+ `AuthorizedKeysFile` does not read this block.
149
230
 
150
231
  - **A view looks wrong / stuck:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client reconnects.
151
232
  - **A lane was added/renamed/removed in a repo's `.vscode/terminals.json`:** nothing to do. `lanes-sync.timer`
@@ -179,8 +260,26 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
179
260
  - **A newer layout added a tab (e.g. `Throttle`):** `lanes upgrade-session` adds it to the live sessions without a
180
261
  rebuild and leaves you on the tab you were on. It never changes an existing tab's panes: a changed pane (e.g. the
181
262
  phone picker after plan Task 26, which `lanes doctor` flags) needs `lanes rebuild <device>`.
182
- - **A new workspace:** add it to `~/.config/lanes/workspaces/catalog.json` + a manifest, `lanes sync`, then
183
- `lanes rebuild <device>` (the only time a layout changes).
263
+ - **A new workspace:** `lanes workspaces add <id> --container <name> --repo /home/coder/<repo>` (dry run), the same
264
+ command with `--write`, then `lanes install-container <name>` and `lanes sync`. Sync adds the tab to running views
265
+ (`upgrade-session`). A view with no attached client gets it at its next connection. Without `--write` nothing is
266
+ written. An id that is already in the catalog is never rewritten: a different container, repository, title or
267
+ session is listed and the command exits 1. A repository another board already declares (same container and
268
+ folder, or another board's `cursor` file) is refused before any write: `lanes cursor-sync` would rewrite its
269
+ `terminals.json` for both boards. The first write keeps a `catalog.json.bak-<UTC>Z` copy beside the
270
+ catalog. Boards with several repositories are edited by hand in `~/.config/lanes/workspaces/`.
271
+ - **A new user's container:** `lanes onboard <login> --container <name> --workspace <id> --repo /home/coder/<repo>`
272
+ prints every step and changes nothing. The same command with `--write` adds the board, installs the contract,
273
+ merges the Claude and Codex hooks, runs `throttle setup --install-clients` inside the container when Throttle is
274
+ installed and configured there (otherwise it prints the one-time setup to run on a terminal), syncs,
275
+ canonicalizes the repository's create recipes, syncs again
276
+ when they changed, and reports `lanes doctor`. The receipt is in `~/.local/state/lanes/onboard/<login>-<UTC>.json`.
277
+ A step marked `manual` in the table is left for a person, with its command. Exit 1 means a step `failed`; an
278
+ unknown container (checked first, read-only, so a typo never reaches the catalog), the board and the contract stop
279
+ the run, and the steps it did not reach are listed as `not-run`. When the host account's UID or the home volume's
280
+ owner is not 1000, the check fails and the run stops there, in a dry run too: nothing is written (the agent could
281
+ not read the files), every later step is `not-run` with the reason, and the exit code is 1. Re-running is safe ([install guide](INSTALL.md#onboard-a-users-container)): a hook install that is
282
+ already current is left alone, even after Throttle's setup has added its own group and re-sorted the file.
184
283
  - **Registry invalid:** the workspace keeps its previous lanes; the sidebar footer and `lanes doctor` show why.
185
284
  - **The Studio Host (optional, `lanes-studio-host.service`):** `lanes studio-host status` names the state —
186
285
  `running`, `waiting-for-enrolment` (the deploy has not written a receipt yet; the unit is enabled and the enrol
@@ -225,6 +324,9 @@ fails first. A question that got no answer is never published as "the lane is go
225
324
  | Viewport status screen WITHOUT that line | The lane's tmux socket is really gone. | "A dead lane, or a blind harness?" below. |
226
325
  | Client window: `connecting…`, later a hold screen with a countdown | `lanes-launch` never closes the window by itself. ssh failed, or the server dropped a slow client (zellij does that with exit 0), or the server refused (exit 64–78): it holds, re-attaches when the countdown ends, backs off up to 60 s. `Enter` = now; closing the window = quit. Log: `~/.local/state/lanes-client/launch-<target>.log`. | Leave it open. |
227
326
  | Upload strip ` ✗ no upload helper` (the old upload box: `no upload helper`) while windows were closing | The helper is a per-machine service now (launchd `ai.beyondwork.lanes-helper` / `systemctl --user status lanes-helper`) and survives the window. It counts as serving while `~/.local/state/lanes-client/helper.alive` is younger than 90 s. | `./install.sh --check` on the client. |
327
+ | Linux client, key with a passphrase: `journalctl --user -u lanes-helper` shows `Permission denied (publickey)` | The user manager has no SSH agent (or one without the key). The helper looks up the agent the user manager holds before every login, so an agent the desktop session hands over after boot (GNOME or KDE keyring, uwsm) is used from its next attempt, at most a minute later — the journal then says `upload helper: SSH agent of the user manager: <socket>`. The installer hands one over and the `# >>> lanes agent hand-off >>>` block in the login profile repeats it at each local login, but only when `SSH_AUTH_SOCK` is set there — an agent started later that never reaches the user manager (for example `ssh-agent` from a compositor's autostart) is not seen. | From a terminal whose agent holds the key: `systemctl --user import-environment SSH_AUTH_SOCK` (the helper takes it at its next attempt; `systemctl --user restart lanes-helper` for at once), or re-run `./install.sh`. |
328
+ | `./install.sh --check` warns `upload helper: uploads are off, but the service still runs` | Uploads are off — `install-options` says `UPLOADS=0`, or `--check` was given `--no-uploads` — while `lanes-helper` is still active: an install that turned uploads off could not stop it, or the service was started by hand. | `./install.sh --no-uploads` (it takes away what uploads-off leaves running; a bare `./install.sh` repeats the remembered choice, which may still be `UPLOADS=1`); by hand: `systemctl --user disable --now lanes-helper.service lanes-upload.path` (macOS: `launchctl bootout gui/$(id -u)/ai.beyondwork.lanes-helper`). |
329
+ | Client install or `--uninstall` warns `… could not be written (left as it was)` / `… the lanes agent hand-off block is still in it` | The login profile cannot be written: a read-only dotfiles checkout, a root-owned or immutable file, a full disk. The installer left it exactly as it was (a write that stopped part-way is put back from its backup, and no stray backup stays), still handed this terminal's agent over and finished; only the hand-off at later logins is missing. | Add the printed `systemctl --user import-environment SSH_AUTH_SOCK; systemctl --user try-restart lanes-helper` to your login file where you keep it (your dotfiles source), or make the file writable and re-run `./install.sh`; for `--uninstall`, delete the lines from `# >>> lanes agent hand-off >>>` to `# <<< lanes agent hand-off <<<` yourself. |
228
330
  | A live test suite prints `SKIP host is busy: …`, exit 75 | `tests/*.py` refuse to start above load 2 × cores or memory pressure (some avg10) 20 %: they add load and would fail for the wrong reason. | Run it later (`LANES_TESTS_FORCE=1` only on purpose). |
229
331
 
230
332
  ### Look, without adding load
@@ -349,14 +451,20 @@ Coexistence mode can be turned back on at any time with `touch ~/.local/state/la
349
451
  - Hyprland: one line `require("hypr.lanes")` at the end of `~/.config/hypr/hyprland.lua` + `~/.config/hypr/lanes.lua`
350
452
  (validated with `hyprctl configerrors`; backup `hyprland.lua.pre-lanes.*`). Pin it to a workspace by uncommenting
351
453
  the `o.window` line in `lanes.lua`.
352
- - Path: tailnet (`Host lanes`); after two immediate connect failures the client uses `lanes-public` (same sshd,
353
- public address, key-only) until the next healthy session.
454
+ - Path: tailnet (`Host lanes`: the server's MagicDNS name when the client resolved it at install time, else its tailnet
455
+ address); after two immediate connect failures the client uses `lanes-ip` (the tailnet address, written only beside a
456
+ name), then `lanes-public` (same sshd, public address, key-only), and goes back to `lanes` after the last, or on the
457
+ next healthy session.
354
458
  - Update: `git pull` in the checkout, then `./install.sh` again. Verify: `./install.sh --check`. Uninstall:
355
459
  `./install.sh --uninstall` (removes the Hyprland line and bind file, the launcher entry, the commands, `~/.config/lanes`,
356
460
  the uploads watcher and the `Include` line — each edited file is backed up first); then on the server
357
461
  `zellij kill-session <device>`.
358
- - New people: they run `./install.sh` from the package (see `README.md`); it prints their public key, and an admin
359
- appends that line to `~dev/.ssh/authorized_keys` on the server. Site facts that should not live in the package
462
+ - New people: with an invite code they run `npx @vibelord/lanes --invite <code>`; without one the installer files an
463
+ access request a member approves (both through `lanes-enrold`, W8 Track A, once the server runs it). Otherwise it
464
+ prints their public key, and an admin authorizes it on the server with `lanes keys add <device> <file.pub>` (or
465
+ `lanes keys add <device> -` and paste the line), which writes it into the lanes-managed block of
466
+ `~dev/.ssh/authorized_keys`; keys appended by hand the old way move into that block with `lanes keys adopt`. Site
467
+ facts that should not live in the package
360
468
  (the public fallback address) are served to authorized clients by `lanes client-config` from
361
469
  `~/.config/lanes/site.conf` on the server.
362
470
 
@@ -426,8 +534,9 @@ The sidebar's own colours and glyphs come from one token table (`libexec/tokens.
426
534
  every direct include of the lanes `ghostty.conf`, the palette's included. The installer therefore records the
427
535
  everyday chain's deepest include and places the palette keys (or the theme pair) behind as many one-line hop files
428
536
  (`ghostty-palette.1.conf`, …) as it takes to sit at that depth, where they load last; on Omarchy that is one hop,
429
- and the `say` line names it. A plain `./install.sh` re-run without `--palette` removes the include and all palette
430
- files: inherit-first is the default. NOT yet looked at on a real Ghostty (2026-10-04: no Mac or Omarchy client was
537
+ and the `say` line names it. `./install.sh --no-palette` removes the include and all palette files (the choice is
538
+ remembered in `~/.config/lanes/install-options`, so a plain re-run keeps whichever was chosen last; a fresh client
539
+ starts inherit-first). NOT yet looked at on a real Ghostty (2026-10-04: no Mac or Omarchy client was
431
540
  used; the installer was exercised against a fake `ghostty` that reports a version, fake everyday configs and a
432
541
  model of Ghostty's load order read from its source). DEF-1547 stays open until that is seen: run `--palette` on a
433
542
  Mac (Ghostty ≥ 1.1) and on Omarchy, and the window background must be `#101318` (the dark `surface` token).
@@ -487,14 +596,17 @@ only normalizes HOW a terminal reaches its lane:
487
596
  Without hooks the sidebar's agent glyph comes from Claude's session files and Codex's pane title. Hooks make it
488
597
  event-driven: Claude Code or Codex runs `~/.lz/bin/lane-signal <status>` on a turn event, which writes
489
598
  `~/.lz/state/hooks/<lane id>.json` in the container, and `lanes-statusd` prefers that over the other signals while
490
- its agent runs and its last event is under an hour old. Nothing installs a hook for you. (`~/.lz/state/<name>.json`
599
+ its agent runs and its last event is under an hour old. Nothing installs a hook unasked: `lanes onboard <login>
600
+ --container <name> --write` installs both (with the rest of a user's container, "A new user's container" above), or
601
+ install them one by one as below. (`~/.lz/state/<name>.json`
491
602
  itself is `lane-run`'s phase record of a zmx lane; the hook never writes there. A hooked agent in a zmx lane writes
492
603
  its sidecar under the zmx session name, which the sidebar does not show yet — DEF-1573.)
493
604
 
494
605
  1. `lanes install-container <name>` — puts `lane-signal` in `~/.lz/bin/` and the definitions in `~/.lz/hooks/`.
495
606
  2. `lanes hooks install --agent claude --container <name>` — prints the unified diff it would make to that
496
607
  container's `~/.claude/settings.json` (your own hooks and keys stay; an earlier lane-signal install is replaced,
497
- not duplicated). Read it. Then add `--write` (the old file is kept as `settings.json.lanes-bak-<time>`).
608
+ not duplicated; a current one stays where it is, whatever another tool put around it). Read it. Then add
609
+ `--write` (the old file is kept as `settings.json.lanes-bak-<time>`; nothing is written when there is no change).
498
610
  3. `lanes hooks install --agent codex --container <name>` — the same for `~/.codex/hooks.json`, plus a top-level
499
611
  `notify = [".../lane-signal", "done", …]` in `~/.codex/config.toml` only when that file has no top-level
500
612
  `notify` (an existing notifier is left alone). Codex asks you to review and trust new hooks before running
@@ -502,7 +614,9 @@ its sidecar under the zmx session name, which the sidebar does not show yet —
502
614
 
503
615
  Expect a hook to reach only agent sessions started after the install (both harnesses read their hook
504
616
  configuration at start — INFERRED, not measured here). `--home <dir>` targets a home directory instead of a
505
- container (for trying the merge on a copy). Undo: restore the `.lanes-bak-*` file, or delete the groups whose
617
+ container (for trying the merge on a copy; it is not checked against the container uid). With `--container`,
618
+ `--write` refuses when this account's UID or the home volume's owner is not 1000 — the agent could not read what it
619
+ would write — and a dry run says so above the diff (DEF-1727). Undo: restore the `.lanes-bak-*` file, or delete the groups whose
506
620
  command contains `lane-signal`.
507
621
 
508
622
  What each event writes (`agent{}` vocabulary):
@@ -629,10 +743,11 @@ the next poll re-folds today. For a look without the daemon: `LANES_THROTTLE_CON
629
743
 
630
744
  `lanes-notify.service` sends one push to an [ntfy](https://ntfy.sh) topic when a lane's agent ENTERS needs-you —
631
745
  `agent.status` becomes `needs_input` (`Needs you`), `needs_approval` (`Needs approval`) or `errored` (`Error`), the
632
- same set the sidebar's Needs you section and its OSC 9 use. It is the away-from-every-terminal half of the sidebar's
633
- OSC 9 ("Title and notifications" above): it reads only `~/.local/state/lanes/status.json` (checked every 3 s,
634
- re-read when its mtime changes) and `registry.json` for lane names; it never touches a lane, a session or the
635
- Throttle daemon.
746
+ same set the sidebar's Needs you section and its OSC 9 use — and one when someone files an enrolment request
747
+ (below). It is the away-from-every-terminal half of the sidebar's OSC 9 ("Title and notifications" above): it reads
748
+ only `~/.local/state/lanes/status.json` (checked every 3 s, re-read when its mtime changes), `registry.json` for
749
+ lane names and `enrol-requests/`; the one file it writes is `notify.enrol-seen.json` beside them. It never touches a
750
+ lane, a session, a request or the Throttle daemon.
636
751
 
637
752
  What a push says: `<lane name>: <agent task> — <status>` and, on a second line, `lanes-launch lane <id>` (the
638
753
  command that opens that lane from a client). No client registers a URL handler for that command, so a push is
@@ -657,15 +772,36 @@ When nothing is sent:
657
772
  A failed send (server down, `HTTP 429`, no network) is one journal line naming the lane and the failure class — the
658
773
  topic and the token are never logged — and is not retried.
659
774
 
775
+ **Enrolment requests (W8 A5).** When someone files a request from the installer (`POST /enrol/request`, "Enrolment
776
+ service" below), the service pushes once, titled `Enrol request`, priority 4: `<name or login> · <device>` and, on a
777
+ second line, the command that admits them, `lanes enrol approve <id>` (or open `/enrol/pending` on the phone). It
778
+ reads `~/.local/state/lanes/enrol-requests/` through the enrolment service's own reader (when that directory's
779
+ mtime changes, when the config changes, and once more after a change seen within 2 s), so it announces exactly the
780
+ requests `lanes enrol list` shows as pending: an approved, denied or expired request never pushes.
781
+
782
+ - No presence suppression: the person is waiting at their installer, so a desk device in use never mutes it.
783
+ - Once per request id, across restarts: the ids announced are in `~/.local/state/lanes/notify.enrol-seen.json`
784
+ (`{"version": 1, "announced": [...]}`, 0600, pruned to the requests still on disk). An id is recorded before its
785
+ push goes out, so a failed send (one journal line, `send failed for enrol request <id>: <class>`) is not retried.
786
+ Deleting the file (or a damaged one) announces the requests still pending once more.
787
+ - Without a valid `url` and `topic`, or while `token_file` cannot be read, nothing is announced or recorded: the
788
+ requests still pending when the config becomes valid are pushed then, whichever of `notify.conf` and the token
789
+ file you write last.
790
+
791
+ The push carries the requester's name or tailnet login and the device name to the ntfy server — the same audience as
792
+ the lane pushes, so the same operator gate.
793
+
660
794
  Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` in the server installer's environment — or
661
795
  `install/enable-studio-host.sh`'s, which re-renders every lanes unit — renders another path into this unit only),
662
- `key=value` lines, `#` comments, re-read on change:
796
+ `key=value` lines, `#` comments. It is re-read when `notify.conf` or the `token_file` it names changes — written,
797
+ replaced, rotated, removed or `chmod`-ed, in either order — and on every 3 s tick while sending is off, so a token
798
+ that becomes readable switches the pushes on within one tick and a removed one stops them:
663
799
 
664
800
  | Key | Default | Meaning |
665
801
  |---|---|---|
666
802
  | `url` | required | the ntfy server, `https://…` (self-hosted or `https://ntfy.sh`) |
667
803
  | `topic` | required | `[A-Za-z0-9_-]{1,64}`; on a public server the topic name is the only secret — pick a long random one |
668
- | `token_file` | unset | a file holding an ntfy access token (sent as a bearer). An unreadable file disables sending rather than sending unauthenticated. Keep the token in that file (`chmod 600`), never in `notify.conf` |
804
+ | `token_file` | unset | a file holding an ntfy access token (sent as a bearer). An unreadable file disables sending rather than sending unauthenticated (or with the token it held before). Keep the token in that file (`chmod 600`), never in `notify.conf` |
669
805
  | `quiet_when_device_active_s` | `120` | presence window above; `0` never suppresses |
670
806
  | `action_url` | unset | a URL template over `{lane}` (the lane id, URL-quoted), e.g. a page of yours that opens it; becomes the push's tap target and an **Open lane** button |
671
807
 
@@ -678,8 +814,9 @@ systemctl --user enable --now lanes-notify.service
678
814
  journalctl --user -u lanes-notify.service # send failures, one line each
679
815
  ```
680
816
 
681
- **Operator gate.** The unit is installed with the others and enabled by nothing: it sends lane names and agent
682
- task titles to the server you configure. Not yet run against any ntfy server or phone (unit-proven only).
817
+ **Operator gate.** The unit is installed with the others and enabled by nothing: it sends lane names, agent task
818
+ titles and enrolment requesters' names, logins and device names to the server you configure. Not yet run against any
819
+ ntfy server or phone (unit-proven only).
683
820
 
684
821
  **Not part of this:** answering a `PermissionRequest`-held approval from the phone. A push only says the lane needs
685
822
  you; approving from the notification needs a request/response channel back into the hook that holds the prompt — a
@@ -777,10 +914,25 @@ keep their source files for a retry.
777
914
  ## Web / phone
778
915
 
779
916
  The [installation guide](INSTALL.md#4-optional-safari--home-screen-access) owns setup. After the server has
780
- Caddy, Tailscale and operator permission, run `sh install/enable-phone.sh --host lanes` from an installed
781
- client, or `sh install/enable-phone.sh --local` on the host. The remote path uses the selected SSH key and
782
- the installed checkout shortcut, with no password fallback or agent forwarding. The token is printed only
783
- in the interactive caller's terminal. Existing tokens remain valid.
917
+ Caddy, Tailscale and operator permission, each user runs `lanes-launch web phone` (or `laptop`, or a device name)
918
+ from their client: `ssh -t lanes` into `lanes web <device>-phone --mobile --token --default-device <device>`, which
919
+ prepares the view, makes Tailscale Serve forward private HTTPS 8443 to the frontend when that mapping is missing
920
+ ("Tailscale Serve" below), prints the URL and a fresh token. On the host, `lanes web phone --mobile --token` does
921
+ the same for the device `phone`; `sh install/enable-phone.sh --host lanes` from an installed client is the older
922
+ one-shot (a thin wrapper: on the server it execs `lanes web <device> <layout> --token`). The remote path uses the
923
+ selected SSH key and the installed checkout shortcut, with no password fallback or agent forwarding. The token is
924
+ printed only in the interactive caller's terminal. Existing tokens remain valid.
925
+
926
+ QR code (W8 B5, LNS-49): before the token, `lanes web --token` draws a QR code of the URL, and only of the URL. The
927
+ token cannot be in it, because it does not exist until the pinned zellij prints it when `web_tokens.mint` runs it
928
+ afterwards. The code comes
929
+ from `qrencode -t ANSIUTF8` when the server has it on PATH and it answers within 10 s (optional; no install step adds
930
+ it); otherwise from `libexec/qr.py` (standard library only: byte mode, level M, versions 1–6, so URLs up to 106 bytes,
931
+ drawn in the same black-framed half blocks; `python3 libexec/qr.py URL` draws one by hand). No code is drawn for the
932
+ loopback URL (only the host opens it), for a URL over 106 bytes without qrencode, for a terminal encoding that cannot
933
+ show `█▀▄`, or with `--no-qr` (`lanes-launch web … --no-qr`). The `url:` line is the plain URL in every case. If a
934
+ phone opens nothing after a scan, check that the phone is on the tailnet before suspecting the code: the code only
935
+ carries the URL.
784
936
 
785
937
  On the phone, connect Tailscale and open the printed URL. Paste the token into the sign-in screen and choose
786
938
  **Share → Add to Home Screen**. Use **Workspaces**, **Lanes** and **Panes** to navigate. The keyboard opens only
@@ -821,7 +973,7 @@ The Mac is a client; serving happens on the workspace host. See [frontend mainte
821
973
 
822
974
  `lanes prepare phone --mobile` creates the view without attaching a terminal. Hidden mobile views wait for
823
975
  a real client before attaching; visible views accept 20 columns / 4 rows for the on-screen keyboard.
824
- Revoke a named token with `~/.local/share/lanes/bin/zellij-0.45.1 web --revoke-token NAME`.
976
+ Revoke a device's token with `lanes token revoke <device>` (see "Login tokens" above).
825
977
  An SSH-only phone client can run `lanes attach phone --mobile` instead.
826
978
 
827
979
  Laptop and tablet browsers: see the [installation guide](INSTALL.md#laptop-and-tablet-browser). Troubleshooting:
@@ -841,6 +993,281 @@ Laptop and tablet browsers: see the [installation guide](INSTALL.md#laptop-and-t
841
993
  - **`lanes web` printed a `127.0.0.1` URL**: the host has no Tailscale name. Connect Tailscale on the host;
842
994
  there is no public fallback.
843
995
 
996
+ ### Tailscale Serve
997
+
998
+ `lanes web` (not `--print-only`) makes Tailscale Serve forward `https:8443` to `http://127.0.0.1:8083` after it
999
+ prepared the view and enabled the services: it reads `tailscale serve status --json` and leaves a present mapping
1000
+ alone, adds a missing one with `tailscale serve --bg --https=8443 http://127.0.0.1:8083`, and never replaces a
1001
+ mapping of 8443 to something else. Its `serve:` line says `present` or `added`; `--print-only` shows what a real run
1002
+ would find and do without changing anything. Exit 64 means the operator has a step to take, printed on stderr:
1003
+
1004
+ - **Tailscale refused the account** (`Access denied`, or no answer in 30 s — for example when HTTPS is not enabled for
1005
+ the tailnet and `tailscale serve` waits for the admin console): `sudo tailscale set --operator=<account>`
1006
+ (`sudo/one-time-root.sh` does it), enable HTTPS in the Tailscale admin console if the output names it, run again.
1007
+ - **8443 serves something else**: free it with `tailscale serve --https=8443 off` if Lanes should have it, run again.
1008
+
1009
+ `lanes doctor` reports the same mapping and the two web units: `✓` when both units run and the mapping is present;
1010
+ `⚠` when the units were never enabled (no browser or phone view yet) or run without being enabled (a reboot loses
1011
+ them); `✗` when an enabled unit is not running (`journalctl --user -u lanes-web.service`) or, once a web unit is
1012
+ enabled, when the mapping is missing or points elsewhere. Without a Tailscale name on the host (tailscale logged out,
1013
+ no MagicDNS name, or `tailscale status` silent) `lanes web` touches no Serve config and prints the loopback URL, and
1014
+ doctor grades the mapping by the same rule: `⚠ Tailscale Serve not checked — this host has no Tailscale name`, never
1015
+ a `✗` that `lanes web` would not act on.
1016
+
1017
+ ### Login tokens
1018
+
1019
+ Zellij 0.45.1 cannot name a login token (`--token-name` is refused beside `--create-token`) and calls each new one
1020
+ `token_<number of tokens + 1>`. `lanes web --token` therefore runs `zellij web --create-token` on your terminal (the
1021
+ token is shown there only; Lanes never reads, prints or logs it), lists the token names before and after, and records
1022
+ the new name for the device in `~/.local/state/lanes/web-tokens.json` (`DM-LANES-WEB-TOKENS`; names and creation
1023
+ times only) — and, for a view that belongs to another machine (`lanes-launch web phone` opens `<device>-phone` with
1024
+ `--default-device <device>`), that machine as `base`, so `lanes offboard <device>` signs those views out too. Zellij keeps the tokens in `$XDG_DATA_HOME/zellij/tokens.db`, and both sides pin that root to
1025
+ `~/.local/share` (the account's passwd home, what systemd's `%h` names): `lanes-web.service` carries
1026
+ `Environment=XDG_DATA_HOME=%h/.local/share`, and `lanes web --token` / `lanes token` run zellij with the same
1027
+ `XDG_DATA_HOME`, whatever the calling shell or the systemd user manager exports. `lanes doctor` reads the running
1028
+ server's environment (`/proc/<MainPID>/environ`) and says `✓ lanes-web.service reads login tokens from …`, or `✗`
1029
+ when it reads another database — a server started from a unit rendered before the pin, under a user manager that
1030
+ carries its own `XDG_DATA_HOME`. Then a minted token is refused at sign-in and a revocation never reaches the server,
1031
+ so `lanes web --token` (exit 64) and every `lanes token` verb refuse until it is fixed: `lanes web <device>`
1032
+ re-renders the unit with the pin, then `systemctl --user restart lanes-web.service` (open browser views reconnect; a
1033
+ token minted into the other database must be minted again). When the server runs no process or its environment
1034
+ cannot be read, the token commands proceed (the pinned unit reads the pinned root on its next start) and doctor says
1035
+ what it could not compare. INFERRED, not measured on a host: that systemd resolves `%h` to the passwd home in the
1036
+ user manager's units; doctor's comparison is the measurement.
1037
+
1038
+ | Task | Command |
1039
+ | --- | --- |
1040
+ | See every token and its device | `lanes token list` |
1041
+ | Sign one device out | `lanes token revoke <device>` |
1042
+ | Sign a whole machine out (its views and phones too) when it leaves | `lanes offboard <device> --yes` (step 3 below) |
1043
+ | Revoke a token Lanes did not record (created before LNS-50, or with zellij directly) | `lanes token revoke --name token_N` |
1044
+ | Sign every browser and phone out | `lanes token revoke-all --yes` (without `--yes` it only says what it would revoke, exit 2) |
1045
+
1046
+ The naming rule makes one revocation hazardous: after revoking any token but the newest, Zellij's next name is taken
1047
+ and every `--create-token` fails with "Token name 'token_N' already exists". `lanes token revoke` refuses a
1048
+ revocation that would cause this and names `lanes token revoke-all --yes`, the coarse fallback; `lanes web --token`
1049
+ and `lanes token list` name the same repair when the database is already in that state. Revoking a token also deletes
1050
+ its sign-in sessions (measured in Zellij's database), so the browser signs in again when it next connects; whether an
1051
+ already open connection is cut has not been checked.
1052
+ ## Enrolment service (`lanes-enrold`, W8 A3)
1053
+
1054
+ A new machine gets its key into `~/.ssh/authorized_keys` without anyone editing that file: it redeems an invite code,
1055
+ or files a request a member approves. `lanes-enrold.service` runs `libexec/enrold.py` on **127.0.0.1:8184**; the
1056
+ `lanes-mobile` Caddy forwards `https://<host>.<tailnet>.ts.net:8443/enrol/*` to it (`handle_path /enrol/*` in
1057
+ `web/Caddyfile`, which strips `/enrol` and sits before the Zellij routes), behind the same Tailscale Serve as the
1058
+ phone view. Every server install enables it and restarts it, so it runs the installed code; it writes the lanes-managed
1059
+ block through `libexec/keys.py` under the same `members.lock` as `lanes keys`, and never `devices/`.
1060
+
1061
+ The unit, `lanes-enrold.service` (rendered from `systemd/lanes-enrold.service` by `install/services.py`), runs
1062
+ `python3 -S -E libexec/enrold.py` in `lanes.slice` with `Restart=on-failure` (10 s), `NoNewPrivileges=true`,
1063
+ `UMask=0077`, `MemoryHigh=48M` and `MemoryMax=96M`, and `WantedBy=default.target`. It has no `ProtectHome`: it writes
1064
+ `~/.ssh/authorized_keys` and `~/.local/state/lanes/`. `LANES_ENROL_PORT` moves it off 8184 (the Caddyfile's route
1065
+ must follow). Look at it with:
1066
+
1067
+ ```sh
1068
+ systemctl --user status lanes-enrold.service
1069
+ journalctl --user -u lanes-enrold.service -n 30 # "listening on 127.0.0.1:8184; request mode …; domains …"
1070
+ tail ~/.local/state/lanes/enrol.log # one line per redemption, request, decision, invite, offboard
1071
+ ```
1072
+
1073
+ A stopped service costs only enrolment: a joining machine's installer falls back to printing its public key (exit 3),
1074
+ and every member keeps logging in, because sshd reads the block, not the service.
1075
+
1076
+ | Endpoint | Answer (JSON, wire contract v1, additive only) |
1077
+ |---|---|
1078
+ | `POST /redeem {code, pubkey, device, client_version}` | `200 {device, owner, ssh_ready: true}`; **one** `403 {"error": "invalid-or-expired"}` for an unknown, expired, revoked or used code and for a key that is not one `ssh-ed25519` public key; `409 device-taken\|key-taken` only after a valid code; `429 rate-limited` + `Retry-After` |
1079
+ | `POST /request {pubkey, device, name?, client_version}` | `202 {id, expires_at}` (24 h; the same login, device and key again return the same request); `403 requests-disabled\|no-identity\|domain-not-allowed`; `429 too-many-requests` (3 open per login) |
1080
+ | `GET /request/<id>` | `{id, state: pending\|approved\|denied\|expired, expires_at}` — to the login that filed it; anyone else `404` |
1081
+ | `GET /site` | `{alias, host_name, host_key, version, request_mode, caller, source}` — `caller` is the login received, `source` the rate-limit source derived from the proxy chain |
1082
+ | `GET\|POST /pending` | an HTML page of open requests with **Approve** / **Deny**, for a tailnet login that owns a member record (see "Who approves" below) |
1083
+
1084
+ - **Identity** is what Serve injects for a user's device: `Tailscale-User-Login` / `Tailscale-User-Name`. `/site`
1085
+ echoes the login it received as `caller`. **Request mode** (`/request`, `/pending`, and a redeemed invite owned by
1086
+ the redeemer's own login — login, email and display name all the caller's, whoever the invite was minted for; the
1087
+ invite's own name and email are kept in the member record as `invited_as`, DEF-1732) is decided, in this order, by `LANES_ENROL_REQUEST_MODE` in the unit's environment (`1`
1088
+ on, `0` invite-only — a drop-in, below); by the pin `lanes enrol mode request|invite` writes to
1089
+ `~/.local/state/lanes/enrol-mode` (read on every request: no restart, and kept across restarts and reinstalls; a pin
1090
+ that cannot be read is invite-only and the journal says so once); else, unpinned (`auto`), by the FIRST request that
1091
+ came through Tailscale Serve — its `X-Forwarded-For` names a tailnet address — after the process started: on when it
1092
+ carried that header set. Pin it after the operator verification below. Without it, a redeemed invite takes its owner's login from
1093
+ the invite: its `email` when that is an address, else its `name` when that is itself a login (a handle such as
1094
+ `anna`, no blanks). A display name such as "Anna Berg" is never a login, so an invite that names only that records
1095
+ the member with **no owner** — the journal says so (`invite <id> names no login … lanes keys owner <device>
1096
+ <login>`), the member keeps its `invite_id` and the invite file keeps the name. Mint invites for an invite-only
1097
+ server with an email. A probe of `127.0.0.1:8184`, or of Caddy from this host, decides nothing (DEF-1725); a
1098
+ tagged tailnet device's request through Serve does (no identity: off until the next restart) — which is why the
1099
+ mode is pinned.
1100
+ - **Trust boundary (DEF-1645, open).** Serve replaces a forged identity header only on the tailnet path. Caddy
1101
+ (`127.0.0.1:8083`) and enrold (`127.0.0.1:8184`) cannot tell Serve from any other local client, so a process on
1102
+ this server that can reach loopback can send the headers itself. In request mode it can file a request under one
1103
+ login and approve it on `/pending` under a member's login, which puts its key in the block. Unpinned, the same
1104
+ forged headers (with a forged tailnet `X-Forwarded-For`) sent as the first request after a (re)start also switch
1105
+ request mode on; `lanes enrol mode invite` takes that away. It can pick its own
1106
+ `X-Forwarded-For` too, so the per-source limit does not bind it. Invites are not affected: a code is a secret, a
1107
+ header is not. This grants nothing when only the workspace account and root run processes in the host's network
1108
+ namespace, because that account already owns `authorized_keys`. If another account or a host-network container
1109
+ does (`getent passwd` for login accounts, `docker ps --filter network=host`), pin `lanes enrol mode invite` (or
1110
+ set `LANES_ENROL_REQUEST_MODE=0`, the drop-in below, which wins over any pin). enrold alone cannot close this: it
1111
+ needs a Serve→Caddy hop no other local process can use.
1112
+ - **Domain gate:** `LANES_ENROL_DOMAINS` (default `beyondwork.ai`; comma or space separated, `*` = any) applies to
1113
+ `/request` and `/pending`, not to invites (an invite is the admin's decision).
1114
+ - **Controls:** a code is compared, as sha256, against every live invite with `hmac.compare_digest` and no early exit;
1115
+ only ed25519 keys (checked byte for byte); device names are `L.IDENT` ≤ 32 (anchored at the end of the string: a
1116
+ trailing newline is refused), display names `L._title` plus the member store's text rule; bodies ≤ 8 KiB (`413`);
1117
+ 5 failed redemptions per source in 10 minutes, counted when the attempt starts (a malformed body counts too; a
1118
+ `409 device-taken|key-taken` does not, since only a valid code reaches it and its holder retries with another
1119
+ `--device`, DEF-1731). The
1120
+ source is the last `X-Forwarded-For` hop before the loopback proxies: Tailscale Serve sets that header to the
1121
+ client's tailnet address, and the `/enrol/*` route in `web/Caddyfile` trusts Serve's loopback peer
1122
+ (`trusted_proxies 127.0.0.0/8`), so Caddy keeps it and appends `127.0.0.1` — one bucket per tailnet device; one
1123
+ device's wrong codes never pause another's redemption. Both proxy behaviours are read from Tailscale's and Caddy's
1124
+ source, not yet measured on ceobox: `/site` shows the `source` enrold derived (the operator check below). Were the
1125
+ header lost, every device would arrive as `127.0.0.1` and share ONE bucket (fail closed). A `/pending` form carries
1126
+ an HMAC token bound to the member and the request (per process: after a restart, reload the page), and a POST
1127
+ decides only when the browser's `Sec-Fetch-Site` says `same-origin` or `none`: a cross-site or same-site POST, and
1128
+ one without the header (curl, a script), is refused (DEF-1730). Every browser that reaches the page over Serve's
1129
+ HTTPS sends it; from a terminal, decide with `lanes enrol approve|deny` instead.
1130
+ - **Stores** under `~/.local/state/lanes/`: `invites/<id>.json` (minted by `lanes invite`, W8 A4; a redemption adds
1131
+ `redeemed_by {device, login, pubkey_sha256}` and `redeemed_at`), `enrol-requests/<id>.json`, `members/<device>.json`
1132
+ (via `invite`/`request`; `approved_by` = the approving tailnet login on `/enrol/pending`, `cli[:<device>]@<address>`
1133
+ from `lanes enrol approve`, null for an invite minted over SSH — its `minted_by.login` is null). The invite is used
1134
+ up before the key is written; when the key cannot be written and the block demonstrably lacks it, the invite and the
1135
+ member file are put back (`503`).
1136
+ - **Log:** `~/.local/state/lanes/enrol.log` (0600, append-only), one line per attempt —
1137
+ `<UTC> <redeem|request|approve|deny|invite|invite-revoke|offboard> <id> <device> <login> fp=<SHA256:…> <result>`,
1138
+ `-` for an unknown field. No code is ever written, not even the id half of a code that matched nothing. Service
1139
+ errors go to the journal. A redeem or approval re-renders the whole block from the member files that load, so a
1140
+ member file that no longer validates (a hand edit) loses its line — fail closed — and that is logged, one
1141
+ `<redeem|approve> <id> <its device> <login> fp=- dropped-member` line per such file before the attempt's own, with
1142
+ the reason in the journal (DEF-1729). `lanes keys list` names the file; fix or remove it, then `lanes keys adopt`
1143
+ re-renders the block.
1144
+
1145
+ **Who approves.** `/enrol/pending` lets in a tailnet login that some member record names as its owner (and, in
1146
+ request mode, passes the domain gate). Keys added by hand — `lanes keys adopt` (the plan's bootstrap step) or
1147
+ `lanes keys add` — record **no owner**, so after the bootstrap nobody can approve on the web yet. Name yourself on
1148
+ one of your own devices, with the login exactly as `/site` echoes it as `caller`:
1149
+
1150
+ ```sh
1151
+ lanes keys list # find your device, e.g. my-mac
1152
+ lanes keys owner my-mac you@example.com --name 'Your Name'
1153
+ ```
1154
+
1155
+ `lanes keys owner <device> <login> [--name NAME] [--email ADDR]` writes only `members/<device>.json` (the block
1156
+ carries no owner) and also corrects the owner an enrolment recorded. The `/pending` refusal for a login that owns no
1157
+ member prints the same command.
1158
+
1159
+ Settings go in a drop-in (a re-install rewrites the unit, never its drop-ins):
1160
+
1161
+ ```sh
1162
+ systemctl --user edit lanes-enrold.service # [Service] Environment=LANES_ENROL_DOMAINS=beyondwork.ai,example.com
1163
+ systemctl --user restart lanes-enrold.service
1164
+ journalctl --user -u lanes-enrold.service # "listening on 127.0.0.1:8184; request mode …; domains …"
1165
+ ```
1166
+
1167
+ **Caddy picks up the route** from the Caddyfile of the tree it was started from. `./install.sh --server` restarts a
1168
+ running `lanes-mobile` (`try-restart`; browser clients reconnect), because an npm update stages a new tree and a reload
1169
+ would re-read the old file. After editing the Caddyfile in place, `systemctl --user reload lanes-mobile.service` sends
1170
+ Caddy `SIGUSR1` (the unit's `ExecReload`); not every Caddy version re-reads its config on that signal — if
1171
+ `/enrol/site` still answers with the Zellij page, `systemctl --user restart lanes-mobile.service`. The route needs
1172
+ **Caddy 2.5 or later** (`reverse_proxy { trusted_proxies … }`); an older Caddy refuses the whole Caddyfile with
1173
+ `unrecognized subdirective trusted_proxies` and `lanes-mobile` does not start (`caddy version`; upgrade Caddy).
1174
+
1175
+ **Operator verification (plan gate 3, not yet run on ceobox).** Right after the service (re)starts, from ANOTHER
1176
+ tailnet device signed in as yourself:
1177
+
1178
+ ```sh
1179
+ curl -s -H 'Tailscale-User-Login: evil@x' -H 'X-Forwarded-For: 6.6.6.6' https://<name>:8443/enrol/site
1180
+ ```
1181
+
1182
+ It must echo your REAL login as `caller`, `"request_mode": true`, and as `source` that device's own tailnet address
1183
+ (`100.x.y.z`, `tailscale ip -4` on it). Then pin the result on the server, so no restart has to repeat it:
1184
+ `lanes enrol mode request` when the check passed, `lanes enrol mode invite` when it did not or when another account
1185
+ shares the host's network namespace (trust boundary above); `lanes enrol mode` says what is pinned and names a unit
1186
+ drop-in that overrides it. `"caller": "evil@x"` means Serve passed the forged header through: `lanes enrol mode
1187
+ invite` (or `LANES_ENROL_REQUEST_MODE=0` in the drop-in and a restart). `"caller": null` with `"request_mode": false`
1188
+ means Serve injects no identity for that device (a tagged device, for example): enrolment stays invite-only.
1189
+ `"source": "6.6.6.6"` means the forged X-Forwarded-For survived Serve (anyone could pick a fresh rate-limit bucket):
1190
+ report it; `"source": "127.0.0.1"` means Caddy dropped Serve's header and every device shares one bucket (check the
1191
+ Caddy version and that `lanes-mobile` runs the installed Caddyfile). Unit-proven only so far
1192
+ (`tests/unit/test_enrold.py`, a real server on a loopback port, fed the X-Forwarded-For the shipped Caddyfile
1193
+ produces); the client side (`npx @vibelord/lanes --invite`, W8 B3) is a separate task, and the operator verbs are the
1194
+ next section.
1195
+
1196
+ ## Invite, approve, offboard (`lanes invite|enrol|offboard`, W8 A4)
1197
+
1198
+ `libexec/enrol.py` is the operator's half of enrolment: it writes `invites/` and calls the enrolment service's own
1199
+ functions (`enrold.decide`, the code hash, the store readers), under the same `members.lock`, so the CLI, the service
1200
+ and `lanes keys` never interleave.
1201
+
1202
+ **Invite.** `lanes invite <name> [--email ADDR] [--expires 7d] [--workspaces a,b] [--tailscale-link URL] [--md]`
1203
+ mints `LN-<id4>-<secret12>` over `enrold.CODE_ALPHABET` (31 characters, no `0 1 i l o`; the secret is 12 characters
1204
+ from Python's `secrets`, about 59 bits) and stores `invites/<id>.json` = `{id, code_sha256, minted_at, minted_by
1205
+ {device, login, unix_user}, expires_at, name, email, workspaces, redeemed_by, revoked_at}` (0600 in a 0700 directory).
1206
+ The code itself is in no file and no log; it is printed once, in the welcome kit, and only when stdin and stdout are
1207
+ both terminals — otherwise exit `2` and nothing is minted, so a code never lands in a pipe, a CI log or a scrollback
1208
+ that is not a person's. `minted_by.device` is `$LANES_DEVICE` when that is a device name (a claim: nothing sets it
1209
+ yet), `login` is null (every SSH login is the one Unix account; the CLI never claims a tailnet login), `unix_user`
1210
+ the account. The member a redemption writes therefore has `approved_by` null. `lanes invite list` prints each invite's
1211
+ state; `lanes invite revoke <id>` sets `revoked_at` (enrold then answers that code exactly like an unknown one) and
1212
+ refuses a redeemed invite with the `lanes offboard` command for its device.
1213
+
1214
+ The kit's steps 2 and 3 are the client verbs of W8 B3/B4 (`npx @vibelord/lanes --invite`, `lanes-launch web
1215
+ phone|laptop`). Two opt-in extras, each with its own Lanes key file — never another tool's key, never the
1216
+ environment:
1217
+
1218
+ | File | Effect | Notes |
1219
+ |---|---|---|
1220
+ | `~/.config/lanes/resend.key` | `--email` also mails the plain kit through Resend (`POST https://api.resend.com/emails`, bearer auth) | The key on its first line; `from=Lanes <lanes@your-domain>` on another sets the sender (a Resend-verified domain). Without it the sender is Resend's test sender, which delivers only to the Resend account's own address: Resend answers 403 for anyone else, and the note names the missing `from=` line (a 403 with a `from=` line names the domain verification) |
1221
+ | `~/.config/lanes/tailscale-api.key` | Without `--tailscale-link`, step 1 becomes a freshly minted Tailscale user invite (`POST https://api.tailscale.com/api/v2/tailnet/-/user-invites`, `[{"role": "member"}]`) | A **user-owned** API access token (Tailscale refuses OAuth clients here: an invite needs an inviting user). The invitee's email is NOT sent, so Tailscale returns `inviteUrl` (it omits it for an address in the tailnet's own domain) and emails nothing: the kit stays the one message |
1222
+
1223
+ Each request is bounded at 10 s for the whole exchange; a missing key, a refusal or a timeout is one note on stderr
1224
+ and the kit is printed all the same. Keep both files `0600` (a broader mode is warned about). Both wire shapes are
1225
+ read from the providers' published API references (2026-10-05) and unit-tested against fakes; neither was called
1226
+ live from this repository.
1227
+
1228
+ **Approve or deny a request.** `lanes enrol list [--all]` shows pending requests (`--all` adds approved, denied and
1229
+ expired ones) with the key's fingerprint. `lanes enrol approve <id> [--as <device>]` / `deny <id>` call
1230
+ `enrold.decide` — the same function `/enrol/pending` calls, so approval writes `members/<device>.json` (via
1231
+ `request`) and its line in the managed block. The approver recorded (`approved_by`, `decided_by`) is
1232
+ `cli[:<device>]@<address>`: the address of `$SSH_CLIENT` (`local` on the console, `unknown` when it is not an
1233
+ address) and, with `--as`, a member device you name as yours. Over SSH everybody is the same account, so this records
1234
+ where the decision came from, not who made it; it is never a tailnet login and admits nobody to `/enrol/pending`.
1235
+ The phone path (`/enrol/pending`) records the real tailnet login. With `lanes-notify.service` on, each new request
1236
+ also arrives as one push naming the requester, the device and this command ("Push notifications (opt-in)").
1237
+
1238
+ **Pin request mode.** `lanes enrol mode request|invite|auto` writes (or, for `auto`, removes)
1239
+ `~/.local/state/lanes/enrol-mode` = `{version: 1, mode, set_at, set_by}` (`DM-LANES-ENROL-MODE`, 0600; `set_by` is
1240
+ `cli@<address>` as above) and logs a `mode` line in `enrol.log`; the running service reads it at its next request.
1241
+ `lanes enrol mode` alone says what is pinned. Both warn when systemd reports a `LANES_ENROL_REQUEST_MODE` in the
1242
+ unit's environment, because that drop-in wins over the pin.
1243
+
1244
+ **Offboard a machine.** `lanes offboard <device> --yes`, in your own terminal (without `--yes`, or piped, it prints
1245
+ the plan, changes nothing and exits `2`), runs in this order and says each step:
1246
+
1247
+ 1. **key** — `lanes keys revoke <device>`: out of the managed block, then `members/<device>.json`. A failure stops
1248
+ here (exit `1`, nothing after it ran); running offboard again finishes it.
1249
+ 2. **device** — `lanes device forget <device>` when `devices/<device>.json` exists, so `lanes ensure` does not bring
1250
+ the view back. A failure stops here.
1251
+ 3. **web tokens** — the machine's browsers and phones are signed out (`web_tokens.offboard`, under the token lock):
1252
+ every login token `web-tokens.json` ("Login tokens" above) attributes to `<device>`, to one of its windows, or to a
1253
+ view minted with `<device>` as its machine (`lanes-launch web phone|laptop` record it as `base`), is revoked with
1254
+ `web --revoke-token token_<n>` — never another machine's. Zellij 0.45.1 names every token `token_<n>` (measured
1255
+ 2026-10-05), so the record decides, not the name. It runs like every token command: the pinned binary, the pinned
1256
+ `XDG_DATA_HOME`, refused while the running `lanes-web.service` reads another token database. Unlike `lanes token
1257
+ revoke`, it revokes even when that leaves zellij unable to create the next token (DEF-1711), then warns with `lanes
1258
+ token revoke-all --yes`, which repairs creation. A token nobody can attribute (minted before 1.1.0, or outside `lanes
1259
+ web --token`) is warned about with `lanes token revoke --name NAME`. A failure here (zellij does not answer, the map
1260
+ cannot be read, a revocation fails, the database mismatch) makes the offboard incomplete (exit `1`); the view is
1261
+ still ended, and running offboard again finishes it.
1262
+ 4. **view** — the view session `<device>` is killed and deleted (as `lanes rebuild` does, without the reconnect stamp
1263
+ and without recreating it); no lane, agent or container is touched. A view that cannot be ended makes the offboard
1264
+ incomplete (exit `1`).
1265
+
1266
+ Windows of the machine (`mac-2`, whose `devices/*.json` records `base` = the device) share its now-revoked key and are
1267
+ signed out in step 3; their views are named, not ended — offboard each by name to end its view. An SSH session already
1268
+ open with the key keeps running until it ends (step 1 says so). `enrol.log` gets one `offboard` line with the key's
1269
+ fingerprint and `ok` or `incomplete`.
1270
+
844
1271
  ## Safety rules baked into the code (do not remove)
845
1272
 
846
1273
  - tmux is always called with `-N` (`attach-session`, `new-session` and `start-server` on a missing socket start