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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [chp.adapters]
2
+ launchd = chp_adapter_launchd:LaunchdAdapter