@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
@@ -3,6 +3,7 @@
3
3
  The same `lanes/` directory installs either a desktop client or the workspace server. A deployment needs
4
4
  one server and one client installation per desktop. Safari uses the optional web services on the server.
5
5
  Configuration identifies your server and workspaces; credentials and generated runtime state stay outside Git.
6
+ For daily use after installation, see the [user guide](USERGUIDE.md).
6
7
 
7
8
  ## 1. Prepare a workspace host
8
9
 
@@ -13,6 +14,12 @@ Install missing packages using your distribution's package manager. The installe
13
14
  Run the server as an unprivileged workspace account with Docker access. Container homes must be mounted at
14
15
  `/home/coder` with UID 1000; the host account must be able to read the mounted files and reach those users'
15
16
  Unix sockets through `/proc`. This is the current container contract, including on a host provisioned without Coder.
17
+ The host account must also have UID 1000, the container's. `lanes install-container`, `lanes hooks install` and
18
+ `lanes onboard` write into the container homes as the host account, so a file they create belongs to the host
19
+ account's UID. With any other UID the agent inside cannot read the private ones (`.claude/settings.json` is 0600)
20
+ and cannot write into a directory the host account created. On such a host `lanes onboard` stops at its first check
21
+ and writes nothing (exit 1), `lanes hooks install --write` into a container refuses with the reason, and
22
+ `lanes doctor` warns about each container. Run them from a host account with UID 1000.
16
23
  Containers need Bash, Python 3 and tmux. Use compatible tmux versions on the host and containers (tested with
17
24
  tmux 3.4): the host client attaches directly to the container server. Zmx is installed as an optional holder,
18
25
  not a migration of existing lanes.
@@ -39,20 +46,31 @@ only if no configuration exists. Host protection, temporary-file deletion and we
39
46
 
40
47
  ## 2. Configure workspaces and editor sync
41
48
 
42
- Start from the included configuration, then edit container names and repository paths:
49
+ Add one board per repository. `lanes workspaces add` writes the site catalog and the board's manifest for you:
50
+ `catalog.json` gains `{id, title, manifest}` and a new `workspaces.<id>.json` names the container and the
51
+ repository's `.vscode/terminals.json`. It prints the diff first and writes only with `--write`:
43
52
 
44
53
  ```sh
45
- mkdir -p ~/.config/lanes/workspaces
46
- cp -R workspaces.example/. ~/.config/lanes/workspaces/
47
- $EDITOR ~/.config/lanes/workspaces/catalog.json
48
- $EDITOR ~/.config/lanes/workspaces/workspaces.product.json
49
- $EDITOR ~/.config/lanes/workspaces/workspaces.research.json
54
+ lanes workspaces add product --title Product --container my-workspace-container --repo /home/coder/product
55
+ lanes workspaces add product --title Product --container my-workspace-container --repo /home/coder/product --write
50
56
  lanes install-container my-workspace-container
51
57
  lanes sync
52
58
  lanes doctor
53
59
  ```
54
60
 
55
- Remove unused example boards from the catalog. A complete terminal registry is provided in
61
+ The catalog goes where the server reads it: `$LANES_WORKSPACES`, else `~/.config/lanes/workspaces/` (created on a
62
+ fresh host), or a single-site checkout's own `workspaces/catalog.json`. The first write keeps a
63
+ `catalog.json.bak-<UTC>Z` copy beside it. `--repo` is the repository folder as
64
+ the container sees it, under `/home/coder`. The title defaults to the id, and every tab title must be unique.
65
+ A repository belongs to one board: a container and folder that another board already declares is refused before
66
+ anything is written, because `lanes cursor-sync` would rewrite that repository's `.vscode/terminals.json` for both
67
+ boards.
68
+ Running the command again for the same id changes nothing. If the catalog already describes that id differently,
69
+ the command prints the differences, writes nothing and exits 1. Boards with several repositories, prefixes,
70
+ `remoteShell`, a monitor tab or SSH workspaces are still edited by hand: start from
71
+ [`workspaces.example/`](../workspaces.example/README.md) (`cp -R workspaces.example/. ~/.config/lanes/workspaces/`).
72
+
73
+ A complete terminal registry is provided in
56
74
  [`workspaces.example/terminals.example.json`](../workspaces.example/terminals.example.json): adapt it to the
57
75
  repository's `.vscode/terminals.json` inside the container. Every terminal needs an explicit tmux socket/session
58
76
  binding; there is no guessed command parsing. See [the configuration reference](../workspaces.example/README.md).
@@ -69,6 +87,80 @@ A lane that has not started is shown as absent. Open its terminal in Cursor / VS
69
87
  The viewer itself only attaches. `lanes cursor-commit --push` is a separate, explicit publishing action;
70
88
  it is not run by installation or automatic sync.
71
89
 
90
+ ### Onboard a user's container
91
+
92
+ `lanes onboard` does every server-side step for one user's workspace container in one command. Without `--write` it
93
+ prints each diff and each command it would run, and changes nothing:
94
+
95
+ ```sh
96
+ lanes onboard anna --container ws-anna --workspace anna-tools --repo /home/coder/tools
97
+ lanes onboard anna --container ws-anna --workspace anna-tools --repo /home/coder/tools --write
98
+ ```
99
+
100
+ Both forms first check the container with `docker inspect` (the `inspect` step), which only reads. An unknown
101
+ container, for example a mistyped `--container`, stops the run before anything is written, so no board names a
102
+ container that does not exist. The same step compares the host account's UID and the home volume's owner with the
103
+ container's UID 1000 ([section 1](#1-prepare-a-workspace-host)). On a mismatch the step fails and the run stops
104
+ there, in a dry run too: no board, no contract, no hooks, no Throttle step and no other write. Every later step is
105
+ listed as `not-run` with the reason, and the exit code is 1, as for an unknown container. Run it again from a host
106
+ account with UID 1000. With `--write` it then applies these steps in order:
107
+
108
+ 1. **Board.** Runs `lanes workspaces add`, when `--workspace` and `--repo` are given. `--title` defaults to the id
109
+ or to the title the board already has. A board that already disagrees stops the run before the container is
110
+ touched.
111
+ 2. **Contract.** Runs `lanes install-container`.
112
+ 3. **Agent hooks.** Runs `lanes hooks install --write`, first for Claude and then for Codex. Your own hooks and
113
+ keys are kept, with a backup of each changed file. A Lanes hook group that is already installed stays where
114
+ it is, even when Throttle's setup has added its own group around it.
115
+ 4. **Throttle.** Asks the container, as UID 1000, whether it has a `throttle` and whether Throttle is configured
116
+ there (`~/.config/throttle/config.toml` exists). Only a configured Throttle gets `throttle setup
117
+ --install-clients`, which then runs without a person. A first setup needs one: it signs in to claude.ai with a
118
+ code pasted on a terminal, or adopts the container's own Claude login as a pool account. So when Throttle is
119
+ missing or not configured, the command prints the step to run once on a terminal,
120
+ `docker exec -it -u 1000 <container> sh -lc 'throttle setup --install-clients'`, records it as `manual` and
121
+ continues, because Lanes runs without Throttle. The setup output is not shown: it names accounts and credential
122
+ sources. When the server's
123
+ `~/.config/lanes/site.conf` sets `LANES_THROTTLE_REMOTE_URL`, the command also prints a laptop step: `throttle remote join` with a
124
+ pairing code from the operator.
125
+ 5. **Sync.** Runs `lanes sync`, so the registry knows the board.
126
+ 6. **Recipes.** Runs `lanes cursor-sync <id> --canonicalize --write` when `<repo>/.vscode/terminals.json` exists
127
+ in the container. When the file is missing, the command points at
128
+ [`terminals.example.json`](../workspaces.example/terminals.example.json) instead. When this step rewrote the
129
+ file, a second `lanes sync` copies the new recipes into the container's `~/.lz/registry.json`, which
130
+ `lane open` reads.
131
+ 7. **Doctor.** Runs `lanes doctor`. Its result is reported but does not stop onboarding.
132
+
133
+ The run ends with a `key: value` table and a receipt in `~/.local/state/lanes/onboard/<login>-<UTC>.json`:
134
+ `{version, login, container, workspace, at, steps: [{name, status, detail}]}`. A status is one of `planned`,
135
+ `changed`, `unchanged`, `ok`, `skipped`, `manual` (a step left for a person, with its command), `warn`, `failed` or
136
+ `not-run`. When a step stops the run, every step after it is still listed, as `not-run`, in the table and the receipt.
137
+ The exit code is 1 only when a step failed. Running the command again changes nothing that is already done.
138
+ `--no-mcp` leaves out `lane-mcp`, the in-container MCP server, which has not shipped yet. Until it does, the
139
+ receipt records that step as skipped.
140
+ Each user's board still shows on every device's view. A per-device subset of boards is a separate follow-up.
141
+
142
+ ### Laptop agents on the Throttle pool (operator-gated)
143
+
144
+ Lanes agents run in the containers and reach Throttle there. Agents a user runs on their own laptop can use the same
145
+ pool over the tailnet, without a public relay: Throttle's `throttle remote share --tailnet` and `throttle remote join`
146
+ (source-current, unpublished; the Throttle user guide's "Remote clients over Tailscale" section, which lands with that
147
+ change, in [docs/throttle-userguide.md](../../docs/throttle-userguide.md)). Lanes only prints the laptop step; every
148
+ operator step stays with the operator, because each one changes the production daemon:
149
+
150
+ 1. Decide to share. The remote key is also the daemon's control-plane admin credential: hand it only to people you
151
+ would give operator access.
152
+ 2. Where the daemon's configuration lives, run `throttle remote share --tailnet` (the key from `--key-file` or stdin,
153
+ never an argument). It sets only `server.remote_key` and prints the rest.
154
+ 3. Do the printed steps: `throttle setup --install-clients` (loopback clients need the managed token once a remote key
155
+ is set), `server.host` for a containerised daemon, `throttle restart`, and on the host `tailscale serve --bg
156
+ --https=10000 http://<address>:<data port>`. Lanes' own front keeps 8443.
157
+ 4. Put the resulting URL in the server's `~/.config/lanes/site.conf` as
158
+ `LANES_THROTTLE_REMOTE_URL=https://<host>.<tailnet>.ts.net:10000`.
159
+
160
+ From then on `lanes onboard` prints, and records as a `manual` step in its receipt, `on the user's laptop: throttle
161
+ remote join <pairing code>`; the pairing code comes from the operator's share. Without that line in `site.conf`,
162
+ onboarding says nothing about laptops. No Lanes command shares the pool or touches the daemon.
163
+
72
164
  ## 3. Give clients a site profile
73
165
 
74
166
  Copy [`site/site.example.conf`](../site/site.example.conf) and fill in the server's reachable hostname,
@@ -83,10 +175,9 @@ The actual known_hosts line has three fields: alias, `ssh-ed25519`, and the base
83
175
  `ssh_host_ed25519_key` (the private key). [Site instructions](../site/README.md) cover profiles and updates.
84
176
  The supplied `site/beyondwork/` profile is an explicit choice for the existing team server, not a default.
85
177
 
86
- On each Mac or Linux desktop, install from npm (members of the `@vibelord` npm organization; Node.js 18+):
178
+ On each Mac or Linux desktop, install from npm (public package, no npm login; Node.js 18+):
87
179
 
88
180
  ```sh
89
- npm login # once per machine, or a read-only token in ~/.npmrc
90
181
  npx @vibelord/lanes --site beyondwork # bundled team profile; or --site /path/to/site.conf
91
182
  ```
92
183
 
@@ -101,57 +192,202 @@ cd bw-tools/lanes
101
192
 
102
193
  OpenSSH is required. Install Ghostty for a dedicated window, or run `lanes-launch` in your existing terminal.
103
194
  Linux also supports Foot, plus optional Omarchy/Hyprland shortcuts. Tailscale is required when your profile uses
104
- a tailnet address. The installer installs its own configuration; your normal terminal remains usable.
105
-
106
- Authorize the printed public key in the server account's `~/.ssh/authorized_keys`. The client never edits that
107
- file remotely. Then `./install.sh --check` and `lanes-window`. Useful options:
195
+ a tailnet address. When the profile's `LANES_SERVER_NAME` ends in `.ts.net`, the installer checks Tailscale before
196
+ the server: without it, it prints `tailscale: not installed — macOS: brew install --cask tailscale · Linux:
197
+ https://tailscale.com/download` and exits 4 (the client is installed); on a logged-out machine it prints
198
+ ``tailscale: needs login — opening `tailscale login`… (approve in the browser)``, runs `tailscale login` and waits up
199
+ to 120 s (`LANES_TAILSCALE_WAIT`) with a countdown until the machine is online, then prints `tailscale: connected as
200
+ <login> (<address>)`. On Linux an account without Tailscale operator rights is told to run `sudo tailscale up` once.
201
+ `--check` only looks. The installer installs its own configuration; your normal terminal remains usable.
202
+
203
+ When this machine resolves the profile's MagicDNS name, the SSH alias `lanes` names the server by it (a server that
204
+ gets a new tailnet address keeps working), and a second alias, `lanes-ip`, holds the address. `lanes-launch` falls
205
+ back to `lanes-ip` after two immediate connect failures, then to `lanes-public` when the site has one. A machine
206
+ without MagicDNS keeps the address in `lanes`. Every alias pins the same host key under the profile's alias.
207
+
208
+ ### Join with an invite or a request
209
+
210
+ On a server that runs the enrolment service `lanes-enrold` (every server install enables it), a new machine
211
+ authorizes itself. The user's side, step by step, is the user guide's
212
+ [Join a site](USERGUIDE.md#2-join-a-site): join the tailnet, run the installer with the code, then `lanes-launch`,
213
+ `lanes-launch web phone` and `lanes-launch web laptop`. With an invite code, `npx @vibelord/lanes --invite
214
+ LN-xxxx-xxxxxxxxxxxx` (the code's prefix picks the bundled profile whose `site.conf` says `LANES_SITE_CODE=LN`; an
215
+ explicit `--site` wins) installs the client, redeems the code and prints `invite: redeemed — this machine's key is now
216
+ authorized for <login> (device <device>)`. Without a code, the installer asks on a terminal; Enter files an access
217
+ request that a member approves (the installer waits up to 10 minutes; `--resume` waits again). The installer calls
218
+ `GET /enrol/site`, then `POST /enrol/redeem` or `POST /enrol/request` plus `GET /enrol/request/<id>`, on
219
+ `https://<LANES_SERVER_NAME>:8443` (with curl, or python3 without it; `--resolve` to the tailnet address when MagicDNS is
220
+ off). What the client reads of lanes-enrold's wire contract is in [the architecture](ARCHITECTURE.md#joining-a-site).
221
+ The code is never shown, stored, or put on a command line. A malformed code, or one with another site's prefix, is refused before anything is installed
222
+ (exit 1).
223
+
224
+ Without the service, after a refused code, or with no code and no terminal, authorize the printed public key on the
225
+ server, as the workspace account: `lanes keys add <device> <file.pub>` (or `lanes keys add <device> -` and paste the
226
+ line) writes it into the lanes-managed block of `~/.ssh/authorized_keys` (ed25519 only; every line outside the block
227
+ is kept; see the runbook's "Who may log in"). The client never edits that file; on the server the enrolment service
228
+ and `lanes keys` write it. Then `./install.sh --check` and `lanes-window`.
229
+
230
+ ### Client options
231
+
232
+ Useful options:
108
233
 
109
234
  - `--device NAME`: independent view name; a stable per-machine name is the default.
110
- - `--key PATH`: choose an existing SSH private key; the key stays on the client.
111
- - `--no-desktop` / `--no-uploads`: omit those integrations.
235
+ - `--key PATH`: choose an existing SSH private key; the key stays on the client (enrolment takes an ed25519 key).
236
+ - `--invite CODE` / `--resume`: join with an invite code, or wait again for an open access request (per run).
237
+ - `--no-desktop` / `--no-uploads`: omit those integrations; on a machine where an earlier install added them they are
238
+ taken away (the upload agents stopped and removed, the launcher entry and key binding removed; `~/Lanes Uploads`
239
+ and its files stay). `--desktop` / `--uploads` put them back.
240
+ - `--palette` / `--no-palette`, `--coder-launch` / `--no-coder-launch`: the cockpit's Ghostty palette and the
241
+ `coder-launch` command name, on or off.
112
242
  - `--link`: develop against the checkout instead of installing a client copy.
113
243
  - `--yes`: noninteractive installation; a newly created dedicated key has no passphrase.
114
244
 
115
- The site selection is saved in `~/.config/lanes/client-site.conf`. Subsequent installs and checks can omit `--site`.
245
+ Every choice except `--invite`, `--resume`, `--link` and `--yes` is remembered in `~/.config/lanes/install-options`
246
+ (plain `KEY=VALUE`, mode 0600, never executed; the runbook's
247
+ [Remembered install options](RUNBOOK.md#remembered-install-options) lists the keys): subsequent installs, updates and checks can omit them, and a flag given again overrides
248
+ the remembered value. A bundled profile is remembered by name (an update reads the new package's copy), your own
249
+ profile file by path; the selected profile is also copied to `~/.config/lanes/client-site.conf`. `--uninstall`
250
+ removes both. On Linux with a key that has a passphrase, the upload helper service logs in through the SSH agent your
251
+ systemd user manager holds, looked up again before every login (so an agent your desktop session hands over after
252
+ the service started is used from its next login on, at most a minute later); the installer hands your terminal's
253
+ agent over and adds one marked block (`# >>> lanes agent hand-off >>>`) to your login profile that repeats this at
254
+ each local login (`--no-uploads` and `--uninstall` remove it); a profile it cannot write (a read-only dotfiles
255
+ checkout, a full disk) is left as it was, the installer prints the line to add and finishes the install; see [the
256
+ user guide](USERGUIDE.md#5-uploads).
116
257
  On macOS, the signed Lanes Helper app requests access to protected folders. Allow Downloads/Desktop/Documents
117
258
  as needed. The allowlist is separate from macOS permission; inspect it with `lanes-upload --allowed`.
118
259
 
119
- ## 4. Optional Safari / Home Screen access
260
+ ### Invite a user
120
261
 
121
- Install Caddy and Tailscale on the server and enable Tailscale HTTPS for its tailnet. The web services use
122
- loopback ports **8082** (native Zellij) and **8083** (Lanes frontend); private HTTPS uses **8443**.
123
- Reserve those ports for Lanes on this host. Caddy runs as a separate user service with its admin API disabled.
262
+ Instead of hand-adding each key, a member mints an invite on the server (or over `ssh lanes`), in their own terminal:
124
263
 
125
- Once, a server administrator allows the workspace account to manage Tailscale Serve:
264
+ ```sh
265
+ lanes invite anna --email anna@example.com --workspaces bw-tools,throttle # prints the five-line welcome kit
266
+ lanes invite list # live / redeemed / revoked / expired
267
+ lanes invite revoke <id> # refuse that code from now on
268
+ ```
269
+
270
+ The kit carries a one-time code `LN-<id>-<secret>` (case-insensitive, valid 7 days; `--expires 1h`…`30d`) that the
271
+ new machine redeems at the enrolment service (`lanes-enrold`, RUNBOOK "Enrolment service") through the client's
272
+ `--invite` (W8 B3): its key enters the
273
+ lanes-managed block of `~/.ssh/authorized_keys` without anyone editing it. Only the code's hash is stored; the code is
274
+ printed once, and only to a terminal (`lanes invite` refuses with exit `2` when piped). Give `--email` on an
275
+ invite-only server: it becomes the new member's owner login. Two optional, Lanes-owned key files add to it —
276
+ `~/.config/lanes/resend.key` (a Resend API key on its first line) mails the kit to `--email`; to reach anyone but
277
+ you, add a `from=Name <addr>` line on a domain verified in Resend, because without it the kit goes from Resend's test
278
+ sender, which delivers only to the Resend account's own address (Resend answers 403 and the note says so). The
279
+ other, `~/.config/lanes/tailscale-api.key` (a user-owned Tailscale API access token), mints step 1's tailnet invite
280
+ link; `--tailscale-link URL` pastes one from the Tailscale admin console instead. Keep both files `0600`.
281
+
282
+ A machine that comes without a code asks instead: `lanes enrol list`, then `lanes enrol approve <id>` ("Approve a
283
+ request" below). A machine leaves with `lanes offboard <device> --yes` ("Offboard a machine" below).
284
+
285
+ ### Approve a request
286
+
287
+ A machine that comes without a code files an access request when its user presses Enter at the installer's prompt
288
+ (the user guide's [No code: ask for access](USERGUIDE.md#no-code-ask-for-access)); the installer waits up to 10
289
+ minutes, and the request stays open for 24 hours. Any member decides it:
126
290
 
127
291
  ```sh
128
- sudo sh sudo/one-time-root.sh --user workspace
292
+ lanes enrol list # the open requests: id, device, login, the key's fingerprint
293
+ lanes enrol approve <id> [--as <your-device>] # writes the member and its key line, exactly as the web page does
294
+ lanes enrol deny <id>
295
+ lanes enrol list --all # also the approved, denied and expired ones
129
296
  ```
130
297
 
131
- This does not change SSH daemon settings unless `--ssh-keepalive` is explicitly supplied. Then, in your own
132
- interactive terminal, either run on the server:
298
+ On a phone or laptop on the tailnet, `https://<server>:8443/enrol/pending` lists the same requests with **Approve**
299
+ and **Deny**. It admits a tailnet login that owns a member record, and keys added by hand (`lanes keys add`, `lanes
300
+ keys adopt`) record no owner, so name yourself once on one of your own devices: `lanes keys owner <your-device>
301
+ <your tailnet login>`. With the opt-in push notifier (`lanes-notify.service`), each new request arrives as one push
302
+ naming the requester, the device and the approve command. The CLI records where a decision came from
303
+ (`cli[:<device>]@<address>`), not who made it (everyone over SSH is the one workspace account); the web page records
304
+ the real tailnet login. Requests need request mode, which needs Tailscale Serve to hand the service a tailnet
305
+ identity. Run the runbook's [operator verification](RUNBOOK.md#enrolment-service-lanes-enrold-w8-a3) once after the
306
+ first server install, then pin the result, so it holds across every restart and reinstall:
133
307
 
134
308
  ```sh
135
- sh install/enable-phone.sh --local
309
+ lanes enrol mode request # the header check passed: take access requests
310
+ lanes enrol mode invite # it did not (or other accounts share this host): invites only
311
+ lanes enrol mode # what is pinned; `auto` unpins it
136
312
  ```
137
313
 
138
- or run from an installed Mac/Linux client:
314
+ Unpinned (`auto`), the first request that comes through Tailscale Serve after each start decides, so a tagged device
315
+ or a forged header at the wrong moment can turn requests off — or on — until the next restart. `LANES_ENROL_DOMAINS`
316
+ (default `beyondwork.ai`) limits which logins may file a request, and which may approve one on the web page.
317
+
318
+ ### Offboard a machine
319
+
320
+ In your own terminal (without `--yes`, or piped, it prints the plan, changes nothing and exits 2):
139
321
 
140
322
  ```sh
141
- sh install/enable-phone.sh --host lanes
323
+ lanes offboard anna-mbp --yes # its key, its device record, its browsers and phones, its view session
324
+ lanes offboard anna-mbp-2 --yes # each window it names: forget the device, end that view
142
325
  ```
143
326
 
144
- The remote path uses the installed checkout shortcut and the SSH key selected by the client installer;
145
- there is no password fallback or agent forwarding. It enables the services, prepares a separate `phone` view,
146
- starts private Tailscale Serve and prints a fresh token. Existing tokens remain valid.
327
+ `lanes offboard <device> --yes` takes the key out of the lanes-managed block of `~/.ssh/authorized_keys` and removes
328
+ `members/<device>.json`, forgets the device so `lanes ensure` does not bring its view back, signs the machine's
329
+ browsers and phones out, and ends its view session. No lane, agent or container is touched, and an SSH session
330
+ already open with the key keeps running until it ends. Signing out revokes every web login token `lanes web --token`
331
+ recorded for the machine: its own, its windows' (`<device>-2` from `lanes-window 2`), and those of the views
332
+ `lanes-launch web phone|laptop` opened for it (`<device>-phone`, `<device>-web`) — never another machine's. It
333
+ revokes them even when that leaves Zellij 0.45.1 unable to create the next token, and then names `lanes token
334
+ revoke-all --yes`, which repairs that (and signs every browser and phone out); a token nobody can attribute (minted
335
+ before 1.1.0, or outside `lanes web --token`) is listed with `lanes token revoke --name NAME`
336
+ ([runbook](RUNBOOK.md#login-tokens)). A step that fails (Zellij does not answer, the web server reads another token
337
+ database) leaves the offboard incomplete, exit 1: run it again. The windows share the revoked key; offboard names them
338
+ and leaves their views running, so offboard each by name to end it. A redeemed invite is not revoked by `lanes invite
339
+ revoke`: offboarding its device is how access ends.
340
+
341
+ ## 4. Optional Safari / Home Screen access
342
+
343
+ Install Caddy (2.5 or later: the `/enrol/*` route uses `reverse_proxy { trusted_proxies … }`, which older versions
344
+ refuse; the Debian 12 and Ubuntu 24.04 packages are 2.6) and Tailscale on the server and enable Tailscale HTTPS for
345
+ its tailnet. The web services use
346
+ loopback ports **8082** (native Zellij) and **8083** (Lanes frontend); private HTTPS uses **8443**. The enrolment
347
+ service every server install runs (`lanes-enrold`) listens on loopback **8184** and is reached as `/enrol/*` through
348
+ the frontend.
349
+ Reserve those ports for Lanes on this host. Caddy runs as a separate user service with its admin API disabled.
350
+
351
+ Once, a server administrator allows the workspace account to manage Tailscale Serve:
352
+
353
+ ```sh
354
+ sudo sh sudo/one-time-root.sh --user workspace
355
+ ```
356
+
357
+ This does not change SSH daemon settings unless `--ssh-keepalive` is explicitly supplied. That is the whole server
358
+ setup: each user then runs, from their installed Mac/Linux client, in their own interactive terminal:
359
+
360
+ ```sh
361
+ lanes-launch web phone # their phone's view, <device>-phone (phone layout)
362
+ lanes-launch web laptop # their laptop's browser view, <device>-web (desktop layout)
363
+ ```
364
+
365
+ `lanes-launch web` runs `lanes web <view> --token --default-device <device>` on the server over `ssh -t lanes`, with
366
+ the SSH key the client installer selected (no password fallback, no agent forwarding). On the server itself the same
367
+ step is `lanes web phone --mobile --token`; `sh install/enable-phone.sh --host lanes` from a client is the older
368
+ one-shot for the device `phone`. `lanes web` prepares the view, enables the loopback services, makes Tailscale Serve
369
+ forward private HTTPS 8443 to the frontend, prints the URL with a QR code of it and shows a fresh token in your
370
+ terminal. The Serve step reads `tailscale serve status --json` first: a mapping of `https:8443` to
371
+ `http://127.0.0.1:8083` is left alone, a missing one is added with
372
+ `tailscale serve --bg --https=8443 http://127.0.0.1:8083`, and a mapping of 8443 to anything else is never replaced
373
+ (exit 64, with the command that frees the port). When Tailscale refuses the account, `lanes web` exits 64 and prints
374
+ the `sudo tailscale set --operator=<account>` step above. Existing tokens remain valid.
147
375
 
148
376
  Connect Tailscale on the phone, open the printed URL in Safari, paste only the token value, then
149
- **Share → Add to Home Screen**. A token grants terminal access as the server account. Revoke a named token with:
377
+ **Share → Add to Home Screen**. A token grants terminal access as the server account. Manage tokens with:
150
378
 
151
379
  ```sh
152
- ~/.local/share/lanes/bin/zellij-0.45.1 web --revoke-token NAME
380
+ lanes token list # every token, with the device it was created for
381
+ lanes token revoke phone # sign that device's token out
382
+ lanes token revoke --name token_1 # a token created before Lanes recorded devices, or outside it
383
+ lanes token revoke-all --yes # every token: each browser and phone signs in again
153
384
  ```
154
385
 
386
+ Zellij 0.45.1 cannot name a token: it calls each new one `token_<number of tokens + 1>`, so Lanes records which
387
+ device `lanes web --token` created it for. Because of that naming rule, revoking any token but the newest would leave
388
+ Zellij unable to create the next one; `lanes token revoke` refuses such a revocation and names `revoke-all` instead.
389
+ Revoking a token also deletes its sign-in sessions, so that browser has to sign in again.
390
+
155
391
  `sh install/enable-web.sh` starts only the loopback services and prints no token. The frontend and services
156
392
  run on the workspace host; a Mac does not need to stay awake for the phone to work.
157
393
 
@@ -167,21 +403,35 @@ The same private frontend serves every browser and chooses its controls once, wh
167
403
  whole tab, so a desktop view's lane sidebar sits beside its lanes (**More → Single pane** shows one pane).
168
404
  - **Phone**: the phone shell described above.
169
405
 
170
- Give each browser its own view. On the server, one command prepares it, enables the loopback web services and
171
- prints its private URL:
406
+ Give each browser its own view. From an installed client, in your own terminal, one command does it all on the
407
+ server — prepares the view, enables the loopback web services, prints its private URL with a QR code of it (for a
408
+ phone camera; `--no-qr` leaves it out) and a fresh login token:
409
+
410
+ ```sh
411
+ lanes-launch web phone # <your device>-phone with the phone layout
412
+ lanes-launch web laptop # <your device>-web with the desktop layout
413
+ lanes-launch web ipad --desktop # any other name is that device as given; --desktop / --mobile picks the layout
414
+ ```
415
+
416
+ On the server the same command is `lanes web` (what `lanes-launch web` runs, with `--token` and
417
+ `--default-device <your device>`):
172
418
 
173
419
  ```sh
174
420
  lanes web ipad --desktop # desktop sidebar layout (a new device's default); --mobile: phone lane pickers
175
- lanes web ipad --desktop --token # the same, then a fresh login token in this terminal
176
- lanes web ipad --print-only # shows the exact prepare call and URL; changes nothing
421
+ lanes web ipad --desktop --token # the same, then a QR code of the URL and a fresh login token in this terminal
422
+ lanes web ipad --print-only # shows the exact prepare call, URL and Serve step; changes nothing
177
423
  ```
178
424
 
179
- From an installed client, `sh install/enable-phone.sh --host lanes --desktop ipad` does the same over the
180
- selected SSH key; without a name, `--desktop` uses the device `browser`. The server needs this version of Lanes
181
- for `--desktop`. A device keeps the layout it was created with: `lanes web <device>` without a flag reuses it
182
- (so `lanes web phone` reuses the phone's view), and an explicit request for the other layout is refused; choose
183
- another device name. `lanes web` reads the node's Tailscale name. Tailscale Serve itself is set up once
184
- by `enable-phone.sh`, as described above.
425
+ From an installed client, `lanes-launch web <device> --desktop` or `sh install/enable-phone.sh --host lanes
426
+ --desktop ipad` does the same over the selected SSH key; without a name, `--desktop` uses the device `browser`. The
427
+ server needs this version of Lanes for `--desktop`. A device keeps the layout it was created with: `lanes web
428
+ <device>` without a flag reuses it (so `lanes web phone` reuses the phone's view), and an explicit request for the
429
+ other layout is refused; choose another device name. `lanes web` reads the node's Tailscale name and ensures the
430
+ Tailscale Serve mapping itself, as described above (its `serve:` line says `present` or `added`), so neither
431
+ `lanes-launch web` nor `enable-phone.sh` has a Serve step of its own. `lanes doctor` checks the same mapping (only
432
+ when the host has a Tailscale name, as `lanes web` does), the two web units, and that the running `lanes-web` reads
433
+ the login tokens from `~/.local/share/zellij/tokens.db`, the database `lanes web --token` and `lanes token` use (both
434
+ sides pin `XDG_DATA_HOME=%h/.local/share`; see the [runbook](RUNBOOK.md#login-tokens)).
185
435
 
186
436
  On a network with a captive portal (hotel, train or aircraft Wi-Fi), connect in this order:
187
437
 
@@ -235,6 +485,10 @@ use the same overrides when reinstalling. `LANES_WORKSPACES` is also carried int
235
485
  when supplied; the standard configuration directory is the simplest default.
236
486
 
237
487
  After updating server code, restart the status observer with `systemctl --user restart lanes-statusd.service`.
488
+ The installer itself restarts the enrolment service (`lanes-enrold`) and a running web frontend (`lanes-mobile`, so
489
+ Caddy reads the new tree's Caddyfile; browser clients reconnect). On the first server install with enrolment, run the
490
+ runbook's operator verification and pin its result with `lanes enrol mode request` (or `invite`) — see
491
+ [Approve a request](#approve-a-request); a pinned mode survives this restart and every later one.
238
492
  If you enabled the Studio Host and selected a new `throttle-rc` or edited its config, restart
239
493
  `lanes-studio-host` (re-running `install/enable-studio-host.sh` does that, after `studio host --check`).
240
494
  If web service definitions or the pinned Zellij binary changed, restart `lanes-web` and `lanes-mobile` too;
@@ -247,7 +501,7 @@ Normal paths:
247
501
  | --- | --- |
248
502
  | `~/.config/lanes/` | Site profile, client settings and server workspace configuration |
249
503
  | `~/.local/share/lanes/` | Client copy, pinned binaries, download cache, installed checkout link and npm-staged trees (`pkg/<version>/`) |
250
- | `~/.local/state/lanes/` | Host registry, device selections, status, upload handoff and housekeeping projections |
504
+ | `~/.local/state/lanes/` | Host registry, device selections, status, upload handoff and housekeeping projections; `members/`, `invites/`, `enrol-requests/` and the append-only `enrol.log` of the enrolment service |
251
505
  | `~/.local/state/lanes/studio-host/` | Optional Studio Host: published public key, deploy receipt, enrolment record, migrated unit text (`lanes studio-host re-key --yes` clears the key half after a revoke) |
252
506
  | `~/.config/throttle-rc/`, `~/.local/share/throttle-rc/` | Optional Studio Host config, machine key and enrolment receipt (throttle-rc's own files) |
253
507
  | `~/.local/state/lanes-client/` | Client helper heartbeat and logs |