chp-adapter-launchd 0.11.0__tar.gz

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,36 @@
1
+ node_modules/
2
+ dist/
3
+ .next/
4
+ out/
5
+ .turbo/
6
+ *.tsbuildinfo
7
+ .yalc/
8
+ yalc.lock
9
+ *.tgz
10
+ .env
11
+ .env.*
12
+ coverage/
13
+ dashboard/
14
+ components/
15
+ # ...but the cockpit's app source lives in app/components — never ignore real source.
16
+ !cockpit/app/components/
17
+ !cockpit/app/components/**
18
+ !chp-evidence/app/components/
19
+ !chp-evidence/app/components/**
20
+ .tech-hub-cache/
21
+ __pycache__/
22
+ *.py[cod]
23
+ .pytest_cache/
24
+ .chp/
25
+ .DS_Store
26
+ .coverage
27
+ .forge/
28
+ .hypothesis/
29
+ .claude/
30
+
31
+ # chp-agent evidence store
32
+ .chp-agent/sessions.sqlite
33
+ .publish-dist/
34
+
35
+ # matrix-bot runtime session registry (CC session ids)
36
+ matrix-bot/sessions.json
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: chp-adapter-launchd
3
+ Version: 0.11.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,42 @@
1
+ # chp-adapter-launchd
2
+
3
+ Govern macOS **launchd** LaunchAgent services as CHP capabilities. Built so CHP
4
+ can manage its own long-running infrastructure (e.g. the TEI and vLLM Metal
5
+ servers) — making them persistent across reboot and controllable through the
6
+ capability host with a full evidence trail.
7
+
8
+ ## Capabilities
9
+
10
+ | Capability | Description |
11
+ |---|---|
12
+ | `chp.adapters.launchd.list` | List CHP-managed services (scoped to the managed prefix). |
13
+ | `chp.adapters.launchd.status` | Loaded/running state, pid, last exit code, plist presence. |
14
+ | `chp.adapters.launchd.start` | Bootstrap if unloaded, else `kickstart -k` (restart). |
15
+ | `chp.adapters.launchd.stop` | Bootout a loaded service. |
16
+ | `chp.adapters.launchd.install` | Generate a plist from a spec, write it, and bootstrap. |
17
+ | `chp.adapters.launchd.uninstall` | Bootout and remove the plist. |
18
+
19
+ ## Safety
20
+
21
+ The adapter only manages labels matching `LaunchdConfig.managed_prefix` (default
22
+ `com.chp.`) — it will refuse to touch arbitrary system services. `launchctl` and
23
+ plist I/O are isolated in `_backends.py`.
24
+
25
+ ## Install a service (example: the TEI server)
26
+
27
+ ```json
28
+ {
29
+ "label": "com.chp.tei",
30
+ "program": "/opt/homebrew/bin/text-embeddings-router",
31
+ "args": ["--model-id", "sentence-transformers/all-MiniLM-L6-v2", "--port", "8090"],
32
+ "stdout_path": "/Users/me/.chp/logs/tei.log",
33
+ "stderr_path": "/Users/me/.chp/logs/tei.err",
34
+ "keep_alive": true,
35
+ "run_at_load": true
36
+ }
37
+ ```
38
+
39
+ ## Evidence policy
40
+
41
+ Emitted: label, operation, pid, returncode, plist path, environment **keys**, latency.
42
+ Never emitted: environment variable **values** (may hold tokens) or plist file contents.
@@ -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,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chp-adapter-launchd"
7
+ version = "0.11.0"
8
+ description = "CHP capability adapter — govern macOS launchd (LaunchAgent) services"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Auxo" }]
13
+ keywords = ["chp", "capability-host-protocol", "launchd", "launchctl", "macos", "service", "adapter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Operating System :: MacOS",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ ]
25
+ dependencies = [
26
+ "chp-core>=0.7.0",
27
+ ]
28
+
29
+ [project.entry-points."chp.adapters"]
30
+ launchd = "chp_adapter_launchd:LaunchdAdapter"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ pythonpath = ["."]
38
+ asyncio_mode = "strict"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["chp_adapter_launchd"]
@@ -0,0 +1,193 @@
1
+ """Tests for chp-adapter-launchd using a fake backend — no launchctl calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from chp_adapter_http import HttpAdapter, HttpConfig
11
+ from chp_adapter_launchd import LaunchdAdapter, LaunchdConfig
12
+ from chp_core import LocalCapabilityHost, register_adapter
13
+ from chp_core.store import SQLiteEvidenceStore
14
+
15
+
16
+ class FakeLaunchdBackend:
17
+ def __init__(self) -> None:
18
+ self.installed: dict[str, dict] = {}
19
+
20
+ def list_services(self, prefix: str) -> list[dict]:
21
+ return [
22
+ {"label": "com.chp.tei", "pid": 4242, "running": True, "last_exit_code": 0},
23
+ {"label": "com.chp.vllm", "pid": None, "running": False, "last_exit_code": 1},
24
+ ]
25
+
26
+ def status(self, label: str) -> dict:
27
+ return {"label": label, "loaded": True, "running": True, "pid": 4242,
28
+ "last_exit_code": 0, "plist_exists": True}
29
+
30
+ def start(self, label: str, plist_path: str | None) -> dict:
31
+ return {"label": label, "action": "bootstrap", "ok": True, "returncode": 0, "stderr": ""}
32
+
33
+ def stop(self, label: str) -> dict:
34
+ return {"label": label, "action": "bootout", "ok": True, "returncode": 0, "stderr": ""}
35
+
36
+ def install(self, label: str, spec: dict) -> dict:
37
+ self.installed[label] = spec
38
+ return {"label": label, "plist_path": f"/fake/LaunchAgents/{label}.plist", "ok": True,
39
+ "returncode": 0, "stderr": "", "env_keys": sorted((spec.get("env") or {}).keys())}
40
+
41
+ def uninstall(self, label: str) -> dict:
42
+ return {"label": label, "booted_out": True, "plist_removed": True,
43
+ "plist_path": f"/fake/LaunchAgents/{label}.plist"}
44
+
45
+
46
+ def _make_host(
47
+ fake: FakeLaunchdBackend | None = None,
48
+ transport: Any = None,
49
+ model_probes: list[dict] | None = None,
50
+ ) -> LocalCapabilityHost:
51
+ store = SQLiteEvidenceStore(":memory:")
52
+ host = LocalCapabilityHost(store=store)
53
+ cfg = LaunchdConfig(_backend=fake or FakeLaunchdBackend())
54
+ if model_probes is not None:
55
+ cfg.model_probes = model_probes
56
+ if transport is not None:
57
+ register_adapter(host, HttpAdapter(HttpConfig(transport=transport, max_retries=0, backoff_base=0.0)))
58
+ register_adapter(host, LaunchdAdapter(cfg))
59
+ return host
60
+
61
+
62
+ def _invoke(host: LocalCapabilityHost, cap_id: str, payload: dict | None = None):
63
+ return asyncio.get_event_loop().run_until_complete(host.ainvoke(cap_id, payload or {}))
64
+
65
+
66
+ class TestList:
67
+ def test_returns_services(self):
68
+ result = _invoke(_make_host(), "chp.adapters.launchd.list", {})
69
+ assert result.success
70
+ assert result.data["service_count"] == 2
71
+ assert result.data["services"][0]["label"] == "com.chp.tei"
72
+
73
+
74
+ class TestStatus:
75
+ def test_returns_status(self):
76
+ result = _invoke(_make_host(), "chp.adapters.launchd.status", {"label": "com.chp.tei"})
77
+ assert result.success
78
+ assert result.data["running"] is True
79
+ assert result.data["pid"] == 4242
80
+
81
+ def test_unmanaged_label_rejected(self):
82
+ result = _invoke(_make_host(), "chp.adapters.launchd.status", {"label": "com.apple.something"})
83
+ assert not result.success
84
+
85
+
86
+ class TestStartStop:
87
+ def test_start(self):
88
+ result = _invoke(_make_host(), "chp.adapters.launchd.start", {"label": "com.chp.tei"})
89
+ assert result.success
90
+ assert result.data["ok"] is True
91
+
92
+ def test_stop(self):
93
+ result = _invoke(_make_host(), "chp.adapters.launchd.stop", {"label": "com.chp.vllm"})
94
+ assert result.success
95
+ assert result.data["action"] == "bootout"
96
+
97
+ def test_start_unmanaged_rejected(self):
98
+ result = _invoke(_make_host(), "chp.adapters.launchd.start", {"label": "org.other.svc"})
99
+ assert not result.success
100
+
101
+
102
+ class TestInstall:
103
+ def test_install_writes_spec(self):
104
+ fake = FakeLaunchdBackend()
105
+ result = _invoke(_make_host(fake), "chp.adapters.launchd.install", {
106
+ "label": "com.chp.tei",
107
+ "program": "/opt/homebrew/bin/text-embeddings-router",
108
+ "args": ["--model-id", "x", "--port", "8090"],
109
+ })
110
+ assert result.success
111
+ assert result.data["ok"] is True
112
+ assert "com.chp.tei" in fake.installed
113
+ assert fake.installed["com.chp.tei"]["program"].endswith("text-embeddings-router")
114
+
115
+ def test_env_values_not_in_evidence(self):
116
+ host = _make_host()
117
+ result = _invoke(host, "chp.adapters.launchd.install", {
118
+ "label": "com.chp.secretsvc",
119
+ "program": "/bin/echo",
120
+ "env": {"API_KEY": "SUPER_SECRET_VALUE_123"},
121
+ })
122
+ assert result.success
123
+ # env_keys present, value absent
124
+ assert result.data["env_keys"] == ["API_KEY"]
125
+ replay = host.replay(result.invocation_id)
126
+ for evt in replay:
127
+ blob = str(evt.get("payload", {}))
128
+ assert "SUPER_SECRET_VALUE_123" not in blob
129
+
130
+ def test_install_unmanaged_rejected(self):
131
+ result = _invoke(_make_host(), "chp.adapters.launchd.install", {
132
+ "label": "com.apple.evil", "program": "/bin/sh",
133
+ })
134
+ assert not result.success
135
+
136
+
137
+ class TestUninstall:
138
+ def test_uninstall(self):
139
+ result = _invoke(_make_host(), "chp.adapters.launchd.uninstall", {"label": "com.chp.tei"})
140
+ assert result.success
141
+ assert result.data["plist_removed"] is True
142
+
143
+
144
+ class TestServiceHealth:
145
+ def _mock_transport(self, tei_up: bool = True, vllm_up: bool = True) -> httpx.MockTransport:
146
+ def handler(request: httpx.Request) -> httpx.Response:
147
+ url = str(request.url)
148
+ if "8090" in url:
149
+ return httpx.Response(200 if tei_up else 503, json={"status": "ok"})
150
+ if "8092" in url:
151
+ return httpx.Response(200 if vllm_up else 503, json={"data": []})
152
+ return httpx.Response(404)
153
+ return httpx.MockTransport(handler)
154
+
155
+ def test_service_health_all_up(self):
156
+ probes = [
157
+ {"name": "tei", "url": "http://localhost:8090/health", "expect_status": 200},
158
+ {"name": "vllm", "url": "http://localhost:8092/v1/models", "expect_status": 200},
159
+ ]
160
+ host = _make_host(transport=self._mock_transport(True, True), model_probes=probes)
161
+ result = _invoke(host, "chp.adapters.launchd.service_health", {})
162
+ assert result.success
163
+ assert len(result.data["services"]) == 2
164
+ servers = {s["name"]: s for s in result.data["model_servers"]}
165
+ assert servers["tei"]["reachable"] is True
166
+ assert servers["vllm"]["reachable"] is True
167
+
168
+ def test_service_health_model_down(self):
169
+ probes = [
170
+ {"name": "tei", "url": "http://localhost:8090/health", "expect_status": 200},
171
+ ]
172
+ host = _make_host(transport=self._mock_transport(tei_up=False), model_probes=probes)
173
+ result = _invoke(host, "chp.adapters.launchd.service_health", {})
174
+ assert result.success
175
+ servers = {s["name"]: s for s in result.data["model_servers"]}
176
+ assert servers["tei"]["reachable"] is False
177
+ assert result.data["ok"] is False
178
+
179
+ def test_service_health_no_model_probes(self):
180
+ host = _make_host(model_probes=[])
181
+ result = _invoke(host, "chp.adapters.launchd.service_health", {})
182
+ assert result.success
183
+ assert result.data["model_servers"] == []
184
+
185
+
186
+ class TestConformance:
187
+ def test_adapter_has_no_violations(self):
188
+ from chp_adapter_conformance import check_source_file
189
+ import chp_adapter_launchd.adapter as mod
190
+ import inspect
191
+
192
+ violations = check_source_file(inspect.getfile(mod))
193
+ assert not violations, f"LaunchdAdapter has conformance violations: {violations}"