@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +7 -5
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +1091 -76
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +55 -8
- package/payload/lanes/container/lane-mcp +120 -22
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +524 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +357 -34
- package/payload/lanes/docs/DESIGN.md +13 -3
- package/payload/lanes/docs/INSTALL.md +47 -10
- package/payload/lanes/docs/RUNBOOK.md +761 -12
- package/payload/lanes/docs/USERGUIDE.md +421 -46
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +626 -25
- package/payload/lanes/libexec/statusd.py +530 -32
- package/payload/lanes/libexec/tokens.py +42 -9
- package/payload/lanes/libexec/update.py +171 -26
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +5 -3
|
@@ -24,7 +24,17 @@ sees, and `lane-mcp` may write nothing but protocol frames to its stdout.
|
|
|
24
24
|
`shown_key`, `lane_trace`, `canonical_recipe`, the id grammar (each pinned equal to its lanes_lib original by
|
|
25
25
|
test_lane_mcp_write.HostRules), and `declared_name`, which finds a registry lane's declared terminal in its file.
|
|
26
26
|
* the agents' MCP entries: `mcpServers.lanes` in ~/.claude.json and `[mcp_servers.lanes]` (command + env_vars) in
|
|
27
|
-
~/.codex/config.toml
|
|
27
|
+
~/.codex/config.toml — and (W12) `mcpServers.lanes` in ~/.kimi-code/mcp.json, a `# >>> lanes mcp >>>` block with
|
|
28
|
+
`[mcp_servers.lanes]` in Grok Build's ~/.grok-build/config.toml and the `lanes` item of grok-cli's `mcp.servers` in
|
|
29
|
+
~/.grok/user-settings.json, those three where the harness's home exists — planned as a diff, written with a
|
|
30
|
+
`.bak-<UTC>` copy, atomically and only when missing. TOML marker blocks (toml_block) are shared with the host's
|
|
31
|
+
`lanes hooks install`.
|
|
32
|
+
* the harness of a process (W12): process_harness / parent_harness over /proc (comm, the grok exe split, a native
|
|
33
|
+
Claude Code file), and whoami's fallback to the agent's own /proc environ (parent_environ) for a harness that strips
|
|
34
|
+
its MCP servers' environment (grok-cli).
|
|
35
|
+
* publish requests (plan W10 W4): `publish` writes ~/.lz/state/publish/<lane>/<id>.json for `lane publish` and
|
|
36
|
+
lane-mcp's `lanes_publish` (the file hashed once, the id in docs_store's grammar: `new_publish_id`), and
|
|
37
|
+
`latest_receipt` reads the `<id>.receipt.json` the host's lanes-docsd writes back (`lanes_status`'s `published`).
|
|
28
38
|
"""
|
|
29
39
|
import difflib
|
|
30
40
|
import json
|
|
@@ -48,7 +58,9 @@ HOOK_MAX_AGE = 3600 # statusd.SIDECAR_MAX_AGE: an older last event is no a
|
|
|
48
58
|
STATUS_FRESH = 60 # status.json `at` is renewed at least every 15 s (statusd.HEARTBEAT); older = not live
|
|
49
59
|
READ_MAX = 16 << 20 # a projection bigger than this is not ours
|
|
50
60
|
TMUX_TIMEOUT = 5
|
|
51
|
-
IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # +
|
|
61
|
+
IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE', 'LANES_TRACE') # + our trace headers and GROK_HOME (identity_free)
|
|
62
|
+
TRACE_HEADERS = ('ANTHROPIC_CUSTOM_HEADERS', 'KIMI_CODE_CUSTOM_HEADERS') # ours: 'x-throttle-trace: lanes/<ws>/<id>'
|
|
63
|
+
GROK_HOME = CONTAINER_HOME + '/.grok-build' # Grok Build's home in a lane, never grok-cli's ~/.grok (DM-LANES-GROK-HOME)
|
|
52
64
|
|
|
53
65
|
|
|
54
66
|
class LaneError(Exception):
|
|
@@ -201,9 +213,11 @@ def identity_free(environ, drop=('TMUX',)):
|
|
|
201
213
|
a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
|
|
202
214
|
that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1559). Board sockets are shared by
|
|
203
215
|
many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
|
|
204
|
-
An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays
|
|
216
|
+
An operator's own ANTHROPIC_CUSTOM_HEADERS / KIMI_CODE_CUSTOM_HEADERS that is not a lane trace is theirs and stays,
|
|
217
|
+
and so is a GROK_HOME that is not the one the recipe stamps (W12)."""
|
|
205
218
|
return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
|
|
206
|
-
and not (k
|
|
219
|
+
and not (k in TRACE_HEADERS and v.startswith('x-throttle-trace: lanes/'))
|
|
220
|
+
and not (k == 'GROK_HOME' and v == GROK_HOME)}
|
|
207
221
|
|
|
208
222
|
|
|
209
223
|
def tmux_env(environ=None):
|
|
@@ -295,7 +309,7 @@ def last_lines(text, lines):
|
|
|
295
309
|
def new_session_argv(socket, session, cwd, lane_id, ws, command):
|
|
296
310
|
"""The one tmux client lane-mcp runs WITHOUT -N (`lanes_new`): it creates the session, and starts the socket's
|
|
297
311
|
server when none runs. The lane's identity reaches the session only through these `-e` flags, the canonical
|
|
298
|
-
recipe's
|
|
312
|
+
recipe's seven (the client's own environment is creation_env(), an allow-list: whatever client starts a server
|
|
299
313
|
hands its environment to every later session there, DEF-1559, DEF-1802). `command` is one shell-command string,
|
|
300
314
|
run by the server's default shell like the recipe's command."""
|
|
301
315
|
trace = lane_trace(lane_id, ws)
|
|
@@ -303,7 +317,9 @@ def new_session_argv(socket, session, cwd, lane_id, ws, command):
|
|
|
303
317
|
raise LaneError(f'invalid lane trace: lanes/{ws}/{lane_id}', 'invalid_name')
|
|
304
318
|
return ['tmux', '-L', socket, 'new-session', '-d', '-s', session, '-x', '100', '-y', '30', '-c', cwd,
|
|
305
319
|
'-e', f'LANE_ID={lane_id}', '-e', f'LANE_WS={ws}',
|
|
306
|
-
'-e', f'ANTHROPIC_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'THROTTLE_TRACE={trace}',
|
|
320
|
+
'-e', f'ANTHROPIC_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'THROTTLE_TRACE={trace}',
|
|
321
|
+
'-e', f'KIMI_CODE_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'LANES_TRACE={trace}',
|
|
322
|
+
'-e', f'GROK_HOME={GROK_HOME}', command]
|
|
307
323
|
|
|
308
324
|
|
|
309
325
|
CREATION_ENV = ('HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LANGUAGE', 'TZ', 'TMUX_TMPDIR') # and every LC_*
|
|
@@ -376,8 +392,9 @@ def lane_trace(lane_id, ws):
|
|
|
376
392
|
|
|
377
393
|
def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
378
394
|
"""lanes_lib.canonical_recipe: the create recipe that gives a new tmux lane its identity (LANE_ID, LANE_WS, the
|
|
379
|
-
lanes/<ws>/<id> trace as ANTHROPIC_CUSTOM_HEADERS
|
|
380
|
-
the session exists. LaneError where the host raises
|
|
395
|
+
lanes/<ws>/<id> trace as ANTHROPIC_CUSTOM_HEADERS, THROTTLE_TRACE, KIMI_CODE_CUSTOM_HEADERS and LANES_TRACE, and
|
|
396
|
+
GROK_HOME) through `-e` flags; `new -A` attaches when the session exists. LaneError where the host raises
|
|
397
|
+
LanesError."""
|
|
381
398
|
trace = lane_trace(lane_id, ws)
|
|
382
399
|
if trace is None:
|
|
383
400
|
raise LaneError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)',
|
|
@@ -393,7 +410,8 @@ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
|
393
410
|
header = shlex.quote(f'x-throttle-trace: {trace}')
|
|
394
411
|
return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
|
|
395
412
|
f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
|
|
396
|
-
f'-e THROTTLE_TRACE={trace} {
|
|
413
|
+
f'-e THROTTLE_TRACE={trace} -e KIMI_CODE_CUSTOM_HEADERS={header} -e LANES_TRACE={trace} '
|
|
414
|
+
f'-e GROK_HOME={GROK_HOME} {cmd}')
|
|
397
415
|
|
|
398
416
|
|
|
399
417
|
def lane_name_problem(name):
|
|
@@ -466,17 +484,101 @@ def lane_kind(entry):
|
|
|
466
484
|
return 'registered' if entry.get('registered_session') else 'declared'
|
|
467
485
|
|
|
468
486
|
|
|
469
|
-
|
|
487
|
+
# The harnesses a process tree can name (W12; lane-signal's HARNESS_COMMS and parent chain, pinned equal by
|
|
488
|
+
# test_lane_core). A `grok` is Grok Build when its executable is $GROK_HOME/downloads/grok-linux-x86_64, else grok-cli.
|
|
489
|
+
HARNESS_COMMS = {'claude': 'claude', 'codex': 'codex', 'kimi': 'kimi', 'kimi-code': 'kimi', 'grok': 'grok'}
|
|
490
|
+
HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'unknown')
|
|
491
|
+
GROK_BUILD_EXE = '/downloads/grok-linux-x86_64'
|
|
492
|
+
CLAUDE_EXE = '/claude/versions/' # native Claude Code before 2.1.299 kept its file's name, the version, as comm (MEASURED)
|
|
493
|
+
PARENT_DEPTH = 8
|
|
494
|
+
ENVIRON_MAX = 1 << 20 # /proc/<pid>/environ read at most this far (the kernel caps an environment far below it)
|
|
495
|
+
WHOAMI_VARS = ('LANE_ID', 'LANE_WS', 'TMUX', 'TMUX_PANE') # what whoami reads (MCP_ENV_VARS passes the same)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _stat(proc, pid):
|
|
499
|
+
"""(comm, ppid) of /proc/<pid>/stat, or None."""
|
|
500
|
+
data = read_regular(Path(proc) / str(pid) / 'stat', 1 << 16)
|
|
501
|
+
if data is None:
|
|
502
|
+
return None
|
|
503
|
+
text = data.decode('utf-8', 'replace')
|
|
504
|
+
try:
|
|
505
|
+
return text[text.index('(') + 1:text.rindex(')')], int(text.rsplit(')', 1)[1].split()[1])
|
|
506
|
+
except (ValueError, IndexError):
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _exe(proc, pid):
|
|
511
|
+
try:
|
|
512
|
+
return os.readlink(str(Path(proc) / str(pid) / 'exe')).removesuffix(' (deleted)')
|
|
513
|
+
except OSError:
|
|
514
|
+
return ''
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def process_harness(proc, pid):
|
|
518
|
+
"""The harness process `pid` is, or None: its comm in HARNESS_COMMS (a `grok` split by /proc/<pid>/exe), else a
|
|
519
|
+
native Claude Code file (CLAUDE_EXE) as its executable."""
|
|
520
|
+
found = _stat(proc, pid)
|
|
521
|
+
if not found:
|
|
522
|
+
return None
|
|
523
|
+
harness = HARNESS_COMMS.get(found[0])
|
|
524
|
+
if harness == 'grok':
|
|
525
|
+
return 'grok' if _exe(proc, pid).endswith(GROK_BUILD_EXE) else 'grok-cli'
|
|
526
|
+
return harness or ('claude' if CLAUDE_EXE in _exe(proc, pid) else None)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def parent_harness(proc, pid):
|
|
530
|
+
"""The harness of `pid` or the first of its ancestors that is one (at most PARENT_DEPTH processes), else None."""
|
|
531
|
+
for _step in range(PARENT_DEPTH):
|
|
532
|
+
if not isinstance(pid, int) or pid <= 1:
|
|
533
|
+
return None
|
|
534
|
+
harness = process_harness(proc, pid)
|
|
535
|
+
if harness:
|
|
536
|
+
return harness
|
|
537
|
+
found = _stat(proc, pid)
|
|
538
|
+
pid = found[1] if found else 0
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def parent_environ(proc, pid, names=WHOAMI_VARS):
|
|
543
|
+
"""{name: value} of `names` in /proc/<pid>/environ when `pid` is a known harness (HARNESS_COMMS) — {} otherwise.
|
|
544
|
+
The read is bounded (ENVIRON_MAX) and never follows a link; entries are NUL-split, and one that is not UTF-8 or
|
|
545
|
+
holds no `=` is skipped. The whoami fallback for an agent that hands its MCP servers a stripped environment
|
|
546
|
+
(grok-cli, W12 DEF-1992): its own environment, set by the lane's recipe, still names the lane."""
|
|
547
|
+
if not isinstance(pid, int) or pid <= 1 or not process_harness(proc, pid):
|
|
548
|
+
return {}
|
|
549
|
+
data = read_regular(Path(proc) / str(pid) / 'environ', ENVIRON_MAX)
|
|
550
|
+
found = {}
|
|
551
|
+
for item in (data or b'').split(b'\0'):
|
|
552
|
+
key, sep, value = item.partition(b'=')
|
|
553
|
+
try:
|
|
554
|
+
key, value = key.decode('utf-8'), value.decode('utf-8')
|
|
555
|
+
except UnicodeDecodeError:
|
|
556
|
+
continue
|
|
557
|
+
if sep and key in names and value and key not in found:
|
|
558
|
+
found[key] = value
|
|
559
|
+
return found
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def whoami(root, environ=None, proc=Path('/proc'), parent=None):
|
|
470
563
|
"""Which lane this process runs in: {'lane', 'found_by', 'kind', 'entry' | 'session', 'why'?}.
|
|
471
564
|
$LANE_ID first (a canonical recipe's `-e`, or lane-run for a zmx lane); else the pane's session, asked of $TMUX's
|
|
472
565
|
socket (`display -p -t $TMUX_PANE '#S'`), matched by (socket name, session) in the registry, then among this
|
|
473
|
-
container's unregistered sessions (status.json). Not in a lane is an answer (`lane` null), not an error.
|
|
566
|
+
container's unregistered sessions (status.json). Not in a lane is an answer (`lane` null), not an error.
|
|
567
|
+
`parent` (lane-mcp passes its own parent, the agent, when it reads its own environment): when `environ` holds
|
|
568
|
+
neither LANE_ID nor TMUX/TMUX_PANE, the ones the parent's /proc environ holds stand in — only when the parent is a
|
|
569
|
+
known harness (parent_environ; grok-cli strips its MCP servers' environment, DEF-1992); `found_by` then says
|
|
570
|
+
`parent:LANE_ID` / `parent:tmux`."""
|
|
474
571
|
environ = os.environ if environ is None else environ
|
|
572
|
+
via = ''
|
|
573
|
+
if parent is not None and not environ.get('LANE_ID') and not (environ.get('TMUX') and environ.get('TMUX_PANE')):
|
|
574
|
+
inherited = parent_environ(proc, parent)
|
|
575
|
+
if inherited.get('LANE_ID') or (inherited.get('TMUX') and inherited.get('TMUX_PANE')):
|
|
576
|
+
environ, via = dict(environ, **inherited), 'parent:'
|
|
475
577
|
lanes = registry_lanes(root)
|
|
476
578
|
lane = environ.get('LANE_ID') or ''
|
|
477
579
|
if LANE_RE.fullmatch(lane):
|
|
478
580
|
entry = lanes.get(lane)
|
|
479
|
-
return {'lane': lane, 'found_by': 'LANE_ID', 'kind': lane_kind(entry) if entry else None, 'entry': entry,
|
|
581
|
+
return {'lane': lane, 'found_by': via + 'LANE_ID', 'kind': lane_kind(entry) if entry else None, 'entry': entry,
|
|
480
582
|
**({} if entry else {'why': f'LANE_ID={lane} is not in ~/.lz/registry.json (a zmx lane, or the '
|
|
481
583
|
'registry has not been synced since the lane was added)'})}
|
|
482
584
|
socket_path, pane = (environ.get('TMUX') or '').split(',', 1)[0], environ.get('TMUX_PANE') or ''
|
|
@@ -493,11 +595,12 @@ def whoami(root, environ=None):
|
|
|
493
595
|
socket = os.path.basename(socket_path)
|
|
494
596
|
for lane_id, entry in lanes.items():
|
|
495
597
|
if tmux_target(entry) == (socket, session):
|
|
496
|
-
return {'lane': lane_id, 'found_by': 'tmux', 'kind': lane_kind(entry), 'entry': entry}
|
|
598
|
+
return {'lane': lane_id, 'found_by': via + 'tmux', 'kind': lane_kind(entry), 'entry': entry}
|
|
497
599
|
for row in container_status(root)['unregistered']:
|
|
498
600
|
tmux = row.get('tmux') or {}
|
|
499
601
|
if (tmux.get('socket'), tmux.get('session')) == (socket, session):
|
|
500
|
-
return {'lane': row['id'], 'found_by': 'tmux', 'kind': 'unregistered', 'entry': None,
|
|
602
|
+
return {'lane': row['id'], 'found_by': via + 'tmux', 'kind': 'unregistered', 'entry': None,
|
|
603
|
+
'session': row}
|
|
501
604
|
return {'lane': None, 'found_by': None, 'kind': None, 'entry': None, 'tmux': {'socket': socket, 'session': session},
|
|
502
605
|
'why': 'this tmux session is neither a registry lane nor an unregistered session statusd has seen'}
|
|
503
606
|
|
|
@@ -910,8 +1013,13 @@ def terminals_add(text, terminal, entry=None):
|
|
|
910
1013
|
MCP_NAME = 'lanes'
|
|
911
1014
|
MCP_COMMAND = LZ + '/bin/lane-mcp'
|
|
912
1015
|
MCP_ENTRY = {'command': MCP_COMMAND}
|
|
913
|
-
MCP_AGENTS = ('claude', 'codex')
|
|
914
|
-
MCP_FILES = {'claude': '.claude.json', 'codex': '.codex/config.toml'
|
|
1016
|
+
MCP_AGENTS = ('claude', 'codex', 'kimi', 'grok', 'grok-cli')
|
|
1017
|
+
MCP_FILES = {'claude': '.claude.json', 'codex': '.codex/config.toml', 'kimi': '.kimi-code/mcp.json',
|
|
1018
|
+
'grok': '.grok-build/config.toml', 'grok-cli': '.grok/user-settings.json'}
|
|
1019
|
+
# W12: claude and codex get their entry in every home; kimi, grok (Grok Build, GROK_HOME) and grok-cli only where their
|
|
1020
|
+
# home directory already is a real directory (the harness is installed there), unless named (`--agent`).
|
|
1021
|
+
MCP_ALWAYS = ('claude', 'codex')
|
|
1022
|
+
MCP_HOMES = {'kimi': '.kimi-code', 'grok': '.grok-build', 'grok-cli': '.grok'}
|
|
915
1023
|
TOML_HEADER = re.compile(r'''^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*(?:lanes|"lanes"|'lanes')[ \t]*\][ \t]*(?:#.*)?$''')
|
|
916
1024
|
# What Codex must hand lane-mcp. Codex starts a stdio MCP server with a CLEARED environment: its defaults (HOME,
|
|
917
1025
|
# LOGNAME, PATH, SHELL, USER, LANG, LC_ALL, TERM, TMPDIR, TZ) plus the names in the table's `env_vars` and the
|
|
@@ -950,26 +1058,24 @@ def _an_older_ours(entry):
|
|
|
950
1058
|
and os.path.basename(entry['command']) == 'lane-mcp' and not entry.get('args') and 'url' not in entry
|
|
951
1059
|
|
|
952
1060
|
|
|
953
|
-
def
|
|
954
|
-
"""
|
|
955
|
-
|
|
956
|
-
so). The edit is the one member, inserted in the file's own layout; the result must parse back to the input plus
|
|
957
|
-
that member, else it is refused."""
|
|
1061
|
+
def _json_mcp(text, command, what):
|
|
1062
|
+
"""`what` (a JSON file in Claude Code's `mcpServers` shape) with `mcpServers.lanes = {"command": <command>}` ->
|
|
1063
|
+
(new text, why): claude_mcp and kimi_mcp."""
|
|
958
1064
|
entry = {'command': command}
|
|
959
1065
|
if text is None or not text.strip():
|
|
960
1066
|
return json.dumps({'mcpServers': {MCP_NAME: entry}}, indent=2) + '\n', None
|
|
961
|
-
doc = strict_json(text,
|
|
1067
|
+
doc = strict_json(text, what)
|
|
962
1068
|
if not isinstance(doc, dict):
|
|
963
|
-
return text, '
|
|
1069
|
+
return text, f'{what} is not a JSON object — left alone'
|
|
964
1070
|
servers = doc.get('mcpServers')
|
|
965
1071
|
if servers is not None and not isinstance(servers, dict):
|
|
966
|
-
return text, 'mcpServers in
|
|
1072
|
+
return text, f'mcpServers in {what} is not an object — left alone'
|
|
967
1073
|
current = (servers or {}).get(MCP_NAME)
|
|
968
1074
|
if _ours(current, command):
|
|
969
1075
|
return text, None
|
|
970
1076
|
if current is not None and not _an_older_ours(current):
|
|
971
1077
|
shown = current.get('command') if isinstance(current, dict) else current
|
|
972
|
-
return text, (f'mcpServers.{MCP_NAME} in
|
|
1078
|
+
return text, (f'mcpServers.{MCP_NAME} in {what} is not the entry Lanes writes (command {shown!r}) — '
|
|
973
1079
|
'left alone (remove it to let Lanes add its own)')
|
|
974
1080
|
if servers is None:
|
|
975
1081
|
after = set_member(text, [], 'mcpServers', {MCP_NAME: entry})
|
|
@@ -977,12 +1083,27 @@ def claude_mcp(text, command=MCP_COMMAND):
|
|
|
977
1083
|
after = set_member(text, ['mcpServers'], MCP_NAME, entry)
|
|
978
1084
|
expected = dict(doc)
|
|
979
1085
|
expected['mcpServers'] = dict(servers or {}, **{MCP_NAME: entry})
|
|
980
|
-
if strict_json(after,
|
|
1086
|
+
if strict_json(after, what) != expected:
|
|
981
1087
|
return text, 'the entry could not be added without changing anything else — add it by hand'
|
|
982
1088
|
return after, None
|
|
983
1089
|
|
|
984
1090
|
|
|
985
|
-
def
|
|
1091
|
+
def claude_mcp(text, command=MCP_COMMAND):
|
|
1092
|
+
"""~/.claude.json with `mcpServers.lanes = {"command": <command>}` -> (new text, why). The text is unchanged when
|
|
1093
|
+
an equivalent entry exists; an entry of the same name that starts something else is never replaced (why says
|
|
1094
|
+
so). The edit is the one member, inserted in the file's own layout; the result must parse back to the input plus
|
|
1095
|
+
that member, else it is refused."""
|
|
1096
|
+
return _json_mcp(text, command, '~/.claude.json')
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def kimi_mcp(text, command=MCP_COMMAND):
|
|
1100
|
+
"""~/.kimi-code/mcp.json (Kimi Code's user MCP file, `resolveKimiHome()/mcp.json`; MEASURED in the 2.1.1 binary,
|
|
1101
|
+
2026-10-15: a JSON object whose `mcpServers` object maps a name to a server) the way claude_mcp edits
|
|
1102
|
+
~/.claude.json. Kimi Code starts a stdio server with its own environment, so the lane's variables arrive."""
|
|
1103
|
+
return _json_mcp(text, command, '~/.kimi-code/mcp.json')
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _toml_loads(text, what='~/.codex/config.toml'):
|
|
986
1107
|
try:
|
|
987
1108
|
import tomllib
|
|
988
1109
|
except ImportError: # Python < 3.11 on the host: the textual rules below still hold
|
|
@@ -990,7 +1111,7 @@ def _toml_loads(text):
|
|
|
990
1111
|
try:
|
|
991
1112
|
return tomllib.loads(text)
|
|
992
1113
|
except tomllib.TOMLDecodeError as error:
|
|
993
|
-
raise LaneError(f'
|
|
1114
|
+
raise LaneError(f'{what} is not valid TOML ({error}) — left alone')
|
|
994
1115
|
|
|
995
1116
|
|
|
996
1117
|
TOML_COMMAND = re.compile(r'''^[ \t]*command[ \t]*=[ \t]*"([^"\\]*)"[ \t]*(?:#.*)?$''')
|
|
@@ -1082,14 +1203,206 @@ def codex_mcp(text, command=MCP_COMMAND):
|
|
|
1082
1203
|
return after, None
|
|
1083
1204
|
|
|
1084
1205
|
|
|
1085
|
-
|
|
1206
|
+
# ------------------------------------------------------------------------------------------------ TOML marker blocks
|
|
1207
|
+
# W12: what Lanes adds to a TOML file another program owns (Kimi Code's config.toml and tui.toml, Grok Build's
|
|
1208
|
+
# config.toml) is ONE block between `# >>> lanes <name> >>>` and `# <<< lanes <name> <<<` lines: appended at the end the
|
|
1209
|
+
# first time, replaced in place after that; nothing outside the markers is ever rewritten. A table another writer put
|
|
1210
|
+
# INSIDE the block (a toml_edit writer such as Throttle's Kimi adapter appends its `[[hooks]]` after the last table,
|
|
1211
|
+
# which can be ours) is kept, moved just below the end marker; anything else inside the markers that is not a table,
|
|
1212
|
+
# and markers that are not one ordered pair, refuse the file.
|
|
1213
|
+
|
|
1214
|
+
BLOCK_BEGIN = '# >>> lanes {name} >>>'
|
|
1215
|
+
BLOCK_END = '# <<< lanes {name} <<<'
|
|
1216
|
+
_TOML_TABLE = re.compile(r'^[ \t]*\[')
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _block_rows(text, name):
|
|
1220
|
+
"""(rows, begin index, end index) of the block `name` in `text`; (rows, None, None) when it has none. LaneError
|
|
1221
|
+
when its markers are not exactly one ordered pair."""
|
|
1222
|
+
begin, end = BLOCK_BEGIN.format(name=name), BLOCK_END.format(name=name)
|
|
1223
|
+
rows = (text or '').splitlines(True)
|
|
1224
|
+
marks = [(i, row.strip()) for i, row in enumerate(rows) if row.strip() in (begin, end)]
|
|
1225
|
+
if not marks:
|
|
1226
|
+
return rows, None, None
|
|
1227
|
+
if [mark for _i, mark in marks] != [begin, end]:
|
|
1228
|
+
raise LaneError(f'the `{begin}` … `{end}` lines are not one pair — fix the file by hand', 'source_invalid')
|
|
1229
|
+
return rows, marks[0][0], marks[1][0]
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def without_block(text, name):
|
|
1233
|
+
"""`text` without the block `name` (its markers and every line between them): what the file says apart from us."""
|
|
1234
|
+
rows, first, last = _block_rows(text, name)
|
|
1235
|
+
return text or '' if first is None else ''.join(rows[:first] + rows[last + 1:])
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def toml_block(text, name, body, ours=lambda table: True):
|
|
1239
|
+
"""`text` with the block `name` holding exactly `body` (whole lines) -> new text. `ours(table text)` says whether a
|
|
1240
|
+
table found inside an existing block is ours (replaced by `body`) or another writer's (kept, moved below the
|
|
1241
|
+
block). LaneError for a key or value inside the block that belongs to no table, or for broken markers."""
|
|
1242
|
+
begin, end = BLOCK_BEGIN.format(name=name), BLOCK_END.format(name=name)
|
|
1243
|
+
block = f'{begin}\n{body}{end}\n'
|
|
1244
|
+
if text is None or not text.strip():
|
|
1245
|
+
return block
|
|
1246
|
+
rows, first, last = _block_rows(text, name)
|
|
1247
|
+
if first is None:
|
|
1248
|
+
return text + ('' if text.endswith('\n') else '\n') + '\n' + block
|
|
1249
|
+
tables = []
|
|
1250
|
+
for row in rows[first + 1:last]:
|
|
1251
|
+
if _TOML_TABLE.match(row):
|
|
1252
|
+
tables.append([row])
|
|
1253
|
+
elif tables:
|
|
1254
|
+
tables[-1].append(row)
|
|
1255
|
+
elif row.strip() and not row.lstrip().startswith('#'):
|
|
1256
|
+
raise LaneError(f'the lanes {name} block holds a line outside any table ({row.strip()[:60]!r}) — fix the '
|
|
1257
|
+
'file by hand', 'source_invalid')
|
|
1258
|
+
moved = ''.join(''.join(table) for table in tables if not ours(''.join(table)))
|
|
1259
|
+
return ''.join(rows[:first]) + block + moved + ''.join(rows[last + 1:])
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _lanes_command(table):
|
|
1263
|
+
"""A table of a lanes block that is ours: it starts lane-mcp or runs lane-signal."""
|
|
1264
|
+
return '/.lz/bin/lane-signal' in table or '/.lz/bin/lane-mcp' in table
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def grok_mcp(text, command=MCP_COMMAND):
|
|
1268
|
+
"""Grok Build's $GROK_HOME/config.toml (~/.grok-build/config.toml in a lane, DM-LANES-GROK-HOME) with a
|
|
1269
|
+
`[mcp_servers.lanes]` table that starts <command> -> (new text, why). Written as the marker block `mcp`
|
|
1270
|
+
(toml_block) rather than through `grok mcp add` (the same table; the binary need not be there, and the file edit is
|
|
1271
|
+
what a test can check). Grok Build starts a stdio server with its own environment plus GROK_SESSION_ID (MEASURED in
|
|
1272
|
+
its source, evidence 2026-10-15 §5), so no `env` is needed. A `lanes` server defined outside the block is present
|
|
1273
|
+
when it starts this command with no args, else left alone. The input must parse (tomllib) and the result must parse
|
|
1274
|
+
to the input plus that table; without tomllib any `mcp_servers` outside the block refuses."""
|
|
1275
|
+
what = '~/.grok-build/config.toml'
|
|
1276
|
+
body = f'[mcp_servers.{MCP_NAME}]\ncommand = {json.dumps(command)}\n'
|
|
1277
|
+
try:
|
|
1278
|
+
parsed = _toml_loads(text or '', what)
|
|
1279
|
+
outside = without_block(text, 'mcp')
|
|
1280
|
+
rest = _toml_loads(outside, what)
|
|
1281
|
+
except LaneError as error:
|
|
1282
|
+
return text, str(error)
|
|
1283
|
+
if rest is None:
|
|
1284
|
+
if re.search(r'(?m)^[ \t]*\[*[ \t]*mcp_servers\b|^[ \t]*mcp_servers[ \t]*[.=]', outside):
|
|
1285
|
+
return text, (f'{what} defines mcp_servers in a form this Python (no tomllib) cannot read safely — add '
|
|
1286
|
+
f'the [mcp_servers.{MCP_NAME}] table by hand')
|
|
1287
|
+
else:
|
|
1288
|
+
servers = rest.get('mcp_servers')
|
|
1289
|
+
if servers is not None and not isinstance(servers, dict):
|
|
1290
|
+
return text, f'mcp_servers in {what} is not a table — left alone'
|
|
1291
|
+
current = (servers or {}).get(MCP_NAME)
|
|
1292
|
+
if current is not None:
|
|
1293
|
+
if _ours(current, command) and parsed == rest:
|
|
1294
|
+
return text, None
|
|
1295
|
+
shown = current.get('command') if isinstance(current, dict) else current
|
|
1296
|
+
return text, (f'mcp_servers.{MCP_NAME} in {what} is not the entry Lanes writes (command {shown!r}) — '
|
|
1297
|
+
'left alone (remove it to let Lanes add its own)')
|
|
1298
|
+
try:
|
|
1299
|
+
after = toml_block(text, 'mcp', body, _lanes_command)
|
|
1300
|
+
except LaneError as error:
|
|
1301
|
+
return text, f'{what}: {error}'
|
|
1302
|
+
if rest is not None:
|
|
1303
|
+
expected = dict(rest, mcp_servers=dict(rest.get('mcp_servers') or {}, **{MCP_NAME: {'command': command}}))
|
|
1304
|
+
try:
|
|
1305
|
+
ok = _toml_loads(after, what) == expected
|
|
1306
|
+
except LaneError:
|
|
1307
|
+
ok = False
|
|
1308
|
+
if not ok:
|
|
1309
|
+
return text, 'the table could not be added without changing anything else — add it by hand'
|
|
1310
|
+
return after, None
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _grok_cli_ours(entry, command):
|
|
1314
|
+
"""A grok-cli server entry that already starts this lane-mcp: id `lanes`, stdio, our command, no args (its
|
|
1315
|
+
`enabled`, `label` and `env` are the person's to change: a disabled entry stays disabled)."""
|
|
1316
|
+
return isinstance(entry, dict) and entry.get('id') == MCP_NAME and entry.get('command') == command \
|
|
1317
|
+
and not entry.get('args') and entry.get('transport') == 'stdio' and 'url' not in entry
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def append_item(text, path, value):
|
|
1321
|
+
"""`text` with `value` appended to the array at `path` (keys from the root), laid out like its items."""
|
|
1322
|
+
root = scan(text)
|
|
1323
|
+
node = root
|
|
1324
|
+
for step in path:
|
|
1325
|
+
node = node.get(step) if node.kind == 'object' else None
|
|
1326
|
+
if node is None:
|
|
1327
|
+
raise LaneError(f'no array at {".".join(path)}', 'source_invalid')
|
|
1328
|
+
if node.kind != 'array':
|
|
1329
|
+
raise LaneError(f'no array at {".".join(path)}', 'source_invalid')
|
|
1330
|
+
return _append(text, root, node, lambda indent, unit: _render(value, indent, unit))
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def grok_cli_mcp(text, command=MCP_COMMAND):
|
|
1334
|
+
"""grok-cli's ~/.grok/user-settings.json (os.homedir()/.grok, whatever GROK_HOME says; MEASURED in the 1.1.7
|
|
1335
|
+
binary's source, 2026-10-15: `mcp.servers` is an array of {id, label, enabled, transport, command, args, env, cwd},
|
|
1336
|
+
each stdio server started as `grok-cli-<id>` with ONLY HOME, LOGNAME, PATH, SHELL, TERM and USER of grok-cli's
|
|
1337
|
+
environment plus the entry's `env`) with one server `lanes` -> (new text, why). The entry's `env` is `{}`: a file
|
|
1338
|
+
every lane shares can carry no lane's identity, and nothing secret goes in it; lane-mcp names its lane through
|
|
1339
|
+
whoami's parent fallback (grok-cli's own environment, DEF-1992). The file holds grok-cli's `apiKey`: the edit is the
|
|
1340
|
+
one member or item, every other byte kept, and the result must parse back to the input plus that entry."""
|
|
1341
|
+
what = '~/.grok/user-settings.json'
|
|
1342
|
+
entry = {'id': MCP_NAME, 'label': 'Lanes', 'enabled': True, 'transport': 'stdio', 'command': command, 'args': [],
|
|
1343
|
+
'env': {}}
|
|
1344
|
+
if text is None or not text.strip():
|
|
1345
|
+
return json.dumps({'mcp': {'servers': [entry]}}, indent=2) + '\n', None
|
|
1346
|
+
doc = strict_json(text, what)
|
|
1347
|
+
if not isinstance(doc, dict):
|
|
1348
|
+
return text, f'{what} is not a JSON object — left alone'
|
|
1349
|
+
mcp = doc.get('mcp')
|
|
1350
|
+
servers = mcp.get('servers') if isinstance(mcp, dict) else None
|
|
1351
|
+
if (mcp is not None and not isinstance(mcp, dict)) or (servers is not None and not isinstance(servers, list)):
|
|
1352
|
+
return text, f'mcp.servers in {what} is not an array — left alone'
|
|
1353
|
+
named = [i for i, item in enumerate(servers or []) if isinstance(item, dict) and item.get('id') == MCP_NAME]
|
|
1354
|
+
if len(named) > 1:
|
|
1355
|
+
return text, f'{what} lists the server id {MCP_NAME!r} twice — left alone; fix it by hand'
|
|
1356
|
+
expected = dict(doc)
|
|
1357
|
+
if named:
|
|
1358
|
+
current = servers[named[0]]
|
|
1359
|
+
if _grok_cli_ours(current, command):
|
|
1360
|
+
return text, None
|
|
1361
|
+
if not _an_older_ours(current):
|
|
1362
|
+
return text, (f'the mcp server {MCP_NAME!r} in {what} is not the entry Lanes writes (command '
|
|
1363
|
+
f'{current.get("command")!r}) — left alone (remove it to let Lanes add its own)')
|
|
1364
|
+
item = scan(text).get('mcp').get('servers').items[named[0]]
|
|
1365
|
+
root = scan(text)
|
|
1366
|
+
after = text[:item.start] + _render(entry, _line_indent(text, item.start), _file_unit(text, root)) \
|
|
1367
|
+
+ text[item.end:]
|
|
1368
|
+
expected['mcp'] = dict(mcp, servers=[entry if i == named[0] else s for i, s in enumerate(servers)])
|
|
1369
|
+
elif mcp is None:
|
|
1370
|
+
after = set_member(text, [], 'mcp', {'servers': [entry]})
|
|
1371
|
+
expected['mcp'] = {'servers': [entry]}
|
|
1372
|
+
elif servers is None:
|
|
1373
|
+
after = set_member(text, ['mcp'], 'servers', [entry])
|
|
1374
|
+
expected['mcp'] = dict(mcp, servers=[entry])
|
|
1375
|
+
else:
|
|
1376
|
+
after = append_item(text, ['mcp', 'servers'], entry)
|
|
1377
|
+
expected['mcp'] = dict(mcp, servers=list(servers) + [entry])
|
|
1378
|
+
if strict_json(after, what) != expected:
|
|
1379
|
+
return text, 'the entry could not be added without changing anything else — add it by hand'
|
|
1380
|
+
return after, None
|
|
1086
1381
|
|
|
1087
1382
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1383
|
+
MCP_EDITORS = {'claude': claude_mcp, 'codex': codex_mcp, 'kimi': kimi_mcp, 'grok': grok_mcp, 'grok-cli': grok_cli_mcp}
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def mcp_agents(home):
|
|
1387
|
+
"""The agents whose entry an install writes by default: MCP_ALWAYS, plus each of kimi, grok and grok-cli whose
|
|
1388
|
+
home directory (MCP_HOMES) is a real directory under `home` — never a link (the host writes through the volume)."""
|
|
1389
|
+
found = list(MCP_ALWAYS)
|
|
1390
|
+
for agent in MCP_AGENTS:
|
|
1391
|
+
if agent not in found:
|
|
1392
|
+
try:
|
|
1393
|
+
info = os.lstat(str(Path(home) / MCP_HOMES[agent]))
|
|
1394
|
+
except OSError:
|
|
1395
|
+
continue
|
|
1396
|
+
if stat.S_ISDIR(info.st_mode):
|
|
1397
|
+
found.append(agent)
|
|
1398
|
+
return tuple(found)
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def mcp_plan(home, agents=None, command=MCP_COMMAND):
|
|
1402
|
+
"""[{agent, path, before, after, why}] for each agent's config under `home` (pure: reads only); `agents` defaults
|
|
1403
|
+
to mcp_agents(home). `after == before` with no `why` = already there; a `why` = left alone, with the reason."""
|
|
1091
1404
|
plan = []
|
|
1092
|
-
for agent in agents:
|
|
1405
|
+
for agent in (mcp_agents(home) if agents is None else agents):
|
|
1093
1406
|
path = Path(home) / MCP_FILES[agent]
|
|
1094
1407
|
try:
|
|
1095
1408
|
_ident, before = _snapshot(path, home)
|
|
@@ -1111,14 +1424,14 @@ def mcp_diff(item):
|
|
|
1111
1424
|
f'{path} (with the lanes MCP server)'))
|
|
1112
1425
|
|
|
1113
1426
|
|
|
1114
|
-
def mcp_apply(home, agents=
|
|
1427
|
+
def mcp_apply(home, agents=None, command=MCP_COMMAND):
|
|
1115
1428
|
"""Write each agent's entry where it is missing -> [{agent, path, status, backup, why}], status one of `added`,
|
|
1116
1429
|
`present`, `refused`. Each file is re-planned from its bytes at write time (edit_file: re-read before the
|
|
1117
1430
|
write, one retry), kept as <file>.bak-<UTC> first when it existed, replaced atomically with its own mode (a new
|
|
1118
1431
|
one 0600; a new ~/.codex is 0700). A symbolic link below `home` (the file, or ~/.codex) refuses that file.
|
|
1119
|
-
Never raises for one file: the other still gets its entry."""
|
|
1432
|
+
Never raises for one file: the other still gets its entry. `agents` defaults to mcp_agents(home)."""
|
|
1120
1433
|
results = []
|
|
1121
|
-
for agent in agents:
|
|
1434
|
+
for agent in (mcp_agents(home) if agents is None else agents):
|
|
1122
1435
|
path = Path(home) / MCP_FILES[agent]
|
|
1123
1436
|
refusal = []
|
|
1124
1437
|
|
|
@@ -1138,3 +1451,178 @@ def mcp_apply(home, agents=MCP_AGENTS, command=MCP_COMMAND):
|
|
|
1138
1451
|
results.append({'agent': agent, 'path': path, 'status': status, 'backup': saved,
|
|
1139
1452
|
'why': refusal[0] if refusal else None})
|
|
1140
1453
|
return results
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
# ------------------------------------------------------------------------------------------------ publishing (W10 W4)
|
|
1457
|
+
# `lane publish <file>` and lane-mcp's `lanes_publish` put a file of this container on the Lanes docs page. Nothing here
|
|
1458
|
+
# copies the file anywhere: the request names it, and the host's lanes-docsd reads it through /proc/<container init>/root
|
|
1459
|
+
# on its own 5 s cycle (it trusts nothing in the request: the path is resolved inside the container again, the bytes
|
|
1460
|
+
# hashed again), stores it in the docs store and writes `<id>.receipt.json` back beside the request, which it removes.
|
|
1461
|
+
#
|
|
1462
|
+
# ~/.lz/state/publish/<lane id>/<id>.json the request (DM-LANES-PUBLISH-REQUEST): {v: 1, path, title, kind,
|
|
1463
|
+
# claimed_sha256, mime, bytes, for}, `path` the file's absolute path
|
|
1464
|
+
# in this container with every link resolved
|
|
1465
|
+
# ~/.lz/state/publish/<lane id>/<id>.receipt.json the host's answer (DM-LANES-PUBLISH-RECEIPT): {v: 1, id, request,
|
|
1466
|
+
# sha256, bytes, url, at, outcome: ok|refused, reason?}
|
|
1467
|
+
|
|
1468
|
+
PUBLISH_KINDS = ('md', 'html', 'svg', 'file') # docs_store.KINDS without `upload` (a person's upload is the host's)
|
|
1469
|
+
PUBLISH_MAX = 32 << 20 # docsd.PUBLISH_LIMIT: a larger file is refused here and on the host
|
|
1470
|
+
PUBLISH_PICKUP = 'lanes-docsd' # what takes a request (docsd.Pickup)
|
|
1471
|
+
PUBLISH_LATENCY_S = 5 # docsd.PICKUP_INTERVAL: one pick-up cycle
|
|
1472
|
+
PUBLISH_DIR = ('state', 'publish') # under ~/.lz
|
|
1473
|
+
PUBLISH_ID = re.compile(r'[a-z0-9]{10,20}') # docs_store.ID_RE
|
|
1474
|
+
RECEIPT_SUFFIX = '.receipt.json'
|
|
1475
|
+
TITLE_MAX = 200 # docs_store.MAX_TITLE
|
|
1476
|
+
LOGIN_MAX = 254 # members.MAX_LOGIN
|
|
1477
|
+
ID_TIME = '0123456789abcdefghijklmnopqrstuvwxyz' # docs_store.BASE36: epoch seconds, zero-padded to 6
|
|
1478
|
+
ID_RANDOM = 'abcdefghijklmnopqrstuvwxyz234567' # docs_store.BASE32: 6 random characters
|
|
1479
|
+
KIND_BY_SUFFIX = {'.md': 'md', '.markdown': 'md', '.html': 'html', '.htm': 'html', '.svg': 'svg'}
|
|
1480
|
+
MIME_BY_SUFFIX = {'.md': 'text/markdown', '.markdown': 'text/markdown', '.html': 'text/html', '.htm': 'text/html',
|
|
1481
|
+
'.svg': 'image/svg+xml', '.txt': 'text/plain', '.json': 'application/json', '.csv': 'text/csv',
|
|
1482
|
+
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1483
|
+
'.gif': 'image/gif', '.webp': 'image/webp'} # = libexec/docs_store.MIME_BY_SUFFIX (DEF-1920)
|
|
1484
|
+
REFUSED_TEXT = frozenset(('Cc', 'Cf', 'Cs', 'Co', 'Cn', 'Zl', 'Zp')) # members.REFUSED_CATEGORIES
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def new_publish_id(now=None):
|
|
1488
|
+
"""A request id in docs_store's grammar (`new_id`: base36 epoch seconds zero-padded to 6, then 6 random base32),
|
|
1489
|
+
pinned equal to it by test_lane_publish. The container cannot import the host's store."""
|
|
1490
|
+
import secrets
|
|
1491
|
+
seconds, digits = int(time.time() if now is None else now), ''
|
|
1492
|
+
while True:
|
|
1493
|
+
seconds, rest = divmod(seconds, 36)
|
|
1494
|
+
digits = ID_TIME[rest] + digits
|
|
1495
|
+
if not seconds:
|
|
1496
|
+
break
|
|
1497
|
+
return digits.rjust(6, '0') + ''.join(secrets.choice(ID_RANDOM) for _ in range(6))
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def publish_kind(path, kind=None):
|
|
1501
|
+
"""`kind` when given (one of PUBLISH_KINDS), else from the file name: .md/.markdown md, .html/.htm html, .svg svg,
|
|
1502
|
+
anything else file. LaneError `invalid_kind`."""
|
|
1503
|
+
if kind is None or kind == '':
|
|
1504
|
+
return KIND_BY_SUFFIX.get(os.path.splitext(str(path))[1].lower(), 'file')
|
|
1505
|
+
if kind not in PUBLISH_KINDS:
|
|
1506
|
+
raise LaneError(f'kind {kind!r} is not one of {", ".join(PUBLISH_KINDS)}', 'invalid_kind')
|
|
1507
|
+
return kind
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _one_line(value, what, limit, blanks, code):
|
|
1511
|
+
"""`value` when it is one printable line of at most `limit` characters without outer blanks (members._text's rule;
|
|
1512
|
+
`blanks`: inner spaces allowed), else LaneError `code`. The host checks it again."""
|
|
1513
|
+
if (not isinstance(value, str) or not value or len(value) > limit or value != value.strip()
|
|
1514
|
+
or any(unicodedata.category(char) in REFUSED_TEXT for char in value)
|
|
1515
|
+
or (not blanks and any(char.isspace() for char in value))):
|
|
1516
|
+
raise LaneError(f'{what} is one line of at most {limit} printable characters'
|
|
1517
|
+
+ ('' if blanks else ' without blanks'), code)
|
|
1518
|
+
return value
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def publish_dir(root, lane):
|
|
1522
|
+
return Path(root).joinpath(*PUBLISH_DIR, lane)
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def publish(root, home, path, lane, title=None, for_login=None, kind=None, now=None):
|
|
1526
|
+
"""Write a publish request for the file at `path` (as this process reaches it) from `lane` -> {id, lane, path,
|
|
1527
|
+
kind, title, for, bytes, sha256, request, receipt}: `path` the file's container path, `request`/`receipt` the
|
|
1528
|
+
container paths of the request and of the receipt lanes-docsd writes. `root` is ~/.lz and `home` /home/coder as
|
|
1529
|
+
this process reaches them (themselves in a container, test directories otherwise).
|
|
1530
|
+
|
|
1531
|
+
The file must resolve (os.path.realpath) inside `home` to a regular file of at most PUBLISH_MAX bytes; it is read
|
|
1532
|
+
once, never through a link at its last component and never blocking on a FIFO, to hash it (`claimed_sha256`, which
|
|
1533
|
+
the host compares with its own). Refusals (LaneError): `not_in_lane`, `invalid_title`, `invalid_for`,
|
|
1534
|
+
`invalid_kind`, `outside_home`, `not_a_file`, `too_large`. The request is replaced atomically (0644); nothing else
|
|
1535
|
+
is written."""
|
|
1536
|
+
import hashlib
|
|
1537
|
+
import mimetypes
|
|
1538
|
+
if not isinstance(lane, str) or not LANE_RE.fullmatch(lane):
|
|
1539
|
+
raise LaneError('this agent runs in no lane to publish from', 'not_in_lane')
|
|
1540
|
+
title = None if title in (None, '') else _one_line(title, 'a title', TITLE_MAX, True, 'invalid_title')
|
|
1541
|
+
for_login = None if for_login in (None, '') else _one_line(for_login, 'a login', LOGIN_MAX, False, 'invalid_for')
|
|
1542
|
+
base = os.path.realpath(str(home))
|
|
1543
|
+
real = os.path.realpath(str(path))
|
|
1544
|
+
if not real.startswith(base.rstrip(os.sep) + os.sep):
|
|
1545
|
+
raise LaneError(f'{path} is not a file under {CONTAINER_HOME} (with every link resolved): only this '
|
|
1546
|
+
'container\'s home can be published', 'outside_home')
|
|
1547
|
+
kind = publish_kind(real, kind)
|
|
1548
|
+
try:
|
|
1549
|
+
fd = os.open(real, os.O_RDONLY | os.O_NONBLOCK | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0))
|
|
1550
|
+
except OSError as error:
|
|
1551
|
+
raise LaneError(f'{path}: {error.strerror or "cannot be opened"}', 'not_a_file') from None
|
|
1552
|
+
digest, size = hashlib.sha256(), 0
|
|
1553
|
+
try:
|
|
1554
|
+
info = os.fstat(fd)
|
|
1555
|
+
if not stat.S_ISREG(info.st_mode):
|
|
1556
|
+
raise LaneError(f'{path} is not a regular file', 'not_a_file')
|
|
1557
|
+
if info.st_size > PUBLISH_MAX:
|
|
1558
|
+
raise LaneError(f'{path} is larger than {PUBLISH_MAX >> 20} MiB', 'too_large')
|
|
1559
|
+
while True:
|
|
1560
|
+
chunk = os.read(fd, 1 << 16)
|
|
1561
|
+
if not chunk:
|
|
1562
|
+
break
|
|
1563
|
+
size += len(chunk)
|
|
1564
|
+
if size > PUBLISH_MAX:
|
|
1565
|
+
raise LaneError(f'{path} is larger than {PUBLISH_MAX >> 20} MiB', 'too_large')
|
|
1566
|
+
digest.update(chunk)
|
|
1567
|
+
finally:
|
|
1568
|
+
os.close(fd)
|
|
1569
|
+
container = CONTAINER_HOME + '/' + os.path.relpath(real, base).replace(os.sep, '/')
|
|
1570
|
+
name = os.path.basename(real)
|
|
1571
|
+
# The same table as libexec/docs_store.MIME_BY_SUFFIX: Python's built-in map gained `.md` only in 3.12, so the
|
|
1572
|
+
# request's `mime` must not depend on the interpreter (the host compares nothing on it, but receipts carry it).
|
|
1573
|
+
mime, encoding = MIME_BY_SUFFIX.get(os.path.splitext(name)[1].lower()), None
|
|
1574
|
+
if not mime:
|
|
1575
|
+
mime, encoding = mimetypes.MimeTypes().guess_type(name) # the built-in table, as the host's
|
|
1576
|
+
request = {'v': 1, 'path': container, 'title': title, 'kind': kind, 'claimed_sha256': digest.hexdigest(),
|
|
1577
|
+
'mime': mime if mime and not encoding else 'application/octet-stream', 'bytes': size, 'for': for_login}
|
|
1578
|
+
directory = publish_dir(root, lane)
|
|
1579
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1580
|
+
for _ in range(16):
|
|
1581
|
+
request_id = new_publish_id(now)
|
|
1582
|
+
if not any(os.path.lexists(str(directory / (request_id + suffix))) for suffix in ('.json', RECEIPT_SUFFIX)):
|
|
1583
|
+
break
|
|
1584
|
+
else:
|
|
1585
|
+
raise LaneError('could not mint a free request id', 'refused')
|
|
1586
|
+
atomic_replace(directory / f'{request_id}.json', json.dumps(request, ensure_ascii=False, indent=1) + '\n', 0o644)
|
|
1587
|
+
where = LZ + '/' + '/'.join(PUBLISH_DIR) + f'/{lane}/{request_id}'
|
|
1588
|
+
return {'id': request_id, 'lane': lane, 'path': container, 'kind': kind, 'title': title, 'for': for_login,
|
|
1589
|
+
'bytes': size, 'sha256': request['claimed_sha256'], 'request': where + '.json',
|
|
1590
|
+
'receipt': where + RECEIPT_SUFFIX}
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def latest_receipt(root, lane):
|
|
1594
|
+
"""The newest receipt lanes-docsd wrote back for `lane` (~/.lz/state/publish/<lane>/<id>.receipt.json; the latest
|
|
1595
|
+
`at` — whole seconds —, then the file written last, then the id) as lanes_status shows it -> {id, url, at,
|
|
1596
|
+
outcome, request, reason?}, or None (no receipt yet, or none that reads as one). Each file is read as a regular
|
|
1597
|
+
file only (read_regular: no link, no FIFO)."""
|
|
1598
|
+
if not isinstance(lane, str) or not LANE_RE.fullmatch(lane):
|
|
1599
|
+
return None
|
|
1600
|
+
directory = publish_dir(root, lane)
|
|
1601
|
+
try:
|
|
1602
|
+
names = os.listdir(str(directory))
|
|
1603
|
+
except OSError:
|
|
1604
|
+
return None
|
|
1605
|
+
best = None
|
|
1606
|
+
for name in names:
|
|
1607
|
+
request_id = name[:-len(RECEIPT_SUFFIX)] if name.endswith(RECEIPT_SUFFIX) else ''
|
|
1608
|
+
if not PUBLISH_ID.fullmatch(request_id):
|
|
1609
|
+
continue
|
|
1610
|
+
receipt = read_json(directory / name)
|
|
1611
|
+
if not (isinstance(receipt, dict) and receipt.get('v') == 1 and isinstance(receipt.get('at'), str)
|
|
1612
|
+
and receipt.get('outcome') in ('ok', 'refused')):
|
|
1613
|
+
continue
|
|
1614
|
+
try:
|
|
1615
|
+
written = os.stat(str(directory / name), follow_symlinks=False).st_mtime_ns
|
|
1616
|
+
except OSError:
|
|
1617
|
+
continue
|
|
1618
|
+
key = (receipt['at'], written, request_id)
|
|
1619
|
+
if best is None or key > best[0]:
|
|
1620
|
+
best = (key, request_id, receipt)
|
|
1621
|
+
if best is None:
|
|
1622
|
+
return None
|
|
1623
|
+
_key, request_id, receipt = best
|
|
1624
|
+
out = {'id': receipt.get('id'), 'url': receipt.get('url'), 'at': receipt['at'], 'outcome': receipt['outcome'],
|
|
1625
|
+
'request': request_id}
|
|
1626
|
+
if receipt['outcome'] == 'refused':
|
|
1627
|
+
out['reason'] = receipt.get('reason')
|
|
1628
|
+
return out
|