@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
@@ -7,18 +7,63 @@
7
7
  lanes status one-line-per-workspace health summary
8
8
  lanes attach <device> [--client ID] [--mobile] [--viewports N]
9
9
  [--ascii] [--glyphs unicode|ascii|emoji] [--theme dark|light|auto] [--color truecolor|16|none]
10
+ [--default-device NAME] [--client-version V]
10
11
  attach this terminal to the device's view session (creates it if needed); the look
11
- flags are remembered in devices/<device>.json and reach its running sidebars in a tick
12
+ flags are remembered in devices/<device>.json and reach its running sidebars in a tick.
13
+ lanes-launch passes its machine's device and its version: one dim line says when that
14
+ client is older than this server (`lanes-launch update`)
12
15
  lanes prepare <device> [--mobile|--desktop]
13
16
  create a device view without attaching a terminal (eg. Safari on a phone); an explicit
14
17
  layout that differs from the running view is refused, never rebuilt
15
- lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]
18
+ lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only] [--default-device NAME] [--no-qr]
16
19
  browser view in one step: prepare (desktop by default), enable the loopback web
17
- services (install/enable-web.sh), print the private https://<tailnet name>:8443/<device>
18
- URL; --token then shows a login token in YOUR terminal only
20
+ services (install/enable-web.sh), make Tailscale Serve forward https:8443 to them (added
21
+ when missing; exit 64 with the operator's `tailscale set --operator` step when refused),
22
+ print the private https://<tailnet name>:8443/<device> URL; --token then draws a QR code
23
+ of that URL (never of the token; qrencode when installed, else libexec/qr.py; --no-qr:
24
+ none), shows a login token in YOUR terminal only and records its name for the device;
25
+ --print-only changes nothing (it reads the Serve mapping and says what a real run would
26
+ do). A client runs it as `lanes-launch web phone|laptop|<device>` (--default-device:
27
+ that machine's device)
28
+ lanes token list | revoke <device> | revoke --name NAME | revoke-all [--yes]
29
+ web login tokens with the device each was minted for (zellij 0.45.1 cannot name them:
30
+ lanes records the name `lanes web --token` got). revoke signs that device's token out;
31
+ it refuses when zellij could then create no new token (0.45.1 names them token_<n+1>)
32
+ and names revoke-all, which signs every browser and phone out (nothing without --yes).
33
+ Tokens live under XDG_DATA_HOME=~/.local/share, the root lanes-web.service pins; every
34
+ token verb (and web --token, exit 64) refuses while the running server measurably reads
35
+ another token database (`lanes doctor` shows which)
19
36
  lanes open <lane-id> attach this terminal straight to one lane (no multiplexer in between)
20
37
  lanes register <lane-id> [ws] add an unregistered session to a workspace without restarting it
21
38
  use lanes ls --unregistered [--json] to find its ID
39
+ lanes workspaces print the board ids (what the client installer reads)
40
+ lanes workspaces add <id> --container <name> --repo <path> [--title T] [--session S] [--write]
41
+ add a board to the site catalog (catalog.json + a new workspaces.<id>.json whose one
42
+ repository is <path>/.vscode/terminals.json in that container; <path> under /home/coder).
43
+ Dry-run by default (prints the diff); --write applies, keeping catalog.json.bak-<UTC>Z on
44
+ the first write. An id already in the catalog is verified, never rewritten: agreement is
45
+ a no-op, a difference is reported and exits 1. A repository belongs to one board: one
46
+ another board already declares (same container and folder) is refused, as cursor-sync
47
+ would rewrite its terminals.json for both. Then: install-container, sync, doctor
48
+ lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]]
49
+ [--write] [--no-mcp]
50
+ provision a user's container and agents in one verb. Dry-run by default (every diff and
51
+ command it would run). It first checks the container (read-only docker inspect: an
52
+ unknown one stops the run before anything is written); --write then applies, in order:
53
+ the board (`workspaces add`, when --workspace is given), install-container,
54
+ `hooks install --write` for claude and codex, `throttle setup --install-clients`
55
+ inside the container as uid 1000 when it has a CONFIGURED throttle (else the manual
56
+ step is printed: a first setup needs a person on a terminal; the laptop-pool step too
57
+ when site.conf names LANES_THROTTLE_REMOTE_URL), sync,
58
+ `cursor-sync <id> --canonicalize --write` when <path>/.vscode/terminals.json
59
+ exists (else it points at terminals.example.json), sync again when that rewrote the file,
60
+ and the doctor (reported, never fatal). When this account's uid or the home volume's
61
+ owner is not the container uid (1000), the check fails and the run stops there,
62
+ writing nothing: the agent could not read files this account writes. Prints a
63
+ key: value table (a stopped run names every step it did not reach: not-run) and keeps
64
+ ~/.local/state/lanes/onboard/<login>-<UTC>.json. Re-running changes nothing that is
65
+ done. --no-mcp leaves out lane-mcp (plan W6, not shipped yet).
66
+ Exit 1 only when a step failed; an unknown container or a board that disagrees stops it
22
67
  lanes layout <device> [--mobile] print the generated zellij layout
23
68
  lanes rebuild <device> recreate a device's view session (agents are never touched)
24
69
  lanes upgrade-session [device…] sync catalog tabs and labels to live sessions without a rebuild
@@ -42,12 +87,16 @@
42
87
  with git plumbing only (no working tree or index is touched). Dry-run without --push.
43
88
  lanes hello [device] what the client installer calls to prove the path, the key and the server version
44
89
  lanes client-config site facts for clients (KEY=VALUE from ~/.config/lanes/site.conf on the server)
45
- lanes install-container <name> install the `lane` contract (lane, lane-run, lane-signal, hooks/) + pinned zmx into a
46
- container's home volume (additive; installs no agent hook)
90
+ lanes install-container <name> [--no-mcp]
91
+ install the `lane` contract (lane, lane-run, lane-signal, hooks/) + pinned zmx into a
92
+ container's home volume (additive; installs no agent hook). --no-mcp is accepted for
93
+ lane-mcp (plan W6); no MCP server ships yet, so today it changes nothing
47
94
  lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]
48
95
  merge the lane-signal hooks into that home's ~/.claude/settings.json or ~/.codex/hooks.json
49
96
  (+ a top-level Codex notify when none is set); prints the diff, writes only with --write
50
- (backup kept). Opt-in, per container; Codex still asks you to trust new hooks
97
+ (backup kept). Opt-in, per container; Codex still asks you to trust new hooks.
98
+ --write into a --container whose home the agent could not read from this account (uid
99
+ not 1000, or a home volume not owned by 1000) is refused with the reason
51
100
  lanes invariant save|check snapshot / verify every live pane's pid and window size
52
101
  lanes gc truncate oversized zmx logs, drop stale dynamic resolve files
53
102
  lanes sockets [check|repair] lanes that run but cannot be reached: tmux servers whose socket file was deleted
@@ -92,13 +141,59 @@
92
141
  the lanes per create-recipe class (idempotent / attach-only / null / external /
93
142
  retired-path), lane identity, numeric session names, crowded `default` sockets and
94
143
  orphan tmux socket files (report only; in a container `lane gc --sockets` removes them),
95
- and any record left in the retired legacy-sessions/ directory
144
+ any record left in the retired legacy-sessions/ directory, and the browser/phone half:
145
+ lanes-web / lanes-mobile units (⚠ not enabled, ✗ enabled but not running), the login-token
146
+ database the running lanes-web reads (✗ when it is not the one `lanes token` uses) and the
147
+ Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name)
96
148
  lanes device list | forget <name>
97
- the devices `lanes ensure` recreates view sessions for; forget removes devices/<name>.json
98
- (a running view session is left alone and is not brought back once it ends)
149
+ the devices `lanes ensure` recreates view sessions for, each with its owner's login
150
+ (from members/<device>.json, or the machine's for a window like mac-2, whenever that
151
+ record exists, so it agrees with `lanes keys list`; after a revoke the last one is
152
+ kept; blank for none); forget removes devices/<name>.json (a running view session is
153
+ left alone and is not brought back once it ends)
154
+ lanes keys list | add <device> <pubkey-file|-> | revoke <device> | adopt [--dry-run]
155
+ | owner <device> <login> [--name NAME] [--email ADDR]
156
+ who may log in, as a projection of ~/.local/state/lanes/members/<device>.json: one
157
+ `restrict,pty ssh-ed25519 <key> lanes:<device>` line per member between the
158
+ `# lanes-managed BEGIN` / `END` markers of ~/.ssh/authorized_keys (libexec/keys.py; every
159
+ byte outside the markers is kept, ed25519 keys only). add authorizes one device's .pub
160
+ (`-` reads stdin); revoke takes the line out of the block before it removes the member
161
+ file (a failed revoke exits 1 and keeps it, so revoke again finishes), also removes a
162
+ `lanes:<device>` line left in the block without one, and is a no-op for a device with
163
+ neither (exit 1 only when a line outside the block still lets it in); adopt moves
164
+ hand-added `lanes:<device>` lines into members/
165
+ and the block (first adopt keeps authorized_keys.bak-<UTC>) and re-renders the block;
166
+ owner names the person behind a member (add/adopt record none) — the tailnet login
167
+ `/enrol/pending` must see to let that person approve enrolment requests
168
+ lanes invite <name> [--email ADDR] [--expires 7d] [--workspaces a,b] [--tailscale-link URL] [--md]
169
+ lanes invite list | revoke <id>
170
+ mint a one-time code LN-<id>-<secret> a new machine redeems at lanes-enrold (libexec/
171
+ enrol.py; invites/<id>.json holds its hash only, valid 7 d by default, 1h..30d): the
172
+ welcome kit with the code is printed ONLY on a terminal (else exit 2, nothing minted),
173
+ --md as Markdown. --email records the invitee (on an invite-only server the member's
174
+ owner login) and, with ~/.config/lanes/resend.key, mails the kit (to anyone but the
175
+ Resend account itself only with a from=Name <addr> line on a Resend-verified domain in
176
+ that file); step 1's tailnet link is --tailscale-link, else minted with
177
+ ~/.config/lanes/tailscale-api.key, else "ask your admin". list shows
178
+ live/redeemed/revoked/expired; revoke refuses the code from then on
179
+ lanes enrol list [--all] | approve <id> [--as <device>] | deny <id> [--as <device>]
180
+ the enrolment requests machines filed (POST /enrol/request); approve writes the member
181
+ and its key line exactly as /enrol/pending does, recording cli[:<device>]@<ssh client
182
+ address> as the approver (--as names your own member device)
183
+ lanes enrol mode [request|invite|auto]
184
+ whether lanes-enrold takes access requests, pinned across restarts and reinstalls
185
+ (enrol-mode in the state directory): request after the Serve header check passes
186
+ (RUNBOOK "Operator verification"), invite to refuse every request, auto (the default)
187
+ to let the first request through Tailscale Serve after each start decide. A unit
188
+ drop-in's LANES_ENROL_REQUEST_MODE wins over it. No word: the pinned mode, said
189
+ lanes offboard <device> --yes in your own terminal: revoke its key, forget the device, sign its browsers and phones
190
+ out (the web login tokens `lanes web --token` recorded for it, its windows and the
191
+ views `lanes-launch web` opened for it), end its view session (no lane is touched).
192
+ Without --yes or off a terminal: the plan, exit 2
99
193
  """
100
194
  import json
101
195
  import os
196
+ import pwd
102
197
  import re
103
198
  import shutil
104
199
  import sys
@@ -393,6 +488,9 @@ def create_session(device, mobile, viewports=1):
393
488
  old = old if isinstance(old, dict) else {}
394
489
  if old.get('base'): # learnt at attach (see base_device); `ensure` and `rebuild` must not forget it
395
490
  meta['base'] = old['base']
491
+ owner = device_owner(device, old)
492
+ if owner: # who the device belongs to (W8 A2): the member record's, else the one recorded (device_owner)
493
+ meta['owner'] = owner
396
494
  # The look `lanes attach --ascii|--glyphs|--theme|--color` recorded (record_look) is the operator's choice, not
397
495
  # session state: a recreation (ensure after a reboot, rebuild) keeps every valid one, or every sidebar of the
398
496
  # device would flip back to the environment defaults within one tick of this rewrite.
@@ -414,12 +512,52 @@ def create_session(device, mobile, viewports=1):
414
512
  + ('no answer in 60 s' if rc is None else err.strip() or out.strip() or f'exit {rc}'))
415
513
 
416
514
 
515
+ def device_owner(device, meta):
516
+ """The owner devices/<device>.json records (W8 A2, members.owner_for): the owner of members/<device>.json, else of
517
+ the machine the view belongs to (members/<base>.json, once `lanes attach` has learnt the base: `mac-2` is mac's
518
+ window) — whenever such a record exists it decides, also when it names nobody, so a device handed to another
519
+ person follows its new record (DEF-1702). While neither has a record (revoked, never enrolled) the valid owner
520
+ `meta` already holds is kept across every rewrite, like `base`; `lanes device forget` clears it with the file.
521
+ Never raises: `lanes ensure` recreates views through create_session every minute, and a broken member file must
522
+ not stop that."""
523
+ try:
524
+ members = libexec_module('members')
525
+ except L.LanesError:
526
+ return None
527
+ return members.owner_for((device, meta.get('base')), meta.get('owner'))
528
+
529
+
417
530
  def base_device(device, default):
418
531
  """The machine a view belongs to: `mac-2` / `mac-w2`, opened by the client whose default device is `mac`, share
419
532
  mac's upload helper (one helper service per machine); every other device is its own base."""
420
533
  return default if default and device.startswith(default + '-') else device
421
534
 
422
535
 
536
+ def record_base(device, args):
537
+ """Record base_device in devices/<device>.json, as `lanes attach` and `lanes web` learn it from the client's
538
+ `--default-device`. A client that names no default device teaches nothing: what is recorded stays. Readers treat a
539
+ missing "base" as the device itself. Only an existing file is updated — `ensure` builds a session for every
540
+ devices/*.json, so this never creates one. (`default` is unvalidated client text, but only ever stored when it is
541
+ a prefix of the validated device name.) The owner (W8 A2) is brought in line in the same write: filled in for a
542
+ view created before its member existed, replaced when the member record now names another person, dropped when it
543
+ names nobody (device_owner)."""
544
+ default = args[args.index('--default-device') + 1] if '--default-device' in args[:-1] else None
545
+ meta_file = L.STATE / 'devices' / f'{device}.json'
546
+ meta = L.read_json(meta_file)
547
+ meta = meta if isinstance(meta, dict) else {}
548
+ updated = dict(meta)
549
+ if default and meta and meta.get('base') != base_device(device, default):
550
+ updated['base'] = base_device(device, default)
551
+ if meta:
552
+ owner = device_owner(device, updated)
553
+ if owner:
554
+ updated['owner'] = owner
555
+ else:
556
+ updated.pop('owner', None)
557
+ if updated != meta:
558
+ L.atomic_write(meta_file, json.dumps(updated) + '\n')
559
+
560
+
423
561
  def viewports_arg(args, default=1):
424
562
  if '--viewports' in args:
425
563
  try:
@@ -503,13 +641,8 @@ def cmd_attach(args):
503
641
  missing = False
504
642
  if missing:
505
643
  create_session(device, '--mobile' in args, viewports_arg(args))
506
- # Record the base, also for sessions that existed before it was known. A client that names no default device
507
- # teaches nothing: what is recorded stays. Readers treat a missing "base" as the device itself. (`default` is
508
- # unvalidated client text, but only ever stored when it is a prefix of the validated device name.)
509
- meta_file = L.STATE / 'devices' / f'{device}.json'
510
- meta = L.read_json(meta_file) or {}
511
- if default and meta and meta.get('base') != base_device(device, default):
512
- L.atomic_write(meta_file, json.dumps(dict(meta, base=base_device(device, default))) + '\n')
644
+ # Record the base and bring the owner (W8 A2) in line, also for sessions that existed before either was known.
645
+ record_base(device, args)
513
646
  record_look(device, args)
514
647
  # Views idle until a real client (with a real size) has attached once. We exec into the zellij
515
648
  # client below, so a detached helper raises the flag shortly after the client has sized the session.
@@ -540,6 +673,9 @@ def cmd_attach(args):
540
673
  time.sleep(1.2)
541
674
  zellij(['-s', device, 'action', 'go-to-tab-name', tab], 15)
542
675
  os._exit(0)
676
+ notice = client_notice(args)
677
+ if notice: # before zellij takes the screen: the line is there again once the client detaches
678
+ print(f'\033[2m{notice}\033[0m' if sys.stderr.isatty() else notice, file=sys.stderr, flush=True)
543
679
  env = clean_env({'TERM': os.environ.get('TERM', 'xterm-256color'),
544
680
  'COLORTERM': os.environ.get('COLORTERM', 'truecolor')})
545
681
  if L.run_bounded(['infocmp', env['TERM']], 5)[0] != 0: # unknown here — or no answer: the safe default
@@ -583,8 +719,16 @@ def hook_env(home):
583
719
  handle.write(('' if text.endswith('\n') or not text else '\n') + ENV_HOOK + '\n')
584
720
 
585
721
 
722
+ INSTALL_CONTAINER_USAGE = 'usage: lanes install-container <name> [--no-mcp]'
723
+
724
+
586
725
  def cmd_install_container(args):
587
- container = args[0]
726
+ names = [word for word in args if word != '--no-mcp']
727
+ if len(names) != 1 or names[0].startswith('-'):
728
+ raise L.LanesError(INSTALL_CONTAINER_USAGE)
729
+ # --no-mcp leaves out lane-mcp, the in-container MCP server of plan W6. This checkout ships none, so there is
730
+ # nothing to leave out yet: the flag is accepted so that `lanes onboard --no-mcp` keeps its meaning when it ships.
731
+ container = names[0]
588
732
  info = L.container_info(container)
589
733
  if not info.get('home'):
590
734
  raise L.LanesError(f'{container}: home volume not found')
@@ -640,18 +784,30 @@ def is_lane_signal_group(group):
640
784
 
641
785
  def merge_hooks(existing, ours):
642
786
  """`existing` (a settings.json / hooks.json document) with every lane-signal group from an earlier install removed
643
- and `ours` appended per event. Every other key, event and group stays exactly where it was."""
787
+ and `ours` appended per event. Every other key, event and group stays exactly where it was. An event whose
788
+ lane-signal groups already are ours (key order and group order aside) is left exactly as it is: another writer
789
+ may have put its own group after or before ours and re-encoded the file — `throttle setup --install-clients`
790
+ appends its observer group to Claude's SessionEnd, inserts it first in Codex's SessionStart and sorts every key
791
+ (serde_json) — and moving ours back to the end would rewrite the file, with a backup, on every re-run."""
644
792
  merged = dict(existing)
645
793
  hooks = existing.get('hooks', {})
646
794
  if not isinstance(hooks, dict) or not all(isinstance(groups, list) for groups in hooks.values()):
647
795
  raise L.LanesError('the existing file has a "hooks" value that is not {event: [groups]} — fix it by hand first')
648
- out = {}
796
+
797
+ def same(a, b):
798
+ return sorted(json.dumps(g, sort_keys=True) for g in a) == sorted(json.dumps(g, sort_keys=True) for g in b)
799
+ out, installed = {}, set()
649
800
  for event, groups in hooks.items():
801
+ if event in ours['hooks'] and same([g for g in groups if is_lane_signal_group(g)], ours['hooks'][event]):
802
+ out[event] = groups
803
+ installed.add(event)
804
+ continue
650
805
  kept = [group for group in groups if not is_lane_signal_group(group)]
651
806
  if kept or event in ours['hooks']:
652
807
  out[event] = kept
653
808
  for event, groups in ours['hooks'].items():
654
- out[event] = out.get(event, []) + groups
809
+ if event not in installed:
810
+ out[event] = out.get(event, []) + groups
655
811
  merged['hooks'] = out
656
812
  return merged
657
813
 
@@ -695,7 +851,10 @@ def write_with_backup(path, before, after):
695
851
 
696
852
  def cmd_hooks(args):
697
853
  """`lanes hooks install`: merge the lane-signal hook definitions into ONE container home's agent config. Dry run
698
- (the printed diff) unless --write; never touches Codex's hook trust (`[hooks.state]` is Codex's own decision)."""
854
+ (the printed diff) unless --write; never touches Codex's hook trust (`[hooks.state]` is Codex's own decision).
855
+ A --container target on a uid split (`uid_split`: this account or the home volume's owner is not the container
856
+ uid) is refused with --write — the agent could not read the 0600 file this account would create — and warned in a
857
+ dry run (DEF-1727). `--home <dir>` is for trying the merge on a copy, not an agent's home, and is not checked."""
699
858
  if not args or args[0] != 'install':
700
859
  raise L.LanesError(HOOKS_USAGE)
701
860
  opts, rest = {}, list(args[1:])
@@ -720,6 +879,10 @@ def cmd_hooks(args):
720
879
  home = Path(opts['--home'])
721
880
  if not home.is_dir():
722
881
  raise L.LanesError(f'{home}: not a directory')
882
+ split = uid_split(home) if '--container' in opts else None
883
+ if split and opts.get('--write'):
884
+ raise L.LanesError(f"{split}: the agent (uid {L.CONTAINER_UID}) could not read what this account would write "
885
+ f"into {home} — nothing written; run it from a host account with uid {L.CONTAINER_UID}")
723
886
  target = home / HOOK_TARGETS[agent][1]
724
887
  before = target.read_text() if target.exists() else ''
725
888
  try:
@@ -740,6 +903,8 @@ def cmd_hooks(args):
740
903
  changes.append((config, text, patched))
741
904
  print('Codex asks you to review and trust new hooks before it runs them; lanes never writes that trust.')
742
905
  if not opts.get('--write'):
906
+ if split:
907
+ print(f'⚠ {split}: --write refuses here (run it from a host account with uid {L.CONTAINER_UID})')
743
908
  print('dry run: nothing written (add --write to apply)')
744
909
  return 0
745
910
  for path, old, new in changes:
@@ -835,12 +1000,18 @@ def cmd_prepare(args):
835
1000
 
836
1001
 
837
1002
  WEB_LOOPBACK = 'http://127.0.0.1:8083' # lanes-mobile.service (web/Caddyfile); Tailscale Serve forwards :8443 here
838
- WEB_USAGE = 'usage: lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]'
1003
+ WEB_USAGE = ('usage: lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only] '
1004
+ '[--default-device NAME] [--no-qr]')
1005
+ SERVE_PORT = '8443'
1006
+ SERVE_STATUS = ['tailscale', 'serve', 'status', '--json']
1007
+ SERVE_ADD = ['tailscale', 'serve', '--bg', f'--https={SERVE_PORT}', WEB_LOOPBACK]
1008
+ SERVE_TARGETS = {WEB_LOOPBACK, 'http://localhost:8083'} # how `tailscale serve` stores the Lanes frontend target
1009
+ WEB_UNITS = ('lanes-web.service', 'lanes-mobile.service')
839
1010
 
840
1011
 
841
1012
  def tailnet_name():
842
- """This node's MagicDNS name, read the way install/enable-phone.sh reads it; None when tailscale is absent,
843
- silent or has no name. Read-only: Tailscale Serve itself is configured once by enable-phone.sh."""
1013
+ """This node's MagicDNS name, read the way install/enable-phone.sh read it; None when tailscale is absent,
1014
+ silent or has no name. Read-only (ensure_serve owns the Serve mapping)."""
844
1015
  if not shutil.which('tailscale'):
845
1016
  return None
846
1017
  rc, out, _err = L.run_bounded(['tailscale', 'status', '--json'], 10)
@@ -853,24 +1024,190 @@ def tailnet_name():
853
1024
  return name.rstrip('.') or None
854
1025
 
855
1026
 
1027
+ def web_qr(url):
1028
+ """The view's URL as a QR code for a phone camera (text to print), or None. Only the URL is ever encoded: the
1029
+ login token does not exist yet (the pinned zellij prints it after the exec) and never passes through lanes.
1030
+ `qrencode -t ANSIUTF8` when it is on PATH and answers; else libexec/qr.py (byte mode, level M, versions 1-6: up
1031
+ to 106 bytes), which frames its half blocks the same way. None when this terminal's encoding cannot show the
1032
+ block characters or neither can encode the URL: the `url:` line is the plain URL either way."""
1033
+ try:
1034
+ '█▀▄'.encode(getattr(sys.stdout, 'encoding', None) or 'utf-8')
1035
+ except (LookupError, UnicodeEncodeError):
1036
+ return None
1037
+ if shutil.which('qrencode'):
1038
+ rc, out, _err = L.run_bounded(['qrencode', '-t', 'ANSIUTF8', '-o', '-', url], 10)
1039
+ if rc == 0 and out.strip():
1040
+ return out
1041
+ try:
1042
+ qr = libexec_module('qr')
1043
+ return qr.render(qr.encode(url).modules)
1044
+ except (ValueError, L.LanesError): # past version 6, or a broken install: the code is a convenience, never a stop
1045
+ return None
1046
+ def serve_state():
1047
+ """(state, detail) of the background Tailscale Serve config for HTTPS 8443, from `tailscale serve status --json`
1048
+ (ipn.ServeConfig: `Web` maps "<name>:<port>" to `Handlers` per path). Read-only.
1049
+ present `/` on :8443 proxies to the Lanes frontend (127.0.0.1:8083 or localhost:8083)
1050
+ missing nothing serves `/` on :8443 (`null`, `{}`, other ports only, or only a `Foreground` session that ends
1051
+ with its process)
1052
+ other `/` on :8443 is something else; detail = its target
1053
+ unknown status did not answer or was not JSON; detail = why"""
1054
+ rc, out, err = L.run_bounded(SERVE_STATUS, 10)
1055
+ if rc != 0:
1056
+ return 'unknown', ('no answer in 10 s' if rc is None else err.strip() or out.strip() or f'exit {rc}')
1057
+ try:
1058
+ web = (json.loads(out) or {}).get('Web') or {}
1059
+ except (ValueError, AttributeError):
1060
+ return 'unknown', '`tailscale serve status --json` printed no JSON object'
1061
+ others = []
1062
+ for hostport, config in web.items():
1063
+ if hostport.rpartition(':')[2] != SERVE_PORT:
1064
+ continue
1065
+ handler = ((config or {}).get('Handlers') or {}).get('/')
1066
+ if not handler:
1067
+ continue
1068
+ target = handler.get('Proxy') or 'a file or text handler'
1069
+ if target.rstrip('/') in SERVE_TARGETS:
1070
+ return 'present', target
1071
+ others.append(target)
1072
+ return ('other', ', '.join(others)) if others else ('missing', '')
1073
+
1074
+
1075
+ class ServeRefused(L.LanesError):
1076
+ """`lanes web` could not make https:8443 reach the Lanes frontend; the message says what the operator does."""
1077
+
1078
+
1079
+ def ensure_serve():
1080
+ """Make Tailscale Serve forward HTTPS 8443 to the Lanes frontend -> 'present' | 'added'. A present mapping is
1081
+ left alone (no call); a missing or unreadable one is added with SERVE_ADD; a mapping lanes did not make is never
1082
+ replaced. Raises ServeRefused with the operator's next step."""
1083
+ state, detail = serve_state()
1084
+ if state == 'present':
1085
+ return 'present'
1086
+ if state == 'other':
1087
+ raise ServeRefused(f'Tailscale Serve already maps https:{SERVE_PORT} / to {detail}; lanes does not replace a '
1088
+ f'mapping it did not make. To give the port to Lanes: tailscale serve --https={SERVE_PORT} '
1089
+ 'off, then run this command again.')
1090
+ rc, out, err = L.run_bounded(SERVE_ADD, 30)
1091
+ if rc != 0:
1092
+ reason = 'no answer in 30 s' if rc is None else f'exit {rc}'
1093
+ said = (err.strip() or out.strip())[-400:]
1094
+ user = pwd.getpwuid(os.getuid()).pw_name
1095
+ raise ServeRefused(f"{' '.join(SERVE_ADD)} failed ({reason})" + (f': {said}' if said else '') + '\n'
1096
+ 'The server admin must allow this account to manage Tailscale Serve:\n'
1097
+ f' sudo tailscale set --operator={user}\n'
1098
+ 'Then run this command again. See docs/INSTALL.md for HTTPS setup.')
1099
+ return 'added'
1100
+
1101
+
1102
+ def serve_plan():
1103
+ """The `serve:` value `lanes web --print-only` shows: what a real run would find and do. Read-only."""
1104
+ state, detail = serve_state()
1105
+ return {'present': f'present (https:{SERVE_PORT} -> {WEB_LOOPBACK})',
1106
+ 'missing': f"missing — a real run adds it: {' '.join(SERVE_ADD)}",
1107
+ 'other': f'https:{SERVE_PORT} serves {detail} — a real run refuses to replace it',
1108
+ 'unknown': f'unknown ({detail})'}[state]
1109
+
1110
+
1111
+ def account_home():
1112
+ """The account's home from its passwd entry: what `%h` names in the user manager's units (lanes-web.service's
1113
+ pinned XDG_DATA_HOME), whatever $HOME this shell carries; $HOME only when the account has no passwd entry."""
1114
+ try:
1115
+ return Path(pwd.getpwuid(os.getuid()).pw_dir)
1116
+ except KeyError:
1117
+ return Path.home()
1118
+
1119
+
1120
+ # The ONE root of zellij's login-token database (review of C1): systemd/lanes-web.service pins
1121
+ # Environment=XDG_DATA_HOME=%h/.local/share and the token commands run zellij with the same XDG_DATA_HOME, so neither
1122
+ # the calling shell's nor the user manager's own XDG_DATA_HOME moves either side (test_web.TokenDatabase pins the two
1123
+ # together). It is zellij's own default, so a server whose manager set no XDG_DATA_HOME already agrees.
1124
+ WEB_DATA_HOME = '.local/share'
1125
+ SERVER_PID = ['systemctl', '--user', 'show', '-p', 'MainPID', 'lanes-web.service']
1126
+
1127
+
1128
+ def token_db():
1129
+ """The login-token database `lanes web --token` and `lanes token` use: $XDG_DATA_HOME/zellij/tokens.db (MEASURED
1130
+ 2026-10-05 from the pinned 0.45.1: --data-dir does not move it) under the pinned root."""
1131
+ return account_home() / WEB_DATA_HOME / 'zellij/tokens.db'
1132
+
1133
+
1134
+ def token_env():
1135
+ """The token commands' environment: clean_env() plus the pinned XDG_DATA_HOME — the root lanes-web.service is
1136
+ rendered with, never the caller's (a shell export the user manager does not carry would mint where the server
1137
+ never looks)."""
1138
+ return clean_env({'XDG_DATA_HOME': str(account_home() / WEB_DATA_HOME)})
1139
+
1140
+
1141
+ def server_token_db(pid):
1142
+ """The token database the running process `pid` (lanes-web.service's zellij) reads, by zellij's own rule —
1143
+ $XDG_DATA_HOME when absolute, else $HOME/.local/share — from PROC/<pid>/environ, the environment systemd started it
1144
+ with. -> (Path, '') or (None, why). Read-only."""
1145
+ try:
1146
+ raw = (PROC / str(pid) / 'environ').read_bytes()
1147
+ except OSError as error:
1148
+ return None, f'its environment (/proc/{pid}/environ) is unreadable: {error.strerror or error}'
1149
+ env = dict(entry.partition(b'=')[::2] for entry in raw.split(b'\0') if b'=' in entry)
1150
+ data = env.get(b'XDG_DATA_HOME', b'').decode(errors='replace')
1151
+ if not data.startswith('/'):
1152
+ home = env.get(b'HOME', b'').decode(errors='replace')
1153
+ if not home.startswith('/'):
1154
+ return None, f'its environment (/proc/{pid}/environ) names neither XDG_DATA_HOME nor HOME'
1155
+ data = f'{home}/.local/share'
1156
+ return Path(data) / 'zellij/tokens.db', ''
1157
+
1158
+
1159
+ def same_file(a, b):
1160
+ return os.path.realpath(a) == os.path.realpath(b)
1161
+
1162
+
1163
+ def token_db_mismatch(server):
1164
+ return (f'lanes-web.service reads login tokens from {server}, but `lanes web --token` and `lanes token` use '
1165
+ f'{token_db()}: a token minted now is refused at sign-in and a revocation never reaches the server. '
1166
+ f'`lanes web <device>` renders its unit with the pinned XDG_DATA_HOME=%h/{WEB_DATA_HOME}; then '
1167
+ 'systemctl --user restart lanes-web.service (open browser views reconnect; a token minted into the other '
1168
+ 'database is minted again)')
1169
+
1170
+
1171
+ def token_db_refusal():
1172
+ """The message `lanes web --token` and `lanes token` refuse with when the RUNNING lanes-web.service measurably
1173
+ reads another token database; None when it reads the same one, runs no process, or cannot be compared (no
1174
+ systemctl, no answer, an unreadable environment) — the pinned unit reads the pinned root on its next start, and
1175
+ `lanes doctor` names what was not compared. One bounded `systemctl --user show`; read-only."""
1176
+ if not shutil.which('systemctl'):
1177
+ return None
1178
+ rc, out, _err = L.run_bounded(SERVER_PID, 10, env=clean_env())
1179
+ pid = dict(line.partition('=')[::2] for line in out.splitlines()).get('MainPID', '') if rc == 0 else ''
1180
+ if not pid.isdigit() or int(pid) == 0:
1181
+ return None
1182
+ server, _why = server_token_db(int(pid))
1183
+ return None if server is None or same_file(server, token_db()) else token_db_mismatch(server)
1184
+
1185
+
856
1186
  def cmd_web(args):
857
- """lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]
1187
+ """lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only] [--default-device NAME] [--no-qr]
858
1188
 
859
1189
  One command for a browser view (laptop, tablet, phone): prepare the device's view (idempotent; a running view
860
- of the other layout is refused, never rebuilt), enable the loopback web services through install/enable-web.sh,
861
- print the private URL. Without --desktop/--mobile a known device (devices/<device>.json) keeps its recorded
862
- layout, exactly as a flagless `lanes prepare` reuses whatever runs; only a new device defaults to desktop.
863
- --token execs the pinned `zellij web --create-token` exactly as enable-phone.sh does, in the caller's terminal
864
- only; lanes never prints or logs a token. --print-only changes nothing. Output: `key: value` lines
865
- (DM-LANES-WEB-CLI v1)."""
1190
+ of the other layout is refused, never rebuilt) BEFORE anything is printed (LNS-38), enable the loopback web
1191
+ services through install/enable-web.sh, make Tailscale Serve forward https:8443 to the frontend (ensure_serve;
1192
+ exit 64 with the operator's step when it cannot), print the private URL. Without --desktop/--mobile a known
1193
+ device (devices/<device>.json) keeps its recorded layout, exactly as a flagless `lanes prepare` reuses whatever
1194
+ runs; only a new device defaults to desktop. --token then draws a QR code of the URL for a phone camera (web_qr:
1195
+ `qrencode -t ANSIUTF8`, else libexec/qr.py; none for a loopback URL, past qr.py's 106 bytes, or with --no-qr) —
1196
+ the token cannot be in it: it does not exist yet — and creates a login token with the pinned
1197
+ `zellij web --create-token` on the caller's terminal only, recording its name for the device
1198
+ (libexec/web_tokens.py); lanes never prints or logs a token. It is refused (exit 64, before any output) while the
1199
+ running lanes-web.service reads another token database (token_db_refusal). --print-only changes nothing.
1200
+ `lanes-launch web` (W8 B4) adds --default-device, its machine's device: the view's base is recorded as
1201
+ `lanes attach` records it; and passes --no-qr through when given. Output: `key: value` lines (DM-LANES-WEB-CLI
1202
+ v1); with --token only, the QR code and then the token instruction follow them."""
866
1203
  words, skip = [], False
867
1204
  for arg in args:
868
1205
  if skip:
869
1206
  skip = False
870
- elif arg == '--viewports':
1207
+ elif arg in ('--viewports', '--default-device'):
871
1208
  skip = True
872
1209
  elif arg.startswith('-'):
873
- if arg not in ('--desktop', '--mobile', '--token', '--print-only'):
1210
+ if arg not in ('--desktop', '--mobile', '--token', '--print-only', '--no-qr'):
874
1211
  raise L.LanesError(f'unknown option {arg}; {WEB_USAGE}')
875
1212
  else:
876
1213
  words.append(arg)
@@ -896,27 +1233,73 @@ def cmd_web(args):
896
1233
  state = 'planned'
897
1234
  if not print_only:
898
1235
  state = prepare_view(device, mobile, viewports, explicit=explicit)
1236
+ record_base(device, args)
899
1237
  rc, out, err = L.run_bounded(['sh', str(L.REPO / 'install/enable-web.sh'), device, flag], 180)
900
1238
  if rc != 0:
901
1239
  raise L.LanesError('install/enable-web.sh: ' + ('no answer in 180 s' if rc is None
902
1240
  else err.strip() or out.strip() or f'exit {rc}'))
903
1241
  dns = tailnet_name()
1242
+ url = f'https://{dns}:8443/{device}' if dns else f'{WEB_LOOPBACK}/{device}'
1243
+ serve = None
1244
+ if dns and print_only:
1245
+ serve = serve_plan()
1246
+ elif dns:
1247
+ try:
1248
+ added = ensure_serve()
1249
+ except ServeRefused as refusal:
1250
+ print(f'lanes: {refusal}', file=sys.stderr)
1251
+ return 64
1252
+ serve = (f'present (https:{SERVE_PORT} -> {WEB_LOOPBACK})' if added == 'present'
1253
+ else f"added ({' '.join(SERVE_ADD)})")
1254
+ if token:
1255
+ # Before any output: a token minted into a database the running server does not read is refused at sign-in.
1256
+ refusal = token_db_refusal()
1257
+ if refusal:
1258
+ print(f'lanes: {refusal}', file=sys.stderr)
1259
+ return 64
904
1260
  lines = [('device', device), ('layout', 'mobile' if mobile else 'desktop'), ('state', state),
905
- ('prepare', ' '.join(prepare)), ('services', f'sh install/enable-web.sh {device} {flag}'),
906
- ('url', f'https://{dns}:8443/{device}' if dns else f'{WEB_LOOPBACK}/{device}')]
1261
+ ('prepare', ' '.join(prepare)), ('services', f'sh install/enable-web.sh {device} {flag}'), ('url', url)]
907
1262
  if not dns:
908
1263
  lines.append(('note', 'no Tailscale name on this host: the loopback URL opens only here; '
909
1264
  'there is no public fallback by design (docs/INSTALL.md)'))
910
1265
  if not token:
911
1266
  lines.append(('next', f'lanes web {device} {flag} --token (in your own terminal: a login token)'))
912
- print(''.join(f'{key}: {value}\n' for key, value in lines), end='')
1267
+ if serve:
1268
+ lines.append(('serve', serve)) # appended after every earlier v1 key (DM-LANES-WEB-CLI is additive)
1269
+ print(''.join(f'{key}: {value}\n' for key, value in lines), end='', flush=True)
913
1270
  if token:
914
- print('\nThe last line is NAME: TOKEN. Paste only TOKEN into the sign-in screen.\n', flush=True)
915
- # Zellij 0.45.1 requires --create-token by itself; --token-name cannot accompany it (enable-phone.sh).
916
- os.execv(ZELLIJ_BASE[0], ZELLIJ_BASE + ['web', '--create-token'])
1271
+ # A phone camera opens the URL from this code; a loopback URL opens only on this host, so it gets none.
1272
+ code = web_qr(url) if dns and '--no-qr' not in args else None
1273
+ if code:
1274
+ print('\n' + code, end='' if code.endswith('\n') else '\n')
1275
+ # Zellij 0.45.1 cannot name a token (--token-name may not accompany --create-token): web_tokens records
1276
+ # which device each token was minted for — and its machine, which `lanes offboard <machine>` signs out —
1277
+ # and runs the create on this terminal without reading its output.
1278
+ return libexec_module('web_tokens').mint(device, list(ZELLIJ_BASE), token_env(),
1279
+ machine=view_machine(device, args))
917
1280
  return 0
918
1281
 
919
1282
 
1283
+ def view_machine(device, args):
1284
+ """The machine a view belongs to, for the token record: the `base` devices/<device>.json holds (record_base), else
1285
+ what this call's --default-device makes it (base_device); the device itself for a machine's own view."""
1286
+ meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
1287
+ if isinstance(meta, dict) and isinstance(meta.get('base'), str) and meta['base']:
1288
+ return meta['base']
1289
+ default = args[args.index('--default-device') + 1] if '--default-device' in args[:-1] else None
1290
+ return base_device(device, default)
1291
+
1292
+
1293
+ def cmd_token(args):
1294
+ """lanes token list | revoke <device> | revoke --name NAME | revoke-all [--yes] (libexec/web_tokens.py). Refused
1295
+ while the running lanes-web.service reads another token database: its list would not be the server's tokens and a
1296
+ revocation would not sign anything out."""
1297
+ refusal = token_db_refusal()
1298
+ if refusal:
1299
+ raise L.LanesError(refusal)
1300
+ return libexec_module('web_tokens').main(args, list(ZELLIJ_BASE), token_env())
1301
+
1302
+
920
1303
  # ------------------------------------------------------------------ safety
921
1304
 
922
1305
  PANE_FORMAT = '#{session_name}\t#{window_index}.#{pane_index}\t#{pane_pid}\t#{window_width}x#{window_height}'
@@ -1160,12 +1543,387 @@ def cmd_upload_result(args):
1160
1543
  return 0
1161
1544
 
1162
1545
 
1163
- def cmd_workspaces(_args):
1164
- for workspace in L.load_registry()['workspaces']:
1165
- print(workspace['id'])
1546
+ WORKSPACES_USAGE = ('usage: lanes workspaces | lanes workspaces add <id> --container <name> --repo <path> '
1547
+ '[--title T] [--session S] [--write]')
1548
+
1549
+
1550
+ def cmd_workspaces(args):
1551
+ """`lanes workspaces` prints the board ids (the client installer reads them); `lanes workspaces add` appends a
1552
+ board to the site catalog and writes its manifest (libexec/site_edit.py). Dry run (the printed diff) unless
1553
+ --write; an id already in the catalog is verified and never rewritten (exit 1 when it disagrees); a repository
1554
+ another board already declares is refused (LanesError, before any write)."""
1555
+ if not args:
1556
+ for workspace in L.load_registry()['workspaces']:
1557
+ print(workspace['id'])
1558
+ return 0
1559
+ if args[0] != 'add' or len(args) < 2 or args[1].startswith('-'):
1560
+ raise L.LanesError(WORKSPACES_USAGE)
1561
+ opts, rest = {}, list(args[2:])
1562
+ while rest:
1563
+ word = rest.pop(0)
1564
+ if word in ('--container', '--repo', '--title', '--session') and rest:
1565
+ opts[word] = rest.pop(0)
1566
+ elif word == '--write':
1567
+ opts[word] = True
1568
+ else:
1569
+ raise L.LanesError(WORKSPACES_USAGE)
1570
+ if '--container' not in opts or '--repo' not in opts:
1571
+ raise L.LanesError(WORKSPACES_USAGE)
1572
+ board = args[1]
1573
+ site_edit = libexec_module('site_edit')
1574
+ result = site_edit.add_workspace(board, opts.get('--title', board), opts['--container'], opts['--repo'],
1575
+ opts.get('--session'), write=bool(opts.get('--write')))
1576
+ if result['status'] == 'exists':
1577
+ print(f"{board}: already in the catalog ({result['catalog']}) and agrees; nothing written")
1578
+ return 0
1579
+ if result['status'] == 'mismatch':
1580
+ print(f"{board}: not changed — the site ({result['site']}) already says otherwise:")
1581
+ for difference in result['differences']:
1582
+ print(f' {difference}')
1583
+ print('Edit those files by hand (workspaces.example/README.md) or choose another id.')
1584
+ return 1
1585
+ sys.stdout.write(site_edit.diff_text(result))
1586
+ if result['status'] == 'would-add':
1587
+ print('dry run: nothing written (add --write to apply)')
1588
+ return 0
1589
+ for backup in result['backups']:
1590
+ print(f'backup {backup}')
1591
+ print(f"added {board} to {result['catalog']}\n"
1592
+ f"next: lanes install-container {opts['--container']} && lanes sync && lanes doctor")
1166
1593
  return 0
1167
1594
 
1168
1595
 
1596
+ # ------------------------------------------------------------------- onboard
1597
+
1598
+ ONBOARD_USAGE = ('usage: lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]] '
1599
+ '[--write] [--no-mcp]')
1600
+ # The receipt's status vocabulary. A dry run reports planned, unchanged, skipped, manual or failed, and `ok` for the
1601
+ # read-only `inspect` of the container; `manual` names a step a person still has to take (printed with the exact command);
1602
+ # `warn` is a step that ran and reported a problem that does not stop onboarding (the doctor's verdict, a board with
1603
+ # registry errors); `not-run` is a step a stopped run did not reach. Only `failed` makes exit 1 (an unknown container,
1604
+ # a uid split, a board that disagrees, a contract that could not be installed: each stops the run).
1605
+ ONBOARD_STATUSES = ('planned', 'changed', 'unchanged', 'ok', 'skipped', 'manual', 'warn', 'failed', 'not-run')
1606
+ # `throttle setup` is headless only when Throttle is already configured. It branches on ONE fact: does its config file
1607
+ # exist (src/cli.rs `cmd_setup` -> `preflight_setup_config`, `text.is_some()`), at `config_path()` =
1608
+ # dirs::config_dir()/throttle/config.toml, i.e. $XDG_CONFIG_HOME, else ~/.config (crates/throttle-base product.rs
1609
+ # `RuntimePaths::config_file`). Without it, setup takes the first-run branch: it adopts the container's own Claude login
1610
+ # as pool account "main" and points Claude Code at a local proxy nobody has started, or it opens the claude.ai login
1611
+ # and waits for a pasted code (`run_oauth_flow`). Neither is for a script, so the probe — a file test, the cheapest
1612
+ # truthful answer, no daemon contacted — decides first, and setup runs only on `configured`. `-f` is deliberately
1613
+ # narrower than "exists": a config path that is not a regular file is left to the person too.
1614
+ THROTTLE_PROBE = ('if ! command -v throttle >/dev/null; then echo absent; '
1615
+ 'elif [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/throttle/config.toml" ]; then echo configured; '
1616
+ 'else echo unconfigured; fi')
1617
+ THROTTLE_SETUP = 'throttle setup --install-clients'
1618
+ THROTTLE_PROBE_TIMEOUT = 20
1619
+ THROTTLE_TIMEOUT = 120
1620
+ SITE_LINE = re.compile(r'^(LANES_[A-Z_]+)=([A-Za-z0-9_.:@/-]*)$') # also what `lanes client-config` hands out
1621
+
1622
+
1623
+ def onboard_step(name, status, detail=''):
1624
+ return {'name': name, 'status': status, 'detail': detail}
1625
+
1626
+
1627
+ def uid_split(home):
1628
+ """Why this account must not write the agents' files into a container home, or None. Lanes writes into the home
1629
+ volume as the HOST account, so what it creates belongs to this account's uid; the agent inside runs as
1630
+ CONTAINER_UID (1000) and cannot read a 0600 file it does not own (`.claude/settings.json`), nor write into a
1631
+ directory this account made (`throttle setup`, as uid 1000, writes into `.claude/` and `.codex/`). Both must be the
1632
+ container uid: this account's effective uid, and the home volume's owner."""
1633
+ want, host = L.CONTAINER_UID, os.geteuid()
1634
+ if host != want:
1635
+ return f'host account uid {host} ≠ container uid {want}'
1636
+ owner = os.stat(home).st_uid
1637
+ if owner != want:
1638
+ return f'container home {home} is owned by uid {owner}, not the container uid {want}'
1639
+ return None
1640
+
1641
+
1642
+ def site_profile():
1643
+ """This server's site facts: the LANES_* KEY=VALUE lines of ~/.config/lanes/site.conf (read, never executed)."""
1644
+ try:
1645
+ text = (Path.home() / '.config/lanes/site.conf').read_text()
1646
+ except OSError:
1647
+ return {}
1648
+ return dict(match.groups() for match in map(SITE_LINE.match, text.splitlines()) if match)
1649
+
1650
+
1651
+ def provision_throttle(container, site, write=True):
1652
+ """Attach a container's agents to Throttle: inside it as uid 1000, a probe (absent / configured / unconfigured,
1653
+ THROTTLE_PROBE), then `throttle setup --install-clients` only when it answers `configured` -> receipt steps.
1654
+ Absent, not configured, failed or timed out, it prints the manual step (a missing Throttle is not an onboarding
1655
+ failure: Lanes runs without it). Never reached on a uid split: onboard stops at `inspect` (DEF-1727). Its output
1656
+ is not relayed — setup names accounts and credential sources, and this printout is pasted
1657
+ into tickets — only a failure's last stderr line is.
1658
+ The hook point of plan Track D: the laptop-pool step is printed, and recorded, only when the site profile names
1659
+ LANES_THROTTLE_REMOTE_URL (the operator-gated pool the user's laptop joins with `throttle remote join`)."""
1660
+ uid = str(L.CONTAINER_UID)
1661
+ probe, setup = (f"docker exec -u {uid} {container} sh -lc '{command}'" for command in (THROTTLE_PROBE, THROTTLE_SETUP))
1662
+ by_hand = f"docker exec -it -u {uid} {container} sh -lc '{THROTTLE_SETUP}'"
1663
+
1664
+ def last_line(text):
1665
+ return next((line.strip() for line in reversed(text.splitlines()) if line.strip()), '')
1666
+
1667
+ def manual(reason, then, err=''):
1668
+ last = last_line(err)
1669
+ print(f'⚠ throttle: {reason}' + (f' ({last[:160]})' if last else ''))
1670
+ print(f' manual step: {then}')
1671
+ return [onboard_step('throttle', 'manual', f'{reason} — {then}')]
1672
+ if not write:
1673
+ print(f'would run: {probe}')
1674
+ print(f' and only when it answers `configured`: {setup}')
1675
+ return provision_laptop([onboard_step('throttle', 'planned', f'{probe}; when configured: {setup}')], site)
1676
+ rc, out, err = L.run_bounded(['docker', 'exec', '-u', uid, container, 'sh', '-lc', THROTTLE_PROBE],
1677
+ THROTTLE_PROBE_TIMEOUT)
1678
+ # A login shell may print (a motd, a profile's echo) before the answer: the answer is the last line.
1679
+ answer = last_line(out) if rc == 0 else ''
1680
+ if answer == 'configured':
1681
+ rc, _out, err = L.run_bounded(['docker', 'exec', '-u', uid, container, 'sh', '-lc', THROTTLE_SETUP],
1682
+ THROTTLE_TIMEOUT)
1683
+ if rc == 0:
1684
+ print(f'attached: {THROTTLE_SETUP} exit 0 in {container}')
1685
+ return provision_laptop([onboard_step('throttle', 'ok', f'{THROTTLE_SETUP} in {container}')], site)
1686
+ reason = f'no answer within {THROTTLE_TIMEOUT} s' if rc is None else f'exit {rc}: `{THROTTLE_SETUP}` failed'
1687
+ steps = manual(reason, f'run it on a terminal to see why: {by_hand}', err)
1688
+ elif answer == 'unconfigured':
1689
+ steps = manual(f'Throttle is installed but not configured (no ~/.config/throttle/config.toml for uid {uid}): '
1690
+ "its first setup signs in to claude.ai, or adopts this container's own Claude login, and "
1691
+ 'needs a person', f'run it once, on a terminal: {by_hand}')
1692
+ elif answer == 'absent':
1693
+ steps = manual("no `throttle` on the container's login PATH",
1694
+ f'install it (npx @vibelord/throttle@latest), then run once, on a terminal: {by_hand}')
1695
+ else:
1696
+ reason = (f'the Throttle probe got no answer within {THROTTLE_PROBE_TIMEOUT} s' if rc is None else
1697
+ f'the Throttle probe failed (exit {rc})' if rc else f'the Throttle probe answered {answer!r}')
1698
+ steps = manual(reason, f'inside {container} (as uid {uid}): install Throttle if it is missing, then: {by_hand}',
1699
+ err)
1700
+ return provision_laptop(steps, site)
1701
+
1702
+
1703
+ def provision_laptop(steps, site):
1704
+ remote = site.get('LANES_THROTTLE_REMOTE_URL')
1705
+ if remote:
1706
+ laptop = (f'on the user\'s laptop: throttle remote join <pairing code> — the code comes from the operator\'s '
1707
+ f'`throttle remote share` for the pool at {remote}')
1708
+ print(f' laptop agents on the pool: {laptop}')
1709
+ steps.append(onboard_step('laptop-pool', 'manual', laptop))
1710
+ return steps
1711
+
1712
+
1713
+ def onboard_args(args):
1714
+ if not args or args[0].startswith('-'):
1715
+ raise L.LanesError(ONBOARD_USAGE)
1716
+ opts, rest = {}, list(args[1:])
1717
+ while rest:
1718
+ word = rest.pop(0)
1719
+ if word in ('--container', '--workspace', '--repo', '--title') and rest and word not in opts:
1720
+ opts[word] = rest.pop(0)
1721
+ elif word in ('--write', '--no-mcp'):
1722
+ opts[word] = True
1723
+ else:
1724
+ raise L.LanesError(ONBOARD_USAGE)
1725
+ if '--container' not in opts or ('--workspace' in opts) != ('--repo' in opts) or \
1726
+ ('--title' in opts and '--workspace' not in opts):
1727
+ raise L.LanesError(ONBOARD_USAGE)
1728
+ for label, value in (('login', args[0]), ('container', opts['--container'])):
1729
+ if not L.IDENT.fullmatch(value): # fullmatch: IDENT's `$` would accept a trailing newline
1730
+ raise L.LanesError(f'Invalid {label}: {value!r}')
1731
+ return args[0], opts
1732
+
1733
+
1734
+ def onboard_receipt(login, container, board, steps):
1735
+ """~/.local/state/lanes/onboard/<login>-<UTC>.json; a second run in the same second gets its own file."""
1736
+ now = time.gmtime()
1737
+ stem = f"{login}-{time.strftime('%Y%m%dT%H%M%SZ', now)}"
1738
+ path, n = L.STATE / 'onboard' / f'{stem}.json', 2
1739
+ while path.exists():
1740
+ path, n = L.STATE / 'onboard' / f'{stem}-{n}.json', n + 1
1741
+ document = {'version': 1, 'login': login, 'container': container, 'workspace': board,
1742
+ 'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', now), 'steps': steps}
1743
+ L.atomic_write(path, json.dumps(document, indent=1, ensure_ascii=False) + '\n')
1744
+ return path
1745
+
1746
+
1747
+ def cmd_onboard(args):
1748
+ """`lanes onboard`: one verb provisions a user's container and agents — the board (site_edit), the lane contract,
1749
+ the claude and codex lane-signal hooks, the Throttle attachment, canonical create recipes, sync and the doctor —
1750
+ with a receipt. A dry run (every diff, every command) unless --write. Re-running changes nothing that is done.
1751
+ The container (inspected first, read-only, so that a mistyped --container never reaches the site catalog), the
1752
+ board and the contract are prerequisites: their failure stops the run, and every step it did not reach is still
1753
+ named, as `not-run`, in the table and the receipt. cursor-sync needs the board in the
1754
+ registry, so a sync runs before it, and a second one after it when it rewrote terminals.json, so that the
1755
+ container's ~/.lz/registry.json (what `lane open` reads) carries the canonical recipes."""
1756
+ login, opts = onboard_args(args)
1757
+ write, no_mcp = bool(opts.get('--write')), bool(opts.get('--no-mcp'))
1758
+ container, board, repo = opts['--container'], opts.get('--workspace'), opts.get('--repo')
1759
+ site_edit = libexec_module('site_edit') if board else None
1760
+ site = site_profile()
1761
+ steps = []
1762
+
1763
+ def run(name, step, required=False):
1764
+ print(f'== {name}')
1765
+ try:
1766
+ result = step()
1767
+ except (L.LanesError, OSError, ValueError, KeyError) as error: # KeyError: a hand-edited site missing a key
1768
+ print(f'✗ {error}')
1769
+ result = ('failed', str(error))
1770
+ found = result if isinstance(result, list) else [onboard_step(name, *result)]
1771
+ steps.extend(found)
1772
+ return not (required and any(s['status'] == 'failed' for s in found))
1773
+
1774
+ volume = {}
1775
+
1776
+ def home():
1777
+ return volume['home']
1778
+
1779
+ def inspect_step():
1780
+ """Read-only (`docker inspect`), in a dry run too: every later step needs the container, and the board names
1781
+ it, so a mistyped --container must stop the run before the board is written to the site catalog. It also
1782
+ decides whether this account may write into that home for the agent at all (`uid_split`): on a split the run
1783
+ stops here, before the board, the contract or any other write (DEF-1727)."""
1784
+ info = L.container_info(container)
1785
+ if not info.get('home'):
1786
+ raise L.LanesError(f'{container}: home volume not found')
1787
+ volume['home'] = Path(info['home'])
1788
+ volume['split'] = uid_split(volume['home'])
1789
+ if volume['split']:
1790
+ print(f"✗ {volume['split']}: what this account would create in {volume['home']} (the lane contract, the "
1791
+ f'hooks) would not be the agent\'s, uid {L.CONTAINER_UID} — onboarding stops here and writes nothing; '
1792
+ f'run it from a host account with uid {L.CONTAINER_UID}')
1793
+ return 'failed', (f"home volume {volume['home']}; {volume['split']} — nothing written: run lanes onboard "
1794
+ f'from a host account with uid {L.CONTAINER_UID}')
1795
+ return 'ok', f"home volume {volume['home']}"
1796
+
1797
+ def board_step():
1798
+ if not board:
1799
+ return 'skipped', 'no --workspace: the container and its agents only'
1800
+ title = opts.get('--title')
1801
+ if title is None: # a board an earlier run titled keeps its title, so a re-run agrees with it
1802
+ catalog_file = site_edit.site_dir() / 'catalog.json'
1803
+ catalog = L.read_site_json(catalog_file) if catalog_file.exists() else {}
1804
+ boards = catalog.get('workspaces') # not a list: add_workspace refuses it
1805
+ title = next((w.get('title') for w in boards if isinstance(w, dict) and w.get('id') == board), None) \
1806
+ if isinstance(boards, list) else None
1807
+ title = title or board
1808
+ result = site_edit.add_workspace(board, title, container, repo, write=write)
1809
+ if result['status'] == 'mismatch':
1810
+ for difference in result['differences']:
1811
+ print(f' {difference}')
1812
+ return 'failed', (f"{board} is already in {result['catalog']} and says otherwise: "
1813
+ + '; '.join(result['differences']))
1814
+ if result['status'] == 'exists':
1815
+ print(f"{board}: already in the catalog ({result['catalog']}) and agrees; nothing written")
1816
+ return 'unchanged', f"{board} in {result['catalog']}"
1817
+ sys.stdout.write(site_edit.diff_text(result))
1818
+ for backup in result['backups']:
1819
+ print(f'backup {backup}')
1820
+ return ('changed' if write else 'planned'), f"{board} in {result['catalog']}"
1821
+
1822
+ def contract_step():
1823
+ root = home() / '.lz'
1824
+ if not write:
1825
+ print(f'would install the lane contract into {root} and link {Path.home() / ".lz" / container}')
1826
+ return 'planned', str(root)
1827
+ cmd_install_container([container] + (['--no-mcp'] if no_mcp else []))
1828
+ return 'ok', str(root)
1829
+
1830
+ def mcp_step():
1831
+ if no_mcp:
1832
+ return 'skipped', '--no-mcp: left out'
1833
+ return 'skipped', 'lane-mcp is not shipped yet (plan W6): install-container has nothing to add'
1834
+
1835
+ def hooks_step(agent):
1836
+ def step():
1837
+ target = home()
1838
+ files = [target / HOOK_TARGETS[agent][1]] + ([target / '.codex/config.toml'] if agent == 'codex' else [])
1839
+ before = [path.read_bytes() if path.exists() else None for path in files]
1840
+ cmd_hooks(['install', '--agent', agent, '--container', container, '--write' if write else '--dry-run'])
1841
+ if not write:
1842
+ return 'planned', str(files[0])
1843
+ changed = [str(path) for path, old in zip(files, before)
1844
+ if (path.read_bytes() if path.exists() else None) != old]
1845
+ return ('changed', ', '.join(changed)) if changed else ('unchanged', str(files[0]))
1846
+ return step
1847
+
1848
+ def sync_step():
1849
+ if not write:
1850
+ print('would run: lanes sync')
1851
+ return 'planned', 'lanes sync'
1852
+ if cmd_sync([]) == 0:
1853
+ return 'ok', 'registry published'
1854
+ return 'warn', 'a board has registry errors (listed above)'
1855
+
1856
+ cursor = {}
1857
+
1858
+ def cursor_step():
1859
+ if not board:
1860
+ return 'skipped', 'no --workspace'
1861
+ registry_file = site_edit.registry_path(repo)
1862
+ host_file = L.host_path({'home': str(home())}, registry_file)
1863
+ again = f'lanes cursor-sync {board} --canonicalize --write'
1864
+ if not host_file.exists():
1865
+ example = L.REPO / 'workspaces.example/terminals.example.json'
1866
+ print(f'{container} has no {registry_file}: start it from {example} (every terminal needs an explicit tmux '
1867
+ f'binding), then run: {again} && lanes sync')
1868
+ return 'manual', f'no {registry_file}; start from workspaces.example/terminals.example.json, then {again}'
1869
+ known = next((w for w in (L.read_json(L.STATE / 'registry.json') or {}).get('workspaces', [])
1870
+ if w.get('id') == board), None)
1871
+ if not write:
1872
+ if known and not known.get('errors'):
1873
+ libexec_module('cursor_sync').run([board, '--canonicalize'])
1874
+ else:
1875
+ print(f'after the sync: {again}')
1876
+ return 'planned', registry_file
1877
+ if not known or known.get('errors'):
1878
+ reason = '; '.join((known or {}).get('errors') or ['the board is not in the registry'])
1879
+ return 'warn', f'not run — {reason}; fix it, then {again}'
1880
+ before = host_file.read_bytes()
1881
+ libexec_module('cursor_sync').run([board, '--canonicalize', '--write'])
1882
+ cursor['changed'] = host_file.read_bytes() != before
1883
+ return ('changed' if cursor['changed'] else 'unchanged'), registry_file
1884
+
1885
+ def resync_step():
1886
+ if not write:
1887
+ print('would run: lanes sync (again, when cursor-sync rewrote terminals.json)')
1888
+ return 'planned', 'only when cursor-sync rewrote terminals.json'
1889
+ if not cursor.get('changed'):
1890
+ return 'skipped', 'cursor-sync changed nothing'
1891
+ return sync_step()
1892
+
1893
+ def doctor_step():
1894
+ if not write:
1895
+ print('would run: lanes doctor (reported, never fatal)')
1896
+ return 'planned', 'lanes doctor'
1897
+ return ('ok', 'lanes doctor exit 0') if cmd_doctor([]) == 0 else ('warn', 'lanes doctor exit 1: see ✗ above')
1898
+
1899
+ print(f"lanes onboard {login} -> container {container}" + (f', board {board}' if board else '')
1900
+ + ('' if write else ' (dry run)'))
1901
+ plan = [('inspect', inspect_step, True), ('board', board_step, True), ('contract', contract_step, True),
1902
+ ('lane-mcp', mcp_step, False), ('hooks-claude', hooks_step('claude'), False),
1903
+ ('hooks-codex', hooks_step('codex'), False),
1904
+ ('throttle', lambda: provision_throttle(container, site, write), False),
1905
+ ('sync', sync_step, False), ('cursor-sync', cursor_step, False), ('resync', resync_step, False),
1906
+ ('doctor', doctor_step, False)]
1907
+ halted = None
1908
+ for name, step, required in plan:
1909
+ if halted: # a stopped run still names every step it did not reach (the laptop step rides with throttle)
1910
+ names = [name] + (['laptop-pool'] if name == 'throttle' and site.get('LANES_THROTTLE_REMOTE_URL') else [])
1911
+ why = f" ({volume['split']})" if halted == 'inspect' and volume.get('split') else ''
1912
+ steps.extend(onboard_step(n, 'not-run', f'not reached: {halted} failed{why}') for n in names)
1913
+ elif not run(name, step, required):
1914
+ halted = name
1915
+ print()
1916
+ for key, value in (('login', login), ('container', container), ('workspace', board or '-')):
1917
+ print(f'{key + ":":<14}{value}')
1918
+ for step in steps:
1919
+ print(f"{step['name'] + ':':<14}{step['status']:<10} {step['detail']}")
1920
+ if write:
1921
+ print(f"{'receipt:':<14}{onboard_receipt(login, container, board, steps)}")
1922
+ else:
1923
+ print('dry run: nothing written (add --write to apply)')
1924
+ return 1 if any(step['status'] == 'failed' for step in steps) else 0
1925
+
1926
+
1169
1927
  def cmd_rebuilt(args):
1170
1928
  """Exit 0 if this device's view session was rebuilt in the last minute (the client then reconnects)."""
1171
1929
  try:
@@ -1175,9 +1933,49 @@ def cmd_rebuilt(args):
1175
1933
  return 0 if time.time() - stamp < 60 else 1
1176
1934
 
1177
1935
 
1936
+ def server_version():
1937
+ """This server's release (lanes/VERSION), as `lanes hello` reports it; 'dev' without the file."""
1938
+ path = L.REPO / 'VERSION'
1939
+ return path.read_text().strip() if path.exists() else 'dev'
1940
+
1941
+
1942
+ VERSION_RE = re.compile(r'(\d+)\.(\d+)\.(\d+)(?:[-+][A-Za-z0-9.]+)?')
1943
+
1944
+
1945
+ def version_key(text):
1946
+ """(major, minor, patch) of a release string, else None ('dev', or client text that is no version). A pre-release
1947
+ or build suffix is not compared: 1.1.0-rc.1 counts as 1.1.0, so a release candidate is never told to update."""
1948
+ match = VERSION_RE.fullmatch(text or '') if len(text or '') <= 32 else None
1949
+ return tuple(int(part) for part in match.groups()) if match else None
1950
+
1951
+
1952
+ # The newest published release whose lanes-launch passes `--client` but no `--client-version` (@vibelord/lanes 1.0.4).
1953
+ # A client without the flag is this release or an earlier one, so it is older only than a server past it. A fixed fact
1954
+ # about what was published: bumping lanes/VERSION never moves it.
1955
+ LAST_FLAGLESS_CLIENT = (1, 0, 4)
1956
+
1957
+
1958
+ def client_notice(args):
1959
+ """The one line `lanes attach` shows a lanes-launch older than this server (W8 B4), or None. lanes-launch passes
1960
+ `--client-version` (the VERSION copied beside it); one that passes `--client` but no version is at most
1961
+ LAST_FLAGLESS_CLIENT and has no update verb, so a server past that release tells it the npm command (DEF-1621: a
1962
+ 1.0.4 server must not tell a 1.0.4 client to update to itself). A person's own `lanes attach` passes neither:
1963
+ nothing to say. The client's text is printed only when it parses as a version."""
1964
+ server = server_version()
1965
+ if version_key(server) is None:
1966
+ return None
1967
+ if '--client-version' in args:
1968
+ client = args[args.index('--client-version') + 1] if '--client-version' in args[:-1] else ''
1969
+ older = version_key(client) is not None and version_key(client) < version_key(server)
1970
+ return f'lanes client {client} is older than the server ({server}) — run lanes-launch update' if older else None
1971
+ if '--client' in args and version_key(server) > LAST_FLAGLESS_CLIENT:
1972
+ return f'lanes client is older than the server ({server}) — update it: npx @vibelord/lanes@latest'
1973
+ return None
1974
+
1975
+
1178
1976
  def cmd_hello(args):
1179
1977
  device = resolve_device(args[0], args) if args else '-'
1180
- version = (L.REPO / 'VERSION').read_text().strip() if (L.REPO / 'VERSION').exists() else 'dev'
1978
+ version = server_version()
1181
1979
  boards = len(L.load_registry()['workspaces']) if (L.STATE / 'registry.json').exists() else 0
1182
1980
  print(f'ok device={device} version={version} boards={boards}')
1183
1981
  return 0
@@ -1188,7 +1986,7 @@ def cmd_client_config(_args):
1188
1986
  need not be written into the distributed package. KEY=VALUE lines, LANES_* keys only."""
1189
1987
  path = Path.home() / '.config/lanes/site.conf'
1190
1988
  for line in (path.read_text().splitlines() if path.exists() else []):
1191
- if re.match(r'^LANES_[A-Z_]+=[A-Za-z0-9_.:@/-]*$', line):
1989
+ if SITE_LINE.match(line):
1192
1990
  print(line)
1193
1991
  return 0
1194
1992
 
@@ -1247,6 +2045,59 @@ def cmd_studio_host(args):
1247
2045
  return libexec_module('studio_host').main(args) # verbs, refusals and exit codes are studio_host's
1248
2046
 
1249
2047
 
2048
+ def cmd_keys(args):
2049
+ return libexec_module('keys').main(args) # verbs, refusals and exit codes are keys.py's
2050
+
2051
+
2052
+ def cmd_invite(args):
2053
+ return libexec_module('enrol').invite_main(args) # verbs, refusals and exit codes are enrol.py's
2054
+
2055
+
2056
+ def cmd_enrol(args):
2057
+ return libexec_module('enrol').enrol_main(args)
2058
+
2059
+
2060
+ def cmd_offboard(args):
2061
+ """lanes offboard <device> --yes: enrol.py owns the order, the refusals and the key step; these are the view half."""
2062
+ return libexec_module('enrol').offboard_main(args, forget=lambda device: cmd_device(['forget', device]),
2063
+ revoke_token=revoke_web_token, end_view=end_view)
2064
+
2065
+
2066
+ def revoke_web_token(device, windows=()):
2067
+ """Offboard step 3 -> (status, sentence), status 'done' | 'warn' | 'failed'; never raises. Signs the machine's
2068
+ browsers and phones out through libexec/web_tokens.py (offboard): every Zellij web login token `lanes web --token`
2069
+ recorded for `device`, for each of its `windows` (devices/<view>.json `base` = device) and for each view minted
2070
+ with `device` as its machine (`lanes-launch web phone|laptop`), never another machine's. It runs as every token
2071
+ command does — the pinned binary in the pinned token environment (token_env), refused while the running
2072
+ lanes-web.service reads another database (token_db_refusal), where a revocation would sign nothing out. Zellij
2073
+ 0.45.1 names every token token_<n> (measured 2026-10-05), so the record, not the name, says whose a token is."""
2074
+ again = f'`lanes offboard {device} --yes` again finishes it'
2075
+ try:
2076
+ refusal = token_db_refusal()
2077
+ if refusal:
2078
+ return 'failed', f'the web login tokens were not revoked: {refusal}; then {again}'
2079
+ return libexec_module('web_tokens').offboard([device, *windows], list(ZELLIJ_BASE), token_env())
2080
+ except (L.LanesError, OSError) as error:
2081
+ return 'failed', f'the web login tokens were not revoked ({error}); {again}'
2082
+
2083
+
2084
+ def end_view(device):
2085
+ """Offboard step 4 -> (status, sentence): end the view session `device` the way `lanes rebuild` does (kill, then
2086
+ delete the serialized session) without its reconnect stamp and without recreating it. No lane is touched."""
2087
+ try:
2088
+ live = live_sessions()
2089
+ except L.LanesError as error:
2090
+ return 'failed', f'whether the view session {device} runs is unknown ({error}); `lanes offboard {device} --yes` again'
2091
+ if device not in live:
2092
+ return 'none', f'no view session {device} is running'
2093
+ rc = zellij(['kill-session', device], 30)[0]
2094
+ if rc != 0:
2095
+ return 'failed', (f'the view session {device} did not end ('
2096
+ + ('no answer in 30 s' if rc is None else f'exit {rc}') + f'); `lanes offboard {device} --yes` again')
2097
+ zellij(['delete-session', '--force', device], 30)
2098
+ return 'done', f'ended view session {device} (no lane was touched)'
2099
+
2100
+
1250
2101
  def libexec_module(name):
1251
2102
  try:
1252
2103
  return __import__(name)
@@ -1384,7 +2235,9 @@ def socket_report(containers, live_by_container):
1384
2235
  def cmd_device(args):
1385
2236
  """lanes device list | lanes device forget <name>: the devices/<name>.json files `lanes ensure` (lanes-ensure.timer)
1386
2237
  recreates a view session from, and `sync`/`upgrade-session` update. Forgetting removes that file only: a running
1387
- view session is never ended here."""
2238
+ view session is never ended here. list prints the owner's login as the next rewrite records it (device_owner: the
2239
+ member record's whenever one exists, so it agrees with `lanes keys list`; else the validated recorded one), blank
2240
+ for none — a devices/*.json or members/*.json value never reaches the terminal unchecked."""
1388
2241
  usage = 'usage: lanes device list | lanes device forget <name>'
1389
2242
  devices = L.STATE / 'devices'
1390
2243
  if args == ['list']:
@@ -1393,11 +2246,15 @@ def cmd_device(args):
1393
2246
  except L.LanesError:
1394
2247
  live = None
1395
2248
  for meta_file in sorted(devices.glob('*.json')):
1396
- meta = L.read_json(meta_file) or {}
2249
+ meta = L.read_json(meta_file)
2250
+ meta = meta if isinstance(meta, dict) else {}
1397
2251
  kind = 'phone' if meta.get('mobile') else f"desktop, {int(meta.get('viewports') or 1)} viewports"
1398
2252
  running = 'unknown (zellij did not answer)' if live is None else (
1399
2253
  'running' if meta_file.stem in live else 'not running')
1400
- print(f'{meta_file.stem:<16} {kind:<24} {running}' + (f" base {meta['base']}" if meta.get('base') else ''))
2254
+ owner = device_owner(meta_file.stem, meta) or {} # validated; blank when none
2255
+ base = meta.get('base') if isinstance(meta.get('base'), str) and L.IDENT.match(meta['base']) else None
2256
+ print(f"{meta_file.stem:<16} {kind:<24} {owner.get('login', ''):<24} {running}"
2257
+ + (f" base {base}" if base else ''))
1401
2258
  return 0
1402
2259
  if len(args) != 2 or args[0] != 'forget':
1403
2260
  raise L.LanesError(usage)
@@ -1424,6 +2281,77 @@ def cmd_device(args):
1424
2281
  return 0
1425
2282
 
1426
2283
 
2284
+ def web_doctor():
2285
+ """`lanes doctor`'s browser/phone lines -> [(mark, text)], mark ✓ | ⚠ | ✗ (LNS-50). One bounded
2286
+ `systemctl --user show` for both web units, a read of the running lanes-web's environment, `tailscale status --json`
2287
+ and, when the host has a Tailscale name, `tailscale serve status --json`; read-only.
2288
+ unit enabled and active ✓ · enabled but not active ✗ · not enabled ⚠ (no browser view yet, or one that a reboot
2289
+ loses when it runs anyway) · systemctl silent ✗ (NOT checked) · no systemctl ⚠
2290
+ token database (lanes-web runs a process): the one the token commands use ✓ · another ✗ · unreadable ⚠
2291
+ Serve mapping present ✓ · missing or foreign ✗ once a web unit is enabled, else ⚠ · unreadable ⚠ · no tailscale ⚠
2292
+ · no Tailscale name ⚠ (cmd_web's own guard: there `lanes web` configures no Serve and prints a loopback URL)"""
2293
+ lines, enabled_any = [], False
2294
+ if not shutil.which('systemctl'):
2295
+ lines.append(('⚠', 'web units not checked — no systemctl on this host (`lanes web` needs systemd user services)'))
2296
+ else:
2297
+ argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
2298
+ '-p', 'MainPID', *WEB_UNITS]
2299
+ rc, out, err = L.run_bounded(argv, 10, env=clean_env())
2300
+ if rc != 0:
2301
+ lines.append(('✗', f"web units NOT checked — systemctl --user {'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'}"))
2302
+ else:
2303
+ units = {}
2304
+ for block in out.strip().split('\n\n'):
2305
+ props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
2306
+ units[props.get('Id', '')] = props
2307
+ for unit in WEB_UNITS:
2308
+ props = units.get(unit, {})
2309
+ active = props.get('ActiveState') or 'unknown'
2310
+ enabled = props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
2311
+ enabled_any = enabled_any or enabled
2312
+ if enabled and active == 'active':
2313
+ lines.append(('✓', f'{unit} active'))
2314
+ elif enabled:
2315
+ lines.append(('✗', f'{unit} enabled but {active} — journalctl --user -u {unit}; '
2316
+ f'systemctl --user restart {unit}'))
2317
+ elif active == 'active':
2318
+ lines.append(('⚠', f'{unit} active but not enabled: a reboot loses it — `lanes web <device>` enables it'))
2319
+ else:
2320
+ lines.append(('⚠', f'{unit} not enabled (no browser or phone view yet) — `lanes web <device>` enables it'))
2321
+ pid = units.get(WEB_UNITS[0], {}).get('MainPID', '')
2322
+ if pid.isdigit() and int(pid) > 0:
2323
+ server, why = server_token_db(int(pid))
2324
+ if server is None:
2325
+ lines.append(('⚠', f'login-token database not compared — lanes-web.service: {why}'))
2326
+ elif same_file(server, token_db()):
2327
+ lines.append(('✓', f'lanes-web.service reads login tokens from {token_db()}, where '
2328
+ '`lanes web --token` and `lanes token` keep them'))
2329
+ else:
2330
+ lines.append(('✗', token_db_mismatch(server)))
2331
+ if not shutil.which('tailscale'):
2332
+ lines.append(('⚠', 'Tailscale Serve not checked — tailscale is not installed: browser and phone views have no '
2333
+ 'private HTTPS'))
2334
+ return lines
2335
+ if not tailnet_name():
2336
+ lines.append(('⚠', 'Tailscale Serve not checked — this host has no Tailscale name (logged out, no MagicDNS '
2337
+ 'name, or `tailscale status` did not answer): `lanes web` configures no Serve mapping and '
2338
+ 'prints a loopback URL that opens only here'))
2339
+ return lines
2340
+ state, detail = serve_state()
2341
+ severity = '✗' if enabled_any else '⚠'
2342
+ if state == 'present':
2343
+ lines.append(('✓', f'Tailscale Serve https:{SERVE_PORT} -> {WEB_LOOPBACK}'))
2344
+ elif state == 'missing':
2345
+ lines.append((severity, f'Tailscale Serve has no https:{SERVE_PORT} -> {WEB_LOOPBACK} mapping, so browser and '
2346
+ 'phone views do not open — `lanes web <device>` adds it'))
2347
+ elif state == 'other':
2348
+ lines.append((severity, f'Tailscale Serve maps https:{SERVE_PORT} / to {detail}, not the Lanes frontend — free '
2349
+ f'the port (tailscale serve --https={SERVE_PORT} off), then `lanes web <device>`'))
2350
+ else:
2351
+ lines.append(('⚠', f'Tailscale Serve not checked — {detail}'))
2352
+ return lines
2353
+
2354
+
1427
2355
  def cmd_doctor(_args):
1428
2356
  problems = 0
1429
2357
 
@@ -1457,6 +2385,16 @@ def cmd_doctor(_args):
1457
2385
  'run lanes sync — ' + '; '.join(stale_sources[:3]) + (' …' if len(stale_sources) > 3 else ''))
1458
2386
  for name, info in containers.items():
1459
2387
  check(bool(info.get('running')), f'container {name} running', f"init pid {info.get('pid')}")
2388
+ # Lanes writes the agents' files into this home as this account: a uid split leaves them unreadable to
2389
+ # the agent (uid 1000). A warning: nothing is broken until something writes there.
2390
+ try:
2391
+ split = uid_split(Path(info['home'])) if info.get('home') else None
2392
+ except OSError as error:
2393
+ split = f'home volume not readable ({error.strerror or error}): its owner was not checked'
2394
+ if split:
2395
+ print(f'⚠ container {name}: {split} — files this account writes there (lanes install-container, '
2396
+ 'lanes hooks install, lanes onboard) would not be the agent\'s; lanes onboard stops before '
2397
+ 'writing and lanes hooks install --write refuses')
1460
2398
  # Recipes, identity, session names, crowded `default` sockets, orphan socket files: warnings, never a ✗
1461
2399
  # (they describe the boards' declarations and the containers' /tmp, which doctor reports and never changes).
1462
2400
  lines, warnings = recipe_report(registry)
@@ -1515,6 +2453,11 @@ def cmd_doctor(_args):
1515
2453
  check(not stale_pickers, 'phone picker panes report their own title (DEF-1531)',
1516
2454
  'built from a layout that names the picker pane, so the phone shows no counts; upgrade-session does not '
1517
2455
  're-lay-out a tab — run ' + '; '.join(f'lanes rebuild {d}' for d in stale_pickers))
2456
+ for mark, text in web_doctor(): # browser/phone: web units and the Tailscale Serve mapping (LNS-50)
2457
+ if mark == '⚠':
2458
+ print(f'⚠ {text}')
2459
+ else:
2460
+ check(mark == '✓', text)
1518
2461
  print('PRESSURE')
1519
2462
  health = L.compute_health()
1520
2463
  print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
@@ -1813,7 +2756,9 @@ COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_cli
1813
2756
  'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
1814
2757
  'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
1815
2758
  'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'hooks': cmd_hooks, 'economy': cmd_economy,
1816
- 'preview': cmd_preview, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev, 'studio-host': cmd_studio_host}
2759
+ 'preview': cmd_preview, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev, 'studio-host': cmd_studio_host,
2760
+ 'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,
2761
+ 'token': cmd_token, 'onboard': cmd_onboard}
1817
2762
 
1818
2763
  if __name__ == '__main__':
1819
2764
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: