diff --git a/.github/workflows/parity-check.yml b/.github/workflows/parity-check.yml new file mode 100644 index 0000000..d29462a --- /dev/null +++ b/.github/workflows/parity-check.yml @@ -0,0 +1,51 @@ +name: parity-check + +on: + push: + paths: + - "typescript-sdk/**" + - "python-sdk/**" + - "test_vectors/**" + - "scripts/**" + - ".github/workflows/parity-check.yml" + pull_request: + paths: + - "typescript-sdk/**" + - "python-sdk/**" + - "test_vectors/**" + - "scripts/**" + - ".github/workflows/parity-check.yml" + +jobs: + parity: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: typescript-sdk/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install TypeScript dependencies + working-directory: typescript-sdk + run: npm ci + + - name: Build TypeScript SDK + working-directory: typescript-sdk + run: npm run build + + - name: Install Python dependencies + working-directory: python-sdk + run: pip install -e '.[dev]' + + - name: Run cross-SDK parity validation + run: node scripts/parity-check.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7e32cc8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +See `DEVELOPER_EXPERIENCE.md` for the current contributor workflow, local validation commands, and repo conventions. diff --git a/MAINTAINER_GUIDE.md b/MAINTAINER_GUIDE.md new file mode 100644 index 0000000..b3ed48b --- /dev/null +++ b/MAINTAINER_GUIDE.md @@ -0,0 +1,3 @@ +# Maintainer Guide + +See `DEVELOPER_EXPERIENCE.md` for maintainer-facing architecture notes and `DEPLOYMENT_GUIDE.md` for release execution details. diff --git a/README.md b/README.md index abf6700..ea1451c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ Official SDK repo for CommandLayer Protocol-Commons v1.1.0. +## Start Here + +- Quickstart → `QUICKSTART.md` +- Full usage → `EXAMPLES.md` +- Contributing → `CONTRIBUTING.md` +- Maintainers → `MAINTAINER_GUIDE.md` +- Releases → `RELEASE_GUIDE.md` +- Test vectors → `test_vectors/README.md` +- Changelog → `CHANGELOG.md` + This repository ships the public developer surfaces for CommandLayer: - the TypeScript SDK: `@commandlayer/sdk`, - the Python SDK: `commandlayer`, diff --git a/RELEASE_GUIDE.md b/RELEASE_GUIDE.md new file mode 100644 index 0000000..2834343 --- /dev/null +++ b/RELEASE_GUIDE.md @@ -0,0 +1,3 @@ +# Release Guide + +See `DEPLOYMENT_GUIDE.md` for the current build, release, and publish workflow. diff --git a/python-sdk/tests/parity_report.py b/python-sdk/tests/parity_report.py new file mode 100644 index 0000000..f3e9963 --- /dev/null +++ b/python-sdk/tests/parity_report.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any + +from commandlayer.verify import parse_ed25519_pubkey, recompute_receipt_hash_sha256, resolve_signer_key, verify_receipt + +ROOT = Path(__file__).resolve().parents[2] +VECTORS = ROOT / "test_vectors" +MANIFEST = json.loads((VECTORS / "parity_manifest.json").read_text(encoding="utf-8")) +PUBLIC_KEY = f"ed25519:{(VECTORS / 'public_key_base64.txt').read_text(encoding='utf-8').strip()}" + +ENS_FIXTURES: dict[str, dict[str, str]] = { + "parseagent.eth": {"cl.receipt.signer": "runtime.commandlayer.eth"}, + "runtime.commandlayer.eth": {"cl.sig.pub": PUBLIC_KEY, "cl.sig.kid": "v1"}, + "invalidagent.eth": {}, + "malformed.eth": {"cl.receipt.signer": "malformed-signer.eth"}, + "malformed-signer.eth": {"cl.sig.pub": "ed25519:not-base64", "cl.sig.kid": "v1"}, +} + + +class FakeResolver: + def get_text(self, name: str, key: str) -> str | None: + return ENS_FIXTURES.get(name, {}).get(key) + + +resolver = FakeResolver() + + +def load_fixture(name: str) -> dict[str, Any]: + return json.loads((VECTORS / name).read_text(encoding="utf-8")) + + +vector_results: list[dict[str, Any]] = [] +for vector in MANIFEST["verification_vectors"]: + receipt = load_fixture(vector["name"]) + verification = verify_receipt(receipt, public_key=PUBLIC_KEY) + recomputed = recompute_receipt_hash_sha256(receipt) + vector_results.append( + { + "name": vector["name"], + "expected_ok": vector["expected_ok"], + "ok": verification["ok"], + "checks": verification["checks"], + "values": verification["values"], + "errors": verification["errors"], + "recomputed_hash": recomputed["hash_sha256"], + } + ) + +ens_results: list[dict[str, Any]] = [] +for case in MANIFEST["ens_resolution_cases"]: + try: + resolution = resolve_signer_key(case["name"], "https://rpc.example", resolver=resolver) + signer_name = resolver.get_text(case["name"], "cl.receipt.signer") + ens_results.append( + { + "name": case["name"], + "ok": True, + "algorithm": resolution.algorithm, + "kid": resolution.kid, + "signer_name": signer_name, + "public_key_b64": base64.b64encode(resolution.raw_public_key_bytes).decode("utf-8"), + "error": None, + } + ) + except Exception as exc: # noqa: BLE001 + ens_results.append( + { + "name": case["name"], + "ok": False, + "algorithm": None, + "kid": None, + "signer_name": resolver.get_text(case["name"], "cl.receipt.signer"), + "public_key_b64": None, + "error": str(exc), + } + ) + +print( + json.dumps( + { + "sdk": "python", + "public_key_length": len(parse_ed25519_pubkey(PUBLIC_KEY)), + "vector_results": vector_results, + "ens_results": ens_results, + }, + sort_keys=True, + indent=2, + ) +) diff --git a/python-sdk/tests/test_public_api.py b/python-sdk/tests/test_public_api.py index e1dc7b5..fc692de 100644 --- a/python-sdk/tests/test_public_api.py +++ b/python-sdk/tests/test_public_api.py @@ -1,10 +1,159 @@ from __future__ import annotations -from commandlayer import CommandLayerClient, create_client +import json +from pathlib import Path +import httpx + +from commandlayer import ( + CommandLayerClient, + canonicalize_stable_json_v1, + create_client, + normalize_command_response, + recompute_receipt_hash_sha256, + verify_receipt, +) + +ROOT = Path(__file__).resolve().parents[2] +VECTORS = ROOT / "test_vectors" +EXPECTED_EXPORTS = { + "CommandLayerClient": CommandLayerClient, + "create_client": create_client, + "verify_receipt": verify_receipt, + "normalize_command_response": normalize_command_response, + "canonicalize_stable_json_v1": canonicalize_stable_json_v1, + "recompute_receipt_hash_sha256": recompute_receipt_hash_sha256, +} +EXPECTED_VERBS = [ + "summarize", + "analyze", + "classify", + "clean", + "convert", + "describe", + "explain", + "format", + "parse", + "fetch", +] + + +def load_fixture(name: str) -> dict: + return json.loads((VECTORS / name).read_text(encoding="utf-8")) + + +def load_pubkey() -> str: + return f"ed25519:{(VECTORS / 'public_key_base64.txt').read_text(encoding='utf-8').strip()}" + + +def test_expected_symbols_are_importable() -> None: + for export_name, export_value in EXPECTED_EXPORTS.items(): + assert export_value is not None, export_name + + + +def test_create_client_accepts_basic_configuration() -> None: + client = create_client( + actor="api-user", + runtime="https://runtime.example", + timeout_ms=12_345, + headers={"X-Test": "1"}, + verify_receipts=False, + ) -def test_create_client_factory() -> None: - client = create_client(actor="api-user") assert isinstance(client, CommandLayerClient) assert client.actor == "api-user" + assert client.runtime == "https://runtime.example" + assert client.timeout_ms == 12_345 + assert client.default_headers["X-Test"] == "1" client.close() + + + +def test_public_client_verbs_exist_and_are_callable() -> None: + client = create_client(actor="verb-check") + try: + for verb in EXPECTED_VERBS: + method = getattr(client, verb) + assert callable(method), verb + finally: + client.close() + + + +def test_mocked_client_response_matches_public_envelope_shape() -> None: + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "receipt": { + "status": "success", + "x402": {"verb": "summarize", "version": "1.1.0"}, + "result": {"summary": "done"}, + "metadata": { + "proof": { + "alg": "ed25519-sha256", + "canonical": "cl-stable-json-v1", + } + }, + }, + "runtime_metadata": {"duration_ms": 7, "provider": "mock-runtime"}, + }, + ) + + client = create_client( + actor="shape-check", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + try: + response = client.summarize(content="Hello", style="bullet_points") + finally: + client.close() + + assert set(response.keys()) == {"receipt", "runtime_metadata"} + assert response["receipt"]["x402"]["verb"] == "summarize" + assert response["receipt"]["result"]["summary"] == "done" + assert response["runtime_metadata"]["duration_ms"] == 7 + + + +def test_verify_receipt_is_importable_callable_and_matches_vector_contract() -> None: + receipt = load_fixture("receipt_valid.json") + + result = verify_receipt(receipt, public_key=load_pubkey()) + + assert callable(verify_receipt) + assert result["ok"] is True + assert result["values"]["recomputed_hash"] == recompute_receipt_hash_sha256(receipt)["hash_sha256"] + assert result["values"]["signer_id"] == "runtime.commandlayer.eth" + assert result["errors"]["verify_error"] is None + + + +def test_mocked_end_to_end_flow_uses_vector_shaped_response() -> None: + receipt = load_fixture("receipt_valid.json") + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "receipt": receipt, + "runtime_metadata": {"duration_ms": 11, "provider": "mock-runtime"}, + }, + ) + + client = create_client( + actor="vector-flow", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + try: + response = client.analyze(content="vector-backed", goal="parity") + finally: + client.close() + + assert response["receipt"] == receipt + assert response["runtime_metadata"]["provider"] == "mock-runtime" + verification = verify_receipt(response["receipt"], public_key=load_pubkey()) + assert verification["ok"] is True diff --git a/scripts/parity-check.mjs b/scripts/parity-check.mjs new file mode 100644 index 0000000..5cab037 --- /dev/null +++ b/scripts/parity-check.mjs @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "test_vectors", "parity_manifest.json"), "utf8")); + +function runJson(command, args, options = {}) { + const stdout = execFileSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + ...options + }); + return JSON.parse(stdout); +} + +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, normalize(value[key])])); + } + return value; +} + +function comparableVector(vector) { + return normalize({ + name: vector.name, + expected_ok: vector.expected_ok, + ok: vector.ok, + checks: vector.checks, + errors: vector.errors, + values: vector.values, + recomputed_hash: vector.recomputed_hash + }); +} + +function comparableEns(result) { + return normalize({ + name: result.name, + ok: result.ok, + algorithm: result.algorithm, + kid: result.kid, + signer_name: result.signer_name, + public_key_b64: result.public_key_b64 + }); +} + +const tsReport = runJson("node", [path.join("scripts", "parity-ts-report.mjs")]); +const pyReport = runJson("python", [path.join("python-sdk", "tests", "parity_report.py")], { + env: { ...process.env, PYTHONPATH: path.join(repoRoot, "python-sdk") } +}); + +let failed = false; +console.log("Parity check against shared test_vectors:\n"); +for (const vector of manifest.verification_vectors) { + const tsVector = tsReport.vector_results.find((entry) => entry.name === vector.name); + const pyVector = pyReport.vector_results.find((entry) => entry.name === vector.name); + const tsComparable = comparableVector(tsVector); + const pyComparable = comparableVector(pyVector); + const matchesExpectation = tsVector.ok === vector.expected_ok && pyVector.ok === vector.expected_ok; + const matchesEachOther = JSON.stringify(tsComparable) === JSON.stringify(pyComparable); + const status = matchesExpectation && matchesEachOther ? "PASS" : "FAIL"; + console.log(`- ${status} ${vector.name}`); + console.log(` expected_ok=${vector.expected_ok} ts_ok=${tsVector.ok} py_ok=${pyVector.ok}`); + console.log(` hash=${tsVector.recomputed_hash}`); + console.log(` signer_id=${tsVector.values.signer_id} pubkey_source=${tsVector.values.pubkey_source}`); + if (!matchesExpectation || !matchesEachOther) { + failed = true; + console.log(" ts=", JSON.stringify(tsComparable, null, 2)); + console.log(" py=", JSON.stringify(pyComparable, null, 2)); + } +} + +console.log("\nENS signer resolution parity:\n"); +for (const caseDef of manifest.ens_resolution_cases) { + const tsEns = tsReport.ens_results.find((entry) => entry.name === caseDef.name); + const pyEns = pyReport.ens_results.find((entry) => entry.name === caseDef.name); + const tsComparable = comparableEns(tsEns); + const pyComparable = comparableEns(pyEns); + const matchesEachOther = JSON.stringify(tsComparable) === JSON.stringify(pyComparable); + const matchesExpectation = caseDef.expected + ? tsEns.ok && pyEns.ok && tsEns.algorithm === caseDef.expected.algorithm && tsEns.kid === caseDef.expected.kid && tsEns.signer_name === caseDef.expected.signer_name + : !tsEns.ok && !pyEns.ok && tsEns.error?.includes(caseDef.error_contains) && pyEns.error?.includes(caseDef.error_contains); + const status = matchesEachOther && matchesExpectation ? "PASS" : "FAIL"; + console.log(`- ${status} ${caseDef.name}`); + console.log(` ts_ok=${tsEns.ok} py_ok=${pyEns.ok} signer=${tsEns.signer_name}`); + console.log(` kid=${tsEns.kid} algorithm=${tsEns.algorithm}`); + if (!matchesEachOther || !matchesExpectation) { + failed = true; + console.log(" ts=", JSON.stringify(tsComparable, null, 2)); + console.log(" py=", JSON.stringify(pyComparable, null, 2)); + } +} + +if (tsReport.public_key_length !== 32 || pyReport.public_key_length !== 32) { + failed = true; + console.error(`\nFAIL public key parsing mismatch: ts=${tsReport.public_key_length}, py=${pyReport.public_key_length}`); +} + +if (failed) { + console.error("\nParity check failed."); + process.exit(1); +} + +console.log("\nParity check passed: TypeScript and Python agree on shared verification semantics."); diff --git a/scripts/parity-ts-report.mjs b/scripts/parity-ts-report.mjs new file mode 100644 index 0000000..39c0e70 --- /dev/null +++ b/scripts/parity-ts-report.mjs @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const require = createRequire(path.join(repoRoot, "typescript-sdk", "package.json")); +const { ethers } = require("ethers"); +const sdk = require(path.join(repoRoot, "typescript-sdk", "dist", "index.cjs")); +const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "test_vectors", "parity_manifest.json"), "utf8")); +const publicKey = `ed25519:${fs.readFileSync(path.join(repoRoot, "test_vectors", "public_key_base64.txt"), "utf8").trim()}`; + +const ensFixtures = { + "parseagent.eth": { "cl.receipt.signer": "runtime.commandlayer.eth" }, + "runtime.commandlayer.eth": { "cl.sig.pub": publicKey, "cl.sig.kid": "v1" }, + "invalidagent.eth": {}, + "malformed.eth": { "cl.receipt.signer": "malformed-signer.eth" }, + "malformed-signer.eth": { "cl.sig.pub": "ed25519:not-base64", "cl.sig.kid": "v1" } +}; + +class MockResolver { + constructor(name) { + this.name = name; + } + async getText(key) { + return ensFixtures[this.name]?.[key] ?? ""; + } +} + +ethers.JsonRpcProvider.prototype.getResolver = async function (name) { + if (!(name in ensFixtures)) return null; + return new MockResolver(name); +}; + +function loadFixture(name) { + return JSON.parse(fs.readFileSync(path.join(repoRoot, "test_vectors", name), "utf8")); +} + +const vectorResults = []; +for (const vector of manifest.verification_vectors) { + const receipt = loadFixture(vector.name); + const verification = await sdk.verifyReceipt(receipt, { publicKey }); + const recomputed = sdk.recomputeReceiptHashSha256(receipt); + vectorResults.push({ + name: vector.name, + expected_ok: vector.expected_ok, + ok: verification.ok, + checks: verification.checks, + values: verification.values, + errors: verification.errors, + recomputed_hash: recomputed.hash_sha256 + }); +} + +const ensResults = []; +for (const caseDef of manifest.ens_resolution_cases) { + try { + const resolution = await sdk.resolveSignerKey(caseDef.name, "https://rpc.example"); + ensResults.push({ + name: caseDef.name, + ok: true, + algorithm: resolution.algorithm, + kid: resolution.kid, + signer_name: ensFixtures[caseDef.name]?.["cl.receipt.signer"] ?? null, + public_key_b64: Buffer.from(resolution.rawPublicKeyBytes).toString("base64"), + error: null + }); + } catch (error) { + ensResults.push({ + name: caseDef.name, + ok: false, + algorithm: null, + kid: null, + signer_name: ensFixtures[caseDef.name]?.["cl.receipt.signer"] ?? null, + public_key_b64: null, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +console.log(JSON.stringify({ sdk: "typescript", public_key_length: sdk.parseEd25519Pubkey(publicKey).length, vector_results: vectorResults, ens_results: ensResults }, null, 2)); diff --git a/test_vectors/README.md b/test_vectors/README.md new file mode 100644 index 0000000..987c6ba --- /dev/null +++ b/test_vectors/README.md @@ -0,0 +1,23 @@ +# Test vectors + +This directory contains shared receipt fixtures used by both SDKs and the parity check. + +## Files + +- `receipt_valid.json` — canonical valid receipt fixture. +- `receipt_valid_v1.json` — additional valid receipt fixture used for version compatibility checks. +- `receipt_invalid_sig.json` — receipt fixture with an invalid signature. +- `receipt_wrong_kid.json` — receipt fixture whose `kid` drifts but still verifies with an explicit public key. +- `receipt_malformed_pubkey.json` — receipt fixture paired with malformed ENS signer metadata scenarios. +- `public_key_base64.txt` — shared Ed25519 public key for explicit-key verification. +- `expected_hash.txt` — expected SHA-256 hash for canonical receipt hashing. +- `parity_manifest.json` — parity contract describing which fixtures must agree across SDKs. + +## Parity validation + +Run `node scripts/parity-check.mjs` from the repo root to verify that the TypeScript and Python SDKs: + +- evaluate the same fixtures, +- recompute the same hashes, +- resolve signer identity consistently, and +- return the same verification pass/fail outcomes. diff --git a/test_vectors/parity_manifest.json b/test_vectors/parity_manifest.json new file mode 100644 index 0000000..61b3ddb --- /dev/null +++ b/test_vectors/parity_manifest.json @@ -0,0 +1,47 @@ +{ + "verification_vectors": [ + { + "name": "receipt_valid.json", + "expected_ok": true, + "description": "Canonical v1.1.0 receipt verifies with explicit public key" + }, + { + "name": "receipt_valid_v1.json", + "expected_ok": true, + "description": "Versioned valid receipt preserves hash and signature semantics" + }, + { + "name": "receipt_invalid_sig.json", + "expected_ok": false, + "description": "Tampered signature fails verification" + }, + { + "name": "receipt_wrong_kid.json", + "expected_ok": false, + "description": "Hash drift caused by kid mutation fails verification even with an explicit key" + }, + { + "name": "receipt_malformed_pubkey.json", + "expected_ok": true, + "description": "Receipt content stays valid even when filename references malformed ENS data" + } + ], + "ens_resolution_cases": [ + { + "name": "parseagent.eth", + "expected": { + "algorithm": "ed25519", + "kid": "v1", + "signer_name": "runtime.commandlayer.eth" + } + }, + { + "name": "invalidagent.eth", + "error_contains": "cl.receipt.signer missing" + }, + { + "name": "malformed.eth", + "error_contains": "cl.sig.pub malformed" + } + ] +} diff --git a/typescript-sdk/package-lock.json b/typescript-sdk/package-lock.json index 599aa81..7d65f5c 100644 --- a/typescript-sdk/package-lock.json +++ b/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commandlayer/sdk", - "version": "0.1.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commandlayer/sdk", - "version": "0.1.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "commander": "^12.1.0", @@ -21,7 +21,7 @@ "typescript": "^5.6.3" }, "engines": { - "node": ">=22" + "node": ">=20" } }, "node_modules/@adraffy/ens-normalize": {