@vibelord/lanes 1.0.2 → 1.0.3

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 (33) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/VERSION +1 -1
  3. package/payload/lanes/bin/lanes +21 -8
  4. package/payload/lanes/container/env.sh +1 -1
  5. package/payload/lanes/container/lane +5 -5
  6. package/payload/lanes/container/lane-signal +4 -4
  7. package/payload/lanes/docs/ARCHITECTURE.md +14 -7
  8. package/payload/lanes/docs/DESIGN.md +3 -3
  9. package/payload/lanes/docs/INSTALL.md +37 -2
  10. package/payload/lanes/docs/RUNBOOK.md +52 -23
  11. package/payload/lanes/docs/SHARING.md +239 -4
  12. package/payload/lanes/holders.lock +10 -0
  13. package/payload/lanes/install/enable-studio-host.sh +14 -0
  14. package/payload/lanes/install/host-install.sh +5 -2
  15. package/payload/lanes/install/services.py +101 -4
  16. package/payload/lanes/install.sh +1 -1
  17. package/payload/lanes/libexec/agent_state.py +1 -1
  18. package/payload/lanes/libexec/cursor_sync.py +2 -2
  19. package/payload/lanes/libexec/economy_poll.py +3 -3
  20. package/payload/lanes/libexec/lanes_lib.py +1 -1
  21. package/payload/lanes/libexec/notify.py +3 -3
  22. package/payload/lanes/libexec/registrations.py +1 -1
  23. package/payload/lanes/libexec/session_actions.py +1 -1
  24. package/payload/lanes/libexec/sidebar.py +7 -7
  25. package/payload/lanes/libexec/statusd.py +25 -10
  26. package/payload/lanes/libexec/studio_host.py +1608 -0
  27. package/payload/lanes/systemd/lanes-economy.service +1 -1
  28. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  29. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  30. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  31. package/payload/lanes/web/README.md +2 -2
  32. package/payload/lanes/zellij/config.kdl +1 -1
  33. package/payload/manifest.json +4 -3
@@ -0,0 +1,1608 @@
1
+ #!/usr/bin/env python3
2
+ """The Throttle Studio Host co-located on this lanes server (docs/SHARING.md "Co-located Studio Host";
3
+ docs/design/throttle-studio.md §8.10 C3). Python 3 stdlib only.
4
+
5
+ lanes studio-host status [--json] [--check]
6
+ what is installed, enrolled and running; exit 0 only when the Host runs enrolled (and, with --check, its
7
+ own `studio host --check` exits 0)
8
+ lanes studio-host re-key [--yes] [--dry-run]
9
+ after a revoke: stop the Host, remove the machine key, its dead enrolment and every record of it, and publish
10
+ a NEW public key for the next deploy (or a registration key) to enrol. Nothing changes without --yes.
11
+ lanes studio-host install [--from PATH [--sha256 HEX]]
12
+ select the throttle-rc binary the unit runs: the holders.lock opt-in pin (download, both digests checked),
13
+ or a locally built file (hashed, copied, re-hashed). Never starts or restarts anything.
14
+ sh install/enable-studio-host.sh [options] (→ `enable`; --help lists the options)
15
+ the opt-in step: config in lanes-server mode, the machine's PUBLIC key for the deploy, enrolment from a
16
+ deploy receipt or a registration key, the units — or the unit installed WAITING for enrolment.
17
+ lanes-studio-host-enrol.service runs `enable --retry`: acts only on something new, never loops on a refusal.
18
+
19
+ Invariants (design §8.10 C5, each pinned by tests/unit/test_studio_host.py):
20
+ * the machine key is created ON THIS MACHINE by `throttle-rc studio publish-key`; this module never reads it;
21
+ * no bearer secret is created or stored: the receipt is inert, the registration key is read by throttle-rc
22
+ from its 0600 file and never by this module — not opened, not hashed: its stat identity and access(2) only
23
+ (review round 5);
24
+ * one Host per machine: refused while `throttle-rc studio service`'s own unit is enabled or active, and the
25
+ hand-built `lanes-sharing-host.service` is only replaced with --migrate-hand-built, after its text is saved;
26
+ * throttle-rc children get an allowlisted environment, nothing else from the caller.
27
+ """
28
+ import base64
29
+ import errno
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import tarfile
38
+ import tempfile
39
+ import time
40
+ from pathlib import Path
41
+
42
+ UNIT = 'lanes-studio-host.service'
43
+ ENROL_SERVICE = 'lanes-studio-host-enrol.service'
44
+ ENROL_TIMER = 'lanes-studio-host-enrol.timer'
45
+ SUPERVISOR_UNIT = 'throttle-rc-studio-host.service' # `throttle-rc studio service install`'s own unit (design §8.9)
46
+ HAND_BUILT_UNIT = 'lanes-sharing-host.service' # ceobox's hand-built sharing Host (docs/handoffs/2026-09-22)
47
+ BINARY = 'throttle-rc'
48
+ PIN_RECORD = 'throttle-rc.pin.json'
49
+ LOCK_OPT_IN = 'opt-in'
50
+ CONFIG_HEADER = '# Written by lanes/install/enable-studio-host.sh'
51
+ HEX64 = re.compile(r'^[0-9a-f]{64}$')
52
+ VERSION_WORD = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
53
+ MACHINE_ID = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
54
+ # Only these reach a throttle-rc child (C5): directories and locale, never a token or a key from the caller.
55
+ ENV_ALLOWLIST = ('HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LC_ALL', 'TMPDIR', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME',
56
+ 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'LANES_STATE', 'LANES_SHARE', 'LANES_WORKSPACES')
57
+ SYSTEMCTL_TIMEOUT = 30
58
+ RETRY_KEY_AFTER = 3600
59
+ RECEIPT_REFUSAL_MEMORY = ('receipt_refused_sha', 'receipt_refused_config_sha', 'receipt_refused_at',
60
+ 'receipt_refused_code')
61
+ # Review round 6: what `studio enroll` answered for the key the timer remembers trying (exit and code), so `status`
62
+ # can say the key was refused instead of pointing at the deploy for the hour the timer skips it.
63
+ KEY_REFUSAL_MEMORY = ('key_failed_exit', 'key_failed_code')
64
+ # The two provenance marks throttle-rc writes into a refused state.json's detail (apps/throttle-rc/src/studio_host/
65
+ # mod.rs PLATFORM_REFUSAL_MARK / SELF_REVOKE_MARK; pinned by a test that reads that file). A revocation is MEASURED
66
+ # only with one of them, for this config's server — the rule `measured_revocation` applies.
67
+ PLATFORM_REFUSAL_MARK = '[platform refused this dial]'
68
+ SELF_REVOKE_MARK = '[measured by `throttle-rc studio revoke` on this machine]'
69
+ # Review round 3: a non-default --receipt / --registration-key-file is REMEMBERED in the enrolment record, so the
70
+ # timer (which runs `--retry` with no options) and `status` watch the path the operator named.
71
+ REMEMBERED_PATHS = {'receipt': 'receipt_path', 'key_file': 'registration_key_path'}
72
+ # The timer's memory of a tried registration key (review round 5): its stat identity, never a digest of its bytes.
73
+ # `key_tried_sha` is the round-1..4 spelling (a SHA-256 of the secret); it is dropped wherever the record is saved.
74
+ LEGACY_KEY_DIGEST = 'key_tried_sha'
75
+ # The lines whose loss changes what the unit DOES (design §8.9 reasons). `status` reports an installed unit missing
76
+ # any of them; the golden test has a sabotage arm per line.
77
+ UNIT_CONTRACT = ('SuccessExitStatus=3', 'KillSignal=SIGINT', 'KillMode=process', 'Restart=on-failure',
78
+ 'Slice=lanes.slice', 'After=lanes-statusd.service', 'Wants=lanes-statusd.service',
79
+ ' studio host --config "')
80
+
81
+
82
+ class Refusal(Exception):
83
+ """A typed refusal: `refused <code>: <sentence>` on stderr, exit `code_exit` (3 unless said otherwise)."""
84
+
85
+ def __init__(self, code, sentence, exit_code=3):
86
+ super().__init__(sentence)
87
+ self.code, self.sentence, self.exit_code = code, sentence, exit_code
88
+
89
+
90
+ class EnrolmentRefused(Refusal):
91
+ """`studio adopt-receipt` or `studio enroll` did not enrol this machine (typed or not): the units are still
92
+ installed waiting, so the enrol timer can retry (review round 3)."""
93
+
94
+
95
+ # ------------------------------------------------------------------------------------------------ paths
96
+
97
+ class Layout:
98
+ """Every path this module touches, from one environment. Mirrors throttle-rc's RuntimePaths (dirs crate:
99
+ $XDG_CONFIG_HOME|~/.config, $XDG_DATA_HOME|~/.local/share) and the lanes installer's LANES_SHARE/LANES_STATE."""
100
+
101
+ def __init__(self, home, share, state, config_home=None, data_home=None):
102
+ self.home = Path(home)
103
+ self.share = Path(share)
104
+ self.state = Path(state)
105
+ self.config_home = Path(config_home) if config_home else self.home / '.config'
106
+ self.data_home = Path(data_home) if data_home else self.home / '.local/share'
107
+
108
+ @classmethod
109
+ def from_env(cls, env=None):
110
+ env = os.environ if env is None else env
111
+ home = Path(env.get('HOME') or Path.home())
112
+ return cls(home, env.get('LANES_SHARE') or home / '.local/share/lanes',
113
+ env.get('LANES_STATE') or home / '.local/state/lanes',
114
+ env.get('XDG_CONFIG_HOME') or None, env.get('XDG_DATA_HOME') or None)
115
+
116
+ config = property(lambda self: self.config_home / 'throttle-rc/studio-host.toml')
117
+ registration_key = property(lambda self: self.config_home / 'throttle-rc/registration.key')
118
+ credential = property(lambda self: self.data_home / 'throttle-rc/studio-host/credential.json')
119
+ machine_key = property(lambda self: self.data_home / 'throttle-rc/machine/key.json') # existence only
120
+ host_state = property(lambda self: self.data_home / 'throttle-rc/studio-host/state.json') # the Host's own
121
+ binary = property(lambda self: self.share / 'bin' / BINARY)
122
+ pin_record = property(lambda self: self.share / 'bin' / PIN_RECORD)
123
+ studio_state = property(lambda self: self.state / 'studio-host')
124
+ public_jwk = property(lambda self: self.studio_state / 'public.jwk')
125
+ receipt = property(lambda self: self.studio_state / 'receipt.json')
126
+ record = property(lambda self: self.studio_state / 'enrolment.json')
127
+ migrated = property(lambda self: self.studio_state / 'migrated')
128
+ units = property(lambda self: self.home / '.config/systemd/user')
129
+
130
+
131
+ def sha256_file(path):
132
+ digest = hashlib.sha256()
133
+ with open(path, 'rb') as handle:
134
+ for block in iter(lambda: handle.read(1 << 20), b''):
135
+ digest.update(block)
136
+ return digest.hexdigest()
137
+
138
+
139
+ def readable_sha(path, code, remedy):
140
+ """sha256 of a file the installer acts on, or None when it is absent. A file that exists but this account
141
+ cannot read is a typed refusal naming its owner and mode (review round 2: an unreadable receipt crashed every
142
+ timer run with a PermissionError traceback, untyped exit 1, while `status` promised the timer would install it)."""
143
+ if not path.is_file():
144
+ return None
145
+ try:
146
+ return sha256_file(path)
147
+ except OSError as error:
148
+ raise Refusal(code, f'{path} exists but this account cannot read it ({unreadable_detail(path, error)}); '
149
+ + remedy)
150
+
151
+
152
+ def unreadable_detail(path, error):
153
+ try:
154
+ stat = os.stat(path)
155
+ owner = f'owner uid {stat.st_uid}, mode {stat.st_mode & 0o7777:04o}'
156
+ except OSError:
157
+ owner = 'owner and mode unknown'
158
+ return f'{error.strerror or error}; {owner}; this account is uid {os.getuid()}'
159
+
160
+
161
+ def key_fingerprint(path, remedy):
162
+ """The registration key's identity for the timer's hour-long memory, WITHOUT reading it (design §8.10 C5;
163
+ review round 5 — this used to hash the secret's bytes into the enrolment record). None when absent. A file this
164
+ account may not read is the typed `key_unreadable` refusal, decided by access(2): the file is never opened."""
165
+ if not path.is_file():
166
+ return None
167
+ if not os.access(path, os.R_OK):
168
+ denied = PermissionError(errno.EACCES, os.strerror(errno.EACCES))
169
+ raise Refusal('key_unreadable', f'{path} exists but this account cannot read it '
170
+ f'({unreadable_detail(path, denied)}); ' + remedy)
171
+ st = os.stat(path)
172
+ # st_ctime_ns too (review round 6): a chmod/chown that FIXES a refused key changes no byte and no mtime, and the
173
+ # timer skipped the fixed file for the rest of the hour.
174
+ return f'{st.st_dev}:{st.st_ino}:{st.st_size}:{st.st_mtime_ns}:{st.st_ctime_ns}'
175
+
176
+
177
+ def read_config_text(layout):
178
+ """studio-host.toml's text, or the typed `config_unreadable` refusal (review round 5: an unreadable config
179
+ ended enable, the timer and `status` in a PermissionError traceback before the typed arm could be reached)."""
180
+ try:
181
+ return layout.config.read_text()
182
+ except OSError as error:
183
+ raise Refusal('config_unreadable', f'{layout.config} exists but this account cannot read it '
184
+ f'({unreadable_detail(layout.config, error)}); it must be owned by this account (0600)')
185
+
186
+
187
+ def is_readable(path):
188
+ """True when `path` opens for reading (a stat is not a readability check)."""
189
+ try:
190
+ with open(path, 'rb'):
191
+ return True
192
+ except OSError:
193
+ return False
194
+
195
+
196
+ def b64url(data):
197
+ return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
198
+
199
+
200
+ def jwk_thumbprint(jwk):
201
+ """RFC 7638 thumbprint (base64url SHA-256) of the Ed25519 public JWK `throttle-rc studio publish-key` writes;
202
+ None for anything else. The same computation as the binary's `machine_jkt` (pinned against the real binary)."""
203
+ if not (isinstance(jwk, dict) and jwk.get('kty') == 'OKP' and jwk.get('crv') == 'Ed25519'
204
+ and isinstance(jwk.get('x'), str) and jwk['x']):
205
+ return None
206
+ canonical = json.dumps({'crv': 'Ed25519', 'kty': 'OKP', 'x': jwk['x']}, separators=(',', ':'), sort_keys=True)
207
+ return b64url(hashlib.sha256(canonical.encode()).digest())
208
+
209
+
210
+ def load_credential(layout):
211
+ """credential.json as a dict ({} when present but unparsable), or None when absent. It is inert: no secret."""
212
+ if not layout.credential.exists():
213
+ return None
214
+ document = read_json(layout.credential)
215
+ return document if isinstance(document, dict) else {}
216
+
217
+
218
+ def key_binding(layout, credential):
219
+ """Whether `credential` binds the machine key this installer last PUBLISHED: True/False, or None when that is
220
+ not measurable here (no recorded thumbprint — a version-1 receipt —, no key, no published key, or a published
221
+ key OLDER than the key file, i.e. not this key's). The machine key itself is never read (C5): the published
222
+ public key is the evidence. Review round 6: `enrolled` used to mean "a credential and SOME key", so after the
223
+ timer minted a new key beside a dead receipt, `status` called it `enrolled`/`stopped`."""
224
+ issued = (credential or {}).get('machine_jkt')
225
+ if not issued or not layout.machine_key.exists() or not layout.public_jwk.is_file():
226
+ return None
227
+ try:
228
+ if layout.public_jwk.stat().st_mtime_ns < layout.machine_key.stat().st_mtime_ns:
229
+ return None
230
+ except OSError:
231
+ return None
232
+ published = jwk_thumbprint(read_json(layout.public_jwk))
233
+ return None if published is None else published == issued
234
+
235
+
236
+ def measured_revoked(layout, server):
237
+ """Whether THIS machine MEASURED its Host row at `server` as revoked: a `refused` state.json for that server
238
+ whose detail carries a provenance mark and the word REVOKED (throttle-rc's own `measured_revocation`)."""
239
+ state = read_json(layout.host_state)
240
+ if not (isinstance(state, dict) and state.get('phase') == 'refused' and server):
241
+ return False
242
+ if str(state.get('server') or '').rstrip('/') != str(server).rstrip('/'):
243
+ return False
244
+ detail = str(state.get('detail') or '')
245
+ return (PLATFORM_REFUSAL_MARK in detail or SELF_REVOKE_MARK in detail) and 'REVOKED' in detail
246
+
247
+
248
+ def atomic_write(path, data, mode):
249
+ """Write next to `path` and rename over it; never through an existing symlink."""
250
+ path = Path(path)
251
+ path.parent.mkdir(parents=True, exist_ok=True)
252
+ fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '.', dir=path.parent)
253
+ try:
254
+ with os.fdopen(fd, 'wb') as handle:
255
+ handle.write(data if isinstance(data, bytes) else data.encode())
256
+ os.chmod(temporary, mode)
257
+ os.replace(temporary, path)
258
+ finally:
259
+ if os.path.exists(temporary):
260
+ os.unlink(temporary)
261
+
262
+
263
+ def read_json(path):
264
+ try:
265
+ return json.loads(Path(path).read_text())
266
+ except (OSError, ValueError):
267
+ return None
268
+
269
+
270
+ def now_utc():
271
+ return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
272
+
273
+
274
+ # -------------------------------------------------------------------------------------------- the pin
275
+
276
+ class Pin:
277
+ def __init__(self, name, version, sha256, url, opt_in=False, member=None, binary_sha256=None):
278
+ self.name, self.version, self.sha256, self.url = name, version, sha256, url
279
+ self.opt_in, self.member, self.binary_sha256 = opt_in, member, binary_sha256
280
+
281
+
282
+ def parse_lock(text):
283
+ """holders.lock -> {name: Pin}. `name version sha256 url` for an always-installed holder;
284
+ `... opt-in member member-sha256` for a pin only its opt-in step installs. Malformed lines are refused by
285
+ line number: a lock is an authority, never best-effort."""
286
+ pins = {}
287
+ for number, line in enumerate(text.splitlines(), 1):
288
+ if not line.strip() or line.lstrip().startswith('#'):
289
+ continue
290
+ words = line.split()
291
+ if len(words) == 4:
292
+ name, version, sha, url = words
293
+ pin = Pin(name, version, sha, url)
294
+ elif len(words) == 7 and words[4] == LOCK_OPT_IN:
295
+ name, version, sha, url, _flag, member, binary_sha = words
296
+ if not HEX64.match(binary_sha):
297
+ raise Refusal('lock_invalid', f'holders.lock line {number}: the member digest is not 64 lowercase hex')
298
+ parts = member.split('/')
299
+ if member.startswith('/') or any(part in ('', '.', '..') for part in parts):
300
+ raise Refusal('lock_invalid', f'holders.lock line {number}: member {member!r} is not a plain relative path')
301
+ pin = Pin(name, version, sha, url, True, member, binary_sha)
302
+ else:
303
+ raise Refusal('lock_invalid', f'holders.lock line {number}: expected 4 fields, or 7 with "{LOCK_OPT_IN}"')
304
+ if not HEX64.match(sha):
305
+ raise Refusal('lock_invalid', f'holders.lock line {number}: the tarball digest is not 64 lowercase hex')
306
+ if not VERSION_WORD.match(version):
307
+ raise Refusal('lock_invalid', f'holders.lock line {number}: version {version!r} is not a plain word')
308
+ if name in pins:
309
+ raise Refusal('lock_invalid', f'holders.lock line {number}: {name} is pinned twice')
310
+ pins[name] = pin
311
+ return pins
312
+
313
+
314
+ def lock_path():
315
+ return Path(__file__).resolve().parent.parent / 'holders.lock'
316
+
317
+
318
+ def _select(layout, filename, record):
319
+ """Point bin/throttle-rc at `filename` (in the same directory) and record what it is. Record first: a crash
320
+ between the two leaves a record that `verify_installed` refuses, never a link with no record."""
321
+ atomic_write(layout.pin_record, json.dumps(record, indent=1, sort_keys=True) + '\n', 0o644)
322
+ link = layout.binary
323
+ temporary = link.with_name(f'.{BINARY}.link-{os.getpid()}')
324
+ if temporary.is_symlink() or temporary.exists():
325
+ temporary.unlink()
326
+ temporary.symlink_to(filename)
327
+ os.replace(temporary, link)
328
+
329
+
330
+ def _place(layout, source_bytes_path, filename, expected_sha):
331
+ """Copy to bin/<filename> (0755, atomic) and re-hash the COPY: the digest that counts is the one the unit runs."""
332
+ target = layout.share / 'bin' / filename
333
+ target.parent.mkdir(parents=True, exist_ok=True)
334
+ fd, temporary = tempfile.mkstemp(prefix=f'.{filename}.', dir=target.parent)
335
+ try:
336
+ with os.fdopen(fd, 'wb') as out, open(source_bytes_path, 'rb') as src:
337
+ shutil.copyfileobj(src, out)
338
+ os.chmod(temporary, 0o755)
339
+ actual = sha256_file(temporary)
340
+ if actual != expected_sha:
341
+ raise Refusal('binary_mismatch', f'the copy at {target} hashed {actual}, not {expected_sha}; nothing selected')
342
+ os.replace(temporary, target)
343
+ finally:
344
+ if os.path.exists(temporary):
345
+ os.unlink(temporary)
346
+ return target
347
+
348
+
349
+ def install_local(layout, source, expected_sha=None):
350
+ """`install --from PATH [--sha256 HEX]`: a locally built binary, for a lanes server before a node/<sha12>
351
+ release carries the co-location CLI."""
352
+ source = Path(source)
353
+ if source.is_symlink() or not source.is_file():
354
+ raise Refusal('usage', f'{source} is not a regular file (a symbolic link is refused: name the file itself)', 2)
355
+ if not os.access(source, os.X_OK):
356
+ raise Refusal('usage', f'{source} is not executable', 2)
357
+ if expected_sha is not None and not HEX64.match(expected_sha):
358
+ raise Refusal('usage', '--sha256 must be 64 lowercase hex', 2)
359
+ actual = sha256_file(source)
360
+ if expected_sha is not None and actual != expected_sha:
361
+ raise Refusal('binary_mismatch', f'{source} hashed {actual}, not the --sha256 given; nothing installed')
362
+ version = f'local-{actual[:12]}'
363
+ _place(layout, source, f'{BINARY}-{version}', actual)
364
+ record = {'source': 'local', 'version': version, 'sha256': actual, 'from': str(source.resolve()),
365
+ 'installed_at': now_utc()}
366
+ _select(layout, f'{BINARY}-{version}', record)
367
+ return record
368
+
369
+
370
+ def fetch(url, dest, timeout=300):
371
+ """Download one release asset. A private repository's asset needs `gh` (its own login); anything else is curl."""
372
+ found = re.match(r'^https://github\.com/([^/]+/[^/]+)/releases/download/(.+)/([^/]+)$', url)
373
+ if found and shutil.which('gh'):
374
+ repo, tag, asset = found.groups()
375
+ argv = ['gh', 'release', 'download', tag, '--repo', repo, '--pattern', asset, '--dir', str(Path(dest).parent),
376
+ '--clobber']
377
+ produced = Path(dest).parent / asset
378
+ else:
379
+ argv = ['curl', '-fsSL', '--retry', '3', '-o', str(dest), url]
380
+ produced = Path(dest)
381
+ try:
382
+ result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout,
383
+ env=child_env({k: os.environ[k] for k in ('GH_TOKEN', 'GITHUB_TOKEN') if os.environ.get(k)}))
384
+ except (OSError, subprocess.TimeoutExpired) as error:
385
+ raise Refusal('download_failed', f'{argv[0]}: {error}', 1)
386
+ if result.returncode != 0:
387
+ raise Refusal('download_failed', f'{argv[0]} exit {result.returncode}: {result.stderr.strip()[:300]}', 1)
388
+ if produced != Path(dest):
389
+ os.replace(produced, dest)
390
+
391
+
392
+ def install_pinned(layout, pin, fetcher=fetch):
393
+ """`install` with no --from: the holders.lock opt-in pin. The tarball digest AND the member digest must match;
394
+ only the named member is read (no extractall, no links)."""
395
+ downloads = layout.share / 'dl'
396
+ downloads.mkdir(parents=True, exist_ok=True)
397
+ archive = downloads / f'{pin.name}-{pin.version}.tar.gz'
398
+ fetcher(pin.url, archive)
399
+ actual = sha256_file(archive)
400
+ if actual != pin.sha256:
401
+ raise Refusal('binary_mismatch', f'{archive.name} hashed {actual}, not the pinned {pin.sha256}; nothing installed')
402
+ with tempfile.TemporaryDirectory(dir=downloads) as scratch:
403
+ extracted = Path(scratch) / BINARY
404
+ try:
405
+ with tarfile.open(archive, 'r:gz') as tar:
406
+ try:
407
+ member = tar.getmember(pin.member)
408
+ except KeyError:
409
+ raise Refusal('binary_mismatch', f'{archive.name} holds no {pin.member}')
410
+ if not member.isfile():
411
+ raise Refusal('binary_mismatch', f'{pin.member} in {archive.name} is not a regular file')
412
+ with tar.extractfile(member) as src, open(extracted, 'wb') as out:
413
+ shutil.copyfileobj(src, out)
414
+ except (tarfile.TarError, OSError) as error:
415
+ raise Refusal('binary_mismatch', f'{archive.name} is not a readable tar.gz: {error}')
416
+ member_sha = sha256_file(extracted)
417
+ if member_sha != pin.binary_sha256:
418
+ raise Refusal('binary_mismatch', f'{pin.member} hashed {member_sha}, not the pinned {pin.binary_sha256}')
419
+ _place(layout, extracted, f'{BINARY}-{pin.version}', member_sha)
420
+ record = {'source': 'lock', 'version': pin.version, 'sha256': member_sha, 'from': pin.url,
421
+ 'installed_at': now_utc()}
422
+ _select(layout, f'{BINARY}-{pin.version}', record)
423
+ return record
424
+
425
+
426
+ def verify_installed(layout):
427
+ """-> (word, detail, record). word: verified | absent | mismatch. Hashes the file the link names NOW."""
428
+ record = read_json(layout.pin_record)
429
+ if not layout.binary.is_symlink() and not layout.binary.exists():
430
+ return 'absent', f'no {layout.binary}', record
431
+ if not isinstance(record, dict) or not HEX64.match(str(record.get('sha256', ''))):
432
+ return 'mismatch', f'{layout.pin_record} is missing or unreadable: nothing says what {layout.binary} should be', record
433
+ try:
434
+ target = layout.binary.resolve(strict=True)
435
+ actual = sha256_file(target)
436
+ except OSError as error:
437
+ return 'absent', f'{layout.binary}: {error}', record
438
+ if actual != record['sha256']:
439
+ return 'mismatch', f'{target} hashed {actual}, the install recorded {record["sha256"]}', record
440
+ return 'verified', f"{target.name} ({record.get('source')}, sha256 {actual[:16]}…)", record
441
+
442
+
443
+ # ------------------------------------------------------------------------------------------- config
444
+
445
+ def validate_server(server):
446
+ """The Host's own rule (studio_host::config::validate_server): scheme + host[:port], nothing after."""
447
+ if not server or not re.match(r'^https?://[^/\s?#]+$', server) or any(ord(c) < 32 for c in server):
448
+ raise Refusal('usage', f'--server {server!r} must be http(s)://host[:port] with nothing after it', 2)
449
+ return server
450
+
451
+
452
+ def default_machine_id():
453
+ base = re.sub(r'[^A-Za-z0-9._-]', '-', os.uname().nodename.split('.')[0])[:57] or 'lanes'
454
+ return f'{base}-lanes'
455
+
456
+
457
+ def toml_string(value):
458
+ value = str(value)
459
+ if any(ord(c) < 32 or ord(c) == 127 for c in value):
460
+ raise Refusal('usage', 'configuration values must not contain control characters', 2)
461
+ return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
462
+
463
+
464
+ def render_config(server, machine_id, state_dir, container_uid=1000):
465
+ validate_server(server)
466
+ if not MACHINE_ID.match(machine_id):
467
+ raise Refusal('usage', f'--machine-id {machine_id!r} is outside 1-64 chars of [A-Za-z0-9._-]', 2)
468
+ return (f'{CONFIG_HEADER} (lanes-server mode, docs/design/throttle-studio.md §8.10 C2).\n'
469
+ '# The installer never rewrites this file; edit it freely, then `systemctl --user restart '
470
+ f'{UNIT}`.\n'
471
+ f'server = {toml_string(server)}\n'
472
+ f'machine_id = {toml_string(machine_id)}\n'
473
+ '\n[inventory]\n'
474
+ 'mode = "lanes-server"\n'
475
+ 'interactive = true\n'
476
+ f'sharing_state_dir = {toml_string(state_dir)}\n'
477
+ f'sharing_container_uid = {int(container_uid)}\n'
478
+ 'legacy_coder_launch = false\n')
479
+
480
+
481
+ _ASSIGN = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*$')
482
+
483
+
484
+ def _scalar(raw):
485
+ raw = raw.split(' #', 1)[0].strip()
486
+ if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
487
+ return raw[1:-1].replace('\\"', '"').replace('\\\\', '\\')
488
+ if raw in ('true', 'false'):
489
+ return raw == 'true'
490
+ return raw
491
+
492
+
493
+ def config_facts(text):
494
+ """The keys this installer reasons about — top-level server/machine_id, [inventory] mode/interactive/
495
+ sharing_state_dir — read line by line (Python 3.9 has no tomllib). Not a validator: `studio host --check` is."""
496
+ facts, table = {'inventory': {}}, ''
497
+ for line in text.splitlines():
498
+ stripped = line.strip()
499
+ if not stripped or stripped.startswith('#'):
500
+ continue
501
+ if stripped.startswith('['):
502
+ table = stripped.strip('[]').strip()
503
+ continue
504
+ found = _ASSIGN.match(line)
505
+ if not found:
506
+ continue
507
+ key, value = found.group(1), _scalar(found.group(2))
508
+ if table == '' and key in ('server', 'machine_id'):
509
+ facts[key] = value
510
+ elif table == 'inventory' and key in ('mode', 'interactive', 'sharing_state_dir', 'sharing_container_uid'):
511
+ facts['inventory'][key] = value
512
+ facts['mode'] = facts['inventory'].get('mode', 'in-container')
513
+ return facts
514
+
515
+
516
+ def set_lanes_server_mode(text, state_dir):
517
+ """Return `text` with [inventory] mode = "lanes-server" and a sharing_state_dir (the mode requires one).
518
+ Every other line is kept byte for byte."""
519
+ lines = text.splitlines()
520
+ start = next((i for i, line in enumerate(lines) if line.strip() == '[inventory]'), None)
521
+ if start is None:
522
+ tail = ['', '[inventory]', 'mode = "lanes-server"', f'sharing_state_dir = {toml_string(state_dir)}']
523
+ return '\n'.join(lines + tail) + '\n'
524
+ end = next((i for i in range(start + 1, len(lines)) if lines[i].strip().startswith('[')), len(lines))
525
+ section = lines[start + 1:end]
526
+ keys = {(_ASSIGN.match(line) or [None, None])[1]: i for i, line in enumerate(section)
527
+ if not line.strip().startswith('#')}
528
+ if 'mode' in keys:
529
+ section[keys['mode']] = 'mode = "lanes-server"'
530
+ else:
531
+ section.insert(0, 'mode = "lanes-server"')
532
+ if 'sharing_state_dir' not in keys:
533
+ section.insert(1, f'sharing_state_dir = {toml_string(state_dir)}')
534
+ return '\n'.join(lines[:start + 1] + section + lines[end:]) + '\n'
535
+
536
+
537
+ # ----------------------------------------------------------------------------------- external calls
538
+
539
+ RUNTIME_DIR_ROOT = Path('/run/user')
540
+
541
+
542
+ def child_env(extra=None):
543
+ """The allowlisted environment. Review round 2: like bin/lanes' clean_env() and the install scripts, a caller
544
+ without XDG_RUNTIME_DIR (a `sudo -u <account> -i` or cron shell) gets /run/user/<uid> when it exists, or every
545
+ `systemctl --user` answer is `no-answer` and `status` says `unknown` for a running Host."""
546
+ env = {key: os.environ[key] for key in ENV_ALLOWLIST if os.environ.get(key)}
547
+ runtime = RUNTIME_DIR_ROOT / str(os.getuid())
548
+ if 'XDG_RUNTIME_DIR' not in env and runtime.is_dir():
549
+ env['XDG_RUNTIME_DIR'] = str(runtime)
550
+ env.update(extra or {})
551
+ return env
552
+
553
+
554
+ def run(argv, timeout, extra_env=None):
555
+ """-> (rc, stdout, stderr); rc None when it did not finish in `timeout` seconds, 127 when it could not spawn."""
556
+ try:
557
+ result = subprocess.run(argv, capture_output=True, text=True, errors='replace', timeout=timeout,
558
+ env=child_env(extra_env), stdin=subprocess.DEVNULL)
559
+ return result.returncode, result.stdout, result.stderr
560
+ except subprocess.TimeoutExpired as error:
561
+ return None, error.stdout or '', error.stderr or ''
562
+ except OSError as error:
563
+ return 127, '', str(error)
564
+
565
+
566
+ def systemctl(*args, timeout=SYSTEMCTL_TIMEOUT):
567
+ return run(['systemctl', '--user', *args], timeout)
568
+
569
+
570
+ def unit_word(verb, unit):
571
+ """`is-active`/`is-enabled` -> its first word, or 'no-answer' (timeout / no manager). Never invented."""
572
+ rc, out, err = systemctl(verb, unit, timeout=10)
573
+ word = (out.strip().splitlines() or [''])[0].strip()
574
+ if rc is None or rc == 127:
575
+ return 'no-answer'
576
+ if not word and 'No such file' in (err or ''):
577
+ return 'not-found' # older managers answer a missing unit on stderr only
578
+ return word or 'no-answer'
579
+
580
+
581
+ def manager_units_dir(layout):
582
+ """Where the user MANAGER reads unit files (systemd.unit(5)): $XDG_CONFIG_HOME/systemd/user when the manager's
583
+ own environment sets it, else ~/.config/systemd/user. Asked of the manager (`show-environment`), never of this
584
+ shell (review round 2: a manager with XDG_CONFIG_HOME set never loads ~/.config/systemd/user, so the enable
585
+ failed `Unit not found` while status said the file was present). No answer keeps the default."""
586
+ rc, out, _err = systemctl('show-environment', timeout=10)
587
+ if rc == 0:
588
+ for line in (out or '').splitlines():
589
+ if line.startswith('XDG_CONFIG_HOME='):
590
+ value = line.split('=', 1)[1]
591
+ if value.startswith('/'):
592
+ return Path(value) / 'systemd/user'
593
+ return layout.units
594
+
595
+
596
+ def layout_roots(layout):
597
+ """The two XDG roots every throttle-rc child — and both studio units (install/services.py) — resolve with:
598
+ this layout's, ALWAYS set. Review round 3: inherited from whoever ran the step, a user manager whose environment
599
+ carries XDG_DATA_HOME the installer's shell does not made the supervised Host look for credential.json where the
600
+ installer never wrote it, while the installer's own `--check` passed."""
601
+ return {'XDG_CONFIG_HOME': str(layout.config_home), 'XDG_DATA_HOME': str(layout.data_home)}
602
+
603
+
604
+ def throttle_rc(layout, *args, timeout=60):
605
+ return run([str(layout.binary), *args], timeout, extra_env=layout_roots(layout))
606
+
607
+
608
+ def hand_built_text(layout):
609
+ """The hand-built unit's text as the manager has it (`systemctl --user cat`), or None when there is none."""
610
+ rc, out, _err = systemctl('cat', HAND_BUILT_UNIT, timeout=10)
611
+ if rc == 0 and out.strip():
612
+ return out
613
+ path = manager_units_dir(layout) / HAND_BUILT_UNIT
614
+ return path.read_text() if path.is_file() else None
615
+
616
+
617
+ def expand_unit_path(value, home):
618
+ """A unit's path argument as the user manager resolves it: `%h` (and `%%`), `${HOME}`/`$HOME` and `~` are the
619
+ account's home (review round 2: an idiomatic `--config %h/...` unit was refused as naming another file)."""
620
+ home = str(home)
621
+ value = value.replace('%%', '\0').replace('%h', home).replace('\0', '%')
622
+ value = re.sub(r'\$\{HOME\}|\$HOME(?![A-Za-z0-9_])', lambda _m: home, value)
623
+ if value == '~' or value.startswith('~/'):
624
+ value = home + value[1:]
625
+ return Path(value)
626
+
627
+
628
+ def hand_built_config(text):
629
+ """-> (runs_studio_host, config path or None) from the unit's ExecStart lines."""
630
+ for line in text.splitlines():
631
+ if not line.strip().startswith('ExecStart='):
632
+ continue
633
+ command = line.split('=', 1)[1]
634
+ if ' studio host' not in command:
635
+ continue
636
+ found = re.search(r'--config[ =]"?([^"\s]+)"?', command)
637
+ return True, (found.group(1) if found else None)
638
+ return False, None
639
+
640
+
641
+ # ------------------------------------------------------------------------------------------- enable
642
+
643
+ USAGE = """usage: sh install/enable-studio-host.sh [options]
644
+ --server URL the platform the Host dials (required only when no config exists yet)
645
+ --machine-id ID the config's machine_id (default: <hostname>-lanes)
646
+ --container-uid N the workspace containers' tmux uid (default 1000)
647
+ --receipt PATH a deploy receipt to adopt (default: $LANES_STATE/studio-host/receipt.json);
648
+ remembered, so the enrol timer and `status` watch it (the default path forgets it)
649
+ --registration-key-file PATH enrol with the owner's registration key (default: ~/.config/throttle-rc/registration.key);
650
+ remembered like --receipt
651
+ --set-mode an existing config is not in lanes-server mode: rewrite that one key (keeps a .bak)
652
+ --migrate-hand-built replace the hand-built lanes-sharing-host.service (its text is saved first)
653
+ --from PATH [--sha256 HEX] install this locally built throttle-rc first (else the holders.lock pin)
654
+ --settle SECONDS how long a started Host must stay running (default 5)
655
+ --dry-run print the plan; write, enable and start nothing
656
+ --retry what lanes-studio-host-enrol.timer runs: act only on something new
657
+ Exit: 0 running or waiting for enrolment; 2 usage; 3 a typed refusal; 1 an operational failure. A refused or
658
+ failed receipt/key still leaves the units enabled and waiting (the exit says what was refused)."""
659
+
660
+
661
+ def parse_enable_args(argv):
662
+ options = {'server': None, 'machine_id': None, 'container_uid': 1000, 'receipt': None, 'key_file': None,
663
+ 'set_mode': False, 'migrate': False, 'from': None, 'sha256': None, 'settle': 5.0,
664
+ 'dry_run': False, 'retry': False}
665
+ flags = {'--set-mode': 'set_mode', '--migrate-hand-built': 'migrate', '--dry-run': 'dry_run', '--retry': 'retry'}
666
+ values = {'--server': 'server', '--machine-id': 'machine_id', '--container-uid': 'container_uid',
667
+ '--receipt': 'receipt', '--registration-key-file': 'key_file', '--from': 'from', '--sha256': 'sha256',
668
+ '--settle': 'settle'}
669
+ items = list(argv)
670
+ while items:
671
+ item = items.pop(0)
672
+ if item in ('-h', '--help'):
673
+ print(USAGE)
674
+ raise SystemExit(0)
675
+ if item in flags:
676
+ options[flags[item]] = True
677
+ elif item in values:
678
+ if not items:
679
+ raise Refusal('usage', f'{item} needs a value\n{USAGE}', 2)
680
+ options[values[item]] = items.pop(0)
681
+ else:
682
+ raise Refusal('usage', f'unknown option {item!r}\n{USAGE}', 2)
683
+ try:
684
+ options['container_uid'] = int(options['container_uid'])
685
+ options['settle'] = float(options['settle'])
686
+ except ValueError:
687
+ raise Refusal('usage', '--container-uid and --settle take numbers', 2)
688
+ if options['sha256'] and not options['from']:
689
+ raise Refusal('usage', '--sha256 goes with --from', 2)
690
+ return options
691
+
692
+
693
+ class Plan:
694
+ """Prints each step; in a dry run, says what it WOULD do and does nothing."""
695
+
696
+ def __init__(self, dry_run, quiet=False):
697
+ self.dry_run, self.quiet = dry_run, quiet
698
+
699
+ def say(self, text):
700
+ if not self.quiet:
701
+ print(text)
702
+
703
+ def step(self, text, action=None):
704
+ if self.dry_run:
705
+ print(f'would: {text}')
706
+ return None
707
+ self.say(f'{text}')
708
+ return action() if action else None
709
+
710
+
711
+ def _record(layout):
712
+ return read_json(layout.record) or {}
713
+
714
+
715
+ def receipt_path(layout, record):
716
+ """The receipt this machine watches: the remembered --receipt, else the default."""
717
+ return Path(record['receipt_path']) if record.get('receipt_path') else layout.receipt
718
+
719
+
720
+ def key_path(layout, record):
721
+ """The registration key file this machine watches: the remembered --registration-key-file, else the default."""
722
+ return Path(record['registration_key_path']) if record.get('registration_key_path') else layout.registration_key
723
+
724
+
725
+ def _remember_paths(layout, options, record):
726
+ """A manual run's --receipt/--registration-key-file is remembered (an explicit default forgets it)."""
727
+ defaults = {'receipt': layout.receipt, 'key_file': layout.registration_key}
728
+ changed = False
729
+ for option, key in REMEMBERED_PATHS.items():
730
+ if not options[option]:
731
+ continue
732
+ chosen = Path(options[option]).absolute()
733
+ value = None if chosen == defaults[option] else str(chosen)
734
+ if record.get(key) != value:
735
+ if value is None:
736
+ record.pop(key, None)
737
+ else:
738
+ record[key] = value
739
+ changed = True
740
+ if changed:
741
+ _save_record(layout, record)
742
+
743
+
744
+ def _save_record(layout, record):
745
+ record.pop(LEGACY_KEY_DIGEST, None)
746
+ layout.studio_state.mkdir(parents=True, exist_ok=True)
747
+ os.chmod(layout.studio_state, 0o700)
748
+ atomic_write(layout.record, json.dumps(record, indent=1, sort_keys=True) + '\n', 0o600)
749
+
750
+
751
+ def _check_systemctl(rc, out, err, what):
752
+ if rc != 0:
753
+ raise Refusal('systemd_failed', f'systemctl --user {what}: exit {rc}: {(err or out).strip()[:300]}', 1)
754
+
755
+
756
+ def _bounded_detail(text, limit=600):
757
+ """A child's output for a refusal line: its HEAD and its tail when it is long (review round 6: a tail-only cut
758
+ dropped the head of the binary's one refusal line, `--registration-key-file …` reading `n-key-file …`)."""
759
+ text = (text or '').strip()
760
+ if len(text) <= limit:
761
+ return text or '(no output)'
762
+ half = limit // 2
763
+ return f'{text[:half]} … {text[-half:]}'
764
+
765
+
766
+ def _throttle_rc_failed(verb, rc, out, err, kind=None, typed_exits=(3,)):
767
+ """The installer's refusal for a failed throttle-rc verb. Exit 3 (typed) when the child's exit is one of
768
+ `typed_exits` — `studio enroll` types a refused key SOURCE as its usage exit 2 (`registration_key_source_refused`,
769
+ review round 6), which is a refusal, not "an operational failure" — else 1."""
770
+ kind = kind or Refusal
771
+ detail = _bounded_detail(err or out)
772
+ if rc is None:
773
+ return kind('throttle_rc_failed', f'throttle-rc studio {verb} did not finish in time', 1)
774
+ return kind('throttle_rc_failed', f'throttle-rc studio {verb} exit {rc}: {detail}', 3 if rc in typed_exits else 1)
775
+
776
+
777
+ def _publish(layout):
778
+ """`throttle-rc studio publish-key` into $LANES_STATE/studio-host (0700). Idempotent for an existing machine key
779
+ (it is loaded, never rewritten); a key is created here only when there is none."""
780
+ layout.studio_state.mkdir(parents=True, exist_ok=True)
781
+ os.chmod(layout.studio_state, 0o700)
782
+ rc, out, err = throttle_rc(layout, 'studio', 'publish-key', '--out', str(layout.public_jwk), timeout=30)
783
+ if rc != 0:
784
+ raise _throttle_rc_failed('publish-key', rc, out, err)
785
+
786
+
787
+ def _ensure_binary(layout, options, plan):
788
+ """The binary the unit runs is installed AND its file still hashes to what was recorded."""
789
+ planned = False
790
+ if options['from']:
791
+ plan.step(f"install {options['from']} as the Host binary (sha256-verified)",
792
+ lambda: install_local(layout, options['from'], options['sha256']))
793
+ planned = True
794
+ word, detail, _pin = verify_installed(layout)
795
+ if word == 'absent' and not options['from'] and not options['retry']:
796
+ pins = parse_lock(lock_path().read_text())
797
+ if BINARY not in pins:
798
+ raise Refusal('no_binary', f'{detail}, and holders.lock pins no {BINARY} release yet. Build it '
799
+ '(cargo build --release --locked -p throttle-rc) and pass --from PATH, or run '
800
+ '`lanes studio-host install --from PATH` first.')
801
+ plan.step(f'install the holders.lock pin {BINARY} {pins[BINARY].version} (both digests checked)',
802
+ lambda: install_pinned(layout, pins[BINARY]))
803
+ planned = True
804
+ word, detail, _pin = verify_installed(layout)
805
+ if word != 'verified' and not (options['dry_run'] and planned):
806
+ raise Refusal('binary_mismatch' if word == 'mismatch' else 'no_binary', detail)
807
+ plan.say(f'binary: {detail}' if word == 'verified' else 'binary: (installed by this run)')
808
+
809
+
810
+ def _one_host_per_machine(layout, options, record):
811
+ """One Host per machine. Returns the hand-built unit's text when this run migrates it, else None."""
812
+ for verb in ('is-active', 'is-enabled'):
813
+ state = unit_word(verb, SUPERVISOR_UNIT)
814
+ if state in ('active', 'activating', 'reloading', 'enabled', 'enabled-runtime', 'linked'):
815
+ raise Refusal('another_host_unit', f'{SUPERVISOR_UNIT} is {state}: one Studio Host per machine. Remove it '
816
+ 'first (`throttle-rc studio service uninstall --yes`), then re-run this.')
817
+ hand_built = hand_built_text(layout)
818
+ if hand_built is None:
819
+ return None
820
+ states = (unit_word('is-active', HAND_BUILT_UNIT), unit_word('is-enabled', HAND_BUILT_UNIT))
821
+ stopped = states[0] in ('inactive', 'failed') and states[1] != 'enabled'
822
+ if record.get('hand_built_migrated_at') and stopped:
823
+ return None # migrated by an earlier run; its file is left for the operator to delete
824
+ if options['retry'] and stopped:
825
+ # Review round 1: the timer never refuses on a stopped, disabled hand-built unit (its migration record may
826
+ # have been lost with $LANES_STATE/studio-host); a manual run still asks for --migrate-hand-built.
827
+ return None
828
+ if not options['migrate']:
829
+ raise Refusal('hand_built_unit_present', f'{HAND_BUILT_UNIT} exists ({states[0]}, {states[1]}). Read it '
830
+ f'(`systemctl --user cat {HAND_BUILT_UNIT}`), then re-run with --migrate-hand-built: its text '
831
+ f'is saved under {layout.migrated}, it is disabled and stopped, and the machine key and '
832
+ 'enrolment are kept.')
833
+ runs_host, config_path = hand_built_config(hand_built)
834
+ if not runs_host:
835
+ raise Refusal('hand_built_unit_unrecognised', f'{HAND_BUILT_UNIT} does not run `studio host`; it is not '
836
+ 'the shape this migration knows. Nothing was changed.')
837
+ if config_path and expand_unit_path(config_path, layout.home) != layout.config:
838
+ raise Refusal('hand_built_unit_unrecognised', f'{HAND_BUILT_UNIT} runs the Host with --config {config_path};'
839
+ f' the lanes unit reads {layout.config}. Move that file there first. Nothing was changed.')
840
+ return hand_built
841
+
842
+
843
+ def _ensure_config(layout, options, plan):
844
+ """Step 3: a config in lanes-server mode. Never rewrites an existing file beyond the one `mode` key. Returns the
845
+ .bak it kept when it patched an existing file (the migration restores it if the replacement does not check)."""
846
+ if not layout.config.exists():
847
+ if not options['server']:
848
+ raise Refusal('server_required', f'there is no {layout.config} yet: name the platform with --server URL')
849
+ text = render_config(options['server'], options['machine_id'] or default_machine_id(), str(layout.state),
850
+ options['container_uid'])
851
+ plan.step(f'write {layout.config} in lanes-server mode (0600)', lambda: atomic_write(layout.config, text, 0o600))
852
+ return None
853
+ facts = config_facts(read_config_text(layout))
854
+ if facts['mode'] == 'lanes-server':
855
+ plan.say(f'config: {layout.config} (lanes-server)')
856
+ return None
857
+ if not (options['set_mode'] or options['migrate']):
858
+ raise Refusal('config_mode', f'{layout.config} is in {facts["mode"]} mode. Re-run with --set-mode to rewrite '
859
+ 'that one key to lanes-server (a .bak is kept), or edit it yourself.')
860
+
861
+ kept = []
862
+
863
+ def patch():
864
+ text = read_config_text(layout)
865
+ backup = layout.config.with_name(f'{layout.config.name}.bak-{time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())}')
866
+ shutil.copy2(layout.config, backup)
867
+ atomic_write(layout.config, set_lanes_server_mode(text, str(layout.state)), 0o600)
868
+ kept.append(backup)
869
+ print(f'kept the previous file as {backup}')
870
+ plan.step(f'set [inventory] mode = "lanes-server" in {layout.config} (keeping a .bak)', patch)
871
+ return kept[0] if kept else None
872
+
873
+
874
+ def _migrate_hand_built(layout, plan, hand_built, record, replaced):
875
+ """Step 4: save the text, then disable --now. The key is never touched.
876
+
877
+ Review round 2: the hand-built Host is stopped only after the replacement's own `studio host --check` passes on
878
+ the migrated config. An enrolled machine whose check refuses (a version-1 credential's unchecked key binding, a
879
+ refused state) keeps its hand-built Host running and enabled, the config is restored from the .bak this run
880
+ kept, and the refusal names both. (`--check` is read-only and safe beside a live Host.) An unenrolled machine
881
+ has no serving Host to protect: its check would only say "not enrolled", so it is not run here. The config is
882
+ given back by `enable` (review round 5: on ANY failure before `replaced` is set, not only this one)."""
883
+ def migrate():
884
+ if layout.credential.exists():
885
+ rc, out, err = throttle_rc(layout, 'studio', 'host', '--check', '--config', str(layout.config),
886
+ timeout=60)
887
+ if rc != 0:
888
+ raise Refusal('host_check_failed', f'`throttle-rc studio host --check` exit {rc} on the migrated '
889
+ f'config, so {HAND_BUILT_UNIT} was LEFT running and enabled and nothing was replaced. '
890
+ 'Fix what the check names, then re-run with --migrate-hand-built:\n'
891
+ + (out + err).strip()[-1500:], 3 if rc == 3 else 1)
892
+ layout.migrated.mkdir(parents=True, exist_ok=True)
893
+ saved = layout.migrated / f'{HAND_BUILT_UNIT}.{time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())}'
894
+ atomic_write(saved, hand_built, 0o600)
895
+ _check_systemctl(*systemctl('disable', '--now', HAND_BUILT_UNIT), f'disable --now {HAND_BUILT_UNIT}')
896
+ replaced.append(saved)
897
+ record['hand_built_migrated_at'] = now_utc()
898
+ record['hand_built_saved'] = str(saved)
899
+ _save_record(layout, record)
900
+ print(f'saved {saved}; {HAND_BUILT_UNIT} disabled and stopped (its unit file is left for you to delete)')
901
+ plan.step(f'check the migrated config (`studio host --check`), save {HAND_BUILT_UNIT} under {layout.migrated}, '
902
+ 'then disable --now it', migrate)
903
+
904
+
905
+ RECEIPT_UNREADABLE_REMEDY = ('a deploy run as another account writes it 0600 as that account (DEF-1429): give it to '
906
+ 'this account (chown, or chmod 0644 — the receipt is inert), or have the deploy write '
907
+ 'it where this account can read it and pass --receipt PATH')
908
+ KEY_UNREADABLE_REMEDY = 'give it to this account (owned by it, mode 0600); it is read by throttle-rc, never by this script'
909
+ REFUSAL_CODE = re.compile(r'refused ([a-z0-9_]+)')
910
+ # `studio enroll`'s line names its code at the END, `<sentence> (<code>)` (enroll.rs EnrollRefusal::text_line).
911
+ TRAILING_CODE = re.compile(r'\(([a-z][a-z0-9_]*_[a-z0-9_]+)\)\s*$', re.MULTILINE)
912
+
913
+
914
+ def _refusal_code(text):
915
+ found = REFUSAL_CODE.search(text or '') or TRAILING_CODE.search(text or '')
916
+ return found.group(1) if found else 'unknown'
917
+
918
+
919
+ def _configured_server(layout, options):
920
+ facts = config_facts(read_config_text(layout)) if layout.config.exists() else {}
921
+ return facts.get('server') or options['server'], facts
922
+
923
+
924
+ def _enrol(layout, options, plan, record, receipt, key_file):
925
+ """Step 5. Returns whether this machine is (or, in a dry run, would be) enrolled.
926
+
927
+ Review round 1: an existing credential short-circuits ONLY while it binds the machine key beside it and no NEW
928
+ deploy receipt has arrived. Review round 6: a credential whose key is gone (or is not the key on disk) is DEAD
929
+ only when this machine has MEASURED its host row revoked (state.json, the binary's own rule) — then a new key is
930
+ published and the next enrolment says `--new-key`. Without that measurement it is a live, un-revoked host row
931
+ whose key went missing (a lost or restored key file, a partial hand remedy): enrolling a second machine beside
932
+ it is the operator's decision, so this refuses `credential_for_gone_key` and names the two steps (revoke, then
933
+ `lanes studio-host re-key --yes`). It used to publish a new key and say `--new-key` by itself."""
934
+ receipt_sha = readable_sha(receipt, 'receipt_unreadable', RECEIPT_UNREADABLE_REMEDY)
935
+ config_sha = readable_sha(layout.config, 'config_unreadable', 'it must be owned by this account (0600)')
936
+ credential = load_credential(layout)
937
+ enrolled = (credential is not None and layout.machine_key.exists()
938
+ and key_binding(layout, credential) is not False)
939
+ # Review round 2: the refusal memory is the TIMER's, as the key's is (design §8.10 C3 (c)). It holds only for a
940
+ # TYPED refusal (exit 3) of THIS receipt under THIS config, for an hour. A manual run always tries a refused
941
+ # receipt (the operator just fixed the Host side; the deploy re-emits byte-identical bytes), and a transient
942
+ # failure (exit 1/2, a timeout, a failed spawn) is never remembered at all. Review round 4: the same holds for an
943
+ # ENROLLED machine — a new receipt (a new host row after a platform reset, a relabel) refused while an old
944
+ # credential.json was on disk used to be skipped for ever, by the timer and by a manual run alike, leaving a dead
945
+ # credential beside a valid receipt.
946
+ refused_before = bool(receipt_sha) and receipt_sha == record.get('receipt_refused_sha')
947
+ refusal_recent = (refused_before and config_sha == record.get('receipt_refused_config_sha')
948
+ and time.time() - record.get('receipt_refused_at', 0) <= RETRY_KEY_AFTER)
949
+ skip_refused = refused_before and options['retry'] and refusal_recent
950
+ fresh_receipt = bool(receipt_sha) and receipt_sha != record.get('receipt_adopted_sha') and not skip_refused
951
+ if skip_refused: # the one line the timer logs
952
+ code = record.get('receipt_refused_code', 'unknown')
953
+ print(f'receipt {receipt} was refused ({code}); the timer tries it again after an hour or when '
954
+ f'{layout.config} changes; a manual run of install/enable-studio-host.sh tries it now')
955
+ if enrolled and not fresh_receipt:
956
+ plan.say(f'enrolment: {layout.credential}')
957
+ return True
958
+ new_key = []
959
+ if credential is not None and not enrolled:
960
+ server, _facts = _configured_server(layout, options)
961
+ what = ('is for a machine key that is gone' if not layout.machine_key.exists()
962
+ else 'is for another machine key than the one on disk')
963
+ host = credential.get('host_id') or 'unrecorded'
964
+ machine = credential.get('machine_id') or 'unrecorded'
965
+ if not measured_revoked(layout, server):
966
+ raise EnrolmentRefused(
967
+ 'credential_for_gone_key',
968
+ f'{layout.credential} (Studio Host {host}, machine {machine}) {what}, and this machine has not '
969
+ f'measured that host row revoked: it may still be enrolled — and if its key exists anywhere, able to '
970
+ f'dial. Enrolling now would enrol a SECOND machine beside it, so nothing was published or enrolled. '
971
+ f'Revoke host {host} (Revoke `{machine}` in the console, or `throttle-rc studio revoke --id {host}` '
972
+ 'from an owner session), then run `lanes studio-host re-key --yes` — the explicit decision to enrol '
973
+ 'this machine under a new key.', 3)
974
+ plan.say(f'enrolment: {layout.credential} {what}; this machine measured host {host} revoked, so the next '
975
+ 'enrolment is a new key (`--new-key`)')
976
+ new_key = ['--new-key']
977
+
978
+ # The retry republishes only when there is no key to have published (a re-key): publish-key is idempotent for
979
+ # an existing key, so a published key is re-written only on a manual run.
980
+ if not (options['retry'] and layout.public_jwk.exists() and layout.machine_key.exists()):
981
+ plan.step(f"publish this machine's PUBLIC key to {layout.public_jwk} (`throttle-rc studio publish-key`; the "
982
+ 'key is created here if absent and never leaves this machine)', lambda: _publish(layout))
983
+ if fresh_receipt:
984
+ def adopt():
985
+ # --config: the receipt must name the server THIS unit's Host dials (receipt_server_mismatch, exit 3,
986
+ # is refused before anything is installed or recorded as adopted).
987
+ rc, out, err = throttle_rc(layout, 'studio', 'adopt-receipt', '--file', str(receipt), '--config',
988
+ str(layout.config), *new_key, timeout=30)
989
+ if rc == 3:
990
+ # A typed refusal of these bytes under this config: remembered for the TIMER (see above).
991
+ record.update(receipt_refused_sha=receipt_sha, receipt_refused_config_sha=config_sha,
992
+ receipt_refused_at=time.time(), receipt_refused_code=_refusal_code(err or out))
993
+ _save_record(layout, record)
994
+ if rc != 0:
995
+ raise _throttle_rc_failed('adopt-receipt', rc, out, err, EnrolmentRefused)
996
+ for key in RECEIPT_REFUSAL_MEMORY:
997
+ record.pop(key, None)
998
+ record.update(receipt_adopted_sha=receipt_sha, receipt_adopted_at=now_utc())
999
+ _save_record(layout, record)
1000
+ plan.step(f"adopt the deploy receipt {receipt} (`throttle-rc studio adopt-receipt"
1001
+ + (' --new-key' if new_key else '') + "`; its machine_jkt must be this machine's key and its "
1002
+ "server this config's)", adopt)
1003
+ return True
1004
+ # Review round 6: the key's stat identity is taken only on the registration-key route — an unusable leftover key
1005
+ # file used to refuse `key_unreadable` BEFORE a fresh deploy receipt, which needs no key, could be adopted.
1006
+ # Review round 5: its stat identity, never its bytes — this module does not open the registration key.
1007
+ key_id = key_fingerprint(key_file, KEY_UNREADABLE_REMEDY)
1008
+ # The hour's memory is the TIMER's (design §8.10 C3 (c)): a manual run always tries the key — the operator just
1009
+ # fixed the file or the server and asked (review round 1: the manual re-run the refusal tells them to make was
1010
+ # silently skipped for an hour).
1011
+ key_recent = (key_id == record.get('key_tried_stat')
1012
+ and time.time() - record.get('key_tried_at', 0) <= RETRY_KEY_AFTER)
1013
+ if key_id and not (options['retry'] and key_recent):
1014
+ server, facts = _configured_server(layout, options)
1015
+ label = facts.get('machine_id') or options['machine_id'] or default_machine_id()
1016
+
1017
+ def enrol():
1018
+ for key in KEY_REFUSAL_MEMORY:
1019
+ record.pop(key, None)
1020
+ record.update(key_tried_stat=key_id, key_tried_at=time.time())
1021
+ _save_record(layout, record)
1022
+ rc, out, err = throttle_rc(layout, 'studio', 'enroll', server, '--label', label,
1023
+ '--registration-key-file', str(key_file), *new_key, timeout=120)
1024
+ if rc != 0:
1025
+ # Review round 6: what was answered is remembered beside the attempt, so `status` names the refusal
1026
+ # (`key-refused`) for the hour the timer skips this key instead of pointing at the deploy.
1027
+ record.update(key_failed_exit=rc if rc is not None else 'timeout',
1028
+ key_failed_code=_refusal_code(err or out) if rc in (2, 3) else 'throttle_rc_failed')
1029
+ _save_record(layout, record)
1030
+ raise _throttle_rc_failed('enroll', rc, out, err, EnrolmentRefused, typed_exits=(2, 3))
1031
+ plan.step(f'enrol at {server} with the registration key in {key_file} (`throttle-rc studio enroll`'
1032
+ + (' --new-key: this machine measured the old host row revoked' if new_key else '')
1033
+ + '; the key is read by throttle-rc, never by this script)', enrol)
1034
+ return True
1035
+ if key_id and key_recent:
1036
+ minutes = int((time.time() - record.get('key_tried_at', 0)) // 60)
1037
+ plan.say(f'registration key {key_file} was tried {minutes} min ago and refused; the timer tries it again '
1038
+ 'after an hour, a manual run of this script tries it now')
1039
+ return False
1040
+
1041
+
1042
+ def _units(layout, plan):
1043
+ """Step 6: render every lanes unit for this checkout (services.py), then enable the Host and its retry timer."""
1044
+ repo = Path(__file__).resolve().parent.parent
1045
+
1046
+ def render():
1047
+ sys.path.insert(0, str(repo / 'install'))
1048
+ import services
1049
+ # The roots THIS run resolved (Layout.from_env) are recorded, so a later `./install.sh --server` from a
1050
+ # shell without them re-renders the studio units with the same ones (review round 4).
1051
+ xdg = {name: os.environ[name] for name in services.XDG_ROOTS if os.environ.get(name)}
1052
+ services.record_xdg(layout.state, xdg)
1053
+ # Every lanes unit is re-rendered here, so the opt-in economy poller's and push notifier's settings come from
1054
+ # this shell exactly as `./install.sh --server` takes them (install/services.py __main__): without them the
1055
+ # enable step would strip an operator's LANES_THROTTLE_*/LANES_NOTIFY_CONF from those two units.
1056
+ services.register(repo, layout.home, layout.share, layout.state, os.environ.get('LANES_WORKSPACES'),
1057
+ economy=services.economy_settings(os.environ), notify=services.notify_settings(os.environ),
1058
+ xdg=xdg, units_dir=manager_units_dir(layout))
1059
+ _check_systemctl(*systemctl('daemon-reload'), 'daemon-reload')
1060
+ _check_systemctl(*systemctl('enable', UNIT, ENROL_TIMER), f'enable {UNIT} {ENROL_TIMER}')
1061
+ _check_systemctl(*systemctl('start', ENROL_TIMER), f'start {ENROL_TIMER}')
1062
+ plan.step(f'render the lanes units (install/services.py), daemon-reload, enable {UNIT} and {ENROL_TIMER}, start '
1063
+ 'the timer', render)
1064
+
1065
+
1066
+ def _stale_refusal_only(rc, out):
1067
+ """The `last_refusal` of a `studio host --check` document whose ONLY red is that recorded refusal (exit 3,
1068
+ `identity.ok` true, `ok` false); None for anything else — an unparsable answer is never read as clean."""
1069
+ if rc != 3:
1070
+ return None
1071
+ try:
1072
+ doc = json.loads(out)
1073
+ except ValueError:
1074
+ return None
1075
+ if not isinstance(doc, dict) or doc.get('ok') is not False:
1076
+ return None
1077
+ identity, refusal = doc.get('identity'), doc.get('last_refusal')
1078
+ if not (isinstance(identity, dict) and identity.get('ok') is True and isinstance(refusal, str) and refusal):
1079
+ return None
1080
+ return refusal
1081
+
1082
+
1083
+ def _start(layout, options, record):
1084
+ """Step 7: start — or restart, so a new config or binary is in force — a Host that is enrolled and checks clean.
1085
+ The retry never restarts a Host that already ran with this enrolment: its exit 3 is a refusal, not a crash."""
1086
+ credential_sha = readable_sha(layout.credential, 'credential_unreadable',
1087
+ 'it must be owned by this account (0600)')
1088
+ active = unit_word('is-active', UNIT)
1089
+ if options['retry']:
1090
+ if active in ('active', 'activating'):
1091
+ return 0
1092
+ if credential_sha == record.get('started_credential_sha'):
1093
+ print(f'{UNIT} is {active} with the enrolment it was already started with; not restarting it '
1094
+ '(`lanes studio-host status`)')
1095
+ return 0
1096
+ rc, out, err = throttle_rc(layout, 'studio', 'host', '--check', '--config', str(layout.config), timeout=60)
1097
+ # Review round 6: whatever enrolment the recorded refusal was written under. The sha gate that stood here made a
1098
+ # receipt adopted under a RUNNING Host (a relabel re-emits different bytes; the active-unit return above leaves
1099
+ # `started_credential_sha` naming the old credential) turn the Host's own later refusal into `host_check_failed`
1100
+ # on every timer tick AND on the documented manual re-run. The identity checks clean, so the Host is started and
1101
+ # judges THIS enrolment; the timer starts it once per enrolment (the sha check above, recorded below).
1102
+ stale = _stale_refusal_only(rc, out)
1103
+ if rc != 0 and stale is None:
1104
+ raise Refusal('host_check_failed', f'`throttle-rc studio host --check` exit {rc}; {UNIT} was not started:\n'
1105
+ + (out + err).strip()[-1500:], 3 if rc == 3 else 1)
1106
+ if stale is not None:
1107
+ # Review round 4: the Host this installer started with THIS enrolment stopped on a refusal (the owner's
1108
+ # tunnel cap, a build mismatch, three displacements) and left it in state.json; `--check` reds on that
1109
+ # record alone, and only a Host run rewrites it. The identity checks clean, so the Host is started and the
1110
+ # settle window below judges THIS run — a refusal that still holds exits 3 again and is reported typed.
1111
+ print(f'`studio host --check`: the enrolment checks clean; the only red is the refusal the last run of '
1112
+ f'{UNIT} recorded ({stale[:200]}); starting it — its first phase replaces that record')
1113
+ verb = 'restart' if active == 'active' else 'start'
1114
+ _check_systemctl(*systemctl(verb, UNIT), f'{verb} {UNIT}')
1115
+ record['started_credential_sha'] = credential_sha
1116
+ _save_record(layout, record)
1117
+ deadline = time.monotonic() + options['settle']
1118
+ while True:
1119
+ state = unit_word('is-active', UNIT)
1120
+ if state != 'active':
1121
+ raise Refusal('host_not_running', f'{UNIT} is {state} after {verb}: `journalctl --user -u {UNIT} -n 30` '
1122
+ '(exit 3 = a typed refusal from the Host)')
1123
+ remaining = deadline - time.monotonic()
1124
+ if remaining <= 0:
1125
+ break
1126
+ time.sleep(min(0.5, remaining))
1127
+ print(f'running: {UNIT} {verb}ed and still active after {options["settle"]:g} s')
1128
+ return 0
1129
+
1130
+
1131
+ def enable(argv, layout=None):
1132
+ """`sh install/enable-studio-host.sh [options]` — see USAGE. Everything that can refuse statically (another Host
1133
+ unit, the hand-built unit, no binary, the config mode) refuses before anything is changed."""
1134
+ options = parse_enable_args(argv)
1135
+ layout = layout or Layout.from_env()
1136
+ if options['retry'] and not layout.config.exists():
1137
+ print(f'{layout.config} is absent: nothing to retry (run install/enable-studio-host.sh)')
1138
+ return 0
1139
+ plan = Plan(options['dry_run'], quiet=options['retry'])
1140
+ record = _record(layout)
1141
+ receipt = Path(options['receipt']).absolute() if options['receipt'] else receipt_path(layout, record)
1142
+ key_file = Path(options['key_file']).absolute() if options['key_file'] else key_path(layout, record)
1143
+ hand_built = _one_host_per_machine(layout, options, record)
1144
+ # Review round 4: a RUNNING hand-built Host on a machine with no credential.json (ceobox's shape: a human-session
1145
+ # Host from before §8.7 enrolment) is enrolled FIRST, with the same machine key, while it still runs; only then is
1146
+ # it checked, stopped and replaced.
1147
+ enrol_first = (hand_built is not None and not layout.credential.exists()
1148
+ and unit_word('is-active', HAND_BUILT_UNIT) in ('active', 'activating', 'reloading'))
1149
+ if enrol_first and not (receipt.exists() or key_file.exists()):
1150
+ # Review round 5: with nothing to enrol from, the machine's PUBLIC key is published FIRST — the one artifact
1151
+ # the deploy needs to adopt it (deploy/studio-adopt-host.sh reads it). Refusing before publishing left each
1152
+ # side waiting for the other. publish-key loads the existing machine key and never rewrites it, and the
1153
+ # hand-built Host, its unit and its config are not touched.
1154
+ _ensure_binary(layout, options, plan)
1155
+ plan.step(f"publish this machine's PUBLIC key to {layout.public_jwk} (`throttle-rc studio publish-key`; the "
1156
+ 'existing machine key is loaded, never rewritten)', lambda: _publish(layout))
1157
+ published = 'would be published' if options['dry_run'] else 'is published'
1158
+ raise Refusal('hand_built_host_unenrolled', f'{HAND_BUILT_UNIT} is running, but this machine holds no '
1159
+ f'enrolment receipt ({layout.credential}): the hand-built Host authenticates another way (a '
1160
+ "login session), which the lanes unit never uses. This machine's PUBLIC key "
1161
+ f'{published} at {layout.public_jwk} (the machine key itself unchanged). Enrol first — deploy '
1162
+ f'with THROTTLE_RC_STUDIO_ADOPT_JWK={layout.public_jwk} THROTTLE_RC_STUDIO_ADOPT_OWNER=<the '
1163
+ f"owner's sign-in email> THROTTLE_RC_STUDIO_ADOPT_RECEIPT={receipt} (from where those paths "
1164
+ "are this machine's), or put the owner's registration key in "
1165
+ f'{key_file} (0600) — then re-run with --migrate-hand-built: the receipt is installed while '
1166
+ f'{HAND_BUILT_UNIT} still runs, and it is stopped only after the new Host checks clean. Nothing '
1167
+ 'else was changed: the hand-built Host, its unit and its config are as they were.', 3)
1168
+ _ensure_binary(layout, options, plan)
1169
+ backup = _ensure_config(layout, options, plan)
1170
+ if not options['dry_run']:
1171
+ _remember_paths(layout, options, record)
1172
+ replaced = [] # set once the hand-built unit is saved, disabled and stopped
1173
+
1174
+ def give_back():
1175
+ """The hand-built Host reads the same config: until it is stopped, ANY failure gives it back the file it ran
1176
+ with (review round 5: only an enrolment refusal and a failed check used to, so a failed publish-key, an
1177
+ unreadable receipt or key, or a systemctl failure left it on a `mode` key its older binary refuses)."""
1178
+ state = 'LEFT running and enabled' if enrol_first else 'not stopped or disabled'
1179
+ if backup is None or options['dry_run']:
1180
+ return f'{HAND_BUILT_UNIT} was {state}'
1181
+ kept = Path(backup).read_bytes()
1182
+ try:
1183
+ current = layout.config.read_bytes()
1184
+ except OSError:
1185
+ current = None
1186
+ if current != kept:
1187
+ atomic_write(layout.config, kept, 0o600)
1188
+ return f'{HAND_BUILT_UNIT} was {state}, {layout.config} was restored from {backup}'
1189
+ try:
1190
+ if enrol_first:
1191
+ _enrol(layout, options, plan, record, receipt, key_file)
1192
+ if not options['dry_run'] and not layout.credential.exists():
1193
+ raise Refusal('hand_built_host_unenrolled', 'nothing enrolled this machine (no new receipt or '
1194
+ 'registration key was tried)', 3)
1195
+ if hand_built is not None:
1196
+ _migrate_hand_built(layout, plan, hand_built, record, replaced)
1197
+ except BaseException as failure:
1198
+ if hand_built is not None and not replaced:
1199
+ note = give_back()
1200
+ if isinstance(failure, Refusal):
1201
+ failure.sentence += f'\n{note}; nothing was replaced'
1202
+ else:
1203
+ print(f'{note}; nothing was replaced', file=sys.stderr)
1204
+ raise
1205
+ try:
1206
+ enrolled = _enrol(layout, options, plan, record, receipt, key_file)
1207
+ except EnrolmentRefused as refusal:
1208
+ # Review round 3: a refused (or failed) receipt/key on the FIRST run used to end it before the units were
1209
+ # rendered, so no Host unit and no enrol timer existed while every surface promised the timer would retry.
1210
+ # The machine is left genuinely enabled and waiting; the refusal is still the run's answer (its exit code).
1211
+ if not options['retry']:
1212
+ _units(layout, plan)
1213
+ refusal.sentence += (f'\n{UNIT} is enabled and waiting for enrolment: {ENROL_TIMER} tries again '
1214
+ 'when the receipt or key changes, the config changes, or the hour passes; a '
1215
+ 'manual run of install/enable-studio-host.sh tries now')
1216
+ raise
1217
+ if not options['retry']:
1218
+ _units(layout, plan)
1219
+ if options['dry_run']:
1220
+ print(f'would: run `studio host --check`, then start (or restart) {UNIT}' if enrolled
1221
+ else f'would: leave {UNIT} enabled and waiting for enrolment')
1222
+ return 0
1223
+ # Review round 6: the enrolment `_enrol` measured, never "a credential.json exists" — a dead receipt beside a new
1224
+ # key (measured revoked, waiting for the deploy) is waiting, and is not started or checked.
1225
+ if not enrolled or not layout.credential.exists():
1226
+ print(f'waiting for enrolment: {UNIT} is enabled and exits 3 until this machine is enrolled.\n'
1227
+ f' deploy-provisioned: THROTTLE_RC_STUDIO_ADOPT_JWK={layout.public_jwk}, '
1228
+ f'THROTTLE_RC_STUDIO_ADOPT_RECEIPT={receipt}; {ENROL_TIMER} adopts the receipt within 5 minutes\n'
1229
+ f" or: put the owner's registration key in {key_file} (0600) and re-run this"
1230
+ if not options['retry'] else 'waiting for enrolment: no new receipt or registration key')
1231
+ return 0
1232
+ return _start(layout, options, record)
1233
+
1234
+
1235
+ # ------------------------------------------------------------------------------------------- re-key
1236
+
1237
+ REKEY_FORGET = ('receipt_adopted_sha', 'receipt_adopted_at', 'receipt_refused_sha', 'receipt_refused_config_sha',
1238
+ 'receipt_refused_at', 'receipt_refused_code', 'key_tried_stat', LEGACY_KEY_DIGEST, 'key_tried_at',
1239
+ 'started_credential_sha')
1240
+
1241
+
1242
+ def re_key(argv, layout=None):
1243
+ """`lanes studio-host re-key [--yes] [--dry-run]` — the documented remedy after the owner revokes this machine
1244
+ (design §8.10 C4, review round 1). A revoked key is never adopted again, so the machine needs a NEW key: stop the
1245
+ Host, remove the machine key, the dead credential, the published key, the old receipt and the Host's refused
1246
+ state, forget the enrolment record's receipt/key memory (the hand-built migration record stays), then publish the
1247
+ new PUBLIC key. The next deploy adopts it and the enrol timer installs the receipt; or put a registration key in
1248
+ place and run install/enable-studio-host.sh. Without --yes it prints the plan and changes nothing (exit 2)."""
1249
+ unknown = [a for a in argv if a not in ('--yes', '--dry-run')]
1250
+ if unknown:
1251
+ raise Refusal('usage', 'usage: lanes studio-host re-key [--yes] [--dry-run]', 2)
1252
+ layout = layout or Layout.from_env()
1253
+ dry_run = '--dry-run' in argv or '--yes' not in argv
1254
+ plan = Plan(dry_run)
1255
+ word, detail, _pin = verify_installed(layout)
1256
+ if word != 'verified':
1257
+ raise Refusal('binary_mismatch' if word == 'mismatch' else 'no_binary', detail)
1258
+ active = unit_word('is-active', UNIT)
1259
+ if active == 'no-answer':
1260
+ # Review round 2: an unanswered manager is not "stopped" — deleting the key under a Host that may be running
1261
+ # is refused, never guessed.
1262
+ raise Refusal('manager_unreachable', f'the user manager did not answer `systemctl --user is-active {UNIT}`, '
1263
+ 'so whether the Host runs is unknown and its key is not removed under it. Run this from a '
1264
+ 'login of this account (XDG_RUNTIME_DIR set), or stop the Host first.')
1265
+ # Review round 5: the key is shared by EVERY Host unit of this account, not only the lanes one. The hand-built
1266
+ # sharing Host (ceobox) and `throttle-rc studio service`'s own unit run with this very machine key; removing it
1267
+ # under either makes its next proof fail. Refused while either may be running — the manager is asked, never
1268
+ # assumed.
1269
+ for other, remedy in ((HAND_BUILT_UNIT, 'migrate it first (sh install/enable-studio-host.sh --migrate-hand-built '
1270
+ f'— that keeps the key), or stop and disable it (systemctl --user disable --now {HAND_BUILT_UNIT})'),
1271
+ (SUPERVISOR_UNIT, 'remove it first (throttle-rc studio service uninstall --yes)')):
1272
+ state = unit_word('is-active', other)
1273
+ if state in ('active', 'activating', 'reloading', 'deactivating', 'no-answer'):
1274
+ raise Refusal('another_host_running', f'{other} is {state}: it runs a Host with this machine key '
1275
+ f'({layout.machine_key}), so the key is not removed under it. Nothing was changed; {remedy}, '
1276
+ 'then re-run `lanes studio-host re-key`.')
1277
+ remembered = receipt_path(layout, _record(layout))
1278
+ # Review round 6: the machine key is removed LAST, and every path is checked removable BEFORE anything changes.
1279
+ # It used to go first, so a remembered --receipt in a directory this account may not write (the deploying
1280
+ # account's — the very place RECEIPT_UNREADABLE_REMEDY points at) ended the run in a PermissionError traceback
1281
+ # with the key already destroyed, the record unchanged and no new key published; every re-run failed the same way.
1282
+ doomed = [path for path in dict.fromkeys((layout.credential, layout.public_jwk, layout.receipt, remembered,
1283
+ layout.host_state, layout.machine_key))
1284
+ if path.exists() or path.is_symlink()]
1285
+ blocked = [path for path in doomed if not os.access(path.parent, os.W_OK | os.X_OK)]
1286
+ if blocked:
1287
+ detail = '; '.join(f'{path} (its directory: {unreadable_detail(path.parent, PermissionError(errno.EACCES, os.strerror(errno.EACCES)))})'
1288
+ for path in blocked)
1289
+ raise Refusal('rekey_path_unremovable', f'this account cannot remove {detail}. Nothing was changed: give it '
1290
+ 'write access to that directory, or remove the file as its owner, then re-run '
1291
+ '`lanes studio-host re-key`.')
1292
+ if active in ('active', 'activating', 'reloading'):
1293
+ plan.step(f'stop {UNIT} (it is {active})', lambda: _check_systemctl(*systemctl('stop', UNIT), f'stop {UNIT}'))
1294
+
1295
+ def remove():
1296
+ for path in doomed:
1297
+ try:
1298
+ path.unlink()
1299
+ except FileNotFoundError:
1300
+ continue
1301
+ except OSError as error:
1302
+ raise Refusal('rekey_path_unremovable', f'{path}: {unreadable_detail(path, error)}. Removed so far: '
1303
+ + (', '.join(str(p) for p in doomed[:doomed.index(path)]) or 'nothing')
1304
+ + f'; the machine key {layout.machine_key} is removed last and was '
1305
+ + ('not touched' if path != layout.machine_key else 'not removed')
1306
+ + '. Fix the permission, then re-run `lanes studio-host re-key --yes`.') from error
1307
+ record = _record(layout)
1308
+ for key in REKEY_FORGET:
1309
+ record.pop(key, None)
1310
+ record['rekeyed_at'] = now_utc()
1311
+ _save_record(layout, record)
1312
+ plan.step('remove ' + (', '.join(str(p) for p in doomed) or 'nothing (no key or enrolment on disk)')
1313
+ + f' and forget the receipt/key memory in {layout.record}', remove)
1314
+
1315
+ plan.step(f"publish the NEW machine's PUBLIC key to {layout.public_jwk} (`throttle-rc studio publish-key`)",
1316
+ lambda: _publish(layout))
1317
+ if '--yes' not in argv:
1318
+ print('nothing was changed: re-run with --yes (the old key cannot be restored; any throttle-rc session on '
1319
+ 'this account bound to it must log in again)')
1320
+ return 2
1321
+ if dry_run:
1322
+ return 0
1323
+ print(f'next: deploy again (THROTTLE_RC_STUDIO_ADOPT_JWK={layout.public_jwk}); {ENROL_TIMER} installs the new '
1324
+ f"receipt within 5 minutes. Or put the owner's registration key in {layout.registration_key} (0600) and run "
1325
+ 'sh install/enable-studio-host.sh')
1326
+ return 0
1327
+
1328
+
1329
+ # ------------------------------------------------------------------------------------------- status
1330
+
1331
+ def unit_contract_missing(text):
1332
+ return [line for line in UNIT_CONTRACT if line not in text]
1333
+
1334
+
1335
+ def status_facts(layout, check=False):
1336
+ word, detail, pin = verify_installed(layout)
1337
+ facts = {'binary': {'state': word, 'detail': detail, 'path': str(layout.binary),
1338
+ 'version': (pin or {}).get('version'), 'source': (pin or {}).get('source')}}
1339
+ try:
1340
+ facts['lock_pin'] = getattr(parse_lock(lock_path().read_text()).get(BINARY), 'version', None)
1341
+ except (OSError, Refusal) as error:
1342
+ facts['lock_pin'] = f'unreadable: {error}'
1343
+ config = {'path': str(layout.config), 'present': layout.config.exists()}
1344
+ if config['present']:
1345
+ try:
1346
+ parsed = config_facts(read_config_text(layout))
1347
+ except Refusal as refusal: # review round 5: said, never a traceback
1348
+ config.update(readable=False, detail=refusal.sentence)
1349
+ else:
1350
+ config.update(readable=True, mode=parsed['mode'], server=parsed.get('server'),
1351
+ machine_id=parsed.get('machine_id'),
1352
+ sharing_state_dir=parsed['inventory'].get('sharing_state_dir'))
1353
+ facts['config'] = config
1354
+ credential = load_credential(layout)
1355
+ record = _record(layout)
1356
+ receipt, key_file = receipt_path(layout, record), key_path(layout, record)
1357
+ receipt_readable = is_readable(receipt) if receipt.exists() else None
1358
+ # Review round 3: the timer's refusal memory, surfaced. A receipt on disk whose bytes the installer recorded as
1359
+ # refused is NOT "waiting for the deploy": the deploy did its part and the Host side refused it.
1360
+ refused = None
1361
+ if receipt_readable and record.get('receipt_refused_sha'):
1362
+ try:
1363
+ on_disk = sha256_file(receipt)
1364
+ except OSError:
1365
+ on_disk = None
1366
+ if on_disk == record['receipt_refused_sha']:
1367
+ config_now = None
1368
+ if layout.config.exists():
1369
+ try:
1370
+ config_now = sha256_file(layout.config)
1371
+ except OSError:
1372
+ config_now = None
1373
+ at = record.get('receipt_refused_at') or 0
1374
+ refused = {'code': record.get('receipt_refused_code', 'unknown'),
1375
+ 'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(at)) if at else None,
1376
+ 'config_changed_since': config_now != record.get('receipt_refused_config_sha'),
1377
+ 'retry_after_s': max(0, int(at + RETRY_KEY_AFTER - time.time())) if at else 0}
1378
+ # Review round 6: the registration key's refusal, surfaced as the receipt's is — while the timer's memory of it
1379
+ # holds (the same file, within the hour). Its stat identity only: never opened.
1380
+ key_refused = None
1381
+ if record.get('key_failed_exit') is not None and key_file.exists() and os.access(key_file, os.R_OK):
1382
+ try:
1383
+ same_key = key_fingerprint(key_file, '') == record.get('key_tried_stat')
1384
+ except (Refusal, OSError):
1385
+ same_key = False
1386
+ at = record.get('key_tried_at') or 0
1387
+ if same_key and at:
1388
+ key_refused = {'code': record.get('key_failed_code') or 'unknown', 'exit': record['key_failed_exit'],
1389
+ 'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(at)),
1390
+ 'retry_after_s': max(0, int(at + RETRY_KEY_AFTER - time.time()))}
1391
+ binding = key_binding(layout, credential) if credential is not None else None
1392
+ server = config.get('server') if config.get('readable') else None
1393
+ # Review rounds 4 and 6: the predicate `_enrol` uses — a credential whose machine key is gone, or is NOT the key
1394
+ # this machine published, is not an enrolment.
1395
+ facts['enrolment'] = {
1396
+ 'enrolled': credential is not None and layout.machine_key.exists() and binding is not False,
1397
+ 'machine_key_present': layout.machine_key.exists(),
1398
+ 'key_binding': {True: 'verified', False: 'another key', None: 'unchecked'}[binding],
1399
+ 'measured_revoked': measured_revoked(layout, server) if credential is not None else None,
1400
+ 'via': (credential or {}).get('via', 'unrecorded') if credential is not None else None,
1401
+ 'machine_id': (credential or {}).get('machine_id'), 'owner': (credential or {}).get('owner_user_word_id'),
1402
+ 'host_id': (credential or {}).get('host_id'),
1403
+ 'public_key': str(layout.public_jwk) if layout.public_jwk.exists() else None,
1404
+ 'receipt': str(receipt) if receipt.exists() else None,
1405
+ 'receipt_path': str(receipt),
1406
+ 'receipt_readable': receipt_readable,
1407
+ 'receipt_adopted_at': record.get('receipt_adopted_at'),
1408
+ 'receipt_refused': refused,
1409
+ 'registration_key_file': str(key_file) if key_file.exists() else None,
1410
+ # access(2), never an open (review round 5: this module does not open the registration key)
1411
+ 'registration_key_readable': os.access(key_file, os.R_OK) if key_file.exists() else None,
1412
+ 'registration_key_tried_at': (time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(record['key_tried_at']))
1413
+ if record.get('key_tried_at') else None),
1414
+ 'registration_key_refused': key_refused,
1415
+ }
1416
+ installed = manager_units_dir(layout) / UNIT
1417
+ facts['unit'] = {'file': installed.exists(), 'path': str(installed),
1418
+ 'contract_missing': unit_contract_missing(installed.read_text()) if installed.exists() else None,
1419
+ 'enabled': unit_word('is-enabled', UNIT), 'active': unit_word('is-active', UNIT),
1420
+ 'enrol_timer': unit_word('is-active', ENROL_TIMER)}
1421
+ facts['conflicts'] = {SUPERVISOR_UNIT: unit_word('is-active', SUPERVISOR_UNIT),
1422
+ HAND_BUILT_UNIT: unit_word('is-active', HAND_BUILT_UNIT)}
1423
+ facts['state'], facts['next'] = classify(facts)
1424
+ if check and word == 'verified' and config['present'] and config.get('readable'):
1425
+ rc, out, err = throttle_rc(layout, 'studio', 'host', '--check', '--config', str(layout.config), timeout=60)
1426
+ facts['check'] = {'exit': rc, 'output': (out + err).strip()[-4000:]}
1427
+ # Review round 2: `--check` binds the exit. A running unit whose own check refuses is not "running".
1428
+ if facts['state'] == 'running' and rc != 0:
1429
+ facts['state'] = 'running-check-failed'
1430
+ facts['next'] = (f'the unit runs but `throttle-rc studio host --check` exit {rc}: read its output below '
1431
+ f'and journalctl --user -u {UNIT} -n 30')
1432
+ return facts
1433
+
1434
+
1435
+ ENABLED_WORDS = ('enabled', 'enabled-runtime', 'linked', 'linked-runtime', 'alias')
1436
+
1437
+
1438
+ def classify(facts):
1439
+ """-> (state word, next step). Words: running | running-unenrolled | waiting-for-enrolment | receipt-refused |
1440
+ key-refused | credential-for-gone-key | receipt-unreadable | key-unreadable | config-unreadable | not-enabled |
1441
+ stopped | failed | conflict | not-installed | unknown (and,
1442
+ with --check, running-check-failed, set by status_facts). `unknown` when the manager did not answer: never
1443
+ guessed. Only `running` (active AND enrolled, and with --check its check exit 0) exits 0."""
1444
+ unit, binary = facts['unit'], facts['binary']
1445
+ if facts['config'].get('readable') is False:
1446
+ return 'config-unreadable', f"{facts['config']['detail']}; then sh install/enable-studio-host.sh"
1447
+ if 'active' in facts['conflicts'].values():
1448
+ other = next(name for name, state in facts['conflicts'].items() if state == 'active')
1449
+ return 'conflict', f'{other} is running a Host on this machine: see docs/SHARING.md "Migrating the hand-built unit"'
1450
+ if binary['state'] != 'verified' or not unit['file'] or not facts['config']['present']:
1451
+ return 'not-installed', 'sh install/enable-studio-host.sh --server URL [--from PATH]'
1452
+ if unit['active'] == 'no-answer':
1453
+ return 'unknown', 'the user manager did not answer: systemctl --user status lanes-studio-host.service'
1454
+ enrolled = facts['enrolment']['enrolled']
1455
+ if unit['active'] == 'active':
1456
+ return ('running', '') if enrolled else ('running-unenrolled',
1457
+ 'the Host runs without an enrolment receipt: check it')
1458
+ if not enrolled and facts['enrolment'].get('receipt_readable') is False:
1459
+ return 'receipt-unreadable', (f"{facts['enrolment']['receipt']} exists but this account cannot read it, so "
1460
+ f'{ENROL_TIMER} cannot install it: {RECEIPT_UNREADABLE_REMEDY}')
1461
+ if not enrolled and facts['enrolment'].get('registration_key_readable') is False:
1462
+ return 'key-unreadable', (f"{facts['enrolment']['registration_key_file']} exists but this account cannot read "
1463
+ f'it: {KEY_UNREADABLE_REMEDY}')
1464
+ # Review round 3: "waiting" is said only of a machine whose waiting is real — the Host unit enabled AND the enrol
1465
+ # timer active. Otherwise nothing will retry (a refused first enable used to leave neither installed).
1466
+ if unit['enabled'] == 'no-answer' or unit['enrol_timer'] == 'no-answer':
1467
+ return 'unknown', 'the user manager did not answer: systemctl --user status lanes-studio-host.service'
1468
+ timer_waits = unit['enabled'] in ENABLED_WORDS and unit['enrol_timer'] in ('active', 'activating')
1469
+ refused = facts['enrolment'].get('receipt_refused')
1470
+ # Review round 4: a refused receipt on disk is named whether or not an (older) credential.json exists — an
1471
+ # enrolled-but-stopped machine beside a refused NEW receipt is not merely `stopped`.
1472
+ if refused:
1473
+ if not timer_waits:
1474
+ when = 'nothing retries it: the units are not enabled'
1475
+ elif refused['config_changed_since']:
1476
+ when = f'{ENROL_TIMER} tries it again on its next tick (the config changed since)'
1477
+ else:
1478
+ when = (f"{ENROL_TIMER} tries it again in {refused['retry_after_s'] // 60} min or when "
1479
+ 'studio-host.toml changes')
1480
+ return 'receipt-refused', (f"adopt-receipt refused {facts['enrolment']['receipt']} ({refused['code']}) at "
1481
+ f"{refused['at']}; {when}. receipt_server_mismatch: make the config's `server` the "
1482
+ "deployment's URL; receipt_key_mismatch: the deploy adopted another key — redeploy "
1483
+ 'with this public key; then run sh install/enable-studio-host.sh to try it now')
1484
+ enrolment = facts['enrolment']
1485
+ if not enrolled and enrolment.get('via') is not None and enrolment.get('measured_revoked') is False:
1486
+ # Review round 6: a receipt whose key is gone (or is another key) for a row this machine has NOT measured
1487
+ # revoked — `_enrol` refuses to enrol a second machine beside it.
1488
+ host = enrolment.get('host_id') or 'unrecorded'
1489
+ return 'credential-for-gone-key', (f"the receipt on disk (host {host}, machine {enrolment['machine_id']}) is "
1490
+ "not for the machine key on disk, and this machine has not measured that "
1491
+ f"row revoked: revoke host {host} (console, or `throttle-rc studio revoke "
1492
+ f"--id {host}`), then lanes studio-host re-key --yes")
1493
+ key_refused = enrolment.get('registration_key_refused')
1494
+ if not enrolled and key_refused:
1495
+ # Review round 6: the key route's refusal, named as the receipt's is (it used to say "waiting" and point at
1496
+ # the deploy while the timer skipped the key for the hour).
1497
+ when = (f"{ENROL_TIMER} tries it again in {key_refused['retry_after_s'] // 60} min or when the file changes"
1498
+ if timer_waits else 'nothing retries it: the units are not enabled')
1499
+ return 'key-refused', (f"`throttle-rc studio enroll` refused {enrolment['registration_key_file']} "
1500
+ f"({key_refused['code']}, exit {key_refused['exit']}) at {key_refused['at']}; {when}; "
1501
+ f'a manual run of sh install/enable-studio-host.sh tries it now '
1502
+ f'(journalctl --user -u {ENROL_SERVICE} -n 20 has its line)')
1503
+ if not enrolled and not timer_waits:
1504
+ return 'not-enabled', (f"{UNIT} is {unit['enabled']} and {ENROL_TIMER} is {unit['enrol_timer']}, so nothing "
1505
+ 'retries the enrolment: run sh install/enable-studio-host.sh')
1506
+ if not enrolled:
1507
+ return 'waiting-for-enrolment', ('the deploy adopts the public key and writes the receipt; '
1508
+ f'{ENROL_TIMER} installs it (or: registration key file, re-run the enable step)')
1509
+ if unit['active'] == 'failed':
1510
+ return 'failed', f'journalctl --user -u {UNIT} -n 30, then systemctl --user reset-failed {UNIT}'
1511
+ return 'stopped', f'journalctl --user -u {UNIT} -n 30 (exit 3 = typed refusal), then systemctl --user start {UNIT}'
1512
+
1513
+
1514
+ def print_status(facts):
1515
+ print(f"state: {facts['state']}" + (f" — next: {facts['next']}" if facts['next'] else ''))
1516
+ binary = facts['binary']
1517
+ print(f"binary: {binary['state']} — {binary['detail']}; holders.lock pin: {facts['lock_pin'] or 'none yet'}")
1518
+ config = facts['config']
1519
+ if config['present'] and config.get('readable') is False:
1520
+ print(f"config: {config['path']} — unreadable by this account")
1521
+ else:
1522
+ print(f"config: {config['path']}" + (f" — mode {config.get('mode')}, server {config.get('server')}, machine "
1523
+ f"{config.get('machine_id')}" if config['present'] else ' — absent'))
1524
+ enrolment = facts['enrolment']
1525
+ if enrolment['enrolled']:
1526
+ print(f"enrolment: via {enrolment['via']}, machine {enrolment['machine_id']}, owner {enrolment['owner']}")
1527
+ else:
1528
+ if enrolment['via'] is not None: # a credential.json whose machine key is gone, or is another key
1529
+ which = 'gone' if not enrolment['machine_key_present'] else 'not the key on disk'
1530
+ revoked = (' (this machine measured that host row revoked)' if enrolment.get('measured_revoked')
1531
+ else ' (NOT measured revoked here)')
1532
+ print(f"enrolment: the receipt on disk (machine {enrolment['machine_id']}) is for a machine key that is "
1533
+ f'{which}{revoked}; it does not enrol this machine')
1534
+ key_word = 'none'
1535
+ if enrolment['registration_key_file']:
1536
+ refused = enrolment.get('registration_key_refused')
1537
+ key_word = 'present' + (f" — tried {refused['at']}, refused {refused['code']} (exit {refused['exit']})"
1538
+ if refused else '')
1539
+ print('enrolment: none — public key ' + (enrolment['public_key'] or 'not published')
1540
+ + '; receipt ' + (enrolment['receipt'] or f"none (watching {enrolment['receipt_path']})")
1541
+ + '; registration key file ' + key_word)
1542
+ if enrolment.get('receipt_refused'):
1543
+ refused = enrolment['receipt_refused']
1544
+ print(f"receipt refused: {refused['code']} at {refused['at']} (journalctl --user -u {ENROL_SERVICE} -n 20)")
1545
+ unit = facts['unit']
1546
+ print(f"unit: {UNIT} file {'present' if unit['file'] else 'absent'}, {unit['enabled']}, {unit['active']}; "
1547
+ f"{ENROL_TIMER} {unit['enrol_timer']}")
1548
+ if unit['contract_missing']:
1549
+ print(f" the installed unit lacks: {'; '.join(unit['contract_missing'])} — re-run ./install.sh --server")
1550
+ print('other Host units: ' + ', '.join(f'{name} {state}' for name, state in facts['conflicts'].items()))
1551
+ if 'check' in facts:
1552
+ print(f"studio host --check: exit {facts['check']['exit']}")
1553
+ print(facts['check']['output'])
1554
+
1555
+
1556
+ def run_cli(args):
1557
+ """`lanes studio-host <verb>` (bin/lanes hands over its arguments; `main` types the refusals)."""
1558
+ verb, rest = (args[0], args[1:]) if args else ('status', [])
1559
+ layout = Layout.from_env()
1560
+ if verb == 'status':
1561
+ unknown = [a for a in rest if a not in ('--json', '--check')]
1562
+ if unknown:
1563
+ raise Refusal('usage', 'usage: lanes studio-host status [--json] [--check]', 2)
1564
+ facts = status_facts(layout, check='--check' in rest)
1565
+ if '--json' in rest:
1566
+ print(json.dumps({'schema': 'lanes.studio-host-status', 'version': 1, **facts}, indent=1, sort_keys=True))
1567
+ else:
1568
+ print_status(facts)
1569
+ return 0 if facts['state'] == 'running' else 1
1570
+ if verb == 'install':
1571
+ source = expected = None
1572
+ items = list(rest)
1573
+ while items:
1574
+ item = items.pop(0)
1575
+ if item in ('--from', '--sha256') and items:
1576
+ source, expected = (items.pop(0), expected) if item == '--from' else (source, items.pop(0))
1577
+ else:
1578
+ raise Refusal('usage', 'usage: lanes studio-host install [--from PATH [--sha256 HEX]]', 2)
1579
+ if expected and not source:
1580
+ raise Refusal('usage', '--sha256 goes with --from', 2)
1581
+ if source:
1582
+ record = install_local(layout, source, expected)
1583
+ else:
1584
+ pins = parse_lock(lock_path().read_text())
1585
+ if BINARY not in pins:
1586
+ raise Refusal('no_binary', f'holders.lock pins no {BINARY} release yet: use --from PATH')
1587
+ record = install_pinned(layout, pins[BINARY])
1588
+ print(f"selected {layout.binary} -> {BINARY}-{record['version']} (sha256 {record['sha256']}); nothing was "
1589
+ f'restarted: `systemctl --user restart {UNIT}` puts it in force')
1590
+ return 0
1591
+ if verb == 'enable':
1592
+ return enable(rest, layout)
1593
+ if verb == 're-key':
1594
+ return re_key(rest, layout)
1595
+ raise Refusal('usage', 'usage: lanes studio-host status [--json] [--check] | install [--from PATH [--sha256 HEX]]'
1596
+ ' | re-key [--yes] [--dry-run]', 2)
1597
+
1598
+
1599
+ def main(argv):
1600
+ try:
1601
+ return run_cli(argv)
1602
+ except Refusal as refusal:
1603
+ print(f'refused {refusal.code}: {refusal.sentence}', file=sys.stderr)
1604
+ return refusal.exit_code
1605
+
1606
+
1607
+ if __name__ == '__main__':
1608
+ sys.exit(main(sys.argv[1:]))