@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.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -22
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2459 -164
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +106 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1091 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1140 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +625 -15
- package/payload/lanes/docs/DESIGN.md +122 -44
- package/payload/lanes/docs/INSTALL.md +157 -17
- package/payload/lanes/docs/RUNBOOK.md +653 -81
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +792 -140
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +163 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/members.py +16 -2
- package/payload/lanes/libexec/notify.py +104 -24
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2135 -361
- package/payload/lanes/libexec/statusd.py +137 -10
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +121 -30
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +972 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/README.md +207 -25
- package/payload/lanes/web/browser.js +3 -5
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +128 -76
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- 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
|
-
|
|
42
|
-
if
|
|
52
|
+
at = L.tree_file_at(args, 'libexec/' + script)
|
|
53
|
+
if at is None:
|
|
43
54
|
continue
|
|
44
|
-
tail = args[
|
|
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
|
-
|
|
55
|
-
if
|
|
56
|
-
tail = args[
|
|
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
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
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'
|
|
634
|
-
|
|
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
|
|