chp-adapter-launchd 0.10.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.
- chp_adapter_launchd/__init__.py +21 -0
- chp_adapter_launchd/_backends.py +145 -0
- chp_adapter_launchd/adapter.py +393 -0
- chp_adapter_launchd-0.10.0.dist-info/METADATA +65 -0
- chp_adapter_launchd-0.10.0.dist-info/RECORD +7 -0
- chp_adapter_launchd-0.10.0.dist-info/WHEEL +4 -0
- chp_adapter_launchd-0.10.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""chp-adapter-launchd — govern macOS launchd services as CHP capabilities.
|
|
2
|
+
|
|
3
|
+
Manage long-running LaunchAgent services (list/status/start/stop/install/uninstall)
|
|
4
|
+
with evidence. Scoped by a managed label prefix (default ``com.chp.``) so it only
|
|
5
|
+
touches CHP's own services. launchctl + plist I/O is isolated in ``_backends.py``.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
10
|
+
from chp_adapter_launchd import LaunchdAdapter, LaunchdConfig
|
|
11
|
+
|
|
12
|
+
host = LocalCapabilityHost()
|
|
13
|
+
register_adapter(host, LaunchdAdapter(LaunchdConfig()))
|
|
14
|
+
result = host.invoke("chp.adapters.launchd.list", {})
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .adapter import LaunchdAdapter, LaunchdConfig
|
|
20
|
+
|
|
21
|
+
__all__ = ["LaunchdAdapter", "LaunchdConfig"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""launchd backend — the only file that touches launchctl and the filesystem.
|
|
2
|
+
|
|
3
|
+
Isolated here (the CLI-adapter convention used by git/radicle/process) so
|
|
4
|
+
adapter.py contains no subprocess or file I/O in its capability bodies and stays
|
|
5
|
+
conformance-clean. The adapter depends only on the LaunchdBackend protocol.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import plistlib
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
_LAUNCHCTL = "/bin/launchctl"
|
|
17
|
+
_TIMEOUT = 30.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class LaunchdBackend(Protocol):
|
|
22
|
+
def list_services(self, prefix: str) -> list[dict]: ...
|
|
23
|
+
def status(self, label: str) -> dict: ...
|
|
24
|
+
def start(self, label: str, plist_path: str | None) -> dict: ...
|
|
25
|
+
def stop(self, label: str) -> dict: ...
|
|
26
|
+
def install(self, label: str, spec: dict) -> dict: ...
|
|
27
|
+
def uninstall(self, label: str) -> dict: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _domain() -> str:
|
|
31
|
+
return f"gui/{os.getuid()}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _agents_dir() -> Path:
|
|
35
|
+
return Path.home() / "Library" / "LaunchAgents"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _plist_path(label: str) -> Path:
|
|
39
|
+
return _agents_dir() / f"{label}.plist"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run(args: list[str]) -> subprocess.CompletedProcess:
|
|
43
|
+
return subprocess.run(args, capture_output=True, text=True, timeout=_TIMEOUT)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _RealLaunchdBackend:
|
|
47
|
+
"""Wraps launchctl + plistlib for managing user LaunchAgents."""
|
|
48
|
+
|
|
49
|
+
def _list_raw(self) -> list[dict]:
|
|
50
|
+
proc = _run([_LAUNCHCTL, "list"])
|
|
51
|
+
services: list[dict] = []
|
|
52
|
+
for line in proc.stdout.splitlines()[1:]: # skip header "PID Status Label"
|
|
53
|
+
parts = line.split("\t")
|
|
54
|
+
if len(parts) != 3:
|
|
55
|
+
continue
|
|
56
|
+
pid_s, status_s, label = parts
|
|
57
|
+
pid = int(pid_s) if pid_s.lstrip("-").isdigit() and pid_s != "-" else None
|
|
58
|
+
try:
|
|
59
|
+
last_exit = int(status_s)
|
|
60
|
+
except ValueError:
|
|
61
|
+
last_exit = None
|
|
62
|
+
services.append({
|
|
63
|
+
"label": label,
|
|
64
|
+
"pid": pid,
|
|
65
|
+
"running": pid is not None,
|
|
66
|
+
"last_exit_code": last_exit,
|
|
67
|
+
})
|
|
68
|
+
return services
|
|
69
|
+
|
|
70
|
+
def list_services(self, prefix: str) -> list[dict]:
|
|
71
|
+
return [s for s in self._list_raw() if s["label"].startswith(prefix)]
|
|
72
|
+
|
|
73
|
+
def status(self, label: str) -> dict:
|
|
74
|
+
match = next((s for s in self._list_raw() if s["label"] == label), None)
|
|
75
|
+
plist_exists = _plist_path(label).exists()
|
|
76
|
+
if match is None:
|
|
77
|
+
return {"label": label, "loaded": False, "running": False, "pid": None,
|
|
78
|
+
"last_exit_code": None, "plist_exists": plist_exists}
|
|
79
|
+
return {"label": label, "loaded": True, "running": match["running"],
|
|
80
|
+
"pid": match["pid"], "last_exit_code": match["last_exit_code"],
|
|
81
|
+
"plist_exists": plist_exists}
|
|
82
|
+
|
|
83
|
+
def start(self, label: str, plist_path: str | None) -> dict:
|
|
84
|
+
loaded = any(s["label"] == label for s in self._list_raw())
|
|
85
|
+
if loaded:
|
|
86
|
+
proc = _run([_LAUNCHCTL, "kickstart", "-k", f"{_domain()}/{label}"])
|
|
87
|
+
action = "kickstart"
|
|
88
|
+
else:
|
|
89
|
+
path = plist_path or str(_plist_path(label))
|
|
90
|
+
if not Path(path).exists():
|
|
91
|
+
raise FileNotFoundError(f"No plist for {label!r} at {path}; install it first.")
|
|
92
|
+
proc = _run([_LAUNCHCTL, "bootstrap", _domain(), path])
|
|
93
|
+
action = "bootstrap"
|
|
94
|
+
ok = proc.returncode == 0
|
|
95
|
+
return {"label": label, "action": action, "ok": ok,
|
|
96
|
+
"returncode": proc.returncode, "stderr": proc.stderr.strip()[:300]}
|
|
97
|
+
|
|
98
|
+
def stop(self, label: str) -> dict:
|
|
99
|
+
proc = _run([_LAUNCHCTL, "bootout", f"{_domain()}/{label}"])
|
|
100
|
+
return {"label": label, "action": "bootout", "ok": proc.returncode == 0,
|
|
101
|
+
"returncode": proc.returncode, "stderr": proc.stderr.strip()[:300]}
|
|
102
|
+
|
|
103
|
+
def install(self, label: str, spec: dict) -> dict:
|
|
104
|
+
program: str = spec["program"]
|
|
105
|
+
args: list[str] = spec.get("args") or []
|
|
106
|
+
plist: dict[str, Any] = {
|
|
107
|
+
"Label": label,
|
|
108
|
+
"ProgramArguments": [program, *args],
|
|
109
|
+
"RunAtLoad": bool(spec.get("run_at_load", True)),
|
|
110
|
+
"KeepAlive": bool(spec.get("keep_alive", True)),
|
|
111
|
+
}
|
|
112
|
+
if spec.get("env"):
|
|
113
|
+
plist["EnvironmentVariables"] = dict(spec["env"])
|
|
114
|
+
if spec.get("working_dir"):
|
|
115
|
+
plist["WorkingDirectory"] = spec["working_dir"]
|
|
116
|
+
if spec.get("stdout_path"):
|
|
117
|
+
plist["StandardOutPath"] = spec["stdout_path"]
|
|
118
|
+
if spec.get("stderr_path"):
|
|
119
|
+
plist["StandardErrorPath"] = spec["stderr_path"]
|
|
120
|
+
|
|
121
|
+
_agents_dir().mkdir(parents=True, exist_ok=True)
|
|
122
|
+
path = _plist_path(label)
|
|
123
|
+
with open(path, "wb") as fh:
|
|
124
|
+
plistlib.dump(plist, fh)
|
|
125
|
+
|
|
126
|
+
# Re-bootstrap: bootout first if already loaded (ignore failure), then bootstrap.
|
|
127
|
+
_run([_LAUNCHCTL, "bootout", f"{_domain()}/{label}"])
|
|
128
|
+
proc = _run([_LAUNCHCTL, "bootstrap", _domain(), str(path)])
|
|
129
|
+
return {"label": label, "plist_path": str(path), "ok": proc.returncode == 0,
|
|
130
|
+
"returncode": proc.returncode, "stderr": proc.stderr.strip()[:300],
|
|
131
|
+
"env_keys": sorted((spec.get("env") or {}).keys())}
|
|
132
|
+
|
|
133
|
+
def uninstall(self, label: str) -> dict:
|
|
134
|
+
boot = _run([_LAUNCHCTL, "bootout", f"{_domain()}/{label}"])
|
|
135
|
+
path = _plist_path(label)
|
|
136
|
+
removed = False
|
|
137
|
+
if path.exists():
|
|
138
|
+
path.unlink()
|
|
139
|
+
removed = True
|
|
140
|
+
return {"label": label, "booted_out": boot.returncode == 0,
|
|
141
|
+
"plist_removed": removed, "plist_path": str(path)}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def make_backend() -> _RealLaunchdBackend:
|
|
145
|
+
return _RealLaunchdBackend()
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""LaunchdAdapter — govern macOS launchd (LaunchAgent) services as CHP capabilities.
|
|
2
|
+
|
|
3
|
+
Manage long-running local services — start, stop, status, and install/uninstall
|
|
4
|
+
persistent LaunchAgents — with evidence. Built for CHP's own infrastructure
|
|
5
|
+
services (e.g. the TEI and vLLM Metal servers) so they survive reboot and can be
|
|
6
|
+
governed through the capability host.
|
|
7
|
+
|
|
8
|
+
All launchctl + plist I/O is isolated in _backends.py (the CLI-adapter convention
|
|
9
|
+
used by git/radicle/process); the adapter delegates and emits.
|
|
10
|
+
|
|
11
|
+
Evidence policy:
|
|
12
|
+
Emitted: label, operation, pid, returncode, plist path, env *keys*, latency.
|
|
13
|
+
NOT emitted: environment variable VALUES (may hold tokens), plist file contents.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from chp_core import BaseAdapter, capability
|
|
24
|
+
|
|
25
|
+
from ._backends import LaunchdBackend, make_backend
|
|
26
|
+
|
|
27
|
+
_EMITS = [
|
|
28
|
+
"launchd_listed",
|
|
29
|
+
"launchd_status_checked",
|
|
30
|
+
"launchd_start_started",
|
|
31
|
+
"launchd_start_completed",
|
|
32
|
+
"launchd_start_failed",
|
|
33
|
+
"launchd_stop_started",
|
|
34
|
+
"launchd_stop_completed",
|
|
35
|
+
"launchd_stop_failed",
|
|
36
|
+
"launchd_install_started",
|
|
37
|
+
"launchd_install_completed",
|
|
38
|
+
"launchd_install_failed",
|
|
39
|
+
"launchd_uninstall_completed",
|
|
40
|
+
"service_health_checked",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_HTTP_CAP = "chp.adapters.http.request"
|
|
44
|
+
|
|
45
|
+
# Default model-server readiness probes; overridable in LaunchdConfig.
|
|
46
|
+
_DEFAULT_MODEL_PROBES = [
|
|
47
|
+
{"name": "tei", "url": "http://localhost:8090/health", "expect_status": 200},
|
|
48
|
+
{"name": "vllm", "url": "http://localhost:8092/v1/models", "expect_status": 200},
|
|
49
|
+
{"name": "scout", "url": "http://localhost:8094/health", "expect_status": 200},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
_DEFAULT_PREFIX = "com.chp."
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class LaunchdConfig:
|
|
57
|
+
"""Config for LaunchdAdapter.
|
|
58
|
+
|
|
59
|
+
``managed_prefix`` — only labels with this prefix may be managed (safety: the
|
|
60
|
+
adapter will not touch arbitrary system services). ``list`` is also scoped to it.
|
|
61
|
+
``model_probes`` — list of {name, url, expect_status} dicts for service_health
|
|
62
|
+
readiness checks (defaults to TEI + vllm at their standard ports).
|
|
63
|
+
"""
|
|
64
|
+
managed_prefix: str = _DEFAULT_PREFIX
|
|
65
|
+
model_probes: list[dict] = field(default_factory=lambda: list(_DEFAULT_MODEL_PROBES))
|
|
66
|
+
_backend: Any = field(default=None, repr=False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LaunchdAdapter(BaseAdapter):
|
|
70
|
+
"""Manage macOS LaunchAgent services as governed CHP capabilities."""
|
|
71
|
+
|
|
72
|
+
adapter_id = "chp.adapters.launchd"
|
|
73
|
+
adapter_name = "Launchd"
|
|
74
|
+
adapter_description = (
|
|
75
|
+
"Govern macOS launchd LaunchAgent services: list, status, start, stop, "
|
|
76
|
+
"install (generate plist + bootstrap), uninstall."
|
|
77
|
+
)
|
|
78
|
+
adapter_category = "infrastructure"
|
|
79
|
+
adapter_tags = ["launchd", "launchctl", "service", "macos", "daemon", "infrastructure"]
|
|
80
|
+
|
|
81
|
+
def __init__(self, config: LaunchdConfig | None = None) -> None:
|
|
82
|
+
self._config = config or LaunchdConfig()
|
|
83
|
+
self.__backend: LaunchdBackend | None = None
|
|
84
|
+
|
|
85
|
+
def _backend(self) -> LaunchdBackend:
|
|
86
|
+
if self._config._backend is not None:
|
|
87
|
+
return self._config._backend
|
|
88
|
+
if self.__backend is None:
|
|
89
|
+
self.__backend = make_backend()
|
|
90
|
+
return self.__backend
|
|
91
|
+
|
|
92
|
+
def _check_managed(self, label: str) -> None:
|
|
93
|
+
if not label.startswith(self._config.managed_prefix):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Label {label!r} is not managed by this adapter "
|
|
96
|
+
f"(must start with {self._config.managed_prefix!r})."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# list
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
@capability(
|
|
104
|
+
id="chp.adapters.launchd.list",
|
|
105
|
+
version="1.0.0",
|
|
106
|
+
description="List CHP-managed launchd services (scoped to the managed label prefix).",
|
|
107
|
+
category="infrastructure",
|
|
108
|
+
provider="launchd",
|
|
109
|
+
risk="low",
|
|
110
|
+
emits=_EMITS,
|
|
111
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
112
|
+
)
|
|
113
|
+
async def list(self, ctx: Any, payload: dict) -> dict:
|
|
114
|
+
t0 = time.monotonic()
|
|
115
|
+
services = await asyncio.to_thread(self._backend().list_services, self._config.managed_prefix)
|
|
116
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
117
|
+
ctx.emit("launchd_listed", {"service_count": len(services), "latency_ms": latency_ms}, redacted=False)
|
|
118
|
+
return {"services": services, "service_count": len(services), "latency_ms": latency_ms}
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# status
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@capability(
|
|
125
|
+
id="chp.adapters.launchd.status",
|
|
126
|
+
version="1.0.0",
|
|
127
|
+
description="Report whether a launchd service is loaded/running, its pid, and last exit code.",
|
|
128
|
+
category="infrastructure",
|
|
129
|
+
provider="launchd",
|
|
130
|
+
risk="low",
|
|
131
|
+
emits=_EMITS,
|
|
132
|
+
input_schema={
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {"label": {"type": "string", "description": "Service label, e.g. com.chp.tei"}},
|
|
135
|
+
"required": ["label"],
|
|
136
|
+
"additionalProperties": False,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
async def status(self, ctx: Any, payload: dict) -> dict:
|
|
140
|
+
label = payload["label"]
|
|
141
|
+
self._check_managed(label)
|
|
142
|
+
t0 = time.monotonic()
|
|
143
|
+
result = await asyncio.to_thread(self._backend().status, label)
|
|
144
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
145
|
+
ctx.emit("launchd_status_checked", {
|
|
146
|
+
"label": label, "loaded": result.get("loaded"), "running": result.get("running"),
|
|
147
|
+
"pid": result.get("pid"), "latency_ms": latency_ms,
|
|
148
|
+
}, redacted=False)
|
|
149
|
+
return {**result, "latency_ms": latency_ms}
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# start
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
@capability(
|
|
156
|
+
id="chp.adapters.launchd.start",
|
|
157
|
+
version="1.0.0",
|
|
158
|
+
description="Start (bootstrap) a service if not loaded, or restart it (kickstart -k) if already loaded.",
|
|
159
|
+
category="infrastructure",
|
|
160
|
+
provider="launchd",
|
|
161
|
+
risk="medium",
|
|
162
|
+
side_effects=["process_start"],
|
|
163
|
+
emits=_EMITS,
|
|
164
|
+
input_schema={
|
|
165
|
+
"type": "object",
|
|
166
|
+
"properties": {
|
|
167
|
+
"label": {"type": "string"},
|
|
168
|
+
"plist_path": {"type": "string", "description": "Plist path (defaults to ~/Library/LaunchAgents/<label>.plist)"},
|
|
169
|
+
},
|
|
170
|
+
"required": ["label"],
|
|
171
|
+
"additionalProperties": False,
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
async def start(self, ctx: Any, payload: dict) -> dict:
|
|
175
|
+
label = payload["label"]
|
|
176
|
+
self._check_managed(label)
|
|
177
|
+
plist_path = payload.get("plist_path")
|
|
178
|
+
ctx.emit("launchd_start_started", {"label": label}, redacted=False)
|
|
179
|
+
t0 = time.monotonic()
|
|
180
|
+
try:
|
|
181
|
+
result = await asyncio.to_thread(self._backend().start, label, plist_path)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
ctx.emit("launchd_start_failed", {"label": label, "error": str(exc)[:300]}, redacted=False)
|
|
184
|
+
raise
|
|
185
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
186
|
+
ctx.emit("launchd_start_completed", {
|
|
187
|
+
"label": label, "action": result.get("action"), "ok": result.get("ok"),
|
|
188
|
+
"returncode": result.get("returncode"), "latency_ms": latency_ms,
|
|
189
|
+
}, redacted=False)
|
|
190
|
+
return {**result, "latency_ms": latency_ms}
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# stop
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
@capability(
|
|
197
|
+
id="chp.adapters.launchd.stop",
|
|
198
|
+
version="1.0.0",
|
|
199
|
+
description="Stop (bootout) a loaded launchd service.",
|
|
200
|
+
category="infrastructure",
|
|
201
|
+
provider="launchd",
|
|
202
|
+
risk="medium",
|
|
203
|
+
side_effects=["process_stop"],
|
|
204
|
+
emits=_EMITS,
|
|
205
|
+
input_schema={
|
|
206
|
+
"type": "object",
|
|
207
|
+
"properties": {"label": {"type": "string"}},
|
|
208
|
+
"required": ["label"],
|
|
209
|
+
"additionalProperties": False,
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
async def stop(self, ctx: Any, payload: dict) -> dict:
|
|
213
|
+
label = payload["label"]
|
|
214
|
+
self._check_managed(label)
|
|
215
|
+
ctx.emit("launchd_stop_started", {"label": label}, redacted=False)
|
|
216
|
+
t0 = time.monotonic()
|
|
217
|
+
try:
|
|
218
|
+
result = await asyncio.to_thread(self._backend().stop, label)
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
ctx.emit("launchd_stop_failed", {"label": label, "error": str(exc)[:300]}, redacted=False)
|
|
221
|
+
raise
|
|
222
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
223
|
+
ctx.emit("launchd_stop_completed", {
|
|
224
|
+
"label": label, "ok": result.get("ok"), "returncode": result.get("returncode"),
|
|
225
|
+
"latency_ms": latency_ms,
|
|
226
|
+
}, redacted=False)
|
|
227
|
+
return {**result, "latency_ms": latency_ms}
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# install
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
@capability(
|
|
234
|
+
id="chp.adapters.launchd.install",
|
|
235
|
+
version="1.0.0",
|
|
236
|
+
description=(
|
|
237
|
+
"Generate a LaunchAgent plist from a service spec, write it to "
|
|
238
|
+
"~/Library/LaunchAgents, and bootstrap it. Environment values are written "
|
|
239
|
+
"to the plist but never recorded in evidence (only env keys)."
|
|
240
|
+
),
|
|
241
|
+
category="infrastructure",
|
|
242
|
+
provider="launchd",
|
|
243
|
+
risk="high",
|
|
244
|
+
side_effects=["file_write", "process_start"],
|
|
245
|
+
emits=_EMITS,
|
|
246
|
+
input_schema={
|
|
247
|
+
"type": "object",
|
|
248
|
+
"properties": {
|
|
249
|
+
"label": {"type": "string", "description": "Service label (must match the managed prefix)"},
|
|
250
|
+
"program": {"type": "string", "description": "Absolute path to the executable"},
|
|
251
|
+
"args": {"type": "array", "items": {"type": "string"}, "description": "Program arguments"},
|
|
252
|
+
"env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Environment variables (values not evidenced)"},
|
|
253
|
+
"working_dir": {"type": "string"},
|
|
254
|
+
"stdout_path": {"type": "string"},
|
|
255
|
+
"stderr_path": {"type": "string"},
|
|
256
|
+
"run_at_load": {"type": "boolean", "default": True},
|
|
257
|
+
"keep_alive": {"type": "boolean", "default": True},
|
|
258
|
+
},
|
|
259
|
+
"required": ["label", "program"],
|
|
260
|
+
"additionalProperties": False,
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
async def install(self, ctx: Any, payload: dict) -> dict:
|
|
264
|
+
label = payload["label"]
|
|
265
|
+
self._check_managed(label)
|
|
266
|
+
spec = {k: payload[k] for k in payload if k != "label"}
|
|
267
|
+
env_keys = sorted((payload.get("env") or {}).keys())
|
|
268
|
+
ctx.emit("launchd_install_started", {
|
|
269
|
+
"label": label, "program": payload["program"], "env_keys": env_keys,
|
|
270
|
+
}, redacted=False)
|
|
271
|
+
t0 = time.monotonic()
|
|
272
|
+
try:
|
|
273
|
+
result = await asyncio.to_thread(self._backend().install, label, spec)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
ctx.emit("launchd_install_failed", {"label": label, "error": str(exc)[:300]}, redacted=False)
|
|
276
|
+
raise
|
|
277
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
278
|
+
ctx.emit("launchd_install_completed", {
|
|
279
|
+
"label": label, "plist_path": result.get("plist_path"), "ok": result.get("ok"),
|
|
280
|
+
"env_keys": result.get("env_keys"), "latency_ms": latency_ms,
|
|
281
|
+
}, redacted=False)
|
|
282
|
+
return {**result, "latency_ms": latency_ms}
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# uninstall
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
@capability(
|
|
289
|
+
id="chp.adapters.launchd.uninstall",
|
|
290
|
+
version="1.0.0",
|
|
291
|
+
description="Bootout a service and remove its LaunchAgent plist.",
|
|
292
|
+
category="infrastructure",
|
|
293
|
+
provider="launchd",
|
|
294
|
+
risk="high",
|
|
295
|
+
side_effects=["file_delete", "process_stop"],
|
|
296
|
+
emits=_EMITS,
|
|
297
|
+
input_schema={
|
|
298
|
+
"type": "object",
|
|
299
|
+
"properties": {"label": {"type": "string"}},
|
|
300
|
+
"required": ["label"],
|
|
301
|
+
"additionalProperties": False,
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
async def uninstall(self, ctx: Any, payload: dict) -> dict:
|
|
305
|
+
label = payload["label"]
|
|
306
|
+
self._check_managed(label)
|
|
307
|
+
t0 = time.monotonic()
|
|
308
|
+
result = await asyncio.to_thread(self._backend().uninstall, label)
|
|
309
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
310
|
+
ctx.emit("launchd_uninstall_completed", {
|
|
311
|
+
"label": label, "booted_out": result.get("booted_out"),
|
|
312
|
+
"plist_removed": result.get("plist_removed"), "latency_ms": latency_ms,
|
|
313
|
+
}, redacted=False)
|
|
314
|
+
return {**result, "latency_ms": latency_ms}
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# service_health
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
@capability(
|
|
321
|
+
id="chp.adapters.launchd.service_health",
|
|
322
|
+
version="1.0.0",
|
|
323
|
+
description=(
|
|
324
|
+
"Report combined health: all com.chp.* launchd services (running/stopped) "
|
|
325
|
+
"and model-server readiness probes (TEI, vllm) via the http transport."
|
|
326
|
+
),
|
|
327
|
+
category="infrastructure",
|
|
328
|
+
provider="launchd",
|
|
329
|
+
risk="low",
|
|
330
|
+
emits=_EMITS,
|
|
331
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
332
|
+
)
|
|
333
|
+
async def service_health(self, ctx: Any, payload: dict) -> dict:
|
|
334
|
+
t0 = time.monotonic()
|
|
335
|
+
|
|
336
|
+
# 1. Launchd service states
|
|
337
|
+
services = await asyncio.to_thread(self._backend().list_services, self._config.managed_prefix)
|
|
338
|
+
running_count = sum(1 for s in services if s.get("running"))
|
|
339
|
+
|
|
340
|
+
# 2. Model-server readiness probes (fast, parallel, via http transport)
|
|
341
|
+
model_results: list[dict] = []
|
|
342
|
+
probe_tasks = [
|
|
343
|
+
self._probe_model_server(ctx, p)
|
|
344
|
+
for p in self._config.model_probes
|
|
345
|
+
]
|
|
346
|
+
for coro in asyncio.as_completed(probe_tasks):
|
|
347
|
+
model_results.append(await coro)
|
|
348
|
+
|
|
349
|
+
overall_ok = (
|
|
350
|
+
all(s.get("running") for s in services if s.get("label", "").endswith((".tei", ".vllm", ".mac")))
|
|
351
|
+
and all(r["reachable"] for r in model_results)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
355
|
+
ctx.emit("service_health_checked", {
|
|
356
|
+
"service_count": len(services),
|
|
357
|
+
"running_count": running_count,
|
|
358
|
+
"model_probe_count": len(model_results),
|
|
359
|
+
"ok": overall_ok,
|
|
360
|
+
"latency_ms": latency_ms,
|
|
361
|
+
}, redacted=False)
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
"ok": overall_ok,
|
|
365
|
+
"services": services,
|
|
366
|
+
"model_servers": sorted(model_results, key=lambda r: r["name"]),
|
|
367
|
+
"latency_ms": latency_ms,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async def _probe_model_server(self, ctx: Any, probe: dict) -> dict:
|
|
371
|
+
"""Probe one model server endpoint via the http transport (non-fatal on failure)."""
|
|
372
|
+
name = probe["name"]
|
|
373
|
+
url = probe["url"]
|
|
374
|
+
expect = probe.get("expect_status", 200)
|
|
375
|
+
t0 = time.monotonic()
|
|
376
|
+
try:
|
|
377
|
+
result = await ctx.ainvoke(_HTTP_CAP, {"method": "GET", "url": url, "timeout": 3.0})
|
|
378
|
+
if getattr(result, "success", False):
|
|
379
|
+
status = result.data.get("status_code")
|
|
380
|
+
reachable = status == expect
|
|
381
|
+
else:
|
|
382
|
+
reachable = False
|
|
383
|
+
status = None
|
|
384
|
+
except Exception:
|
|
385
|
+
reachable = False
|
|
386
|
+
status = None
|
|
387
|
+
return {
|
|
388
|
+
"name": name,
|
|
389
|
+
"url": url,
|
|
390
|
+
"reachable": reachable,
|
|
391
|
+
"status_code": status,
|
|
392
|
+
"latency_ms": round((time.monotonic() - t0) * 1000),
|
|
393
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-launchd
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: CHP capability adapter — govern macOS launchd (LaunchAgent) services
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,capability-host-protocol,chp,launchctl,launchd,macos,service
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: chp-core>=0.7.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# chp-adapter-launchd
|
|
25
|
+
|
|
26
|
+
Govern macOS **launchd** LaunchAgent services as CHP capabilities. Built so CHP
|
|
27
|
+
can manage its own long-running infrastructure (e.g. the TEI and vLLM Metal
|
|
28
|
+
servers) — making them persistent across reboot and controllable through the
|
|
29
|
+
capability host with a full evidence trail.
|
|
30
|
+
|
|
31
|
+
## Capabilities
|
|
32
|
+
|
|
33
|
+
| Capability | Description |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `chp.adapters.launchd.list` | List CHP-managed services (scoped to the managed prefix). |
|
|
36
|
+
| `chp.adapters.launchd.status` | Loaded/running state, pid, last exit code, plist presence. |
|
|
37
|
+
| `chp.adapters.launchd.start` | Bootstrap if unloaded, else `kickstart -k` (restart). |
|
|
38
|
+
| `chp.adapters.launchd.stop` | Bootout a loaded service. |
|
|
39
|
+
| `chp.adapters.launchd.install` | Generate a plist from a spec, write it, and bootstrap. |
|
|
40
|
+
| `chp.adapters.launchd.uninstall` | Bootout and remove the plist. |
|
|
41
|
+
|
|
42
|
+
## Safety
|
|
43
|
+
|
|
44
|
+
The adapter only manages labels matching `LaunchdConfig.managed_prefix` (default
|
|
45
|
+
`com.chp.`) — it will refuse to touch arbitrary system services. `launchctl` and
|
|
46
|
+
plist I/O are isolated in `_backends.py`.
|
|
47
|
+
|
|
48
|
+
## Install a service (example: the TEI server)
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"label": "com.chp.tei",
|
|
53
|
+
"program": "/opt/homebrew/bin/text-embeddings-router",
|
|
54
|
+
"args": ["--model-id", "sentence-transformers/all-MiniLM-L6-v2", "--port", "8090"],
|
|
55
|
+
"stdout_path": "/Users/me/.chp/logs/tei.log",
|
|
56
|
+
"stderr_path": "/Users/me/.chp/logs/tei.err",
|
|
57
|
+
"keep_alive": true,
|
|
58
|
+
"run_at_load": true
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Evidence policy
|
|
63
|
+
|
|
64
|
+
Emitted: label, operation, pid, returncode, plist path, environment **keys**, latency.
|
|
65
|
+
Never emitted: environment variable **values** (may hold tokens) or plist file contents.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
chp_adapter_launchd/__init__.py,sha256=LjDXIa3JHYcPTL2l4lmzUGbrvrDj7O9zFA7mr-VdIyc,758
|
|
2
|
+
chp_adapter_launchd/_backends.py,sha256=MYtpmV1xjh3OC13wduURIF66nyZBwpAusIx6D6pdseo,5708
|
|
3
|
+
chp_adapter_launchd/adapter.py,sha256=SA-jAm-yzJogUCSxum85h5-MnfXJQb3qZd4uz99vGvg,15955
|
|
4
|
+
chp_adapter_launchd-0.10.0.dist-info/METADATA,sha256=V1wZVMgsOrvUbYQcUMSgvRN8_OnlBnli6T58V7fJGUo,2553
|
|
5
|
+
chp_adapter_launchd-0.10.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
chp_adapter_launchd-0.10.0.dist-info/entry_points.txt,sha256=FeNq-AHCOumE5FsVGhg7929YS2vOed-35UHwOOk3-2g,60
|
|
7
|
+
chp_adapter_launchd-0.10.0.dist-info/RECORD,,
|