@vibelord/lanes 1.2.0 → 1.5.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 (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +7 -5
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +1091 -76
  5. package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
  6. package/payload/lanes/container/hooks/grok-hooks.json +12 -0
  7. package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
  8. package/payload/lanes/container/lane +55 -8
  9. package/payload/lanes/container/lane-mcp +120 -22
  10. package/payload/lanes/container/lane-signal +86 -23
  11. package/payload/lanes/container/lane_core.py +524 -36
  12. package/payload/lanes/container/throttle-pulse +456 -0
  13. package/payload/lanes/docs/ARCHITECTURE.md +357 -34
  14. package/payload/lanes/docs/DESIGN.md +13 -3
  15. package/payload/lanes/docs/INSTALL.md +47 -10
  16. package/payload/lanes/docs/RUNBOOK.md +761 -12
  17. package/payload/lanes/docs/USERGUIDE.md +421 -46
  18. package/payload/lanes/holders.lock +5 -0
  19. package/payload/lanes/install/enable-docs.sh +78 -0
  20. package/payload/lanes/install/host-install.sh +39 -5
  21. package/payload/lanes/libexec/agent_state.py +604 -12
  22. package/payload/lanes/libexec/docs_store.py +995 -0
  23. package/payload/lanes/libexec/docsd.py +2010 -0
  24. package/payload/lanes/libexec/economy_poll.py +6 -2
  25. package/payload/lanes/libexec/harness_grok.py +742 -0
  26. package/payload/lanes/libexec/lanes_lib.py +143 -20
  27. package/payload/lanes/libexec/mdlite.py +575 -0
  28. package/payload/lanes/libexec/members.py +5 -0
  29. package/payload/lanes/libexec/notify.py +65 -5
  30. package/payload/lanes/libexec/sidebar.py +626 -25
  31. package/payload/lanes/libexec/statusd.py +530 -32
  32. package/payload/lanes/libexec/tokens.py +42 -9
  33. package/payload/lanes/libexec/update.py +171 -26
  34. package/payload/lanes/libexec/uploads.py +538 -0
  35. package/payload/lanes/libexec/users.py +31 -3
  36. package/payload/lanes/site/site.example.conf +4 -0
  37. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  38. package/payload/lanes/web/Caddyfile +24 -1
  39. package/payload/lanes/web/README.md +86 -8
  40. package/payload/lanes/web/browser.js +2 -4
  41. package/payload/lanes/web/docs.css +52 -0
  42. package/payload/lanes/web/docs.js +36 -0
  43. package/payload/lanes/web/mobile.js +3 -2
  44. package/payload/lanes/web/upload.css +17 -0
  45. package/payload/lanes/web/upload.js +84 -0
  46. package/payload/manifest.json +5 -3
@@ -0,0 +1,538 @@
1
+ """lanes uploads — one way in for a file a person uploads (docs cockpit W10 task W5, LNS-106).
2
+
3
+ Both upload paths land the file where the agents have always found it AND record it in the docs store:
4
+ * the SSH droplet `lanes upload` (bin/lanes cmd_upload: a tar stream on stdin from the client's `lanes-upload`, the
5
+ sidebar's upload strip through the upload helper, the watch folder, the macOS droplet) — receive_tar, source `ssh`;
6
+ * the Docs page's `POST /up` (libexec/docsd.py: a multipart form from the phone or laptop) — receive, source `web`.
7
+
8
+ Landing, as cmd_upload always did it: `<folder>/uploads/<name>` in the workspace's container, reached through the
9
+ container's home volume (lanes_lib.host_path), where `<folder>` is the workspace's first lane folder, else its working
10
+ directory (landing). An existing name is never overwritten: the next is `<stem>-1<suffix>`, `-2` … (link_free, the
11
+ rule docs_store names documents by). `uploads/` is created on first use (0755) and, when the folder is a git
12
+ repository (`.git` a directory), `.git/info/exclude` gains the `uploads/` line unless a line already names it, so an
13
+ upload is never committed (ensure_excluded). A file appears under its name only once it is complete (written as
14
+ `.incoming-…`, then linked or renamed into place).
15
+
16
+ Safety (DEF-1890, DEF-1891). The folder and everything in it are the container's to change, and the host writes there
17
+ as the account the agents run as (bin/lanes uid_split: the same uid). So every directory is opened component by
18
+ component without following a link (lanes_lib.open_real_dir: an absolute link would resolve against the HOST's root),
19
+ every file is created O_EXCL|O_NOFOLLOW, a tar stream is unpacked here member by member (unpack; never
20
+ tarfile.extractall, which follows a link an agent plants while it runs), and no git process runs in the repository:
21
+ `git check-ignore` runs the repository's `core.fsmonitor`, a command the container chose, on the host.
22
+
23
+ Recording. Each file's bytes pass through a Tee: every chunk read from the upload is written to the landing file and
24
+ handed to docs_store.intake in the same pass, so the store's sha256 receipt is of exactly the bytes that arrived — never
25
+ of a file an agent could rewrite before it was read. The store document is a COPY, never a hard link to the landed
26
+ file: a link shares one inode with a file the container may write, and an edit there would change the receipt-bound
27
+ bytes under an unchanged receipt (test_uploads.Ownership measures that, and that a link cannot even cross to another
28
+ filesystem). Store facts (record): `kind` by extension (kind_for: md, html, svg, else file), `for` and `by` the
29
+ uploader's login, at most UPLOAD_LIMIT bytes a file — a larger file lands without a store document (intake logs
30
+ `too-large`). An SSH upload from a device that no member record owns lands as before, with a docs.log
31
+ `upload - - <lane> <bytes> <sha256> no-owner` line and no store document. LANES_DOCS_AGE_RECIPIENT encrypts the store
32
+ copy only (docs_store), never the landed file.
33
+ """
34
+ import hashlib
35
+ import os
36
+ import secrets
37
+ import stat
38
+ import tarfile
39
+ from pathlib import PurePosixPath
40
+
41
+ import lanes_lib as L
42
+ import docs_store as D
43
+
44
+ UPLOAD_LIMIT = 32 * 1024 * 1024 # bytes of ONE file the store keeps from an upload (docsd's POST /up body cap)
45
+ MAX_RECORDS = 64 # store documents one SSH upload makes at most (docsd's MAX_PARTS for a form)
46
+ MAX_EXCLUDE = 64 * 1024 # a .git/info/exclude larger than this is not read (nor appended to)
47
+ MAX_LINKS = 4096 # names link_free tries before it gives up
48
+ CHUNK = 64 * 1024
49
+ UPLOADS = 'uploads'
50
+ FILE_MODE, DIR_MODE = 0o644, 0o755 # what a web upload and a new uploads/ get, whatever the caller's umask
51
+ EXCLUDE_BLOCK = b'\n# lanes upload: local intake folder, never committed\nuploads/\n' # cmd_upload's, byte for byte
52
+ EXCLUDES = frozenset(('uploads', 'uploads/', '/uploads', '/uploads/', 'uploads/*', '/uploads/*', 'uploads/**',
53
+ '/uploads/**'))
54
+ KINDS = {'.md': 'md', '.markdown': 'md', '.html': 'html', '.htm': 'html', '.svg': 'svg'}
55
+ _CLOEXEC = getattr(os, 'O_CLOEXEC', 0)
56
+ _DIR = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | _CLOEXEC
57
+ _NEW = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | _CLOEXEC
58
+
59
+
60
+ class UploadError(L.LanesError):
61
+ """A file that could not be stored (both-or-neither, `receive`): `word` is the refusal (busy, too-large,
62
+ encrypt-failed, invalid, no-owner)."""
63
+
64
+ def __init__(self, word):
65
+ super().__init__(f'the upload could not be stored ({word})')
66
+ self.word = word
67
+
68
+
69
+ def kind_for(name):
70
+ """The store kind of an uploaded file: md, html or svg by its extension (case-insensitive), else file."""
71
+ return KINDS.get(PurePosixPath(name or '').suffix.lower(), 'file')
72
+
73
+
74
+ # ----------------------------------------------------------------------------------------------------- the landing
75
+
76
+ def landing(registry, workspace):
77
+ """(folder, home, parts) of `workspace`'s uploads: the container folder they land under (`<folder>/uploads/`) —
78
+ the first lane folder, else the workspace's working directory —, the container's home volume on the host, and the
79
+ folder's components below it. Raises LanesError exactly as lanes_lib.host_path does (an unsafe path, one outside the
80
+ home volume, a container whose home volume is unknown: not running at the last `lanes sync`)."""
81
+ folder = next((lane.get('folder') for lane in workspace.get('lanes') or [] if lane.get('folder')), None) \
82
+ or workspace.get('cwd') or ''
83
+ info = (registry.get('containers') or {}).get(workspace.get('container')) or {}
84
+ L.host_path(info, folder)
85
+ rest = folder[len(L.CONTAINER_HOME) + 1:]
86
+ return folder, info['home'], [part for part in rest.split('/') if part]
87
+
88
+
89
+ def _mkdir(dir_fd, name, mode):
90
+ """mkdir `name` in dir_fd -> True when it made it (an existing entry is left as it is)."""
91
+ try:
92
+ os.mkdir(name, mode, dir_fd=dir_fd)
93
+ return True
94
+ except FileExistsError:
95
+ return False
96
+
97
+
98
+ def open_uploads(home, parts):
99
+ """fd of <home>/<parts…>/uploads, made (0755) when absent; every component a real directory — a symbolic link
100
+ anywhere is refused, never followed —, and the repository's exclude file kept naming it (ensure_excluded). The
101
+ caller closes it. Raises LanesError."""
102
+ try:
103
+ root = L.open_real_dir(home, parts)
104
+ except (OSError, L.LanesError):
105
+ raise L.LanesError('the workspace folder cannot be opened without following a symbolic link') from None
106
+ try:
107
+ try:
108
+ created = _mkdir(root, UPLOADS, 0o700)
109
+ except OSError as error:
110
+ raise L.LanesError(f'uploads/ cannot be made in the workspace folder: {error.strerror or error}') from None
111
+ try:
112
+ fd = os.open(UPLOADS, _DIR, dir_fd=root)
113
+ except OSError:
114
+ raise L.LanesError('uploads/ in the workspace folder is not a directory (a symbolic link is refused)') \
115
+ from None
116
+ if created:
117
+ os.fchmod(fd, DIR_MODE)
118
+ try:
119
+ ensure_excluded(root)
120
+ except BaseException:
121
+ os.close(fd)
122
+ raise
123
+ return fd
124
+ finally:
125
+ os.close(root)
126
+
127
+
128
+ def _ignores_uploads(data):
129
+ return any(line.strip() in EXCLUDES for line in data.decode('utf-8', 'replace').splitlines())
130
+
131
+
132
+ def ensure_excluded(root):
133
+ """Keep the repository folder `root` (an fd) from committing uploads/ -> True when it appended. Only when `.git` is a
134
+ real directory (absent, a linked worktree's `.git` file or a link: nothing, as before): `.git/info/exclude` (made
135
+ when absent) gains EXCLUDE_BLOCK unless a line of it already names uploads/ (EXCLUDES). No git process runs here
136
+ (DEF-1890). Raises LanesError when the exclude file is no regular file or cannot be written: an upload that could be
137
+ committed is not made."""
138
+ try:
139
+ git = os.open('.git', _DIR, dir_fd=root)
140
+ except OSError:
141
+ return False
142
+ try:
143
+ try:
144
+ _mkdir(git, 'info', 0o755)
145
+ info = os.open('info', _DIR, dir_fd=git)
146
+ except OSError:
147
+ raise L.LanesError('.git/info in the workspace folder is not a directory') from None
148
+ try:
149
+ try:
150
+ fd = os.open('exclude', os.O_RDWR | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK | _CLOEXEC,
151
+ 0o644, dir_fd=info)
152
+ except OSError:
153
+ raise L.LanesError('.git/info/exclude cannot be opened without following a link') from None
154
+ try:
155
+ info_stat = os.fstat(fd)
156
+ if not stat.S_ISREG(info_stat.st_mode) or info_stat.st_size > MAX_EXCLUDE:
157
+ raise L.LanesError('.git/info/exclude is not a plain file of at most 64 KiB')
158
+ data, size = [], 0
159
+ while size <= MAX_EXCLUDE:
160
+ chunk = os.read(fd, CHUNK)
161
+ if not chunk:
162
+ break
163
+ data.append(chunk)
164
+ size += len(chunk)
165
+ if _ignores_uploads(b''.join(data)):
166
+ return False
167
+ os.write(fd, EXCLUDE_BLOCK)
168
+ return True
169
+ except OSError as error:
170
+ raise L.LanesError(f'.git/info/exclude cannot be written: {error.strerror or error}') from None
171
+ finally:
172
+ os.close(fd)
173
+ finally:
174
+ os.close(info)
175
+ finally:
176
+ os.close(git)
177
+
178
+
179
+ def _next(name, n):
180
+ path = PurePosixPath(name)
181
+ return f'{path.stem}-{n}{path.suffix}'
182
+
183
+
184
+ def _exists(dir_fd, name):
185
+ try:
186
+ os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
187
+ return True
188
+ except FileNotFoundError:
189
+ return False
190
+
191
+
192
+ def free_name(dir_fd, name):
193
+ """The first name of `name`'s collision sequence (`name`, `<stem>-1<suffix>`, `-2` …) nothing in dir_fd has."""
194
+ final, n = name, 1
195
+ while _exists(dir_fd, final):
196
+ final, n = _next(name, n), n + 1
197
+ if n > MAX_LINKS:
198
+ raise L.LanesError('no free name in uploads/')
199
+ return final
200
+
201
+
202
+ def link_free(dir_fd, source, name, source_dir=None):
203
+ """Hard-link `source` (an entry of source_dir, default dir_fd; never followed when it is a link) to the first free
204
+ name of `name`'s collision sequence in dir_fd -> the name used. link(2) never replaces an existing name, so a name
205
+ taken in the meantime moves on to the next one: an earlier upload is never overwritten."""
206
+ final, n = name, 1
207
+ while True:
208
+ try:
209
+ os.link(source, final, src_dir_fd=dir_fd if source_dir is None else source_dir, dst_dir_fd=dir_fd,
210
+ follow_symlinks=False)
211
+ return final
212
+ except FileExistsError:
213
+ final, n = _next(name, n), n + 1
214
+ if n > MAX_LINKS:
215
+ raise L.LanesError('no free name in uploads/') from None
216
+
217
+
218
+ def rename_free(source_dir, source, dir_fd, name):
219
+ """Rename the directory `source` (in source_dir) to the first free name of `name`'s sequence in dir_fd -> the name
220
+ used. A name some entry has is skipped (rename would replace an empty directory)."""
221
+ final, n = name, 1
222
+ while True:
223
+ if not _exists(dir_fd, final):
224
+ try:
225
+ os.rename(source, final, src_dir_fd=source_dir, dst_dir_fd=dir_fd)
226
+ return final
227
+ except OSError as error:
228
+ if error.errno not in (17, 39): # EEXIST, ENOTEMPTY: taken meanwhile
229
+ raise
230
+ final, n = _next(name, n), n + 1
231
+ if n > MAX_LINKS:
232
+ raise L.LanesError('no free name in uploads/')
233
+
234
+
235
+ def remove_tree(dir_fd, name):
236
+ """Remove the entry `name` of dir_fd and, for a directory, everything in it — never through a link."""
237
+ try:
238
+ info = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
239
+ except FileNotFoundError:
240
+ return
241
+ if not stat.S_ISDIR(info.st_mode):
242
+ os.unlink(name, dir_fd=dir_fd)
243
+ return
244
+ fd = os.open(name, _DIR, dir_fd=dir_fd)
245
+ try:
246
+ for entry in list(os.scandir(fd)):
247
+ remove_tree(fd, entry.name)
248
+ finally:
249
+ os.close(fd)
250
+ os.rmdir(name, dir_fd=dir_fd)
251
+
252
+
253
+ # ------------------------------------------------------------------------------------------------------- recording
254
+
255
+ class Tee:
256
+ """A reader over `source` that writes every chunk it hands out to the fd `out` and hashes it on the way."""
257
+
258
+ def __init__(self, source, out):
259
+ self.source, self.out, self.size, self.digest = source, out, 0, hashlib.sha256()
260
+
261
+ def read(self, size=-1):
262
+ chunk = self.source.read(size if size and size > 0 else CHUNK)
263
+ if chunk:
264
+ view = memoryview(chunk)
265
+ while view:
266
+ view = view[os.write(self.out, view):]
267
+ self.digest.update(chunk)
268
+ self.size += len(chunk)
269
+ return chunk
270
+
271
+ def drain(self):
272
+ """Read the rest (written to `out` too) -> (bytes, sha256 hex) of everything that passed."""
273
+ while self.read(CHUNK):
274
+ pass
275
+ return self.size, self.digest.hexdigest()
276
+
277
+
278
+ def record(tee, *, login, ws, lane, name, source):
279
+ """Hand the bytes `tee` yields to docs_store.intake as a document of `login` in `ws`/`lane` named `name` — kind
280
+ by extension, for and by the login, at most UPLOAD_LIMIT bytes — then drain `tee`, so the landed file is complete
281
+ whatever the store said -> (meta or None, problem word or None): too-large, busy, encrypt-failed, invalid (intake
282
+ logged each), no-owner (no login: a docs.log `upload` line with the bytes and sha256 only), no-lane."""
283
+ meta, problem = None, None
284
+ if login is None or lane is None:
285
+ problem = 'no-owner' if login is None else 'no-lane'
286
+ else:
287
+ try:
288
+ meta = D.intake(login, ws, lane, name, tee, limit=UPLOAD_LIMIT, source=source, kind=kind_for(name),
289
+ for_logins=[login], by=login)
290
+ except D.TooLarge:
291
+ problem = 'too-large'
292
+ except D.Busy:
293
+ problem = 'busy'
294
+ except D.EncryptError:
295
+ problem = 'encrypt-failed'
296
+ except D.DocsError:
297
+ problem = 'invalid'
298
+ size, sha256 = tee.drain()
299
+ if meta is None and problem in ('no-owner', 'no-lane'):
300
+ D.audit('upload', login, None, lane, size, sha256, problem)
301
+ return meta, problem
302
+
303
+
304
+ def receive(uploads, name, reader, *, login, ws, lane, source):
305
+ """One file into the uploads/ directory `uploads` (an fd) AND the store, both or neither (POST /up) -> (meta, the
306
+ name it landed under). Written as `.incoming-<hex>` while the store takes the same bytes, then linked to `name`'s
307
+ first free name. Raises UploadError(word) when the store refused it (nothing lands), OSError when it could not land
308
+ (the store document is removed again)."""
309
+ temporary = f'.incoming-{secrets.token_hex(8)}'
310
+ fd = os.open(temporary, _NEW, 0o600, dir_fd=uploads)
311
+ try:
312
+ meta, problem = record(Tee(reader, fd), login=login, ws=ws, lane=lane, name=name, source=source)
313
+ if meta is None:
314
+ raise UploadError(problem)
315
+ try:
316
+ os.fchmod(fd, FILE_MODE)
317
+ final = link_free(uploads, temporary, name)
318
+ except BaseException:
319
+ try:
320
+ D.remove(meta['id'], login)
321
+ except D.DocsError:
322
+ pass
323
+ raise
324
+ return meta, final
325
+ finally:
326
+ os.close(fd)
327
+ try:
328
+ os.unlink(temporary, dir_fd=uploads)
329
+ except OSError:
330
+ pass
331
+
332
+
333
+ # --------------------------------------------------------------------------------------------- the SSH tar stream
334
+
335
+ def member_parts(name):
336
+ """The path components of a tar member name below the upload: leading `/` and `.` components dropped (as tar's
337
+ data filter strips a leading slash), None for a name with `..` or a NUL (refused)."""
338
+ if not isinstance(name, str) or '\0' in name:
339
+ return None
340
+ parts = [part for part in name.split('/') if part not in ('', '.')]
341
+ return None if '..' in parts else parts
342
+
343
+
344
+ def _file_mode(mode):
345
+ return (mode & 0o755) | 0o600 # tar's data filter: no setuid/setgid/sticky, no group/other write, owner rw
346
+
347
+
348
+ def _dir_mode(mode):
349
+ return (mode & 0o755) | 0o700
350
+
351
+
352
+ def _dirs(base, parts, create=True):
353
+ """fd of base/<parts…>, each a real directory (made 0755 when absent and `create`); never through a link."""
354
+ fd = os.dup(base)
355
+ try:
356
+ for part in parts:
357
+ if create and _mkdir(fd, part, 0o700):
358
+ made = os.open(part, _DIR, dir_fd=fd)
359
+ os.fchmod(made, DIR_MODE)
360
+ os.close(fd)
361
+ fd = made
362
+ continue
363
+ child = os.open(part, _DIR, dir_fd=fd)
364
+ os.close(fd)
365
+ fd = child
366
+ except BaseException:
367
+ os.close(fd)
368
+ raise
369
+ return fd
370
+
371
+
372
+ def _create(parent, name, make):
373
+ """make(parent, name) for a new entry; an entry of that name an earlier member made is replaced (a tar stream may
374
+ carry a path twice, and extraction keeps the last)."""
375
+ try:
376
+ return make(parent, name)
377
+ except FileExistsError:
378
+ os.unlink(name, dir_fd=parent)
379
+ return make(parent, name)
380
+
381
+
382
+ def _link_inside(parts, target):
383
+ """Whether a symbolic link at `parts` pointing at `target` stays inside the upload (relative, never above it)."""
384
+ if not target or target.startswith('/') or '\0' in target:
385
+ return False
386
+ depth = len(parts) - 1
387
+ for part in target.split('/'):
388
+ if part == '..':
389
+ depth -= 1
390
+ if depth < 0:
391
+ return False
392
+ elif part not in ('', '.'):
393
+ depth += 1
394
+ return True
395
+
396
+
397
+ def unpack(stream, staging, on_file):
398
+ """Unpack the tar stream `stream` into the directory `staging` (an fd) member by member -> the top-level names in
399
+ the order they came. Directories and regular files are made without following a link; a regular file's bytes go
400
+ through a Tee into it, and on_file(parts, tee) is called while they stream (it must drain the tee); a symbolic
401
+ link is kept only when it stays inside the upload and a hard link only to a file of it; a device, FIFO or any other
402
+ member is refused (LanesError), as tar's data filter refuses it. An empty stream is no member."""
403
+ tops = []
404
+ try:
405
+ archive = tarfile.open(fileobj=stream, mode='r|')
406
+ except tarfile.ReadError as error:
407
+ if 'empty' in str(error):
408
+ return tops
409
+ raise L.LanesError('the upload is not a tar stream') from None
410
+ with archive:
411
+ try:
412
+ for member in archive:
413
+ parts = member_parts(member.name)
414
+ if parts is None:
415
+ raise L.LanesError('refused: an archive member climbs out of the upload')
416
+ if not parts:
417
+ continue
418
+ if parts[0] not in tops:
419
+ tops.append(parts[0])
420
+ if member.isdir():
421
+ fd = _dirs(staging, parts)
422
+ os.fchmod(fd, _dir_mode(member.mode))
423
+ os.close(fd)
424
+ continue
425
+ parent = _dirs(staging, parts[:-1])
426
+ try:
427
+ if member.isreg():
428
+ fd = _create(parent, parts[-1], lambda p, n: os.open(n, _NEW, 0o600, dir_fd=p))
429
+ try:
430
+ on_file(parts, Tee(archive.extractfile(member), fd))
431
+ os.fchmod(fd, _file_mode(member.mode))
432
+ os.utime(fd, (member.mtime, member.mtime))
433
+ finally:
434
+ os.close(fd)
435
+ elif member.issym():
436
+ if not _link_inside(parts, member.linkname):
437
+ raise L.LanesError('refused: an archive link points outside the upload')
438
+ _create(parent, parts[-1], lambda p, n: os.symlink(member.linkname, n, dir_fd=p))
439
+ elif member.islnk():
440
+ target = member_parts(member.linkname)
441
+ if not target:
442
+ raise L.LanesError('refused: an archive hard link points outside the upload')
443
+ source = _dirs(staging, target[:-1], create=False)
444
+ try:
445
+ _create(parent, parts[-1], lambda p, n: os.link(target[-1], n, src_dir_fd=source,
446
+ dst_dir_fd=p, follow_symlinks=False))
447
+ finally:
448
+ os.close(source)
449
+ else:
450
+ raise L.LanesError('refused: the archive holds a device, a FIFO or another special file')
451
+ finally:
452
+ os.close(parent)
453
+ except (tarfile.TarError, EOFError):
454
+ raise L.LanesError('the upload is not a whole tar stream') from None
455
+ except OSError as error:
456
+ raise L.LanesError(f'the upload could not be unpacked: {error.strerror or error}') from None
457
+ return tops
458
+
459
+
460
+ def place(staging, name, uploads):
461
+ """Move the top-level entry `name` of `staging` into `uploads` under its first free name -> that name: a file (or
462
+ a link) by link(2) — the same inode, no copy —, a directory by rename. Both directories are on the volume."""
463
+ info = os.stat(name, dir_fd=staging, follow_symlinks=False)
464
+ if stat.S_ISDIR(info.st_mode):
465
+ return rename_free(staging, name, uploads, name)
466
+ final = link_free(uploads, name, name, source_dir=staging)
467
+ os.unlink(name, dir_fd=staging)
468
+ return final
469
+
470
+
471
+ def upload_lane(workspace, device=None):
472
+ """The lane an SSH upload to `workspace` is recorded under: the lane `device`'s first viewport shows on this board
473
+ (views/<device>/<ws>.main.target, the lane `--paste` types into), else the board's default lane (defaults/<ws>),
474
+ else its first lane; None for a board without lanes."""
475
+ ids = [lane['id'] for lane in workspace.get('lanes') or [] if isinstance(lane, dict)
476
+ and isinstance(lane.get('id'), str)]
477
+ paths = [L.STATE / 'views' / device / f"{workspace['id']}.main.target"] if device else []
478
+ for path in paths + [L.STATE / 'defaults' / workspace['id']]:
479
+ try:
480
+ first = path.read_text().split('\n')[0].strip()
481
+ except (OSError, UnicodeDecodeError):
482
+ continue
483
+ if first in ids:
484
+ return first
485
+ return ids[0] if ids else None
486
+
487
+
488
+ def receive_tar(stream, uploads, *, login, ws, lane, say=None):
489
+ """An SSH upload: the tar stream `stream` into the uploads/ directory `uploads` (an fd) and the store -> the names
490
+ that landed in uploads/, sorted (cmd_upload's order). Unpacked into a fresh `.incoming-<pid>-<hex>` there, each
491
+ regular file recorded while it streams — a top-level file under the name it is about to land as, a file of a
492
+ folder under its own name; at most MAX_RECORDS documents, the rest land with a docs.log `too-many` line —, then
493
+ every top-level entry placed. A stream that fails part-way lands nothing, as before, and the documents it had
494
+ already recorded are removed again. say(text) hears one line per file (its receipt, or why the store has none)."""
495
+ staging = f'.incoming-{os.getpid()}-{secrets.token_hex(4)}'
496
+ try:
497
+ os.mkdir(staging, 0o700, dir_fd=uploads)
498
+ except OSError as error:
499
+ raise L.LanesError(f'uploads/ cannot be written: {error.strerror or error}') from None
500
+ landed, recorded = [], []
501
+ try:
502
+ staging_fd = os.open(staging, _DIR, dir_fd=uploads)
503
+ try:
504
+ def on_file(parts, tee):
505
+ name = free_name(uploads, parts[0]) if len(parts) == 1 else parts[-1]
506
+ if len(recorded) >= MAX_RECORDS:
507
+ size, sha256 = tee.drain()
508
+ D.audit('upload', login, None, lane, size, sha256, 'too-many')
509
+ problem, meta = 'too-many', None
510
+ else:
511
+ meta, problem = record(tee, login=login, ws=ws, lane=lane, name=name, source='ssh')
512
+ if meta is not None:
513
+ recorded.append(meta)
514
+ if say:
515
+ say(f'# docs: {meta["name"]} {meta["bytes"]} B sha256 {meta["sha256"]} {meta["id"]}' if meta
516
+ else f'# docs: {name}: no receipt ({problem})')
517
+ try:
518
+ tops = unpack(stream, staging_fd, on_file)
519
+ except BaseException:
520
+ for meta in recorded:
521
+ try:
522
+ D.remove(meta['id'], login)
523
+ except D.DocsError:
524
+ pass
525
+ raise
526
+ for name in sorted(tops):
527
+ try:
528
+ landed.append(place(staging_fd, name, uploads))
529
+ except OSError as error:
530
+ raise L.LanesError(f'{name} could not be moved into uploads/: {error.strerror or error}') from None
531
+ finally:
532
+ os.close(staging_fd)
533
+ finally:
534
+ try:
535
+ remove_tree(uploads, staging)
536
+ except OSError:
537
+ pass
538
+ return landed
@@ -4,7 +4,7 @@ One JSON file per person under ~/.local/state/lanes/users/ (directory 0700, file
4
4
  person owns (members.owner_for decides ownership, as devices/<device>.json records it):
5
5
 
6
6
  {"version": 1, "login": "anna@example.com", "name": "Anna Berg",
7
- "look": {"theme": "light", "color": "256", "glyphs": "ascii"},
7
+ "look": {"theme": "light", "color": "256", "glyphs": "ascii", "fleet_collapsed": true},
8
8
  "seen": {"bw-tools--lanes-zellij": 1791000000.25},
9
9
  "workspaces": {"order": ["throttle", "bw-tools"], "hidden": []},
10
10
  "lanes": {"pins": ["bw-tools--lanes-zellij"], "hidden": []},
@@ -16,7 +16,8 @@ person owns (members.owner_for decides ownership, as devices/<device>.json recor
16
16
  A login whose quoted name would not fit a file name (MAX_STEM) has no profile.
17
17
  * Times are epoch seconds. `seen` is DM-LANES-CLIENT-SEEN's {lane id: when it was last opened} for the person (the
18
18
  newest MAX_SEEN marks); `look` holds only the keys set, with bin/lanes LOOK_KEYS' values (`notify`/`dim` stay
19
- per-device); `notify` holds `quiet_s` only when set; `last_opened` is null until a lane is opened. Lane ids are
19
+ per-device), and the sidebar's LOOK_FLAGS as JSON booleans (`fleet_collapsed`, the fleet block's `i`, W11);
20
+ `notify` holds `quiet_s` only when set; `last_opened` is null until a lane is opened. Lane ids are
20
21
  L.IDENT (at most MAX_LANE_ID characters), workspace ids L.IDENT at most 64, devices members.device_name, `name`
21
22
  one printable line (members._text: no control, format, surrogate, private-use or unassigned character).
22
23
  * Readers are tolerant and never raise: an invalid entry is dropped on its own; unknown top-level keys (a newer
@@ -49,7 +50,9 @@ person owns (members.owner_for decides ownership, as devices/<device>.json recor
49
50
  (load_all): one written by a desk device within the quiet window is presence, and `notify.quiet_s` is that
50
51
  person's own window. `updated_by_device` names the device a change was made AT (a sidebar, `lanes attach`, `lanes
51
52
  next|prev`, `lanes here`); the arranging verbs stamp `cli:<command>` and migrate() `cli:migration` instead
52
- (writers that are no device, so an admin's verb over SSH is never somebody at a desk: W9 review F3).
53
+ (writers that are no device, so an admin's verb over SSH is never somebody at a desk: W9 review F3). Since W11
54
+ (spec 2026-10-11 §3) the sidebar's `i` writes `look.fleet_collapsed` (set_look_flag) and every sidebar of the
55
+ person folds or unfolds its fleet block from the profile on its next tick (look_flag).
53
56
  """
54
57
  import copy
55
58
  import fcntl
@@ -71,6 +74,13 @@ FIELDS = ('version', 'login', 'name', 'look', 'seen', 'workspaces', 'lanes', 'la
71
74
  # bin/lanes LOOK_KEYS' theme/color/glyphs, value for value (test_users pins the equality).
72
75
  LOOK = {'theme': ('dark', 'light', 'auto'), 'color': ('truecolor', '256', '16', 'none'),
73
76
  'glyphs': ('unicode', 'ascii', 'emoji')}
77
+ # The person's sidebar switches beside LOOK (W11, spec 2026-10-11 §3): JSON booleans in the profile's `look`, written
78
+ # only by a sidebar key (set_look_flag) — never by `lanes attach`, never in a device file, never part of LOOK (so
79
+ # layered_look, device_only, unfolded and migrate's fold never see one). `fleet_collapsed` is the fleet block folded
80
+ # to its one line (`i`); absent is unfolded. A release before W11 drops the key when it normalizes a profile — its
81
+ # writer then stores the profile without it (DEF-1950): a sidebar still running the previous tree during an update
82
+ # unfolds the block for every device of the person once, until the next `i`.
83
+ LOOK_FLAGS = ('fleet_collapsed',)
74
84
  MAX_STEM = 200 # '.' + stem + '.json.' + 8 (L.atomic_write's temp name) stays under NAME_MAX 255
75
85
  MAX_LANE_ID = 256 # L.TRACE's cap: a longer lane id cannot be traced either
76
86
  MAX_SEEN = 1024 # newest marks kept; an older mark only re-shows a Done lane until it is opened
@@ -213,6 +223,7 @@ def normalize(document, login=None):
213
223
  if isinstance(look, dict):
214
224
  profile['look'] = {key: look[key] for key, allowed in LOOK.items() if isinstance(look.get(key), str)
215
225
  and look[key] in allowed}
226
+ profile['look'].update({key: look[key] for key in LOOK_FLAGS if isinstance(look.get(key), bool)})
216
227
  seen = document.get('seen')
217
228
  if isinstance(seen, dict):
218
229
  profile['seen'] = _bounded({lane: at for lane, at in seen.items() if _valid(check_lane, lane) and _time(at)})
@@ -409,6 +420,23 @@ def update(login, device, fn, now=None, directory=None, timeout=LOCK_S):
409
420
  os.close(fd)
410
421
 
411
422
 
423
+ def look_flag(profile, key):
424
+ """The LOOK_FLAGS boolean `key` of a normalized profile (read()), False when the profile or the key is absent."""
425
+ return bool(profile) and profile['look'].get(key) is True
426
+
427
+
428
+ def set_look_flag(login, device, key, value, directory=None, timeout=LOCK_S):
429
+ """Set one LOOK_FLAGS boolean in `login`'s profile look through update() — its lock, its merge, its dropped-write
430
+ rule (False: the lock was busy within `timeout`, or the profile is not one this release writes). Raises
431
+ L.LanesError for a key that is no flag or a value that is no bool (a caller's bug, never a person's input)."""
432
+ if key not in LOOK_FLAGS or not isinstance(value, bool):
433
+ raise L.LanesError('not a profile look flag')
434
+
435
+ def change(profile):
436
+ profile['look'][key] = value
437
+ return update(login, device, change, directory=directory, timeout=timeout)
438
+
439
+
412
440
  # ------------------------------------------------------------------------------------------------------- migration
413
441
 
414
442
  def _names(directory, suffix):
@@ -13,3 +13,7 @@ LANES_SERVER_NAME=workspace-server.example.ts.net
13
13
  LANES_SERVER_USER=workspace
14
14
  # Optional public SSH fallback (use only if your server exposes SSH there):
15
15
  # LANES_SERVER_PUBLIC=ssh.example.com
16
+ # Optional, read only on the server (its ~/.config/lanes/site.conf): the fleet dashboard the cockpit's host row and
17
+ # `lanes doctor` link to. https only (https://host[:port][/path]); anything else is ignored and doctor says why. Leave
18
+ # it unset until an authenticated Grafana deployment exists — no Lanes command ever writes it.
19
+ # LANES_FLEET_DASHBOARD_URL=https://grafana.example.ts.net/d/fleet
@@ -0,0 +1,29 @@
1
+ [Unit]
2
+ Description=lanes docs service (login-bound docs, artifacts and uploads) on loopback 127.0.0.1:8185
3
+
4
+ # Enabled and restarted by every server install (install/host-install.sh). lanes-mobile's Caddy forwards
5
+ # https://<host>:8443/lanes-docs/* here (web/Caddyfile `handle_path /lanes-docs/*`). It reads and writes the docs store
6
+ # ~/.local/state/lanes/docs/ and docs.log and reads members/, enrol-mode and registry.json; its publish pick-up (W4)
7
+ # reads each workspace container's ~/.lz/state/publish/ through /proc/<pid>/root, as lanes-statusd does, and writes the
8
+ # receipts back there; and (W5) it lands uploads in a workspace's uploads/ through the container's home volume. Hence
9
+ # it has no ProtectHome.
10
+ # It answers nothing until the operator pins the Serve header check (`lanes enrol mode request`, read on every
11
+ # request). Optional settings (LANES_DOCS_DOMAINS, LANES_DOCS_PORT, and the default-off encryption at rest
12
+ # LANES_DOCS_AGE_RECIPIENT / LANES_DOCS_AGE_IDENTITY, which `lanes upload` reads from the same drop-in) go in a drop-in,
13
+ # `systemctl --user edit lanes-docsd.service`, which a re-install keeps (RUNBOOK "Docs service").
14
+ # Memory: docsd holds at most two 32 MiB buffers at once (UPLOAD_SLOTS, shared by uploads and decryptions, one per
15
+ # login), two Markdown renders and an 8 MiB render cache, and its publish pick-up reads at most 4,096 directory entries
16
+ # of a container per cycle, so what a container plants or a member sends stays under MemoryMax (RUNBOOK "Docs service",
17
+ # Limits; the composed peak is not measured: DEF-1917).
18
+ [Service]
19
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/docsd.py"
20
+ Restart=on-failure
21
+ RestartSec=10
22
+ NoNewPrivileges=true
23
+ UMask=0077
24
+ MemoryHigh=128M
25
+ MemoryMax=192M
26
+ Slice=lanes.slice
27
+
28
+ [Install]
29
+ WantedBy=default.target