chp-server 0.60.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chp_server/__init__.py +53 -0
- chp_server/_catalog.py +60 -0
- chp_server/_example.py +45 -0
- chp_server/_scaffold.py +69 -0
- chp_server/app.py +132 -0
- chp_server/cli.py +163 -0
- chp_server/config.py +77 -0
- chp_server/features.py +106 -0
- chp_server/ha.py +156 -0
- chp_server/http.py +234 -0
- chp_server/introduction.py +492 -0
- chp_server/local.py +138 -0
- chp_server/ports.py +150 -0
- chp_server/profiles.py +54 -0
- chp_server/replay.py +85 -0
- chp_server/resolver.py +194 -0
- chp_server/server.py +235 -0
- chp_server-0.60.2.dist-info/METADATA +224 -0
- chp_server-0.60.2.dist-info/RECORD +23 -0
- chp_server-0.60.2.dist-info/WHEEL +4 -0
- chp_server-0.60.2.dist-info/entry_points.txt +7 -0
- chp_server-0.60.2.dist-info/licenses/LICENSE +202 -0
- chp_server-0.60.2.dist-info/licenses/NOTICE +18 -0
chp_server/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""chp-server — independently installable reference CHP server.
|
|
2
|
+
|
|
3
|
+
Assembly only: protocol semantics live in chp-core; domain behavior attaches
|
|
4
|
+
through the `chp_server.ports` entry-point group. The only mandatory CHP
|
|
5
|
+
dependency is chp-core (DEC-SRV-001) — importing this package must never pull
|
|
6
|
+
an optional CHP package (PKG-009; enforced by tests/test_import_purity.py).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .config import ServerConfig
|
|
10
|
+
from .features import FEATURES, FeatureDescriptor, FeatureRegistry
|
|
11
|
+
from .introduction import (
|
|
12
|
+
EntryPointIntroductionPort,
|
|
13
|
+
IntroductionCoordinator,
|
|
14
|
+
RemoteChpIntroductionSource,
|
|
15
|
+
SourceTrustPolicy,
|
|
16
|
+
)
|
|
17
|
+
from .local import ExistingHostPort, LocalArtifactPort, LocalStandalonePorts
|
|
18
|
+
from .resolver import DirectoryResolutionPort, advertisement_batch
|
|
19
|
+
from .ports import ENTRY_POINT_GROUP, PORT_ROLES, Attachment, AttachmentRegistry
|
|
20
|
+
from .profiles import CONFORMANCE_MANIFEST, PROFILES, validate_profile
|
|
21
|
+
from .server import Server, ServerInstanceIdentity, ServerStatus
|
|
22
|
+
from .app import CapabilityServer
|
|
23
|
+
|
|
24
|
+
# Replay / ReplayEvent (the typed /replay view) live in chp_server.replay, not the
|
|
25
|
+
# top-level namespace — API-008 keeps the public surface small and curated. Advanced
|
|
26
|
+
# callers use `from chp_server.replay import Replay`; the CLI imports it directly.
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"CapabilityServer",
|
|
30
|
+
"Attachment",
|
|
31
|
+
"AttachmentRegistry",
|
|
32
|
+
"CONFORMANCE_MANIFEST",
|
|
33
|
+
"ENTRY_POINT_GROUP",
|
|
34
|
+
"EntryPointIntroductionPort",
|
|
35
|
+
"ExistingHostPort",
|
|
36
|
+
"IntroductionCoordinator",
|
|
37
|
+
"SourceTrustPolicy",
|
|
38
|
+
"DirectoryResolutionPort",
|
|
39
|
+
"advertisement_batch",
|
|
40
|
+
"LocalArtifactPort",
|
|
41
|
+
"FEATURES",
|
|
42
|
+
"FeatureDescriptor",
|
|
43
|
+
"FeatureRegistry",
|
|
44
|
+
"LocalStandalonePorts",
|
|
45
|
+
"PORT_ROLES",
|
|
46
|
+
"PROFILES",
|
|
47
|
+
"RemoteChpIntroductionSource",
|
|
48
|
+
"Server",
|
|
49
|
+
"ServerConfig",
|
|
50
|
+
"ServerInstanceIdentity",
|
|
51
|
+
"ServerStatus",
|
|
52
|
+
"validate_profile",
|
|
53
|
+
]
|
chp_server/_catalog.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Discover installed ``chp-adapter-*`` capability sets — the composable catalog.
|
|
2
|
+
|
|
3
|
+
Enumerates adapters registered under the ``chp.adapters`` entry-point group and reads each
|
|
4
|
+
one's ``@capability`` descriptors WITHOUT instantiating the adapter — so adapters that need
|
|
5
|
+
configuration (API keys, backends) still list. A live catalog that can't rot: it reflects what
|
|
6
|
+
is actually installed in this environment. Powers ``chp-server adapters``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _capabilities_of(cls: type) -> list[dict]:
|
|
15
|
+
"""(id, description) for each capability an adapter class exposes.
|
|
16
|
+
|
|
17
|
+
First reads ``@capability`` method descriptors statically — config-free, no
|
|
18
|
+
instantiation (so adapters that need API keys/backends still list). If none are found,
|
|
19
|
+
the adapter likely builds its capabilities dynamically in ``capabilities()`` (e.g.
|
|
20
|
+
chp-core's GitAdapter via git_capabilities()); a static scan can't see those, so it
|
|
21
|
+
showed "0 caps". Try a SAFE no-arg instantiation to read them — an adapter that needs
|
|
22
|
+
configuration raises on ``__init__``/``capabilities()`` and is caught, staying capless
|
|
23
|
+
exactly as before (the config-free contract holds; only no-arg adapters gain coverage).
|
|
24
|
+
"""
|
|
25
|
+
caps: list[dict] = []
|
|
26
|
+
seen: set[str] = set()
|
|
27
|
+
for _name, member in inspect.getmembers(cls):
|
|
28
|
+
descriptor = getattr(member, "__chp_descriptor__", None)
|
|
29
|
+
cid = getattr(descriptor, "id", None)
|
|
30
|
+
if cid and cid not in seen:
|
|
31
|
+
seen.add(cid)
|
|
32
|
+
caps.append({"id": cid, "description": (descriptor.description or "").strip()})
|
|
33
|
+
if not caps:
|
|
34
|
+
try:
|
|
35
|
+
for hosted in cls().capabilities(): # dynamic-capabilities() adapters
|
|
36
|
+
descriptor = getattr(hosted, "descriptor", None)
|
|
37
|
+
cid = getattr(descriptor, "id", None)
|
|
38
|
+
if cid and cid not in seen:
|
|
39
|
+
seen.add(cid)
|
|
40
|
+
caps.append({"id": cid,
|
|
41
|
+
"description": (getattr(descriptor, "description", "") or "").strip()})
|
|
42
|
+
except Exception:
|
|
43
|
+
pass # needs config (or any error) -> report capless, config-free, unchanged
|
|
44
|
+
return sorted(caps, key=lambda c: c["id"])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def adapter_catalog() -> list[dict]:
|
|
48
|
+
"""Installed adapters as ``[{name, module, capabilities: [{id, description}]}]``, sorted by
|
|
49
|
+
name. Empty when no adapter packages are installed (a bare chp-server + chp-core env)."""
|
|
50
|
+
from chp_core.adapters import discover_adapters
|
|
51
|
+
|
|
52
|
+
catalog: list[dict] = []
|
|
53
|
+
for name, cls in sorted(discover_adapters().items()):
|
|
54
|
+
try:
|
|
55
|
+
caps = _capabilities_of(cls)
|
|
56
|
+
except Exception:
|
|
57
|
+
caps = [] # a broken adapter never sinks the catalog
|
|
58
|
+
catalog.append({"name": name, "module": getattr(cls, "__module__", "?"),
|
|
59
|
+
"capabilities": caps})
|
|
60
|
+
return catalog
|
chp_server/_example.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Sample capabilities for ``chp-server serve --example``.
|
|
2
|
+
|
|
3
|
+
A fresh ``chp serve`` starts a truthful ``protocol-only`` server where every
|
|
4
|
+
optional feature is ``unsupported`` — honest, but there is nothing to invoke
|
|
5
|
+
yet. ``--example`` attaches a governed host carrying a few harmless capabilities
|
|
6
|
+
so the very first run is *live* and curl-able, and so the quickstart in the
|
|
7
|
+
README works verbatim. It is a teaching aid, not a product surface.
|
|
8
|
+
|
|
9
|
+
chp-core only — no other CHP package is imported.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
from chp_core import CapabilityDescriptor, LocalCapabilityHost, SQLiteEvidenceStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_example_host(store_path: str, host_id: str = "chp-example") -> LocalCapabilityHost:
|
|
20
|
+
"""A governed host with three sample capabilities: ``greet.hello``,
|
|
21
|
+
``math.add`` and ``time.now``. Attach it with ``ExistingHostPort``."""
|
|
22
|
+
host = LocalCapabilityHost(host_id, store=SQLiteEvidenceStore(store_path))
|
|
23
|
+
|
|
24
|
+
host.register(
|
|
25
|
+
CapabilityDescriptor(
|
|
26
|
+
id="greet.hello", version="1.0.0", description="Greet a name.",
|
|
27
|
+
input_schema={"type": "object",
|
|
28
|
+
"properties": {"name": {"type": "string"}}}),
|
|
29
|
+
lambda ctx, payload: {"greeting": f"hello, {payload.get('name', 'world')}"},
|
|
30
|
+
)
|
|
31
|
+
host.register(
|
|
32
|
+
CapabilityDescriptor(
|
|
33
|
+
id="math.add", version="1.0.0", description="Add two numbers.",
|
|
34
|
+
input_schema={"type": "object",
|
|
35
|
+
"properties": {"a": {"type": "number"}, "b": {"type": "number"}}}),
|
|
36
|
+
lambda ctx, payload: {"sum": payload.get("a", 0) + payload.get("b", 0)},
|
|
37
|
+
)
|
|
38
|
+
host.register(
|
|
39
|
+
CapabilityDescriptor(
|
|
40
|
+
id="time.now", version="1.0.0",
|
|
41
|
+
description="Current UTC time as an ISO-8601 string."),
|
|
42
|
+
lambda ctx, payload: {
|
|
43
|
+
"now": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")},
|
|
44
|
+
)
|
|
45
|
+
return host
|
chp_server/_scaffold.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Scaffold a starter CHP capability server — the ``chp-server new`` command.
|
|
2
|
+
|
|
3
|
+
Writes a single runnable Python file that serves one sample capability through the
|
|
4
|
+
full CHP pipeline, so a newcomer goes from install to *their own* governed server in
|
|
5
|
+
one command. The generated file needs only chp-core + chp-server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Placeholders are substituted by str.replace (not str.format) so the template can
|
|
14
|
+
# contain literal f-strings and dict/JSON braces without escaping every one.
|
|
15
|
+
_TEMPLATE = '''#!/usr/bin/env python3
|
|
16
|
+
"""A starter CHP capability server. Run it: python __FILENAME__
|
|
17
|
+
|
|
18
|
+
Serves your capabilities over HTTP with governed invocation and a signed, replayable
|
|
19
|
+
evidence chain. Needs only chp-core + chp-server. See the serving guide:
|
|
20
|
+
https://github.com/capabilityhostprotocol/chp-core
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from chp_server import CapabilityServer
|
|
25
|
+
|
|
26
|
+
app = CapabilityServer("__HOST_ID__")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.capability("greet.hello")
|
|
30
|
+
def hello(name: str = "world") -> dict:
|
|
31
|
+
"""Greet a name."""
|
|
32
|
+
# The docstring becomes the description; the type hints become the input
|
|
33
|
+
# schema, which the pipeline enforces before this function runs.
|
|
34
|
+
return {"greeting": f"hello, {name}"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# TODO: add your own capabilities — one decorated function each.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
# try: curl localhost:8800/invoke -H 'Content-Type: application/json' \\
|
|
42
|
+
# -d '{"capability_id": "greet.hello", "payload": {"name": "CHP"}}'
|
|
43
|
+
app.run(port=8800)
|
|
44
|
+
'''
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _host_id(name: str) -> str:
|
|
48
|
+
"""A safe host_id/slug from the requested name (letters, digits, dashes)."""
|
|
49
|
+
slug = re.sub(r"[^a-z0-9-]+", "-", Path(name).stem.lower()).strip("-")
|
|
50
|
+
return slug or "my-host"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _target_path(name: str) -> Path:
|
|
54
|
+
return Path(name if name.endswith(".py") else f"{name}.py")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_starter(name: str) -> str:
|
|
58
|
+
"""The generated starter file's source (does not touch disk)."""
|
|
59
|
+
path = _target_path(name)
|
|
60
|
+
return _TEMPLATE.replace("__FILENAME__", path.name).replace("__HOST_ID__", _host_id(name))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_starter(name: str, *, force: bool = False) -> Path:
|
|
64
|
+
"""Write the starter file next to the caller; refuse to clobber unless *force*."""
|
|
65
|
+
path = _target_path(name)
|
|
66
|
+
if path.exists() and not force:
|
|
67
|
+
raise FileExistsError(f"{path} already exists (use --force to overwrite)")
|
|
68
|
+
path.write_text(render_starter(name))
|
|
69
|
+
return path
|
chp_server/app.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""``CapabilityServer`` — the clean, decorator-first way to surface capabilities.
|
|
2
|
+
|
|
3
|
+
from chp_server import CapabilityServer
|
|
4
|
+
|
|
5
|
+
app = CapabilityServer("my-host")
|
|
6
|
+
|
|
7
|
+
@app.capability("math.add")
|
|
8
|
+
def add(a: int, b: int) -> dict:
|
|
9
|
+
"Add two numbers."
|
|
10
|
+
return {"sum": a + b}
|
|
11
|
+
|
|
12
|
+
app.run(port=8800)
|
|
13
|
+
|
|
14
|
+
`app.capability(...)` registers a governed capability from a plain function:
|
|
15
|
+
|
|
16
|
+
- the **docstring** becomes the description,
|
|
17
|
+
- the **type hints** become the input schema — and the pipeline *enforces* it: a
|
|
18
|
+
malformed call is denied (`input_schema_validation_failed`) before your handler runs,
|
|
19
|
+
- payload fields arrive as **keyword arguments** (write `def add(a, b)`, not `payload`).
|
|
20
|
+
|
|
21
|
+
It is thin sugar over `LocalCapabilityHost` + `Server.serving`; reach for the explicit
|
|
22
|
+
host/attachment API (`ExistingHostPort`, resolution, federation, the distribute path,
|
|
23
|
+
custom evidence stores) when you outgrow it. chp-core only.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import inspect
|
|
29
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
30
|
+
|
|
31
|
+
from chp_core import LocalCapabilityHost, SQLiteEvidenceStore
|
|
32
|
+
from chp_core.decorators import capability as _capability
|
|
33
|
+
|
|
34
|
+
from .server import Server
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from chp_core import CapabilityDescriptor
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CapabilityServer:
|
|
41
|
+
"""A host you decorate capabilities onto, then serve."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, host_id: str = "chp-server", *, store: str | None = None) -> None:
|
|
44
|
+
self.host = LocalCapabilityHost(
|
|
45
|
+
host_id, store=SQLiteEvidenceStore(store or f"{host_id}.sqlite"))
|
|
46
|
+
|
|
47
|
+
def capability(self, id: str, *, version: str = "1.0.0",
|
|
48
|
+
description: str | None = None,
|
|
49
|
+
input_schema: dict | bool | None = None,
|
|
50
|
+
**descriptor_kwargs: Any) -> Callable[[Callable], Callable]:
|
|
51
|
+
"""Register the decorated function as a governed capability.
|
|
52
|
+
|
|
53
|
+
``description`` defaults to the function's docstring; ``input_schema`` defaults
|
|
54
|
+
to one inferred from its type hints (pass a dict to override, or ``False`` to
|
|
55
|
+
declare none). Any other ``CapabilityDescriptor`` field (``policy``, ``risk``,
|
|
56
|
+
``tags``, ``timeout_s``, ...) may be passed through.
|
|
57
|
+
"""
|
|
58
|
+
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
59
|
+
desc = description or (inspect.getdoc(fn) or "").strip() or id
|
|
60
|
+
# input_schema None -> infer from type hints (chp-core does it);
|
|
61
|
+
# False -> declare none; a dict -> use it verbatim.
|
|
62
|
+
explicit = input_schema if isinstance(input_schema, dict) else None
|
|
63
|
+
decorated = _capability(
|
|
64
|
+
id=id, version=version, description=desc,
|
|
65
|
+
input_schema=explicit, infer_schema=(input_schema is None),
|
|
66
|
+
**descriptor_kwargs)(fn)
|
|
67
|
+
self.host.register(decorated)
|
|
68
|
+
return decorated
|
|
69
|
+
return decorate
|
|
70
|
+
|
|
71
|
+
def compose(self, adapter: Any, *, replace: bool = False) -> list[CapabilityDescriptor]:
|
|
72
|
+
"""Compose an EXISTING adapter's capabilities onto this server — the adapter-first
|
|
73
|
+
way to add git / http / secrets / filesystem / … without reimplementing them. A
|
|
74
|
+
server serves whatever mix it needs: hand-written ``@capability`` functions *and*
|
|
75
|
+
any ``chp-adapter-*`` (a ``chp_core.adapters.BaseAdapter`` of ``@capability``
|
|
76
|
+
methods). One governed implementation, reused here — not a parallel copy. Returns
|
|
77
|
+
the registered capability descriptors; ``replace=True`` overwrites duplicates (e.g.
|
|
78
|
+
an adapter re-created with new config)."""
|
|
79
|
+
from chp_core import register_adapter
|
|
80
|
+
return register_adapter(self.host, adapter, replace=replace)
|
|
81
|
+
|
|
82
|
+
def serving(self, **serving_kwargs: Any) -> Server:
|
|
83
|
+
"""Build (do not start) a Server projecting this host — for embedding/tests."""
|
|
84
|
+
return Server.serving(self.host, **serving_kwargs)
|
|
85
|
+
|
|
86
|
+
def _served_capability_ids(self) -> list[str]:
|
|
87
|
+
"""Best-effort list of capability ids the host serves (for the run() banner).
|
|
88
|
+
|
|
89
|
+
host.discover() returns a HostDescriptor dict ({..., "capabilities": [{"id": ...}]});
|
|
90
|
+
tolerate an object shape too so this never breaks the banner.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
desc = self.host.discover()
|
|
94
|
+
except Exception:
|
|
95
|
+
return []
|
|
96
|
+
caps = (desc.get("capabilities") if isinstance(desc, dict)
|
|
97
|
+
else getattr(desc, "capabilities", None)) or []
|
|
98
|
+
ids = []
|
|
99
|
+
for c in caps:
|
|
100
|
+
cid = c.get("id") if isinstance(c, dict) else getattr(c, "id", None)
|
|
101
|
+
if cid:
|
|
102
|
+
ids.append(cid)
|
|
103
|
+
return sorted(ids)
|
|
104
|
+
|
|
105
|
+
def run(self, *, port: int = 8800, bind: str = "127.0.0.1",
|
|
106
|
+
**serving_kwargs: Any) -> None:
|
|
107
|
+
"""Serve this host over HTTP (blocking)."""
|
|
108
|
+
server = self.serving(port=port, bind=bind, **serving_kwargs)
|
|
109
|
+
server.start()
|
|
110
|
+
base = f"http://{bind}:{server.port}"
|
|
111
|
+
caps = self._served_capability_ids()
|
|
112
|
+
# A first-run banner that SHOWS the payoff: the URL, what's served, a ready-to-paste
|
|
113
|
+
# invoke, and the replay hint (the evidence chain is the point). flush=True so it
|
|
114
|
+
# appears immediately even when stdout is piped/redirected, not just on a tty.
|
|
115
|
+
print(f"chp-server serving at {base} (Ctrl-C to stop)", flush=True)
|
|
116
|
+
if caps:
|
|
117
|
+
shown = ", ".join(caps[:8]) + (f" (+{len(caps) - 8} more)" if len(caps) > 8 else "")
|
|
118
|
+
print(f" {len(caps)} capabilit{'y' if len(caps) == 1 else 'ies'}: {shown}", flush=True)
|
|
119
|
+
print(" try it:", flush=True)
|
|
120
|
+
print(f" curl -s {base}/invoke -H 'Content-Type: application/json' \\", flush=True)
|
|
121
|
+
print(f" -d '{{\"capability_id\": \"{caps[0]}\", \"payload\": {{}}}}'", flush=True)
|
|
122
|
+
print(f" curl -s {base}/replay/<correlation_id> # the signed evidence chain",
|
|
123
|
+
flush=True)
|
|
124
|
+
else:
|
|
125
|
+
print(f" no capabilities registered — GET {base}/server for honest feature truth",
|
|
126
|
+
flush=True)
|
|
127
|
+
try:
|
|
128
|
+
server.serve_forever()
|
|
129
|
+
except KeyboardInterrupt:
|
|
130
|
+
pass
|
|
131
|
+
finally:
|
|
132
|
+
server.stop()
|
chp_server/cli.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""chp-server CLI — `chp serve` (via the chp-core shim) lands here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="chp-server",
|
|
13
|
+
description="Reference CHP server: chp-core protocol surface + optional attachments.")
|
|
14
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
|
|
16
|
+
serve = sub.add_parser("serve", help="Start the server (blocking).")
|
|
17
|
+
serve.add_argument("--bind", default=None)
|
|
18
|
+
serve.add_argument("--port", type=int, default=None)
|
|
19
|
+
serve.add_argument("--profile", default=None)
|
|
20
|
+
serve.add_argument("--config", default=None, metavar="FILE", help="JSON config file")
|
|
21
|
+
serve.add_argument("--store", default=None, metavar="PATH", help="Evidence store path")
|
|
22
|
+
serve.add_argument("--example", action="store_true",
|
|
23
|
+
help="Attach a few sample capabilities (greet.hello, math.add, "
|
|
24
|
+
"time.now) so the server is live and curl-able on first run.")
|
|
25
|
+
|
|
26
|
+
describe = sub.add_parser("describe", help="Print Server.Describe for a config without serving.")
|
|
27
|
+
describe.add_argument("--config", default=None, metavar="FILE")
|
|
28
|
+
describe.add_argument("--profile", default=None)
|
|
29
|
+
|
|
30
|
+
new = sub.add_parser("new", help="Scaffold a runnable starter capability server.")
|
|
31
|
+
new.add_argument("name", help="Starter file to create, e.g. 'mycaps' or 'mycaps.py'.")
|
|
32
|
+
new.add_argument("--force", action="store_true", help="Overwrite if the file exists.")
|
|
33
|
+
|
|
34
|
+
adapters = sub.add_parser(
|
|
35
|
+
"adapters", help="List installed chp-adapter-* capability sets you can compose().")
|
|
36
|
+
adapters.add_argument("--verbose", action="store_true",
|
|
37
|
+
help="Show each capability id + description.")
|
|
38
|
+
adapters.add_argument("--json", action="store_true", help="Emit the catalog as JSON.")
|
|
39
|
+
|
|
40
|
+
replay = sub.add_parser(
|
|
41
|
+
"replay", help="Pretty-print a GET /replay evidence chain (JSON from stdin or --file).")
|
|
42
|
+
replay.add_argument("--file", default=None, metavar="PATH",
|
|
43
|
+
help="Read the /replay JSON from a file instead of stdin.")
|
|
44
|
+
|
|
45
|
+
args = parser.parse_args(argv)
|
|
46
|
+
|
|
47
|
+
if args.command == "replay":
|
|
48
|
+
from .replay import Replay
|
|
49
|
+
if args.file:
|
|
50
|
+
try:
|
|
51
|
+
raw = open(args.file).read()
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
elif sys.stdin.isatty():
|
|
56
|
+
# No --file and an interactive stdin: NEVER block on stdin.read() (that hung a
|
|
57
|
+
# CLI invocation indefinitely). Show usage and exit instead.
|
|
58
|
+
print("chp-server replay: pipe a /replay response, or pass --file. e.g.\n"
|
|
59
|
+
" curl -s http://127.0.0.1:8800/replay/<correlation_id> | chp-server replay\n"
|
|
60
|
+
" chp-server replay --file replay.json", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
else:
|
|
63
|
+
raw = sys.stdin.read()
|
|
64
|
+
try:
|
|
65
|
+
doc = json.loads(raw)
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
print(f"ERROR: input is not valid JSON ({exc}). Pipe a /replay response, e.g.:\n"
|
|
68
|
+
" curl -s http://127.0.0.1:8800/replay/<correlation_id> | chp-server replay",
|
|
69
|
+
file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
print(Replay.from_wire(doc).render())
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
if args.command == "adapters":
|
|
75
|
+
from ._catalog import adapter_catalog
|
|
76
|
+
catalog = adapter_catalog()
|
|
77
|
+
if getattr(args, "json", False):
|
|
78
|
+
print(json.dumps(catalog, indent=2))
|
|
79
|
+
return 0
|
|
80
|
+
if not catalog:
|
|
81
|
+
print("No chp-adapter-* packages installed in this environment.\n"
|
|
82
|
+
"Install some (e.g. pip install chp-adapter-filesystem), then compose them:\n"
|
|
83
|
+
" app.compose(FilesystemAdapter(...))")
|
|
84
|
+
return 0
|
|
85
|
+
total = sum(len(a["capabilities"]) for a in catalog)
|
|
86
|
+
print(f"{len(catalog)} adapters installed ({total} capabilities) — "
|
|
87
|
+
"compose with app.compose(AdapterClass(...)):\n")
|
|
88
|
+
for a in catalog:
|
|
89
|
+
print(f" {a['name']:24} {len(a['capabilities']):>3} caps")
|
|
90
|
+
if getattr(args, "verbose", False):
|
|
91
|
+
for c in a["capabilities"]:
|
|
92
|
+
print(f" {c['id']} — {c['description'][:68]}")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
if args.command == "new":
|
|
96
|
+
from ._scaffold import write_starter
|
|
97
|
+
try:
|
|
98
|
+
path = write_starter(args.name, force=args.force)
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
print(f"Created {path} — run it with: python {path}")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
from .config import ServerConfig
|
|
106
|
+
from .server import Server
|
|
107
|
+
|
|
108
|
+
# --example needs a host to attach to; default it into the `host` profile
|
|
109
|
+
# unless the caller chose one explicitly.
|
|
110
|
+
example = getattr(args, "example", False)
|
|
111
|
+
profile = args.profile or ("host" if example else None)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
config = ServerConfig.from_sources(
|
|
115
|
+
config_file=args.config, bind=getattr(args, "bind", None),
|
|
116
|
+
port=getattr(args, "port", None), profile=profile,
|
|
117
|
+
store=getattr(args, "store", None))
|
|
118
|
+
except (ValueError, OSError) as exc:
|
|
119
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
server = Server(config)
|
|
123
|
+
|
|
124
|
+
example_dir = None
|
|
125
|
+
if example:
|
|
126
|
+
import os
|
|
127
|
+
import tempfile
|
|
128
|
+
from ._example import build_example_host
|
|
129
|
+
from .local import ExistingHostPort
|
|
130
|
+
example_dir = tempfile.mkdtemp(prefix="chp-example-")
|
|
131
|
+
server.attach(ExistingHostPort(
|
|
132
|
+
build_example_host(os.path.join(example_dir, "example.sqlite"))))
|
|
133
|
+
|
|
134
|
+
if args.command == "describe":
|
|
135
|
+
print(json.dumps(server.describe(), indent=2))
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
server.start()
|
|
140
|
+
except RuntimeError as exc: # fail-closed profile validation
|
|
141
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
print(f"chp-server {server.identity.instance_id} serving profile "
|
|
144
|
+
f"{config.profile!r} at http://{config.bind}:{server.port}")
|
|
145
|
+
print("Routes: GET /health /ready /server /host /capabilities, POST /invoke")
|
|
146
|
+
if example:
|
|
147
|
+
base = f"http://{config.bind}:{server.port}"
|
|
148
|
+
print("\nSample capabilities attached — try:")
|
|
149
|
+
print(f" curl {base}/capabilities.txt")
|
|
150
|
+
print(f" curl {base}/invoke -H 'Content-Type: application/json' \\")
|
|
151
|
+
print(" -d '{\"capability_id\": \"greet.hello\", \"payload\": {\"name\": \"CHP\"}}'")
|
|
152
|
+
try:
|
|
153
|
+
server.serve_forever()
|
|
154
|
+
except KeyboardInterrupt:
|
|
155
|
+
pass
|
|
156
|
+
finally:
|
|
157
|
+
server.stop()
|
|
158
|
+
print("\nStopped chp-server.")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
raise SystemExit(main())
|
chp_server/config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""ServerConfig — deterministic precedence + generations (doc 40).
|
|
2
|
+
|
|
3
|
+
Precedence: built-in defaults -> config file (JSON) -> environment -> explicit
|
|
4
|
+
programmatic overrides. Effective configuration is inspectable with secrets
|
|
5
|
+
redacted; unknown fields fail validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field, fields
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from chp_core.environment import current_environment, validate_environment
|
|
17
|
+
|
|
18
|
+
_ENV_PREFIX = "CHP_SERVER_"
|
|
19
|
+
_SECRET_FIELDS = ("tls_keyfile",)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ServerConfig:
|
|
24
|
+
bind: str = "127.0.0.1"
|
|
25
|
+
port: int = 8770
|
|
26
|
+
profile: str = "protocol-only"
|
|
27
|
+
environment: str = field(default_factory=current_environment)
|
|
28
|
+
host_id: str = "chp-server"
|
|
29
|
+
store: str | None = None # evidence store path; None = chp-core default
|
|
30
|
+
tls_certfile: str | None = None
|
|
31
|
+
tls_keyfile: str | None = None
|
|
32
|
+
tls_cafile: str | None = None
|
|
33
|
+
# attachments: {entry_point_name: {kwargs}} — Phase B config (doc 40 §3);
|
|
34
|
+
# only listed attachments are loaded even when more are installed.
|
|
35
|
+
attachments: dict[str, dict] = field(default_factory=dict)
|
|
36
|
+
# HA multi-instance (HA-003): when enabled, instances of one logical Host contend
|
|
37
|
+
# for a store-backed ownership lease; only the ACTIVE holder admits consequential
|
|
38
|
+
# work, standby refuses server_not_active. Requires a SHARED `store` all instances
|
|
39
|
+
# point at. Off = single-instance (always active). ttl = lease/heartbeat window.
|
|
40
|
+
ha_enabled: bool = False
|
|
41
|
+
ha_lease_ttl_s: float = 30.0
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
validate_environment(self.environment)
|
|
45
|
+
from .profiles import PROFILES
|
|
46
|
+
if self.profile not in PROFILES:
|
|
47
|
+
raise ValueError(f"unknown profile {self.profile!r}; valid: {sorted(PROFILES)}")
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_sources(cls, config_file: str | Path | None = None,
|
|
51
|
+
env: dict[str, str] | None = None, **overrides: Any) -> "ServerConfig":
|
|
52
|
+
env = os.environ if env is None else env
|
|
53
|
+
known = {f.name for f in fields(cls)}
|
|
54
|
+
merged: dict[str, Any] = {}
|
|
55
|
+
if config_file:
|
|
56
|
+
data = json.loads(Path(config_file).read_text())
|
|
57
|
+
unknown = set(data) - known
|
|
58
|
+
if unknown: # unknown fields fail closed (doc 40 §7)
|
|
59
|
+
raise ValueError(f"unknown configuration fields: {sorted(unknown)}")
|
|
60
|
+
merged.update(data)
|
|
61
|
+
for f in fields(cls):
|
|
62
|
+
key = _ENV_PREFIX + f.name.upper()
|
|
63
|
+
if key in env:
|
|
64
|
+
raw = env[key]
|
|
65
|
+
merged[f.name] = int(raw) if f.type == "int" else raw
|
|
66
|
+
merged.update({k: v for k, v in overrides.items() if v is not None})
|
|
67
|
+
unknown = set(merged) - known
|
|
68
|
+
if unknown:
|
|
69
|
+
raise ValueError(f"unknown configuration fields: {sorted(unknown)}")
|
|
70
|
+
return cls(**merged)
|
|
71
|
+
|
|
72
|
+
def redacted(self) -> dict:
|
|
73
|
+
d = {f.name: getattr(self, f.name) for f in fields(self)}
|
|
74
|
+
for k in _SECRET_FIELDS:
|
|
75
|
+
if d.get(k):
|
|
76
|
+
d[k] = "<redacted>"
|
|
77
|
+
return d
|