@vibelord/lanes 1.0.4 → 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 +52 -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 +39 -18
  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 +86 -2
  11. package/payload/lanes/docs/INSTALL.md +296 -43
  12. package/payload/lanes/docs/RUNBOOK.md +453 -27
  13. package/payload/lanes/docs/USERGUIDE.md +449 -76
  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
@@ -6,6 +6,12 @@
6
6
  # lanes-launch <device> [--ascii] [--theme dark|light|auto] [--glyphs M] [--color C] the device's look (lanes attach)
7
7
  # lanes-launch lane <lane-id> attach straight to ONE lane, no multiplexer in between
8
8
  # lanes-launch ls list lanes with live status
9
+ # lanes-launch web phone|laptop|<device> [--desktop|--mobile] [--no-qr]
10
+ # a browser view in one step: the server prepares it, prints its private URL and a QR
11
+ # code of it (--no-qr: none), then a login token in THIS terminal. phone = <your
12
+ # device>-phone (phone layout), laptop = <your device>-web (desktop layout); any other
13
+ # name is that device
14
+ # lanes-launch update [install.sh options] the newest client from npm, with your remembered install options
9
15
  # lanes-launch --helper <device> [--service | --while-pid PID] run only the upload helper
10
16
  # (--service: the per-machine background service that install.sh sets up)
11
17
  # It never ends by itself: after network loss, sleep, or a server that dropped or refused it, it says so, waits and
@@ -13,7 +19,9 @@
13
19
  set -u
14
20
  host=${LANES_HOST:-lanes}
15
21
  primary=$host
16
- fallback=${LANES_FALLBACK_HOST:-lanes-public}
22
+ # Tried in this order after two immediate connect failures, each only when the ssh config defines it: lanes-ip (the
23
+ # server's tailnet address, when `lanes` names it by its MagicDNS name), then lanes-public (the site's public path).
24
+ fallbacks=${LANES_FALLBACK_HOST:-lanes-ip lanes-public}
17
25
  remote='$HOME/.local/bin/lanes'
18
26
  config=${XDG_CONFIG_HOME:-$HOME/.config}/lanes
19
27
  state=${XDG_STATE_HOME:-$HOME/.local/state}/lanes-client # logs and helper.alive; `install.sh --check` reads them
@@ -24,6 +32,113 @@ default_device() {
24
32
  printf '%s' "$name" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9_.-' '-' | sed 's/-*$//'
25
33
  }
26
34
 
35
+ # The directory this script really lives in. install.sh links ~/.local/bin/lanes-launch (and coder-launch) to the copy
36
+ # in ~/.local/share/lanes/client, or with --link to a checkout's client/ — always by an absolute path.
37
+ self_dir() {
38
+ sd=$0 sd_hops=0
39
+ while [ -L "$sd" ] && [ "$sd_hops" -lt 8 ]; do
40
+ sd_to=$(readlink "$sd") || break
41
+ case "$sd_to" in /*) sd=$sd_to ;; *) sd=$(dirname "$sd")/$sd_to ;; esac
42
+ sd_hops=$((sd_hops + 1))
43
+ done
44
+ (cd "$(dirname "$sd")" 2>/dev/null && pwd -P)
45
+ }
46
+ # This client's release: the VERSION file install.sh copies beside the client, else a checkout's lanes/VERSION.
47
+ # Nothing when neither is there (then the server is told no version).
48
+ client_version() {
49
+ cv_dir=$(self_dir)
50
+ for cv_file in "$cv_dir/VERSION" "$cv_dir/../VERSION"; do
51
+ if [ -r "$cv_file" ]; then head -n 1 "$cv_file" | tr -cd 'A-Za-z0-9._+-'; return 0; fi
52
+ done
53
+ }
54
+
55
+ # `lanes-launch web phone|laptop|<device>`: one command for a browser view, run in THIS terminal: `lanes web` on the
56
+ # server prepares the view, enables the web services and prints the private URL with a QR code of it (never of the
57
+ # token; --no-qr: none), then shows a fresh login token here (the server refuses it without a terminal; nothing logs
58
+ # it). phone and laptop are views of their own, named after this machine's device so the server counts them as this
59
+ # machine's (`--default-device`: one upload helper per machine).
60
+ web_view() {
61
+ wv_usage='usage: lanes-launch web phone|laptop|<device> [--desktop|--mobile] [--no-qr]'
62
+ [ $# -gt 0 ] || { echo "$wv_usage" >&2; exit 64; }
63
+ wv_base=$(default_device)
64
+ case "$1" in
65
+ phone) wv_device=$wv_base-phone wv_layout=--mobile ;;
66
+ laptop) wv_device=$wv_base-web wv_layout=--desktop ;;
67
+ *) wv_device=$1 wv_layout= ;;
68
+ esac
69
+ shift
70
+ wv_chosen= wv_qr=
71
+ # Spliced into the remote command line: only this closed vocabulary passes, anything else refuses before a connection.
72
+ while [ $# -gt 0 ]; do
73
+ case "$1" in
74
+ --desktop|--mobile)
75
+ if [ -n "$wv_chosen" ] && [ "$wv_chosen" != "$1" ]; then echo '--desktop and --mobile are exclusive' >&2; exit 64; fi
76
+ wv_chosen=$1 ;;
77
+ --no-qr) wv_qr=' --no-qr' ;;
78
+ *) echo "lanes-launch web: unknown option $1" >&2; echo "$wv_usage" >&2; exit 64 ;;
79
+ esac
80
+ shift
81
+ done
82
+ [ -z "$wv_chosen" ] || wv_layout=$wv_chosen
83
+ for wv_name in "$wv_base" "$wv_device"; do
84
+ case "$wv_name" in ''|-*|*[!A-Za-z0-9_.-]*) echo "invalid name: $wv_name" >&2; exit 64 ;; esac
85
+ done
86
+ # The server's limit. Never shortened here: a cut suffix could turn the view into the machine's own device.
87
+ if [ "${#wv_device}" -gt 32 ]; then
88
+ echo "lanes-launch web: $wv_device is longer than 32 characters; name the view: lanes-launch web <name>" >&2; exit 64
89
+ fi
90
+ # One connection, decided on the server: `lanes hello` says its release (`ok device=- version=V boards=N`, every
91
+ # release since 1.0). A server older than 1.1.0 refuses --default-device and --no-qr (`unknown option`, before the
92
+ # view exists), so it gets the view and the token without them, and this says what that costs and how the server
93
+ # updates. Run by sh -c on the server whatever the account's login shell is; the names are checked above and the
94
+ # sentence holds no single quote.
95
+ wv_view="$remote web $wv_device${wv_layout:+ $wv_layout} --token"
96
+ wv_old='lanes-launch web: the server runs lanes '\$wv_release', older than this client: no QR code, and the upload box of this view does not reach this machine. The server updates with: npx @vibelord/lanes@latest --server'
97
+ exec ssh -t "$host" "sh -c 'wv_release=\$($remote hello 2>/dev/null); wv_release=\${wv_release##* version=}; wv_release=\${wv_release%% *}; case \$wv_release in 0.*|1.0.*) echo \"$wv_old\" >&2; exec $wv_view ;; *) exec $wv_view --default-device $wv_base$wv_qr ;; esac'"
98
+ }
99
+
100
+ # `lanes-launch update`: the newest client from npm with this machine's remembered choices. Every KEY=VALUE line of
101
+ # ~/.config/lanes/install-options becomes its install.sh flag, checked as install.sh checks the file (a value it would
102
+ # ignore is not passed; it names that one itself), so the update repeats the last install even where the new package
103
+ # would resolve a default differently. Arguments given here follow the replayed ones: on install.sh's command line the
104
+ # last flag wins.
105
+ update_client() {
106
+ for uc_arg in "$@"; do
107
+ case "$uc_arg" in --server) echo 'lanes-launch update updates this client; a server updates with: npx @vibelord/lanes@latest --server' >&2; exit 64 ;; esac
108
+ done
109
+ uc_dir=$(self_dir)
110
+ if [ -r "$uc_dir/../install.sh" ]; then
111
+ # (a linked or checkout client: npm would replace it with a copy)
112
+ echo "lanes-launch update: this client runs from a checkout ($(dirname "$uc_dir")): update it there with git pull, then ./install.sh" >&2
113
+ exit 64
114
+ fi
115
+ command -v npx >/dev/null 2>&1 || { echo 'lanes-launch update: needs Node.js 18 or newer (npx), https://nodejs.org' >&2; exit 69; }
116
+ uc_given=$#
117
+ uc_options=$config/install-options
118
+ if [ -r "$uc_options" ]; then
119
+ case "$(sed -n 's/^SCHEMA=//p' "$uc_options" | tail -n 1)" in
120
+ ''|1)
121
+ while IFS= read -r uc_line || [ -n "$uc_line" ]; do
122
+ uc_value=${uc_line#*=}
123
+ uc_flag=$(printf '%s' "${uc_line%%=*}" | tr 'A-Z_' 'a-z-')
124
+ case "$uc_line" in
125
+ DEVICE=*) case "$uc_value" in ''|*[!a-z0-9_.-]*|.*) ;; *) set -- "$@" --device "$uc_value" ;; esac ;;
126
+ KEY=/*|KEY='~/'*) set -- "$@" --key "$uc_value" ;;
127
+ SITE=/*) set -- "$@" --site "$uc_value" ;;
128
+ SITE=*) case "$uc_value" in ''|.*|*/*|*[!A-Za-z0-9_.-]*) ;; *) set -- "$@" --site "$uc_value" ;; esac ;;
129
+ DESKTOP=1|UPLOADS=1|PALETTE=1|CODER_LAUNCH=1) set -- "$@" "--$uc_flag" ;;
130
+ DESKTOP=0|UPLOADS=0|PALETTE=0|CODER_LAUNCH=0) set -- "$@" "--no-$uc_flag" ;;
131
+ esac # (SCHEMA, comments, unknown keys: install.sh does not read them either)
132
+ done < "$uc_options" ;;
133
+ *) echo "lanes-launch update: $uc_options was written by a newer installer: not replayed" >&2 ;;
134
+ esac
135
+ fi
136
+ # The arguments given here go last: move them behind the replayed flags.
137
+ while [ "$uc_given" -gt 0 ]; do set -- "$@" "$1"; shift; uc_given=$((uc_given - 1)); done
138
+ printf 'lanes-launch update: npx --yes @vibelord/lanes@latest %s\n' "$*" >&2
139
+ exec npx --yes @vibelord/lanes@latest "$@"
140
+ }
141
+
27
142
  while_pid=
28
143
  service=
29
144
  extra=
@@ -39,10 +154,12 @@ case "${1:-}" in
39
154
  esac
40
155
  done ;;
41
156
  ls) exec ssh -o RequestTTY=no "$host" "$remote ls" ;;
157
+ web) shift; web_view "$@" ;;
158
+ update) shift; update_client "$@" ;;
42
159
  lane)
43
160
  [ -n "${2:-}" ] || { echo 'usage: lanes-launch lane <lane-id>' >&2; exit 64; }
44
161
  mode=open target=$2 ;;
45
- -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
162
+ -h|--help) sed -n '2,18p' "$0"; exit 0 ;;
46
163
  *) mode=attach target=${1:-$(default_device)} ;;
47
164
  esac
48
165
  if [ "$mode" = attach ] && [ $# -gt 1 ]; then
@@ -65,6 +182,9 @@ fi
65
182
  case "$target" in ''|-*|*[!A-Za-z0-9_.-]*) echo "invalid name: $target" >&2; exit 64 ;; esac
66
183
  client="$(hostname -s 2>/dev/null || echo client)-$$"
67
184
  client=$(printf '%s' "$client" | tr -c 'A-Za-z0-9_.-' '-')
185
+ # Told to `lanes attach`, which says in one line when this client is older than the server (`lanes-launch update`).
186
+ version=$(client_version)
187
+ [ -z "$version" ] || version=" --client-version $version"
68
188
 
69
189
  # ---- log: what a window that showed nothing never left behind ------------------------------------------------------
70
190
  # One line per connection event, rotated to .1 at 256 KiB. The service writes to stderr instead: launchd / journald
@@ -176,10 +296,19 @@ hold() {
176
296
  wait "$reader" 2>/dev/null || :
177
297
  reader=
178
298
  }
179
- # The fallback host counts only if the ssh config really defines it (an undefined alias resolves to itself).
180
- fallback_defined() {
181
- resolved=$(ssh -G "$fallback" 2>/dev/null | awk '$1 == "hostname" { print $2; exit }')
182
- [ -n "$resolved" ] && [ "$resolved" != "$fallback" ]
299
+ # A fallback host counts only if the ssh config really defines it (an undefined alias resolves to itself).
300
+ defined() {
301
+ resolved=$(ssh -G "$1" 2>/dev/null | awk '$1 == "hostname" { print $2; exit }')
302
+ [ -n "$resolved" ] && [ "$resolved" != "$1" ]
303
+ }
304
+ # The path after $host: the next DEFINED alias in $fallbacks, and past the last one the primary again.
305
+ next_host() {
306
+ nh_after=; [ "$host" = "$primary" ] && nh_after=1
307
+ for nh in $fallbacks; do
308
+ if [ -n "$nh_after" ] && [ "$nh" != "$primary" ] && defined "$nh"; then printf '%s' "$nh"; return 0; fi
309
+ [ "$nh" = "$host" ] && nh_after=1
310
+ done
311
+ printf '%s' "$primary"
183
312
  }
184
313
  # ---- upload helper: serves the sidebar's upload box ------------------------------------------------------------
185
314
  # A drop on the terminal only gives the SERVER a local path string; the file lives here. This helper waits for
@@ -213,6 +342,20 @@ service_serving() {
213
342
  seen=$(mtime "$state/helper.alive") || return 1
214
343
  [ -n "$seen" ] && [ $(( $(date +%s) - seen )) -lt 90 ]
215
344
  }
345
+ # A systemd user service keeps the environment it was started with, and default.target starts lanes-helper at boot —
346
+ # often before the desktop session (gnome-keyring, KDE, uwsm) hands its SSH_AUTH_SOCK to the user manager. So the
347
+ # SERVICE takes the agent the user manager holds before every login: a key with a passphrase works from the first
348
+ # login after that hand-over, with no restart and no login shell. Only a plain absolute path is taken (systemd prints a
349
+ # value that needs quoting as $'…'). A window's helper keeps its terminal's agent; launchd has no user manager to ask.
350
+ manager_agent() {
351
+ [ -n "$service" ] || return 0
352
+ command -v systemctl >/dev/null 2>&1 || return 0
353
+ ma=$(systemctl --user show-environment 2>/dev/null | sed -n 's/^SSH_AUTH_SOCK=//p' | tail -n 1)
354
+ case "$ma" in /*) ;; *) return 0 ;; esac
355
+ [ "$ma" != "${SSH_AUTH_SOCK:-}" ] || return 0
356
+ SSH_AUTH_SOCK=$ma; export SSH_AUTH_SOCK
357
+ log "upload helper: SSH agent of the user manager: $ma"
358
+ }
216
359
  upload_helper() {
217
360
  [ -x "$HOME/.local/bin/lanes-upload" ] || return 0
218
361
  dflt=$(default_device)
@@ -230,6 +373,7 @@ upload_helper() {
230
373
  # A window's own helper is the fallback only. While the service serves this machine it opens no connection
231
374
  # (each one is a long-lived process on the server); when the service goes quiet it takes over within ~2 min.
232
375
  if [ -z "$service" ] && [ "$helper_target" = "$dflt" ] && service_serving; then sleep 30; continue; fi
376
+ manager_agent
233
377
  if ssh -o RequestTTY=no -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$primary" "$remote upload-requests $helper_target --default-device $dflt" 2>"$errfile" |
234
378
  ( heard=1
235
379
  while IFS= read -r line; do
@@ -319,7 +463,7 @@ while :; do
319
463
  log "connecting: $mode $target via $host"
320
464
  started=$(date +%s)
321
465
  if [ "$mode" = attach ]; then
322
- run ssh "$host" "$remote attach $target --client $client --default-device $(default_device) $extra" || :
466
+ run ssh "$host" "$remote attach $target --client $client --default-device $(default_device)$version $extra" || :
323
467
  else
324
468
  run ssh "$host" "$remote open $target" || :
325
469
  fi
@@ -353,14 +497,16 @@ while :; do
353
497
  pause=60
354
498
  what='trying again'
355
499
  else
356
- # Two transport failures in a row on the primary path: try the fallback path once it exists. (25 s: a connect
357
- # that times out takes ConnectTimeout 20 — it must count, or a dead tailnet never reaches the fallback.)
500
+ # Two transport failures in a row on one path: the next path the ssh config defines (lanes -> lanes-ip ->
501
+ # lanes-public -> lanes). (25 s: a connect that times out takes ConnectTimeout 20 — it must count, or a dead
502
+ # tailnet never reaches a fallback.)
358
503
  if [ "$rc" = 255 ] && [ "$lasted" -lt 25 ]; then fails=$((fails + 1)); else fails=0; fi
359
- if [ "$fails" -ge 2 ] && [ "$host" = "$primary" ] && fallback_defined; then
360
- host=$fallback; fails=0; delay=1
361
- printf '\r\n\033[2mlanes: %s unreachable, using %s\033[0m\r\n' "$primary" "$fallback"
362
- elif [ "$fails" -ge 2 ] && [ "$host" = "$fallback" ]; then
363
- host=$primary; fails=0
504
+ if [ "$fails" -ge 2 ]; then
505
+ next=$(next_host); fails=0
506
+ if [ "$next" != "$host" ] && [ "$next" != "$primary" ]; then
507
+ printf '\r\n\033[2mlanes: %s unreachable, using %s\033[0m\r\n' "$host" "$next"; delay=1
508
+ fi
509
+ host=$next
364
510
  fi
365
511
  printf '\r\n\033[2mlanes: connection ended (%s).\033[0m\r\n' "$rc"
366
512
  pause=$delay
@@ -3,7 +3,15 @@
3
3
  # ~/.ssh/config; nothing else in your SSH setup is changed. Re-run the installer instead of editing this file.
4
4
  # Options that only some OpenSSH builds know (ObscureKeystrokeTiming, UseKeychain) are written ONLY when this
5
5
  # machine's ssh accepts them: an unknown option is a parse error for EVERY host, even inside a non-matching block.
6
- Host lanes
6
+ # Every alias below pins the same host key: HostKeyAlias keys known_hosts by "@ALIAS@", whatever HostName says.
7
+ #@IPALIAS@ # `lanes` names the server by its MagicDNS name (this machine resolved it at install time); lanes-ip is the
8
+ #@IPALIAS@ # same server by its tailnet address, which lanes-launch falls back to after two immediate connect failures on
9
+ #@IPALIAS@ # `lanes` (MagicDNS switched off later, say). OpenSSH keeps the FIRST value it reads for an option, so this
10
+ #@IPALIAS@ # HostName wins over the one in the shared block below.
11
+ #@IPALIAS@ Host lanes-ip
12
+ #@IPALIAS@ HostName @IP@
13
+ #@IPALIAS@ Host lanes lanes-ip
14
+ #@NOIPALIAS@ Host lanes
7
15
  HostName @HOST@
8
16
  User @USER@
9
17
  HostKeyAlias @ALIAS@
@@ -31,7 +39,8 @@ Host lanes
31
39
  LogLevel ERROR
32
40
  #@PUBLIC@
33
41
  #@PUBLIC@ # Fallback path: the same sshd over the server's public address (key-only). lanes-launch uses it only after two
34
- #@PUBLIC@ # immediate connect failures on `lanes` (tailnet down); it goes back to the tailnet on the next healthy session.
42
+ #@PUBLIC@ # immediate connect failures on `lanes` (and lanes-ip; tailnet down); it goes back to the tailnet on the next
43
+ #@PUBLIC@ # healthy session.
35
44
  #@PUBLIC@ Host lanes-public
36
45
  #@PUBLIC@ HostName @PUBLIC_HOST@
37
46
  #@PUBLIC@ User @USER@
@@ -198,7 +198,9 @@ shim, so `EVAL_CLAUDE_REAL_BIN=<checkout>/.bw-tools/bin/claude eval-claude` comp
198
198
  The client installer creates an SSH alias and dedicated terminal configuration, using the selected profile's
199
199
  host key and the user's SSH key. `lanes-launch` reconnects its view and provides a helper channel for uploads.
200
200
  A background helper can serve the same channel when installed; on macOS it runs under the Lanes Helper app's
201
- identity so folder permissions work.
201
+ identity so folder permissions work. On Linux it is a systemd user service that logs in through the SSH agent the
202
+ user manager holds, looked up before every login, so an agent a desktop session hands over after boot is used
203
+ without a restart. The installer's remembered `--no-uploads` / `--no-desktop` remove what an earlier install added.
202
204
 
203
205
  The sidebar sends an upload request naming paths, workspace and device. The client checks its folder allowlist,
204
206
  streams an archive over SSH, and reports original errors and progress. The host extracts data into that
@@ -206,11 +208,44 @@ workspace's `uploads/` folder without overwriting files; an optional paste place
206
208
  selected lane. Active transfers have an idle timeout, not a short total-duration limit. Credentials, `.git`
207
209
  metadata and disallowed folders cannot be uploaded through the helper.
208
210
 
211
+ ## Joining a site
212
+
213
+ A machine whose key the server does not know (`lanes hello` refused with `Permission denied`) is authorized through
214
+ the site's enrolment service, `lanes-enrold` (W8 Track A, loopback, behind Tailscale Serve and Caddy's `/enrol/*`),
215
+ never by the client editing `authorized_keys`. The client half is in `install.sh` (`enrol`). It asks
216
+ `https://<LANES_SERVER_NAME>:8443` with curl, or with python3's `http.client` when curl is absent. When the machine
217
+ cannot resolve the name (MagicDNS off), it repeats the request against the profile's IPv4 `LANES_SERVER_HOST`
218
+ (`--resolve NAME:PORT:ADDRESS`), so TLS still checks the certificate against the name. Request bodies go through stdin;
219
+ curl runs with `-q` (no `~/.curlrc`) and `--noproxy '*'` (the service is on the tailnet only).
220
+
221
+ Wire contract v1 is the one `lanes-enrold` serves (W8 A3; its RUNBOOK section "Enrolment service" and the data-model
222
+ row DM-LANES-ENROL-WIRE are the authority; additive only, so the client ignores keys it does not know). The client
223
+ reads JSON answers of at most 64 KiB; any status other than the success below is a refusal whose body may carry
224
+ `{"error": "<word>"}` (for example `invalid-or-expired`, `requests-disabled`, `rate-limited`), which the client shows:
225
+
226
+ | Call | Body the client sends | Success, and what the client reads of it |
227
+ | --- | --- | --- |
228
+ | `GET /enrol/site` | — | `200 {alias, host_name, host_key, version, request_mode, caller, source}`: `request_mode` (`true`\|`false`, always present) is what tells the service from a web page or any other JSON; `false` = invite only; `caller` is the tailnet login the service received (`null` outside request mode). `version` is the server's Lanes release, not a wire number: the client does not gate on it |
229
+ | `POST /enrol/redeem` | `{"code", "pubkey": "ssh-ed25519 <base64>", "device", "client_version"}` | `200 {device, owner: {login, name, email} \| null, ssh_ready: true}`: the key is authorized now |
230
+ | `POST /enrol/request` | `{"pubkey", "device", "client_version"}` | `202 {id, expires_at}` (the same device and key asked again: the same request); the client keeps `id` |
231
+ | `GET /enrol/request/<id>` | — | `200 {id, state: pending\|approved\|denied\|expired, expires_at}`; `404` = the service does not know it (or it was filed by another login): the request is over |
232
+
233
+ A verdict carries no owner: an approved request belongs to the login it was filed under, the `/site` `caller`, which
234
+ is what the client names. The client sends the public key without its comment (the managed block writes
235
+ `lanes:<device>`), never sends the code anywhere but the redeem body, and keeps an open request's id in
236
+ `~/.config/lanes/enrol-request` (`SCHEMA=1`, `ID`, `URL`, `DEVICE`, `FILED`; mode 0600; read without executing it;
237
+ removed once answered and by `--uninstall`). No service (no answer, a page, `502` from a Caddy whose enrold is down), a
238
+ refusal, or no code and no terminal: the exit-3 text with the public key, unchanged — except a `409 {"error":
239
+ "device-taken"|"key-taken"}`. enrold answers that only after a valid code (or to a request it would otherwise file),
240
+ and nothing is used up, so the exit-3 text names the re-run that joins instead: another `--device NAME` (the invite is
241
+ still valid) or a key of its own, and where a member looks (`lanes offboard <device> --yes`, `lanes keys list`).
242
+
209
243
  ## Web and phone
210
244
 
211
245
  Tailscale Serve terminates private HTTPS on 8443 and forwards to the dedicated Caddy user service on loopback
212
246
  8083. Caddy serves `web/` and forwards native assets, authentication and WebSockets to Zellij on loopback 8082.
213
- Zellij's existing token/cookie checks remain authoritative; the mobile layer adds no second login system.
247
+ Zellij's existing token/cookie checks remain authoritative; the mobile layer adds no second login system. The one
248
+ other route, `/enrol/*`, goes to the enrolment service on loopback 8184 (see Access).
214
249
 
215
250
  The phone shell uses the pinned client's window hooks for workspaces, panes, input and sizing. A visible
216
251
  **Type here** field holds the original terminal input. Programmatic input focus is blocked on phones, so a
@@ -251,3 +286,52 @@ and [host protection](HOST-GUARD.md) for those operations.
251
286
  The package uses Python's standard library and POSIX shell. Host binaries are versioned and checksum-pinned
252
287
  in `holders.lock`; Zellij's web assets are served by that binary and integrity-pinned in `web/index.html`.
253
288
  Deployment profiles, workspace catalogs and runtime state remain distinct from shared source code.
289
+
290
+ ## Access
291
+
292
+ Clients log in to one Unix account over SSH (tailnet, or the `lanes-public` fallback). Who may do so is the
293
+ `members/<device>.json` store in the lanes state directory, projected by `libexec/keys.py` (`lanes keys`) into one
294
+ `# lanes-managed BEGIN` … `END` block of that account's `~/.ssh/authorized_keys`: one
295
+ `restrict,pty ssh-ed25519 <key> lanes:<device>` line per member, re-rendered whole on every change, written
296
+ atomically and only when the bytes change. Lines outside the block belong to the operator and are never edited
297
+ (only `lanes keys adopt` moves plain hand-added `lanes:<device>` lines in, keeping a backup). The block never names a
298
+ device the store does not hold: a member file is written before its line and a revoked line leaves the block before
299
+ its member file does, so an interrupted change can leave a member without a line (no access) but never a line
300
+ without a member, and running the same verb again finishes it. The options follow
301
+ what the client does over that key: a forced TTY and plain commands with stdin pipes, no forwarding. The client
302
+ installer never edits the server's file.
303
+
304
+ Identity on the server is *device → owner*: a member record also names the owner `{login, name, email}` (the
305
+ tailnet login), how the key arrived (`invite`, `request`, `manual`), who approved it and the workspaces it was
306
+ invited to. `libexec/members.py` owns that store's file I/O and the one definition of a valid record's identity
307
+ fields; `keys.py` owns the key fields. The Unix account stays shared. A view session takes its owner from the
308
+ store (`create_session` and `lanes attach`, also from the machine's record for a window of that machine) and records
309
+ it in `devices/<device>.json`: whenever a member record exists it decides (`members.owner_for`), so `lanes device
310
+ list` agrees with `lanes keys list` and a handed-over machine follows its new owner; after a revoke the last owner is
311
+ kept, like the base. Enrolment writes `members/` only, never `devices/`, because every device file is a view `lanes
312
+ ensure` keeps alive. Every text field of a member is refused when it holds a control, format (bidi, zero-width),
313
+ surrogate, private-use or unassigned character, since `device list` and `keys list` print them.
314
+
315
+ Enrolment is a service, not an admin edit: `libexec/enrold.py` (`lanes-enrold.service`, loopback 8184, reached as
316
+ `/enrol/*` through the same Tailscale Serve and Caddy as the browser view) adds a member either by redeeming a one-time
317
+ invite code from `invites/` or by a request in `enrol-requests/` that a member approves on `/enrol/pending` (a member
318
+ whose owner login is the caller's; `lanes keys owner` names the owner of a key added by hand). It writes
319
+ through `keys.py` under the same lock as `lanes keys`, so the block stays a projection of `members/`. The caller's
320
+ identity is the `Tailscale-User-Login` header Serve injects (a redeemed invite then belongs to that caller, whoever it
321
+ was minted for; the invite's own name and email stay in the record as `invited_as`), trusted only in request mode —
322
+ pinned by the operator with `lanes enrol mode request` after the header check (or forced by the unit's environment),
323
+ else decided by the first request that came through Serve after a start. Serve replaces a forged header only on the tailnet path: any process that
324
+ reaches the host's loopback can send one itself, so request mode is safe only where no account besides the workspace
325
+ account and root can (DEF-1645, open; RUNBOOK "Trust boundary"). Every redeem failure is one indistinguishable
326
+ answer, rate-limited per tailnet device (Caddy keeps the `X-Forwarded-For` Serve sets, by trusting its loopback peer),
327
+ and the append-only `enrol.log` never holds a code. RUNBOOK "Enrolment service" has the wire contract.
328
+
329
+ The operator's half is `libexec/enrol.py` behind three verbs. `lanes invite` writes `invites/<id>.json` — the code's
330
+ hash, never the code, which is printed once and only to a terminal — and can mail the welcome kit and mint the
331
+ tailnet invite, each through its own optional Lanes key file. `lanes enrol approve|deny` calls the service's own
332
+ `decide()`, recording the SSH client address as the approver (over SSH every login is the same account; the phone
333
+ path records the tailnet login); `lanes enrol mode` pins request mode. `lanes offboard` reverses an enrolment in a
334
+ fixed order — key, device record, the machine's web login tokens (those `web-tokens.json` records for the device, its
335
+ windows and the views minted with it as their machine: Zellij 0.45.1 names tokens `token_<n>`, so the record decides),
336
+ view session — stopping at the first two when they fail, because a view ended while its device file or key remains is
337
+ brought back.