alter-runtime 0.3.0__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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Install / enable / disable host service units for alter-runtime.
|
|
2
|
+
|
|
3
|
+
Handles three platforms:
|
|
4
|
+
|
|
5
|
+
* **Linux** via systemd *user* units. We install to
|
|
6
|
+
``~/.config/systemd/user/alter-runtime.service``, run
|
|
7
|
+
``systemctl --user daemon-reload`` + ``systemctl --user enable --now``,
|
|
8
|
+
and tear down symmetrically on uninstall. User units avoid needing
|
|
9
|
+
sudo and match the per-user security model (JWT, XDG paths, etc.).
|
|
10
|
+
* **macOS** via launchd *LaunchAgents*. We install to
|
|
11
|
+
``~/Library/LaunchAgents/com.alter.runtime.plist``, bootstrap into
|
|
12
|
+
the current GUI session with ``launchctl bootstrap``, and log to
|
|
13
|
+
``~/Library/Logs/alter-runtime/``.
|
|
14
|
+
* **Windows** - stubbed out. Wave 3 stream 3b ships the ``pywin32``-based
|
|
15
|
+
Windows Service wrapper.
|
|
16
|
+
|
|
17
|
+
The module exposes a small, testable surface:
|
|
18
|
+
|
|
19
|
+
* :func:`render_systemd_unit` / :func:`render_launchd_plist` - pure string
|
|
20
|
+
templating (read the template, substitute placeholders, return the
|
|
21
|
+
rendered body). No filesystem writes, so tests can assert on the output.
|
|
22
|
+
* :func:`install` / :func:`uninstall` - platform-dispatching wrappers
|
|
23
|
+
that call into the concrete implementations.
|
|
24
|
+
* :func:`service_status` - reports whether the unit is installed /
|
|
25
|
+
loaded / running. Used by ``alter-runtime status``.
|
|
26
|
+
|
|
27
|
+
All writes are idempotent; re-running ``install`` overwrites the existing
|
|
28
|
+
unit file with a freshly rendered copy so operators can pick up template
|
|
29
|
+
changes by re-running a single command.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import TYPE_CHECKING
|
|
42
|
+
|
|
43
|
+
from alter_runtime.services import (
|
|
44
|
+
launchd_template_path,
|
|
45
|
+
systemd_cf_access_dropin_template_path,
|
|
46
|
+
systemd_template_path,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Sequence
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ServiceStatus",
|
|
54
|
+
"current_platform",
|
|
55
|
+
"install",
|
|
56
|
+
"launchd_plist_install_path",
|
|
57
|
+
"render_launchd_plist",
|
|
58
|
+
"render_systemd_cf_access_dropin",
|
|
59
|
+
"render_systemd_unit",
|
|
60
|
+
"resolve_runtime_binary",
|
|
61
|
+
"service_status",
|
|
62
|
+
"systemd_cf_access_dropin_install_path",
|
|
63
|
+
"systemd_unit_install_path",
|
|
64
|
+
"uninstall",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger("alter_runtime.service_install")
|
|
68
|
+
|
|
69
|
+
SYSTEMD_UNIT_NAME: str = "alter-runtime.service"
|
|
70
|
+
LAUNCHD_LABEL: str = "com.alter.runtime"
|
|
71
|
+
LAUNCHD_PLIST_NAME: str = f"{LAUNCHD_LABEL}.plist"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Platform detection
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def current_platform() -> str:
|
|
80
|
+
"""Return one of ``"linux"``, ``"darwin"``, ``"windows"``, or ``"other"``.
|
|
81
|
+
|
|
82
|
+
Kept as a public helper so the CLI can produce helpful error messages
|
|
83
|
+
on unsupported platforms without duplicating the platform-check logic.
|
|
84
|
+
"""
|
|
85
|
+
if sys.platform.startswith("linux"):
|
|
86
|
+
return "linux"
|
|
87
|
+
if sys.platform == "darwin":
|
|
88
|
+
return "darwin"
|
|
89
|
+
if sys.platform == "win32":
|
|
90
|
+
return "windows"
|
|
91
|
+
return "other"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Binary resolution
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_runtime_binary() -> Path:
|
|
100
|
+
"""Return the absolute path of the installed ``alter-runtime`` entry point.
|
|
101
|
+
|
|
102
|
+
``pip install alter-runtime`` drops a console script at whatever bin
|
|
103
|
+
directory the active Python environment is using; we find it via
|
|
104
|
+
``shutil.which`` (which respects ``$PATH``) and fall back to
|
|
105
|
+
``sys.executable + ` -m alter_runtime.cli``` semantics as a last
|
|
106
|
+
resort. Returning a ``Path`` (rather than a string) lets callers
|
|
107
|
+
cleanly assert on filesystem checks.
|
|
108
|
+
"""
|
|
109
|
+
from_path = shutil.which("alter-runtime")
|
|
110
|
+
if from_path:
|
|
111
|
+
return Path(from_path).resolve()
|
|
112
|
+
|
|
113
|
+
# Fallback: try the same directory as the current Python interpreter.
|
|
114
|
+
sibling = Path(sys.executable).parent / "alter-runtime"
|
|
115
|
+
if sibling.exists():
|
|
116
|
+
return sibling.resolve()
|
|
117
|
+
|
|
118
|
+
raise FileNotFoundError(
|
|
119
|
+
"alter-runtime entry point not found on PATH or next to the current "
|
|
120
|
+
"Python interpreter. Install the package first with `pip install -e .` "
|
|
121
|
+
"from the packages/alter-runtime directory, or `pip install alter-runtime`."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Template rendering
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _substitute(body: str, mapping: dict[str, str]) -> str:
|
|
131
|
+
"""Replace ``@KEY@`` placeholders in ``body`` using ``mapping``."""
|
|
132
|
+
out = body
|
|
133
|
+
for key, value in mapping.items():
|
|
134
|
+
out = out.replace(f"@{key}@", value)
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def render_systemd_unit(
|
|
139
|
+
binary_path: Path | None = None,
|
|
140
|
+
home: Path | None = None,
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Render the systemd unit template with the current binary path.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
binary_path:
|
|
147
|
+
Override for the ``@ALTER_RUNTIME_BIN@`` placeholder. Defaults to
|
|
148
|
+
:func:`resolve_runtime_binary`.
|
|
149
|
+
home:
|
|
150
|
+
Override for the ``@ALTER_RUNTIME_HOME@`` placeholder. Defaults to
|
|
151
|
+
``Path.home()``.
|
|
152
|
+
"""
|
|
153
|
+
binary_path = binary_path or resolve_runtime_binary()
|
|
154
|
+
home = home or Path.home()
|
|
155
|
+
template = systemd_template_path().read_text(encoding="utf-8")
|
|
156
|
+
return _substitute(
|
|
157
|
+
template,
|
|
158
|
+
{
|
|
159
|
+
"ALTER_RUNTIME_BIN": str(binary_path),
|
|
160
|
+
"ALTER_RUNTIME_HOME": str(home),
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def render_systemd_cf_access_dropin() -> str:
|
|
166
|
+
"""Render the CF Access env drop-in template.
|
|
167
|
+
|
|
168
|
+
The template is a static drop-in for the main systemd user unit —
|
|
169
|
+
it pulls ``CF_ACCESS_CLIENT_ID`` + ``CF_ACCESS_CLIENT_SECRET`` from
|
|
170
|
+
``~/.config/alter/cf-access.env`` into the daemon's process
|
|
171
|
+
environment so the httpx clients can inject the CF Access
|
|
172
|
+
service-token headers required by the production edge at
|
|
173
|
+
``mcp.truealter.com``.
|
|
174
|
+
|
|
175
|
+
Currently the template has no placeholders (it uses systemd's ``%h``
|
|
176
|
+
specifier for the user home), but the function follows the same
|
|
177
|
+
shape as :func:`render_systemd_unit` for future-proofing — adding
|
|
178
|
+
placeholders later does not change the call sites.
|
|
179
|
+
"""
|
|
180
|
+
return systemd_cf_access_dropin_template_path().read_text(encoding="utf-8")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def render_launchd_plist(
|
|
184
|
+
binary_path: Path | None = None,
|
|
185
|
+
log_dir: Path | None = None,
|
|
186
|
+
home: Path | None = None,
|
|
187
|
+
) -> str:
|
|
188
|
+
"""Render the launchd plist template with the current binary path."""
|
|
189
|
+
binary_path = binary_path or resolve_runtime_binary()
|
|
190
|
+
home = home or Path.home()
|
|
191
|
+
log_dir = log_dir or (home / "Library" / "Logs" / "alter-runtime")
|
|
192
|
+
template = launchd_template_path().read_text(encoding="utf-8")
|
|
193
|
+
return _substitute(
|
|
194
|
+
template,
|
|
195
|
+
{
|
|
196
|
+
"ALTER_RUNTIME_BIN": str(binary_path),
|
|
197
|
+
"ALTER_RUNTIME_HOME": str(home),
|
|
198
|
+
"ALTER_RUNTIME_LOG_DIR": str(log_dir),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Install paths
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def systemd_unit_install_path() -> Path:
|
|
209
|
+
"""Return the path where the systemd user unit should be written."""
|
|
210
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
211
|
+
base = Path(xdg_config) if xdg_config else Path.home() / ".config"
|
|
212
|
+
return base / "systemd" / "user" / SYSTEMD_UNIT_NAME
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def systemd_cf_access_dropin_install_path() -> Path:
|
|
216
|
+
"""Return the path where the CF Access env drop-in should be written.
|
|
217
|
+
|
|
218
|
+
Drop-ins for a user unit live in ``<unit>.service.d/`` next to the
|
|
219
|
+
main unit file. systemd merges every ``*.conf`` in that directory
|
|
220
|
+
into the parent unit at daemon-reload time.
|
|
221
|
+
"""
|
|
222
|
+
return systemd_unit_install_path().parent / (SYSTEMD_UNIT_NAME + ".d") / "cf-access-env.conf"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def launchd_plist_install_path() -> Path:
|
|
226
|
+
"""Return the path where the launchd LaunchAgent plist should be written."""
|
|
227
|
+
return Path.home() / "Library" / "LaunchAgents" / LAUNCHD_PLIST_NAME
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Install / uninstall
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class InstallResult:
|
|
237
|
+
"""Outcome of an :func:`install` call - what was written + what ran."""
|
|
238
|
+
|
|
239
|
+
platform: str
|
|
240
|
+
unit_path: Path
|
|
241
|
+
rendered: str
|
|
242
|
+
service_commands: list[list[str]]
|
|
243
|
+
command_results: list[tuple[list[str], int]]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def install(
|
|
247
|
+
*,
|
|
248
|
+
enable: bool = True,
|
|
249
|
+
dry_run: bool = False,
|
|
250
|
+
binary_path: Path | None = None,
|
|
251
|
+
reinstall: bool = False,
|
|
252
|
+
) -> InstallResult:
|
|
253
|
+
"""Install the host service unit for the current platform.
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
enable:
|
|
258
|
+
When ``True`` (the default), also run ``systemctl --user enable
|
|
259
|
+
--now`` (Linux) or ``launchctl bootstrap`` (macOS) to start the
|
|
260
|
+
daemon immediately. Set to ``False`` for a file-only install.
|
|
261
|
+
dry_run:
|
|
262
|
+
When ``True``, render + return the rendered unit body but do not
|
|
263
|
+
touch the filesystem or invoke any service manager command. Used
|
|
264
|
+
by the CLI to preview without side effects.
|
|
265
|
+
binary_path:
|
|
266
|
+
Override for the resolved ``alter-runtime`` binary - tests pass a
|
|
267
|
+
fake path to check templating without requiring the script to be
|
|
268
|
+
installed.
|
|
269
|
+
reinstall:
|
|
270
|
+
When ``True``, bypass the deletion-permanence tombstone check.
|
|
271
|
+
Corresponds to the ``--reinstall`` CLI flag.
|
|
272
|
+
"""
|
|
273
|
+
platform = current_platform()
|
|
274
|
+
if platform == "linux":
|
|
275
|
+
return _install_systemd(
|
|
276
|
+
enable=enable, dry_run=dry_run, binary_path=binary_path, reinstall=reinstall
|
|
277
|
+
)
|
|
278
|
+
if platform == "darwin":
|
|
279
|
+
return _install_launchd(
|
|
280
|
+
enable=enable, dry_run=dry_run, binary_path=binary_path, reinstall=reinstall
|
|
281
|
+
)
|
|
282
|
+
if platform == "windows":
|
|
283
|
+
raise NotImplementedError("Windows Service install lands in Wave 3 stream 3b (D-RT1).")
|
|
284
|
+
raise NotImplementedError(f"unsupported platform: {sys.platform}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def uninstall() -> list[tuple[list[str], int]]:
|
|
288
|
+
"""Disable + remove the host service unit for the current platform.
|
|
289
|
+
|
|
290
|
+
Returns a list of ``(command, exit_code)`` tuples for every shell
|
|
291
|
+
invocation the uninstaller ran (empty on platforms that only need a
|
|
292
|
+
file delete).
|
|
293
|
+
"""
|
|
294
|
+
platform = current_platform()
|
|
295
|
+
if platform == "linux":
|
|
296
|
+
return _uninstall_systemd()
|
|
297
|
+
if platform == "darwin":
|
|
298
|
+
return _uninstall_launchd()
|
|
299
|
+
if platform == "windows":
|
|
300
|
+
raise NotImplementedError("Windows Service uninstall lands in Wave 3 stream 3b.")
|
|
301
|
+
raise NotImplementedError(f"unsupported platform: {sys.platform}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# systemd (Linux)
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _install_systemd(
|
|
310
|
+
*,
|
|
311
|
+
enable: bool,
|
|
312
|
+
dry_run: bool,
|
|
313
|
+
binary_path: Path | None,
|
|
314
|
+
reinstall: bool = False,
|
|
315
|
+
) -> InstallResult:
|
|
316
|
+
unit_body = render_systemd_unit(binary_path=binary_path)
|
|
317
|
+
unit_path = systemd_unit_install_path()
|
|
318
|
+
|
|
319
|
+
commands: list[list[str]] = [
|
|
320
|
+
["systemctl", "--user", "daemon-reload"],
|
|
321
|
+
]
|
|
322
|
+
if enable:
|
|
323
|
+
commands.append(
|
|
324
|
+
["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT_NAME],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if dry_run:
|
|
328
|
+
return InstallResult(
|
|
329
|
+
platform="linux",
|
|
330
|
+
unit_path=unit_path,
|
|
331
|
+
rendered=unit_body,
|
|
332
|
+
service_commands=commands,
|
|
333
|
+
command_results=[],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# C3 - deletion-permanence guard. Refuse to recreate a unit file the
|
|
337
|
+
# user previously removed unless --reinstall was passed explicitly.
|
|
338
|
+
if not reinstall:
|
|
339
|
+
from alter_runtime.consent import UserDeletedArtefact, is_tombstoned
|
|
340
|
+
|
|
341
|
+
if is_tombstoned(unit_path):
|
|
342
|
+
raise UserDeletedArtefact(unit_path)
|
|
343
|
+
|
|
344
|
+
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
345
|
+
unit_path.write_text(unit_body, encoding="utf-8")
|
|
346
|
+
try:
|
|
347
|
+
unit_path.chmod(0o644)
|
|
348
|
+
except OSError:
|
|
349
|
+
pass
|
|
350
|
+
logger.info("wrote systemd unit to %s", unit_path)
|
|
351
|
+
|
|
352
|
+
# CF Access env drop-in (D-SUBSTRATE-UNIFIED-1 §2.3 Option A) — lands
|
|
353
|
+
# alongside the main unit so the daemon's httpx clients inherit the
|
|
354
|
+
# service-token env-vars from ~/.config/alter/cf-access.env on every
|
|
355
|
+
# restart. EnvironmentFile in the drop-in uses a leading "-" so a
|
|
356
|
+
# missing envfile is non-fatal; dev hosts without CF Access still
|
|
357
|
+
# boot cleanly.
|
|
358
|
+
dropin_body = render_systemd_cf_access_dropin()
|
|
359
|
+
dropin_path = systemd_cf_access_dropin_install_path()
|
|
360
|
+
dropin_path.parent.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
dropin_path.write_text(dropin_body, encoding="utf-8")
|
|
362
|
+
try:
|
|
363
|
+
dropin_path.chmod(0o644)
|
|
364
|
+
except OSError:
|
|
365
|
+
pass
|
|
366
|
+
logger.info("wrote systemd CF Access drop-in to %s", dropin_path)
|
|
367
|
+
|
|
368
|
+
results = _run_commands(commands)
|
|
369
|
+
return InstallResult(
|
|
370
|
+
platform="linux",
|
|
371
|
+
unit_path=unit_path,
|
|
372
|
+
rendered=unit_body,
|
|
373
|
+
service_commands=commands,
|
|
374
|
+
command_results=results,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _uninstall_systemd() -> list[tuple[list[str], int]]:
|
|
379
|
+
results = _run_commands(
|
|
380
|
+
[
|
|
381
|
+
["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT_NAME],
|
|
382
|
+
["systemctl", "--user", "daemon-reload"],
|
|
383
|
+
]
|
|
384
|
+
)
|
|
385
|
+
unit_path = systemd_unit_install_path()
|
|
386
|
+
if unit_path.exists():
|
|
387
|
+
try:
|
|
388
|
+
unit_path.unlink()
|
|
389
|
+
logger.info("removed systemd unit %s", unit_path)
|
|
390
|
+
except OSError as exc:
|
|
391
|
+
logger.warning("could not delete %s: %s", unit_path, exc)
|
|
392
|
+
# Remove the CF Access drop-in we installed. The drop-in directory
|
|
393
|
+
# may hold other operator-authored drop-ins (memory caps, eBPF
|
|
394
|
+
# hardening, repo overrides); we only touch the one we wrote.
|
|
395
|
+
dropin_path = systemd_cf_access_dropin_install_path()
|
|
396
|
+
if dropin_path.exists():
|
|
397
|
+
try:
|
|
398
|
+
dropin_path.unlink()
|
|
399
|
+
logger.info("removed systemd CF Access drop-in %s", dropin_path)
|
|
400
|
+
except OSError as exc:
|
|
401
|
+
logger.warning("could not delete %s: %s", dropin_path, exc)
|
|
402
|
+
return results
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
# launchd (macOS)
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _install_launchd(
|
|
411
|
+
*,
|
|
412
|
+
enable: bool,
|
|
413
|
+
dry_run: bool,
|
|
414
|
+
binary_path: Path | None,
|
|
415
|
+
reinstall: bool = False,
|
|
416
|
+
) -> InstallResult:
|
|
417
|
+
plist_body = render_launchd_plist(binary_path=binary_path)
|
|
418
|
+
plist_path = launchd_plist_install_path()
|
|
419
|
+
log_dir = Path.home() / "Library" / "Logs" / "alter-runtime"
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
uid = os.getuid() # type: ignore[attr-defined]
|
|
423
|
+
except AttributeError:
|
|
424
|
+
uid = 0 # pragma: no cover - macOS always has getuid
|
|
425
|
+
|
|
426
|
+
commands: list[list[str]] = []
|
|
427
|
+
if enable:
|
|
428
|
+
commands.append(
|
|
429
|
+
["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
430
|
+
)
|
|
431
|
+
commands.append(
|
|
432
|
+
["launchctl", "kickstart", "-k", f"gui/{uid}/{LAUNCHD_LABEL}"],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if dry_run:
|
|
436
|
+
return InstallResult(
|
|
437
|
+
platform="darwin",
|
|
438
|
+
unit_path=plist_path,
|
|
439
|
+
rendered=plist_body,
|
|
440
|
+
service_commands=commands,
|
|
441
|
+
command_results=[],
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# C3 - deletion-permanence guard (launchd).
|
|
445
|
+
if not reinstall:
|
|
446
|
+
from alter_runtime.consent import UserDeletedArtefact, is_tombstoned
|
|
447
|
+
|
|
448
|
+
if is_tombstoned(plist_path):
|
|
449
|
+
raise UserDeletedArtefact(plist_path)
|
|
450
|
+
|
|
451
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
plist_path.write_text(plist_body, encoding="utf-8")
|
|
454
|
+
try:
|
|
455
|
+
plist_path.chmod(0o644)
|
|
456
|
+
except OSError:
|
|
457
|
+
pass
|
|
458
|
+
logger.info("wrote launchd plist to %s", plist_path)
|
|
459
|
+
|
|
460
|
+
results = _run_commands(commands)
|
|
461
|
+
return InstallResult(
|
|
462
|
+
platform="darwin",
|
|
463
|
+
unit_path=plist_path,
|
|
464
|
+
rendered=plist_body,
|
|
465
|
+
service_commands=commands,
|
|
466
|
+
command_results=results,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _uninstall_launchd() -> list[tuple[list[str], int]]:
|
|
471
|
+
try:
|
|
472
|
+
uid = os.getuid() # type: ignore[attr-defined]
|
|
473
|
+
except AttributeError:
|
|
474
|
+
uid = 0
|
|
475
|
+
|
|
476
|
+
results = _run_commands(
|
|
477
|
+
[
|
|
478
|
+
["launchctl", "bootout", f"gui/{uid}/{LAUNCHD_LABEL}"],
|
|
479
|
+
]
|
|
480
|
+
)
|
|
481
|
+
plist_path = launchd_plist_install_path()
|
|
482
|
+
if plist_path.exists():
|
|
483
|
+
try:
|
|
484
|
+
plist_path.unlink()
|
|
485
|
+
logger.info("removed launchd plist %s", plist_path)
|
|
486
|
+
except OSError as exc:
|
|
487
|
+
logger.warning("could not delete %s: %s", plist_path, exc)
|
|
488
|
+
return results
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
# Status
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass
|
|
497
|
+
class ServiceStatus:
|
|
498
|
+
"""Snapshot of the host service's installation and running state."""
|
|
499
|
+
|
|
500
|
+
platform: str
|
|
501
|
+
installed: bool
|
|
502
|
+
active: bool
|
|
503
|
+
unit_path: Path
|
|
504
|
+
status_line: str
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def service_status() -> ServiceStatus:
|
|
508
|
+
"""Report whether the service unit is installed + running.
|
|
509
|
+
|
|
510
|
+
The active-state probe is a best-effort ``systemctl is-active`` /
|
|
511
|
+
``launchctl print`` call - if the service manager itself is missing
|
|
512
|
+
(e.g. headless container without systemd) we return
|
|
513
|
+
``active=False`` and a status line explaining why.
|
|
514
|
+
"""
|
|
515
|
+
platform = current_platform()
|
|
516
|
+
if platform == "linux":
|
|
517
|
+
unit_path = systemd_unit_install_path()
|
|
518
|
+
installed = unit_path.exists()
|
|
519
|
+
active = False
|
|
520
|
+
status_line = "not installed"
|
|
521
|
+
if installed:
|
|
522
|
+
exit_code, stdout = _capture(["systemctl", "--user", "is-active", SYSTEMD_UNIT_NAME])
|
|
523
|
+
active = exit_code == 0 and stdout.strip() == "active"
|
|
524
|
+
status_line = stdout.strip() or f"systemctl exit={exit_code}"
|
|
525
|
+
return ServiceStatus(
|
|
526
|
+
platform=platform,
|
|
527
|
+
installed=installed,
|
|
528
|
+
active=active,
|
|
529
|
+
unit_path=unit_path,
|
|
530
|
+
status_line=status_line,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if platform == "darwin":
|
|
534
|
+
plist_path = launchd_plist_install_path()
|
|
535
|
+
installed = plist_path.exists()
|
|
536
|
+
active = False
|
|
537
|
+
status_line = "not installed"
|
|
538
|
+
if installed:
|
|
539
|
+
try:
|
|
540
|
+
uid = os.getuid() # type: ignore[attr-defined]
|
|
541
|
+
except AttributeError:
|
|
542
|
+
uid = 0
|
|
543
|
+
exit_code, stdout = _capture(["launchctl", "print", f"gui/{uid}/{LAUNCHD_LABEL}"])
|
|
544
|
+
active = exit_code == 0 and "state = running" in stdout
|
|
545
|
+
status_line = "running" if active else f"launchctl exit={exit_code}"
|
|
546
|
+
return ServiceStatus(
|
|
547
|
+
platform=platform,
|
|
548
|
+
installed=installed,
|
|
549
|
+
active=active,
|
|
550
|
+
unit_path=plist_path,
|
|
551
|
+
status_line=status_line,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
return ServiceStatus(
|
|
555
|
+
platform=platform,
|
|
556
|
+
installed=False,
|
|
557
|
+
active=False,
|
|
558
|
+
unit_path=Path("/"),
|
|
559
|
+
status_line=f"unsupported platform: {sys.platform}",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
# Subprocess helpers
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _run_commands(commands: Sequence[Sequence[str]]) -> list[tuple[list[str], int]]:
|
|
569
|
+
"""Run each command in order, collecting exit codes. Non-fatal on failure.
|
|
570
|
+
|
|
571
|
+
We log warnings on non-zero exits rather than raising so that a missing
|
|
572
|
+
``systemctl`` (e.g. inside a non-systemd container) doesn't crash
|
|
573
|
+
``alter-runtime install`` - the file write still succeeds and the
|
|
574
|
+
operator can enable the unit manually.
|
|
575
|
+
"""
|
|
576
|
+
results: list[tuple[list[str], int]] = []
|
|
577
|
+
for cmd in commands:
|
|
578
|
+
cmd_list = list(cmd)
|
|
579
|
+
try:
|
|
580
|
+
completed = subprocess.run( # noqa: S603 - trusted args
|
|
581
|
+
cmd_list,
|
|
582
|
+
check=False,
|
|
583
|
+
capture_output=True,
|
|
584
|
+
text=True,
|
|
585
|
+
)
|
|
586
|
+
except FileNotFoundError:
|
|
587
|
+
logger.warning("command not found: %s - skipping", cmd_list[0])
|
|
588
|
+
results.append((cmd_list, 127))
|
|
589
|
+
continue
|
|
590
|
+
if completed.returncode != 0:
|
|
591
|
+
logger.warning(
|
|
592
|
+
"%s exited %d stderr=%r",
|
|
593
|
+
" ".join(cmd_list),
|
|
594
|
+
completed.returncode,
|
|
595
|
+
completed.stderr.strip()[:256],
|
|
596
|
+
)
|
|
597
|
+
results.append((cmd_list, completed.returncode))
|
|
598
|
+
return results
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _capture(cmd: Sequence[str]) -> tuple[int, str]:
|
|
602
|
+
"""Run ``cmd`` and return ``(exit_code, stdout)``.
|
|
603
|
+
|
|
604
|
+
Missing binaries yield ``(127, "")`` so callers can treat them as
|
|
605
|
+
"not installed" without branching on ``FileNotFoundError``.
|
|
606
|
+
"""
|
|
607
|
+
try:
|
|
608
|
+
completed = subprocess.run( # noqa: S603
|
|
609
|
+
list(cmd),
|
|
610
|
+
check=False,
|
|
611
|
+
capture_output=True,
|
|
612
|
+
text=True,
|
|
613
|
+
)
|
|
614
|
+
except FileNotFoundError:
|
|
615
|
+
return 127, ""
|
|
616
|
+
return completed.returncode, completed.stdout
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Host service unit templates for alter-runtime (Wave 2 stream 2c).
|
|
2
|
+
|
|
3
|
+
Contains the platform-specific unit files that make the daemon a long-lived
|
|
4
|
+
system service:
|
|
5
|
+
|
|
6
|
+
* ``systemd/alter-runtime.service.in`` - template for a systemd *user* unit
|
|
7
|
+
installed to ``~/.config/systemd/user/alter-runtime.service``. User units
|
|
8
|
+
are preferred over system units because the daemon holds a user-scoped
|
|
9
|
+
JWT and writes into ``$XDG_RUNTIME_DIR`` / ``$XDG_DATA_HOME`` - both of
|
|
10
|
+
which live under the user's account.
|
|
11
|
+
* ``launchd/com.alter.runtime.plist.in`` - template for a launchd
|
|
12
|
+
*LaunchAgent* installed to ``~/Library/LaunchAgents/com.alter.runtime.plist``
|
|
13
|
+
on macOS. Agents run at per-user login (as opposed to daemons which run
|
|
14
|
+
at boot and need elevated privileges).
|
|
15
|
+
* ``windows/`` - reserved for the Windows Service wrapper shipped in Wave 3
|
|
16
|
+
stream 3b.
|
|
17
|
+
|
|
18
|
+
The unit files are templates because the installed binary path is not
|
|
19
|
+
known until install time: ``pip install alter-runtime`` drops the entry
|
|
20
|
+
point at whatever ``scripts`` directory the active Python environment uses
|
|
21
|
+
(``~/.local/bin``, ``$VIRTUAL_ENV/bin``, ``/usr/local/bin``, …), and a
|
|
22
|
+
fixed ``ExecStart`` path would only work for one of those. The install
|
|
23
|
+
helper in :mod:`alter_runtime.service_install` resolves the current
|
|
24
|
+
binary via ``shutil.which`` and renders the template before writing.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"SERVICES_DIR",
|
|
33
|
+
"launchd_template_path",
|
|
34
|
+
"systemd_cf_access_dropin_template_path",
|
|
35
|
+
"systemd_template_path",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
SERVICES_DIR: Path = Path(__file__).parent
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def systemd_template_path() -> Path:
|
|
42
|
+
"""Return the absolute path of the bundled systemd unit template."""
|
|
43
|
+
return SERVICES_DIR / "systemd" / "alter-runtime.service.in"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def systemd_cf_access_dropin_template_path() -> Path:
|
|
47
|
+
"""Return the absolute path of the bundled CF Access env drop-in template.
|
|
48
|
+
|
|
49
|
+
The drop-in loads ``~/.config/alter/cf-access.env`` into the daemon's
|
|
50
|
+
environment so the httpx clients can inject the CF Access
|
|
51
|
+
service-token headers required by ``mcp.truealter.com``. Installed
|
|
52
|
+
alongside the main unit by :func:`alter_runtime.service_install.install`.
|
|
53
|
+
"""
|
|
54
|
+
return SERVICES_DIR / "systemd" / "cf-access-env.conf.in"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def launchd_template_path() -> Path:
|
|
58
|
+
"""Return the absolute path of the bundled launchd plist template."""
|
|
59
|
+
return SERVICES_DIR / "launchd" / "com.alter.runtime.plist.in"
|