diff --git a/EXAMPLES.md b/EXAMPLES.md index 3334a85..c5365e2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -21,7 +21,7 @@ All examples in this file target: "alg": "ed25519-sha256", "canonical": "cl-stable-json-v1", "signer_id": "runtime.commandlayer.eth", - "hash_sha256": "...", + "hash_sha256": "same-value-as-receipt_id", "signature_b64": "..." } } @@ -34,48 +34,19 @@ All examples in this file target: } ``` -## 2. TypeScript examples - -### Create client +## TypeScript ```ts -import { createClient } from "@commandlayer/sdk"; - -const client = createClient({ - actor: "examples-ts", - runtime: "https://runtime.commandlayer.org" -}); -``` +import { createClient, extractReceiptVerb, verifyReceipt } from "@commandlayer/sdk"; -### Summarize - -```ts +const client = createClient({ actor: "examples-ts" }); const response = await client.summarize({ - content: "CommandLayer defines semantic agent verbs.", + content: "CommandLayer defines verifiable agent verbs.", style: "bullet_points" }); -console.log(response.receipt.result?.summary); -``` - -### Analyze - -```ts -const response = await client.analyze({ - content: "Invoice total: $1200", - goal: "detect finance intent" -}); -``` - -### Classify - -```ts -const response = await client.classify({ - content: "Contact support@example.com" -}); -``` - -### Clean +console.log(extractReceiptVerb(response)); +console.log(response.receipt.metadata.receipt_id); ```ts const response = await client.clean({ @@ -172,120 +143,52 @@ import { verifyReceipt } from "@commandlayer/sdk"; const result = await verifyReceipt(response.receipt, { publicKey: "ed25519:BASE64_PUBLIC_KEY" }); +console.log(verified.ok); ``` -### Python, explicit key +## Python ```python -from commandlayer import verify_receipt - -result = verify_receipt(response["receipt"], public_key="ed25519:BASE64_PUBLIC_KEY") -``` +from commandlayer import create_client, verify_receipt +from commandlayer.verify import extract_receipt_verb -### ENS-backed verification - -```ts -const result = await verifyReceipt(response.receipt, { - ens: { - name: "summarizeagent.eth", - rpcUrl: process.env.MAINNET_RPC_URL! - } -}); -``` - -```python -result = verify_receipt( - response["receipt"], - ens={"name": "summarizeagent.eth", "rpcUrl": "https://mainnet.infura.io/v3/YOUR_KEY"}, +client = create_client(actor="examples-py") +response = client.summarize( + content="CommandLayer defines verifiable agent verbs.", + style="bullet_points", ) -``` - -## 5. CLI examples -### Summarize - -```bash -commandlayer summarize \ - --content "CommandLayer defines semantic verbs." \ - --style bullet_points \ - --json -``` - -### Analyze - -```bash -commandlayer analyze \ - --content "Invoice total: $500" \ - --goal "detect finance intent" \ - --json +print(extract_receipt_verb(response)) +print(response["receipt"]["metadata"]["receipt_id"]) +print(verify_receipt(response["receipt"], public_key="ed25519:BASE64_PUBLIC_KEY")["ok"]) ``` -### Verify a saved receipt - -```bash -commandlayer verify \ - --file receipt.json \ - --public-key "ed25519:BASE64_PUBLIC_KEY" -``` - -## 6. Runtime override - -### TypeScript - -```ts -const client = createClient({ - actor: "override-example", - runtime: "https://staging-runtime.commandlayer.org" -}); -``` - -### Python - -```python -client = create_client( - actor="override-example", - runtime="https://staging-runtime.commandlayer.org", -) -``` +## Explicit request building -## 7. Persist the canonical receipt +### Commons ```ts -import { writeFile } from "node:fs/promises"; +import { buildCommonsRequest } from "@commandlayer/sdk"; -await writeFile("receipt.json", JSON.stringify(response.receipt, null, 2)); +const payload = buildCommonsRequest("parse", { + input: { content: '{"a":1}', content_type: "json", mode: "strict" }, + limits: { max_output_tokens: 300 } +}, { actor: "examples-ts" }); ``` ```python -import json -from pathlib import Path - -Path("receipt.json").write_text(json.dumps(response["receipt"], indent=2), encoding="utf-8") -``` - -## 8. Error handling - -### TypeScript - -```ts -import { CommandLayerError } from "@commandlayer/sdk"; +from commandlayer import build_commons_request -try { - await client.summarize({ content: "" }); -} catch (error) { - if (error instanceof CommandLayerError) { - console.error(error.statusCode, error.message, error.details); - } -} +payload = build_commons_request( + "parse", + { + "input": {"content": '{"a":1}', "content_type": "json", "mode": "strict"}, + "limits": {"max_output_tokens": 300}, + }, + actor="examples-py", +) ``` -### Python +### Commercial request shaping -```python -from commandlayer import CommandLayerError - -try: - client.summarize(content="") -except CommandLayerError as error: - print(error.status_code, error, error.details) -``` +Commercial request shaping is intentionally separate from Commons examples. Use the dedicated `buildCommercialRequest` / `build_commercial_request` helper only if you are integrating a payment-aware flow outside this repo's first-class runtime client surface. diff --git a/QUICKSTART.md b/QUICKSTART.md index 7cb3994..12566b2 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,30 +1,13 @@ # CommandLayer SDK Quickstart -Goal: install the SDK, run one verb, inspect the receipt, verify it, and reproduce the call in under three minutes. - ## 1. Install -### TypeScript / JavaScript - ```bash npm install @commandlayer/sdk -``` - -### Python - -```bash pip install commandlayer ``` -### CLI - -The CLI ships with the npm package: - -```bash -npm install -g @commandlayer/sdk -``` - -## 2. Make your first call +## 2. Make one call ### TypeScript @@ -32,13 +15,13 @@ npm install -g @commandlayer/sdk import { createClient } from "@commandlayer/sdk"; const client = createClient({ actor: "quickstart-ts" }); - const response = await client.summarize({ content: "CommandLayer makes agent execution verifiable.", style: "bullet_points" }); console.log(response.receipt.result?.summary); +console.log(response.receipt.metadata.receipt_id); ``` ### Python @@ -53,6 +36,7 @@ response = client.summarize( ) print(response["receipt"]["result"]["summary"]) +print(response["receipt"]["metadata"]["receipt_id"]) ``` ## 3. Inspect the response @@ -89,67 +73,17 @@ Use `response.receipt` as the durable protocol artifact. `runtime_metadata` is o ```ts import { verifyReceipt } from "@commandlayer/sdk"; - -const result = await verifyReceipt(response.receipt, { - publicKey: "ed25519:BASE64_PUBLIC_KEY" -}); - -console.log(result.ok); +await verifyReceipt(response.receipt, { publicKey: "ed25519:BASE64_PUBLIC_KEY" }); ``` -### Python - ```python from commandlayer import verify_receipt - -result = verify_receipt( - response["receipt"], - public_key="ed25519:BASE64_PUBLIC_KEY", -) -print(result["ok"]) -``` - -### ENS-backed verification - -Use the same signer-discovery model in both SDKs: -- agent ENS TXT: `cl.receipt.signer` -- signer ENS TXT: `cl.sig.pub` -- signer ENS TXT: `cl.sig.kid` - -## 5. Try the CLI - -```bash -commandlayer summarize \ - --content "CommandLayer makes agent execution verifiable." \ - --style bullet_points \ - --json -``` - -Save the returned JSON and verify it: - -```bash -commandlayer verify \ - --file receipt.json \ - --public-key "ed25519:BASE64_PUBLIC_KEY" +verify_receipt(response["receipt"], public_key="ed25519:BASE64_PUBLIC_KEY") ``` -## 6. What is stable today? - -Stable in this repo: -- Protocol-Commons v1.1.0 verb surface, -- canonical signed receipt verification, -- ENS signer discovery helpers, -- TypeScript SDK `@commandlayer/sdk` v1.1.0, -- Python SDK `commandlayer` v1.1.0. - -Not claimed as first-class SDK support here: -- Protocol-Commercial payment flows, -- runtime-specific orchestration metadata beyond the generic `runtime_metadata` envelope. - -## Next steps +## 4. Remember the contract -- More recipes: `EXAMPLES.md` -- Package docs: `typescript-sdk/README.md`, `python-sdk/README.md` -- Maintainer notes: `DEVELOPER_EXPERIENCE.md` -- Release guide: `RELEASE_GUIDE.md` -- Deployment checklist: `DEPLOYMENT_GUIDE.md` +- Persist `response.receipt`. +- Treat `response.runtime_metadata` as optional unsigned context. +- Treat `receipt.metadata.receipt_id` as the receipt hash identifier. +- Read the verb from `receipt.x402.verb`. diff --git a/README.md b/README.md index db07b59..50af6f0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ # CommandLayer SDK -Official SDK repo for CommandLayer Protocol-Commons v1.1.0. +Official SDK repo for the current-line CommandLayer Commons receipt contract (`1.1.0`). -## Start Here +## What this repo now treats as canonical + +- **Requests**: Commons requests are built around one explicit envelope: top-level `x402.verb`, `x402.version`, `actor`, and the verb body. +- **Responses**: the signed artifact is always `response.receipt`. +- **Unsigned runtime context**: optional execution details live in `response.runtime_metadata`. +- **Verification**: verification recomputes the receipt hash from the unsigned receipt, checks `metadata.receipt_id === metadata.proof.hash_sha256`, then verifies the Ed25519 signature over the UTF-8 hash string. +- **Verb semantics**: the verb is read from `receipt.x402.verb`. + +This repo no longer presents legacy blended envelopes as the primary contract. Legacy normalization remains only to accept older runtime responses that inlined `trace` beside the receipt. + +## Start here - Quickstart → `QUICKSTART.md` - Full usage → `EXAMPLES.md` @@ -37,23 +47,50 @@ Protocol-Commercial / x402 payment flows are intentionally separate from the Com npm install @commandlayer/sdk ``` -Node.js 20+ is supported. - ### Python ```bash pip install commandlayer ``` -Python 3.10+ is supported. +## Canonical receipt shape -## First call: TypeScript +```json +{ + "receipt": { + "status": "success", + "x402": { + "verb": "summarize", + "version": "1.1.0" + }, + "result": { + "summary": "..." + }, + "metadata": { + "receipt_id": "sha256-of-unsigned-receipt", + "proof": { + "alg": "ed25519-sha256", + "canonical": "cl-stable-json-v1", + "signer_id": "runtime.commandlayer.eth", + "hash_sha256": "same-value-as-receipt_id", + "signature_b64": "..." + } + } + }, + "runtime_metadata": { + "trace_id": "trace_123", + "duration_ms": 118, + "provider": "runtime.commandlayer.org" + } +} +``` + +## TypeScript quick path ```ts import { createClient, verifyReceipt } from "@commandlayer/sdk"; const client = createClient({ actor: "my-app" }); - const response = await client.summarize({ content: "CommandLayer turns agent calls into signed receipts.", style: "bullet_points" @@ -69,7 +106,7 @@ const verification = await verifyReceipt(response.receipt, { console.log(verification.ok); ``` -## First call: Python +## Python quick path ```python from commandlayer import create_client, verify_receipt @@ -121,54 +158,37 @@ Client methods now return a command response envelope: The canonical signed object is `receipt`. `runtime_metadata` is optional and unsigned. Verification, persistence, and downstream audit should use the canonical `receipt` object. -The SDK still normalizes older blended runtime responses for compatibility, but that normalization is legacy-only. The repo documents the v1.1.0 envelope as the single canonical public contract. +### Commons -## Verification +Use the client verb methods or the explicit request builder helpers: -### Offline verification +- TypeScript: `buildCommonsRequest(verb, body, { actor })` +- Python: `build_commons_request(verb, body, actor=...)` -```ts -import { verifyReceipt } from "@commandlayer/sdk"; +### Commercial -const result = await verifyReceipt(response.receipt, { - publicKey: "ed25519:BASE64_PUBLIC_KEY" -}); -``` +The repo does **not** claim first-class commercial runtime coverage, but both SDKs now isolate commercial request shaping behind explicit opt-in helpers instead of mixing it into Commons request construction: -### ENS-backed verification +- TypeScript: `buildCommercialRequest(...)` +- Python: `build_commercial_request(...)` -```ts -const result = await verifyReceipt(response.receipt, { - ens: { - name: "summarizeagent.eth", - rpcUrl: process.env.MAINNET_RPC_URL! - } -}); -``` +That commercial builder is intentionally separate from the Commons happy path. -ENS signer discovery resolves: -1. `cl.receipt.signer` on the agent ENS name, -2. `cl.sig.pub` on the signer ENS name, -3. `cl.sig.kid` on the signer ENS name. +## Verification -## CLI +Verification reads exactly the current receipt contract: -Install the npm package and use the bundled CLI: +1. take `response.receipt`, +2. remove `metadata.receipt_id` and the signed hash/signature fields, +3. canonicalize with `cl-stable-json-v1`, +4. recompute `sha256`, +5. require `metadata.receipt_id === metadata.proof.hash_sha256`, +6. verify the Ed25519 signature. -```bash -commandlayer summarize --content "Test text" --style bullet_points --json -commandlayer verify --file receipt.json --public-key "ed25519:BASE64_PUBLIC_KEY" -``` +## Legacy handling retained -The CLI is intended for demos, CI smoke tests, debugging, and reproducing SDK flows without writing app code. +Only one legacy surface is retained in the main packages: -## Repo guide +- `normalizeCommandResponse` / `normalize_command_response` still accept older blended runtime payloads that used a top-level `trace` field and convert them into `{ receipt, runtime_metadata }`. -- Fast onboarding: `QUICKSTART.md` -- Cookbook examples: `EXAMPLES.md` -- Maintainer / architecture notes: `DEVELOPER_EXPERIENCE.md` -- Release guide: `RELEASE_GUIDE.md` -- Deployment checklist: `DEPLOYMENT_GUIDE.md` -- Changelog: `CHANGELOG.md` -- TypeScript package docs: `typescript-sdk/README.md` -- Python package docs: `python-sdk/README.md` +Everything else is documented and typed against the current receipt contract. diff --git a/python-sdk/README.md b/python-sdk/README.md index 042e0b8..b621498 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -1,12 +1,8 @@ # CommandLayer Python SDK -Official Python SDK for CommandLayer Commons v1.1.0. +Current-line Python SDK for the CommandLayer Commons receipt contract (`1.1.0`). -The Python package mirrors the TypeScript SDK's protocol model: -- client methods return `{ "receipt": ..., "runtime_metadata": ... }`, -- the signed `receipt` is the canonical verification payload, -- `runtime_metadata` is optional execution context, and -- verification can use an explicit Ed25519 key or ENS discovery. +## Canonical contract Any `response["receipt"]["x402"]` block should be treated as legacy / commercial-only metadata rather than part of the Commons happy path in this repository. @@ -16,16 +12,14 @@ Any `response["receipt"]["x402"]` block should be treated as legacy / commercial pip install commandlayer ``` -Supported Python versions: 3.10+. - -## Quick start +## Happy path ```python from commandlayer import create_client, verify_receipt client = create_client(actor="docs-example") response = client.summarize( - content="CommandLayer makes agent execution verifiable.", + content="CommandLayer makes receipt verification explicit.", style="bullet_points", ) @@ -39,26 +33,37 @@ verification = verify_receipt( print(verification["ok"]) ``` -## Verification +## Explicit request builders ```python -result = verify_receipt( - response["receipt"], - ens={ - "name": "summarizeagent.eth", - "rpcUrl": "https://mainnet.infura.io/v3/YOUR_KEY", +from commandlayer import build_commons_request, build_commercial_request + +commons = build_commons_request( + "summarize", + { + "input": {"content": "hello", "summary_style": "bullet_points"}, + "limits": {"max_output_tokens": 400}, }, + actor="docs-example", +) + +commercial = build_commercial_request( + "summarize", + {"input": {"content": "hello"}}, + actor="docs-example", + payment={"scheme": "x402", "quote_id": "quote_123"}, ) ``` -## Development +Commercial request shaping is deliberately isolated from the Commons client happy path. -```bash -cd python-sdk -python -m venv .venv -source .venv/bin/activate -pip install -e '.[dev]' -ruff check . -mypy commandlayer -pytest -``` +## Verification helpers + +- `verify_receipt(receipt, public_key=...)` +- `verify_receipt(receipt, ens={"name": ..., "rpcUrl": ...})` +- `extract_receipt_verb(receipt_or_response)` +- `recompute_receipt_hash_sha256(receipt_or_response)` + +## Legacy support + +`normalize_command_response()` still accepts older blended payloads with top-level `trace` and rewrites them to the canonical envelope. That is compatibility-only. diff --git a/python-sdk/commandlayer/__init__.py b/python-sdk/commandlayer/__init__.py index 405ed4c..778d227 100644 --- a/python-sdk/commandlayer/__init__.py +++ b/python-sdk/commandlayer/__init__.py @@ -1,6 +1,12 @@ """CommandLayer Python SDK.""" -from .client import CommandLayerClient, create_client, normalize_command_response +from .client import ( + CommandLayerClient, + build_commercial_request, + build_commons_request, + create_client, + normalize_command_response, +) from .errors import CommandLayerError from .types import ( CanonicalReceipt, @@ -14,6 +20,7 @@ from .verify import ( canonicalize_stable_json_v1, parse_ed25519_pubkey, + extract_receipt_verb, recompute_receipt_hash_sha256, resolve_signer_key, sha256_hex_utf8, @@ -30,8 +37,11 @@ "VerifyOptions", "SignerKeyResolution", "VerifyResult", + "build_commercial_request", + "build_commons_request", "canonicalize_stable_json_v1", "create_client", + "extract_receipt_verb", "normalize_command_response", "sha256_hex_utf8", "parse_ed25519_pubkey", diff --git a/python-sdk/commandlayer/client.py b/python-sdk/commandlayer/client.py index 8ba67be..e12f863 100644 --- a/python-sdk/commandlayer/client.py +++ b/python-sdk/commandlayer/client.py @@ -32,26 +32,57 @@ def _normalize_base(url: str) -> str: return str(url or "").rstrip("/") +def build_commons_request( + verb: str, + body: dict[str, Any], + *, + actor: str, + version: str = COMMONS_VERSION, +) -> dict[str, Any]: + request_actor = str(body.get("actor") or actor or "sdk-user") + return { + "x402": {"verb": verb, "version": version}, + "actor": request_actor, + **body, + "actor": request_actor, + } + + +def build_commercial_request( + verb: str, + body: dict[str, Any], + *, + actor: str, + payment: dict[str, Any], + version: str = COMMONS_VERSION, +) -> dict[str, Any]: + return { + "mode": "commercial", + "receipt": {"verb": verb, "version": version}, + "payment": payment, + "actor": str(body.get("actor") or actor or "sdk-user"), + **body, + } + + def normalize_command_response(payload: Any) -> CommandResponse: if not isinstance(payload, dict): raise CommandLayerError("Runtime response must be a JSON object", 502, payload) - if isinstance(payload.get("receipt"), dict): response: CommandResponse = {"receipt": payload["receipt"]} if isinstance(payload.get("runtime_metadata"), dict): response["runtime_metadata"] = cast(RuntimeMetadata, dict(payload["runtime_metadata"])) return response - receipt = dict(payload) runtime_metadata = receipt.pop("trace", None) - response = {"receipt": receipt} + response: CommandResponse = {"receipt": receipt} if isinstance(runtime_metadata, dict): response["runtime_metadata"] = cast(RuntimeMetadata, runtime_metadata) return response class CommandLayerClient: - """Synchronous CommandLayer client for Protocol-Commons v1.1.0 verbs.""" + """Synchronous CommandLayer client for the current Commons receipt contract.""" def __init__( self, @@ -81,9 +112,7 @@ def __init__( def _ensure_verify_config_if_enabled(self) -> None: if not self.verify_receipts: return - explicit_public_key = self.verify_defaults.get("public_key") or self.verify_defaults.get( - "publicKey" - ) + 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"))) @@ -94,136 +123,31 @@ 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, - ) -> CommandResponse: - return self.call( - "summarize", - { - "input": { - "content": content, - "summary_style": style, - "format_hint": format, - }, - "limits": {"max_output_tokens": max_tokens}, - }, - ) + def summarize(self, *, content: str, style: str | None = None, format: str | None = None, max_tokens: int = 1000) -> CommandResponse: + return self.call("summarize", {"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, - ) -> CommandResponse: - payload: 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) -> CommandResponse: + payload: dict[str, Any] = {"input": content, "limits": {"max_output_tokens": max_tokens}} if goal: payload["goal"] = goal if hints: payload["hints"] = hints return self.call("analyze", payload) - def classify( - self, *, content: str, max_labels: int = 5, max_tokens: int = 1000 - ) -> CommandResponse: - return self.call( - "classify", - { - "actor": self.actor, - "input": {"content": content}, - "limits": {"max_labels": max_labels, "max_output_tokens": max_tokens}, - }, - ) + def classify(self, *, content: str, max_labels: int = 5, max_tokens: int = 1000) -> CommandResponse: + return self.call("classify", {"input": {"content": content}, "limits": {"max_labels": max_labels, "max_output_tokens": max_tokens}}) - def clean( - self, - *, - content: str, - operations: list[str] | None = None, - max_tokens: int = 1000, - ) -> CommandResponse: - return self.call( - "clean", - { - "input": { - "content": content, - "operations": operations - or ["normalize_newlines", "collapse_whitespace", "trim"], - }, - "limits": {"max_output_tokens": max_tokens}, - }, - ) + def clean(self, *, content: str, operations: list[str] | None = None, max_tokens: int = 1000) -> CommandResponse: + return self.call("clean", {"input": {"content": content, "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, - ) -> CommandResponse: - return self.call( - "convert", - { - "input": { - "content": content, - "source_format": from_format, - "target_format": to_format, - }, - "limits": {"max_output_tokens": max_tokens}, - }, - ) + def convert(self, *, content: str, from_format: str, to_format: str, max_tokens: int = 1000) -> CommandResponse: + return self.call("convert", {"input": {"content": content, "source_format": from_format, "target_format": to_format}, "limits": {"max_output_tokens": max_tokens}}) - def describe( - self, - *, - subject: str, - audience: str = "general", - detail: str = "medium", - max_tokens: int = 1000, - ) -> CommandResponse: - return self.call( - "describe", - { - "input": { - "subject": (subject or "")[:140], - "audience": audience, - "detail_level": detail, - }, - "limits": {"max_output_tokens": max_tokens}, - }, - ) + def describe(self, *, subject: str, audience: str = "general", detail: str = "medium", max_tokens: int = 1000) -> CommandResponse: + return self.call("describe", {"input": {"subject": (subject or "")[:140], "audience": audience, "detail_level": detail}, "limits": {"max_output_tokens": max_tokens}}) - def explain( - self, - *, - subject: str, - audience: str = "general", - style: str = "step-by-step", - detail: str = "medium", - max_tokens: int = 1000, - ) -> CommandResponse: - return self.call( - "explain", - { - "input": { - "subject": (subject or "")[:140], - "audience": audience, - "style": style, - "detail_level": detail, - }, - "limits": {"max_output_tokens": max_tokens}, - }, - ) + def explain(self, *, subject: str, audience: str = "general", style: str = "step-by-step", detail: str = "medium", max_tokens: int = 1000) -> CommandResponse: + return self.call("explain", {"input": {"subject": (subject or "")[:140], "audience": audience, "style": style, "detail_level": detail}, "limits": {"max_output_tokens": max_tokens}}) def format(self, *, content: str, to: str, max_tokens: int = 1000) -> CommandResponse: return self.call( @@ -256,14 +180,7 @@ def parse( payload["input"]["schema"] = schema or target_schema return self.call("parse", payload) - def fetch( - self, - *, - source: str, - query: str | None = None, - include_metadata: bool | None = None, - max_tokens: int = 1000, - ) -> CommandResponse: + def fetch(self, *, source: str, query: str | None = None, include_metadata: bool | None = None, max_tokens: int = 1000) -> CommandResponse: input_obj: dict[str, Any] = {"source": source} if query is not None: input_obj["query"] = query @@ -299,32 +216,24 @@ def call(self, verb: str, body: dict[str, Any]) -> CommandResponse: if verb not in VERBS: raise CommandLayerError(f"Unsupported verb: {verb}", 400) self._ensure_verify_config_if_enabled() - payload = self._build_payload(verb, body) + payload = build_commons_request(verb, body, actor=self.actor) response = self._request(verb, payload) - try: data: Any = response.json() except json.JSONDecodeError: data = {} - 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 ((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) - normalized = normalize_command_response(data) if self.verify_receipts: verify_result = verify_receipt( normalized["receipt"], - 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 verify_result["ok"]: diff --git a/python-sdk/commandlayer/types.py b/python-sdk/commandlayer/types.py index 7bad916..0c31330 100644 --- a/python-sdk/commandlayer/types.py +++ b/python-sdk/commandlayer/types.py @@ -3,7 +3,32 @@ from dataclasses import dataclass from typing import Any, Literal, TypedDict -CanonicalReceipt = dict[str, Any] + +class ReceiptProtocolMetadata(TypedDict, total=False): + verb: str + version: str + + +class ReceiptProof(TypedDict, total=False): + alg: Literal["ed25519-sha256"] + canonical: Literal["cl-stable-json-v1"] + signer_id: str + hash_sha256: str + signature_b64: str + + +class ReceiptMetadata(TypedDict, total=False): + receipt_id: str + proof: ReceiptProof + actor: dict[str, Any] + + +class CanonicalReceipt(TypedDict, total=False): + status: str + x402: ReceiptProtocolMetadata + result: Any + error: Any + metadata: ReceiptMetadata class RuntimeMetadata(TypedDict, total=False): diff --git a/python-sdk/commandlayer/verify.py b/python-sdk/commandlayer/verify.py index 7219b01..bd480ab 100644 --- a/python-sdk/commandlayer/verify.py +++ b/python-sdk/commandlayer/verify.py @@ -11,14 +11,10 @@ from nacl.signing import VerifyKey from web3 import Web3 -from .types import ( - CanonicalReceipt, - CommandResponse, - EnsVerifyOptions, - SignerKeyResolution, - VerifyResult, -) +from .types import CanonicalReceipt, CommandResponse, EnsVerifyOptions, SignerKeyResolution, VerifyResult +CANONICAL_ALG = "ed25519-sha256" +CANONICAL_FORMAT = "cl-stable-json-v1" _ED25519_PREFIX_RE = re.compile(r"^ed25519\s*[:=]\s*(.+)$", re.IGNORECASE) _ED25519_HEX_RE = re.compile(r"^(0x)?[0-9a-fA-F]{64}$") @@ -44,24 +40,41 @@ def get_text(self, name: str, key: str) -> str | None: return text or None +def _is_mapping(value: Any) -> bool: + return isinstance(value, dict) + + +def extract_receipt(subject: CanonicalReceipt | CommandResponse) -> CanonicalReceipt: + if isinstance(subject, dict) and isinstance(subject.get("receipt"), dict): + return dict(subject["receipt"]) + return dict(subject) + + +def extract_receipt_verb(subject: CanonicalReceipt | CommandResponse) -> str | None: + receipt = extract_receipt(subject) + x402 = receipt.get("x402") + if isinstance(x402, dict) and isinstance(x402.get("verb"), str): + return str(x402["verb"]) + return None + + def canonicalize_stable_json_v1(value: Any) -> str: def encode(v: Any) -> str: if v is None: return "null" - value_type = type(v) - if value_type is str: + if type(v) is str: return json.dumps(v, ensure_ascii=False) - if value_type is bool: + if type(v) is bool: return "true" if v else "false" - if value_type in (int, float): + if type(v) 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 str(v) - if value_type in (complex, bytes, bytearray): - raise ValueError(f"canonicalize: unsupported type {value_type.__name__}") + if type(v) in (complex, bytes, bytearray): + raise ValueError(f"canonicalize: unsupported type {type(v).__name__}") if isinstance(v, list): return "[" + ",".join(encode(item) for item in v) + "]" if isinstance(v, dict): @@ -72,7 +85,7 @@ def encode(v: Any) -> str: 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 {value_type.__name__}") + raise ValueError(f"canonicalize: unsupported type {type(v).__name__}") return encode(value) @@ -101,11 +114,7 @@ def parse_ed25519_pubkey(text: str) -> 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") try: @@ -122,12 +131,7 @@ def verify_ed25519_signature_over_utf8_hash_string( return False -def resolve_signer_key( - name: str, - rpc_url: str, - *, - resolver: EnsTextResolver | None = None, -) -> SignerKeyResolution: +def resolve_signer_key(name: str, rpc_url: str, *, resolver: EnsTextResolver | None = None) -> SignerKeyResolution: if not rpc_url: raise ValueError("rpcUrl is required for ENS verification") txt_resolver = resolver or Web3EnsTextResolver(rpc_url) @@ -143,39 +147,25 @@ def resolve_signer_key( 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 _extract_receipt(receipt: CanonicalReceipt | CommandResponse) -> CanonicalReceipt: - if isinstance(receipt, dict) and isinstance(receipt.get("receipt"), dict): - return dict(receipt["receipt"]) - return dict(receipt) + 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: CanonicalReceipt | CommandResponse) -> CanonicalReceipt: - target = _extract_receipt(receipt) - if not isinstance(target, dict): + unsigned = copy.deepcopy(extract_receipt(receipt)) + if not _is_mapping(unsigned): raise ValueError("receipt must be an object") - unsigned = copy.deepcopy(target) + unsigned.pop("receipt_id", None) metadata = unsigned.get("metadata") if isinstance(metadata, dict): metadata.pop("receipt_id", None) proof = metadata.get("proof") if isinstance(proof, dict): - unsigned_proof: dict[str, str] = {} - for key in ("alg", "canonical", "signer_id"): - value = proof.get(key) - if isinstance(value, str): - unsigned_proof[key] = value - metadata["proof"] = unsigned_proof - unsigned.pop("receipt_id", None) + metadata["proof"] = { + key: proof[key] + for key in ("alg", "canonical", "signer_id") + if isinstance(proof.get(key), str) + } return unsigned @@ -189,27 +179,21 @@ def _extract_rpc_url(ens: EnsVerifyOptions) -> str: return str(ens.get("rpcUrl") or ens.get("rpc_url") or "") -def verify_receipt( - receipt: CanonicalReceipt | CommandResponse, - public_key: str | None = None, - ens: EnsVerifyOptions | None = None, -) -> VerifyResult: - target = _extract_receipt(receipt) +def verify_receipt(receipt: CanonicalReceipt | CommandResponse, public_key: str | None = None, ens: EnsVerifyOptions | None = None) -> VerifyResult: + target = extract_receipt(receipt) try: - proof = ( - ((target.get("metadata") or {}).get("proof") or {}) if isinstance(target, 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 - ) + metadata = target.get("metadata") if isinstance(target, dict) else None + proof = metadata.get("proof") if isinstance(metadata, dict) else None + if not isinstance(proof, dict): + proof = {} + + 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 - alg_matches = alg == "ed25519-sha256" - canonical_matches = canonical == "cl-stable-json-v1" + alg_matches = alg == CANONICAL_ALG + canonical_matches = canonical == CANONICAL_FORMAT recomputed_hash = recompute_receipt_hash_sha256(target)["hash_sha256"] hash_matches = bool(claimed_hash and claimed_hash == recomputed_hash) metadata = target.get("metadata") if isinstance(target, dict) else None @@ -241,31 +225,21 @@ def verify_receipt( signature_valid = False signature_error: str | None = None if not alg_matches: - signature_error = f'proof.alg must be "ed25519-sha256" (got {alg})' + signature_error = f'proof.alg must be "{CANONICAL_ALG}" (got {alg})' elif not canonical_matches: - signature_error = f'proof.canonical must be "cl-stable-json-v1" (got {canonical})' + signature_error = f'proof.canonical must be "{CANONICAL_FORMAT}" (got {canonical})' 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, - ) + signature_valid = verify_ed25519_signature_over_utf8_hash_string(claimed_hash, signature_b64, pubkey) except Exception as err: # noqa: BLE001 signature_error = str(err) return { - "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, "checks": { "hash_matches": hash_matches, "signature_valid": signature_valid, @@ -274,9 +248,7 @@ def verify_receipt( "canonical_matches": canonical_matches, }, "values": { - "verb": ((target.get("x402") or {}).get("verb")) - if isinstance(target, dict) - else None, + "verb": extract_receipt_verb(target), "signer_id": signer_id, "alg": alg, "canonical": canonical, @@ -293,9 +265,7 @@ def verify_receipt( }, } except Exception as err: # noqa: BLE001 - proof = ( - ((target.get("metadata") or {}).get("proof") or {}) if isinstance(target, dict) else {} - ) + proof = ((target.get("metadata") or {}).get("proof") or {}) if isinstance(target, dict) else {} metadata = target.get("metadata") if isinstance(target, dict) else None return { "ok": False, @@ -307,29 +277,15 @@ def verify_receipt( "canonical_matches": False, }, "values": { - "verb": ((target.get("x402") or {}).get("verb")) - if isinstance(target, dict) - else None, - "signer_id": proof.get("signer_id") - if isinstance(proof.get("signer_id"), str) - else None, + "verb": extract_receipt_verb(target), + "signer_id": proof.get("signer_id") if isinstance(proof.get("signer_id"), 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, - "claimed_hash": proof.get("hash_sha256") - if isinstance(proof.get("hash_sha256"), str) - else None, + "canonical": proof.get("canonical") if isinstance(proof.get("canonical"), str) else None, + "claimed_hash": proof.get("hash_sha256") if isinstance(proof.get("hash_sha256"), str) else None, "recomputed_hash": None, - "receipt_id": metadata.get("receipt_id") - if isinstance(metadata, dict) and isinstance(metadata.get("receipt_id"), str) - else None, + "receipt_id": metadata.get("receipt_id") if isinstance(metadata, dict) and isinstance(metadata.get("receipt_id"), str) else None, "pubkey_source": None, "ens_txt_key": None, }, - "errors": { - "signature_error": None, - "ens_error": None, - "verify_error": str(err), - }, + "errors": {"signature_error": None, "ens_error": None, "verify_error": str(err)}, } diff --git a/python-sdk/docs/verification.md b/python-sdk/docs/verification.md index d0c2792..72518c3 100644 --- a/python-sdk/docs/verification.md +++ b/python-sdk/docs/verification.md @@ -1,29 +1,19 @@ # Verification -The SDK verifies canonical signed receipts using: +The verification helper validates the current receipt contract directly. -- canonical JSON: `cl-stable-json-v1` -- hash: `sha256` over the unsigned receipt -- signature: `ed25519` over the resulting hash string +## Rules -`runtime_metadata` is not part of the signed payload. +1. Read the signed object from `receipt`. +2. Remove `metadata.receipt_id` and the signed hash/signature fields. +3. Canonicalize with `cl-stable-json-v1`. +4. Recompute `sha256`. +5. Require `metadata.receipt_id == metadata.proof.hash_sha256`. +6. Verify the Ed25519 signature over the UTF-8 hash string. -## ENS key resolution flow +## Helpers -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( - response["receipt"], - ens={"name": "summarizeagent.eth", "rpcUrl": "https://..."}, -) -print(result["ok"]) -``` +- `verify_receipt(receipt, public_key=...)` +- `verify_receipt(receipt, ens={"name": ..., "rpcUrl": ...})` +- `extract_receipt_verb(receipt_or_response)` +- `recompute_receipt_hash_sha256(receipt_or_response)` diff --git a/python-sdk/tests/test_public_api.py b/python-sdk/tests/test_public_api.py index 6e1fa75..c7adbeb 100644 --- a/python-sdk/tests/test_public_api.py +++ b/python-sdk/tests/test_public_api.py @@ -8,8 +8,10 @@ from commandlayer import ( CommandLayerClient, + build_commons_request, canonicalize_stable_json_v1, create_client, + extract_receipt_verb, normalize_command_response, recompute_receipt_hash_sha256, verify_receipt, @@ -19,7 +21,9 @@ VECTORS = ROOT / "test_vectors" EXPECTED_EXPORTS = { "CommandLayerClient": CommandLayerClient, + "build_commons_request": build_commons_request, "create_client": create_client, + "extract_receipt_verb": extract_receipt_verb, "verify_receipt": verify_receipt, "normalize_command_response": normalize_command_response, "canonicalize_stable_json_v1": canonicalize_stable_json_v1, @@ -131,6 +135,18 @@ def test_verify_receipt_is_importable_callable_and_matches_vector_contract() -> assert result["errors"]["verify_error"] is None +def test_build_commons_request_and_extract_receipt_verb_follow_current_contract() -> None: + payload = build_commons_request( + "summarize", + {"input": {"content": "Hello"}, "limits": {"max_output_tokens": 50}}, + actor="shape-check", + ) + + assert payload["x402"] == {"verb": "summarize", "version": "1.1.0"} + assert payload["actor"] == "shape-check" + assert extract_receipt_verb(load_fixture("receipt_valid.json")) == "summarize" + + def test_mocked_end_to_end_flow_uses_vector_shaped_response() -> None: receipt = load_fixture("receipt_valid.json") diff --git a/typescript-sdk/README.md b/typescript-sdk/README.md index 357c15b..115f5be 100644 --- a/typescript-sdk/README.md +++ b/typescript-sdk/README.md @@ -1,13 +1,13 @@ # CommandLayer TypeScript SDK -Official TypeScript/JavaScript SDK for CommandLayer Commons v1.1.0. +Current-line TypeScript SDK for the CommandLayer Commons receipt contract (`1.1.0`). -Use this package to: -- call CommandLayer Commons verbs, -- receive the canonical signed `receipt`, -- capture optional unsigned `runtime_metadata` separately, -- verify receipts offline or through ENS, and -- reproduce calls from the CLI. +## What is canonical + +- `response.receipt` is the signed receipt. +- `response.runtime_metadata` is optional unsigned execution context. +- `receipt.metadata.receipt_id` is the receipt hash identifier and must match `receipt.metadata.proof.hash_sha256`. +- The verb lives at `receipt.x402.verb`. ## Install @@ -15,17 +15,14 @@ Use this package to: npm install @commandlayer/sdk ``` -Supported runtime: Node.js 20+. - -## Quick start +## Happy path ```ts import { createClient, verifyReceipt } from "@commandlayer/sdk"; const client = createClient({ actor: "docs-example" }); - const response = await client.summarize({ - content: "CommandLayer makes agent execution verifiable.", + content: "CommandLayer makes receipt verification explicit.", style: "bullet_points" }); @@ -33,7 +30,7 @@ console.log(response.receipt.result?.summary); console.log(response.runtime_metadata?.duration_ms); const verification = await verifyReceipt(response.receipt, { - publicKey: process.env.COMMANDLAYER_PUBLIC_KEY! + publicKey: "ed25519:BASE64_PUBLIC_KEY" }); console.log(verification.ok); @@ -81,31 +78,31 @@ const result = await verifyReceipt(response.receipt, { ### ENS-backed ```ts -const result = await verifyReceipt(response.receipt, { - ens: { - name: "summarizeagent.eth", - rpcUrl: process.env.MAINNET_RPC_URL! - } +import { buildCommonsRequest, buildCommercialRequest } from "@commandlayer/sdk"; + +const commons = buildCommonsRequest("summarize", { + input: { content: "hello", summary_style: "bullet_points" }, + limits: { max_output_tokens: 400 } +}, { actor: "docs-example" }); + +const commercial = buildCommercialRequest("summarize", { + input: { content: "hello" } +}, { + actor: "docs-example", + payment: { scheme: "x402", quote_id: "quote_123" } }); ``` -The ENS flow resolves: -1. `cl.receipt.signer` on the agent ENS name, -2. `cl.sig.pub` on the signer ENS name, -3. `cl.sig.kid` on the signer ENS name. - -## CLI - -The package ships the `commandlayer` CLI. +The commercial builder is isolated on purpose; this package's first-class runtime client remains Commons-first. -```bash -commandlayer summarize --content "hello" --style bullet_points --json -commandlayer verify --file receipt.json --public-key "ed25519:BASE64_PUBLIC_KEY" -``` +## Verification helpers -## Development +- `verifyReceipt(receipt, { publicKey })` +- `verifyReceipt(receipt, { ens: { name, rpcUrl } })` +- `extractReceiptVerb(receiptOrResponse)` +- `recomputeReceiptHashSha256(receiptOrResponse)` -`npm test` is package-local and reproducible from this repo alone. The optional protocol integration lane remains available as `npm run test:integration`. +## Legacy support ```bash cd typescript-sdk diff --git a/typescript-sdk/scripts/unit-tests.mjs b/typescript-sdk/scripts/unit-tests.mjs index 646808d..1f2c509 100644 --- a/typescript-sdk/scripts/unit-tests.mjs +++ b/typescript-sdk/scripts/unit-tests.mjs @@ -86,6 +86,8 @@ const { recomputeReceiptHashSha256, verifyReceipt, resolveSignerKey, + buildCommonsRequest, + extractReceiptVerb, CommandLayerError, CommandLayerClient, } = require("../dist/index.cjs"); @@ -227,6 +229,15 @@ const vrEns = await verifyReceipt(receipt, { }); 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"); +assert(extractReceiptVerb(receipt) === "summarize", "extractReceiptVerb reads canonical verb field"); + +const builtRequest = buildCommonsRequest( + "summarize", + { input: { content: "hello" }, limits: { max_output_tokens: 10 } }, + { actor: "sdk-test" } +); +assert(builtRequest.x402.verb === "summarize", "buildCommonsRequest sets canonical verb metadata"); +assert(builtRequest.actor === "sdk-test", "buildCommonsRequest sets actor"); // Tampered receipt const tamperedReceipt = JSON.parse(JSON.stringify(receipt)); diff --git a/typescript-sdk/src/index.ts b/typescript-sdk/src/index.ts index 5313fd6..ec80d4e 100644 --- a/typescript-sdk/src/index.ts +++ b/typescript-sdk/src/index.ts @@ -8,10 +8,29 @@ export const packageVersion = "1.1.0"; /** @deprecated Use commonsVersion. */ export const version = commonsVersion; -export type Proof = { - alg?: string; - canonical?: string; - signer_id?: string; +const CANONICAL_ALG = "ed25519-sha256" as const; +const CANONICAL_FORMAT = "cl-stable-json-v1" as const; +const DEFAULT_RUNTIME = "https://runtime.commandlayer.org"; +const VERBS = [ + "summarize", + "analyze", + "classify", + "clean", + "convert", + "describe", + "explain", + "format", + "parse", + "fetch" +] as const; + +export type Verb = (typeof VERBS)[number]; +export type ReceiptStatus = "success" | "error" | string; + +export type ReceiptProof = { + alg: typeof CANONICAL_ALG; + canonical: typeof CANONICAL_FORMAT; + signer_id: string; hash_sha256?: string; signature_b64?: string; [k: string]: unknown; @@ -19,7 +38,7 @@ export type Proof = { export type ReceiptMetadata = { receipt_id?: string; - proof?: Proof; + proof: ReceiptProof; actor?: { id: string; role?: string; [k: string]: unknown }; [k: string]: unknown; }; @@ -57,11 +76,15 @@ export type RuntimeMetadata = { [k: string]: unknown; }; -export type CommandResponse = { - receipt: CanonicalReceipt; +export type CommandResponse = { + receipt: CanonicalReceipt; runtime_metadata?: RuntimeMetadata; }; +export type LegacyBlendedReceipt = CanonicalReceipt & { + trace?: RuntimeMetadata; +}; + export type VerifyChecks = { hash_matches: boolean; signature_valid: boolean; @@ -93,6 +116,30 @@ export type VerifyResult = { }; }; +export type EnsVerifyOptions = { name: string; rpcUrl: string }; +export type SignerKeyResolution = { algorithm: "ed25519"; kid: string; rawPublicKeyBytes: Uint8Array }; +export type VerifyOptions = { publicKey?: string; ens?: EnsVerifyOptions }; +export type ClientOptions = { + runtime?: string; + actor?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; + verifyReceipts?: boolean; + verify?: VerifyOptions; +}; + +export type CommonsRequestEnvelope = Record> = { + x402: ReceiptProtocolMetadata; + actor: string; +} & TBody; + +export type CommercialRequestEnvelope = Record> = { + mode: "commercial"; + receipt: ReceiptProtocolMetadata; + actor: string; + payment: Record; +} & TBody; + export class CommandLayerError extends Error { statusCode?: number; details?: unknown; @@ -105,53 +152,40 @@ export class CommandLayerError extends Error { } } -export type EnsVerifyOptions = { - name: string; - rpcUrl: string; -}; - -export type SignerKeyResolution = { - algorithm: "ed25519"; - kid: string; - rawPublicKeyBytes: Uint8Array; -}; - -export type VerifyOptions = { - publicKey?: string; - ens?: EnsVerifyOptions; -}; - -export type ClientOptions = { - runtime?: string; - actor?: string; - timeoutMs?: number; - fetchImpl?: typeof fetch; - verifyReceipts?: boolean; - verify?: VerifyOptions; -}; +function normalizeBase(url: string) { + return String(url || "").replace(/\/+$/, ""); +} -const DEFAULT_RUNTIME = "https://runtime.commandlayer.org"; -const VERBS = [ - "summarize", - "analyze", - "classify", - "clean", - "convert", - "describe", - "explain", - "format", - "parse", - "fetch" -] as const; +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} -type Verb = (typeof VERBS)[number]; +function isVerb(value: string): value is Verb { + return (VERBS as readonly string[]).includes(value); +} -type LegacyBlendedReceipt = CanonicalReceipt & { - trace?: RuntimeMetadata; -}; +export function buildCommonsRequest>( + verb: Verb, + body: TBody, + options: { actor: string; version?: string } = { actor: "sdk-user" } +): CommonsRequestEnvelope { + const actor = String(options.actor || body.actor || "sdk-user"); + const merged = { ...body, actor } as TBody & { actor: string }; + return { x402: { verb, version: options.version ?? commonsVersion }, ...merged }; +} -function normalizeBase(url: string) { - return String(url || "").replace(/\/+$/, ""); +export function buildCommercialRequest>( + verb: string, + body: TBody, + options: { actor: string; version?: string; payment: Record } +): CommercialRequestEnvelope { + return { + mode: "commercial", + receipt: { verb, version: options.version ?? commonsVersion }, + payment: options.payment, + actor: String(options.actor || body.actor || "sdk-user"), + ...body + }; } export function canonicalizeStableJsonV1(value: unknown): string { @@ -179,14 +213,11 @@ export function canonicalizeStableJsonV1(value: unknown): string { for (let i = 0; i < keys.length; i += 1) { const key = keys[i]!; const val = obj[key]; - if (typeof val === "undefined") { - throw new Error(`canonicalize: undefined for key "${key}" not allowed`); - } + if (typeof val === "undefined") throw new Error(`canonicalize: undefined for key "${key}" not allowed`); if (i) out += ","; out += JSON.stringify(key) + ":" + encode(val); } - out += "}"; - return out; + return out + "}"; } throw new Error("canonicalize: unsupported value"); } @@ -203,9 +234,7 @@ function b64ToBytes(b64: string): Uint8Array { function hexToBytes(hex: string): Uint8Array { const clean = hex.startsWith("0x") ? hex.slice(2) : hex; - if (!/^[0-9a-fA-F]{64}$/.test(clean)) { - throw new Error("invalid hex (expected 64 hex chars for ed25519 pubkey)"); - } + if (!/^[0-9a-fA-F]{64}$/.test(clean)) throw new Error("invalid hex (expected 64 hex chars for ed25519 pubkey)"); const buf = Buffer.from(clean, "hex"); return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); } @@ -223,35 +252,25 @@ export function parseEd25519Pubkey(text: string): Uint8Array { return pk; } -export function verifyEd25519SignatureOverUtf8HashString( - hashHex: string, - signatureB64: string, - pubkey32: Uint8Array -): boolean { +export function verifyEd25519SignatureOverUtf8HashString(hashHex: string, signatureB64: string, pubkey32: Uint8Array): boolean { if (pubkey32.length !== 32) throw new Error("ed25519: pubkey must be 32 bytes"); - const msg = Buffer.from(hashHex, "utf8"); const sig = b64ToBytes(signatureB64); if (sig.length !== 64) throw new Error("ed25519: signature must be 64 bytes"); - return nacl.sign.detached.verify(new Uint8Array(msg), sig, pubkey32); + return nacl.sign.detached.verify(new Uint8Array(Buffer.from(hashHex, "utf8")), sig, pubkey32); } 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 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}`); - try { return { algorithm: "ed25519", kid, rawPublicKeyBytes: parseEd25519Pubkey(pubKeyText) }; } catch (error) { @@ -274,45 +293,40 @@ function extractReceipt(subject: CanonicalReceipt | CommandResponse | LegacyBlen return subject as CanonicalReceipt; } -export function normalizeCommandResponse(payload: unknown): CommandResponse { - if (!payload || typeof payload !== "object") { - throw new CommandLayerError("Runtime response must be a JSON object", 502, payload); - } +export function extractReceiptVerb(subject: CanonicalReceipt | CommandResponse | LegacyBlendedReceipt): string | null { + const receipt = extractReceipt(subject); + return isRecord(receipt.x402) && typeof receipt.x402.verb === "string" ? receipt.x402.verb : null; +} - const candidate = payload as Record; - if (candidate.receipt && typeof candidate.receipt === "object") { - const normalized: CommandResponse = { receipt: candidate.receipt as CanonicalReceipt }; - if (candidate.runtime_metadata && typeof candidate.runtime_metadata === "object") { - normalized.runtime_metadata = candidate.runtime_metadata as RuntimeMetadata; - } - return normalized; +export function normalizeCommandResponse(payload: unknown): CommandResponse { + if (!isRecord(payload)) throw new CommandLayerError("Runtime response must be a JSON object", 502, payload); + if (isRecord(payload.receipt)) { + const response: CommandResponse = { receipt: payload.receipt as CanonicalReceipt }; + if (isRecord(payload.runtime_metadata)) response.runtime_metadata = payload.runtime_metadata as RuntimeMetadata; + return response; } - - const legacy = payload as LegacyBlendedReceipt; - const runtime_metadata = legacy.trace && typeof legacy.trace === "object" ? legacy.trace : undefined; - const receipt = structuredClone(legacy) as LegacyBlendedReceipt; - delete receipt.trace; - const normalizedReceipt = receipt as CanonicalReceipt; - return runtime_metadata ? { receipt: normalizedReceipt, runtime_metadata } : { receipt: normalizedReceipt }; + const legacy = structuredClone(payload) as LegacyBlendedReceipt; + const runtime_metadata = isRecord(legacy.trace) ? legacy.trace : undefined; + delete legacy.trace; + return runtime_metadata ? { receipt: legacy, runtime_metadata } : { receipt: legacy }; } export function toUnsignedReceipt(receiptLike: CanonicalReceipt | CommandResponse): CanonicalReceipt { const receipt = extractReceipt(receiptLike); - if (!receipt || typeof receipt !== "object") throw new Error("receipt must be an object"); - + if (!isRecord(receipt)) throw new Error("receipt must be an object"); const unsigned = structuredClone(receipt) as CanonicalReceipt; - if (unsigned.metadata && typeof unsigned.metadata === "object") { + delete (unsigned as Record).receipt_id; + if (isRecord(unsigned.metadata)) { delete unsigned.metadata.receipt_id; - if (unsigned.metadata.proof && typeof unsigned.metadata.proof === "object") { + if (isRecord(unsigned.metadata.proof)) { const proof = unsigned.metadata.proof; - const unsignedProof: Proof = {}; - if (typeof proof.alg === "string") unsignedProof.alg = proof.alg; - if (typeof proof.canonical === "string") unsignedProof.canonical = proof.canonical; - if (typeof proof.signer_id === "string") unsignedProof.signer_id = proof.signer_id; - unsigned.metadata.proof = unsignedProof; + unsigned.metadata.proof = { + alg: proof.alg as typeof CANONICAL_ALG, + canonical: proof.canonical as typeof CANONICAL_FORMAT, + signer_id: String(proof.signer_id || "") + }; } } - delete (unsigned as { receipt_id?: string }).receipt_id; return unsigned; } @@ -321,22 +335,19 @@ export function recomputeReceiptHashSha256(receiptLike: CanonicalReceipt | Comma return { canonical, hash_sha256: sha256HexUtf8(canonical) }; } -export async function verifyReceipt( - receiptLike: CanonicalReceipt | CommandResponse, - opts: VerifyOptions = {} -): Promise { +export async function verifyReceipt(receiptLike: CanonicalReceipt | CommandResponse, opts: VerifyOptions = {}): Promise { try { const receipt = extractReceipt(receiptLike); - const proof: Proof = (receipt.metadata?.proof as Proof) || {}; + const proof = isRecord(receipt.metadata?.proof) ? (receipt.metadata.proof as Record) : {}; const claimedHash = typeof proof.hash_sha256 === "string" ? proof.hash_sha256 : null; const signatureB64 = typeof proof.signature_b64 === "string" ? proof.signature_b64 : null; const alg = typeof proof.alg === "string" ? proof.alg : null; const canonical = typeof proof.canonical === "string" ? proof.canonical : null; - const signer_id = typeof proof.signer_id === "string" ? proof.signer_id : null; - const algMatches = alg === "ed25519-sha256"; - const canonicalMatches = canonical === "cl-stable-json-v1"; + const signerId = typeof proof.signer_id === "string" ? proof.signer_id : null; + const algMatches = alg === CANONICAL_ALG; + const canonicalMatches = canonical === CANONICAL_FORMAT; const { hash_sha256: recomputedHash } = recomputeReceiptHashSha256(receipt); - const hashMatches = claimedHash ? recomputedHash === claimedHash : false; + const hashMatches = claimedHash === recomputedHash; const receiptId = typeof receipt.metadata?.receipt_id === "string" ? receipt.metadata.receipt_id : null; const receiptIdMatches = !receiptId || !claimedHash ? true : receiptId === claimedHash; @@ -361,8 +372,8 @@ export async function verifyReceipt( let signature_valid = false; let signature_error: string | null = null; - if (!algMatches) signature_error = `proof.alg must be "ed25519-sha256" (got ${String(alg)})`; - else if (!canonicalMatches) signature_error = `proof.canonical must be "cl-stable-json-v1" (got ${String(canonical)})`; + if (!algMatches) signature_error = `proof.alg must be "${CANONICAL_ALG}" (got ${String(alg)})`; + else if (!canonicalMatches) signature_error = `proof.canonical must be "${CANONICAL_FORMAT}" (got ${String(canonical)})`; else if (!claimedHash || !signatureB64) signature_error = "missing proof.hash_sha256 or proof.signature_b64"; else if (!pubkey) signature_error = ens_error || "no public key available (provide publicKey or ens)"; else { @@ -394,11 +405,7 @@ export async function verifyReceipt( pubkey_source, ens_txt_key }, - errors: { - signature_error, - ens_error, - verify_error: null - } + errors: { signature_error, ens_error, verify_error: null } }; } catch (error) { const receipt = extractReceipt(receiptLike as CanonicalReceipt | CommandResponse); @@ -419,15 +426,11 @@ export async function verifyReceipt( canonical: typeof receipt?.metadata?.proof?.canonical === "string" ? receipt.metadata.proof.canonical : null, claimed_hash: typeof receipt?.metadata?.proof?.hash_sha256 === "string" ? receipt.metadata.proof.hash_sha256 : null, recomputed_hash: null, - receipt_id: typeof receipt?.metadata?.receipt_id === "string" ? receipt.metadata.receipt_id : null, + receipt_id: typeof receipt.metadata?.receipt_id === "string" ? receipt.metadata.receipt_id : null, pubkey_source: null, ens_txt_key: null }, - errors: { - signature_error: null, - ens_error: null, - verify_error: error instanceof Error ? error.message : String(error) - } + errors: { signature_error: null, ens_error: null, verify_error: error instanceof Error ? error.message : String(error) } }; } } @@ -454,75 +457,33 @@ export class CommandLayerClient { const hasExplicit = !!this.verifyDefaults?.publicKey?.trim(); const hasEns = !!(this.verifyDefaults?.ens?.name && this.verifyDefaults?.ens?.rpcUrl); if (!hasExplicit && !hasEns) { - throw new CommandLayerError( - "verifyReceipts is enabled but no verification key config provided. Set verify.publicKey or verify.ens { name, rpcUrl }.", - 400 - ); + throw new CommandLayerError("verifyReceipts is enabled but no verification key config provided. Set verify.publicKey or verify.ens { name, rpcUrl }.", 400); } } async summarize(opts: { content: string; style?: string; format?: string; maxTokens?: number }) { - return this.call("summarize", { - input: { content: opts.content, summary_style: opts.style, format_hint: opts.format }, - limits: { max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("summarize", { input: { content: opts.content, summary_style: opts.style, format_hint: opts.format }, limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } - async analyze(opts: { content: string; goal?: string; hints?: string[]; maxTokens?: number }) { - return this.call("analyze", { - input: opts.content, - ...(opts.goal ? { goal: opts.goal } : {}), - ...(opts.hints ? { hints: opts.hints } : {}), - limits: { max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("analyze", { input: opts.content, ...(opts.goal ? { goal: opts.goal } : {}), ...(opts.hints ? { hints: opts.hints } : {}), limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } - async classify(opts: { content: string; maxLabels?: number; maxTokens?: number }) { - return this.call("classify", { - actor: this.actor, - input: { content: opts.content }, - limits: { max_labels: opts.maxLabels ?? 5, max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("classify", { input: { content: opts.content }, limits: { max_labels: opts.maxLabels ?? 5, max_output_tokens: opts.maxTokens ?? 1000 } }); } - async clean(opts: { content: string; operations?: string[]; maxTokens?: number }) { - return this.call("clean", { - input: { content: opts.content, operations: opts.operations ?? ["normalize_newlines", "collapse_whitespace", "trim"] }, - limits: { max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("clean", { input: { content: opts.content, operations: opts.operations ?? ["normalize_newlines", "collapse_whitespace", "trim"] }, limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } - async convert(opts: { content: string; from: string; to: string; maxTokens?: number }) { - return this.call("convert", { - input: { content: opts.content, source_format: opts.from, target_format: opts.to }, - limits: { max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("convert", { input: { content: opts.content, source_format: opts.from, target_format: opts.to }, limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } - async describe(opts: { subject: string; audience?: string; detail?: "short" | "medium" | "detailed"; maxTokens?: number }) { - return this.call("describe", { - input: { subject: (opts.subject || "").slice(0, 140), audience: opts.audience ?? "general", detail_level: opts.detail ?? "medium" }, - limits: { max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("describe", { input: { subject: (opts.subject || "").slice(0, 140), audience: opts.audience ?? "general", detail_level: opts.detail ?? "medium" }, limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } - async explain(opts: { subject: string; audience?: string; style?: string; detail?: "short" | "medium" | "detailed"; maxTokens?: number }) { - return this.call("explain", { - input: { - subject: (opts.subject || "").slice(0, 140), - audience: opts.audience ?? "general", - style: opts.style ?? "step-by-step", - detail_level: opts.detail ?? "medium" - }, - limits: { max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("explain", { input: { subject: (opts.subject || "").slice(0, 140), audience: opts.audience ?? "general", style: opts.style ?? "step-by-step", detail_level: opts.detail ?? "medium" }, limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } - async format(opts: { content: string; to: string; maxTokens?: number }) { - return this.call("format", { - input: { content: opts.content, target_style: opts.to }, - limits: { max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("format", { input: { content: opts.content, target_style: opts.to }, limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } async parse(opts: { @@ -544,20 +505,12 @@ export class CommandLayerClient { limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } - async fetch(opts: { source: string; query?: string; include_metadata?: boolean; maxTokens?: number }) { - return this.call("fetch", { - input: { - source: opts.source, - ...(opts.query !== undefined ? { query: opts.query } : {}), - ...(opts.include_metadata !== undefined ? { include_metadata: opts.include_metadata } : {}) - }, - limits: { max_output_tokens: opts.maxTokens ?? 1000 } - }); + return this.call("fetch", { input: { source: opts.source, ...(opts.query !== undefined ? { query: opts.query } : {}), ...(opts.include_metadata !== undefined ? { include_metadata: opts.include_metadata } : {}) }, limits: { max_output_tokens: opts.maxTokens ?? 1000 } }); } async call(verb: Verb, body: Record): Promise { - if (!VERBS.includes(verb)) throw new CommandLayerError(`Unsupported verb: ${verb}`, 400); + if (!isVerb(verb)) throw new CommandLayerError(`Unsupported verb: ${verb}`, 400); this.ensureVerifyConfigIfEnabled(); const payload = { @@ -567,7 +520,6 @@ export class CommandLayerClient { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); - try { const response = await this.fetchImpl(`${this.runtime}/${verb}/v${commonsVersion}`, { method: "POST", @@ -575,7 +527,6 @@ export class CommandLayerClient { body: JSON.stringify(payload), signal: controller.signal }); - let data: unknown; try { data = await response.json(); @@ -583,12 +534,10 @@ export class CommandLayerClient { if (!response.ok) throw new CommandLayerError(`HTTP ${response.status} (non-JSON response)`, response.status); throw new CommandLayerError("Runtime returned non-JSON response", response.status); } - if (!response.ok) { const detail = data as Record; throw new CommandLayerError(detail?.message || detail?.error?.message || `HTTP ${response.status}`, response.status, data); } - const normalized = normalizeCommandResponse(data); if (this.verifyReceipts) { const verified = await verifyReceipt(normalized.receipt, this.verifyDefaults || {});