From ba1d7d4baec600f395c0c8749c8c1acbb38d3796 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sun, 15 Feb 2026 17:55:08 -0500 Subject: [PATCH] Rebuild Python SDK with typed client, verification, docs, and tests --- python-sdk/README.md | 79 ++++--- python-sdk/commandlayer/__init__.py | 25 ++- python-sdk/commandlayer/client.py | 222 +++++++++++++------ python-sdk/commandlayer/errors.py | 9 +- python-sdk/commandlayer/types.py | 33 ++- python-sdk/commandlayer/verify.py | 305 ++++++++++++++++++-------- python-sdk/docs/client.md | 30 +++ python-sdk/docs/getting-started.md | 33 +++ python-sdk/docs/verification.md | 24 ++ python-sdk/pyproject.toml | 12 +- python-sdk/tests/test_client.py | 80 ++++++- python-sdk/tests/test_public_api.py | 10 + python-sdk/tests/test_verify.py | 110 ++++++++-- typescript-sdk/README.md | 12 +- typescript-sdk/scripts/unit-tests.mjs | 110 ++++++++-- typescript-sdk/src/index.ts | 79 ++++--- 16 files changed, 902 insertions(+), 271 deletions(-) create mode 100644 python-sdk/docs/client.md create mode 100644 python-sdk/docs/getting-started.md create mode 100644 python-sdk/docs/verification.md create mode 100644 python-sdk/tests/test_public_api.py diff --git a/python-sdk/README.md b/python-sdk/README.md index 43c5463..4e3f5ad 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -4,13 +4,21 @@ Semantic verbs. Signed receipts. Deterministic verification. Official Python SDK for **CommandLayer Commons v1.0.0**. +--- + ## Installation ```bash pip install commandlayer ``` -Python 3.10+ is supported. +Python **3.10+** is supported. + +For local development: + +```bash +pip install -e '.[dev]' +``` --- @@ -33,48 +41,37 @@ print(receipt["status"]) print(receipt["metadata"]["receipt_id"]) ``` -> `verify_receipts` is **off by default** (matching TypeScript SDK behavior). +> `verify_receipts` is **off by default** (matching the TypeScript SDK behavior). --- ## Client Configuration ```python -client = create_client( +from commandlayer import CommandLayerClient + +client = CommandLayerClient( runtime="https://runtime.commandlayer.org", actor="my-app", timeout_ms=30_000, + headers={"X-Trace-ID": "abc123"}, + retries=1, verify_receipts=True, verify={ "public_key": "ed25519:7Vkkmt6R02Iltp/+i3D5mraZyvLjfuTSVB33KwfzQC8=", # or ENS: - # "ens": {"name": "runtime.commandlayer.eth", "rpcUrl": "https://..."} + # "ens": {"name": "summarizeagent.eth", "rpcUrl": "https://..."}, }, ) ``` ### Verification options -- `verify["public_key"]` (alias: `publicKey`): explicit Ed25519 pubkey (`ed25519:`, ``, `0x`, ``) -- `verify["ens"]`: `{ "name": str, "rpcUrl"|"rpc_url": str, "pubkeyTextKey"|"pubkey_text_key"?: str }` - ---- - -## Supported Verbs -All verbs return a signed receipt. - -```python -client.summarize(content="...", style="bullet_points") -client.analyze(content="...", goal="extract key risks") -client.classify(content="...", max_labels=5) -client.clean(content="...", operations=["trim", "normalize_newlines"]) -client.convert(content='{"a":1}', from_format="json", to_format="csv") -client.describe(subject="x402 receipt", detail="medium") -client.explain(subject="receipt verification", style="step-by-step") -client.format(content="a: 1\nb: 2", to="table") -client.parse(content='{"a":1}', content_type="json", mode="strict") -client.fetch(source="https://example.com", include_metadata=True) -``` +- `verify["public_key"]` (alias: `publicKey`): explicit Ed25519 pubkey + - accepted formats: `ed25519:`, ``, `0x`, `` +- `verify["ens"]`: `{ "name": str, "rpcUrl"|"rpc_url": str }` + - resolves `cl.receipt.signer` on the agent ENS name + - resolves `cl.sig.pub` and `cl.sig.kid` on the signer ENS name --- @@ -98,15 +95,33 @@ ENS-based verification: result = verify_receipt( receipt, ens={ - "name": "runtime.commandlayer.eth", + "name": "summarizeagent.eth", "rpcUrl": "https://mainnet.infura.io/v3/YOUR_KEY", - "pubkeyTextKey": "cl.pubkey", }, ) ``` --- +## Supported Verbs + +All verbs return a signed receipt. + +```python +client.summarize(content="...", style="bullet_points") +client.analyze(content="...", goal="extract key risks") +client.classify(content="...", max_labels=5) +client.clean(content="...", operations=["trim", "normalize_newlines"]) +client.convert(content='{"a":1}', from_format="json", to_format="csv") +client.describe(subject="x402 receipt", detail="medium") +client.explain(subject="receipt verification", style="step-by-step") +client.format(content="a: 1\nb: 2", to="table") +client.parse(content='{"a":1}', content_type="json", mode="strict") +client.fetch(source="https://example.com", include_metadata=True) +``` + +--- + ## Development ```bash @@ -114,5 +129,17 @@ cd python-sdk python -m venv .venv source .venv/bin/activate pip install -e '.[dev]' +ruff check . +mypy commandlayer pytest ``` + +--- + +## Documentation + +See `docs/` for usage and API details: + +- `docs/getting-started.md` +- `docs/client.md` +- `docs/verification.md` diff --git a/python-sdk/commandlayer/__init__.py b/python-sdk/commandlayer/__init__.py index 1295daa..6a150cc 100644 --- a/python-sdk/commandlayer/__init__.py +++ b/python-sdk/commandlayer/__init__.py @@ -2,15 +2,36 @@ from .client import CommandLayerClient, create_client from .errors import CommandLayerError -from .types import Receipt, VerifyResult -from .verify import verify_receipt +from .types import ( + EnsVerifyOptions, + Receipt, + SignerKeyResolution, + VerifyOptions, + VerifyResult, +) +from .verify import ( + canonicalize_stable_json_v1, + parse_ed25519_pubkey, + recompute_receipt_hash_sha256, + resolve_signer_key, + sha256_hex_utf8, + verify_receipt, +) __all__ = [ "CommandLayerClient", "create_client", "CommandLayerError", + "EnsVerifyOptions", + "VerifyOptions", + "SignerKeyResolution", "Receipt", "VerifyResult", + "canonicalize_stable_json_v1", + "sha256_hex_utf8", + "parse_ed25519_pubkey", + "recompute_receipt_hash_sha256", + "resolve_signer_key", "verify_receipt", ] diff --git a/python-sdk/commandlayer/client.py b/python-sdk/commandlayer/client.py index 2d1f027..06e5949 100644 --- a/python-sdk/commandlayer/client.py +++ b/python-sdk/commandlayer/client.py @@ -1,13 +1,17 @@ from __future__ import annotations +import json +import time +from collections.abc import Mapping from typing import Any import httpx from .errors import CommandLayerError -from .types import Receipt +from .types import Receipt, VerifyOptions from .verify import verify_receipt +VERSION = "1.0.0" VERBS = { "summarize", @@ -28,27 +32,44 @@ def _normalize_base(url: str) -> str: class CommandLayerClient: + """Synchronous CommandLayer client for Commons verbs.""" + def __init__( self, runtime: str = "https://runtime.commandlayer.org", actor: str = "sdk-user", timeout_ms: int = 30_000, + headers: Mapping[str, str] | None = None, + retries: int = 0, verify_receipts: bool = False, - verify: dict[str, Any] | None = None, + verify: VerifyOptions | None = None, + http_client: httpx.Client | None = None, ): self.runtime = _normalize_base(runtime) self.actor = actor self.timeout_ms = timeout_ms + self.retries = max(0, retries) self.verify_receipts = verify_receipts is True - self.verify_defaults = verify or {} - self._http = httpx.Client(timeout=self.timeout_ms / 1000) + self.verify_defaults: VerifyOptions = verify or {} + + self.default_headers = { + "Content-Type": "application/json", + "User-Agent": f"commandlayer-py/{VERSION}", + } + if headers: + self.default_headers.update(dict(headers)) + + self._http = http_client or httpx.Client(timeout=self.timeout_ms / 1000) def _ensure_verify_config_if_enabled(self) -> None: if not self.verify_receipts: return - public_key = self.verify_defaults.get("public_key") or self.verify_defaults.get("publicKey") - has_explicit = bool(str(public_key or "").strip()) + explicit_public_key = self.verify_defaults.get("public_key") or self.verify_defaults.get( + "publicKey" + ) + has_explicit = bool(str(explicit_public_key or "").strip()) + ens = self.verify_defaults.get("ens") or {} has_ens = bool(ens.get("name") and (ens.get("rpcUrl") or ens.get("rpc_url"))) @@ -59,22 +80,43 @@ def _ensure_verify_config_if_enabled(self) -> None: 400, ) - def summarize(self, *, content: str, style: str | None = None, format: str | None = None, max_tokens: int = 1000) -> Receipt: + def summarize( + self, + *, + content: str, + style: str | None = None, + format: str | None = None, + max_tokens: int = 1000, + ) -> Receipt: return self.call( "summarize", { - "input": {"content": content, "summary_style": style, "format_hint": format}, + "input": { + "content": content, + "summary_style": style, + "format_hint": format, + }, "limits": {"max_output_tokens": max_tokens}, }, ) - def analyze(self, *, content: str, goal: str | None = None, hints: list[str] | None = None, max_tokens: int = 1000) -> Receipt: - body: dict[str, Any] = {"input": content, "limits": {"max_output_tokens": max_tokens}} + def analyze( + self, + *, + content: str, + goal: str | None = None, + hints: list[str] | None = None, + max_tokens: int = 1000, + ) -> Receipt: + payload: dict[str, Any] = { + "input": content, + "limits": {"max_output_tokens": max_tokens}, + } if goal: - body["goal"] = goal + payload["goal"] = goal if hints: - body["hints"] = hints - return self.call("analyze", body) + payload["hints"] = hints + return self.call("analyze", payload) def classify(self, *, content: str, max_labels: int = 5, max_tokens: int = 1000) -> Receipt: return self.call( @@ -86,23 +128,41 @@ def classify(self, *, content: str, max_labels: int = 5, max_tokens: int = 1000) }, ) - def clean(self, *, content: str, operations: list[str] | None = None, max_tokens: int = 1000) -> Receipt: + def clean( + self, + *, + content: str, + operations: list[str] | None = None, + max_tokens: int = 1000, + ) -> Receipt: return self.call( "clean", { "input": { "content": content, - "operations": operations or ["normalize_newlines", "collapse_whitespace", "trim"], + "operations": operations + or ["normalize_newlines", "collapse_whitespace", "trim"], }, "limits": {"max_output_tokens": max_tokens}, }, ) - def convert(self, *, content: str, from_format: str, to_format: str, max_tokens: int = 1000) -> Receipt: + def convert( + self, + *, + content: str, + from_format: str, + to_format: str, + max_tokens: int = 1000, + ) -> Receipt: return self.call( "convert", { - "input": {"content": content, "source_format": from_format, "target_format": to_format}, + "input": { + "content": content, + "source_format": from_format, + "target_format": to_format, + }, "limits": {"max_output_tokens": max_tokens}, }, ) @@ -115,11 +175,14 @@ def describe( detail: str = "medium", max_tokens: int = 1000, ) -> Receipt: - subject = (subject or "")[:140] return self.call( "describe", { - "input": {"subject": subject, "audience": audience, "detail_level": detail}, + "input": { + "subject": (subject or "")[:140], + "audience": audience, + "detail_level": detail, + }, "limits": {"max_output_tokens": max_tokens}, }, ) @@ -133,12 +196,11 @@ def explain( detail: str = "medium", max_tokens: int = 1000, ) -> Receipt: - subject = (subject or "")[:140] return self.call( "explain", { "input": { - "subject": subject, + "subject": (subject or "")[:140], "audience": audience, "style": style, "detail_level": detail, @@ -150,7 +212,10 @@ def explain( def format(self, *, content: str, to: str, max_tokens: int = 1000) -> Receipt: return self.call( "format", - {"input": {"content": content, "target_style": to}, "limits": {"max_output_tokens": max_tokens}}, + { + "input": {"content": content, "target_style": to}, + "limits": {"max_output_tokens": max_tokens}, + }, ) def parse( @@ -162,15 +227,17 @@ def parse( target_schema: str | None = None, max_tokens: int = 1000, ) -> Receipt: - input_obj: dict[str, Any] = { - "content": content, - "content_type": content_type, - "mode": mode, + payload: dict[str, Any] = { + "input": { + "content": content, + "content_type": content_type, + "mode": mode, + }, + "limits": {"max_output_tokens": max_tokens}, } if target_schema: - input_obj["target_schema"] = target_schema - - return self.call("parse", {"input": input_obj, "limits": {"max_output_tokens": max_tokens}}) + payload["input"]["target_schema"] = target_schema + return self.call("parse", payload) def fetch( self, @@ -186,69 +253,90 @@ def fetch( if include_metadata is not None: input_obj["include_metadata"] = include_metadata - return self.call("fetch", {"input": input_obj, "limits": {"max_output_tokens": max_tokens}}) - - def call(self, verb: str, body: dict[str, Any]) -> Receipt: - if verb not in VERBS: - raise CommandLayerError(f"Unsupported verb: {verb}", 400) - - self._ensure_verify_config_if_enabled() - url = f"{self.runtime}/{verb}/v1.0.0" + return self.call( + "fetch", + {"input": input_obj, "limits": {"max_output_tokens": max_tokens}}, + ) - payload = { + def _build_payload(self, verb: str, body: dict[str, Any]) -> dict[str, Any]: + return { "x402": { "verb": verb, - "version": "1.0.0", - "entry": f"x402://{verb}agent.eth/{verb}/v1.0.0", + "version": VERSION, + "entry": f"x402://{verb}agent.eth/{verb}/v{VERSION}", }, "actor": body.get("actor", self.actor), **body, } - try: - resp = self._http.post( - url, - headers={"Content-Type": "application/json", "User-Agent": "commandlayer-py/1.0.0"}, - json=payload, - ) - except httpx.TimeoutException as err: - raise CommandLayerError("Request timed out", 408) from err - except Exception as err: - raise CommandLayerError(str(err)) from err + def _request(self, verb: str, payload: dict[str, Any]) -> httpx.Response: + url = f"{self.runtime}/{verb}/v{VERSION}" + + attempt = 0 + while True: + try: + return self._http.post(url, headers=self.default_headers, json=payload) + except httpx.TimeoutException as err: + if attempt >= self.retries: + raise CommandLayerError("Request timed out", 408) from err + except httpx.HTTPError as err: + if attempt >= self.retries: + raise CommandLayerError(f"HTTP transport error: {err}") from err + + attempt += 1 + time.sleep(min(0.2 * attempt, 1.0)) + + def call(self, verb: str, body: dict[str, Any]) -> Receipt: + if verb not in VERBS: + raise CommandLayerError(f"Unsupported verb: {verb}", 400) + + self._ensure_verify_config_if_enabled() + payload = self._build_payload(verb, body) + response = self._request(verb, payload) try: - data = resp.json() - except Exception: + data: Any = response.json() + except json.JSONDecodeError: data = {} - if not resp.is_success: + if not response.is_success: message = ( - data.get("message") if isinstance(data, dict) else None - ) or ( - (data.get("error") or {}).get("message") if isinstance(data, dict) and isinstance(data.get("error"), dict) else None - ) or f"HTTP {resp.status_code}" - raise CommandLayerError(message, resp.status_code, data) + (data.get("message") if isinstance(data, dict) else None) + or ( + (data.get("error") or {}).get("message") + if isinstance(data, dict) and isinstance(data.get("error"), dict) + else None + ) + or f"HTTP {response.status_code}" + ) + raise CommandLayerError(str(message), response.status_code, data) + + if not isinstance(data, dict): + raise CommandLayerError( + "Runtime response must be a JSON object", response.status_code, data + ) if self.verify_receipts: - result = verify_receipt( + verify_result = verify_receipt( data, - public_key=self.verify_defaults.get("public_key") or self.verify_defaults.get("publicKey"), + public_key=self.verify_defaults.get("public_key") + or self.verify_defaults.get("publicKey"), ens=self.verify_defaults.get("ens"), ) - if not result["ok"]: - raise CommandLayerError("Receipt verification failed", 422, result) + if not verify_result["ok"]: + raise CommandLayerError("Receipt verification failed", 422, verify_result) - return data # type: ignore[no-any-return] + return data - def close(self): + def close(self) -> None: self._http.close() - def __enter__(self): + def __enter__(self) -> CommandLayerClient: return self - def __exit__(self, *args: object): + def __exit__(self, *args: object) -> None: self.close() -def create_client(**kwargs) -> CommandLayerClient: +def create_client(**kwargs: Any) -> CommandLayerClient: return CommandLayerClient(**kwargs) diff --git a/python-sdk/commandlayer/errors.py b/python-sdk/commandlayer/errors.py index 830bf22..9e933c6 100644 --- a/python-sdk/commandlayer/errors.py +++ b/python-sdk/commandlayer/errors.py @@ -1,5 +1,12 @@ +from __future__ import annotations + +from typing import Any + + class CommandLayerError(Exception): - def __init__(self, message: str, status_code: int | None = None, details=None): + """Top-level SDK error with optional HTTP metadata.""" + + def __init__(self, message: str, status_code: int | None = None, details: Any = None): super().__init__(message) self.status_code = status_code self.details = details diff --git a/python-sdk/commandlayer/types.py b/python-sdk/commandlayer/types.py index 38675df..5e4b548 100644 --- a/python-sdk/commandlayer/types.py +++ b/python-sdk/commandlayer/types.py @@ -1,7 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass from typing import Any, Literal, TypedDict +Receipt = dict[str, Any] + + +class EnsVerifyOptions(TypedDict, total=False): + """ENS options for receipt verification.""" + + name: str + rpc_url: str + rpcUrl: str -class VerifyChecks(TypedDict, total=False): + +class VerifyOptions(TypedDict, total=False): + """Verification options for client-side receipt checks.""" + + public_key: str + publicKey: str + ens: EnsVerifyOptions + + +class VerifyChecks(TypedDict): hash_matches: bool signature_valid: bool receipt_id_matches: bool @@ -9,7 +30,7 @@ class VerifyChecks(TypedDict, total=False): canonical_matches: bool -class VerifyValues(TypedDict, total=False): +class VerifyValues(TypedDict): verb: str | None signer_id: str | None alg: str | None @@ -21,7 +42,7 @@ class VerifyValues(TypedDict, total=False): ens_txt_key: str | None -class VerifyErrors(TypedDict, total=False): +class VerifyErrors(TypedDict): signature_error: str | None ens_error: str | None verify_error: str | None @@ -34,4 +55,8 @@ class VerifyResult(TypedDict): errors: VerifyErrors -Receipt = dict[str, Any] +@dataclass(frozen=True) +class SignerKeyResolution: + algorithm: Literal["ed25519"] + kid: str + raw_public_key_bytes: bytes diff --git a/python-sdk/commandlayer/verify.py b/python-sdk/commandlayer/verify.py index f62b03b..a709b3f 100644 --- a/python-sdk/commandlayer/verify.py +++ b/python-sdk/commandlayer/verify.py @@ -1,41 +1,82 @@ +from __future__ import annotations + import base64 import copy import hashlib import json import re -from typing import Any, Literal +from typing import Any, Protocol -from nacl.signing import VerifyKey from nacl.exceptions import BadSignatureError +from nacl.signing import VerifyKey from web3 import Web3 -from .types import Receipt, VerifyResult +from .types import EnsVerifyOptions, Receipt, SignerKeyResolution, VerifyResult + +_ED25519_PREFIX_RE = re.compile(r"^ed25519\s*[:=]\s*(.+)$", re.IGNORECASE) +_ED25519_HEX_RE = re.compile(r"^(0x)?[0-9a-fA-F]{64}$") + + +class EnsTextResolver(Protocol): + def get_text(self, name: str, key: str) -> str | None: ... + + +class Web3EnsTextResolver: + def __init__(self, rpc_url: str): + self._w3 = Web3(Web3.HTTPProvider(rpc_url)) + + def get_text(self, name: str, key: str) -> str | None: + if not self._w3.is_connected(): + raise ValueError(f"Unable to connect to RPC: {self._w3.provider}") + + ens_module = self._w3.ens # type: ignore[attr-defined] + if ens_module is None: + raise ValueError("ENS module is unavailable on this web3 instance") + + value = ens_module.get_text(name, key) # type: ignore[union-attr] + if value is None: + return None + + text = str(value).strip() + return text or None def canonicalize_stable_json_v1(value: Any) -> str: def encode(v: Any) -> str: if v is None: return "null" - if isinstance(v, bool): - return "true" if v else "false" - if isinstance(v, str): + + value_type = type(v) + + if value_type is str: return json.dumps(v, ensure_ascii=False) - if isinstance(v, (int, float)): + if value_type is bool: + return "true" if v else "false" + + if value_type in (int, float): if isinstance(v, float): if v != v or v in (float("inf"), float("-inf")): raise ValueError("canonicalize: non-finite number not allowed") if v == 0.0 and str(v).startswith("-"): return "0" - return format(v, "g") if isinstance(v, float) else str(v) + return str(v) + + if value_type in (complex, bytes, bytearray): + raise ValueError(f"canonicalize: unsupported type {value_type.__name__}") + if isinstance(v, list): - return "[" + ",".join(encode(x) for x in v) + "]" + return "[" + ",".join(encode(item) for item in v) + "]" + if isinstance(v, dict): - out = [] - for k in sorted(v.keys()): - val = v[k] - out.append(f"{json.dumps(str(k), ensure_ascii=False)}:{encode(val)}") + out: list[str] = [] + for key in sorted(v.keys()): + val = v[key] + if val is ...: + raise ValueError(f'canonicalize: unsupported value for key "{key}"') + out.append(f"{json.dumps(str(key), ensure_ascii=False)}:{encode(val)}") return "{" + ",".join(out) + "}" - raise ValueError(f"canonicalize: unsupported type {type(v).__name__}") + + raise ValueError(f"canonicalize: unsupported type {value_type.__name__}") return encode(value) @@ -45,95 +86,141 @@ def sha256_hex_utf8(text: str) -> str: def parse_ed25519_pubkey(text: str) -> bytes: - s = str(text).strip() - match = re.match(r"^ed25519\s*[:=]\s*(.+)$", s, re.IGNORECASE) - candidate = (match.group(1) if match else s).strip() + candidate = str(text).strip() + match = _ED25519_PREFIX_RE.match(candidate) + if match: + candidate = match.group(1).strip() + + if _ED25519_HEX_RE.match(candidate): + hex_part = candidate[2:] if candidate.startswith("0x") else candidate + decoded = bytes.fromhex(hex_part) + if len(decoded) != 32: + raise ValueError("invalid ed25519 pubkey length") + return decoded - if re.match(r"^(0x)?[0-9a-fA-F]{64}$", candidate): - h = candidate[2:] if candidate.startswith("0x") else candidate - return bytes.fromhex(h) + try: + decoded = base64.b64decode(candidate, validate=True) + except Exception as err: # noqa: BLE001 + raise ValueError("invalid base64 in ed25519 pubkey") from err - decoded = base64.b64decode(candidate, validate=True) if len(decoded) != 32: raise ValueError("invalid base64 ed25519 pubkey length (need 32 bytes)") + return decoded -def verify_ed25519_signature_over_utf8_hash_string(hash_hex: str, signature_b64: str, pubkey32: bytes) -> bool: +def verify_ed25519_signature_over_utf8_hash_string( + hash_hex: str, + signature_b64: str, + pubkey32: bytes, +) -> bool: if len(pubkey32) != 32: raise ValueError("ed25519: pubkey must be 32 bytes") - sig = base64.b64decode(signature_b64) - if len(sig) != 64: + + try: + signature = base64.b64decode(signature_b64, validate=True) + except Exception as err: # noqa: BLE001 + raise ValueError("ed25519: signature must be valid base64") from err + + if len(signature) != 64: raise ValueError("ed25519: signature must be 64 bytes") - vk = VerifyKey(pubkey32) + verify_key = VerifyKey(pubkey32) try: - vk.verify(hash_hex.encode("utf-8"), sig) + verify_key.verify(hash_hex.encode("utf-8"), signature) return True except BadSignatureError: return False -def resolve_ens_ed25519_pubkey(name: str, rpc_url: str, pubkey_text_key: str = "cl.pubkey") -> dict[str, Any]: +def resolve_signer_key( + name: str, + rpc_url: str, + *, + resolver: EnsTextResolver | None = None, +) -> SignerKeyResolution: if not rpc_url: - return {"pubkey": None, "source": None, "error": "rpcUrl is required for ENS verification", "txt_key": pubkey_text_key} - try: - w3 = Web3(Web3.HTTPProvider(rpc_url)) - if not w3.is_connected(): - return {"pubkey": None, "source": None, "error": "Unable to connect to RPC", "txt_key": pubkey_text_key} + raise ValueError("rpcUrl is required for ENS verification") + + txt_resolver = resolver or Web3EnsTextResolver(rpc_url) - try: - ens_module = w3.ens # type: ignore[attr-defined] - if ens_module is None: - return {"pubkey": None, "source": None, "error": "ENS module not available", "txt_key": pubkey_text_key} - txt = ens_module.get_text(name, pubkey_text_key) # type: ignore[union-attr] - except Exception as err: - return {"pubkey": None, "source": None, "error": f"ENS TXT lookup failed: {err}", "txt_key": pubkey_text_key} + signer_name = txt_resolver.get_text(name, "cl.receipt.signer") + if not signer_name: + raise ValueError(f"ENS TXT cl.receipt.signer missing for agent ENS name: {name}") - if not txt: - return {"pubkey": None, "source": None, "error": f"ENS TXT {pubkey_text_key} missing", "txt_key": pubkey_text_key} + pub_key_text = txt_resolver.get_text(signer_name, "cl.sig.pub") + if not pub_key_text: + raise ValueError(f"ENS TXT cl.sig.pub missing for signer ENS name: {signer_name}") - pubkey = parse_ed25519_pubkey(str(txt).strip()) - return {"pubkey": pubkey, "source": "ens", "txt_key": pubkey_text_key, "txt_value": txt} - except Exception as err: - return {"pubkey": None, "source": None, "error": str(err), "txt_key": pubkey_text_key} + kid = txt_resolver.get_text(signer_name, "cl.sig.kid") + if not kid: + raise ValueError(f"ENS TXT cl.sig.kid missing for signer ENS name: {signer_name}") + + try: + raw_public_key_bytes = parse_ed25519_pubkey(pub_key_text) + except ValueError as err: + raise ValueError( + f"ENS TXT cl.sig.pub malformed for signer ENS name: {signer_name}. {err}" + ) from err + + return SignerKeyResolution( + algorithm="ed25519", + kid=kid, + raw_public_key_bytes=raw_public_key_bytes, + ) def to_unsigned_receipt(receipt: Receipt) -> Receipt: if not isinstance(receipt, dict): raise ValueError("receipt must be an object") - r = copy.deepcopy(receipt) + unsigned = copy.deepcopy(receipt) - metadata = r.get("metadata") + metadata = unsigned.get("metadata") if isinstance(metadata, dict): metadata.pop("receipt_id", None) proof = metadata.get("proof") if isinstance(proof, dict): - unsigned_proof = {} + unsigned_proof: dict[str, str] = {} for key in ("alg", "canonical", "signer_id"): - if isinstance(proof.get(key), str): - unsigned_proof[key] = proof[key] + value = proof.get(key) + if isinstance(value, str): + unsigned_proof[key] = value metadata["proof"] = unsigned_proof - r.pop("receipt_id", None) - return r + unsigned.pop("receipt_id", None) + return unsigned def recompute_receipt_hash_sha256(receipt: Receipt) -> dict[str, str]: unsigned = to_unsigned_receipt(receipt) canonical = canonicalize_stable_json_v1(unsigned) - hash_sha256 = sha256_hex_utf8(canonical) - return {"canonical": canonical, "hash_sha256": hash_sha256} + return {"canonical": canonical, "hash_sha256": sha256_hex_utf8(canonical)} -def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[str, Any] | None = None) -> VerifyResult: - try: - proof = ((receipt.get("metadata") or {}).get("proof") or {}) if isinstance(receipt, dict) else {} +def _extract_rpc_url(ens: EnsVerifyOptions) -> str: + return str(ens.get("rpcUrl") or ens.get("rpc_url") or "") + - claimed_hash = proof.get("hash_sha256") if isinstance(proof.get("hash_sha256"), str) else None - signature_b64 = proof.get("signature_b64") if isinstance(proof.get("signature_b64"), str) else None +def verify_receipt( + receipt: Receipt, + public_key: str | None = None, + ens: EnsVerifyOptions | None = None, +) -> VerifyResult: + try: + proof = ( + ((receipt.get("metadata") or {}).get("proof") or {}) + if isinstance(receipt, dict) + else {} + ) + + claimed_hash = ( + proof.get("hash_sha256") if isinstance(proof.get("hash_sha256"), str) else None + ) + signature_b64 = ( + proof.get("signature_b64") if isinstance(proof.get("signature_b64"), str) else None + ) alg = proof.get("alg") if isinstance(proof.get("alg"), str) else None canonical = proof.get("canonical") if isinstance(proof.get("canonical"), str) else None signer_id = proof.get("signer_id") if isinstance(proof.get("signer_id"), str) else None @@ -144,14 +231,18 @@ def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[st recomputed_hash = recompute_receipt_hash_sha256(receipt)["hash_sha256"] hash_matches = bool(claimed_hash and claimed_hash == recomputed_hash) - metadata = receipt.get("metadata") if isinstance(receipt.get("metadata"), dict) else {} - assert isinstance(metadata, dict) # narrowing for mypy; always true given the guard above - receipt_id = metadata.get("receipt_id") or receipt.get("receipt_id") - receipt_id = receipt_id if isinstance(receipt_id, str) else None + receipt_id_value: Any = None + if isinstance(receipt, dict): + metadata = receipt.get("metadata") + if isinstance(metadata, dict): + receipt_id_value = metadata.get("receipt_id") + receipt_id_value = receipt_id_value or receipt.get("receipt_id") + + receipt_id = receipt_id_value if isinstance(receipt_id_value, str) else None receipt_id_matches = bool(claimed_hash and receipt_id == claimed_hash) pubkey: bytes | None = None - pubkey_source: Literal["explicit", "ens"] | None = None + pubkey_source: str | None = None ens_error: str | None = None ens_txt_key: str | None = None @@ -159,21 +250,20 @@ def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[st pubkey = parse_ed25519_pubkey(public_key) pubkey_source = "explicit" elif ens: - ens_rpc_url = ens.get("rpcUrl") or ens.get("rpc_url") or "" - res = resolve_ens_ed25519_pubkey( - name=ens["name"], - rpc_url=str(ens_rpc_url), - pubkey_text_key=ens.get("pubkeyTextKey") or ens.get("pubkey_text_key") or "cl.pubkey", - ) - ens_txt_key = res.get("txt_key") - if not res.get("pubkey"): - ens_error = res.get("error") or "ENS pubkey not found" + ens_txt_key = "cl.receipt.signer -> cl.sig.pub, cl.sig.kid" + ens_name = ens.get("name") + if not ens_name: + ens_error = "ens.name is required" else: - pubkey = res["pubkey"] - pubkey_source = "ens" + try: + signer_key = resolve_signer_key(ens_name, _extract_rpc_url(ens)) + pubkey = signer_key.raw_public_key_bytes + pubkey_source = "ens" + except Exception as err: # noqa: BLE001 + ens_error = str(err) signature_valid = False - signature_error = None + signature_error: str | None = None if not alg_matches: signature_error = f'proof.alg must be "ed25519-sha256" (got {alg})' @@ -182,15 +272,26 @@ def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[st elif not claimed_hash or not signature_b64: signature_error = "missing proof.hash_sha256 or proof.signature_b64" elif not pubkey: - signature_error = ens_error or "no public key available (provide public_key/publicKey or ens)" + signature_error = ( + ens_error or "no public key available (provide public_key/publicKey or ens)" + ) else: try: - signature_valid = verify_ed25519_signature_over_utf8_hash_string(claimed_hash, signature_b64, pubkey) - except Exception as err: - signature_valid = False + signature_valid = verify_ed25519_signature_over_utf8_hash_string( + claimed_hash, + signature_b64, + pubkey, + ) + except Exception as err: # noqa: BLE001 signature_error = str(err) - ok = alg_matches and canonical_matches and hash_matches and receipt_id_matches and signature_valid + ok = ( + alg_matches + and canonical_matches + and hash_matches + and receipt_id_matches + and signature_valid + ) return { "ok": ok, @@ -202,14 +303,16 @@ def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[st "canonical_matches": canonical_matches, }, "values": { - "verb": (((receipt.get("x402") or {}).get("verb")) if isinstance(receipt, dict) else None), + "verb": ((receipt.get("x402") or {}).get("verb")) + if isinstance(receipt, dict) + else None, "signer_id": signer_id, "alg": alg, "canonical": canonical, "claimed_hash": claimed_hash, "recomputed_hash": recomputed_hash, "receipt_id": receipt_id, - "pubkey_source": pubkey_source, + "pubkey_source": pubkey_source, # type: ignore[typeddict-item] "ens_txt_key": ens_txt_key, }, "errors": { @@ -218,7 +321,7 @@ def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[st "verify_error": None, }, } - except Exception as err: + except Exception as err: # noqa: BLE001 return { "ok": False, "checks": { @@ -229,13 +332,35 @@ def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[st "canonical_matches": False, }, "values": { - "verb": ((receipt.get("x402") or {}).get("verb")) if isinstance(receipt, dict) else None, - "signer_id": ((((receipt.get("metadata") or {}).get("proof") or {}).get("signer_id")) if isinstance(receipt, dict) else None), - "alg": ((((receipt.get("metadata") or {}).get("proof") or {}).get("alg")) if isinstance(receipt, dict) else None), - "canonical": ((((receipt.get("metadata") or {}).get("proof") or {}).get("canonical")) if isinstance(receipt, dict) else None), - "claimed_hash": ((((receipt.get("metadata") or {}).get("proof") or {}).get("hash_sha256")) if isinstance(receipt, dict) else None), + "verb": ((receipt.get("x402") or {}).get("verb")) + if isinstance(receipt, dict) + else None, + "signer_id": ( + (((receipt.get("metadata") or {}).get("proof") or {}).get("signer_id")) + if isinstance(receipt, dict) + else None + ), + "alg": ( + (((receipt.get("metadata") or {}).get("proof") or {}).get("alg")) + if isinstance(receipt, dict) + else None + ), + "canonical": ( + (((receipt.get("metadata") or {}).get("proof") or {}).get("canonical")) + if isinstance(receipt, dict) + else None + ), + "claimed_hash": ( + (((receipt.get("metadata") or {}).get("proof") or {}).get("hash_sha256")) + if isinstance(receipt, dict) + else None + ), "recomputed_hash": None, - "receipt_id": (((receipt.get("metadata") or {}).get("receipt_id")) if isinstance(receipt, dict) else None), + "receipt_id": ( + ((receipt.get("metadata") or {}).get("receipt_id")) + if isinstance(receipt, dict) + else None + ), "pubkey_source": None, "ens_txt_key": None, }, diff --git a/python-sdk/docs/client.md b/python-sdk/docs/client.md new file mode 100644 index 0000000..b8d6704 --- /dev/null +++ b/python-sdk/docs/client.md @@ -0,0 +1,30 @@ +# Client API + +## Constructor + +`CommandLayerClient(runtime, actor, timeout_ms, headers, retries, verify_receipts, verify)` + +- `runtime`: Base runtime URL. +- `actor`: Default actor ID used in requests. +- `timeout_ms`: Request timeout. +- `headers`: Additional request headers. +- `retries`: Retry count for transport/timeout errors. +- `verify_receipts`: If true, verify every returned receipt. +- `verify`: Verification options (`public_key` / `ens`). + +## Verbs + +- `summarize` +- `analyze` +- `classify` +- `clean` +- `convert` +- `describe` +- `explain` +- `format` +- `parse` +- `fetch` + +## Generic invoke + +Use `client.call(verb, payload)` for full control. diff --git a/python-sdk/docs/getting-started.md b/python-sdk/docs/getting-started.md new file mode 100644 index 0000000..399ca7a --- /dev/null +++ b/python-sdk/docs/getting-started.md @@ -0,0 +1,33 @@ +# Getting Started + +## Install + +```bash +pip install commandlayer +``` + +## First request + +```python +from commandlayer import create_client + +client = create_client(actor="my-app") +receipt = client.summarize(content="Hello world", style="bullet_points") +print(receipt["status"]) +``` + +## Verify receipts (recommended in production) + +```python +from commandlayer import CommandLayerClient + +client = CommandLayerClient( + verify_receipts=True, + verify={ + "ens": { + "name": "summarizeagent.eth", + "rpcUrl": "https://mainnet.infura.io/v3/YOUR_KEY", + } + }, +) +``` diff --git a/python-sdk/docs/verification.md b/python-sdk/docs/verification.md new file mode 100644 index 0000000..b68d58b --- /dev/null +++ b/python-sdk/docs/verification.md @@ -0,0 +1,24 @@ +# Verification + +The SDK verifies signed receipts using: + +- canonical JSON: `cl-stable-json-v1` +- hash: `sha256` over unsigned receipt +- signature: `ed25519` over the hash string + +## ENS key resolution flow + +1. Resolve agent ENS TXT: `cl.receipt.signer` +2. Resolve signer ENS TXT: `cl.sig.pub` +3. Resolve signer ENS TXT: `cl.sig.kid` + +Use `resolve_signer_key(name, rpc_url)` for direct key resolution. + +## Programmatic verification + +```python +from commandlayer import verify_receipt + +result = verify_receipt(receipt, ens={"name": "summarizeagent.eth", "rpcUrl": "https://..."}) +print(result["ok"]) +``` diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index 1feb0f8..0de7d4e 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -33,8 +33,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0.0", - "ruff>=0.4.0", - "mypy>=1.8.0", + "ruff>=0.6.0", + "mypy>=1.10.0", "build>=1.1.0", "twine>=5.0.0", ] @@ -52,14 +52,20 @@ include = ["commandlayer*"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" +pythonpath = ["."] [tool.ruff] line-length = 100 target-version = "py310" +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] + [tool.mypy] python_version = "3.10" warn_return_any = true warn_unused_configs = true -disallow_untyped_defs = false +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true ignore_missing_imports = true diff --git a/python-sdk/tests/test_client.py b/python-sdk/tests/test_client.py index 2c844e6..ae12b07 100644 --- a/python-sdk/tests/test_client.py +++ b/python-sdk/tests/test_client.py @@ -1,24 +1,88 @@ +from __future__ import annotations + +import json + +import httpx import pytest from commandlayer.client import CommandLayerClient from commandlayer.errors import CommandLayerError -def test_call_rejects_unsupported_verb(): - client = CommandLayerClient() - with pytest.raises(CommandLayerError): +def test_call_rejects_unsupported_verb() -> None: + client = CommandLayerClient( + http_client=httpx.Client(transport=httpx.MockTransport(lambda _: httpx.Response(200))) + ) + with pytest.raises(CommandLayerError, match="Unsupported verb"): client.call("unknown", {}) -def test_verify_config_required_when_enabled(): +def test_verify_config_required_when_enabled() -> None: client = CommandLayerClient(verify_receipts=True) - with pytest.raises(CommandLayerError): + with pytest.raises(CommandLayerError, match="verification key config"): client._ensure_verify_config_if_enabled() -def test_verify_config_accepts_camelcase_aliases(): +def test_client_posts_expected_payload() -> None: + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["json"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + json={ + "status": "success", + "x402": {"verb": "summarize"}, + "metadata": {"proof": {"alg": "ed25519-sha256", "canonical": "cl-stable-json-v1"}}, + }, + ) + + http = httpx.Client(transport=httpx.MockTransport(handler)) + client = CommandLayerClient( + runtime="https://runtime.commandlayer.org", actor="tester", http_client=http + ) + + client.summarize(content="hello", style="bullet_points") + + assert captured["url"] == "https://runtime.commandlayer.org/summarize/v1.0.0" + sent = captured["json"] + assert isinstance(sent, dict) + assert sent["actor"] == "tester" + assert sent["x402"]["verb"] == "summarize" + + +def test_client_surfaces_error_message() -> None: + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(422, json={"error": {"message": "bad input"}}) + + client = CommandLayerClient(http_client=httpx.Client(transport=httpx.MockTransport(handler))) + + with pytest.raises(CommandLayerError, match="bad input") as exc: + client.summarize(content="x") + + assert exc.value.status_code == 422 + + +def test_client_verify_receipts_failure(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"status": "success", "metadata": {"proof": {}}}) + + monkeypatch.setattr( + "commandlayer.client.verify_receipt", + lambda *_args, **_kwargs: { + "ok": False, + "checks": {}, + "values": {}, + "errors": {"signature_error": "boom", "ens_error": None, "verify_error": None}, + }, + ) + client = CommandLayerClient( verify_receipts=True, - verify={"publicKey": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + verify={"public_key": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + http_client=httpx.Client(transport=httpx.MockTransport(handler)), ) - client._ensure_verify_config_if_enabled() + + with pytest.raises(CommandLayerError, match="Receipt verification failed"): + client.summarize(content="x") diff --git a/python-sdk/tests/test_public_api.py b/python-sdk/tests/test_public_api.py new file mode 100644 index 0000000..e1dc7b5 --- /dev/null +++ b/python-sdk/tests/test_public_api.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from commandlayer import CommandLayerClient, create_client + + +def test_create_client_factory() -> None: + client = create_client(actor="api-user") + assert isinstance(client, CommandLayerClient) + assert client.actor == "api-user" + client.close() diff --git a/python-sdk/tests/test_verify.py b/python-sdk/tests/test_verify.py index 151b507..d0c742d 100644 --- a/python-sdk/tests/test_verify.py +++ b/python-sdk/tests/test_verify.py @@ -1,12 +1,28 @@ +from __future__ import annotations + import base64 +import pytest from nacl.signing import SigningKey -from commandlayer.verify import parse_ed25519_pubkey, recompute_receipt_hash_sha256, verify_receipt +from commandlayer.verify import ( + parse_ed25519_pubkey, + recompute_receipt_hash_sha256, + resolve_signer_key, + verify_receipt, +) + + +class FakeResolver: + def __init__(self, records: dict[tuple[str, str], str | None]): + self.records = records + def get_text(self, name: str, key: str) -> str | None: + return self.records.get((name, key)) -def test_verify_receipt_happy_path_explicit_public_key(): - receipt = { + +def _signed_receipt() -> tuple[dict[str, object], str]: + receipt: dict[str, object] = { "status": "success", "x402": { "verb": "summarize", @@ -22,24 +38,82 @@ def test_verify_receipt_happy_path_explicit_public_key(): }, } - hash_out = recompute_receipt_hash_sha256(receipt) - sk = SigningKey.generate() - signature = sk.sign(hash_out["hash_sha256"].encode("utf-8")).signature + key = SigningKey.generate() + h = recompute_receipt_hash_sha256(receipt)["hash_sha256"] + sig = key.sign(h.encode("utf-8")).signature + pub_b64 = base64.b64encode(bytes(key.verify_key)).decode("utf-8") + + metadata = receipt["metadata"] + assert isinstance(metadata, dict) + proof = metadata["proof"] + assert isinstance(proof, dict) + proof["hash_sha256"] = h + proof["signature_b64"] = base64.b64encode(sig).decode("utf-8") + metadata["receipt_id"] = h + + return receipt, pub_b64 + + +def test_parse_ed25519_pubkey_supports_base64_and_hex() -> None: + _, pub_b64 = _signed_receipt() + parsed = parse_ed25519_pubkey(f"ed25519:{pub_b64}") + assert len(parsed) == 32 + + hex_key = parsed.hex() + assert parse_ed25519_pubkey(hex_key) == parsed + assert parse_ed25519_pubkey(f"0x{hex_key}") == parsed + + +def test_resolve_signer_key_two_hop_lookup() -> None: + _, pub_b64 = _signed_receipt() + resolver = FakeResolver( + { + ("summarizeagent.eth", "cl.receipt.signer"): "runtime.commandlayer.eth", + ("runtime.commandlayer.eth", "cl.sig.pub"): f"ed25519:{pub_b64}", + ("runtime.commandlayer.eth", "cl.sig.kid"): "2026-01", + } + ) + + out = resolve_signer_key("summarizeagent.eth", "https://rpc.example", resolver=resolver) + assert out.algorithm == "ed25519" + assert out.kid == "2026-01" + assert base64.b64encode(out.raw_public_key_bytes).decode("utf-8") == pub_b64 + + +def test_resolve_signer_key_missing_fields_are_clear() -> None: + resolver = FakeResolver({}) + with pytest.raises(ValueError, match="cl.receipt.signer missing"): + resolve_signer_key("agent.eth", "https://rpc.example", resolver=resolver) + + +def test_verify_receipt_with_explicit_and_ens_keys(monkeypatch: pytest.MonkeyPatch) -> None: + receipt, pub_b64 = _signed_receipt() + + explicit = verify_receipt(receipt, public_key=f"ed25519:{pub_b64}") + assert explicit["ok"] is True + assert explicit["checks"]["signature_valid"] is True - receipt["metadata"]["proof"]["hash_sha256"] = hash_out["hash_sha256"] - receipt["metadata"]["proof"]["signature_b64"] = base64.b64encode(signature).decode("utf-8") - receipt["metadata"]["receipt_id"] = hash_out["hash_sha256"] + class Resolved: + algorithm = "ed25519" + kid = "2026-01" + raw_public_key_bytes = parse_ed25519_pubkey(f"ed25519:{pub_b64}") - pubkey_b64 = base64.b64encode(bytes(sk.verify_key)).decode("utf-8") + monkeypatch.setattr( + "commandlayer.verify.resolve_signer_key", lambda *_args, **_kwargs: Resolved() + ) - out = verify_receipt(receipt, public_key=f"ed25519:{pubkey_b64}") - assert out["ok"] is True - assert out["checks"]["signature_valid"] is True - assert out["checks"]["hash_matches"] is True + ens_out = verify_receipt( + receipt, + ens={"name": "summarizeagent.eth", "rpcUrl": "https://rpc.example"}, + ) + assert ens_out["ok"] is True + assert ens_out["values"]["pubkey_source"] == "ens" -def test_parse_ed25519_pubkey_rejects_invalid_base64(): - import pytest +def test_verify_receipt_rejects_tampered_receipt() -> None: + receipt, pub_b64 = _signed_receipt() + receipt["status"] = "error" - with pytest.raises(Exception): - parse_ed25519_pubkey("not_base64!!!") + out = verify_receipt(receipt, public_key=f"ed25519:{pub_b64}") + assert out["ok"] is False + assert out["checks"]["hash_matches"] is False diff --git a/typescript-sdk/README.md b/typescript-sdk/README.md index 403cd7d..4359154 100644 --- a/typescript-sdk/README.md +++ b/typescript-sdk/README.md @@ -127,21 +127,21 @@ console.log(result.ok); **Option B — ENS-based Verification** -Resolves the public key from ENS TXT records. +Resolves signer metadata from ENS TXT records. Required ENS records: -- `cl.receipt.pubkey_pem` -- `cl.receipt.signer_id` -- `cl.receipt.alg` -- +- Agent ENS TXT: `cl.receipt.signer` +- Signer ENS TXT: `cl.sig.pub` +- Signer ENS TXT: `cl.sig.kid` + Example: ``` import { verifyReceipt } from "@commandlayer/sdk"; const out = await verifyReceipt(receipt, { ens: { - name: "runtime.commandlayer.eth", + name: "summarizeagent.eth", rpcUrl: process.env.ETH_RPC_URL! } }); diff --git a/typescript-sdk/scripts/unit-tests.mjs b/typescript-sdk/scripts/unit-tests.mjs index 425729a..4ee8b3f 100644 --- a/typescript-sdk/scripts/unit-tests.mjs +++ b/typescript-sdk/scripts/unit-tests.mjs @@ -5,17 +5,7 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); -const { - canonicalizeStableJsonV1, - sha256HexUtf8, - parseEd25519Pubkey, - verifyEd25519SignatureOverUtf8HashString, - recomputeReceiptHashSha256, - verifyReceipt, - CommandLayerError, - CommandLayerClient, -} = require("../dist/index.cjs"); - +const ethers = require("ethers"); const nacl = require("tweetnacl"); let passed = 0; @@ -42,6 +32,64 @@ function assertThrows(fn, name) { } } +async function assertRejects(fn, expected, name) { + try { + await fn(); + failed++; + console.error(`FAIL: ${name} (did not throw)`); + } catch (err) { + const msg = err?.message || String(err); + if (!msg.includes(expected)) { + failed++; + console.error(`FAIL: ${name} (unexpected message: ${msg})`); + return; + } + passed++; + console.log(`PASS: ${name}`); + } +} + +const kp = nacl.sign.keyPair(); +const b64Key = Buffer.from(kp.publicKey).toString("base64"); +const hexKey = Buffer.from(kp.publicKey).toString("hex"); + +const ensFixtures = { + "summarizeagent.eth": { "cl.receipt.signer": "runtime.commandlayer.eth" }, + "runtime.commandlayer.eth": { "cl.sig.pub": `ed25519:${b64Key}`, "cl.sig.kid": "2026-01" }, + "missing-signer.eth": {}, + "missing-pub.eth": { "cl.receipt.signer": "signer-without-pub.eth" }, + "signer-without-pub.eth": { "cl.sig.kid": "2026-01" }, + "malformed-pub.eth": { "cl.receipt.signer": "signer-with-malformed-pub.eth" }, + "signer-with-malformed-pub.eth": { "cl.sig.pub": "ed25519:not-base64", "cl.sig.kid": "2026-01" }, +}; + +class MockResolver { + constructor(name) { + this.name = name; + } + + async getText(key) { + return ensFixtures[this.name]?.[key] ?? ""; + } +} + +ethers.ethers.JsonRpcProvider.prototype.getResolver = async function(name) { + if (!(name in ensFixtures)) return null; + return new MockResolver(name); +}; + +const { + canonicalizeStableJsonV1, + sha256HexUtf8, + parseEd25519Pubkey, + verifyEd25519SignatureOverUtf8HashString, + recomputeReceiptHashSha256, + verifyReceipt, + resolveSignerKey, + CommandLayerError, + CommandLayerClient, +} = require("../dist/index.cjs"); + // ---- Canonicalization ---- assert(canonicalizeStableJsonV1(null) === "null", "canonicalize null"); @@ -84,10 +132,6 @@ assert(sha256HexUtf8("hello") !== sha256HexUtf8("world"), "sha256 differs for di // ---- Ed25519 pubkey parsing ---- -const kp = nacl.sign.keyPair(); -const b64Key = Buffer.from(kp.publicKey).toString("base64"); -const hexKey = Buffer.from(kp.publicKey).toString("hex"); - const pk1 = parseEd25519Pubkey(b64Key); assert(pk1.length === 32, "parse base64 pubkey"); @@ -123,6 +167,31 @@ assert( "wrong key rejects" ); +// ---- ENS signer key resolution ---- + +const signerKey = await resolveSignerKey("summarizeagent.eth", "http://mock-rpc.local"); +assert(signerKey.algorithm === "ed25519", "resolveSignerKey returns algorithm"); +assert(signerKey.kid === "2026-01", "resolveSignerKey returns kid from cl.sig.kid"); +assert(Buffer.from(signerKey.rawPublicKeyBytes).toString("base64") === b64Key, "resolveSignerKey returns public key bytes from cl.sig.pub"); + +await assertRejects( + () => resolveSignerKey("missing-signer.eth", "http://mock-rpc.local"), + "ENS TXT cl.receipt.signer missing", + "resolveSignerKey throws clear error when cl.receipt.signer missing" +); + +await assertRejects( + () => resolveSignerKey("missing-pub.eth", "http://mock-rpc.local"), + "ENS TXT cl.sig.pub missing", + "resolveSignerKey throws clear error when cl.sig.pub missing" +); + +await assertRejects( + () => resolveSignerKey("malformed-pub.eth", "http://mock-rpc.local"), + "ENS TXT cl.sig.pub malformed", + "resolveSignerKey throws clear error when cl.sig.pub malformed" +); + // ---- Receipt verification (end-to-end) ---- const receipt = { @@ -147,11 +216,20 @@ receipt.metadata.proof.signature_b64 = Buffer.from(receiptSig).toString("base64" receipt.metadata.receipt_id = hash_sha256; const vr = await verifyReceipt(receipt, { publicKey: `ed25519:${b64Key}` }); -assert(vr.ok === true, "verifyReceipt ok for valid receipt"); +assert(vr.ok === true, "verifyReceipt ok for valid receipt (explicit key)"); assert(vr.checks.hash_matches === true, "verifyReceipt hash matches"); assert(vr.checks.signature_valid === true, "verifyReceipt signature valid"); assert(vr.checks.receipt_id_matches === true, "verifyReceipt receipt_id matches"); +const vrEns = await verifyReceipt(receipt, { + ens: { + name: "summarizeagent.eth", + rpcUrl: "http://mock-rpc.local" + } +}); +assert(vrEns.ok === true, "verifyReceipt ok with ENS cl.receipt.signer + cl.sig.pub"); +assert(vrEns.values.pubkey_source === "ens", "verifyReceipt reports ENS key source"); + // Tampered receipt const tamperedReceipt = JSON.parse(JSON.stringify(receipt)); tamperedReceipt.result.summary = "tampered"; diff --git a/typescript-sdk/src/index.ts b/typescript-sdk/src/index.ts index b67f913..1e7f924 100644 --- a/typescript-sdk/src/index.ts +++ b/typescript-sdk/src/index.ts @@ -108,19 +108,16 @@ export class CommandLayerError extends Error { } export type EnsVerifyOptions = { - /** ENS name that holds TXT records (commonly runtime.commandlayer.eth) */ + /** Agent ENS name that holds TXT records (e.g. summarizeagent.eth) */ name: string; /** Ethereum RPC URL (required for ENS resolution) */ rpcUrl: string; - /** - * TXT record key that contains an Ed25519 public key (32 bytes). - * Accepts formats: - * - "ed25519:" - * - "" (32 bytes) - * - "0x" / "" (64 hex chars) - * Default: "cl.pubkey" - */ - pubkeyTextKey?: string; +}; + +export type SignerKeyResolution = { + algorithm: "ed25519"; + kid: string; + rawPublicKeyBytes: Uint8Array; }; export type VerifyOptions = { @@ -290,25 +287,47 @@ export function verifyEd25519SignatureOverUtf8HashString( } // ----------------------- -// ENS TXT pubkey resolution (ethers v6) +// ENS TXT signer key resolution (ethers v6) // ----------------------- -export async function resolveEnsEd25519Pubkey( - ens: EnsVerifyOptions -): Promise<{ pubkey: Uint8Array | null; source: "ens" | null; error?: string; txtKey: string; txtValue?: string }> { - const txtKey = ens.pubkeyTextKey || "cl.pubkey"; - try { - const provider = new ethers.JsonRpcProvider(ens.rpcUrl); - const resolver = await provider.getResolver(ens.name); - if (!resolver) return { pubkey: null, source: null, error: "No resolver for ENS name", txtKey }; +export async function resolveSignerKey(name: string, rpcUrl: string): Promise { + const provider = new ethers.JsonRpcProvider(rpcUrl); + const agentResolver = await provider.getResolver(name); + if (!agentResolver) { + throw new Error(`No resolver for agent ENS name: ${name}`); + } + + const signerName = (await agentResolver.getText("cl.receipt.signer"))?.trim(); + if (!signerName) { + throw new Error(`ENS TXT cl.receipt.signer missing for agent ENS name: ${name}`); + } + + const signerResolver = await provider.getResolver(signerName); + if (!signerResolver) { + throw new Error(`No resolver for signer ENS name: ${signerName}`); + } - const txt = (await resolver.getText(txtKey))?.trim(); - if (!txt) return { pubkey: null, source: null, error: `ENS TXT ${txtKey} missing`, txtKey }; + const pubKeyText = (await signerResolver.getText("cl.sig.pub"))?.trim(); + if (!pubKeyText) { + throw new Error(`ENS TXT cl.sig.pub missing for signer ENS name: ${signerName}`); + } + + const kid = (await signerResolver.getText("cl.sig.kid"))?.trim(); + if (!kid) { + throw new Error(`ENS TXT cl.sig.kid missing for signer ENS name: ${signerName}`); + } - const pubkey = parseEd25519Pubkey(txt); - return { pubkey, source: "ens", txtKey, txtValue: txt }; + let rawPublicKeyBytes: Uint8Array; + try { + rawPublicKeyBytes = parseEd25519Pubkey(pubKeyText); } catch (e: any) { - return { pubkey: null, source: null, error: e?.message || "ENS resolution failed", txtKey }; + throw new Error(`ENS TXT cl.sig.pub malformed for signer ENS name: ${signerName}. ${e?.message || String(e)}`); } + + return { + algorithm: "ed25519", + kid, + rawPublicKeyBytes + }; } // ----------------------- @@ -372,13 +391,13 @@ export async function verifyReceipt(receipt: Receipt, opts: VerifyOptions = {}): pubkey = parseEd25519Pubkey(opts.publicKey); pubkey_source = "explicit"; } else if (opts.ens) { - const res = await resolveEnsEd25519Pubkey(opts.ens); - ens_txt_key = res.txtKey; - if (!res.pubkey) { - ens_error = res.error || "ENS pubkey not found"; - } else { - pubkey = res.pubkey; + ens_txt_key = "cl.receipt.signer -> cl.sig.pub, cl.sig.kid"; + try { + const signerKey = await resolveSignerKey(opts.ens.name, opts.ens.rpcUrl); + pubkey = signerKey.rawPublicKeyBytes; pubkey_source = "ens"; + } catch (e: any) { + ens_error = e?.message || "ENS signer key resolution failed"; } }