@vibelord/lanes 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -15
- package/bin/lanes-install.mjs +8 -2
- package/lib/plan.mjs +34 -1
- package/package.json +2 -2
- package/payload/lanes/README.md +91 -33
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +994 -49
- package/payload/lanes/client/lanes-launch +160 -14
- package/payload/lanes/client/ssh.stanza.in +11 -2
- package/payload/lanes/docs/ARCHITECTURE.md +88 -3
- package/payload/lanes/docs/INSTALL.md +297 -43
- package/payload/lanes/docs/RUNBOOK.md +458 -31
- package/payload/lanes/docs/USERGUIDE.md +1612 -0
- package/payload/lanes/install/enable-phone.sh +8 -20
- package/payload/lanes/install/enable-web.sh +2 -2
- package/payload/lanes/install/host-install.sh +6 -0
- package/payload/lanes/install.sh +888 -52
- package/payload/lanes/libexec/enrol.py +695 -0
- package/payload/lanes/libexec/enrold.py +1264 -0
- package/payload/lanes/libexec/keys.py +644 -0
- package/payload/lanes/libexec/lanes_lib.py +14 -6
- package/payload/lanes/libexec/members.py +297 -0
- package/payload/lanes/libexec/notify.py +146 -21
- package/payload/lanes/libexec/qr.py +217 -0
- package/payload/lanes/libexec/site_edit.py +237 -0
- package/payload/lanes/libexec/web_tokens.py +315 -0
- package/payload/lanes/site/README.md +13 -1
- package/payload/lanes/site/beyondwork/site.conf +3 -0
- package/payload/lanes/site/site.example.conf +5 -0
- package/payload/lanes/sudo/one-time-root.sh +1 -1
- package/payload/lanes/systemd/lanes-enrold.service +22 -0
- package/payload/lanes/systemd/lanes-notify.service +4 -4
- package/payload/lanes/systemd/lanes-web.service +4 -0
- package/payload/lanes/web/Caddyfile +10 -0
- package/payload/lanes/web/README.md +8 -2
- package/payload/lanes/workspaces.example/README.md +6 -1
- package/payload/manifest.json +7 -4
package/payload/lanes/bin/lanes
CHANGED
|
@@ -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),
|
|
18
|
-
|
|
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>
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
(
|
|
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
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
843
|
-
silent or has no name. Read-only
|
|
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
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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 = (
|
|
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
|
|
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)
|
|
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
|
-
|
|
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:
|