@vibelord/lanes 1.1.1 → 1.3.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 (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -0,0 +1,1318 @@
1
+ """lane_core — what the in-container lane contract shares: `lane`, `lane-mcp` (both under ~/.lz/bin) and, on the host,
2
+ `lanes install-container`, which writes the agents' MCP entries through the same code (LNS-34).
3
+
4
+ Python 3.9+ stdlib only (the host's floor; containers run 3.12). Nothing here prints: callers decide what a person
5
+ sees, and `lane-mcp` may write nothing but protocol frames to its stdout.
6
+
7
+ * readers of the two host projections in ~/.lz: `registry.json` (`lanes sync`; DM-LANES-REGISTRY) and
8
+ `status.json` (`lanes-statusd`; DM-LANES-CONTAINER-STATUS). A newer `version`/`v` is refused (NewerVersion),
9
+ never half-read.
10
+ * one tmux runner: every client runs with `-N` (never start a server: MEASURED, tmux 3.4, `attach-session` on a
11
+ socket nobody serves starts one and leaves its socket file behind; DEF-1797), without a lane identity in its
12
+ environment (DEF-1559), with stdin closed (lane-mcp's stdin is its protocol stream) and bounded in time (exit
13
+ 124 when tmux does not answer, 127 when it cannot start). Targets are `=<session>:` (a bare `=<session>` is
14
+ refused by send-keys and capture-pane). A text and its Enter are two clients ENTER_DELAY s apart (send_keys,
15
+ DEF-1803); the one client allowed to start a server (`lanes_new`) gets creation_env(), an allow-list (DEF-1802).
16
+ * whoami: $LANE_ID, else the pane's session (`display -p -t $TMUX_PANE '#S'` on $TMUX's socket) matched in the
17
+ registry, then among the container's unregistered sessions. Codex clears an MCP server's environment, so its
18
+ entry passes these through (`env_vars`, MCP_ENV_VARS); Claude Code's servers inherit them.
19
+ * the terminals.json minimal editor: strict JSON only, every byte outside the edited span kept, re-read before the
20
+ write (one retry when the file changed meanwhile), atomic replace; never the root `throttle` object, never a
21
+ terminal's `name`, never a reorder, never the last terminal. lane-mcp's write tools (LNS-35) and, on the host,
22
+ `session_actions.unregister_declared` (the sidebar's `u` on a declared lane) edit through it.
23
+ * the host's lane-id and recipe rules, ported so the container can predict what `lanes sync` will publish: `slug`,
24
+ `shown_key`, `lane_trace`, `canonical_recipe`, the id grammar (each pinned equal to its lanes_lib original by
25
+ test_lane_mcp_write.HostRules), and `declared_name`, which finds a registry lane's declared terminal in its file.
26
+ * the agents' MCP entries: `mcpServers.lanes` in ~/.claude.json and `[mcp_servers.lanes]` (command + env_vars) in
27
+ ~/.codex/config.toml, planned as a diff, written with a `.bak-<UTC>` copy, atomically and only when missing.
28
+ * publish requests (plan W10 W4): `publish` writes ~/.lz/state/publish/<lane>/<id>.json for `lane publish` and
29
+ lane-mcp's `lanes_publish` (the file hashed once, the id in docs_store's grammar: `new_publish_id`), and
30
+ `latest_receipt` reads the `<id>.receipt.json` the host's lanes-docsd writes back (`lanes_status`'s `published`).
31
+ """
32
+ import difflib
33
+ import json
34
+ import os
35
+ import pwd
36
+ import re
37
+ import shlex
38
+ import stat
39
+ import subprocess
40
+ import tempfile
41
+ import time
42
+ import unicodedata
43
+ from pathlib import Path
44
+
45
+ CONTAINER_HOME = '/home/coder'
46
+ LZ = CONTAINER_HOME + '/.lz'
47
+ LANE_RE = re.compile(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}') # host lanes_lib.IDENT, bounded
48
+ STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
49
+ NEEDS_YOU = ('needs_approval', 'errored', 'needs_input') # the sidebar's "Needs you" section (sidebar.ATTENTION)
50
+ HOOK_MAX_AGE = 3600 # statusd.SIDECAR_MAX_AGE: an older last event is no agent
51
+ STATUS_FRESH = 60 # status.json `at` is renewed at least every 15 s (statusd.HEARTBEAT); older = not live
52
+ READ_MAX = 16 << 20 # a projection bigger than this is not ours
53
+ TMUX_TIMEOUT = 5
54
+ IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
55
+
56
+
57
+ class LaneError(Exception):
58
+ """A refusal with a sentence a person (or an agent) can act on, and, where a caller branches on it, a machine
59
+ `code` (lane-mcp's write tools answer it as `refused`; the editors below name theirs)."""
60
+
61
+ def __init__(self, message, code=None):
62
+ super().__init__(message)
63
+ self.code = code
64
+
65
+
66
+ class NewerVersion(LaneError):
67
+ """A projection written by a newer Lanes than this contract: refreshed by `lanes install-container`."""
68
+
69
+ def __init__(self, message):
70
+ super().__init__(message, 'contract_outdated')
71
+
72
+
73
+ # ------------------------------------------------------------------------------------------------ reading files
74
+
75
+ def read_regular(path, limit=READ_MAX):
76
+ """The bytes of a regular file, or None (missing, a symbolic link, not a regular file, unreadable). Opened
77
+ non-blocking and without following a link: a FIFO planted at the name can never hang a reader; a file bigger than
78
+ `limit` is not read whole (None)."""
79
+ try:
80
+ fd = os.open(str(path), os.O_RDONLY | os.O_NONBLOCK | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0))
81
+ except OSError:
82
+ return None
83
+ try:
84
+ info = os.fstat(fd)
85
+ if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
86
+ return None
87
+ chunks, size = [], 0
88
+ while True:
89
+ chunk = os.read(fd, 1 << 16)
90
+ if not chunk:
91
+ return b''.join(chunks)
92
+ size += len(chunk)
93
+ if size > limit:
94
+ return None
95
+ chunks.append(chunk)
96
+ except OSError:
97
+ return None
98
+ finally:
99
+ os.close(fd)
100
+
101
+
102
+ def read_json(path):
103
+ """A JSON document from a regular file, or None (missing, unreadable, not JSON)."""
104
+ data = read_regular(path)
105
+ if data is None:
106
+ return None
107
+ try:
108
+ return json.loads(data.decode('utf-8'))
109
+ except ValueError:
110
+ return None
111
+
112
+
113
+ def registry_lanes(root):
114
+ """{lane id: entry} of <root>/registry.json — what `lanes sync` places in this container (declared and registered
115
+ lanes). {} when there is none yet (a zmx-only container needs none). A newer registry raises NewerVersion."""
116
+ data = read_json(Path(root) / 'registry.json')
117
+ if not isinstance(data, dict):
118
+ return {}
119
+ try:
120
+ version = int(data.get('version', 1))
121
+ except (TypeError, ValueError):
122
+ return {}
123
+ if version > 1:
124
+ raise NewerVersion('the registry is newer than this `lane` — on the host run: lanes install-container <container>')
125
+ lanes = data.get('lanes')
126
+ if not isinstance(lanes, dict):
127
+ return {}
128
+ return {key: value for key, value in lanes.items() if isinstance(value, dict) and LANE_RE.fullmatch(key)}
129
+
130
+
131
+ def container_status(root, now=None):
132
+ """<root>/status.json, the slice of statusd's observation the host writes into this container:
133
+ {'available', 'why'?, 'at', 'age', 'fresh', 'lanes': {id: row}, 'unregistered': [row]}. `fresh` is False once
134
+ `at` is older than STATUS_FRESH (statusd renews it every 15 s while it runs). A newer `v` raises NewerVersion."""
135
+ now = time.time() if now is None else now
136
+ data = read_json(Path(root) / 'status.json')
137
+ if not isinstance(data, dict):
138
+ return {'available': False, 'why': 'no ~/.lz/status.json yet: the host\'s lanes-statusd writes it within a few '
139
+ 'seconds of `lanes install-container` and `lanes sync`', 'at': None, 'age': None, 'fresh': False,
140
+ 'lanes': {}, 'unregistered': []}
141
+ try:
142
+ version = int(data.get('v', 1))
143
+ at = float(data.get('at'))
144
+ except (TypeError, ValueError):
145
+ version, at = 1, None
146
+ if version > 1:
147
+ raise NewerVersion('~/.lz/status.json is newer than this contract — on the host run: '
148
+ 'lanes install-container <container>')
149
+ lanes = data.get('lanes') if isinstance(data.get('lanes'), dict) else {}
150
+ ghosts = [row for row in data.get('unregistered') or [] if isinstance(row, dict) and row.get('id')]
151
+ age = None if at is None else max(0, round(now - at, 1))
152
+ return {'available': True, 'at': at, 'age': age, 'fresh': age is not None and age <= STATUS_FRESH,
153
+ 'lanes': {key: value for key, value in lanes.items() if isinstance(value, dict)}, 'unregistered': ghosts}
154
+
155
+
156
+ def hook_agent(hooks_dir, name, proc=Path('/proc')):
157
+ """The lane's `lane-signal` sidecar (<hooks_dir>/<name>.json) as an agent dict, or {} — judged as the host's
158
+ `agent_state.read_sidecar_dict` judges it: ours (`v` 1, a vocabulary word), a last event (`at`, else `since`)
159
+ within HOOK_MAX_AGE, and a recorded agent pid still alive in this container (and the same process when the
160
+ writer recorded its `start`). A crashed agent's last word is never an agent."""
161
+ try:
162
+ d = json.loads((Path(hooks_dir) / f'{name}.json').read_text())
163
+ except (OSError, ValueError):
164
+ return {}
165
+ if not isinstance(d, dict) or d.get('v') != 1 or d.get('status') not in STATUSES:
166
+ return {}
167
+ try:
168
+ since = int(d.get('since') or 0)
169
+ at = int(d.get('at') or since)
170
+ pid = int(d.get('pid') or 0)
171
+ except (TypeError, ValueError):
172
+ return {}
173
+ if since <= 0 or time.time() - at > HOOK_MAX_AGE:
174
+ return {}
175
+ if pid > 0:
176
+ try:
177
+ text = (Path(proc) / str(pid) / 'stat').read_text()
178
+ except OSError:
179
+ return {}
180
+ if d.get('start') is not None:
181
+ fields = text.rsplit(')', 1)[-1].split()
182
+ if len(fields) < 20 or fields[19] != str(d['start']):
183
+ return {}
184
+ agent = {'harness': str(d.get('harness') or ''), 'status': d['status'], 'since': since, 'at': at,
185
+ 'event': str(d.get('event') or ''), 'session': str(d.get('session') or ''), 'pid': pid, 'source': 'hook'}
186
+ if isinstance(d.get('name'), str) and d['name']:
187
+ agent['name'] = d['name'] # the agent's own note (lanes_signal), which the host shows as its task line
188
+ return agent
189
+
190
+
191
+ def tmux_target(entry):
192
+ """(socket, session) of a registry entry held by tmux, else None."""
193
+ if not isinstance(entry, dict) or entry.get('holder') != 'tmux':
194
+ return None
195
+ tmux = entry.get('tmux') or {}
196
+ socket, session = tmux.get('socket'), tmux.get('session')
197
+ return (str(socket), str(session)) if socket and session else None
198
+
199
+
200
+ # ------------------------------------------------------------------------------------------------ the tmux runner
201
+
202
+ def identity_free(environ, drop=('TMUX',)):
203
+ """`environ` without any lane identity (lanes/docs/ARCHITECTURE.md "Lane identity"). Whatever tmux client STARTS
204
+ a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
205
+ that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1559). Board sockets are shared by
206
+ many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
207
+ An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays."""
208
+ return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
209
+ and not (k == 'ANTHROPIC_CUSTOM_HEADERS' and v.startswith('x-throttle-trace: lanes/'))}
210
+
211
+
212
+ def tmux_env(environ=None):
213
+ """The environment of every tmux client: the caller's, without a lane identity and without TMUX/TMUX_PANE."""
214
+ return identity_free(os.environ if environ is None else environ, drop=('TMUX', 'TMUX_PANE'))
215
+
216
+
217
+ def tmux_argv(socket, *args):
218
+ """`tmux -N -L <socket> ARGS…` — -N: a client never starts a server (DEF-1797)."""
219
+ return ['tmux', '-N', '-L', socket, *args]
220
+
221
+
222
+ def target(session):
223
+ """The exact session, its current window: `=<session>:` (a bare `=<session>` is refused by send-keys and
224
+ capture-pane with "can't find pane", MEASURED tmux 3.4)."""
225
+ return f'={session}:'
226
+
227
+
228
+ def run_tmux(argv, timeout=None, env=None):
229
+ """One tmux client -> subprocess.CompletedProcess. Never raises: exit 124 when tmux did not answer within
230
+ `timeout` seconds (default TMUX_TIMEOUT), 127 when it could not be started. stdin is closed, stdout/stderr captured (bytes that are not
231
+ UTF-8 become U+FFFD: a pane's output never breaks the caller). `env` defaults to tmux_env() (creation_env() for
232
+ the one client that may start a server)."""
233
+ timeout = TMUX_TIMEOUT if timeout is None else timeout
234
+ try:
235
+ return subprocess.run(argv, capture_output=True, text=True, errors='replace', timeout=timeout,
236
+ env=tmux_env() if env is None else env, stdin=subprocess.DEVNULL)
237
+ except subprocess.TimeoutExpired:
238
+ return subprocess.CompletedProcess(argv, 124, '', f'tmux did not answer within {timeout} s\n')
239
+ except OSError as error:
240
+ return subprocess.CompletedProcess(argv, 127, '', f'tmux could not start: {error.strerror or error}\n')
241
+
242
+
243
+ def tmux_text(text):
244
+ """tmux's argv parser ends a command at ONE trailing `;` of ANY argument and turns a trailing `\\;` into `;`
245
+ (MEASURED, tmux 3.4: `send-keys -l -- 'echo a;'` types `echo a`). Writing that last `;` as `\\;` types the text
246
+ exactly, a trailing `\\;` included."""
247
+ return text[:-1] + '\\;' if text.endswith(';') else text
248
+
249
+
250
+ ENTER_DELAY = 0.5 # seconds between the typed text and its Enter (send_keys)
251
+
252
+
253
+ def send_argvs(socket, session, text, enter=True):
254
+ """The tmux clients that type `text` literally into the session's active pane and then press Enter: the text,
255
+ then Enter as a SECOND client. One client (`send-keys -l -- TEXT ; send-keys Enter`) hands the program in the
256
+ pane the text and the CR in a single read (MEASURED, tmux 3.4: a raw-mode reader got b'please run the tests\\r' in
257
+ one chunk), and an agent's prompt reads a multi-character chunk as a paste, CR included, so the turn never starts
258
+ (DEF-1803). send_keys runs them ENTER_DELAY seconds apart."""
259
+ typed = tmux_argv(socket, 'send-keys', '-t', target(session), '-l', '--', tmux_text(text))
260
+ return [typed, tmux_argv(socket, 'send-keys', '-t', target(session), 'Enter')] if enter else [typed]
261
+
262
+
263
+ def pause(seconds):
264
+ """time.sleep, named so a test can record the pause between a text and its Enter instead of waiting it out."""
265
+ time.sleep(seconds)
266
+
267
+
268
+ def send_keys(socket, session, text, enter=True, run=None):
269
+ """Type `text` (send_argvs), wait ENTER_DELAY seconds, press Enter -> [CompletedProcess, …] of the clients that
270
+ ran: it stops at the first that fails, so one result that failed means nothing was typed, and a failed second
271
+ means the text was typed without its Enter. `run` is the caller's tmux runner (default run_tmux)."""
272
+ run = run or run_tmux
273
+ results = []
274
+ for argv in send_argvs(socket, session, text, enter):
275
+ if results:
276
+ pause(ENTER_DELAY)
277
+ results.append(run(argv))
278
+ if results[-1].returncode:
279
+ break
280
+ return results
281
+
282
+
283
+ def capture_argv(socket, session, lines, vt=False):
284
+ """Joined scrollback of the session's active pane: the last `lines` history lines plus the screen (`-S -N`; the
285
+ caller keeps the last N non-blank lines), or all of it (`-S -`); `vt` keeps the escape sequences (`-e`)."""
286
+ argv = tmux_argv(socket, 'capture-pane', '-p', '-J', '-S', f'-{lines}' if lines else '-', '-t', target(session))
287
+ return argv + ['-e'] if vt else argv
288
+
289
+
290
+ def last_lines(text, lines):
291
+ """The last `lines` lines that end in something: a tmux capture ends with the screen's unused blank rows."""
292
+ rows = text.splitlines()
293
+ while rows and not rows[-1].strip():
294
+ rows.pop()
295
+ return '\n'.join(rows[-lines:]) + '\n' if rows else ''
296
+
297
+
298
+ def new_session_argv(socket, session, cwd, lane_id, ws, command):
299
+ """The one tmux client lane-mcp runs WITHOUT -N (`lanes_new`): it creates the session, and starts the socket's
300
+ server when none runs. The lane's identity reaches the session only through these `-e` flags, the canonical
301
+ recipe's four (the client's own environment is creation_env(), an allow-list: whatever client starts a server
302
+ hands its environment to every later session there, DEF-1559, DEF-1802). `command` is one shell-command string,
303
+ run by the server's default shell like the recipe's command."""
304
+ trace = lane_trace(lane_id, ws)
305
+ if trace is None:
306
+ raise LaneError(f'invalid lane trace: lanes/{ws}/{lane_id}', 'invalid_name')
307
+ return ['tmux', '-L', socket, 'new-session', '-d', '-s', session, '-x', '100', '-y', '30', '-c', cwd,
308
+ '-e', f'LANE_ID={lane_id}', '-e', f'LANE_WS={ws}',
309
+ '-e', f'ANTHROPIC_CUSTOM_HEADERS=x-throttle-trace: {trace}', '-e', f'THROTTLE_TRACE={trace}', command]
310
+
311
+
312
+ CREATION_ENV = ('HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LANGUAGE', 'TZ', 'TMUX_TMPDIR') # and every LC_*
313
+ CREATION_FIXED = {'TERM': 'xterm-256color', 'COLORTERM': 'truecolor'} # session_actions.new_shell's two `-e`
314
+
315
+
316
+ def creation_env(environ=None):
317
+ """The environment of the one client that may start a tmux server (`lanes_new`): an ALLOW-LIST, never the caller's
318
+ environment minus a deny-list (DEF-1802). The client that starts a socket's server hands its whole environment to
319
+ the server's global environment, and every later session there inherits it (DEF-1559). lane-mcp runs with the
320
+ AGENT's environment: under Claude Code that holds CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_ENTRYPOINT,
321
+ CLAUDE_CODE_MESSAGING_SOCKET/TOKEN, ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL (MEASURED in a Claude Code MCP server's
322
+ /proc/<pid>/environ, 2026-10-06), none of which may reach the shells a person later opens on that board.
323
+
324
+ The host's twin, `session_actions.new_shell`, starts the same `lanes-shells-<ws>` server through `docker exec -e
325
+ TERM=xterm-256color -e COLORTERM=truecolor`, i.e. the container's own environment and nothing of the operator's
326
+ shell. This builds the nearest equivalent from inside: the account and locale names in CREATION_ENV, every `LC_*`,
327
+ TMUX_TMPDIR when set (the socket directory), a UTF-8 LANG where the caller has none (Codex clears an MCP server's
328
+ environment), HOME from the password database where the caller has none, and the host's TERM and COLORTERM. A login
329
+ shell in the new session reads the profiles (and ~/.lz/env.sh) itself; no lane identity is in the list, so
330
+ `ANTHROPIC_CUSTOM_HEADERS` — an operator's own included — never reaches the server either."""
331
+ environ = os.environ if environ is None else environ
332
+ env = {key: value for key, value in environ.items()
333
+ if value and (key in CREATION_ENV or (key.startswith('LC_') and re.fullmatch(r'LC_[A-Z]+', key)))}
334
+ env.setdefault('LANG', 'C.UTF-8')
335
+ if 'HOME' not in env:
336
+ try:
337
+ env['HOME'] = pwd.getpwuid(os.getuid()).pw_dir
338
+ except KeyError:
339
+ env['HOME'] = CONTAINER_HOME
340
+ env.update(CREATION_FIXED)
341
+ return env
342
+
343
+
344
+ # ------------------------------------------------------------------------------------------------ the host's rules
345
+ # Ports of lanes_lib's (the host's) rules, so a write tool in the container can predict what `lanes sync` will
346
+ # publish. Each is pinned equal to its original by test_lane_mcp_write.HostRules; change both or neither.
347
+
348
+ IDENT = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*\Z') # lanes_lib.IDENT: ids, workspaces, sockets
349
+ TMUX_SESSION = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_. -]*\Z') # lanes_lib.TMUX_SESSION: a declarable session name
350
+ SAFE_PATH = re.compile(r'^/[A-Za-z0-9_./-]+\Z') # lanes_lib.SAFE_PATH: a lane folder
351
+ TRACE = re.compile(r'[A-Za-z0-9._/-]{1,256}') # lanes_lib.TRACE: the proxy's x-throttle-trace
352
+ LANE_OPEN_COMMAND = 'exec ' + LZ + '/bin/lane open ' # cursor_sync.ENTRY: a terminal's uniform command
353
+ SHELL_SOCKET = 'lanes-shells-' # session_actions.shell_socket(<ws>): a board's shells
354
+ NAME_MAX = 128 # a lane name or display name, at most
355
+
356
+
357
+ def slug(text):
358
+ """lanes_lib.slug: ASCII letters and digits of the NFKD form, every other run one `-`, lower case; `lane` when
359
+ nothing is left. A declared lane's id is `<ws>--<slug(prefix + terminal name)>` (lanes_lib.lane_id_for)."""
360
+ text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
361
+ text = re.sub(r'[^A-Za-z0-9]+', '-', text).strip('-').lower()
362
+ return text or 'lane'
363
+
364
+
365
+ def shown_key(value):
366
+ """lanes_lib._shown_key: a name as a row reads it (NFKC, invisible format characters dropped, whitespace runs
367
+ collapsed). Two names with one key are two rows nobody can tell apart."""
368
+ text = ''.join(ch for ch in unicodedata.normalize('NFKC', value) if unicodedata.category(ch) != 'Cf')
369
+ return ' '.join(text.split())
370
+
371
+
372
+ def lane_trace(lane_id, ws):
373
+ """lanes_lib.lane_trace: `lanes/<ws>/<id>`, or None when the proxy would reject it."""
374
+ if not isinstance(ws, str) or not isinstance(lane_id, str):
375
+ return None
376
+ trace = f'lanes/{ws}/{lane_id}'
377
+ return trace if TRACE.fullmatch(trace) else None
378
+
379
+
380
+ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
381
+ """lanes_lib.canonical_recipe: the create recipe that gives a new tmux lane its identity (LANE_ID, LANE_WS, the
382
+ lanes/<ws>/<id> trace as ANTHROPIC_CUSTOM_HEADERS and THROTTLE_TRACE) through `-e` flags; `new -A` attaches when
383
+ the session exists. LaneError where the host raises LanesError."""
384
+ trace = lane_trace(lane_id, ws)
385
+ if trace is None:
386
+ raise LaneError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)',
387
+ 'invalid_name')
388
+ if not isinstance(socket, str) or not IDENT.match(socket):
389
+ raise LaneError(f'Invalid tmux socket: {socket!r}', 'invalid_session')
390
+ if not isinstance(session, str) or not TMUX_SESSION.match(session):
391
+ raise LaneError(f'Invalid tmux session: {session!r}', 'invalid_session')
392
+ if not isinstance(folder, str) or not folder.startswith('/'):
393
+ raise LaneError(f'Invalid lane folder: {folder!r}', 'invalid_cwd')
394
+ if not isinstance(cmd, str) or not cmd.strip():
395
+ raise LaneError('Invalid lane command', 'invalid_command')
396
+ header = shlex.quote(f'x-throttle-trace: {trace}')
397
+ return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
398
+ f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
399
+ f'-e THROTTLE_TRACE={trace} {cmd}')
400
+
401
+
402
+ def lane_name_problem(name):
403
+ """Why `name` cannot name a lane, or None: one line (no control character), no leading or trailing space, at most
404
+ NAME_MAX characters, valid UTF-8 (no lone surrogate), at least one letter or digit A–Z/0–9 (the id is made of
405
+ them: a name without any would get the id `<ws>--lane`)."""
406
+ if not isinstance(name, str) or not name:
407
+ return 'a name is needed'
408
+ if any(unicodedata.category(ch) in ('Cc', 'Cs') for ch in name):
409
+ return 'a name is one line of text: no control characters'
410
+ if name != name.strip():
411
+ return 'no leading or trailing spaces'
412
+ if len(name) > NAME_MAX:
413
+ return f'at most {NAME_MAX} characters'
414
+ if not shown_key(name):
415
+ return 'a name must read as something (not only invisible characters)'
416
+ if not re.search(r'[A-Za-z0-9]', unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode()):
417
+ return 'a name needs at least one letter or digit A-Z/0-9: the lane id is made of them'
418
+ return None
419
+
420
+
421
+ def _binding(entry):
422
+ """(socketName, sessionName) of a terminals.json binding object, else None."""
423
+ tmux = entry.get('tmux') if isinstance(entry, dict) else None
424
+ if not isinstance(tmux, dict):
425
+ return None
426
+ pair = (tmux.get('socketName'), tmux.get('sessionName'))
427
+ return pair if all(isinstance(part, str) and part for part in pair) else None
428
+
429
+
430
+ def declared_name(doc, entry):
431
+ """The DECLARED terminal name (its `terminals[].name`) of a registry entry in its parsed terminals.json, or None.
432
+ First the `lanes.terminals` entry bound to the lane's tmux socket and session (`lanes cursor-sync` writes one per
433
+ terminal), then the root `throttle` block's binding, then the one terminal whose name ends the entry's
434
+ `registry_name` (prefix + declared name). None when none of these names exactly one terminal: a binding the host
435
+ keeps in the board's manifest (bindingOverrides/Recoveries) is not visible from here."""
436
+ try:
437
+ names = [name for name in _terminal_names(doc) if isinstance(name, str) and name]
438
+ except LaneError:
439
+ return None
440
+ target = tmux_target(entry)
441
+ if target:
442
+ blocks = (_lanes_terminals(doc) or {}, ((doc.get('throttle') or {}).get('terminals') or {}))
443
+ for block in blocks:
444
+ hits = [name for name in names if isinstance(block, dict) and _binding(block.get(name)) == target]
445
+ if len(hits) == 1:
446
+ return hits[0]
447
+ registry_name = entry.get('registry_name') if isinstance(entry, dict) else None
448
+ hits = [name for name in names if isinstance(registry_name, str) and registry_name.endswith(name)]
449
+ return hits[0] if len(hits) == 1 else None
450
+
451
+
452
+ def source_prefix(doc, entries):
453
+ """The repository prefix a board puts before the declared names of one terminals.json (the manifest's `prefix`,
454
+ which the container never sees), learned from that board's lanes of the file: `registry_name` minus the declared
455
+ name. None unless every lane that can be matched agrees and at least one can."""
456
+ prefixes = set()
457
+ for entry in entries:
458
+ name = declared_name(doc, entry)
459
+ registry_name = entry.get('registry_name') or ''
460
+ if name and registry_name.endswith(name):
461
+ prefixes.add(registry_name[:len(registry_name) - len(name)])
462
+ return prefixes.pop() if len(prefixes) == 1 else None
463
+
464
+
465
+ # ------------------------------------------------------------------------------------------------ whoami
466
+
467
+ def lane_kind(entry):
468
+ """declared (a terminals.json lane) or registered (a session adopted with `lanes register`)."""
469
+ return 'registered' if entry.get('registered_session') else 'declared'
470
+
471
+
472
+ def whoami(root, environ=None):
473
+ """Which lane this process runs in: {'lane', 'found_by', 'kind', 'entry' | 'session', 'why'?}.
474
+ $LANE_ID first (a canonical recipe's `-e`, or lane-run for a zmx lane); else the pane's session, asked of $TMUX's
475
+ socket (`display -p -t $TMUX_PANE '#S'`), matched by (socket name, session) in the registry, then among this
476
+ container's unregistered sessions (status.json). Not in a lane is an answer (`lane` null), not an error."""
477
+ environ = os.environ if environ is None else environ
478
+ lanes = registry_lanes(root)
479
+ lane = environ.get('LANE_ID') or ''
480
+ if LANE_RE.fullmatch(lane):
481
+ entry = lanes.get(lane)
482
+ return {'lane': lane, 'found_by': 'LANE_ID', 'kind': lane_kind(entry) if entry else None, 'entry': entry,
483
+ **({} if entry else {'why': f'LANE_ID={lane} is not in ~/.lz/registry.json (a zmx lane, or the '
484
+ 'registry has not been synced since the lane was added)'})}
485
+ socket_path, pane = (environ.get('TMUX') or '').split(',', 1)[0], environ.get('TMUX_PANE') or ''
486
+ if not socket_path or not pane:
487
+ return {'lane': None, 'found_by': None, 'kind': None, 'entry': None,
488
+ 'why': 'not inside a lane: this process has no LANE_ID and no TMUX/TMUX_PANE (an agent that clears '
489
+ 'its MCP servers\' environment must pass them: for Codex, `lane mcp install` writes the '
490
+ '[mcp_servers.lanes] env_vars)'}
491
+ result = run_tmux(['tmux', '-N', '-S', socket_path, 'display', '-p', '-t', pane, '#S'])
492
+ session = result.stdout.strip() if result.returncode == 0 else ''
493
+ if not session:
494
+ return {'lane': None, 'found_by': None, 'kind': None, 'entry': None,
495
+ 'why': f'tmux did not name this pane\'s session ({(result.stderr or "no answer").strip()})'}
496
+ socket = os.path.basename(socket_path)
497
+ for lane_id, entry in lanes.items():
498
+ if tmux_target(entry) == (socket, session):
499
+ return {'lane': lane_id, 'found_by': 'tmux', 'kind': lane_kind(entry), 'entry': entry}
500
+ for row in container_status(root)['unregistered']:
501
+ tmux = row.get('tmux') or {}
502
+ if (tmux.get('socket'), tmux.get('session')) == (socket, session):
503
+ return {'lane': row['id'], 'found_by': 'tmux', 'kind': 'unregistered', 'entry': None, 'session': row}
504
+ return {'lane': None, 'found_by': None, 'kind': None, 'entry': None, 'tmux': {'socket': socket, 'session': session},
505
+ 'why': 'this tmux session is neither a registry lane nor an unregistered session statusd has seen'}
506
+
507
+
508
+ # ------------------------------------------------------------------------------------------------ atomic files
509
+
510
+ def atomic_replace(path, text, mode):
511
+ """Replace `path` with `text` (str) atomically: an O_EXCL temp file in the same directory, fsync, chmod, rename
512
+ over whatever the name holds."""
513
+ path = Path(path)
514
+ fd, tmp = tempfile.mkstemp(prefix='.' + path.name + '.', dir=str(path.parent))
515
+ try:
516
+ with os.fdopen(fd, 'w', encoding='utf-8') as handle:
517
+ handle.write(text)
518
+ handle.flush()
519
+ os.fsync(handle.fileno())
520
+ os.chmod(tmp, mode)
521
+ os.replace(tmp, str(path))
522
+ except BaseException:
523
+ try:
524
+ os.unlink(tmp)
525
+ except OSError:
526
+ pass
527
+ raise
528
+
529
+
530
+ EDIT_MAX = 64 << 20 # ~/.claude.json grows with Claude Code's project history
531
+
532
+
533
+ def no_links(root, path):
534
+ """Refuse (LaneError) when a component of `path` below `root` is a symbolic link. The host edits the agents'
535
+ files inside a container's home volume: a link planted there resolves on the HOST (`~/.claude.json ->
536
+ /home/coder/.claude.json` would read, and copy into the container, the operator's own Claude configuration; the
537
+ DEF-1796 class)."""
538
+ path, root = Path(path), Path(root)
539
+ for depth in range(1, len(path.relative_to(root).parts) + 1):
540
+ part = root.joinpath(*path.relative_to(root).parts[:depth])
541
+ try:
542
+ if stat.S_ISLNK(os.lstat(str(part)).st_mode):
543
+ raise LaneError(f'{part} is a symbolic link — left alone', 'source_link')
544
+ except FileNotFoundError:
545
+ return
546
+
547
+
548
+ def _snapshot(path, root=None):
549
+ """(identity, text or None) of a file: identity = (inode, size, mtime_ns, mode), None when it is missing. With
550
+ `root`, no component below it may be a symbolic link (no_links)."""
551
+ if root is not None:
552
+ no_links(root, path)
553
+ try:
554
+ info = os.lstat(str(path))
555
+ except FileNotFoundError:
556
+ return None, None
557
+ data = read_regular(path, EDIT_MAX)
558
+ if data is None:
559
+ raise LaneError(f'{path}: not a regular file of at most {EDIT_MAX >> 20} MiB this account can read — left alone',
560
+ 'source_unreadable')
561
+ try:
562
+ return (info.st_ino, info.st_size, info.st_mtime_ns, stat.S_IMODE(info.st_mode)), data.decode('utf-8')
563
+ except UnicodeDecodeError:
564
+ raise LaneError(f'{path}: not UTF-8 text — left alone', 'source_unreadable')
565
+
566
+
567
+ def backup_name(path, now=None):
568
+ """<path>.bak-<UTC %Y%m%dT%H%M%SZ>, with -2, -3, … when that name is taken."""
569
+ path = Path(path)
570
+ stamp = time.strftime('%Y%m%dT%H%M%SZ', time.gmtime(now))
571
+ candidate, n = path.with_name(f'{path.name}.bak-{stamp}'), 2
572
+ while os.path.lexists(str(candidate)):
573
+ candidate, n = path.with_name(f'{path.name}.bak-{stamp}-{n}'), n + 1
574
+ return candidate
575
+
576
+
577
+ def edit_file(path, transform, new_mode=0o600, backup=False, root=None):
578
+ """Apply `transform(text or None) -> new text` to `path` and write the result atomically -> (before, after,
579
+ backup path or None); before == after means nothing was written. Re-read before the write: when the file
580
+ changed while the edit was computed (another writer — Claude Code rewrites ~/.claude.json), the edit is computed
581
+ once more from the new bytes; a second change refuses (LaneError) and writes nothing. The mode of an existing
582
+ file is kept; a new one gets `new_mode`. With `backup`, the bytes the edit was computed from are kept beside it
583
+ first (same mode). With `root`, a symbolic link at or above the file (below `root`) refuses, checked on the read
584
+ and again just before the write."""
585
+ path = Path(path)
586
+ for attempt in (1, 2):
587
+ ident, before = _snapshot(path, root)
588
+ after = transform(before)
589
+ if after == before:
590
+ return before, after, None
591
+ if _snapshot(path, root) != (ident, before):
592
+ if attempt == 1:
593
+ continue
594
+ raise LaneError(f'{path} changed twice while it was being edited — nothing written; run it again',
595
+ 'edit_conflict')
596
+ mode = ident[3] if ident else new_mode
597
+ saved = None
598
+ if backup and before is not None:
599
+ saved = backup_name(path)
600
+ fd = os.open(str(saved), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
601
+ with os.fdopen(fd, 'w', encoding='utf-8') as handle:
602
+ handle.write(before)
603
+ os.chmod(str(saved), mode)
604
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
605
+ if root is not None:
606
+ no_links(root, path) # a directory this call created is checked once more before anything lands in it
607
+ atomic_replace(path, after, mode)
608
+ return before, after, saved
609
+ raise AssertionError('unreachable')
610
+
611
+
612
+ # ------------------------------------------------------------------------------------------------ strict JSON spans
613
+
614
+ def strict_json(text, what):
615
+ """json.loads that refuses what an edit could not keep exact: comments and trailing commas (not JSON at all),
616
+ NaN/Infinity, and a key given twice in one object (an edit would change one of them)."""
617
+ def pairs(items):
618
+ seen = set()
619
+ for key, _value in items:
620
+ if key in seen:
621
+ raise LaneError(f'{what}: the key {key!r} appears twice in one object — fix it by hand first',
622
+ 'source_invalid')
623
+ seen.add(key)
624
+ return dict(items)
625
+
626
+ def constant(name):
627
+ raise LaneError(f'{what}: {name} is not JSON', 'source_invalid')
628
+ try:
629
+ return json.loads(text, object_pairs_hook=pairs, parse_constant=constant)
630
+ except ValueError as error:
631
+ raise LaneError(f'{what} is not strict JSON ({error}) — comments and trailing commas included; fix it by '
632
+ f'hand first', 'source_invalid')
633
+
634
+
635
+ class Node:
636
+ """A JSON value's span in its text: [start, end). Objects keep `members` [(key, key start, Node)], arrays
637
+ `items` [Node]."""
638
+ __slots__ = ('kind', 'start', 'end', 'members', 'items')
639
+
640
+ def __init__(self, kind, start, end=None):
641
+ self.kind, self.start, self.end, self.members, self.items = kind, start, end, [], []
642
+
643
+ def get(self, key):
644
+ for name, _key_start, value in self.members:
645
+ if name == key:
646
+ return value
647
+ return None
648
+
649
+ def entries(self):
650
+ """[(start, end)] of the members (key through value) or items, in order."""
651
+ if self.kind == 'object':
652
+ return [(key_start, value.end) for _name, key_start, value in self.members]
653
+ return [(item.start, item.end) for item in self.items]
654
+
655
+
656
+ _WS = ' \t\r\n'
657
+ _SCALAR = re.compile(r'-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|true|false|null')
658
+
659
+
660
+ def _skip(text, i):
661
+ while i < len(text) and text[i] in _WS:
662
+ i += 1
663
+ return i
664
+
665
+
666
+ def _string_end(text, i):
667
+ j = i + 1
668
+ while text[j] != '"':
669
+ j += 2 if text[j] == '\\' else 1
670
+ return j + 1
671
+
672
+
673
+ def _node(text, i):
674
+ c = text[i]
675
+ if c in '{[':
676
+ node = Node('object' if c == '{' else 'array', i)
677
+ close = '}' if c == '{' else ']'
678
+ i = _skip(text, i + 1)
679
+ if text[i] == close:
680
+ node.end = i + 1
681
+ return node
682
+ while True:
683
+ if node.kind == 'object':
684
+ key_end = _string_end(text, i)
685
+ key = json.loads(text[i:key_end])
686
+ value = _node(text, _skip(text, _skip(text, key_end) + 1))
687
+ node.members.append((key, i, value))
688
+ else:
689
+ value = _node(text, i)
690
+ node.items.append(value)
691
+ i = _skip(text, value.end)
692
+ if text[i] == ',':
693
+ i = _skip(text, i + 1)
694
+ continue
695
+ node.end = i + 1
696
+ return node
697
+ if c == '"':
698
+ return Node('string', i, _string_end(text, i))
699
+ match = _SCALAR.match(text, i)
700
+ return Node('scalar', i, match.end())
701
+
702
+
703
+ def scan(text):
704
+ """The span tree of a JSON text that strict_json accepted."""
705
+ return _node(text, _skip(text, 0))
706
+
707
+
708
+ def _line_indent(text, pos):
709
+ start = text.rfind('\n', 0, pos) + 1
710
+ end = start
711
+ while end < len(text) and text[end] in ' \t':
712
+ end += 1
713
+ return text[start:end]
714
+
715
+
716
+ def _file_unit(text, root):
717
+ """The indent step of a pretty-printed file (learned from the root's first member), None for a one-line file."""
718
+ if '\n' not in text.strip():
719
+ return None
720
+ entries = root.entries()
721
+ if entries:
722
+ sep = text[root.start + 1:entries[0][0]]
723
+ if '\n' in sep:
724
+ return sep.rsplit('\n', 1)[1] or ' '
725
+ return ' '
726
+
727
+
728
+ def _render(value, indent, unit):
729
+ if unit is None:
730
+ return json.dumps(value, ensure_ascii=False)
731
+ return json.dumps(value, ensure_ascii=False, indent=unit).replace('\n', '\n' + indent)
732
+
733
+
734
+ def _append(text, root, node, piece):
735
+ """`node` (an object or array span) with one more entry at its end, laid out like its existing entries.
736
+ `piece(indent, unit)` renders the entry ("key": value, or a value)."""
737
+ entries = node.entries()
738
+ close_indent = _line_indent(text, node.end - 1)
739
+ if not entries:
740
+ unit = _file_unit(text, root)
741
+ if unit is None:
742
+ return text[:node.start + 1] + piece('', None) + text[node.end - 1:]
743
+ indent = close_indent + unit
744
+ return text[:node.start + 1] + '\n' + indent + piece(indent, unit) + '\n' + close_indent + text[node.end - 1:]
745
+ last_start, last_end = entries[-1]
746
+ if len(entries) > 1:
747
+ sep = text[text.index(',', entries[-2][1]) + 1:last_start]
748
+ else:
749
+ sep = text[node.start + 1:last_start]
750
+ if '\n' in sep:
751
+ indent = sep.rsplit('\n', 1)[1]
752
+ unit = indent[len(close_indent):] if indent.startswith(close_indent) and len(indent) > len(close_indent) \
753
+ else (_file_unit(text, root) or ' ')
754
+ else:
755
+ indent, unit = '', None
756
+ return text[:last_end] + ',' + sep + piece(indent, unit) + text[last_end:]
757
+
758
+
759
+ def _remove(text, node, index):
760
+ """`node` without its entry `index` and the separator that belonged to it."""
761
+ entries = node.entries()
762
+ start, end = entries[index]
763
+ if len(entries) == 1:
764
+ return text[:node.start + 1] + text[node.end - 1:]
765
+ if index < len(entries) - 1:
766
+ return text[:start] + text[entries[index + 1][0]:]
767
+ return text[:entries[index - 1][1]] + text[end:]
768
+
769
+
770
+ def set_member(text, path, key, value):
771
+ """`text` with the member `key` of the object at `path` (keys from the root) set to `value`: its value span
772
+ replaced when the key exists, else one member appended. Every other byte stays."""
773
+ root = scan(text)
774
+ node = root
775
+ for step in path:
776
+ node = node.get(step) if node.kind == 'object' else None
777
+ if node is None or node.kind != 'object':
778
+ raise LaneError(f'no object at {".".join(path)}', 'source_invalid')
779
+ for name, key_start, current in node.members:
780
+ if name == key:
781
+ indent = _line_indent(text, key_start)
782
+ unit = _file_unit(text, root)
783
+ return text[:current.start] + _render(value, indent, unit) + text[current.end:]
784
+ rendered_key = json.dumps(key, ensure_ascii=False)
785
+ return _append(text, root, node, lambda indent, unit: f'{rendered_key}: {_render(value, indent, unit)}')
786
+
787
+
788
+ def remove_member(text, path, key):
789
+ root = scan(text)
790
+ node = root
791
+ for step in path:
792
+ node = node.get(step)
793
+ if node is None or node.kind != 'object':
794
+ raise LaneError(f'no object at {".".join(path)}', 'source_invalid')
795
+ for index, (name, _key_start, _value) in enumerate(node.members):
796
+ if name == key:
797
+ return _remove(text, node, index)
798
+ return text
799
+
800
+
801
+ # ------------------------------------------------------------------------------------------------ terminals.json
802
+
803
+ TERMINALS_WHAT = '.vscode/terminals.json'
804
+ DISPLAY_MAX = 128
805
+
806
+
807
+ def _terminal_names(doc):
808
+ terminals = doc.get('terminals') if isinstance(doc, dict) else None
809
+ if not isinstance(terminals, list):
810
+ raise LaneError(f'{TERMINALS_WHAT} has no "terminals" list', 'source_invalid')
811
+ return [t.get('name') if isinstance(t, dict) else None for t in terminals]
812
+
813
+
814
+ def _check_terminals(before, after, names):
815
+ """The editor's never-rules, checked on the parsed result whatever the edit was: the root `throttle` object and
816
+ every root key but `terminals`/`lanes` unchanged, the terminal names exactly `names` (no rename, no reorder) and
817
+ at least one terminal left."""
818
+ if _terminal_names(after) != names:
819
+ raise LaneError('refused: the edit would rename or reorder terminals', 'edit_refused')
820
+ if not names:
821
+ raise LaneError('refused: never the last terminal', 'last_terminal')
822
+ for key in set(before) | set(after):
823
+ if key not in ('terminals', 'lanes') and before.get(key) != after.get(key):
824
+ raise LaneError(f'refused: the edit would change the root {key!r} object', 'edit_refused')
825
+
826
+
827
+ def _lanes_terminals(doc):
828
+ block = doc.get('lanes')
829
+ entries = block.get('terminals') if isinstance(block, dict) else None
830
+ return entries if isinstance(entries, dict) else None
831
+
832
+
833
+ def terminals_set_display(text, name, display):
834
+ """terminals.json with `lanes.terminals[<name>].displayName` set to `display` (None removes it). The declared
835
+ `name` — what the lane id derives from — never changes (LNS-33: the host reads displayName leniently, the id
836
+ never moves)."""
837
+ doc = strict_json(text, TERMINALS_WHAT)
838
+ names = _terminal_names(doc)
839
+ if name not in names:
840
+ raise LaneError(f'{TERMINALS_WHAT} declares no terminal named {name!r}', 'not_declared_here')
841
+ entries = _lanes_terminals(doc)
842
+ if entries is None or not isinstance(entries.get(name), dict):
843
+ raise LaneError(f'{TERMINALS_WHAT} has no lanes.terminals entry for {name!r} — run `lanes cursor-sync '
844
+ f'--write` on the host first', 'no_lanes_block')
845
+ if display is not None:
846
+ if not isinstance(display, str) or not display.strip() or re.search(r'[\x00-\x1f\x7f]', display) \
847
+ or len(display) > DISPLAY_MAX:
848
+ raise LaneError(f'invalid display name: one line of text, at most {DISPLAY_MAX} characters', 'invalid_name')
849
+ after = set_member(text, ['lanes', 'terminals', name], 'displayName', display)
850
+ else:
851
+ after = remove_member(text, ['lanes', 'terminals', name], 'displayName')
852
+ parsed = strict_json(after, TERMINALS_WHAT)
853
+ _check_terminals(doc, parsed, names)
854
+ if _lanes_terminals(parsed)[name].get('displayName') != display:
855
+ raise LaneError('refused: the edit did not land where it should', 'edit_refused')
856
+ return after
857
+
858
+
859
+ def terminals_remove(text, name):
860
+ """terminals.json without the terminal `name`: its `terminals[]` item and its `lanes.terminals` entry. Never the
861
+ last terminal; the root `throttle` object (Throttle's annotations) is left exactly as it is."""
862
+ doc = strict_json(text, TERMINALS_WHAT)
863
+ names = _terminal_names(doc)
864
+ if names.count(name) != 1:
865
+ raise LaneError(f'{TERMINALS_WHAT} declares {names.count(name)} terminals named {name!r}; refusing',
866
+ 'not_declared_here' if not names.count(name) else 'source_invalid')
867
+ if len(names) == 1:
868
+ raise LaneError(f'{name!r} is the last terminal of {TERMINALS_WHAT}: never the last terminal', 'last_terminal')
869
+ root = scan(text)
870
+ after = _remove(text, root.get('terminals'), names.index(name))
871
+ if name in (_lanes_terminals(doc) or {}):
872
+ after = remove_member(after, ['lanes', 'terminals'], name)
873
+ parsed = strict_json(after, TERMINALS_WHAT)
874
+ _check_terminals(doc, parsed, [n for n in names if n != name])
875
+ if name in (_lanes_terminals(parsed) or {}):
876
+ raise LaneError('refused: the edit did not land where it should', 'edit_refused')
877
+ return after
878
+
879
+
880
+ def terminals_add(text, terminal, entry=None):
881
+ """terminals.json with `terminal` (a VS Code terminal object with a new `name`) appended to `terminals[]` and,
882
+ when given, `entry` as its `lanes.terminals[<name>]` (the block must exist). Nothing is reordered. A binding the
883
+ file already holds — in `lanes.terminals` or the root `throttle` block, under any name — is refused
884
+ (`duplicate_binding`): two declarations of one session would make two lanes of it."""
885
+ doc = strict_json(text, TERMINALS_WHAT)
886
+ names = _terminal_names(doc)
887
+ name = terminal.get('name') if isinstance(terminal, dict) else None
888
+ if not isinstance(name, str) or not name.strip() or re.search(r'[\x00-\x1f\x7f]', name):
889
+ raise LaneError('a terminal needs a one-line name', 'invalid_name')
890
+ if name in names:
891
+ raise LaneError(f'{TERMINALS_WHAT} already declares {name!r}', 'duplicate_name')
892
+ pair = _binding(entry)
893
+ if pair:
894
+ for block in (_lanes_terminals(doc) or {}, ((doc.get('throttle') or {}).get('terminals') or {})):
895
+ held = [other for other, value in (block.items() if isinstance(block, dict) else ()) if _binding(value) == pair]
896
+ if held:
897
+ raise LaneError(f'{TERMINALS_WHAT} already binds tmux {pair[0]}/{pair[1]} (as {held[0]!r})',
898
+ 'duplicate_binding')
899
+ root = scan(text)
900
+ after = _append(text, root, root.get('terminals'), lambda indent, unit: _render(terminal, indent, unit))
901
+ if entry is not None:
902
+ if _lanes_terminals(doc) is None:
903
+ raise LaneError(f'{TERMINALS_WHAT} has no lanes block — run `lanes cursor-sync --write` on the host first',
904
+ 'no_lanes_block')
905
+ after = set_member(after, ['lanes', 'terminals'], name, entry)
906
+ parsed = strict_json(after, TERMINALS_WHAT)
907
+ _check_terminals(doc, parsed, names + [name])
908
+ return after
909
+
910
+
911
+ # ------------------------------------------------------------------------------------------------ MCP entries
912
+
913
+ MCP_NAME = 'lanes'
914
+ MCP_COMMAND = LZ + '/bin/lane-mcp'
915
+ MCP_ENTRY = {'command': MCP_COMMAND}
916
+ MCP_AGENTS = ('claude', 'codex')
917
+ MCP_FILES = {'claude': '.claude.json', 'codex': '.codex/config.toml'}
918
+ TOML_HEADER = re.compile(r'''^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*(?:lanes|"lanes"|'lanes')[ \t]*\][ \t]*(?:#.*)?$''')
919
+ # What Codex must hand lane-mcp. Codex starts a stdio MCP server with a CLEARED environment: its defaults (HOME,
920
+ # LOGNAME, PATH, SHELL, USER, LANG, LC_ALL, TERM, TMPDIR, TZ) plus the names in the table's `env_vars` and the
921
+ # table's own `env` (codex-rs rmcp-client `create_env_for_mcp_server`; MEASURED codex-cli 0.160.1, 2026-10-06: a
922
+ # table without `env_vars` started its server with HOME, PATH and USER only, the same table with this list also with
923
+ # all four). whoami reads LANE_ID, TMUX and TMUX_PANE; LANE_WS completes the lane identity the canonical recipe
924
+ # stamps, so lane-mcp sees under Codex what it sees under Claude Code, which inherits its whole environment.
925
+ MCP_ENV_VARS = ('LANE_ID', 'LANE_WS', 'TMUX', 'TMUX_PANE')
926
+
927
+
928
+ def _ours(entry, command):
929
+ """An MCP entry that already starts this lane-mcp: our command, no arguments, stdio."""
930
+ return isinstance(entry, dict) and entry.get('command') == command and not entry.get('args') \
931
+ and entry.get('type', 'stdio') == 'stdio' and 'url' not in entry
932
+
933
+
934
+ def _codex_passed(entry):
935
+ """The variable names a parsed Codex table passes through (`env_vars`: plain names, or {name, source} tables of
936
+ a local source)."""
937
+ names = entry.get('env_vars') if isinstance(entry, dict) else None
938
+ if not isinstance(names, list):
939
+ return []
940
+ return [item if isinstance(item, str) else item.get('name') for item in names
941
+ if isinstance(item, str) or (isinstance(item, dict) and isinstance(item.get('name'), str)
942
+ and item.get('source', 'local') == 'local')]
943
+
944
+
945
+ def _codex_ours(entry, command):
946
+ """A Codex table that already starts this lane-mcp AND passes it every name in MCP_ENV_VARS (more are fine)."""
947
+ return _ours(entry, command) and set(MCP_ENV_VARS) <= set(_codex_passed(entry))
948
+
949
+
950
+ def _an_older_ours(entry):
951
+ """An entry an earlier install wrote: a lane-mcp at another path (it is replaced)."""
952
+ return isinstance(entry, dict) and isinstance(entry.get('command'), str) \
953
+ and os.path.basename(entry['command']) == 'lane-mcp' and not entry.get('args') and 'url' not in entry
954
+
955
+
956
+ def claude_mcp(text, command=MCP_COMMAND):
957
+ """~/.claude.json with `mcpServers.lanes = {"command": <command>}` -> (new text, why). The text is unchanged when
958
+ an equivalent entry exists; an entry of the same name that starts something else is never replaced (why says
959
+ so). The edit is the one member, inserted in the file's own layout; the result must parse back to the input plus
960
+ that member, else it is refused."""
961
+ entry = {'command': command}
962
+ if text is None or not text.strip():
963
+ return json.dumps({'mcpServers': {MCP_NAME: entry}}, indent=2) + '\n', None
964
+ doc = strict_json(text, '~/.claude.json')
965
+ if not isinstance(doc, dict):
966
+ return text, '~/.claude.json is not a JSON object — left alone'
967
+ servers = doc.get('mcpServers')
968
+ if servers is not None and not isinstance(servers, dict):
969
+ return text, 'mcpServers in ~/.claude.json is not an object — left alone'
970
+ current = (servers or {}).get(MCP_NAME)
971
+ if _ours(current, command):
972
+ return text, None
973
+ if current is not None and not _an_older_ours(current):
974
+ shown = current.get('command') if isinstance(current, dict) else current
975
+ return text, (f'mcpServers.{MCP_NAME} in ~/.claude.json is not the entry Lanes writes (command {shown!r}) — '
976
+ 'left alone (remove it to let Lanes add its own)')
977
+ if servers is None:
978
+ after = set_member(text, [], 'mcpServers', {MCP_NAME: entry})
979
+ else:
980
+ after = set_member(text, ['mcpServers'], MCP_NAME, entry)
981
+ expected = dict(doc)
982
+ expected['mcpServers'] = dict(servers or {}, **{MCP_NAME: entry})
983
+ if strict_json(after, '~/.claude.json') != expected:
984
+ return text, 'the entry could not be added without changing anything else — add it by hand'
985
+ return after, None
986
+
987
+
988
+ def _toml_loads(text):
989
+ try:
990
+ import tomllib
991
+ except ImportError: # Python < 3.11 on the host: the textual rules below still hold
992
+ return None
993
+ try:
994
+ return tomllib.loads(text)
995
+ except tomllib.TOMLDecodeError as error:
996
+ raise LaneError(f'~/.codex/config.toml is not valid TOML ({error}) — left alone')
997
+
998
+
999
+ TOML_COMMAND = re.compile(r'''^[ \t]*command[ \t]*=[ \t]*"([^"\\]*)"[ \t]*(?:#.*)?$''')
1000
+ TOML_ENV_VARS_KEY = re.compile(r'''^[ \t]*(?:env_vars|"env_vars"|'env_vars')[ \t]*=''')
1001
+ _TOML_NAME = r'[ \t]*"[A-Za-z_][A-Za-z0-9_]*"[ \t]*'
1002
+ TOML_ENV_VARS = re.compile(rf'''^[ \t]*env_vars[ \t]*=[ \t]*\[((?:{_TOML_NAME},)*(?:{_TOML_NAME})?)[ \t]*\][ \t]*(?:#.*)?$''')
1003
+
1004
+
1005
+ def _env_vars_line(names):
1006
+ return f'env_vars = {json.dumps(list(names))}'
1007
+
1008
+
1009
+ def codex_mcp(text, command=MCP_COMMAND):
1010
+ """~/.codex/config.toml with a `[mcp_servers.lanes]` table whose `command` is <command> and whose `env_vars`
1011
+ passes MCP_ENV_VARS -> (new text, why). A missing table is appended at the end (a new table header after
1012
+ everything else changes no other key); an earlier install's table (a lane-mcp at another path, or `codex mcp
1013
+ add`'s spelling: a command and no `env_vars`) gets its one `command` line replaced and/or the `env_vars` line
1014
+ inserted right after it, or the missing names appended to its one-line `env_vars` list; a `lanes` server that
1015
+ starts something else, one not written as its own `[mcp_servers.lanes]` table with one plain `command = "…"`
1016
+ line, or one whose `env_vars` is not a one-line list of plain names, is left alone. The decision is read from
1017
+ the text itself, so a host without tomllib (Python < 3.11) decides the same way for the forms it can read;
1018
+ with tomllib the input must parse and the result must parse back to the input plus that table."""
1019
+ line = f'command = {json.dumps(command)}'
1020
+ if text is None or not text.strip():
1021
+ return f'[mcp_servers.{MCP_NAME}]\n{line}\n{_env_vars_line(MCP_ENV_VARS)}\n', None
1022
+ try:
1023
+ parsed = _toml_loads(text)
1024
+ except LaneError as error:
1025
+ return text, str(error)
1026
+ rows = text.splitlines(True)
1027
+ headers = [i for i, row in enumerate(rows) if TOML_HEADER.match(row.rstrip('\r\n'))]
1028
+ names = list(MCP_ENV_VARS)
1029
+ if parsed is not None:
1030
+ servers = parsed.get('mcp_servers')
1031
+ current = servers.get(MCP_NAME) if isinstance(servers, dict) else None
1032
+ if current is not None and _codex_ours(current, command):
1033
+ return text, None
1034
+ elif re.search(r'(?m)^[ \t]*(mcp_servers[ \t]*[.=]|\[[ \t]*mcp_servers[ \t]*\])', text):
1035
+ return text, ('~/.codex/config.toml defines mcp_servers in a form this Python (no tomllib) cannot read '
1036
+ 'safely — add the table by hand')
1037
+ else:
1038
+ current = {} if headers else None
1039
+ if current is None and not headers:
1040
+ after = text + ('' if text.endswith('\n') else '\n') + \
1041
+ f'\n[mcp_servers.{MCP_NAME}]\n{line}\n{_env_vars_line(names)}\n'
1042
+ else:
1043
+ if len(headers) != 1:
1044
+ return text, (f'mcp_servers.{MCP_NAME} in ~/.codex/config.toml is not written as one '
1045
+ f'[mcp_servers.{MCP_NAME}] table — left alone; fix it by hand')
1046
+ start = headers[0] + 1
1047
+ end = next((i for i in range(start, len(rows)) if rows[i].lstrip().startswith('[')), len(rows))
1048
+ hits = [i for i in range(start, end) if re.match(r'[ \t]*command[ \t]*=', rows[i])]
1049
+ found = TOML_COMMAND.match(rows[hits[0]].rstrip('\r\n')) if len(hits) == 1 else None
1050
+ if not found:
1051
+ return text, (f'[mcp_servers.{MCP_NAME}] in ~/.codex/config.toml has no single plain command line — '
1052
+ 'left alone; fix it by hand')
1053
+ if os.path.basename(found.group(1)) != 'lane-mcp' or (parsed is not None and not _an_older_ours(current)):
1054
+ return text, (f'mcp_servers.{MCP_NAME} in ~/.codex/config.toml is not the entry Lanes writes (command '
1055
+ f'{found.group(1)!r}) — left alone (remove it to let Lanes add its own)')
1056
+ passes = [i for i in range(start, end) if TOML_ENV_VARS_KEY.match(rows[i])]
1057
+ listed = TOML_ENV_VARS.match(rows[passes[0]].rstrip('\r\n')) if len(passes) == 1 else None
1058
+ if passes and not listed:
1059
+ return text, (f'[mcp_servers.{MCP_NAME}] in ~/.codex/config.toml has an env_vars that is not one line '
1060
+ f'of plain names — left alone; make it pass {", ".join(MCP_ENV_VARS)} by hand')
1061
+ have = re.findall(r'"([^"]*)"', listed.group(1)) if listed else None
1062
+ if have is not None:
1063
+ names = have + [name for name in MCP_ENV_VARS if name not in have]
1064
+ if found.group(1) == command and have == names:
1065
+ return text, None
1066
+ at = hits[0]
1067
+ ending = rows[at][len(rows[at].rstrip('\r\n')):]
1068
+ rows[at] = line + ((ending or '\n') if have is None else ending)
1069
+ if have is None: # `codex mcp add`'s spelling, or an install from before the pass-through: one line more
1070
+ rows.insert(at + 1, _env_vars_line(names) + ending)
1071
+ elif have != names:
1072
+ kept = rows[passes[0]]
1073
+ rows[passes[0]] = _env_vars_line(names) + kept[len(kept.rstrip('\r\n')):]
1074
+ after = ''.join(rows)
1075
+ if parsed is not None:
1076
+ expected = dict(parsed)
1077
+ expected['mcp_servers'] = dict(parsed.get('mcp_servers') or {})
1078
+ expected['mcp_servers'][MCP_NAME] = dict(current or {}, command=command, env_vars=names)
1079
+ try:
1080
+ ok = _toml_loads(after) == expected
1081
+ except LaneError:
1082
+ ok = False
1083
+ if not ok:
1084
+ return text, 'the table could not be added without changing anything else — add it by hand'
1085
+ return after, None
1086
+
1087
+
1088
+ MCP_EDITORS = {'claude': claude_mcp, 'codex': codex_mcp}
1089
+
1090
+
1091
+ def mcp_plan(home, agents=MCP_AGENTS, command=MCP_COMMAND):
1092
+ """[{agent, path, before, after, why}] for each agent's config under `home` (pure: reads only). `after == before`
1093
+ with no `why` = already there; a `why` = left alone, with the reason."""
1094
+ plan = []
1095
+ for agent in agents:
1096
+ path = Path(home) / MCP_FILES[agent]
1097
+ try:
1098
+ _ident, before = _snapshot(path, home)
1099
+ after, why = MCP_EDITORS[agent](before, command)
1100
+ except LaneError as error:
1101
+ before = after = None
1102
+ why = str(error)
1103
+ plan.append({'agent': agent, 'path': path, 'before': before, 'after': after, 'why': why})
1104
+ return plan
1105
+
1106
+
1107
+ def mcp_diff(item):
1108
+ """The unified diff of one plan item ('' when there is nothing to change)."""
1109
+ if item['why'] or item['after'] == item['before']:
1110
+ return ''
1111
+ before, after = item['before'] or '', item['after'] or ''
1112
+ path = str(item['path'])
1113
+ return ''.join(difflib.unified_diff(before.splitlines(True), after.splitlines(True), f'{path} (now)',
1114
+ f'{path} (with the lanes MCP server)'))
1115
+
1116
+
1117
+ def mcp_apply(home, agents=MCP_AGENTS, command=MCP_COMMAND):
1118
+ """Write each agent's entry where it is missing -> [{agent, path, status, backup, why}], status one of `added`,
1119
+ `present`, `refused`. Each file is re-planned from its bytes at write time (edit_file: re-read before the
1120
+ write, one retry), kept as <file>.bak-<UTC> first when it existed, replaced atomically with its own mode (a new
1121
+ one 0600; a new ~/.codex is 0700). A symbolic link below `home` (the file, or ~/.codex) refuses that file.
1122
+ Never raises for one file: the other still gets its entry."""
1123
+ results = []
1124
+ for agent in agents:
1125
+ path = Path(home) / MCP_FILES[agent]
1126
+ refusal = []
1127
+
1128
+ def transform(before, agent=agent, refusal=refusal):
1129
+ after, why = MCP_EDITORS[agent](before, command)
1130
+ del refusal[:]
1131
+ if why:
1132
+ refusal.append(why)
1133
+ return after
1134
+ try:
1135
+ before, after, saved = edit_file(path, transform, new_mode=0o600, backup=True, root=home)
1136
+ except (LaneError, OSError) as error:
1137
+ results.append({'agent': agent, 'path': path, 'status': 'refused', 'backup': None,
1138
+ 'why': str(error)})
1139
+ continue
1140
+ status = 'refused' if refusal else ('present' if before == after else 'added')
1141
+ results.append({'agent': agent, 'path': path, 'status': status, 'backup': saved,
1142
+ 'why': refusal[0] if refusal else None})
1143
+ return results
1144
+
1145
+
1146
+ # ------------------------------------------------------------------------------------------------ publishing (W10 W4)
1147
+ # `lane publish <file>` and lane-mcp's `lanes_publish` put a file of this container on the Lanes docs page. Nothing here
1148
+ # copies the file anywhere: the request names it, and the host's lanes-docsd reads it through /proc/<container init>/root
1149
+ # on its own 5 s cycle (it trusts nothing in the request: the path is resolved inside the container again, the bytes
1150
+ # hashed again), stores it in the docs store and writes `<id>.receipt.json` back beside the request, which it removes.
1151
+ #
1152
+ # ~/.lz/state/publish/<lane id>/<id>.json the request (DM-LANES-PUBLISH-REQUEST): {v: 1, path, title, kind,
1153
+ # claimed_sha256, mime, bytes, for}, `path` the file's absolute path
1154
+ # in this container with every link resolved
1155
+ # ~/.lz/state/publish/<lane id>/<id>.receipt.json the host's answer (DM-LANES-PUBLISH-RECEIPT): {v: 1, id, request,
1156
+ # sha256, bytes, url, at, outcome: ok|refused, reason?}
1157
+
1158
+ PUBLISH_KINDS = ('md', 'html', 'svg', 'file') # docs_store.KINDS without `upload` (a person's upload is the host's)
1159
+ PUBLISH_MAX = 32 << 20 # docsd.PUBLISH_LIMIT: a larger file is refused here and on the host
1160
+ PUBLISH_PICKUP = 'lanes-docsd' # what takes a request (docsd.Pickup)
1161
+ PUBLISH_LATENCY_S = 5 # docsd.PICKUP_INTERVAL: one pick-up cycle
1162
+ PUBLISH_DIR = ('state', 'publish') # under ~/.lz
1163
+ PUBLISH_ID = re.compile(r'[a-z0-9]{10,20}') # docs_store.ID_RE
1164
+ RECEIPT_SUFFIX = '.receipt.json'
1165
+ TITLE_MAX = 200 # docs_store.MAX_TITLE
1166
+ LOGIN_MAX = 254 # members.MAX_LOGIN
1167
+ ID_TIME = '0123456789abcdefghijklmnopqrstuvwxyz' # docs_store.BASE36: epoch seconds, zero-padded to 6
1168
+ ID_RANDOM = 'abcdefghijklmnopqrstuvwxyz234567' # docs_store.BASE32: 6 random characters
1169
+ KIND_BY_SUFFIX = {'.md': 'md', '.markdown': 'md', '.html': 'html', '.htm': 'html', '.svg': 'svg'}
1170
+ MIME_BY_SUFFIX = {'.md': 'text/markdown', '.markdown': 'text/markdown', '.html': 'text/html', '.htm': 'text/html',
1171
+ '.svg': 'image/svg+xml', '.txt': 'text/plain', '.json': 'application/json', '.csv': 'text/csv',
1172
+ '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1173
+ '.gif': 'image/gif', '.webp': 'image/webp'} # = libexec/docs_store.MIME_BY_SUFFIX (DEF-1920)
1174
+ REFUSED_TEXT = frozenset(('Cc', 'Cf', 'Cs', 'Co', 'Cn', 'Zl', 'Zp')) # members.REFUSED_CATEGORIES
1175
+
1176
+
1177
+ def new_publish_id(now=None):
1178
+ """A request id in docs_store's grammar (`new_id`: base36 epoch seconds zero-padded to 6, then 6 random base32),
1179
+ pinned equal to it by test_lane_publish. The container cannot import the host's store."""
1180
+ import secrets
1181
+ seconds, digits = int(time.time() if now is None else now), ''
1182
+ while True:
1183
+ seconds, rest = divmod(seconds, 36)
1184
+ digits = ID_TIME[rest] + digits
1185
+ if not seconds:
1186
+ break
1187
+ return digits.rjust(6, '0') + ''.join(secrets.choice(ID_RANDOM) for _ in range(6))
1188
+
1189
+
1190
+ def publish_kind(path, kind=None):
1191
+ """`kind` when given (one of PUBLISH_KINDS), else from the file name: .md/.markdown md, .html/.htm html, .svg svg,
1192
+ anything else file. LaneError `invalid_kind`."""
1193
+ if kind is None or kind == '':
1194
+ return KIND_BY_SUFFIX.get(os.path.splitext(str(path))[1].lower(), 'file')
1195
+ if kind not in PUBLISH_KINDS:
1196
+ raise LaneError(f'kind {kind!r} is not one of {", ".join(PUBLISH_KINDS)}', 'invalid_kind')
1197
+ return kind
1198
+
1199
+
1200
+ def _one_line(value, what, limit, blanks, code):
1201
+ """`value` when it is one printable line of at most `limit` characters without outer blanks (members._text's rule;
1202
+ `blanks`: inner spaces allowed), else LaneError `code`. The host checks it again."""
1203
+ if (not isinstance(value, str) or not value or len(value) > limit or value != value.strip()
1204
+ or any(unicodedata.category(char) in REFUSED_TEXT for char in value)
1205
+ or (not blanks and any(char.isspace() for char in value))):
1206
+ raise LaneError(f'{what} is one line of at most {limit} printable characters'
1207
+ + ('' if blanks else ' without blanks'), code)
1208
+ return value
1209
+
1210
+
1211
+ def publish_dir(root, lane):
1212
+ return Path(root).joinpath(*PUBLISH_DIR, lane)
1213
+
1214
+
1215
+ def publish(root, home, path, lane, title=None, for_login=None, kind=None, now=None):
1216
+ """Write a publish request for the file at `path` (as this process reaches it) from `lane` -> {id, lane, path,
1217
+ kind, title, for, bytes, sha256, request, receipt}: `path` the file's container path, `request`/`receipt` the
1218
+ container paths of the request and of the receipt lanes-docsd writes. `root` is ~/.lz and `home` /home/coder as
1219
+ this process reaches them (themselves in a container, test directories otherwise).
1220
+
1221
+ The file must resolve (os.path.realpath) inside `home` to a regular file of at most PUBLISH_MAX bytes; it is read
1222
+ once, never through a link at its last component and never blocking on a FIFO, to hash it (`claimed_sha256`, which
1223
+ the host compares with its own). Refusals (LaneError): `not_in_lane`, `invalid_title`, `invalid_for`,
1224
+ `invalid_kind`, `outside_home`, `not_a_file`, `too_large`. The request is replaced atomically (0644); nothing else
1225
+ is written."""
1226
+ import hashlib
1227
+ import mimetypes
1228
+ if not isinstance(lane, str) or not LANE_RE.fullmatch(lane):
1229
+ raise LaneError('this agent runs in no lane to publish from', 'not_in_lane')
1230
+ title = None if title in (None, '') else _one_line(title, 'a title', TITLE_MAX, True, 'invalid_title')
1231
+ for_login = None if for_login in (None, '') else _one_line(for_login, 'a login', LOGIN_MAX, False, 'invalid_for')
1232
+ base = os.path.realpath(str(home))
1233
+ real = os.path.realpath(str(path))
1234
+ if not real.startswith(base.rstrip(os.sep) + os.sep):
1235
+ raise LaneError(f'{path} is not a file under {CONTAINER_HOME} (with every link resolved): only this '
1236
+ 'container\'s home can be published', 'outside_home')
1237
+ kind = publish_kind(real, kind)
1238
+ try:
1239
+ fd = os.open(real, os.O_RDONLY | os.O_NONBLOCK | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0))
1240
+ except OSError as error:
1241
+ raise LaneError(f'{path}: {error.strerror or "cannot be opened"}', 'not_a_file') from None
1242
+ digest, size = hashlib.sha256(), 0
1243
+ try:
1244
+ info = os.fstat(fd)
1245
+ if not stat.S_ISREG(info.st_mode):
1246
+ raise LaneError(f'{path} is not a regular file', 'not_a_file')
1247
+ if info.st_size > PUBLISH_MAX:
1248
+ raise LaneError(f'{path} is larger than {PUBLISH_MAX >> 20} MiB', 'too_large')
1249
+ while True:
1250
+ chunk = os.read(fd, 1 << 16)
1251
+ if not chunk:
1252
+ break
1253
+ size += len(chunk)
1254
+ if size > PUBLISH_MAX:
1255
+ raise LaneError(f'{path} is larger than {PUBLISH_MAX >> 20} MiB', 'too_large')
1256
+ digest.update(chunk)
1257
+ finally:
1258
+ os.close(fd)
1259
+ container = CONTAINER_HOME + '/' + os.path.relpath(real, base).replace(os.sep, '/')
1260
+ name = os.path.basename(real)
1261
+ # The same table as libexec/docs_store.MIME_BY_SUFFIX: Python's built-in map gained `.md` only in 3.12, so the
1262
+ # request's `mime` must not depend on the interpreter (the host compares nothing on it, but receipts carry it).
1263
+ mime, encoding = MIME_BY_SUFFIX.get(os.path.splitext(name)[1].lower()), None
1264
+ if not mime:
1265
+ mime, encoding = mimetypes.MimeTypes().guess_type(name) # the built-in table, as the host's
1266
+ request = {'v': 1, 'path': container, 'title': title, 'kind': kind, 'claimed_sha256': digest.hexdigest(),
1267
+ 'mime': mime if mime and not encoding else 'application/octet-stream', 'bytes': size, 'for': for_login}
1268
+ directory = publish_dir(root, lane)
1269
+ directory.mkdir(parents=True, exist_ok=True)
1270
+ for _ in range(16):
1271
+ request_id = new_publish_id(now)
1272
+ if not any(os.path.lexists(str(directory / (request_id + suffix))) for suffix in ('.json', RECEIPT_SUFFIX)):
1273
+ break
1274
+ else:
1275
+ raise LaneError('could not mint a free request id', 'refused')
1276
+ atomic_replace(directory / f'{request_id}.json', json.dumps(request, ensure_ascii=False, indent=1) + '\n', 0o644)
1277
+ where = LZ + '/' + '/'.join(PUBLISH_DIR) + f'/{lane}/{request_id}'
1278
+ return {'id': request_id, 'lane': lane, 'path': container, 'kind': kind, 'title': title, 'for': for_login,
1279
+ 'bytes': size, 'sha256': request['claimed_sha256'], 'request': where + '.json',
1280
+ 'receipt': where + RECEIPT_SUFFIX}
1281
+
1282
+
1283
+ def latest_receipt(root, lane):
1284
+ """The newest receipt lanes-docsd wrote back for `lane` (~/.lz/state/publish/<lane>/<id>.receipt.json; the latest
1285
+ `at` — whole seconds —, then the file written last, then the id) as lanes_status shows it -> {id, url, at,
1286
+ outcome, request, reason?}, or None (no receipt yet, or none that reads as one). Each file is read as a regular
1287
+ file only (read_regular: no link, no FIFO)."""
1288
+ if not isinstance(lane, str) or not LANE_RE.fullmatch(lane):
1289
+ return None
1290
+ directory = publish_dir(root, lane)
1291
+ try:
1292
+ names = os.listdir(str(directory))
1293
+ except OSError:
1294
+ return None
1295
+ best = None
1296
+ for name in names:
1297
+ request_id = name[:-len(RECEIPT_SUFFIX)] if name.endswith(RECEIPT_SUFFIX) else ''
1298
+ if not PUBLISH_ID.fullmatch(request_id):
1299
+ continue
1300
+ receipt = read_json(directory / name)
1301
+ if not (isinstance(receipt, dict) and receipt.get('v') == 1 and isinstance(receipt.get('at'), str)
1302
+ and receipt.get('outcome') in ('ok', 'refused')):
1303
+ continue
1304
+ try:
1305
+ written = os.stat(str(directory / name), follow_symlinks=False).st_mtime_ns
1306
+ except OSError:
1307
+ continue
1308
+ key = (receipt['at'], written, request_id)
1309
+ if best is None or key > best[0]:
1310
+ best = (key, request_id, receipt)
1311
+ if best is None:
1312
+ return None
1313
+ _key, request_id, receipt = best
1314
+ out = {'id': receipt.get('id'), 'url': receipt.get('url'), 'at': receipt['at'], 'outcome': receipt['outcome'],
1315
+ 'request': request_id}
1316
+ if receipt['outcome'] == 'refused':
1317
+ out['reason'] = receipt.get('reason')
1318
+ return out