From 81460c2397365a81a37e3f6630f021178510da79 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Thu, 3 Sep 2026 17:26:31 -0700 Subject: [PATCH] test: add Node interoperability --- .github/workflows/interoperability.yml | 67 +++ .gitignore | 1 + Makefile | 6 +- README.md | 10 + pyproject.toml | 1 + scripts/node_interoperability.py | 554 ++++++++++++++++++ scripts/run-node-interoperability.sh | 100 ++++ scripts/verify-node-interoperability.mjs | 90 +++ .../agent/platform_provider.py | 17 +- tests/test_agent_platform_provider.py | 36 ++ uv.lock | 24 + 11 files changed, 893 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/interoperability.yml create mode 100755 scripts/node_interoperability.py create mode 100755 scripts/run-node-interoperability.sh create mode 100644 scripts/verify-node-interoperability.mjs diff --git a/.github/workflows/interoperability.yml b/.github/workflows/interoperability.yml new file mode 100644 index 0000000..61ad2cf --- /dev/null +++ b/.github/workflows/interoperability.yml @@ -0,0 +1,67 @@ +name: Node.js interoperability + +on: + pull_request: + paths: + - ".github/workflows/interoperability.yml" + - "Makefile" + - "pyproject.toml" + - "scripts/node_interoperability.py" + - "scripts/run-node-interoperability.sh" + - "scripts/verify-node-interoperability.mjs" + - "src/**" + - "uv.lock" + workflow_dispatch: + +permissions: + contents: read + +jobs: + interoperability: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: aep-python + + - uses: actions/checkout@v7 + with: + repository: aep-foundation/aep-node + path: aep-node + + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - uses: astral-sh/setup-uv@v10.0.1 + with: + version: "0.11.8" + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - run: corepack enable + + - name: Install Python dependencies + working-directory: aep-python + run: uv sync --all-groups --locked + + - name: Install Node.js dependencies + working-directory: aep-node + run: pnpm install --frozen-lockfile + + - name: Run bidirectional interoperability + working-directory: aep-python + run: make interoperability + env: + AEP_INTEROP_OUTPUT_DIR: .interop/reports + AEP_NODE_DIR: ../aep-node + + - name: Upload interoperability report + uses: actions/upload-artifact@v7 + with: + name: aep-python-node-interoperability + path: aep-python/.interop/reports/aep-python-node-interoperability.json + if-no-files-found: error + retention-days: 7 diff --git a/.gitignore b/.gitignore index ba63896..dd929c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.conformance/aep-specs/ /.conformance/reports/ +/.interop/ /.mypy_cache/ /.pytest_cache/ /.ruff_cache/ diff --git a/Makefile b/Makefile index 8b3a95e..84020eb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build conformance consumer-smoke examples format format-check lint sync test typecheck verify +.PHONY: build conformance consumer-smoke examples format format-check interoperability lint sync test typecheck verify sync: uv sync --all-groups --locked @@ -34,4 +34,8 @@ examples: conformance: ./scripts/run-conformance.sh +interoperability: + uv run mypy scripts/node_interoperability.py + ./scripts/run-node-interoperability.sh + verify: lint typecheck test examples consumer-smoke diff --git a/README.md b/README.md index 22e71a8..9993679 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,16 @@ make conformance The command reads the adjacent `../aep-specs` checkout by default and writes role reports to `.conformance/reports/`. Set `AEP_SPECS_DIR` when the specifications are checked out elsewhere. +Run bidirectional Agent, Service, and Platform interoperability against the Node.js SDK: + +```sh +make interoperability +``` + +The command reads the adjacent `../aep-node` checkout by default and writes a four-row evidence +report to `.interop/reports/aep-python-node-interoperability.json`. Set `AEP_NODE_DIR` when the +Node.js SDK is checked out elsewhere. + See [`aep-specs`](https://github.com/aep-foundation/aep-specs) for the normative drafts, schemas, registries, examples, and test vectors. diff --git a/pyproject.toml b/pyproject.toml index 3626bdd..186f8e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dev = [ "pytest-cov==7.1.0", "ruff==0.16.5", "twine==7.0.0", + "uvicorn==0.52.4", ] [tool.hatch.build.targets.sdist] diff --git a/scripts/node_interoperability.py b/scripts/node_interoperability.py new file mode 100755 index 0000000..853fd15 --- /dev/null +++ b/scripts/node_interoperability.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +from collections.abc import Mapping +from datetime import timedelta +from typing import Any, cast +from urllib.parse import parse_qs, urlsplit + +import httpx +import jwt +import uvicorn +from cryptography.hazmat.primitives.asymmetric import ec +from pydantic import BaseModel + +from agent_enrollment_protocol.adapters import ( + AepAsgiApplication, + AepAuthenticationMiddleware, + AsgiReceive, + AsgiScope, + AsgiSend, + principal_from_scope, +) +from agent_enrollment_protocol.agent import ( + Agent, + AgentOptions, + AuthenticationOptions, + GrantOptions, + PlatformIdentityProvider, + PlatformIdentityProviderOptions, + RevokeOptions, +) +from agent_enrollment_protocol.core import ( + AEP_GRANT_TYPE_API_KEY, + ApiKeyGrantResponse, + ClientAssertionClaims, + GrantRequest, + GrantTypeConfig, + ManagedAgentStatus, + PlatformLifecycleRequest, + PlatformProvisionRequest, + PlatformSignRequest, + SigningAlgorithm, + VerifyClientAssertionOptions, + decode_jwt_unverified, + did_web_document_url, + select_did_web_public_jwk, + sign_client_assertion, + verify_client_assertion, +) +from agent_enrollment_protocol.core.errors import AepAssertionError +from agent_enrollment_protocol.platform import ( + AuthorizationRequest, + DidVerificationMethod, + DiscoveryOptions, + IdentityListQuery, + IdentityRecord, + Platform, + PlatformOptions, + PlatformResult, + RequestContext, +) +from agent_enrollment_protocol.service import ( + AssertionVerificationContext, + GrantContext, + MemoryServiceCredentialStore, + Service, + ServiceOptions, + StoredCredentialGrantTypeOptions, + stored_api_key_grant_type, +) + +PLATFORM_AUTHORIZATION = "Bearer demo-agent" +PRINCIPAL = "interop-agent" + + +class DidWebVerifier: + async def verify( + self, assertion: str, context: AssertionVerificationContext + ) -> ClientAssertionClaims: + try: + header, payload = decode_jwt_unverified(assertion) + issuer = payload.get("iss") + key_id = header.get("kid") + if not isinstance(issuer, str) or not isinstance(key_id, str): + raise ValueError("assertion does not identify a DID verification method") + document_url = did_web_document_url(issuer, allow_insecure_loopback=True) + async with httpx.AsyncClient() as client: + response = await client.get(document_url) + response.raise_for_status() + document = response.json() + if not isinstance(document, dict): + raise ValueError("DID document is not an object") + public_jwk = select_did_web_public_jwk(document, did=issuer, key_id=key_id) + key = jwt.PyJWK.from_dict(public_jwk).key + except (AepAssertionError, httpx.HTTPError, jwt.PyJWTError, ValueError) as error: + raise AepAssertionError("Invalid AEP client assertion.") from error + return verify_client_assertion( + assertion, + key=key, + options=VerifyClientAssertionOptions( + algorithms=context.algorithms, + audience=context.service_did, + clock_tolerance_seconds=int(context.clock_tolerance.total_seconds()), + current_time=int(context.current_time.timestamp()), + issuer=issuer, + operation=context.operation, + resource=context.resource, + subject=issuer, + ), + ) + + +class InteropAuthorizer: + async def authorize(self, request: AuthorizationRequest, context: RequestContext) -> bool: + del request + return context.authorization == PLATFORM_AUTHORIZATION and context.principal == PRINCIPAL + + +class InteropServiceDidResolver: + def __init__(self, service_did: str) -> None: + self._service_did = service_did + + async def resolve(self, service_did: str) -> bool: + return service_did == self._service_did + + +class EphemeralKeyStore: + def __init__(self) -> None: + self._keys: dict[str, ec.EllipticCurvePrivateKey] = {} + + async def create_key(self, identity: IdentityRecord) -> None: + if identity.agent_identity_id in self._keys: + raise ValueError("identity key already exists") + self._keys[identity.agent_identity_id] = ec.generate_private_key(ec.SECP256R1()) + + async def did_verification_method(self, identity: IdentityRecord) -> DidVerificationMethod: + key = self._key(identity).public_key() + numbers = key.public_numbers() + size = (key.key_size + 7) // 8 + + def encode(value: int) -> str: + return base64.urlsafe_b64encode(value.to_bytes(size, "big")).rstrip(b"=").decode() + + return DidVerificationMethod( + controller=identity.agent_did, + id=identity.key_id, + public_key_jwk={ + "crv": "P-256", + "kty": "EC", + "x": encode(numbers.x), + "y": encode(numbers.y), + }, + type="JsonWebKey2020", + ) + + async def sign(self, identity: IdentityRecord, claims: ClientAssertionClaims) -> str: + return sign_client_assertion( + claims, + algorithm=SigningAlgorithm.ES256, + key=self._key(identity), + key_id=identity.key_id, + ) + + async def verification_key(self, identity: IdentityRecord) -> Any: + return self._key(identity).public_key() + + def _key(self, identity: IdentityRecord) -> ec.EllipticCurvePrivateKey: + try: + return self._keys[identity.agent_identity_id] + except KeyError as error: + raise ValueError("identity key is unavailable") from error + + +class InteropApplication: + def __init__(self, listen: str) -> None: + origin = f"http://{listen}" + encoded_host = listen.replace(":", "%3A") + self._service_did = f"did:web:{encoded_host}:services:store" + self._platform = self._create_platform(listen) + service = self._create_service(origin) + self._protected = AepAuthenticationMiddleware( + self._protected_resource, + service, + allow_insecure_loopback=True, + resource_origin=origin, + ) + self._application = AepAsgiApplication(service, self._route) + + async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None: + await self._application(scope, receive, send) + + def _create_service(self, origin: str) -> Service: + credentials = MemoryServiceCredentialStore() + + async def issue(request: GrantRequest, context: GrantContext) -> ApiKeyGrantResponse: + return ApiKeyGrantResponse( + api_key="interop-secret", + credential_id="interop-credential", + expires_at=(context.current_time + timedelta(hours=1)) + .isoformat(timespec="seconds") + .replace("+00:00", "Z"), + header="x-api-key", + scopes=request.requested_scopes, + ) + + api_key = stored_api_key_grant_type( + StoredCredentialGrantTypeOptions( + config=GrantTypeConfig.model_validate({"header_names": ["x-api-key"]}), + issue=issue, + store=credentials, + ) + ) + return Service( + ServiceOptions( + allow_insecure_loopback=True, + authentication_methods=(AEP_GRANT_TYPE_API_KEY,), + grant_types=(api_key,), + identity_methods=("did:web",), + inspect_url=f"{origin}/.well-known/aep", + service_did=self._service_did, + verifier=DidWebVerifier(), + ) + ) + + def _create_platform(self, listen: str) -> Platform: + encoded_host = listen.replace(":", "%3A") + return Platform( + PlatformOptions( + authorizer=InteropAuthorizer(), + did_host=listen, + did_path_prefix="agents", + did_url_template=f"https://{listen}/agents/{{agent_did_id}}/did.json", + discovery=DiscoveryOptions( + endpoint_base="/platform/", + lifecycle_endpoint="/platform/agent-identities/{agent_identity_id}", + list_endpoint="/platform/agent-identities", + platform_did=f"did:web:{encoded_host}", + platform_name="Python Interoperability Platform", + provision_endpoint="/platform/agent-identities", + sign_endpoint="/platform/agent-identities/{agent_identity_id}/sign", + ), + key_store=EphemeralKeyStore(), + service_did_resolver=InteropServiceDidResolver(self._service_did), + signing_algorithms=(SigningAlgorithm.ES256,), + ) + ) + + async def _route(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None: + if scope.get("type") == "lifespan": + await self._lifespan(receive, send) + return + path = _scope_string(scope, "path") + method = _scope_string(scope, "method") + if path in {"/api/resource", "/api/profile"}: + await self._protected(scope, receive, send) + return + if method == "GET" and path == "/health": + await _send_json(send, 200, {"ok": True}) + return + if method == "GET" and path == "/.well-known/aep-platform": + await _send_platform_result(send, self._platform.discovery()) + return + if method == "GET" and path == "/platform/agent-identities": + await _send_platform_result( + send, + await self._platform.list( + _list_query(_scope_string(scope, "query_string")), + _request_context(scope), + ), + ) + return + if method == "POST" and path == "/platform/agent-identities": + request = PlatformProvisionRequest.model_validate_json(await _request_body(receive)) + await _send_platform_result( + send, await self._platform.provision(request, _request_context(scope)) + ) + return + if path.startswith("/platform/agent-identities/"): + await self._identity_route(path, method, scope, receive, send) + return + if method == "GET" and path.startswith("/agents/") and path.endswith("/did.json"): + agent_did_id = path.removeprefix("/agents/").removesuffix("/did.json") + await _send_platform_result(send, await self._platform.did_document(agent_did_id)) + return + if method == "GET" and path == "/services/store/did.json": + await _send_json( + send, + 200, + {"@context": ["https://www.w3.org/ns/did/v1"], "id": self._service_did}, + content_type="application/did+json", + ) + return + await _send_json(send, 404, {"error": "not found"}) + + async def _identity_route( + self, + path: str, + method: str, + scope: AsgiScope, + receive: AsgiReceive, + send: AsgiSend, + ) -> None: + suffix = path.removeprefix("/platform/agent-identities/") + if suffix.endswith("/sign") and method == "POST": + identity_id = suffix.removesuffix("/sign") + sign_request = PlatformSignRequest.model_validate_json(await _request_body(receive)) + sign_result = await self._platform.sign( + identity_id, sign_request, _request_context(scope) + ) + await _send_platform_result(send, sign_result) + return + elif method == "GET" and "/" not in suffix: + identity_result = await self._platform.get_identity(suffix, _request_context(scope)) + await _send_platform_result(send, identity_result) + return + elif method == "PATCH" and "/" not in suffix: + lifecycle_request = PlatformLifecycleRequest.model_validate_json( + await _request_body(receive) + ) + lifecycle_result = await self._platform.update_identity( + suffix, lifecycle_request, _request_context(scope) + ) + await _send_platform_result(send, lifecycle_result) + return + else: + await _send_json(send, 404, {"error": "not found"}) + + async def _protected_resource( + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend + ) -> None: + del receive + if principal_from_scope(scope) is None: + raise RuntimeError("protected route has no authenticated principal") + path = _scope_string(scope, "path") + if path == "/api/resource" and scope.get("method") == "GET": + await _send_json(send, 200, {"available": True}) + elif path == "/api/profile" and scope.get("method") == "POST": + await _send_json(send, 200, {"updated": True}) + else: + await _send_json(send, 404, {"error": "not found"}) + + async def _lifespan(self, receive: AsgiReceive, send: AsgiSend) -> None: + while True: + message_type = (await receive()).get("type") + if message_type == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message_type == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + else: + raise RuntimeError("invalid ASGI lifespan message") + + +async def run_agent(platform_url: str, service_url: str) -> None: + provider = PlatformIdentityProvider( + PlatformIdentityProviderOptions( + allow_insecure_loopback=True, + authorization=PLATFORM_AUTHORIZATION, + platform_url=platform_url, + ) + ) + agent = Agent( + AgentOptions( + allow_insecure_loopback=True, + identity_provider=provider, + ) + ) + try: + session = agent.service(service_url) + inspection = await session.inspect() + if not inspection.document.service.did.startswith("did:web:"): + raise RuntimeError("Node Service did not advertise a did:web Service DID") + enrolled = await session.enroll() + granted = await session.grant( + GrantOptions( + grant_type=AEP_GRANT_TYPE_API_KEY, + requested_scopes=("read:resource", "write:profile"), + ) + ) + credential = granted.body.credential + if not isinstance(credential, ApiKeyGrantResponse): + raise RuntimeError("Node Service did not return an API-key credential") + resource = f"{service_url.rstrip('/')}/api/resource" + headers = await session.authentication_headers( + AuthenticationOptions( + credential_id=credential.credential_id, + grant_type=AEP_GRANT_TYPE_API_KEY, + resource=resource, + ) + ) + async with httpx.AsyncClient() as client: + response = await client.get(resource, headers=headers) + response.raise_for_status() + await session.revoke( + RevokeOptions( + credential_id=credential.credential_id, + grant_type=AEP_GRANT_TYPE_API_KEY, + ) + ) + async with httpx.AsyncClient() as client: + revoked_response = await client.get(resource, headers=headers) + try: + await session.authentication_headers( + AuthenticationOptions( + credential_id=credential.credential_id, + grant_type=AEP_GRANT_TYPE_API_KEY, + resource=resource, + ) + ) + except ValueError: + revoked = True + else: + revoked = False + print( + json.dumps( + { + "agent": "python", + "credential_mode": granted.body.grant_type, + "enrollment": enrolled.body.status.value, + "platform": "node", + "protected_resource_status": response.status_code, + "revoked": revoked, + "revoked_resource_status": revoked_response.status_code, + "service": "node", + }, + separators=(",", ":"), + ) + ) + finally: + await agent.aclose() + await provider.aclose() + + +def _request_context(scope: AsgiScope) -> RequestContext: + headers = _headers(scope) + authorization = headers.get("authorization") + return RequestContext( + authorization=authorization, + idempotency_key=headers.get("idempotency-key"), + principal=PRINCIPAL if authorization == PLATFORM_AUTHORIZATION else "", + ) + + +def _list_query(raw_query: str) -> IdentityListQuery: + values = parse_qs(raw_query) + status_value = values.get("status", [""])[0] + return IdentityListQuery( + descending=values.get("descending", [""])[0] == "true", + limit=int(values.get("limit", ["100"])[0]), + offset=int(values.get("offset", ["0"])[0]), + service_did=values.get("service_did", [None])[0], + status=ManagedAgentStatus(status_value) if status_value else None, + ) + + +def _headers(scope: AsgiScope) -> dict[str, str]: + return { + name.decode("latin-1").lower(): value.decode("latin-1") + for name, value in cast(list[tuple[bytes, bytes]], scope.get("headers", [])) + } + + +def _scope_string(scope: AsgiScope, name: str) -> str: + value = scope.get(name, "") + if name == "query_string" and isinstance(value, bytes): + return value.decode("ascii") + if not isinstance(value, str): + raise RuntimeError(f"ASGI {name} is invalid") + return value + + +async def _request_body(receive: AsgiReceive) -> bytes: + body = bytearray() + while True: + message = await receive() + if message.get("type") != "http.request": + raise RuntimeError("invalid ASGI request message") + chunk = message.get("body", b"") + if not isinstance(chunk, bytes): + raise RuntimeError("invalid ASGI request body") + body.extend(chunk) + if len(body) > 1 << 20: + raise ValueError("interoperability request is too large") + if not message.get("more_body", False): + break + return bytes(body) + + +async def _send_platform_result(send: AsgiSend, result: PlatformResult[Any]) -> None: + value: object = result.problem if result.problem is not None else result.body + if isinstance(value, BaseModel): + value = value.model_dump(by_alias=True, exclude_none=True, mode="json") + await _send_json( + send, + result.status, + value, + content_type=result.content_type, + headers=result.headers, + ) + + +async def _send_json( + send: AsgiSend, + status: int, + value: object, + *, + content_type: str = "application/json", + headers: Mapping[str, str] | None = None, +) -> None: + body = json.dumps(value, separators=(",", ":")).encode() + response_headers = {**(headers or {}), "Content-Length": str(len(body))} + response_headers["Content-Type"] = content_type + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + (name.lower().encode("ascii"), content.encode("latin-1")) + for name, content in response_headers.items() + ], + } + ) + await send({"type": "http.response.body", "body": body}) + + +def main() -> None: + parser = argparse.ArgumentParser() + subcommands = parser.add_subparsers(dest="command", required=True) + agent = subcommands.add_parser("agent") + agent.add_argument("--platform-url", required=True) + agent.add_argument("--service-url", required=True) + server = subcommands.add_parser("server") + server.add_argument("--listen", default="127.0.0.1:4320") + arguments = parser.parse_args() + if arguments.command == "agent": + asyncio.run(run_agent(arguments.platform_url, arguments.service_url)) + return + parsed = urlsplit(f"//{arguments.listen}") + if parsed.hostname not in {"127.0.0.1", "localhost"} or parsed.port is None: + raise ValueError("interoperability server requires a loopback host and port") + uvicorn.run( + InteropApplication(arguments.listen), + host=parsed.hostname, + port=parsed.port, + log_level="warning", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/run-node-interoperability.sh b/scripts/run-node-interoperability.sh new file mode 100755 index 0000000..4f9486b --- /dev/null +++ b/scripts/run-node-interoperability.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +node_repository="${AEP_NODE_DIR:-${repository_root}/../aep-node}" +output_directory="${AEP_INTEROP_OUTPUT_DIR:-${repository_root}/.interop/reports}" +work_directory="${repository_root}/.interop/work" +node_platform_port="${AEP_NODE_PLATFORM_PORT:-4310}" +node_service_port="${AEP_NODE_SERVICE_PORT:-4300}" +python_server_port="${AEP_PYTHON_SERVER_PORT:-4320}" + +wait_for_url() { + local url="$1" + local process="$2" + local name="$3" + local log_file="$4" + for _ in {1..80}; do + if curl --fail --silent --show-error "${url}" >/dev/null 2>&1; then + return 0 + fi + if ! kill -0 "${process}" 2>/dev/null; then + echo "${name} exited before it became ready." >&2 + sed -n '1,240p' "${log_file}" >&2 + return 1 + fi + sleep 0.25 + done + echo "${name} did not become ready." >&2 + sed -n '1,240p' "${log_file}" >&2 + return 1 +} + +if [[ ! -f "${node_repository}/package.json" ]]; then + echo "AEP_NODE_DIR must identify an aep-node checkout." >&2 + exit 1 +fi + +mkdir -p "${output_directory}" "${work_directory}" +( + cd "${node_repository}" + corepack pnpm build +) + +processes=() +cleanup() { + for process in "${processes[@]:-}"; do + kill "${process}" 2>/dev/null || true + wait "${process}" 2>/dev/null || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +PUBLIC_BASE_URL="http://127.0.0.1:${node_platform_port}" \ + DID_HOST="127.0.0.1:${node_platform_port}" \ + PORT="${node_platform_port}" \ + node "${node_repository}/examples/aep-platform-ephemeral/dist/index.js" \ + >"${work_directory}/node-platform.log" 2>&1 & +node_platform_process=$! +processes+=("${node_platform_process}") + +SERVICE_DID="did:web:127.0.0.1%3A${node_service_port}:services:example-service" \ + PORT="${node_service_port}" \ + node "${node_repository}/examples/aep-service-credential-api-key/dist/index.js" \ + >"${work_directory}/node-service.log" 2>&1 & +node_service_process=$! +processes+=("${node_service_process}") + +wait_for_url "http://127.0.0.1:${node_platform_port}/health" "${node_platform_process}" "Node Platform" "${work_directory}/node-platform.log" +wait_for_url "http://127.0.0.1:${node_service_port}/.well-known/aep" "${node_service_process}" "Node Service" "${work_directory}/node-service.log" + +uv run python "${repository_root}/scripts/node_interoperability.py" agent \ + --platform-url "http://127.0.0.1:${node_platform_port}" \ + --service-url "http://127.0.0.1:${node_service_port}" \ + >"${work_directory}/python-agent-node.json" + +cleanup +processes=() + +uv run python "${repository_root}/scripts/node_interoperability.py" server \ + --listen "127.0.0.1:${python_server_port}" \ + >"${work_directory}/python-server.log" 2>&1 & +python_server_process=$! +processes+=("${python_server_process}") + +wait_for_url "http://127.0.0.1:${python_server_port}/health" "${python_server_process}" "Python Server" "${work_directory}/python-server.log" + +PLATFORM_URL="http://127.0.0.1:${python_server_port}" \ + SERVICE_URL="http://127.0.0.1:${python_server_port}" \ + node "${node_repository}/examples/aep-agent-did-web-grant-status-revoke/dist/index.js" \ + >"${work_directory}/node-agent-python.json" + +node "${repository_root}/scripts/verify-node-interoperability.mjs" \ + "${work_directory}/python-agent-node.json" \ + "${work_directory}/node-agent-python.json" \ + "${output_directory}/aep-python-node-interoperability.json" \ + "http://127.0.0.1:${python_server_port}" + +echo "Interoperability report: ${output_directory}/aep-python-node-interoperability.json" diff --git a/scripts/verify-node-interoperability.mjs b/scripts/verify-node-interoperability.mjs new file mode 100644 index 0000000..789a263 --- /dev/null +++ b/scripts/verify-node-interoperability.mjs @@ -0,0 +1,90 @@ +import { readFile, writeFile } from "node:fs/promises"; + +const [pythonResultPath, nodeResultPath, reportPath, pythonServiceUrl] = process.argv.slice(2); + +if ( + pythonResultPath === undefined || + nodeResultPath === undefined || + reportPath === undefined || + pythonServiceUrl === undefined +) { + throw new Error( + "usage: verify-node-interoperability.mjs PYTHON_RESULT NODE_RESULT REPORT PYTHON_SERVICE_URL" + ); +} + +const pythonResult = JSON.parse(await readFile(pythonResultPath, "utf8")); +const nodeResult = JSON.parse(await readFile(nodeResultPath, "utf8")); + +requireEqual(pythonResult.agent, "python", "Python Agent identity"); +requireEqual(pythonResult.service, "node", "Python Agent Service counterpart"); +requireEqual(pythonResult.platform, "node", "Python Agent Platform counterpart"); +requireEqual(pythonResult.enrollment, "active", "Python Agent enrollment"); +requireEqual(pythonResult.credential_mode, "api-key", "Python Agent credential mode"); +requireEqual(pythonResult.protected_resource_status, 200, "Python Agent protected resource"); +requireEqual(pythonResult.revoked, true, "Python Agent credential revocation"); +requireEqual(pythonResult.revoked_resource_status, 401, "Node Service credential invalidation"); + +requireEqual(nodeResult.credentialMode, "api-key", "Node Agent credential mode"); +requireEqual(nodeResult.enroll?.status, "active", "Node Agent enrollment"); +requireEqual(nodeResult.statusBeforeGrant?.status, "active", "Node Agent pre-Grant status"); +requireEqual(nodeResult.statusAfterRevoke?.status, "active", "Node Agent post-Revoke status"); +requireEqual(nodeResult.resource?.available, true, "Node Agent protected resource"); +requireEqual(nodeResult.profile?.updated, true, "Node Agent protected profile"); +requireEqual(typeof nodeResult.grant?.credential_id, "string", "Node Agent credential identifier type"); +requireEqual(nodeResult.grant?.credential_id.length > 0, true, "Node Agent credential identifier"); +requireEqual(Object.keys(nodeResult.revoke ?? {}).length, 0, "Node Agent Revoke response"); + +const credentialHeader = nodeResult.grant?.header; +const credentialValue = nodeResult.grant?.api_key; +requireEqual(typeof credentialHeader, "string", "Node Agent credential header type"); +requireEqual(credentialHeader.length > 0, true, "Node Agent credential header"); +requireEqual(typeof credentialValue, "string", "Node Agent credential value type"); +requireEqual(credentialValue.length > 0, true, "Node Agent credential value"); +const revokedResponse = await fetch(new URL("/api/resource", pythonServiceUrl), { + headers: { [credentialHeader]: credentialValue } +}); +requireEqual(revokedResponse.status, 401, "Python Service credential invalidation"); + +const report = { + aep_version: "1.0", + evidence: [ + { + agent: "python", + counterpart: "node", + flow: "Inspect, Enroll, Grant, protected resource, Revoke, revoked-resource rejection", + role: "service", + status: "passed" + }, + { + agent: "python", + counterpart: "node", + flow: "Discovery, List, Provision, delegated Sign", + role: "platform", + status: "passed" + }, + { + agent: "node", + counterpart: "python", + flow: "Inspect, Enroll, Grant, protected resource, Revoke, revoked-resource rejection", + role: "service", + status: "passed" + }, + { + agent: "node", + counterpart: "python", + flow: "Discovery, List, Provision, delegated Sign", + role: "platform", + status: "passed" + } + ], + status: "passed" +}; + +await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + +function requireEqual(actual, expected, name) { + if (!Object.is(actual, expected)) { + throw new Error(`${name}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); + } +} diff --git a/src/agent_enrollment_protocol/agent/platform_provider.py b/src/agent_enrollment_protocol/agent/platform_provider.py index cc90cc4..08d59b8 100644 --- a/src/agent_enrollment_protocol/agent/platform_provider.py +++ b/src/agent_enrollment_protocol/agent/platform_provider.py @@ -193,7 +193,7 @@ async def find_identity_by_service_did(self, service_did: str) -> ServiceIdentit ), ) for candidate in listed.data: - _validate_platform_identity(candidate, self._allow_insecure_loopback) + _validate_platform_identity(candidate) if ( candidate.service_did == service_did and candidate.status is ManagedAgentStatus.ACTIVE @@ -223,7 +223,7 @@ async def get_or_create_identity(self, request: IdentityRequest) -> ServiceIdent body, PlatformAgentIdentity, "Platform provision response" ), ) - _validate_platform_identity(created, self._allow_insecure_loopback) + _validate_platform_identity(created) if ( created.service_did != request.service_did or created.status is not ManagedAgentStatus.ACTIVE @@ -484,10 +484,7 @@ def _validate_owned_identity(self, identity: ServiceIdentity) -> None: or not identity.signing_algorithms ): raise ValueError("AEP identity is not an active identity from this Platform") - expected = did_web_document_url( - identity.agent_did, - allow_insecure_loopback=self._allow_insecure_loopback, - ) + expected = did_web_document_url(identity.agent_did) if identity.metadata.get("did_document_url") != expected: raise ValueError("AEP Platform DID document URL does not match the Agent DID") @@ -538,18 +535,14 @@ def _endpoint(platform_url: str, path: str, *, agent_identity_id: str | None = N return urljoin(platform_url, path) -def _validate_platform_identity( - value: PlatformAgentIdentity, allow_insecure_loopback: bool -) -> None: +def _validate_platform_identity(value: PlatformAgentIdentity) -> None: if ( not value.agent_did.startswith("did:web:") or value.key_id != value.agent_did or not value.signing_algorithms ): raise ValueError("AEP Platform returned an invalid identity") - expected = did_web_document_url( - value.agent_did, allow_insecure_loopback=allow_insecure_loopback - ) + expected = did_web_document_url(value.agent_did) if value.did_document_url != expected: raise ValueError("AEP Platform DID document URL does not match the Agent DID") diff --git a/tests/test_agent_platform_provider.py b/tests/test_agent_platform_provider.py index f954231..d08c64c 100644 --- a/tests/test_agent_platform_provider.py +++ b/tests/test_agent_platform_provider.py @@ -278,6 +278,42 @@ async def headers() -> dict[str, str]: assert not transport.closed +@pytest.mark.asyncio +async def test_loopback_platform_preserves_canonical_https_did_document_url() -> None: + agent_did = "did:web:127.0.0.1%3A4310:agents:one" + transport = QueueTransport( + json_response( + discovery( + identity={ + "did_methods": ["did:web"], + "did_url_template": "https://127.0.0.1:4310/agents/{agent_did_id}/did.json", + } + ) + ), + json_response( + listed( + identity( + agent_did=agent_did, + did_document_url="https://127.0.0.1:4310/agents/one/did.json", + key_id=agent_did, + ) + ) + ), + ) + instance = provider( + transport, + allow_insecure_loopback=True, + platform_url="http://127.0.0.1:4310", + ) + + recovered = await instance.find_identity_by_service_did(SERVICE_DID) + + assert recovered is not None + assert recovered.metadata["did_document_url"] == "https://127.0.0.1:4310/agents/one/did.json" + await instance.aclose() + assert not transport.closed + + @pytest.mark.asyncio async def test_provisions_when_recovery_is_empty_and_serializes_concurrent_calls() -> None: keys = iter(("provision-one", "provision-two")) diff --git a/uv.lock b/uv.lock index c4c3f48..b5f1456 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,7 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, { name = "twine" }, + { name = "uvicorn" }, ] [package.metadata] @@ -45,6 +46,7 @@ dev = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "ruff", specifier = "==0.16.5" }, { name = "twine", specifier = "==7.0.0" }, + { name = "uvicorn", specifier = "==0.52.4" }, ] [[package]] @@ -410,6 +412,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1431,6 +1442,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + [[package]] name = "zipp" version = "4.1.0"