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.
- chp_adapter_launchd-0.11.0/.gitignore +36 -0
- chp_adapter_launchd-0.11.0/PKG-INFO +65 -0
- chp_adapter_launchd-0.11.0/README.md +42 -0
- chp_adapter_launchd-0.11.0/chp_adapter_launchd/__init__.py +21 -0
- chp_adapter_launchd-0.11.0/chp_adapter_launchd/_backends.py +145 -0
- chp_adapter_launchd-0.11.0/chp_adapter_launchd/adapter.py +393 -0
- chp_adapter_launchd-0.11.0/pyproject.toml +41 -0
- chp_adapter_launchd-0.11.0/tests/test_launchd_adapter.py +193 -0
|
@@ -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}"
|