manigent-daemon 0.1.4__py3-none-any.whl

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.
@@ -0,0 +1,514 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ manigent_daemon/autostart.py
4
+
5
+ Start this computer's connection again at login, without being asked.
6
+
7
+ ## Why this exists
8
+
9
+ Before this module the connection was detached but unsupervised: it survived the
10
+ terminal that started it and did not survive a reboot. That is the wrong
11
+ asymmetry, because almost nobody stops their agents on purpose — they reboot,
12
+ close the lid for the weekend, or take a system update overnight. Every one of
13
+ those looked identical to "Manigent is broken": the web app said the computer
14
+ was off, which was true, and offered no reason to think a single command would
15
+ fix it. Registering with the OS login manager is the one change that makes most
16
+ of those reports disappear.
17
+
18
+ ## One account, one unit
19
+
20
+ A unit is per manigent account, named after it, exactly as the credential,
21
+ pidfile and logs are. Two accounts on one machine get two units that start two
22
+ processes; disabling one leaves the other running. Anything shared would make
23
+ "stop starting my work account at login" impossible to express.
24
+
25
+ ## Deliberately not a supervisor
26
+
27
+ launchd's ``KeepAlive`` and systemd's ``Restart=`` are both OFF. They would
28
+ fight ``manigent computer stop``: that command signals the process directly,
29
+ which both managers read as an unexpected death and answer by starting it again
30
+ within seconds. A user who stops their computer's connection and watches it come
31
+ back has been told the product ignores them, and that is a worse failure than
32
+ the crash-recovery this gives up. The connection already reconnects across
33
+ network failures on its own (``client.run_forever``), so what is lost is
34
+ recovery from a hard crash of the process itself — which the next login fixes
35
+ anyway.
36
+
37
+ ## What is written where
38
+
39
+ - macOS — ``~/Library/LaunchAgents/ai.manigent.computer.<account>.plist``,
40
+ loaded with ``launchctl bootstrap gui/<uid>`` (falling back to ``load -w`` on
41
+ older systems).
42
+ - Linux — ``~/.config/systemd/user/manigent-computer-<account>.service``,
43
+ enabled with ``systemctl --user enable``. ``loginctl enable-linger`` is
44
+ attempted too, because without it the unit stops at logout, which on a
45
+ headless box means it never runs at all.
46
+ - Windows — unsupported here and reported as such rather than half-done. The
47
+ honest answer ("run ``manigent computer start``") beats a Task Scheduler
48
+ entry this module could not reliably remove again.
49
+
50
+ Every function reports rather than raises: a failure to register autostart must
51
+ never fail the install that asked for it. The connection still works; it just
52
+ will not come back by itself, and the caller gets a sentence saying so.
53
+ """
54
+ from __future__ import annotations
55
+
56
+ import hashlib
57
+ import os
58
+ import plistlib
59
+ import shutil
60
+ import subprocess
61
+ import sys
62
+ from dataclasses import dataclass
63
+ from pathlib import Path
64
+
65
+ from manigent_daemon import config as cfg
66
+
67
+ # The reverse-DNS label launchd knows this by, and the prefix of the systemd
68
+ # unit name. Public because `status` output and the uninstall path both key off
69
+ # it, and because a user grepping their LaunchAgents folder should find one
70
+ # obvious name.
71
+ LABEL_PREFIX = "ai.manigent.computer"
72
+ SYSTEMD_PREFIX = "manigent-computer"
73
+
74
+ # How long any launchctl / systemctl call may take. These are local IPC calls
75
+ # that either answer at once or are wedged; waiting longer helps nobody.
76
+ _CMD_TIMEOUT_SECONDS = 20
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class AutostartState:
81
+ """Whether this account's connection is registered to start at login.
82
+
83
+ ``supported`` false means this platform has no mechanism we manage, and
84
+ ``installed`` is then meaningless — callers must check ``supported`` first
85
+ rather than reading ``installed=False`` as "the user should enable it".
86
+ """
87
+
88
+ supported: bool
89
+ installed: bool
90
+ detail: str
91
+ # The plist / unit file, when there is one. Shown so a user can read or
92
+ # delete by hand what we wrote on their machine.
93
+ path: str = ""
94
+
95
+
96
+ def _account(username: str = "") -> str:
97
+ """Select the account these paths belong to, and return its name."""
98
+ if username:
99
+ cfg.use_account(username)
100
+ return cfg.active_account()
101
+
102
+
103
+ def _run(argv: list[str]) -> tuple[bool, str]:
104
+ """Run a manager command. ``(ok, output)``; never raises.
105
+
106
+ Both managers are chatty on stderr and silent on success, so the message is
107
+ whichever stream spoke — that is what a user needs to see when enabling
108
+ autostart did not take.
109
+ """
110
+ try:
111
+ result = subprocess.run(
112
+ argv,
113
+ capture_output=True,
114
+ text=True,
115
+ timeout=_CMD_TIMEOUT_SECONDS,
116
+ )
117
+ except FileNotFoundError:
118
+ return False, f"{argv[0]} is not available on this computer"
119
+ except (OSError, subprocess.SubprocessError) as e:
120
+ return False, str(e)
121
+ message = (result.stderr or result.stdout or "").strip()
122
+ return result.returncode == 0, message
123
+
124
+
125
+ def _run_argv(username: str) -> list[str]:
126
+ """The command the OS should run at login, as an absolute argv.
127
+
128
+ ``sys.executable -m manigent_daemon.cli run`` rather than the
129
+ ``manigent-computer`` console script: launchd and systemd start with a
130
+ minimal PATH that does not include ``~/.local/bin``, where uv puts the
131
+ script, so resolving the interpreter now is what makes the unit work at
132
+ login rather than only in the shell that created it.
133
+
134
+ The account is spelled out for the same reason ``_start_detached`` spells it
135
+ out — the logged-in account can change under a unit that runs for months,
136
+ and a connection that silently began reporting a different account's agents
137
+ would be the worst available bug.
138
+
139
+ ``-P`` for the same reason ``cli_daemon._resolve`` passes it: ``python -m``
140
+ puts the working directory on ``sys.path``, and both units below run from
141
+ ``$HOME``. A file named ``typing.py`` or ``httpx.py`` sitting in someone's
142
+ home directory would otherwise shadow the real module and the connection
143
+ would die in an import traceback at every login — the least debuggable
144
+ possible version of "my agents stopped coming back".
145
+ """
146
+ return [
147
+ sys.executable, "-P", "-m", "manigent_daemon.cli",
148
+ "run", "--user", username,
149
+ ]
150
+
151
+
152
+ def _login_path() -> str:
153
+ """The PATH the login-started process should see: the installing shell's.
154
+
155
+ launchd and systemd start units with a bare system PATH
156
+ (``/usr/bin:/bin:/usr/sbin:/sbin``). ``_run_argv`` pins the interpreter so
157
+ the connection itself starts, but everything IT shells — ``manigent`` for
158
+ agent start/stop and the model-CLI check, and through that ``claude`` /
159
+ ``codex`` in ``~/.local/bin`` or ``/opt/homebrew/bin`` — is found by PATH.
160
+ On a bare PATH the model check finds nothing and the page tells a working
161
+ user that no model is available. Recording the PATH of the shell that ran
162
+ ``computer install`` is what makes the login process see what the user sees.
163
+ Re-running install (or ``autostart enable``) refreshes it.
164
+ """
165
+ entries: list[str] = []
166
+ for entry in (
167
+ str(Path(sys.executable).parent),
168
+ str(Path.home() / ".local" / "bin"),
169
+ *os.environ.get("PATH", "").split(os.pathsep),
170
+ ):
171
+ if entry and entry not in entries:
172
+ entries.append(entry)
173
+ return os.pathsep.join(entries)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # macOS — launchd
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def _data_dir_tag() -> str:
182
+ """A short, stable tag for a non-default data dir, else "".
183
+
184
+ ``MANIGENT_DATA_DIR`` is how one machine keeps test, staging and prod side
185
+ by side, and the same username can exist in each. Without a tag their login
186
+ entries would share one name and the second install would replace the
187
+ first's. The default data dir gets no tag, so existing entries keep their
188
+ names and nothing needs migrating. ``manigent/doctor.py`` re-derives this.
189
+ """
190
+ data_dir = cfg.data_dir().expanduser().resolve()
191
+ if data_dir == (Path.home() / ".manigent").resolve():
192
+ return ""
193
+ return hashlib.sha1(str(data_dir).encode()).hexdigest()[:8]
194
+
195
+
196
+ def _login_env() -> dict[str, str]:
197
+ """Environment for the login-started process.
198
+
199
+ Carries ``MANIGENT_DATA_DIR`` when it is not the default: launchd and
200
+ systemd start with a bare environment, so without it a connection set up
201
+ in another data dir would look in ``~/.manigent`` after a reboot and find
202
+ no key.
203
+ """
204
+ env = {"PATH": _login_path()}
205
+ if _data_dir_tag():
206
+ env["MANIGENT_DATA_DIR"] = str(cfg.data_dir().expanduser().resolve())
207
+ return env
208
+
209
+
210
+ def _launchd_label(account: str) -> str:
211
+ label = f"{LABEL_PREFIX}.{account}" if account else LABEL_PREFIX
212
+ tag = _data_dir_tag()
213
+ return f"{label}.{tag}" if tag else label
214
+
215
+
216
+ def _launchd_path(account: str) -> Path:
217
+ return (
218
+ Path.home() / "Library" / "LaunchAgents" / f"{_launchd_label(account)}.plist"
219
+ )
220
+
221
+
222
+ def _launchd_plist(account: str) -> dict:
223
+ """The agent definition.
224
+
225
+ ``RunAtLoad`` is the whole feature. ``KeepAlive`` is absent on purpose — see
226
+ the module docstring on why supervising would break ``computer stop``.
227
+
228
+ The two log paths are the SAME files the foreground and detached paths write
229
+ (``cfg.log_paths``), so "why did my computer stop last night" is one
230
+ ``manigent computer logs`` regardless of who started it.
231
+ """
232
+ stdout_log, stderr_log = cfg.log_paths()
233
+ return {
234
+ "Label": _launchd_label(account),
235
+ "ProgramArguments": _run_argv(account),
236
+ "EnvironmentVariables": _login_env(),
237
+ "RunAtLoad": True,
238
+ "ProcessType": "Background",
239
+ "StandardOutPath": str(stdout_log),
240
+ "StandardErrorPath": str(stderr_log),
241
+ "WorkingDirectory": str(Path.home()),
242
+ }
243
+
244
+
245
+ def _launchctl_domain() -> str:
246
+ return f"gui/{os.getuid()}"
247
+
248
+
249
+ def _launchd_enable(account: str) -> AutostartState:
250
+ path = _launchd_path(account)
251
+ path.parent.mkdir(parents=True, exist_ok=True)
252
+ stdout_log, _ = cfg.log_paths()
253
+ stdout_log.parent.mkdir(parents=True, exist_ok=True)
254
+ with open(path, "wb") as fh:
255
+ plistlib.dump(_launchd_plist(account), fh)
256
+
257
+ # Replace rather than add: re-enabling after an upgrade must pick up the new
258
+ # interpreter path, and `bootstrap` refuses a label that is already loaded.
259
+ _run(["launchctl", "bootout", f"{_launchctl_domain()}/{_launchd_label(account)}"])
260
+ ok, message = _run(["launchctl", "bootstrap", _launchctl_domain(), str(path)])
261
+ if not ok:
262
+ # `bootstrap` arrived in macOS 10.11 and `load` still works everywhere;
263
+ # trying the old verb costs one call and covers older systems.
264
+ ok, message = _run(["launchctl", "load", "-w", str(path)])
265
+ if not ok:
266
+ return AutostartState(
267
+ supported=True,
268
+ installed=False,
269
+ detail=f"Could not register with macOS: {message}",
270
+ path=str(path),
271
+ )
272
+ return AutostartState(
273
+ supported=True,
274
+ installed=True,
275
+ detail="Will start when you log in",
276
+ path=str(path),
277
+ )
278
+
279
+
280
+ def _launchd_disable(account: str) -> AutostartState:
281
+ path = _launchd_path(account)
282
+ _run(["launchctl", "bootout", f"{_launchctl_domain()}/{_launchd_label(account)}"])
283
+ _run(["launchctl", "unload", "-w", str(path)])
284
+ try:
285
+ path.unlink()
286
+ except FileNotFoundError:
287
+ pass
288
+ except OSError as e:
289
+ return AutostartState(
290
+ supported=True,
291
+ installed=True,
292
+ detail=f"Could not remove {path}: {e}",
293
+ path=str(path),
294
+ )
295
+ return AutostartState(
296
+ supported=True,
297
+ installed=False,
298
+ detail="Will no longer start when you log in",
299
+ path=str(path),
300
+ )
301
+
302
+
303
+ def _launchd_status(account: str) -> AutostartState:
304
+ path = _launchd_path(account)
305
+ if not path.is_file():
306
+ return AutostartState(
307
+ supported=True,
308
+ installed=False,
309
+ detail="Not set to start when you log in",
310
+ path=str(path),
311
+ )
312
+ return AutostartState(
313
+ supported=True,
314
+ installed=True,
315
+ detail="Starts when you log in",
316
+ path=str(path),
317
+ )
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Linux — systemd user units
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ def _systemd_unit(account: str) -> str:
326
+ name = f"{SYSTEMD_PREFIX}-{account}" if account else SYSTEMD_PREFIX
327
+ tag = _data_dir_tag()
328
+ return f"{name}-{tag}.service" if tag else f"{name}.service"
329
+
330
+
331
+ def _systemd_path(account: str) -> Path:
332
+ return Path.home() / ".config" / "systemd" / "user" / _systemd_unit(account)
333
+
334
+
335
+ def _systemd_text(account: str) -> str:
336
+ """The unit file.
337
+
338
+ ``Restart`` is absent for the reason given in the module docstring.
339
+ ``WantedBy=default.target`` is what makes ``enable`` mean "at login".
340
+
341
+ The process writes its own logs, so output goes to the journal as well and
342
+ nothing here redirects it — a systemd user unit that writes to files AND the
343
+ journal duplicates every line.
344
+ """
345
+ argv = " ".join(_shell_quote(a) for a in _run_argv(account))
346
+ return (
347
+ "[Unit]\n"
348
+ f"Description=Manigent — keep this computer connected ({account})\n"
349
+ "After=network-online.target\n"
350
+ "Wants=network-online.target\n"
351
+ "\n"
352
+ "[Service]\n"
353
+ "Type=simple\n"
354
+ f"ExecStart={argv}\n"
355
+ # Quoted whole, with `%` doubled: systemd expands `%` specifiers here.
356
+ + "".join(
357
+ f'Environment="{key}={value.replace("%", "%%")}"\n'
358
+ for key, value in _login_env().items()
359
+ )
360
+ + f"WorkingDirectory={Path.home()}\n"
361
+ "\n"
362
+ "[Install]\n"
363
+ "WantedBy=default.target\n"
364
+ )
365
+
366
+
367
+ def _shell_quote(value: str) -> str:
368
+ """Quote one systemd ``ExecStart`` argument.
369
+
370
+ systemd parses ExecStart itself rather than handing it to a shell, and it
371
+ understands double quotes. Only paths with spaces need it, which is a real
372
+ case (``/Users/Some Name/...``).
373
+ """
374
+ return f'"{value}"' if " " in value else value
375
+
376
+
377
+ def _systemd_enable(account: str) -> AutostartState:
378
+ if not shutil.which("systemctl"):
379
+ return AutostartState(
380
+ supported=False,
381
+ installed=False,
382
+ detail="This Linux has no systemd, so there is nothing to register with",
383
+ )
384
+ path = _systemd_path(account)
385
+ path.parent.mkdir(parents=True, exist_ok=True)
386
+ path.write_text(_systemd_text(account))
387
+
388
+ _run(["systemctl", "--user", "daemon-reload"])
389
+ ok, message = _run(["systemctl", "--user", "enable", _systemd_unit(account)])
390
+ if not ok:
391
+ return AutostartState(
392
+ supported=True,
393
+ installed=False,
394
+ detail=f"Could not register with systemd: {message}",
395
+ path=str(path),
396
+ )
397
+
398
+ # Without lingering, a user unit is torn down at logout — so on a machine
399
+ # nobody sits in front of, "starts at login" would never happen. Best-effort:
400
+ # it needs a polkit yes on some distros, and failing it still leaves a unit
401
+ # that works for a logged-in desktop user.
402
+ linger_ok, _ = _run(["loginctl", "enable-linger", os.environ.get("USER", "")])
403
+ detail = "Will start when you log in"
404
+ if not linger_ok:
405
+ detail += " (while you are logged in — `loginctl enable-linger` to cover reboots with no login)"
406
+ return AutostartState(
407
+ supported=True, installed=True, detail=detail, path=str(path)
408
+ )
409
+
410
+
411
+ def _systemd_disable(account: str) -> AutostartState:
412
+ path = _systemd_path(account)
413
+ _run(["systemctl", "--user", "disable", _systemd_unit(account)])
414
+ try:
415
+ path.unlink()
416
+ except FileNotFoundError:
417
+ pass
418
+ except OSError as e:
419
+ return AutostartState(
420
+ supported=True,
421
+ installed=True,
422
+ detail=f"Could not remove {path}: {e}",
423
+ path=str(path),
424
+ )
425
+ _run(["systemctl", "--user", "daemon-reload"])
426
+ return AutostartState(
427
+ supported=True,
428
+ installed=False,
429
+ detail="Will no longer start when you log in",
430
+ path=str(path),
431
+ )
432
+
433
+
434
+ def _systemd_status(account: str) -> AutostartState:
435
+ if not shutil.which("systemctl"):
436
+ return AutostartState(
437
+ supported=False,
438
+ installed=False,
439
+ detail="This Linux has no systemd, so there is nothing to register with",
440
+ )
441
+ path = _systemd_path(account)
442
+ if not path.is_file():
443
+ return AutostartState(
444
+ supported=True,
445
+ installed=False,
446
+ detail="Not set to start when you log in",
447
+ path=str(path),
448
+ )
449
+ # The FILE existing is not the same as the unit being enabled — a user can
450
+ # `systemctl --user disable` without deleting it, and reporting that as
451
+ # enabled would be the checklist lying.
452
+ ok, _ = _run(["systemctl", "--user", "is-enabled", _systemd_unit(account)])
453
+ return AutostartState(
454
+ supported=True,
455
+ installed=ok,
456
+ detail="Starts when you log in" if ok else "Registered but switched off",
457
+ path=str(path),
458
+ )
459
+
460
+
461
+ # ---------------------------------------------------------------------------
462
+ # The platform-agnostic surface
463
+ # ---------------------------------------------------------------------------
464
+
465
+
466
+ _UNSUPPORTED = (
467
+ "Starting at login is not something Manigent sets up on this platform yet. "
468
+ "Run `manigent computer start` after a restart."
469
+ )
470
+
471
+
472
+ def supported() -> bool:
473
+ return sys.platform in ("darwin", "linux")
474
+
475
+
476
+ def enable(username: str = "") -> AutostartState:
477
+ """Register this account's connection to start at login. Never raises."""
478
+ account = _account(username)
479
+ if not account:
480
+ return AutostartState(
481
+ supported=supported(),
482
+ installed=False,
483
+ detail="No account is selected, so there is nothing to start at login",
484
+ )
485
+ if sys.platform == "darwin":
486
+ return _launchd_enable(account)
487
+ if sys.platform == "linux":
488
+ return _systemd_enable(account)
489
+ return AutostartState(supported=False, installed=False, detail=_UNSUPPORTED)
490
+
491
+
492
+ def disable(username: str = "") -> AutostartState:
493
+ """Stop starting this account's connection at login. Never raises.
494
+
495
+ Does NOT stop a connection that is running now — the two are separate
496
+ decisions, and silently disconnecting a working machine because the user
497
+ asked about next Tuesday would be the wrong reading of the request.
498
+ """
499
+ account = _account(username)
500
+ if sys.platform == "darwin":
501
+ return _launchd_disable(account)
502
+ if sys.platform == "linux":
503
+ return _systemd_disable(account)
504
+ return AutostartState(supported=False, installed=False, detail=_UNSUPPORTED)
505
+
506
+
507
+ def status(username: str = "") -> AutostartState:
508
+ """Is it registered? Never raises, never changes anything."""
509
+ account = _account(username)
510
+ if sys.platform == "darwin":
511
+ return _launchd_status(account)
512
+ if sys.platform == "linux":
513
+ return _systemd_status(account)
514
+ return AutostartState(supported=False, installed=False, detail=_UNSUPPORTED)