sm-divergence 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ """sm-divergence — catch a cheating registry by corroboration.
2
+
3
+ A registry that agents discover each other through can lie by omission (hide a
4
+ record), by tampering (serve a false endpoint), or by equivocation (different
5
+ answers to different clients). No signature on a single record can prove what a
6
+ registry chose not to serve. With two or more registries, this library asks all
7
+ of them the same questions and reports any disagreement: omission, endpoint, or
8
+ key-identity (DID) divergence.
9
+
10
+ Layout:
11
+ - ``sm-resolver`` (dependency) — the source-agnostic kernel: ``Resolver[T]``,
12
+ the ``View`` contract, ``diff_views``, ``Corroborator``, ``Finding``.
13
+ - ``sm_divergence.discovery`` — the registry-integrity layer: ``RecordView``
14
+ plus the ``HttpByIdResolver`` (NEST) and ``NandaIndexV2Resolver`` adapters.
15
+
16
+ Quick start::
17
+
18
+ import asyncio
19
+ from sm_divergence import check_once
20
+
21
+ findings = asyncio.run(check_once(
22
+ ["https://registry-a.example", "https://registry-b.example"],
23
+ ["agent-1", "agent-2"],
24
+ ))
25
+ for f in findings:
26
+ print(f.kind, f.agent_id, f.detail)
27
+ """
28
+
29
+ from sm_resolver import OMISSION, Corroborator, Finding, OnFinding, Resolver, Status, View, ViewT, diff_views
30
+
31
+ from .detector import DivergenceDetector, check_once
32
+ from .discovery import (
33
+ DEFAULT_PATH,
34
+ DID,
35
+ ENDPOINT,
36
+ HttpByIdResolver,
37
+ NandaIndexV2Resolver,
38
+ RecordAdapter,
39
+ RecordView,
40
+ binds_by_did,
41
+ default_adapter,
42
+ fetch_view,
43
+ identity_locator,
44
+ looks_absent,
45
+ signed_record_adapter,
46
+ verified_did,
47
+ )
48
+ from .identity import (
49
+ KEY,
50
+ AliasClosureResolver,
51
+ DescriptionReconciliation,
52
+ DidKeyResolver,
53
+ DidView,
54
+ DidWebResolver,
55
+ HttpDescriptionResolver,
56
+ SelfDescription,
57
+ UniversalResolverResolver,
58
+ binds_by_key,
59
+ build_self_description,
60
+ corroborate_descriptions,
61
+ did_web_url,
62
+ identity_did,
63
+ key_from_did_document,
64
+ key_from_did_key,
65
+ reconcile_descriptions,
66
+ signed_alias_for,
67
+ verified_descriptions,
68
+ verify_self_description,
69
+ )
70
+
71
+ __version__ = "0.7.0"
72
+
73
+ __all__ = [
74
+ "DEFAULT_PATH",
75
+ "DID",
76
+ "ENDPOINT",
77
+ "KEY",
78
+ "OMISSION",
79
+ "AliasClosureResolver",
80
+ "Corroborator",
81
+ "DescriptionReconciliation",
82
+ "DidKeyResolver",
83
+ "DidView",
84
+ "DidWebResolver",
85
+ "DivergenceDetector",
86
+ "Finding",
87
+ "HttpByIdResolver",
88
+ "HttpDescriptionResolver",
89
+ "NandaIndexV2Resolver",
90
+ "OnFinding",
91
+ "RecordAdapter",
92
+ "RecordView",
93
+ "Resolver",
94
+ "SelfDescription",
95
+ "Status",
96
+ "UniversalResolverResolver",
97
+ "View",
98
+ "ViewT",
99
+ "binds_by_did",
100
+ "binds_by_key",
101
+ "build_self_description",
102
+ "check_once",
103
+ "corroborate_descriptions",
104
+ "default_adapter",
105
+ "diff_views",
106
+ "did_web_url",
107
+ "fetch_view",
108
+ "identity_did",
109
+ "identity_locator",
110
+ "key_from_did_document",
111
+ "key_from_did_key",
112
+ "looks_absent",
113
+ "reconcile_descriptions",
114
+ "signed_alias_for",
115
+ "signed_record_adapter",
116
+ "verified_descriptions",
117
+ "verified_did",
118
+ "verify_self_description",
119
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,88 @@
1
+ """Shared Ed25519 / ``did:key`` / JCS primitives — the self-certifying substrate.
2
+
3
+ Used by the discovery layer's verified-DID adapter and the identity layer's
4
+ self-description. Behind the ``attestation`` extra (``cryptography``, ``base58``,
5
+ ``jcs``); every function raises a clear error if the extra is absent, so the base
6
+ install still runs the crypto-free checks (omission, endpoint, multibase key).
7
+
8
+ A ``did:key`` *is* the public key, so everything here verifies offline — no
9
+ network, no external trust.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ from collections.abc import Mapping
16
+ from typing import Any
17
+
18
+ try:
19
+ import base58
20
+ import jcs # type: ignore[import-untyped]
21
+ from cryptography.exceptions import InvalidSignature
22
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
23
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
24
+
25
+ _HAVE_CRYPTO = True
26
+ except ImportError: # pragma: no cover - exercised only without the extra
27
+ _HAVE_CRYPTO = False
28
+
29
+ _ED25519_MULTICODEC = b"\xed\x01"
30
+ _EXTRA = "requires the 'attestation' extra: pip install sm-divergence[attestation]"
31
+
32
+
33
+ def _require() -> None:
34
+ if not _HAVE_CRYPTO:
35
+ raise RuntimeError(_EXTRA)
36
+
37
+
38
+ def canonical(obj: Mapping[str, Any]) -> bytes:
39
+ """RFC 8785 (JCS) canonical bytes — byte-identical for signer and verifier."""
40
+ _require()
41
+ result: bytes = jcs.canonicalize(obj)
42
+ return result
43
+
44
+
45
+ def pubkey_from_did_key(did: str) -> bytes | None:
46
+ """The 32-byte Ed25519 public key a ``did:key`` encodes, or None."""
47
+ _require()
48
+ if not did.startswith("did:key:z"):
49
+ return None
50
+ try:
51
+ raw = base58.b58decode(did[len("did:key:z") :])
52
+ except Exception:
53
+ return None
54
+ if len(raw) != 34 or raw[:2] != _ED25519_MULTICODEC:
55
+ return None
56
+ return bytes(raw[2:])
57
+
58
+
59
+ def did_key_from_pubkey(pubkey: bytes) -> str:
60
+ """The ``did:key`` for an Ed25519 public key (multicodec + base58btc)."""
61
+ _require()
62
+ encoded: str = base58.b58encode(_ED25519_MULTICODEC + pubkey).decode()
63
+ return "did:key:z" + encoded
64
+
65
+
66
+ def public_from_private(private_key: bytes) -> bytes:
67
+ """The 32-byte Ed25519 public key for a 32-byte private key."""
68
+ _require()
69
+ pub: bytes = (
70
+ Ed25519PrivateKey.from_private_bytes(private_key).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
71
+ )
72
+ return pub
73
+
74
+
75
+ def sign(private_key: bytes, message: bytes) -> str:
76
+ """Base64 Ed25519 signature over ``message`` with a 32-byte private key."""
77
+ _require()
78
+ return base64.b64encode(Ed25519PrivateKey.from_private_bytes(private_key).sign(message)).decode()
79
+
80
+
81
+ def verify(pubkey: bytes, message: bytes, sig_b64: str) -> bool:
82
+ """True iff ``sig_b64`` is a valid Ed25519 signature of ``message``. Never raises."""
83
+ _require()
84
+ try:
85
+ Ed25519PublicKey.from_public_bytes(pubkey).verify(base64.b64decode(sig_b64), message)
86
+ return True
87
+ except (InvalidSignature, Exception):
88
+ return False
sm_divergence/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """``python -m sm_divergence`` — one-shot divergence check for cron / CI.
2
+
3
+ python -m sm_divergence check \
4
+ --registry https://registry-a.example \
5
+ --registry https://registry-b.example \
6
+ --watch agent-1 --watch agent-2
7
+
8
+ Exit code is 0 when the registries agree, 2 when any divergence is found (so a
9
+ cron or CI step fails loudly), and 1 on a usage error.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import json
17
+ import sys
18
+ from collections.abc import Sequence
19
+
20
+ from sm_resolver import Finding, Resolver
21
+
22
+ from .detector import check_once
23
+ from .discovery import DEFAULT_PATH, NandaIndexV2Resolver, RecordAdapter, RecordView, default_adapter
24
+
25
+ EXIT_OK = 0
26
+ EXIT_USAGE = 1
27
+ EXIT_DIVERGENCE = 2
28
+
29
+
30
+ def _build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(prog="sm_divergence", description="Catch a cheating registry by corroboration.")
32
+ sub = parser.add_subparsers(dest="command", required=True)
33
+
34
+ check = sub.add_parser("check", help="Compare registries for a set of ids.")
35
+ check.add_argument(
36
+ "--registry",
37
+ action="append",
38
+ default=[],
39
+ metavar="URL",
40
+ help="NEST-style by-id registry base URL (repeatable).",
41
+ )
42
+ check.add_argument(
43
+ "--nanda-index",
44
+ action="append",
45
+ default=[],
46
+ metavar="URL",
47
+ help="NANDA Index v2 base URL — two-hop resolve (repeatable). Watch ids are used as locators.",
48
+ )
49
+ check.add_argument("--watch", action="append", default=[], metavar="ID", help="Agent id to compare (repeatable).")
50
+ check.add_argument(
51
+ "--path", default=DEFAULT_PATH, help=f"By-id path template for --registry (default: {DEFAULT_PATH})."
52
+ )
53
+ check.add_argument("--timeout", type=float, default=10.0, help="Per-request timeout seconds (default: 10).")
54
+ check.add_argument(
55
+ "--attestation",
56
+ action="store_true",
57
+ help="Also compare verified DIDs from signed records (needs the 'attestation' extra).",
58
+ )
59
+ check.add_argument("--json", action="store_true", help="Emit findings as JSON.")
60
+ return parser
61
+
62
+
63
+ def _format_text(findings: list[Finding]) -> str:
64
+ if not findings:
65
+ return "OK — registries agree on all watched ids."
66
+ lines = [f"DIVERGENCE — {len(findings)} finding(s):"]
67
+ for f in findings:
68
+ lines.append(f" [{f.kind}] {f.agent_id}: {json.dumps(f.detail, sort_keys=True)}")
69
+ return "\n".join(lines)
70
+
71
+
72
+ async def _run_check(args: argparse.Namespace) -> int:
73
+ watch = [w for w in args.watch if w]
74
+ http_registries = [r for r in args.registry if r]
75
+ index_urls = [u for u in args.nanda_index if u]
76
+ if len(http_registries) + len(index_urls) < 2:
77
+ print("error: need at least two registries (--registry / --nanda-index) to corroborate", file=sys.stderr)
78
+ return EXIT_USAGE
79
+ if not watch:
80
+ print("error: need at least one --watch id", file=sys.stderr)
81
+ return EXIT_USAGE
82
+
83
+ adapter: RecordAdapter = default_adapter
84
+ if args.attestation:
85
+ from .discovery import signed_record_adapter
86
+
87
+ adapter = signed_record_adapter()
88
+
89
+ registries: list[str | Resolver[RecordView]] = list(http_registries)
90
+ registries += [NandaIndexV2Resolver(u, adapter=adapter, timeout=args.timeout) for u in index_urls]
91
+
92
+ findings = await check_once(registries, watch, adapter=adapter, path=args.path, timeout=args.timeout)
93
+
94
+ if args.json:
95
+ print(json.dumps([f.to_dict() for f in findings], sort_keys=True, indent=2))
96
+ else:
97
+ print(_format_text(findings))
98
+ return EXIT_DIVERGENCE if findings else EXIT_OK
99
+
100
+
101
+ def main(argv: Sequence[str] | None = None) -> int:
102
+ args = _build_parser().parse_args(argv)
103
+ if args.command == "check":
104
+ return asyncio.run(_run_check(args))
105
+ return EXIT_USAGE
106
+
107
+
108
+ if __name__ == "__main__":
109
+ raise SystemExit(main())
@@ -0,0 +1,61 @@
1
+ """The discovery-layer product: a ``Corroborator`` specialized to registries.
2
+
3
+ ``DivergenceDetector`` is a thin convenience over the core ``Corroborator`` — it
4
+ accepts bare registry URLs (wrapping them in ``HttpByIdResolver``) alongside
5
+ ``Resolver`` instances of any format, so ``check_once(["https://a", "https://b"])``
6
+ keeps working while heterogeneous resolvers mix freely. The generic orchestration
7
+ lives in ``sm-resolver`` (the kernel); this file only adds the discovery defaults.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable
13
+
14
+ from sm_resolver import Corroborator, Finding, OnFinding, Resolver
15
+
16
+ from .discovery import DEFAULT_PATH, HttpByIdResolver, RecordAdapter, RecordView, default_adapter
17
+
18
+ __all__ = ["DivergenceDetector", "OnFinding", "check_once"]
19
+
20
+
21
+ class DivergenceDetector(Corroborator[RecordView]):
22
+ """Cross-registry divergence detector.
23
+
24
+ Point it at two or more registries — each a base URL (wrapped in an
25
+ ``HttpByIdResolver`` with the shared ``adapter``/``path``) or a ``Resolver``
26
+ instance for a registry of a different format (e.g. ``NandaIndexV2Resolver``).
27
+ Registries of different formats mix freely; each normalizes to a
28
+ ``RecordView`` so the diff treats them uniformly. Fewer than two → no-op.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ registries: Iterable[str | Resolver[RecordView]],
34
+ *,
35
+ adapter: RecordAdapter = default_adapter,
36
+ path: str = DEFAULT_PATH,
37
+ on_finding: OnFinding | None = None,
38
+ timeout: float = 10.0,
39
+ ) -> None:
40
+ resolvers: list[Resolver[RecordView]] = [
41
+ HttpByIdResolver(r, adapter=adapter, path=path, timeout=timeout) if isinstance(r, str) else r
42
+ for r in registries
43
+ ]
44
+ super().__init__(resolvers, on_finding=on_finding)
45
+
46
+ @property
47
+ def registries(self) -> list[str]:
48
+ """The registry labels being compared (back-compat + introspection)."""
49
+ return self.labels
50
+
51
+
52
+ async def check_once(
53
+ registries: Iterable[str | Resolver[RecordView]],
54
+ watch_ids: Iterable[str],
55
+ *,
56
+ adapter: RecordAdapter = default_adapter,
57
+ path: str = DEFAULT_PATH,
58
+ timeout: float = 10.0,
59
+ ) -> list[Finding]:
60
+ """One-shot convenience: build a detector and run a single check."""
61
+ return await DivergenceDetector(registries, adapter=adapter, path=path, timeout=timeout).check(watch_ids)
@@ -0,0 +1,32 @@
1
+ """sm-divergence discovery layer — the registry-integrity instantiation.
2
+
3
+ Supplies the discovery view type (``RecordView``) and the format resolvers
4
+ (``HttpByIdResolver`` for NEST-style by-id registries, ``NandaIndexV2Resolver``
5
+ for the NANDA Index two-hop resolve), all built on the source-agnostic core.
6
+ Each resolver is a thin adapter; adding another registry format is another file
7
+ here, with no change to the core diff.
8
+ """
9
+
10
+ from .attestation import signed_record_adapter, verified_did
11
+ from .client import DEFAULT_PATH, RecordAdapter, default_adapter, fetch_view, looks_absent
12
+ from .closure import binds_by_did
13
+ from .http_by_id import HttpByIdResolver
14
+ from .index_v2 import NandaIndexV2Resolver, identity_locator
15
+ from .views import DID, ENDPOINT, RecordView
16
+
17
+ __all__ = [
18
+ "DEFAULT_PATH",
19
+ "DID",
20
+ "ENDPOINT",
21
+ "HttpByIdResolver",
22
+ "NandaIndexV2Resolver",
23
+ "RecordAdapter",
24
+ "RecordView",
25
+ "binds_by_did",
26
+ "default_adapter",
27
+ "fetch_view",
28
+ "identity_locator",
29
+ "looks_absent",
30
+ "signed_record_adapter",
31
+ "verified_did",
32
+ ]
@@ -0,0 +1,81 @@
1
+ """Optional: a record adapter that extracts a *verified* DID.
2
+
3
+ Endpoint and omission divergence need no crypto. The ``did`` divergence — two
4
+ registries serving records that claim different key identities — only means
5
+ something if each DID was actually proven, so this module verifies a signed,
6
+ self-certifying record before contributing its DID to the comparison.
7
+
8
+ Reference format (a common self-certifying-record convention, not tied to any
9
+ one registry):
10
+
11
+ {
12
+ "record": { ... , "did": "did:key:z6Mk...", "endpoint": "https://..." },
13
+ "sig": "<base64 Ed25519 signature over the JCS-canonical record>"
14
+ }
15
+
16
+ The DID is verified against ITSELF: a ``did:key`` encodes the Ed25519 public
17
+ key, so the signature is checked with no external lookup. A record whose
18
+ signature doesn't verify (or whose subject doesn't match) contributes no DID.
19
+
20
+ Requires the ``attestation`` extra: ``pip install sm-divergence[attestation]``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from .. import _signing
28
+ from .client import RecordAdapter
29
+ from .views import RecordView
30
+
31
+
32
+ def verified_did(attestation: Any) -> str | None:
33
+ """The DID of a signed record iff its signature verifies against the key
34
+ the DID itself encodes; else None. Never raises."""
35
+ if not isinstance(attestation, dict):
36
+ return None
37
+ record = attestation.get("record")
38
+ sig_b64 = attestation.get("sig")
39
+ if not isinstance(record, dict) or not isinstance(sig_b64, str) or not sig_b64:
40
+ return None
41
+ did = record.get("did")
42
+ if not isinstance(did, str):
43
+ return None
44
+ pub = _signing.pubkey_from_did_key(did)
45
+ if pub is None:
46
+ return None
47
+ if not _signing.verify(pub, _signing.canonical(record), sig_b64):
48
+ return None
49
+ return did
50
+
51
+
52
+ def signed_record_adapter(
53
+ endpoint_field: str = "endpoint",
54
+ attestation_field: str = "attestation",
55
+ *,
56
+ require_subject_match: bool = True,
57
+ ) -> RecordAdapter:
58
+ """A ``RecordAdapter`` that maps a registry record to endpoint + VERIFIED DID.
59
+
60
+ The DID is taken only from a valid attestation under ``attestation_field``.
61
+ When ``require_subject_match`` is set (default), the attestation's
62
+ ``record.agent_id`` must equal the outer record's — a registry can't dress
63
+ one agent's record in another's valid attestation.
64
+ """
65
+
66
+ def _adapt(record: dict[str, Any]) -> RecordView:
67
+ ep = record.get(endpoint_field)
68
+ endpoint = str(ep).rstrip("/") if isinstance(ep, str) and ep else None
69
+
70
+ did: str | None = None
71
+ att = record.get(attestation_field)
72
+ vdid = verified_did(att) if att is not None else None
73
+ if vdid is not None:
74
+ inner = att.get("record", {}) if isinstance(att, dict) else {}
75
+ subject = inner.get("agent_id")
76
+ outer = record.get("agent_id")
77
+ if not require_subject_match or subject is None or outer is None or str(subject) == str(outer):
78
+ did = vdid
79
+ return RecordView(endpoint=endpoint, did=did)
80
+
81
+ return _adapt
@@ -0,0 +1,73 @@
1
+ """Fetch one registry's claim about one id over HTTP, reduced to a RecordView.
2
+
3
+ The HTTP path and the record→RecordView mapping are both injectable, so this
4
+ works against any registry that answers a by-id query. The default targets the
5
+ common NEST-style ``GET /api/agents/{id}`` returning JSON with a top-level
6
+ ``endpoint``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ import httpx
15
+ from sm_resolver import Status
16
+
17
+ from .views import RecordView
18
+
19
+ # Maps a raw registry record (parsed JSON dict) to the discovery view.
20
+ RecordAdapter = Callable[[dict[str, Any]], RecordView]
21
+
22
+ DEFAULT_PATH = "/api/agents/{id}"
23
+
24
+
25
+ def default_adapter(record: dict[str, Any]) -> RecordView:
26
+ """Read a top-level ``endpoint`` string; no DID (see
27
+ ``sm_divergence.discovery.attestation`` for a signed-record adapter)."""
28
+ ep = record.get("endpoint")
29
+ return RecordView(endpoint=str(ep).rstrip("/") if isinstance(ep, str) and ep else None)
30
+
31
+
32
+ def looks_absent(doc: dict[str, Any]) -> bool:
33
+ """A 200 body that actually means 'not found' — some registries soft-404
34
+ with ``{"success": false, "error": "... not found"}`` instead of a 404.
35
+ Shared by every HTTP resolver so soft-404 is classified consistently."""
36
+ if doc.get("success") is False:
37
+ return True
38
+ return "not found" in str(doc.get("error") or "").lower()
39
+
40
+
41
+ async def fetch_view(
42
+ client: httpx.AsyncClient,
43
+ registry_url: str,
44
+ agent_id: str,
45
+ *,
46
+ adapter: RecordAdapter = default_adapter,
47
+ path: str = DEFAULT_PATH,
48
+ timeout: float = 10.0,
49
+ ) -> tuple[Status, RecordView | None]:
50
+ """One registry's claim about one id (``present`` / ``absent`` / ``error``).
51
+ Never raises."""
52
+ url = registry_url.rstrip("/") + path.replace("{id}", agent_id)
53
+ try:
54
+ resp = await client.get(url, timeout=timeout)
55
+ except Exception:
56
+ return "error", None
57
+ if resp.status_code == 404:
58
+ return "absent", None
59
+ if resp.status_code != 200:
60
+ return "error", None
61
+ try:
62
+ doc = resp.json()
63
+ except Exception:
64
+ return "error", None
65
+ if not isinstance(doc, dict):
66
+ return "error", None
67
+ if looks_absent(doc):
68
+ return "absent", None
69
+ try:
70
+ return "present", adapter(doc)
71
+ except Exception:
72
+ # A malformed record the adapter can't map is not a claim of absence.
73
+ return "error", None
@@ -0,0 +1,15 @@
1
+ """Discovery bind-back predicate for alias closure (see
2
+ ``sm_divergence.identity.AliasClosureResolver``).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from .views import RecordView
8
+
9
+
10
+ def binds_by_did(view: RecordView, expected_did: str) -> bool:
11
+ """Discovery bind-back: the record's VERIFIED did must equal the expected
12
+ ``did:key``. A record with no verified did (no valid attestation) cannot bind
13
+ back, so it contributes no claim — you cannot safely follow a claimed
14
+ discovery alias without a record that proves it is the same agent."""
15
+ return view.did is not None and view.did == expected_did
@@ -0,0 +1,54 @@
1
+ """HttpByIdResolver — the ``GET {base}/api/agents/{id}`` registry shape (e.g. NEST).
2
+
3
+ The reference discovery resolver: a thin adapter mapping a single-hop by-id HTTP
4
+ registry onto the core ``Resolver`` protocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+ from sm_resolver import Status
11
+
12
+ from .client import DEFAULT_PATH, RecordAdapter, default_adapter, fetch_view
13
+ from .views import RecordView
14
+
15
+
16
+ class HttpByIdResolver:
17
+ """A registry that serves ``GET {base}/api/agents/{id}``.
18
+
19
+ The record path and the record→view mapping are injectable, so this also
20
+ covers any single-hop by-id HTTP registry. ``transport`` is an injectable
21
+ httpx transport for tests; ``None`` uses the real network.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ base_url: str,
27
+ *,
28
+ adapter: RecordAdapter = default_adapter,
29
+ path: str = DEFAULT_PATH,
30
+ timeout: float = 10.0,
31
+ transport: httpx.AsyncBaseTransport | None = None,
32
+ label: str | None = None,
33
+ ) -> None:
34
+ self.base_url = base_url.rstrip("/")
35
+ self.adapter = adapter
36
+ self.path = path
37
+ self.timeout = timeout
38
+ self._transport = transport
39
+ self._label = label or self.base_url
40
+
41
+ @property
42
+ def label(self) -> str:
43
+ return self._label
44
+
45
+ async def resolve(self, agent_id: str) -> tuple[Status, RecordView | None]:
46
+ async with httpx.AsyncClient(transport=self._transport) as client:
47
+ return await fetch_view(
48
+ client,
49
+ self.base_url,
50
+ agent_id,
51
+ adapter=self.adapter,
52
+ path=self.path,
53
+ timeout=self.timeout,
54
+ )