chp-adapter-host 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,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: chp-adapter-host
3
+ Version: 0.11.0
4
+ Summary: CHP capability adapter — report and update the host runtime on a node
5
+ Author: Auxo
6
+ License: Apache-2.0
7
+ Keywords: adapter,capability-host-protocol,chp,host,ops,update
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: chp-core>=0.8.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # chp-adapter-host
20
+
21
+ Report and update the CHP runtime on a node, as governed capabilities.
22
+
23
+ - `chp.adapters.host.version` — this node's chp-host version, platform, and installed adapters.
24
+ - `chp.adapters.host.update` — schedule a detached `chp-host update --restart` (upgrade CHP packages, then restart the node's services). Returns immediately with `scheduled: true`; the host restarts shortly after, so re-check `/health` for the new `host_version`.
25
+
26
+ Both emit evidence. `update` is `risk: high` (remote code update + restart) and is gated by the host's API key like any other capability. The primary drives a worker's update over the mesh with `chp-host mesh update <url>`.
@@ -0,0 +1,8 @@
1
+ # chp-adapter-host
2
+
3
+ Report and update the CHP runtime on a node, as governed capabilities.
4
+
5
+ - `chp.adapters.host.version` — this node's chp-host version, platform, and installed adapters.
6
+ - `chp.adapters.host.update` — schedule a detached `chp-host update --restart` (upgrade CHP packages, then restart the node's services). Returns immediately with `scheduled: true`; the host restarts shortly after, so re-check `/health` for the new `host_version`.
7
+
8
+ Both emit evidence. `update` is `risk: high` (remote code update + restart) and is gated by the host's API key like any other capability. The primary drives a worker's update over the mesh with `chp-host mesh update <url>`.
@@ -0,0 +1,17 @@
1
+ """chp-adapter-host — report and update the CHP runtime on a node.
2
+
3
+ Usage::
4
+
5
+ from chp_core import LocalCapabilityHost, register_adapter
6
+ from chp_adapter_host import HostAdapter
7
+
8
+ host = LocalCapabilityHost()
9
+ register_adapter(host, HostAdapter())
10
+ host.invoke("chp.adapters.host.version", {})
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .adapter import HostAdapter
16
+
17
+ __all__ = ["HostAdapter"]
@@ -0,0 +1,484 @@
1
+ """HostAdapter — report and update the CHP runtime on this node.
2
+
3
+ Two capabilities:
4
+
5
+ * ``version`` — report this node's chp-host version, platform, installed adapters.
6
+ * ``update`` — schedule a **detached** ``chp-host update --restart``. The host
7
+ running this capability will be restarted by the upgrade, so the work must
8
+ outlive it: we spawn the updater in a new session and return immediately
9
+ (``scheduled: true``) *before* anything restarts. The caller re-checks
10
+ ``/health`` to see the new version.
11
+
12
+ Depends only on chp-core. It does NOT import chp-host (it shells out to the
13
+ installed ``chp-host`` CLI) and discovers adapters via entry points, so there is
14
+ no dependency cycle.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import getpass
20
+ import glob
21
+ import os
22
+ import platform
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ from typing import Any
28
+
29
+ from chp_core import BaseAdapter, capability
30
+ from chp_core.stats import collect_host_stats
31
+
32
+
33
+ def _host_version() -> str:
34
+ from importlib.metadata import PackageNotFoundError, version
35
+ try:
36
+ return version("chp-host")
37
+ except PackageNotFoundError:
38
+ return "unknown"
39
+
40
+
41
+ def _installed_adapters() -> list[str]:
42
+ from importlib.metadata import entry_points
43
+ return sorted(ep.name for ep in entry_points(group="chp.adapters"))
44
+
45
+
46
+ def _service_safe_env() -> dict[str, str]:
47
+ """A child environment safe for use under launchd/systemd (minimal env).
48
+
49
+ Ensures HOME (the service env often lacks it, which breaks pip's cache and
50
+ the ~/.chp log path) and a full PATH including /usr/sbin (where ioreg/sysctl
51
+ and other tools live)."""
52
+ import pwd
53
+ env = dict(os.environ)
54
+ home = env.get("HOME")
55
+ if not home:
56
+ try:
57
+ home = pwd.getpwuid(os.getuid()).pw_dir
58
+ except Exception:
59
+ home = "/tmp"
60
+ env["HOME"] = home
61
+ extra = "/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin:/opt/homebrew/bin"
62
+ env["PATH"] = (env.get("PATH", "") + ":" + extra).strip(":")
63
+ return env
64
+
65
+
66
+ def _profile_path_from_argv() -> str | None:
67
+ """The --profile path this host was started with (so install_adapter can add a
68
+ new adapter to the very profile this serve process is running). The host adapter
69
+ runs inside `chp-host serve --profile <path>`, so sys.argv carries it."""
70
+ argv = sys.argv
71
+ for i, a in enumerate(argv):
72
+ if a == "--profile" and i + 1 < len(argv):
73
+ return argv[i + 1]
74
+ if a.startswith("--profile="):
75
+ return a.split("=", 1)[1]
76
+ return None
77
+
78
+
79
+ def _spawn_detached_cli(args: list[str], log_name: str) -> int:
80
+ """Spawn `python -m chp_host.cli <args>` detached, with a service-safe env,
81
+ capturing the child's stdout+stderr to ~/.chp/logs/<log_name> (so a failed
82
+ remote action is diagnosable, e.g. via filesystem.read_file over the mesh).
83
+ Returns the child pid. Used by update + restart, which must outlive the
84
+ service restart they trigger.
85
+ """
86
+ child_env = _service_safe_env()
87
+ log_dir = os.path.join(child_env["HOME"], ".chp", "logs")
88
+ os.makedirs(log_dir, exist_ok=True)
89
+ fd = os.open(os.path.join(log_dir, log_name), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
90
+ try:
91
+ proc = subprocess.Popen(
92
+ [sys.executable, "-m", "chp_host.cli", *args],
93
+ stdout=fd, stderr=fd, start_new_session=True, env=child_env,
94
+ )
95
+ finally:
96
+ os.close(fd) # Popen duplicated the fd; close our copy
97
+ return proc.pid
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Node setup introspection (host.facts) — turns ad-hoc spelunking into one call.
102
+ # ---------------------------------------------------------------------------
103
+
104
+ def _facts_env() -> dict:
105
+ """Env with common tool dirs prepended to PATH (rad/claude/homebrew live off the bare PATH)."""
106
+ env = dict(os.environ)
107
+ extra = [os.path.expanduser(p) for p in
108
+ ("~/.radicle/bin", "~/.local/bin", "~/.npm-global/bin", "~/.cargo/bin")] + ["/opt/homebrew/bin"]
109
+ env["PATH"] = os.pathsep.join(extra + [env.get("PATH", "")])
110
+ return env
111
+
112
+
113
+ def _facts_sh(args: list[str], timeout: int = 10) -> str:
114
+ """Run a command on the augmented PATH; return stripped stdout ('' on any failure)."""
115
+ try:
116
+ out = subprocess.run(args, capture_output=True, text=True, timeout=timeout, env=_facts_env())
117
+ return (out.stdout or "").strip()
118
+ except Exception:
119
+ return ""
120
+
121
+
122
+ def _facts_tool(name: str) -> dict:
123
+ """Locate a binary on the augmented PATH + its version (best-effort)."""
124
+ path = shutil.which(name, path=_facts_env()["PATH"])
125
+ if not path:
126
+ return {"present": False}
127
+ info = {"present": True, "path": path}
128
+ ver = _facts_sh([path, "--version"], timeout=8)
129
+ if ver:
130
+ info["version"] = ver.splitlines()[0][:60]
131
+ return info
132
+
133
+
134
+ class HostAdapter(BaseAdapter):
135
+ adapter_id = "chp.adapters.host"
136
+ adapter_name = "Host"
137
+ adapter_description = "Report and update the CHP host runtime on this node."
138
+ adapter_category = "infrastructure"
139
+ adapter_tags = ["host", "update", "version", "infrastructure", "ops"]
140
+
141
+ def on_register(self, host: Any) -> None:
142
+ self._host = host # for the capability catalog (host.discover)
143
+
144
+ @capability(
145
+ id="chp.adapters.host.discover",
146
+ version="1.0.0",
147
+ description="List this node's capability catalog (id, risk, category, adapter), with optional "
148
+ "namespace/category/risk filters. Read-only — the mesh-invokable view of GET /capabilities.",
149
+ category="infrastructure",
150
+ risk="low",
151
+ input_schema={
152
+ "type": "object",
153
+ "properties": {
154
+ "namespace": {"type": "string",
155
+ "description": "Prefix filter on capability id, e.g. 'chp.adapters.git.'"},
156
+ "category": {"type": "string", "description": "Exact category filter."},
157
+ "risk": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
158
+ "ids_only": {"type": "boolean", "description": "Return just the sorted capability id list."},
159
+ },
160
+ "additionalProperties": False,
161
+ },
162
+ emits=["host_catalog_reported"],
163
+ tags=["host", "discover", "catalog"],
164
+ )
165
+ async def discover(self, ctx: Any, payload: dict) -> dict:
166
+ host = getattr(self, "_host", None)
167
+ if host is None:
168
+ return {"error": "host catalog unavailable (adapter not registered to a host)"}
169
+ kwargs = {k: payload[k] for k in ("namespace", "category", "risk") if payload.get(k)}
170
+ desc = host.discover(**kwargs) or {}
171
+ caps = desc.get("capabilities", [])
172
+ adapters = sorted({c["id"].split(".")[2] for c in caps
173
+ if c.get("id") and c["id"].count(".") >= 2})
174
+ ctx.emit("host_catalog_reported", {"count": len(caps), "adapters": len(adapters)})
175
+ if payload.get("ids_only"):
176
+ return {"count": len(caps), "adapters": adapters,
177
+ "capability_ids": sorted(c.get("id") for c in caps if c.get("id"))}
178
+ slim = [{"id": c.get("id"), "risk": c.get("risk"), "category": c.get("category"),
179
+ "description": (c.get("description") or "")[:120]} for c in caps]
180
+ return {"count": len(caps), "adapters": adapters, "capabilities": slim}
181
+
182
+ @capability(
183
+ id="chp.adapters.host.version",
184
+ version="1.0.0",
185
+ description="Report this node's chp-host version, platform, and installed adapters.",
186
+ category="infrastructure",
187
+ risk="low",
188
+ input_schema={"type": "object", "properties": {}, "additionalProperties": False},
189
+ emits=["host_version_reported"],
190
+ tags=["host", "version"],
191
+ )
192
+ async def version(self, ctx: Any, payload: dict) -> dict:
193
+ info = {
194
+ "host_version": _host_version(),
195
+ "platform": platform.platform(),
196
+ "python": sys.version.split()[0],
197
+ "adapters": _installed_adapters(),
198
+ }
199
+ ctx.emit("host_version_reported", {"host_version": info["host_version"]})
200
+ return info
201
+
202
+ @capability(
203
+ id="chp.adapters.host.facts",
204
+ version="1.0.0",
205
+ description="Introspect THIS node's setup in one call: host/arch/python, toolchain (claude/codex/rad/"
206
+ "git paths+versions), launchd services, radicle identity/homes/node/seed-policies, and rad "
207
+ "repo checkouts. The 'how is this node configured' view (Ansible-facts-shaped). Runs locally "
208
+ "on whichever node it is routed to.",
209
+ category="infrastructure",
210
+ risk="low",
211
+ input_schema={
212
+ "type": "object",
213
+ "properties": {
214
+ "sections": {
215
+ "type": "array",
216
+ "items": {"type": "string", "enum": ["host", "tools", "services", "radicle", "repos"]},
217
+ "description": "Subset of fact sections to gather (default: all).",
218
+ },
219
+ },
220
+ "additionalProperties": False,
221
+ },
222
+ emits=["host_facts_reported"],
223
+ tags=["host", "facts", "introspection", "setup", "ops"],
224
+ )
225
+ async def facts(self, ctx: Any, payload: dict) -> dict:
226
+ sections = set(payload.get("sections") or ["host", "tools", "services", "radicle", "repos"])
227
+ out: dict[str, Any] = {}
228
+
229
+ if "host" in sections:
230
+ try:
231
+ user = getpass.getuser()
232
+ except Exception:
233
+ user = os.environ.get("USER", "")
234
+ out["host"] = {
235
+ "hostname": platform.node(), "platform": platform.platform(),
236
+ "arch": platform.machine(), "user": user,
237
+ "python": sys.executable, "home": os.path.expanduser("~"),
238
+ }
239
+
240
+ if "tools" in sections:
241
+ out["tools"] = {n: _facts_tool(n) for n in ("claude", "codex", "gemini", "rad", "git", "node")}
242
+
243
+ if "services" in sections:
244
+ ll = _facts_sh(["launchctl", "list"])
245
+ out["services"] = sorted({
246
+ line.split()[-1] for line in ll.splitlines()
247
+ if line.split() and ("chp" in line.lower() or "rad" in line.lower())
248
+ })[:25]
249
+
250
+ if "radicle" in sections:
251
+ rad: dict[str, Any] = {}
252
+ for line in _facts_sh(["rad", "self"]).splitlines():
253
+ low = line.strip().lower()
254
+ if low.startswith("alias"):
255
+ rad["alias"] = line.split(None, 1)[-1].strip()
256
+ elif low.startswith("did"):
257
+ rad["did"] = line.split(None, 1)[-1].strip() # did:key is a public id (NID not surfaced)
258
+ rad["homes"] = sorted(os.path.basename(p) for p in glob.glob(os.path.expanduser("~/.radicle*")))
259
+ node = _facts_sh(["rad", "node", "status"])
260
+ rad["node_running"] = "running" in node.lower() and "not running" not in node.lower()
261
+ seed = _facts_sh(["rad", "seed"])
262
+ rad["seeded_repos"] = sum(1 for line in seed.splitlines()
263
+ if line.strip().startswith("│") and "rad:" in line)
264
+ out["radicle"] = rad
265
+
266
+ if "repos" in sections:
267
+ repos = []
268
+ for line in _facts_sh(["rad", "ls"]).splitlines():
269
+ m = re.search(r"rad:[0-9a-zA-Z]+", line)
270
+ if m and line.strip().startswith("│"):
271
+ name = line.strip("│").split()[0] if line.strip("│").split() else ""
272
+ repos.append({"name": name, "rid": m.group(0)})
273
+ out["repos"] = repos[:40]
274
+
275
+ tools_present = sum(1 for t in out.get("tools", {}).values() if t.get("present"))
276
+ ctx.emit("host_facts_reported",
277
+ {"sections": sorted(out.keys()), "tools_present": tools_present})
278
+ return out
279
+
280
+ @capability(
281
+ id="chp.adapters.host.topology",
282
+ version="1.0.0",
283
+ description="Mesh connectivity view from this node: the radicle peer graph (who it's connected to) "
284
+ "+ Tailscale device status (which mesh nodes are online). 'Where the nodes are connected'.",
285
+ category="infrastructure",
286
+ risk="low",
287
+ input_schema={"type": "object", "properties": {}, "additionalProperties": False},
288
+ emits=["host_topology_reported"],
289
+ tags=["host", "topology", "mesh", "connectivity", "ops"],
290
+ )
291
+ async def topology(self, ctx: Any, payload: dict) -> dict:
292
+ # Radicle peer graph (connected = "✓" marker; address shown for reachable peers).
293
+ peers = []
294
+ for line in _facts_sh(["rad", "node", "status"]).splitlines():
295
+ if "│" not in line:
296
+ continue
297
+ nid = re.search(r"z6Mk[0-9A-Za-z]{20,}", line)
298
+ addr = re.search(r"\b(100\.\d+\.\d+\.\d+:\d+|[\w.-]+:\d{2,5})\b", line)
299
+ if nid:
300
+ peers.append({"nid": nid.group(0)[:14] + "…", "address": addr.group(0) if addr else "",
301
+ "connected": "✓" in line})
302
+
303
+ # Tailscale device status (mesh-node reachability).
304
+ ts = _facts_sh(["tailscale", "status"]) or \
305
+ _facts_sh(["/Applications/Tailscale.app/Contents/MacOS/Tailscale", "status"])
306
+ devices = []
307
+ for line in ts.splitlines():
308
+ parts = line.split()
309
+ if len(parts) >= 2 and parts[0].startswith("100."):
310
+ devices.append({"ip": parts[0], "name": parts[1],
311
+ "os": parts[3] if len(parts) >= 4 else "",
312
+ "online": "offline" not in line.lower()})
313
+
314
+ result = {"radicle_peers": peers,
315
+ "radicle_connected": sum(1 for p in peers if p["connected"]),
316
+ "tailscale_devices": devices[:50],
317
+ "tailscale_online": sum(1 for d in devices if d["online"])}
318
+ ctx.emit("host_topology_reported",
319
+ {"peers": len(peers), "devices": len(devices), "online": result["tailscale_online"]})
320
+ return result
321
+
322
+ @capability(
323
+ id="chp.adapters.host.stats",
324
+ version="1.0.0",
325
+ description="Report CPU load, memory, disk, GPU, and platform stats for this node.",
326
+ category="infrastructure",
327
+ risk="low",
328
+ input_schema={"type": "object", "properties": {}, "additionalProperties": False},
329
+ emits=["host_stats_reported"],
330
+ tags=["host", "stats", "capacity"],
331
+ )
332
+ async def stats(self, ctx: Any, payload: dict) -> dict:
333
+ result = collect_host_stats()
334
+ ctx.emit("host_stats_reported", {
335
+ "load_per_core": result.get("load_per_core"),
336
+ "gpu": result.get("gpu"),
337
+ })
338
+ return result
339
+
340
+ @capability(
341
+ id="chp.adapters.host.update",
342
+ version="1.0.0",
343
+ description="Schedule a detached upgrade of this node's CHP packages, then restart its services.",
344
+ category="infrastructure",
345
+ risk="high",
346
+ input_schema={
347
+ "type": "object",
348
+ "properties": {
349
+ "version": {"type": "string", "description": "Pin chp-core/chp-host to this version."},
350
+ "channel": {"type": "string", "enum": ["github", "pypi"]},
351
+ "require_provenance": {"type": "boolean", "default": False, "description": "Verify every package's signed provenance BEFORE upgrading any (spec section 9, atomic)."},
352
+ },
353
+ "additionalProperties": False,
354
+ },
355
+ emits=["host_update_scheduled"],
356
+ tags=["host", "update", "ops"],
357
+ )
358
+ async def update(self, ctx: Any, payload: dict) -> dict:
359
+ before = _host_version()
360
+ # `chp-host update` restarts by default (the flag is --no-restart).
361
+ args = ["update"]
362
+ if payload.get("require_provenance"):
363
+ args += ["--require-provenance"]
364
+ store_path = getattr(getattr(getattr(ctx, "host", None), "store", None), "path", None)
365
+ if store_path and store_path != ":memory:":
366
+ child = ctx.child_correlation()
367
+ args += ["--evidence-store", str(store_path),
368
+ "--correlation-id", str(child.correlation_id),
369
+ "--host-id", str(getattr(ctx.host, "host_id", "") or "unknown")]
370
+ if child.causation_id:
371
+ args += ["--causation-id", str(child.causation_id)]
372
+ if payload.get("version"):
373
+ args += ["--version", str(payload["version"])]
374
+ if payload.get("channel"):
375
+ args += ["--channel", str(payload["channel"])]
376
+ pid = _spawn_detached_cli(args, "host-update-child.log")
377
+ ctx.emit("host_update_scheduled", {"from_version": before, "pid": pid})
378
+ return {
379
+ "from_version": before,
380
+ "scheduled": True,
381
+ "pid": pid,
382
+ "note": "Update runs detached; this host will restart shortly. "
383
+ "Re-check /health for the new host_version.",
384
+ }
385
+
386
+ @capability(
387
+ id="chp.adapters.host.install_adapter",
388
+ version="1.0.0",
389
+ description=(
390
+ "Install (or pull) a CHP adapter package onto this node, optionally add it "
391
+ "to this host's profile, then restart so the new capabilities register. "
392
+ "Detached — this host restarts shortly after scheduling."
393
+ ),
394
+ category="infrastructure",
395
+ risk="high",
396
+ input_schema={
397
+ "type": "object",
398
+ "properties": {
399
+ "package": {"type": "string", "description": "pip package name, e.g. 'chp-adapter-mlx'"},
400
+ "version": {"type": "string", "description": "Pin to this version (ignored if url is set)."},
401
+ "url": {"type": "string", "description": "Direct wheel/sdist URL (e.g. a GitHub release asset)."},
402
+ "adapter_name": {"type": "string", "description": "Short entry-point name to add to this host's profile, e.g. 'mlx'."},
403
+ "extras": {"type": "string", "description": "Optional-dependency extra to also install, e.g. 'serve' → chp-adapter-mlx[serve] (pulls the adapter's runtime tooling)."},
404
+ "restart": {"type": "boolean", "default": True},
405
+ "require_provenance": {"type": "boolean", "default": False, "description": "Verify the publisher's signed provenance statement against the artifact BEFORE installing (spec section 9)."},
406
+ "publisher_key": {"type": "string", "description": "Require this publisher key_id."},
407
+ "publisher_domain": {"type": "string", "description": "Require this domain anchor on the publisher attestation."},
408
+ "provenance": {"type": "string", "description": "Statement path/URL (default: GitHub release asset convention)."},
409
+ },
410
+ "required": ["package"],
411
+ "additionalProperties": False,
412
+ },
413
+ emits=["host_adapter_install_scheduled", "host_adapter_installed"],
414
+ tags=["host", "adapter", "install", "ops"],
415
+ )
416
+ async def install_adapter(self, ctx: Any, payload: dict) -> dict:
417
+ package = str(payload.get("package") or "").strip()
418
+ if not package:
419
+ raise ValueError("package is required")
420
+ args = ["install-adapter", package]
421
+ # Deferred-evidence coordinates (chp-v0.2.md §7): the detached install
422
+ # appends `host_adapter_installed` (version + record_sha256 provenance)
423
+ # under THIS correlation with a causal edge to this invocation.
424
+ store_path = getattr(getattr(getattr(ctx, "host", None), "store", None), "path", None)
425
+ if store_path and store_path != ":memory:":
426
+ child = ctx.child_correlation()
427
+ args += ["--evidence-store", str(store_path),
428
+ "--correlation-id", str(child.correlation_id),
429
+ "--host-id", str(getattr(ctx.host, "host_id", "") or "unknown")]
430
+ if child.causation_id:
431
+ args += ["--causation-id", str(child.causation_id)]
432
+ if payload.get("url"):
433
+ args += ["--url", str(payload["url"])]
434
+ elif payload.get("version"):
435
+ args += ["--version", str(payload["version"])]
436
+ if payload.get("extras"):
437
+ args += ["--extras", str(payload["extras"])]
438
+ adapter_name = payload.get("adapter_name")
439
+ profile = None
440
+ if adapter_name:
441
+ args += ["--adapter-name", str(adapter_name)]
442
+ profile = _profile_path_from_argv()
443
+ if profile:
444
+ args += ["--profile", profile]
445
+ if payload.get("require_provenance"):
446
+ args += ["--require-provenance"]
447
+ for flag in ("publisher_key", "publisher_domain", "provenance"):
448
+ if payload.get(flag):
449
+ args += [f"--{flag.replace('_', '-')}", str(payload[flag])]
450
+ if payload.get("restart") is False:
451
+ args += ["--no-restart"]
452
+ pid = _spawn_detached_cli(args, "host-install-adapter-child.log")
453
+ ctx.emit("host_adapter_install_scheduled",
454
+ {"package": package, "adapter_name": adapter_name, "pid": pid})
455
+ return {
456
+ "scheduled": True,
457
+ "pid": pid,
458
+ "package": package,
459
+ "adapter_name": adapter_name,
460
+ "profile": profile,
461
+ "note": "Install runs detached; this node restarts shortly. Re-check "
462
+ "/capabilities (or host.version adapters) for the new adapter.",
463
+ }
464
+
465
+ @capability(
466
+ id="chp.adapters.host.restart",
467
+ version="1.0.0",
468
+ description="Restart this node's CHP services (detached, no upgrade).",
469
+ category="infrastructure",
470
+ risk="high",
471
+ input_schema={"type": "object", "properties": {}, "additionalProperties": False},
472
+ emits=["host_restart_scheduled"],
473
+ tags=["host", "restart", "ops"],
474
+ )
475
+ async def restart(self, ctx: Any, payload: dict) -> dict:
476
+ # Detached so it survives the very services it restarts. Captures output
477
+ # to ~/.chp/logs/host-restart-child.log for remote diagnosis.
478
+ pid = _spawn_detached_cli(["restart"], "host-restart-child.log")
479
+ ctx.emit("host_restart_scheduled", {"pid": pid})
480
+ return {
481
+ "scheduled": True,
482
+ "pid": pid,
483
+ "note": "Restart runs detached; this node's services bounce shortly.",
484
+ }
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chp-adapter-host"
7
+ version = "0.11.0"
8
+ description = "CHP capability adapter — report and update the host runtime on a node"
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", "host", "update", "ops", "adapter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ ]
21
+ dependencies = [
22
+ "chp-core>=0.8.0",
23
+ ]
24
+
25
+ [project.entry-points."chp.adapters"]
26
+ host = "chp_adapter_host:HostAdapter"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ pythonpath = ["."]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["chp_adapter_host"]
@@ -0,0 +1,187 @@
1
+ """Tests for chp-adapter-host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from chp_core import LocalCapabilityHost, register_adapter
6
+
7
+ from chp_adapter_host import HostAdapter
8
+ from chp_adapter_host import adapter as adapter_module
9
+
10
+
11
+ def _host():
12
+ h = LocalCapabilityHost("test")
13
+ register_adapter(h, HostAdapter())
14
+ return h
15
+
16
+
17
+ def test_version_capability():
18
+ result = _host().invoke("chp.adapters.host.version", {})
19
+ assert result.outcome == "success"
20
+ assert "host_version" in result.data
21
+ assert isinstance(result.data["adapters"], list)
22
+ assert "platform" in result.data
23
+
24
+
25
+ def test_stats_capability_success():
26
+ result = _host().invoke("chp.adapters.host.stats", {})
27
+ assert result.outcome == "success"
28
+
29
+
30
+ def test_stats_capability_has_cpu_count():
31
+ result = _host().invoke("chp.adapters.host.stats", {})
32
+ assert result.outcome == "success"
33
+ assert "cpu_count" in result.data
34
+ assert isinstance(result.data["cpu_count"], int)
35
+ assert result.data["cpu_count"] >= 1
36
+
37
+
38
+ def test_stats_capability_has_load_per_core():
39
+ result = _host().invoke("chp.adapters.host.stats", {})
40
+ assert result.outcome == "success"
41
+ # load_per_core may be None on platforms without getloadavg, but key must exist
42
+ assert "load_per_core" in result.data
43
+
44
+
45
+ def test_stats_capability_has_disk():
46
+ result = _host().invoke("chp.adapters.host.stats", {})
47
+ assert result.outcome == "success"
48
+ disk = result.data.get("disk")
49
+ assert disk is not None
50
+ assert disk["total_gb"] > 0
51
+
52
+
53
+ def test_stats_capability_evidence_recorded():
54
+ result = _host().invoke("chp.adapters.host.stats", {})
55
+ assert result.outcome == "success"
56
+ assert result.evidence_ids
57
+
58
+
59
+ def test_update_schedules_detached(monkeypatch):
60
+ calls: dict = {}
61
+
62
+ class FakeProc:
63
+ pid = 4242
64
+
65
+ def fake_popen(cmd, **kwargs):
66
+ calls["cmd"] = cmd
67
+ calls["kwargs"] = kwargs
68
+ return FakeProc()
69
+
70
+ monkeypatch.setattr(adapter_module.subprocess, "Popen", fake_popen)
71
+
72
+ result = _host().invoke("chp.adapters.host.update", {"version": "0.8.9", "channel": "pypi"})
73
+ assert result.outcome == "success"
74
+ assert result.data["scheduled"] is True
75
+ assert result.data["pid"] == 4242
76
+
77
+ # Detached so it survives the host restart it triggers.
78
+ assert calls["kwargs"].get("start_new_session") is True
79
+ # Shells out to `chp-host update` (restart is the default). It must NOT pass
80
+ # --restart — that's not a valid flag and argparse would kill the child.
81
+ assert "update" in calls["cmd"]
82
+ assert "--restart" not in calls["cmd"]
83
+ assert "--version" in calls["cmd"] and "0.8.9" in calls["cmd"]
84
+ assert "--channel" in calls["cmd"] and "pypi" in calls["cmd"]
85
+ # The child env must carry HOME (services run without it) so pip + logging work.
86
+ assert calls["kwargs"].get("env", {}).get("HOME")
87
+
88
+
89
+ def test_install_adapter_schedules_detached(monkeypatch):
90
+ calls: dict = {}
91
+
92
+ class FakeProc:
93
+ pid = 99
94
+
95
+ def fake_popen(cmd, **kwargs):
96
+ calls["cmd"] = cmd
97
+ calls["kwargs"] = kwargs
98
+ return FakeProc()
99
+
100
+ monkeypatch.setattr(adapter_module.subprocess, "Popen", fake_popen)
101
+ # Pretend this host was started with a profile (so adapter_name → profile edit).
102
+ monkeypatch.setattr(adapter_module.sys, "argv",
103
+ ["chp-host", "serve", "--profile", "/tmp/inference.json"])
104
+
105
+ result = _host().invoke("chp.adapters.host.install_adapter",
106
+ {"package": "chp-adapter-mlx", "adapter_name": "mlx"})
107
+ assert result.outcome == "success"
108
+ assert result.data["scheduled"] is True
109
+ cmd = calls["cmd"]
110
+ assert "install-adapter" in cmd and "chp-adapter-mlx" in cmd
111
+ # adapter_name + discovered profile are passed through to the installer.
112
+ assert "--adapter-name" in cmd and "mlx" in cmd
113
+ assert "--profile" in cmd and "/tmp/inference.json" in cmd
114
+ assert calls["kwargs"].get("start_new_session") is True
115
+ assert calls["kwargs"].get("env", {}).get("HOME")
116
+
117
+
118
+ def test_install_adapter_with_extras(monkeypatch):
119
+ calls: dict = {}
120
+ monkeypatch.setattr(adapter_module.subprocess, "Popen",
121
+ lambda cmd, **kw: calls.update(cmd=cmd) or type("P", (), {"pid": 1})())
122
+ result = _host().invoke("chp.adapters.host.install_adapter",
123
+ {"package": "chp-adapter-mlx", "adapter_name": "mlx", "extras": "serve"})
124
+ assert result.outcome == "success"
125
+ cmd = calls["cmd"]
126
+ assert "--extras" in cmd and "serve" in cmd
127
+
128
+
129
+ def test_install_adapter_with_wheel_url(monkeypatch):
130
+ calls: dict = {}
131
+ monkeypatch.setattr(adapter_module.subprocess, "Popen",
132
+ lambda cmd, **kw: calls.update(cmd=cmd) or type("P", (), {"pid": 1})())
133
+ result = _host().invoke("chp.adapters.host.install_adapter",
134
+ {"package": "chp-adapter-mlx", "url": "https://example/chp_adapter_mlx-0.8.0.whl"})
135
+ assert result.outcome == "success"
136
+ cmd = calls["cmd"]
137
+ assert "--url" in cmd and "https://example/chp_adapter_mlx-0.8.0.whl" in cmd
138
+
139
+
140
+ def test_install_adapter_requires_package():
141
+ result = _host().invoke("chp.adapters.host.install_adapter", {"package": ""})
142
+ assert result.outcome != "success"
143
+
144
+
145
+ def test_restart_schedules_detached(monkeypatch):
146
+ calls: dict = {}
147
+
148
+ class FakeProc:
149
+ pid = 7
150
+
151
+ def fake_popen(cmd, **kwargs):
152
+ calls["cmd"] = cmd
153
+ calls["kwargs"] = kwargs
154
+ return FakeProc()
155
+
156
+ monkeypatch.setattr(adapter_module.subprocess, "Popen", fake_popen)
157
+ result = _host().invoke("chp.adapters.host.restart", {})
158
+ assert result.outcome == "success"
159
+ assert result.data["scheduled"] is True
160
+ # Spawns `chp-host restart` detached with HOME — no upgrade, no bogus flags.
161
+ assert "restart" in calls["cmd"] and "update" not in calls["cmd"]
162
+ assert calls["kwargs"].get("start_new_session") is True
163
+ assert calls["kwargs"].get("env", {}).get("HOME")
164
+
165
+
166
+ def test_facts_capability():
167
+ result = _host().invoke("chp.adapters.host.facts", {"sections": ["host", "tools"]})
168
+ assert result.outcome == "success"
169
+ d = result.data
170
+ assert "host" in d and "tools" in d
171
+ assert d["host"]["python"] # interpreter path always present
172
+ assert isinstance(d["tools"]["git"], dict) # each tool → {present, [path, version]}
173
+ assert "present" in d["tools"]["git"]
174
+
175
+
176
+ def test_facts_unknown_field_denied():
177
+ result = _host().invoke("chp.adapters.host.facts", {"bogus": 1})
178
+ assert result.outcome == "denied"
179
+
180
+
181
+ def test_topology_capability():
182
+ result = _host().invoke("chp.adapters.host.topology", {})
183
+ assert result.outcome == "success"
184
+ d = result.data
185
+ assert isinstance(d["radicle_peers"], list)
186
+ assert isinstance(d["tailscale_devices"], list)
187
+ assert "radicle_connected" in d and "tailscale_online" in d