From b5b5b7bda0a6d8bbe031f28def110c2ef16f54a2 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 3 May 2026 13:13:30 -0400 Subject: [PATCH 1/6] Add service-backed Agent Registry backends --- src/agent_term/agent_registry_service.py | 218 +++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/agent_term/agent_registry_service.py diff --git a/src/agent_term/agent_registry_service.py b/src/agent_term/agent_registry_service.py new file mode 100644 index 0000000..ed248eb --- /dev/null +++ b/src/agent_term/agent_registry_service.py @@ -0,0 +1,218 @@ +"""Service-backed Agent Registry backends. + +AgentTerm is not the authority for agent identity. This module adds file and HTTP +service seams behind the existing AgentRegistryBackend protocol while keeping CI +offline-safe and fail-closed. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urljoin +from urllib.request import Request, urlopen + +from agent_term.agent_registry import AgentRegistration, AgentRegistryBackend, ToolGrant +from agent_term.config import AgentTermConfig + + +class AgentRegistryServiceError(RuntimeError): + """Raised when a service-backed Agent Registry lookup cannot be completed.""" + + +@dataclass(frozen=True) +class AgentRegistryServiceConfig: + """Configuration for service-backed Agent Registry lookups.""" + + endpoint_url: str | None = None + fixture_path: str | None = None + token_env: str = "AGENT_TERM_AGENT_REGISTRY_TOKEN" + timeout_seconds: float = 5.0 + + +class JsonFileAgentRegistryBackend: + """Agent Registry backend backed by a local JSON fixture file. + + Supported shape: + + ```json + { + "agents": [ + {"agent_id": "agent.codex", "registry_ref": "...", "spec_version": "v1"} + ], + "tool_grants": [ + {"grant_id": "grant.repo-write", "agent_id": "agent.codex", "tool": "repo-write"} + ] + } + ``` + """ + + def __init__(self, path: Path | str) -> None: + self.path = Path(path) + self._agents, self._grants = self._load() + + def resolve_agent(self, agent_id: str) -> AgentRegistration | None: + return self._agents.get(agent_id) + + def resolve_tool_grant(self, agent_id: str, tool: str) -> ToolGrant | None: + return self._grants.get((agent_id, tool)) + + def _load(self) -> tuple[dict[str, AgentRegistration], dict[tuple[str, str], ToolGrant]]: + with self.path.open("r", encoding="utf-8") as handle: + raw = json.load(handle) + if not isinstance(raw, dict): + raise ValueError("Agent Registry fixture must be a JSON object") + + agents = { + registration.agent_id: registration + for registration in (_agent_from_record(record) for record in _records(raw.get("agents"))) + } + grants = { + (grant.agent_id, grant.tool): grant + for grant in (_grant_from_record(record) for record in _records(raw.get("tool_grants"))) + } + return agents, grants + + +class HttpAgentRegistryBackend: + """Minimal HTTP Agent Registry backend. + + Expected endpoints are intentionally small and stable: + + - `GET {endpoint}/agents/{agent_id}` returns an agent registration object or 404. + - `GET {endpoint}/agents/{agent_id}/grants/{tool}` returns a tool grant object or 404. + + A bearer token is optional and read from an environment variable, never JSON config. + """ + + def __init__( + self, + *, + endpoint_url: str, + token: str | None = None, + timeout_seconds: float = 5.0, + ) -> None: + self.endpoint_url = endpoint_url.rstrip("/") + "/" + self.token = token + self.timeout_seconds = timeout_seconds + + def resolve_agent(self, agent_id: str) -> AgentRegistration | None: + record = self._get_json(f"agents/{quote(agent_id, safe='')}") + return _agent_from_record(record) if record is not None else None + + def resolve_tool_grant(self, agent_id: str, tool: str) -> ToolGrant | None: + record = self._get_json( + f"agents/{quote(agent_id, safe='')}/grants/{quote(tool, safe='')}" + ) + return _grant_from_record(record) if record is not None else None + + def _get_json(self, path: str) -> dict[str, Any] | None: + url = urljoin(self.endpoint_url, path) + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + request = Request(url, headers=headers, method="GET") + try: + with urlopen(request, timeout=self.timeout_seconds) as response: # noqa: S310 + raw = response.read().decode("utf-8") + except HTTPError as exc: + if exc.code == 404: + return None + raise AgentRegistryServiceError(f"Agent Registry HTTP error {exc.code}: {url}") from exc + except URLError as exc: + raise AgentRegistryServiceError(f"Agent Registry connection error: {url}") from exc + value = json.loads(raw) + if not isinstance(value, dict): + raise AgentRegistryServiceError("Agent Registry response must be a JSON object") + return value + + +def build_agent_registry_backend_from_config( + config: AgentTermConfig, + *, + fallback: AgentRegistryBackend, +) -> AgentRegistryBackend: + """Build an Agent Registry backend from config, falling back to local fixtures. + + Config can point to a local fixture or HTTP endpoint. If neither is configured, + the provided fallback backend is returned. + """ + + fixture_path = getattr(config.agent_registration, "fixture_path", None) + if fixture_path: + return JsonFileAgentRegistryBackend(fixture_path) + + endpoint_url = getattr(config.agent_registration, "endpoint_url", None) + if endpoint_url: + token_env = getattr(config.agent_registration, "token_env", "AGENT_TERM_AGENT_REGISTRY_TOKEN") + timeout_seconds = float(getattr(config.agent_registration, "timeout_seconds", 5.0)) + return HttpAgentRegistryBackend( + endpoint_url=endpoint_url, + token=os.environ.get(token_env), + timeout_seconds=timeout_seconds, + ) + + return fallback + + +def _records(value: object) -> list[dict[str, Any]]: + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + if isinstance(value, dict): + return [item for item in value.values() if isinstance(item, dict)] + return [] + + +def _agent_from_record(record: dict[str, Any]) -> AgentRegistration: + agent_id = str(record.get("agent_id") or record.get("id") or record.get("agentId")) + tool_grants_raw = record.get("tool_grants") or record.get("toolGrants") or [] + tool_grants = frozenset(str(item) for item in tool_grants_raw if item is not None) + known = { + "agent_id", + "id", + "agentId", + "registry_ref", + "registryRef", + "spec_version", + "specVersion", + "runtime_authority", + "runtimeAuthority", + "status", + "session_id", + "sessionId", + "tool_grants", + "toolGrants", + "revoked", + } + return AgentRegistration( + agent_id=agent_id, + registry_ref=str(record.get("registry_ref") or record.get("registryRef") or agent_id), + spec_version=str(record.get("spec_version") or record.get("specVersion") or "unknown"), + runtime_authority=str( + record.get("runtime_authority") or record.get("runtimeAuthority") or "agent-registry" + ), + status=str(record.get("status") or "registered"), + session_id=_optional_str(record.get("session_id") or record.get("sessionId")), + tool_grants=tool_grants, + revoked=bool(record.get("revoked", False)), + metadata={key: value for key, value in record.items() if key not in known}, + ) + + +def _grant_from_record(record: dict[str, Any]) -> ToolGrant: + known = {"grant_id", "grantId", "agent_id", "agentId", "tool", "status"} + return ToolGrant( + grant_id=str(record.get("grant_id") or record.get("grantId")), + agent_id=str(record.get("agent_id") or record.get("agentId")), + tool=str(record.get("tool")), + status=str(record.get("status") or "active"), + metadata={key: value for key, value in record.items() if key not in known}, + ) + + +def _optional_str(value: object) -> str | None: + return str(value) if value is not None else None From 0e888d47f0065dbd11e76e38be37f322d1c1ef8c Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 3 May 2026 13:16:23 -0400 Subject: [PATCH 2/6] Add Agent Registry service config fields --- src/agent_term/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/agent_term/config.py b/src/agent_term/config.py index b62366f..c2f72fb 100644 --- a/src/agent_term/config.py +++ b/src/agent_term/config.py @@ -41,6 +41,10 @@ class AgentRegistrationConfig: fail_closed_when_registry_unavailable: bool = True repository: str = "SocioProphet/agent-registry" required_for: tuple[str, ...] = () + fixture_path: str | None = None + endpoint_url: str | None = None + token_env: str = "AGENT_TERM_AGENT_REGISTRY_TOKEN" + timeout_seconds: float = 5.0 @dataclass(frozen=True) @@ -158,6 +162,10 @@ def config_from_dict(raw: dict[str, Any]) -> AgentTermConfig: ), repository=str(registration_raw.get("repository") or "SocioProphet/agent-registry"), required_for=tuple(str(item) for item in _list(registration_raw.get("requiredFor"))), + fixture_path=_optional_str(registration_raw.get("fixturePath")), + endpoint_url=_optional_str(registration_raw.get("endpointUrl")), + token_env=str(registration_raw.get("tokenEnv") or "AGENT_TERM_AGENT_REGISTRY_TOKEN"), + timeout_seconds=float(registration_raw.get("timeoutSeconds") or 5.0), ), planes=planes, participants=participants, From 392869b14bb52a60c14fbab9ba7b89b3976f5708 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 3 May 2026 13:20:46 -0400 Subject: [PATCH 3/6] Use service-backed Agent Registry backend in dispatch CLI --- src/agent_term/dispatch_cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agent_term/dispatch_cli.py b/src/agent_term/dispatch_cli.py index 5afe562..2f9f7c2 100644 --- a/src/agent_term/dispatch_cli.py +++ b/src/agent_term/dispatch_cli.py @@ -9,6 +9,7 @@ from agent_term.agent_registry import AgentRegistration, AgentRegistryAdapter from agent_term.agent_registry import InMemoryAgentRegistryBackend, ToolGrant +from agent_term.agent_registry_service import build_agent_registry_backend_from_config from agent_term.agentplane import AgentPlaneAdapter, InMemoryAgentPlaneBackend from agent_term.cloudshell_fog import CloudShellFogAdapter, InMemoryCloudShellFogBackend from agent_term.config import AgentTermConfig, load_config @@ -115,7 +116,7 @@ def build_event(args: argparse.Namespace, config: AgentTermConfig) -> AgentTermE ) -def build_registry_backend(args: argparse.Namespace, config: AgentTermConfig) -> InMemoryAgentRegistryBackend: +def build_registry_backend(args: argparse.Namespace, config: AgentTermConfig): agent_ids = set(config.local_runtime.registered_agents) agent_ids.update(args.register_agent) agent_id = args.agent_id or config.participant_agent_id(args.source) @@ -132,7 +133,8 @@ def build_registry_backend(args: argparse.Namespace, config: AgentTermConfig) -> for agent_id in sorted(agent_ids) ] grants = [_parse_grant(raw) for raw in (*config.local_runtime.tool_grants, *args.grant)] - return InMemoryAgentRegistryBackend(agents=agents, grants=grants) + fallback = InMemoryAgentRegistryBackend(agents=agents, grants=grants) + return build_agent_registry_backend_from_config(config, fallback=fallback) def _parse_grant(raw: str) -> ToolGrant: From eda4405824b9c9483823e0ff227e517fec618d53 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 3 May 2026 13:25:34 -0400 Subject: [PATCH 4/6] Add Agent Registry service backend tests --- tests/test_agent_registry_service.py | 161 +++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/test_agent_registry_service.py diff --git a/tests/test_agent_registry_service.py b/tests/test_agent_registry_service.py new file mode 100644 index 0000000..91bfa7e --- /dev/null +++ b/tests/test_agent_registry_service.py @@ -0,0 +1,161 @@ +import json +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Thread + +from agent_term.agent_registry import InMemoryAgentRegistryBackend +from agent_term.agent_registry_service import ( + HttpAgentRegistryBackend, + JsonFileAgentRegistryBackend, + build_agent_registry_backend_from_config, +) +from agent_term.config import config_from_dict + + +def test_json_file_agent_registry_backend_resolves_agent_and_grant(tmp_path): + fixture = tmp_path / "agent-registry.json" + fixture.write_text( + json.dumps( + { + "agents": [ + { + "agent_id": "agent.codex", + "registry_ref": "fixture://agent.codex", + "spec_version": "v1", + "session_id": "session-codex", + "tool_grants": ["grant.repo-write"], + } + ], + "tool_grants": [ + { + "grant_id": "grant.repo-write", + "agent_id": "agent.codex", + "tool": "repo-write", + } + ], + } + ), + encoding="utf-8", + ) + + backend = JsonFileAgentRegistryBackend(fixture) + agent = backend.resolve_agent("agent.codex") + grant = backend.resolve_tool_grant("agent.codex", "repo-write") + + assert agent is not None + assert agent.agent_id == "agent.codex" + assert agent.session_id == "session-codex" + assert agent.tool_grants == frozenset({"grant.repo-write"}) + assert grant is not None + assert grant.grant_id == "grant.repo-write" + assert grant.is_active is True + + +def test_json_file_agent_registry_backend_returns_none_for_unknowns(tmp_path): + fixture = tmp_path / "agent-registry.json" + fixture.write_text(json.dumps({"agents": [], "tool_grants": []}), encoding="utf-8") + + backend = JsonFileAgentRegistryBackend(fixture) + + assert backend.resolve_agent("agent.unknown") is None + assert backend.resolve_tool_grant("agent.unknown", "repo-write") is None + + +def test_build_agent_registry_backend_uses_fixture_path(tmp_path): + fixture = tmp_path / "agent-registry.json" + fixture.write_text( + json.dumps( + { + "agents": [{"agent_id": "agent.github", "spec_version": "v1"}], + "tool_grants": [], + } + ), + encoding="utf-8", + ) + config = config_from_dict({"agentRegistration": {"fixturePath": str(fixture)}}) + + backend = build_agent_registry_backend_from_config( + config, + fallback=InMemoryAgentRegistryBackend(), + ) + + assert isinstance(backend, JsonFileAgentRegistryBackend) + assert backend.resolve_agent("agent.github") is not None + + +def test_build_agent_registry_backend_uses_fallback_without_service_config(): + fallback = InMemoryAgentRegistryBackend() + + backend = build_agent_registry_backend_from_config(config_from_dict({}), fallback=fallback) + + assert backend is fallback + + +def test_http_agent_registry_backend_resolves_agent_and_grant(): + server = _AgentRegistryHttpFixtureServer() + try: + backend = HttpAgentRegistryBackend(endpoint_url=server.url) + + agent = backend.resolve_agent("agent.codex") + grant = backend.resolve_tool_grant("agent.codex", "repo-write") + missing = backend.resolve_agent("agent.missing") + finally: + server.close() + + assert agent is not None + assert agent.agent_id == "agent.codex" + assert agent.spec_version == "v1" + assert grant is not None + assert grant.grant_id == "grant.repo-write" + assert missing is None + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + if self.path == "/agents/agent.codex": + self._send( + 200, + { + "agent_id": "agent.codex", + "registry_ref": "http://registry/agent.codex", + "spec_version": "v1", + "status": "active", + }, + ) + return + if self.path == "/agents/agent.codex/grants/repo-write": + self._send( + 200, + { + "grant_id": "grant.repo-write", + "agent_id": "agent.codex", + "tool": "repo-write", + "status": "active", + }, + ) + return + self._send(404, {"error": "not found"}) + + def log_message(self, format, *args): # noqa: A002 + return + + def _send(self, status, payload): + raw = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +class _AgentRegistryHttpFixtureServer: + def __init__(self): + self._server = HTTPServer(("127.0.0.1", 0), _Handler) + self._thread = Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + host, port = self._server.server_address + self.url = f"http://{host}:{port}/" + + def close(self): + self._server.shutdown() + self._thread.join(timeout=5) + self._server.server_close() From 01ddb1671b0baecf6ad7ec5ce5e1a161e2aa960b Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 3 May 2026 13:30:25 -0400 Subject: [PATCH 5/6] Add dispatch coverage for file-backed Agent Registry --- tests/test_dispatch_cli.py | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/test_dispatch_cli.py b/tests/test_dispatch_cli.py index 3d3343d..418b383 100644 --- a/tests/test_dispatch_cli.py +++ b/tests/test_dispatch_cli.py @@ -132,6 +132,73 @@ def test_dispatch_cli_uses_config_event_store_and_local_runtime_fixtures(tmp_pat assert events[-1].metadata["policy_decision_id"] == "decision.allow.github.pr.create" +def test_dispatch_cli_uses_file_backed_agent_registry(tmp_path, capsys): + db_path = tmp_path / "configured-events.sqlite3" + fixture_path = tmp_path / "agent-registry.json" + fixture_path.write_text( + json.dumps( + { + "agents": [ + { + "agent_id": "agent.github", + "registry_ref": "fixture://agent.github", + "spec_version": "v1", + "session_id": "session-github", + } + ], + "tool_grants": [ + { + "grant_id": "grant.repo-write", + "agent_id": "agent.github", + "tool": "repo-write", + } + ], + } + ), + encoding="utf-8", + ) + config_path = tmp_path / "agent-term.json" + config_path.write_text( + json.dumps( + { + "eventStore": {"driver": "sqlite", "path": str(db_path)}, + "agentRegistration": {"fixturePath": str(fixture_path)}, + "participants": {"github": {"agentRegistryId": "agent.github"}}, + "localRuntime": {"allowPolicies": ["github.pr.create"]}, + } + ), + encoding="utf-8", + ) + + exit_code = main( + [ + "github", + "github_mutation", + "!github", + "Create PR", + "--config", + str(config_path), + "--tool", + "repo-write", + "--policy-action", + "github.pr.create", + ] + ) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "dispatch_status=ok" in captured.out + + store = EventStore(db_path) + try: + events = store.tail(limit=10) + finally: + store.close() + assert events[1].metadata["agent_registry_ref"] == "fixture://agent.github" + assert events[-1].metadata["grant_id"] == "grant.repo-write" + assert events[-1].metadata["session_id"] == "session-github" + + def test_dispatch_cli_blocks_unknown_agent(tmp_path, capsys): db_path = tmp_path / "events.sqlite3" From 69f4bea35bd41491183db013794e177189d13518 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 3 May 2026 13:31:32 -0400 Subject: [PATCH 6/6] Add Agent Registry service config tests --- tests/test_config.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index bb0623c..b16f69f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -24,6 +24,10 @@ def test_loads_example_config_shape(tmp_path): "failClosedWhenRegistryUnavailable": True, "repository": "SocioProphet/agent-registry", "requiredFor": ["codex", "githubBots"], + "fixturePath": "fixtures/agent-registry.json", + "endpointUrl": "https://agent-registry.example.org", + "tokenEnv": "AGENT_TERM_AGENT_REGISTRY_TOKEN", + "timeoutSeconds": 2.5, }, "participants": { "codex": { @@ -55,6 +59,10 @@ def test_loads_example_config_shape(tmp_path): assert config.matrix.rooms["sourceosOps"] == "!sourceos-ops:example.org" assert config.agent_registration.repository == "SocioProphet/agent-registry" assert config.agent_registration.required_for == ("codex", "githubBots") + assert config.agent_registration.fixture_path == "fixtures/agent-registry.json" + assert config.agent_registration.endpoint_url == "https://agent-registry.example.org" + assert config.agent_registration.token_env == "AGENT_TERM_AGENT_REGISTRY_TOKEN" + assert config.agent_registration.timeout_seconds == 2.5 assert config.participant_agent_id("codex") == "agent.codex" assert config.participants["codex"].require_policy_approval_for_mutation is True assert config.planes["policyFabric"].repository == "SocioProphet/policy-fabric" @@ -65,6 +73,8 @@ def test_defaults_are_safe_without_config_file(): assert config.workspace == "sourceos" assert config.agent_registration.require_registered_participants is True + assert config.agent_registration.token_env == "AGENT_TERM_AGENT_REGISTRY_TOKEN" + assert config.agent_registration.timeout_seconds == 5.0 assert config.matrix.require_encrypted_room_posture_for_sensitive_context is True assert config.pipeline_config().require_agent_registry_for_participants is True assert config.pipeline_config().require_matrix_posture_for_sensitive_context is True