@vibelord/lanes 1.1.0 → 1.2.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 (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +625 -15
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +653 -81
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +792 -140
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +207 -25
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +128 -76
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -8,7 +8,8 @@ braille spinner while working, and rings BEL on turn completion. Claude's title
8
8
 
9
9
  The agent{} status vocabulary is fixed here and reused by statusd, the sidebar, the hook sidecar and the phone:
10
10
  working | needs_input | needs_approval | done | errored | shell | idle. It is a NEW key beside status.json's
11
- six-word `state`, never a replacement for it.
11
+ six-word `state`, never a replacement for it. So are the attention SECTION each word belongs to and its RANK, and
12
+ `settle`, the debounce statusd applies between `merge`'s verdict and what it publishes (cockpit v3 S1).
12
13
 
13
14
  Two stale-signal traps, both MEASURED 2026-10-04 on this host: (1) a Claude session file outlives its pid, and pids
14
15
  wrap (pid_max 4194304, live pids already ~2.3M), so a recycled pid would resurrect a dead session — every file
@@ -37,6 +38,15 @@ SHELL_TITLES = {'', 'work', 'bash', 'sh', 'zsh', 'fish'}
37
38
  STATUS_FROM_CLAUDE = {'busy': 'working', 'idle': 'needs_input', 'shell': 'shell', 'waiting': 'needs_input'}
38
39
  STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle')
39
40
  SHELL_COMMANDS = {'bash', 'sh', 'dash', 'zsh', 'fish', 'ksh', 'tcsh', 'csh'}
41
+ # The attention section each status belongs to (None = the workspace's other lanes) and how urgent a section is: the
42
+ # ONE table statusd's `settle` and the sidebar's sections (`sidebar.SECTION_OF`, its ATTENTION order) read.
43
+ SECTION = {'needs_approval': 'needs', 'errored': 'needs', 'needs_input': 'needs', 'working': 'working', 'done': 'done',
44
+ 'shell': None, 'idle': None}
45
+ RANK = {'needs': 3, 'working': 2, 'done': 1, None: 0}
46
+ # Seconds a LOWER-ranked word must hold before the published status leaves a section, keyed by the section LEFT.
47
+ # Entering a higher section never waits. GONE_DWELL: seconds without any agent signal before agent{} is dropped.
48
+ DWELL = {'needs': 8, 'working': 5, 'done': 5, None: 0}
49
+ GONE_DWELL = 5
40
50
 
41
51
 
42
52
  def pane_has_child(proc_root, pid):
@@ -244,3 +254,66 @@ def merge(pane_row, claude_by_pane, sidecar):
244
254
  title['since'] = 0
245
255
  return title
246
256
  return None
257
+
258
+
259
+ def _stamp(value):
260
+ """A usable epoch second from a published or source field, or None (absent, bool, zero, negative, not a number)."""
261
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
262
+ return None
263
+ return int(value)
264
+
265
+
266
+ def settle(prev, raw, now):
267
+ """The agent{} statusd publishes for one lane this cycle: `merge`'s verdict `raw` debounced against `prev`, the
268
+ agent{} it published last cycle (statusd's previous payload — status.json itself after a restart), at `now` on
269
+ statusd's clock. None = publish no agent key. Pure; neither input is mutated.
270
+
271
+ Until cockpit v3 (plan context item 4, read in the code 2026-10-06) statusd published `merge`'s word every 3 s
272
+ cycle with no hysteresis, so a lane whose signal flips (a turn ending and restarting, a dialog opened and closed, a
273
+ Codex title losing its spinner for a frame) moved between the sidebar's sections every cycle, and its `since`
274
+ restarted on every flip — for a Codex lane read from its title, on every output (`window_activity`). Rules (S1,
275
+ LNS-59), by SECTION and RANK:
276
+
277
+ * higher section than the one published: publish now (a question or an approval is never delayed);
278
+ * same section: publish now, `stable_since` kept (needs_input -> needs_approval changes only the urgency);
279
+ * lower section: publish only once the raw word has held for DWELL[section left] — `now - raw_since`;
280
+ * no agent at all: the published agent{} stays until GONE_DWELL has passed, then the key goes.
281
+
282
+ Published keys: `merge`'s own (harness, name, session_id, pid, source, event) from the verdict that was PUBLISHED —
283
+ a held status keeps the dict it was published with — plus `status` (the debounced word), `since` (when that status
284
+ began: the source's own start, never later than the cycle that first saw it, and kept while the status is
285
+ unchanged, so a moving source stamp is no longer a clock), `raw_status` (`merge`'s word this cycle, None while the
286
+ agent is gone), `raw_since` (statusd's clock when that word was first seen; reset only when it differs from the
287
+ previous `raw_status`, never a source timestamp) and `stable_since` (statusd's clock when the lane entered the
288
+ section it is published in). All stamps are integer seconds, so a steady lane publishes byte-identically and
289
+ status.json is not rewritten. A `prev` that is not an agent{} (a zmx row's lane-run record, an unknown word) is no
290
+ previous cycle; one written before these keys existed is adopted with `raw_since`/`stable_since` = `now`."""
291
+ now = int(now)
292
+ if not (isinstance(raw, dict) and raw.get('status') in STATUSES):
293
+ raw = None
294
+ if not (isinstance(prev, dict) and prev.get('status') in STATUSES):
295
+ prev = None
296
+ if prev is None:
297
+ return None if raw is None else _published(raw, now, now)
298
+ word = raw['status'] if raw else None
299
+ last = prev['raw_status'] if 'raw_status' in prev else prev['status'] # a pre-v3 entry: its status
300
+ raw_since = (_stamp(prev.get('raw_since')) if word == last else None) or now
301
+ stable = _stamp(prev.get('stable_since')) or now
302
+ held = dict(prev, raw_status=word, raw_since=raw_since, stable_since=stable)
303
+ if raw is None:
304
+ return None if now - raw_since >= GONE_DWELL else held
305
+ old, new = SECTION[prev['status']], SECTION[word]
306
+ if new == old:
307
+ kept = _stamp(prev.get('since')) if word == prev['status'] else None
308
+ return dict(_published(raw, raw_since, stable), since=kept) if kept else _published(raw, raw_since, stable)
309
+ if RANK[new] > RANK[old] or now - raw_since >= DWELL[old]:
310
+ return _published(raw, raw_since, now)
311
+ return held
312
+
313
+
314
+ def _published(raw, raw_since, stable_since):
315
+ """`raw` as published: its `since` the start of the status (the source's, capped at `raw_since`, the cycle that
316
+ first saw the word), then the three settle keys."""
317
+ source = _stamp(raw.get('since'))
318
+ return dict(raw, since=min(source, raw_since) if source else raw_since, raw_status=raw['status'],
319
+ raw_since=raw_since, stable_since=stable_since)
@@ -9,6 +9,7 @@ The repository's `.vscode/terminals.json` stays the source of truth for WHICH la
9
9
  view shows the lane);
10
10
  * the ORIGINAL command and the exact binding are preserved next to it in a top-level `lanes` block, which the
11
11
  extension and Throttle ignore: lanes.terminals[<name>] = {id, holder, tmux{socketName,sessionName}, create}.
12
+ An entry's optional `displayName` (the container's display layer for the lane, LNS-33) is carried as is.
12
13
 
13
14
  Text is edited minimally (command strings replaced in place, the `lanes` block inserted before the final brace) so
14
15
  git diffs stay small. Dry-run by default: prints a unified diff. `--write` applies (with a timestamped backup).
@@ -179,6 +180,11 @@ def normalized_text(text, lanes_by_name, canonicalize=None):
179
180
  entry.pop('create', None)
180
181
  entry['create'] = recipe
181
182
  entry['createPrevious'] = create
183
+ if 'displayName' in previous:
184
+ # LNS-33: the container's display layer (lanes_lib._workspace_lanes reads it; the host manifest's
185
+ # displayNames win over it). Carried verbatim and last, never dropped: this tool rebuilds the entry from
186
+ # the registry and validates nothing it does not own — the reader ignores a value that is no lane name.
187
+ entry['displayName'] = previous['displayName']
182
188
  block['terminals'][term['name']] = entry
183
189
  commands[term['name']] = ENTRY + lane['id']
184
190
  # Nothing to do: leave the file byte-identical (never reformat somebody else's JSON).
@@ -1,4 +1,13 @@
1
- """Reconcile catalog tabs without switching focus or touching persistent agents."""
1
+ """Reconcile catalog tabs without switching focus or touching persistent agents.
2
+
3
+ The one tab this ever closes (cockpit v3 R4, LNS-77) is one whose panes IDENTIFY a workspace the owner's profile hides
4
+ (`hidden`): its sidebar and view.sh panes are viewers — the lanes they show live in containers, so closing the tab loses
5
+ nothing, and the workspace comes back as a new tab when it is shown again. A tab that only carries such a title, an
6
+ unmanaged tab, and every other identity are left open. Such a close never leaves the device without a workspace tab:
7
+ it waits while the hidden boards' tabs are the only workspace tabs the device HAS — counted over the tabs that exist,
8
+ not the specs, because a board added while no client was attached has no tab yet (W9 review F2) — and a reconcile
9
+ whose specs want no workspace at all is refused. Closing the tab a client is looking at moves that client to a
10
+ neighbouring tab (the person hid it)."""
2
11
  import fcntl
3
12
  import json
4
13
  import shlex
@@ -28,7 +37,9 @@ def observe(device, call):
28
37
 
29
38
 
30
39
  def identities(device, tabs, panes, registry, mobile):
31
- """Read workspace IDs from the launched commands, independent of tab labels."""
40
+ """Read workspace IDs from the launched commands, independent of tab labels — and of the tree path: a tree file
41
+ is matched by its suffix (lanes_lib.tree_file_at), so a pane started from a `pkg/<version>` layout written before
42
+ W9 U1, from one written through the `current` link since, or from a checkout keeps its identity across updates."""
32
43
  by_tab = {}
33
44
  for pane in panes:
34
45
  if pane.get('is_plugin'):
@@ -38,10 +49,10 @@ def identities(device, tabs, panes, registry, mobile):
38
49
  except ValueError:
39
50
  continue
40
51
  for script in ('sidebar', 'view.sh'):
41
- path = str(L.REPO / 'libexec' / script)
42
- if path not in args:
52
+ at = L.tree_file_at(args, 'libexec/' + script)
53
+ if at is None:
43
54
  continue
44
- tail = args[args.index(path) + 1:]
55
+ tail = args[at + 1:]
45
56
  if len(tail) < 2 or tail[0] != device:
46
57
  continue
47
58
  workspace = tail[1]
@@ -51,9 +62,9 @@ def identities(device, tabs, panes, registry, mobile):
51
62
  role = 'picker' if mobile and script == 'sidebar' else 'view'
52
63
  key = f'workspace/{workspace}/{role}'
53
64
  by_tab.setdefault(pane['tab_id'], set()).add(key)
54
- holder = str(L.REPO / 'holders/ssh.sh')
55
- if holder in args:
56
- tail = args[args.index(holder) + 1:]
65
+ at = L.tree_file_at(args, 'holders/ssh.sh')
66
+ if at is not None:
67
+ tail = args[at + 1:]
57
68
  for workspace in registry.get('ssh_workspaces', []):
58
69
  if (len(tail) == 3 and tail[:2] == [workspace['host'], workspace['command']]
59
70
  and any(pane['id'] == tail[2] for pane in workspace['panes'])):
@@ -72,7 +83,16 @@ def identities(device, tabs, panes, registry, mobile):
72
83
  return result
73
84
 
74
85
 
75
- def reconcile(device, registry, specs, mobile, call):
86
+ def reconcile(device, registry, specs, mobile, call, hidden=()):
87
+ """Make the device's tabs the `specs` (bin/lanes tab_specs): add each missing one (only while a client is attached),
88
+ rename a changed title, order them; close the tab of each workspace id in `hidden` (the boards the owner's profile
89
+ hides, cockpit v3 R4) whose identity is `workspace/<id>/…` and that no spec wants — an unknown id is ignored. ->
90
+ the changes made, as words. Two guards keep a workspace tab on the device:
91
+ * specs that want no workspace at all with such a tab to close: refused (L.LanesError, before any change);
92
+ * no workspace tab the device has would stay open — every EXISTING `workspace/…` tab is one to close, the
93
+ shown boards having none yet (one added while no client was attached, W9 review F2): nothing is closed and
94
+ the words say so (`kept <titles> open although hidden: …`); the rest runs — the missing tabs are added
95
+ once a client is attached — and a later reconcile, counting again, closes them."""
76
96
  lock = L.STATE / 'locks' / f'device-sync.{device}.lock'
77
97
  lock.parent.mkdir(parents=True, exist_ok=True)
78
98
  with lock.open('a') as handle:
@@ -88,6 +108,22 @@ def reconcile(device, registry, specs, mobile, call):
88
108
  raise L.LanesError(f'{device}: {args[0]} failed: {error or "no answer"}')
89
109
  return output
90
110
 
111
+ keys, hidden = {spec['key'] for spec in specs}, set(hidden)
112
+ doomed = [tab for key, tab in known.items()
113
+ if key.startswith('workspace/') and key.split('/')[1] in hidden and key not in keys]
114
+ if doomed and not any(key.startswith('workspace/') for key in keys):
115
+ raise L.LanesError(f'{device}: closing every workspace tab is refused (one workspace stays shown); '
116
+ 'no tab was closed')
117
+ if doomed and len([key for key in known if key.startswith('workspace/')]) - len(doomed) == 0:
118
+ them, they = ('it', 'it closes') if len(doomed) == 1 else ('them', 'they close')
119
+ changes.append(f"kept {', '.join(tab['name'] for tab in doomed)} open although hidden: no other workspace "
120
+ f'has a tab yet (closing {them} would leave none); {they} once a shown workspace has one')
121
+ doomed = []
122
+ for tab in doomed:
123
+ action(['close-tab', '--tab-id', str(tab['tab_id'])])
124
+ tabs = [t for t in tabs if t['tab_id'] != tab['tab_id']]
125
+ changes.append('closed ' + tab['name'])
126
+
91
127
  for spec in specs:
92
128
  tab = known.get(spec['key'])
93
129
  if tab is None:
@@ -115,7 +151,8 @@ def reconcile(device, registry, specs, mobile, call):
115
151
  wanted.append(tab['tab_id'])
116
152
 
117
153
  # Sort managed tabs by adjacent moves using stable IDs. Each observation
118
- # must answer. Unmanaged tabs remain open; no close/rebuild/focus action exists.
154
+ # must answer. Unmanaged tabs remain open; no rebuild/focus action exists, and the
155
+ # only close is a hidden workspace's identified tab above.
119
156
  for position, tab_id in enumerate(wanted):
120
157
  for _ in range(len(tabs) + 1):
121
158
  rc, output, error = call(['-s', device, 'action', 'list-tabs', '--json'], 20)
@@ -666,6 +666,7 @@ def main(argv=None):
666
666
  report(diagnostics, None, always=True)
667
667
  return 0
668
668
  control_url(env) # a malformed URL stops the unit at start, not once per tick
669
+ L.record_tree('economy', state) # W9 U0: the unit's tree; `--once`/`--dry-run` (a person's diagnostic) never
669
670
  last = None
670
671
  while True:
671
672
  diagnostics = {}
@@ -66,11 +66,15 @@ members/ and authorized_keys, so the CLI and this service never interleave:
66
66
  The opt-in lanes-notify service (notify.py, W8 A5) reads them through load_requests and
67
67
  pushes one note per new pending request.
68
68
  members/<device>.json members.py's schema, via invite|request, written through keys.py (save_member, then
69
- write_authorized_keys) — never devices/.
69
+ write_authorized_keys) — never devices/. `client_version` (W9 K1, LNS-86) is the
70
+ release the body named (client_version(): the redeem's, or on approval the request's);
71
+ absent when it named none.
70
72
  enrol-mode {version: 1, mode: request|invite, set_at, set_by}, written by `lanes enrol mode` (enrol.py,
71
73
  through write_mode; `auto` removes it); read on every request, never written here.
72
74
  enrol.log append-only, one line per attempt: `<UTC> <event> <id> <device> <login> fp=<SHA256:…> <result>`
73
- (`-` for an unknown field). A code is never written anywhere, not even its id half on a miss.
75
+ (`-` for an unknown field); every line redeem() writes for its attempt adds ` client=<v>`
76
+ when the body named a release (W9 K1). A code is never written anywhere, not even its id
77
+ half on a miss.
74
78
  A redeem or approval re-renders the whole block from the members keys.load_members accepts,
75
79
  so a member file it cannot use loses its line (fail closed): each such file is one line
76
80
  `<event> <id> <its device> <login> fp=- dropped-member` before the attempt's own, and the
@@ -132,7 +136,7 @@ ACTIONS = ('approve', 'deny')
132
136
  HTML_POLICY = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
133
137
  _HEX64 = re.compile(r'[0-9a-f]{64}')
134
138
  _REQUEST_ID = re.compile(r'[a-z0-9]{1,32}')
135
- _CLIENT_VERSION = re.compile(r'[A-Za-z0-9._+-]{1,32}')
139
+ _CLIENT_VERSION = M.CLIENT_VERSION # a body's client_version: the rule the member record is validated by
136
140
  _STAMP = re.compile(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', re.ASCII)
137
141
  _LOG_UNSAFE = re.compile(r'[^A-Za-z0-9_.@:+=/-]')
138
142
  _SITE_LINE = re.compile(r'^(LANES_[A-Z_]+)=([A-Za-z0-9_.:@/-]*)$')
@@ -294,10 +298,13 @@ def _log_field(value):
294
298
  return _LOG_UNSAFE.sub('?', str(value))[:128] if value else '-'
295
299
 
296
300
 
297
- def audit(event, ident, device, login, fingerprint, result, now=None):
298
- """Append one line to enrol.log (0600). Never raises: a log that cannot be written is said on stderr."""
301
+ def audit(event, ident, device, login, fingerprint, result, now=None, client=None):
302
+ """Append one line to enrol.log (0600). Never raises: a log that cannot be written is said on stderr. `client`
303
+ (a redeem attempt's client_version, W9 K1) ends the line as `client=<it>` when given; no field otherwise."""
299
304
  line = ' '.join((stamp(time.time() if now is None else now), _log_field(event), _log_field(ident),
300
305
  _log_field(device), _log_field(login), 'fp=' + _log_field(fingerprint), _log_field(result)))
306
+ if client is not None:
307
+ line += ' client=' + _log_field(client)
301
308
  try:
302
309
  L.STATE.mkdir(parents=True, exist_ok=True)
303
310
  handle = os.open(log_path(), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
@@ -510,14 +517,26 @@ def _block_lacks(path, members, device):
510
517
 
511
518
  # ------------------------------------------------------------------------------------------------------- redeem
512
519
 
520
+ def client_version(value):
521
+ """The client's release as an enrolment body states it (`client_version`; install.sh sends its VERSION, `dev`
522
+ without one), else None: a string matching members.CLIENT_VERSION. Anything else (absent, null, a number, a blank,
523
+ a terminal escape, over 32 characters) is not stored and refuses nothing. One rule for the request file
524
+ (create_request), the member record (redeem, and an approval from its request) and the redeem audit line."""
525
+ return value if isinstance(value, str) and _CLIENT_VERSION.fullmatch(value) else None
526
+
527
+
513
528
  def redeem(fields, caller=None, now=None):
514
- """POST /redeem -> (status, payload). `caller` is the TRUSTED tailnet identity (None outside request mode)."""
529
+ """POST /redeem -> (status, payload). `caller` is the TRUSTED tailnet identity (None outside request mode). Every
530
+ enrol.log line of the attempt ends `client=<release>` when the body named one (client_version, W9 K1, LNS-86), and
531
+ the member record keeps it; a line the HTTP layer writes before a body is in hand (rate-limited; a body refused as
532
+ bad-request, too-large or length-required) carries none."""
515
533
  now = time.time() if now is None else now
516
534
  caller = caller or NOBODY
535
+ client = client_version(fields.get('client_version'))
517
536
  try:
518
537
  device = M.device_name(fields.get('device'))
519
538
  except L.LanesError:
520
- audit('redeem', None, None, caller.login, None, 'invalid-device', now)
539
+ audit('redeem', None, None, caller.login, None, 'invalid-device', now, client)
521
540
  return 400, {'error': 'invalid-device'}
522
541
  try:
523
542
  pubkey = K.parse_pubkey(fields['pubkey']) if isinstance(fields.get('pubkey'), str) else None
@@ -526,21 +545,21 @@ def redeem(fields, caller=None, now=None):
526
545
  fingerprint = K.fingerprint(pubkey) if pubkey else None
527
546
  match = match_invite(fields.get('code'), now)
528
547
  if match is None or pubkey is None:
529
- audit('redeem', None, device, caller.login, fingerprint, INVALID, now)
548
+ audit('redeem', None, device, caller.login, fingerprint, INVALID, now, client)
530
549
  return 403, {'error': INVALID}
531
550
  try:
532
551
  with K.members_lock():
533
- return _redeem_locked(match, device, pubkey, fingerprint, caller, now)
552
+ return _redeem_locked(match, device, pubkey, fingerprint, caller, now, client)
534
553
  except L.LanesError: # only the lock raises out of _redeem_locked
535
- audit('redeem', match[0], device, caller.login, fingerprint, 'busy', now)
554
+ audit('redeem', match[0], device, caller.login, fingerprint, 'busy', now, client)
536
555
  return 503, {'error': 'busy'}
537
556
 
538
557
 
539
- def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
558
+ def _redeem_locked(match, device, pubkey, fingerprint, caller, now, client=None):
540
559
  invite_id, path, digest = match
541
560
  invite = _read_doc(path)
542
561
  if invite is None or not invite_live(invite, now) or not hmac.compare_digest(digest, invite['code_sha256']):
543
- audit('redeem', None, device, caller.login, fingerprint, INVALID, now) # used while we waited
562
+ audit('redeem', None, device, caller.login, fingerprint, INVALID, now, client) # used while we waited
544
563
  return 403, {'error': INVALID}
545
564
  try:
546
565
  ak, data = _authorized_keys()
@@ -549,19 +568,19 @@ def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
549
568
  owner = invite_owner(invite, caller)
550
569
  record = K.member_record(device, pubkey, via='invite', now=now, owner=owner,
551
570
  approved_by=minter_login(invite), invite_id=invite_id,
552
- workspaces=_workspaces(invite.get('workspaces')),
571
+ workspaces=_workspaces(invite.get('workspaces')), client_version=client,
553
572
  **({'invited_as': invited_as(invite)} if caller.login else {}))
554
573
  original = path.read_bytes()
555
574
  except (OSError, L.LanesError) as error:
556
575
  print(f'lanes-enrold: redeem {invite_id}: {error}', file=sys.stderr, flush=True)
557
- audit('redeem', invite_id, device, caller.login, fingerprint, 'server-error', now)
576
+ audit('redeem', invite_id, device, caller.login, fingerprint, 'server-error', now, client)
558
577
  return 503, {'error': 'server-error'}
559
578
  who = owner['login'] if owner else caller.login
560
579
  if taken:
561
- audit('redeem', invite_id, device, who, fingerprint, 'device-taken', now)
580
+ audit('redeem', invite_id, device, who, fingerprint, 'device-taken', now, client)
562
581
  return 409, {'error': 'device-taken'}
563
582
  if any(member['pubkey'] == pubkey for member in members):
564
- audit('redeem', invite_id, device, who, fingerprint, 'key-taken', now)
583
+ audit('redeem', invite_id, device, who, fingerprint, 'key-taken', now, client)
565
584
  return 409, {'error': 'key-taken'}
566
585
  consumed = dict(invite, redeemed_by={'device': device, 'login': who, 'pubkey_sha256': fingerprint},
567
586
  redeemed_at=stamp(now))
@@ -569,7 +588,7 @@ def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
569
588
  L.atomic_write(path, _json_bytes(consumed), mode=0o600) # used up FIRST: a crash never leaves it reusable
570
589
  except OSError as error:
571
590
  print(f'lanes-enrold: cannot mark invite {invite_id} used: {error.strerror}', file=sys.stderr, flush=True)
572
- audit('redeem', invite_id, device, who, fingerprint, 'server-error', now)
591
+ audit('redeem', invite_id, device, who, fingerprint, 'server-error', now, client)
573
592
  return 503, {'error': 'server-error'}
574
593
  report_dropped('redeem', invite_id, who, problems, now) # the writes below re-render the block
575
594
  try:
@@ -583,13 +602,13 @@ def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
583
602
  L.atomic_write(path, original, mode=0o600)
584
603
  except OSError as undo:
585
604
  print(f'lanes-enrold: undo of {invite_id} incomplete: {undo.strerror}', file=sys.stderr, flush=True)
586
- audit('redeem', invite_id, device, who, fingerprint, 'server-error', now)
605
+ audit('redeem', invite_id, device, who, fingerprint, 'server-error', now, client)
587
606
  return 503, {'error': 'server-error'}
588
607
  if owner is None: # said, never silent (DEF-1644)
589
608
  print(f'lanes-enrold: invite {invite_id} names no login (no trusted tailnet login, no email address, and its '
590
609
  f'name is not a login): members/{device}.json has no owner — `lanes keys owner {device} <login>` '
591
610
  'names one', file=sys.stderr, flush=True)
592
- audit('redeem', invite_id, device, who, fingerprint, 'ok', now)
611
+ audit('redeem', invite_id, device, who, fingerprint, 'ok', now, client)
593
612
  return 200, {'device': device, 'owner': owner, 'ssh_ready': True}
594
613
 
595
614
 
@@ -677,8 +696,7 @@ def create_request(fields, caller, now=None):
677
696
  {'login': login, 'name': None, 'email': _address(login)})
678
697
  if owner is None:
679
698
  return 403, {'error': 'no-identity'}
680
- client = fields.get('client_version')
681
- client = client if isinstance(client, str) and _CLIENT_VERSION.fullmatch(client) else None
699
+ client = client_version(fields.get('client_version'))
682
700
  try:
683
701
  with K.members_lock():
684
702
  pending = [r for r in load_requests() if request_state(r, now) == 'pending']
@@ -780,7 +798,7 @@ def _approve_locked(document, approver, now):
780
798
  owner = M.validate_owner({'login': document['login'], 'name': document.get('name'),
781
799
  'email': document.get('email')})
782
800
  record = K.member_record(device, pubkey, via='request', now=now, owner=owner, approved_by=approver,
783
- request_id=document['id'])
801
+ request_id=document['id'], client_version=client_version(document.get('client_version')))
784
802
  try:
785
803
  K.save_member(record)
786
804
  except OSError as error:
@@ -1248,6 +1266,7 @@ def main(argv=None):
1248
1266
  parser.error(f'{port} is not a port number')
1249
1267
  service = Service.from_environ(os.environ)
1250
1268
  server = Server(('127.0.0.1', port), service)
1269
+ L.record_tree('enrold') # W9 U0: the tree this service runs from (`lanes version`, doctor)
1251
1270
  print(f'lanes-enrold {version()}: listening on 127.0.0.1:{server.server_address[1]}; request mode '
1252
1271
  f'{service.mode_source()}; '
1253
1272
  f'domains {",".join(service.domains)}', file=sys.stderr, flush=True)
@@ -37,6 +37,11 @@ The account's ~/.ssh/authorized_keys carries ONE managed block, re-rendered whol
37
37
  * `lanes keys owner <device> <login> [--name NAME] [--email ADDR]` names the person behind a member (`add`/`adopt`
38
38
  write owner null): the one change to a record's owner, and how a key added by hand makes its person an
39
39
  `/enrol/pending` approver. It writes only the member file — the block carries no owner.
40
+ * `lanes keys adopt --login LOGIN` (cockpit v3 R2, LNS-75) records {login: LOGIN} as the owner of every member it
41
+ adopts, in the same write — a machine from before enrolment and its person in one step; a member that exists keeps
42
+ its owner (`owner` changes that). `main(args, on_owned=…)`: whenever adopt --login or owner names an owner, the
43
+ caller is told (login, [devices]) after the write and its return value is the exit code — bin/lanes folds those
44
+ devices' views into the person's profile (users.py); this module never touches devices/ or users/.
40
45
 
41
46
  `pubkey_sha256` is OpenSSH's own fingerprint form (`SHA256:` + unpadded base64 of the blob's SHA-256): what
42
47
  `ssh-keygen -lf` prints and what sshd logs on `Accepted publickey`, so a log line names its device.
@@ -65,7 +70,7 @@ MAX_DEVICE = M.MAX_DEVICE # bin/lanes device_name()
65
70
  MAX_PUBKEY_FILE = 8192
66
71
  LOCK_TIMEOUT = 5
67
72
  USAGE = ('usage: lanes keys list | lanes keys add <device> <pubkey-file|-> | lanes keys revoke <device> | '
68
- 'lanes keys adopt [--dry-run] | lanes keys owner <device> <login> [--name NAME] [--email ADDR]')
73
+ 'lanes keys adopt [--dry-run] [--login LOGIN] | lanes keys owner <device> <login> [--name NAME] [--email ADDR]')
69
74
  _ED25519_HEAD = struct.pack('>I', len(KEY_TYPE)) + KEY_TYPE.encode() + struct.pack('>I', 32)
70
75
  _BASE64 = re.compile(r'[A-Za-z0-9+/]+={0,2}')
71
76
  _KEY_TYPE_WORD = re.compile(r'(?:ssh-|ecdsa-|sk-)[A-Za-z0-9@._-]+')
@@ -504,11 +509,11 @@ def cmd_revoke(path, device):
504
509
  return 1 if leftovers else 0
505
510
 
506
511
 
507
- def plan_adopt(data, members):
512
+ def plan_adopt(data, members, owner=None):
508
513
  """(new member records by device, [(pubkey, device)] whose plain outside lines go, [report lines]) for the
509
514
  `lanes:<device>` lines outside the block. Taken: a plain (no options) ssh-ed25519 line whose device holds no other
510
515
  key and whose key no other device holds; a line for an existing member with the same key is a duplicate and goes.
511
- Everything else stays byte for byte and is named."""
516
+ Everything else stays byte for byte and is named. A new record's owner is `owner` (validated; None = null)."""
512
517
  by_device = {m['device']: m for m in members}
513
518
  holders = {m['pubkey']: m['device'] for m in members}
514
519
  new, drop, report = {}, set(), []
@@ -539,19 +544,24 @@ def plan_adopt(data, members):
539
544
  if pubkey in holders:
540
545
  report.append(f'kept {where}: that key is already enrolled as {holders[pubkey]}')
541
546
  continue
542
- new[device] = member_record(device, pubkey)
547
+ new[device] = member_record(device, pubkey, owner=owner)
543
548
  holders[pubkey] = device
544
549
  drop.add((pubkey, device))
545
- report.append(f'adopted {device} ({fingerprint(pubkey)}) from line {number}')
550
+ report.append(f'adopted {device} ({fingerprint(pubkey)}) from line {number}'
551
+ + (f", owner {owner['login']}" if owner else ''))
546
552
  return new, drop, report
547
553
 
548
554
 
549
- def cmd_adopt(path, dry_run=False):
555
+ def cmd_adopt(path, dry_run=False, owner=None, on_owned=None):
556
+ """Move the plain hand-added `lanes:<device>` lines into members/ (owner `owner`, validated by the caller) and the
557
+ block. -> 0, or on_owned(login, adopted devices)'s answer when `owner` named the owner of any (never on a dry run)."""
558
+ unowned = ('; --login names the owner of the keys an adopt moves in — a member that exists keeps its own '
559
+ '(`lanes keys owner <device> <login>` changes it)') if owner else ''
550
560
  with members_lock():
551
561
  members, problems = load_members()
552
562
  _warn_problems(problems)
553
563
  data = _read(path)
554
- new, drop, report = plan_adopt(data, members)
564
+ new, drop, report = plan_adopt(data, members, owner)
555
565
 
556
566
  def goes(raw):
557
567
  parsed = parse_line(raw.decode('utf-8', 'surrogateescape'))
@@ -562,7 +572,7 @@ def cmd_adopt(path, dry_run=False):
562
572
  for line in report:
563
573
  print('would: ' + line if not line.startswith('adopted') else 'would adopt ' + line[len('adopted '):])
564
574
  if not drop:
565
- print('nothing to adopt')
575
+ print('nothing to adopt' + unowned)
566
576
  return 0
567
577
  for record in new.values():
568
578
  save_member(record) # the records before their lines (see cmd_revoke)
@@ -573,19 +583,22 @@ def cmd_adopt(path, dry_run=False):
573
583
  for line in report:
574
584
  print(line)
575
585
  if not drop:
576
- print('nothing to adopt' + ('; the block was re-rendered from members/' if changed else ''))
586
+ print('nothing to adopt' + ('; the block was re-rendered from members/' if changed else '') + unowned)
577
587
  else:
578
588
  print(f'{len(new)} adopted; the lanes-managed block of {path} holds {len(members) + len(new)} key(s); '
579
589
  'the file before the first adopt is kept as authorized_keys.bak-<UTC>')
590
+ if owner and new and on_owned:
591
+ return on_owned(owner['login'], sorted(new))
580
592
  return 0
581
593
 
582
594
 
583
- def cmd_owner(device, login, name=None, email=None):
595
+ def cmd_owner(device, login, name=None, email=None, on_owned=None):
584
596
  """Name the person behind member `device`: its owner becomes {login, name, email}. The only change to who owns a key
585
597
  added by hand (`add`/`adopt` write owner null), and so how its person becomes a `/enrol/pending` approver, which
586
598
  matches the tailnet login a member record's owner names (DEF-1643). The record keeps every other field and its
587
599
  order; the block carries no owner, so authorized_keys is not touched. Refuses an owner members.py would not store,
588
- a device with no record, and a record that does not load (fix it first) — never echoing a value."""
600
+ a device with no record, and a record that does not load (fix it first) — never echoing a value. Then, also when
601
+ nothing changed, -> on_owned(login, [device])'s answer (0 without one)."""
589
602
  device = device_name(device)
590
603
  owner = M.validate_owner({'login': login, 'name': name, 'email': email})
591
604
  with members_lock():
@@ -599,14 +612,15 @@ def cmd_owner(device, login, name=None, email=None):
599
612
  before = M.valid_owner(current.get('owner'))
600
613
  if before == owner:
601
614
  print(f"{device} is already owned by {owner['login']}; nothing changed")
602
- return 0
603
- record = dict(current, owner=owner)
604
- if record.get('pubkey_sha256') is None: # a record written before A2 carries no fingerprint
605
- record['pubkey_sha256'] = fingerprint(record['pubkey'])
606
- save_member(record)
607
- print(f"owner of {device}: {before['login'] if before else 'none'} -> {owner['login']}"
608
- + (f" ({owner['name']})" if owner['name'] else ''))
609
- return 0
615
+ else:
616
+ record = dict(current, owner=owner)
617
+ if record.get('pubkey_sha256') is None: # a record written before A2 carries no fingerprint
618
+ record['pubkey_sha256'] = fingerprint(record['pubkey'])
619
+ save_member(record)
620
+ if before != owner:
621
+ print(f"owner of {device}: {before['login'] if before else 'none'} -> {owner['login']}"
622
+ + (f" ({owner['name']})" if owner['name'] else ''))
623
+ return on_owned(owner['login'], [device]) if on_owned else 0
610
624
 
611
625
 
612
626
  def _owner_args(rest):
@@ -621,7 +635,22 @@ def _owner_args(rest):
621
635
  return rest[0], rest[1], options.get('--name'), options.get('--email')
622
636
 
623
637
 
624
- def main(args):
638
+ def _adopt_args(rest):
639
+ """[--dry-run] [--login LOGIN], each at most once, in either order -> (dry_run, login or None)."""
640
+ dry_run, login, index = False, None, 0
641
+ while index < len(rest):
642
+ if rest[index] == '--dry-run' and not dry_run:
643
+ dry_run, index = True, index + 1
644
+ elif rest[index] == '--login' and login is None and index + 1 < len(rest):
645
+ login, index = rest[index + 1], index + 2
646
+ else:
647
+ raise L.LanesError(USAGE)
648
+ return dry_run, login
649
+
650
+
651
+ def main(args, on_owned=None):
652
+ """`lanes keys …`. `on_owned(login, devices) -> exit code` is called after adopt --login or owner named an owner
653
+ (bin/lanes passes its profile fold); None (this module run on its own) calls nothing."""
625
654
  path = authorized_keys_path()
626
655
  verb, rest = (args[0], args[1:]) if args else (None, [])
627
656
  if verb == 'list' and not rest:
@@ -630,10 +659,12 @@ def main(args):
630
659
  return cmd_add(path, *rest)
631
660
  if verb == 'revoke' and len(rest) == 1:
632
661
  return cmd_revoke(path, rest[0])
633
- if verb == 'adopt' and rest in ([], ['--dry-run']):
634
- return cmd_adopt(path, dry_run=bool(rest))
662
+ if verb == 'adopt':
663
+ dry_run, login = _adopt_args(rest)
664
+ owner = None if login is None else M.validate_owner({'login': login}) # refused before anything is read
665
+ return cmd_adopt(path, dry_run=dry_run, owner=owner, on_owned=on_owned)
635
666
  if verb == 'owner':
636
- return cmd_owner(*_owner_args(rest))
667
+ return cmd_owner(*_owner_args(rest), on_owned=on_owned)
637
668
  raise L.LanesError(USAGE)
638
669
 
639
670