@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.
- package/README.md +55 -15
- package/bin/lanes-install.mjs +8 -2
- package/lib/plan.mjs +34 -1
- package/package.json +2 -2
- package/payload/lanes/README.md +91 -33
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +994 -49
- package/payload/lanes/client/lanes-launch +160 -14
- package/payload/lanes/client/ssh.stanza.in +11 -2
- package/payload/lanes/docs/ARCHITECTURE.md +88 -3
- package/payload/lanes/docs/INSTALL.md +297 -43
- package/payload/lanes/docs/RUNBOOK.md +458 -31
- package/payload/lanes/docs/USERGUIDE.md +1612 -0
- package/payload/lanes/install/enable-phone.sh +8 -20
- package/payload/lanes/install/enable-web.sh +2 -2
- package/payload/lanes/install/host-install.sh +6 -0
- package/payload/lanes/install.sh +888 -52
- package/payload/lanes/libexec/enrol.py +695 -0
- package/payload/lanes/libexec/enrold.py +1264 -0
- package/payload/lanes/libexec/keys.py +644 -0
- package/payload/lanes/libexec/lanes_lib.py +14 -6
- package/payload/lanes/libexec/members.py +297 -0
- package/payload/lanes/libexec/notify.py +146 -21
- package/payload/lanes/libexec/qr.py +217 -0
- package/payload/lanes/libexec/site_edit.py +237 -0
- package/payload/lanes/libexec/web_tokens.py +315 -0
- package/payload/lanes/site/README.md +13 -1
- package/payload/lanes/site/beyondwork/site.conf +3 -0
- package/payload/lanes/site/site.example.conf +5 -0
- package/payload/lanes/sudo/one-time-root.sh +1 -1
- package/payload/lanes/systemd/lanes-enrold.service +22 -0
- package/payload/lanes/systemd/lanes-notify.service +4 -4
- package/payload/lanes/systemd/lanes-web.service +4 -0
- package/payload/lanes/web/Caddyfile +10 -0
- package/payload/lanes/web/README.md +8 -2
- package/payload/lanes/workspaces.example/README.md +6 -1
- 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
|
|
5
|
-
its parts
|
|
6
|
-
|
|
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
|
|
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
|
|
122
|
-
|
|
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
|
|
183
|
-
`lanes
|
|
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
|
|
353
|
-
|
|
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
|
|
359
|
-
|
|
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.
|
|
430
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
633
|
-
OSC 9 ("Title and notifications" above): it reads
|
|
634
|
-
re-read when its mtime changes)
|
|
635
|
-
|
|
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
|
|
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
|
|
682
|
-
|
|
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,
|
|
781
|
-
client
|
|
782
|
-
the
|
|
783
|
-
|
|
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
|
|
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
|