From ccb45b1cf7b5a77ef434e119d1de18872dcc9202 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 19:47:29 +0000 Subject: [PATCH] fix: production readiness improvements across TS and Python SDKs - Fix 5 mypy type errors in Python SDK (union-attr, typeddict-item, arg-type, no-any-return) - Fix silent JSON parse failure in TS client that swallowed server errors as empty objects - Add runtime verb validation in TS client.call() to match Python SDK behavior - Add NaN-safe numeric parsing for CLI --timeout-ms and --max-tokens options - Add context manager (__enter__/__exit__) to Python client to prevent resource leaks - Add Python CI workflow (ruff + mypy + pytest across Python 3.10-3.12) - Add TS unit tests for canonicalization, hashing, Ed25519, and receipt verification (30 tests) - Update TS CI workflow to run unit tests alongside smoke tests https://claude.ai/code/session_018eVmxc7pcom9N88xbcZvaZ --- .github/workflows/python-sdk-tests.yml | 41 ++++ .../workflows/typescript-sdk-cli-smoke.yml | 3 + python-sdk/commandlayer/client.py | 8 +- python-sdk/commandlayer/verify.py | 19 +- typescript-sdk/package.json | 4 +- typescript-sdk/scripts/unit-tests.mjs | 176 ++++++++++++++++++ typescript-sdk/src/cli.ts | 15 +- typescript-sdk/src/index.ts | 14 +- 8 files changed, 265 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/python-sdk-tests.yml create mode 100644 typescript-sdk/scripts/unit-tests.mjs diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml new file mode 100644 index 0000000..d91f253 --- /dev/null +++ b/.github/workflows/python-sdk-tests.yml @@ -0,0 +1,41 @@ +name: Python SDK Tests + +on: + push: + paths: + - "python-sdk/**" + - ".github/workflows/python-sdk-tests.yml" + pull_request: + paths: + - "python-sdk/**" + - ".github/workflows/python-sdk-tests.yml" + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + defaults: + run: + working-directory: python-sdk + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e '.[dev]' + + - name: Lint (ruff) + run: python -m ruff check commandlayer/ + + - name: Type check (mypy) + run: python -m mypy commandlayer/ + + - name: Tests (pytest) + run: python -m pytest tests/ -v diff --git a/.github/workflows/typescript-sdk-cli-smoke.yml b/.github/workflows/typescript-sdk-cli-smoke.yml index 4948d98..d3e7fd9 100644 --- a/.github/workflows/typescript-sdk-cli-smoke.yml +++ b/.github/workflows/typescript-sdk-cli-smoke.yml @@ -36,5 +36,8 @@ jobs: - name: Build run: npm run build + - name: Unit tests + run: npm run test:unit + - name: CLI smoke tests run: npm run test:cli-smoke diff --git a/python-sdk/commandlayer/client.py b/python-sdk/commandlayer/client.py index 5f4bfa0..2d1f027 100644 --- a/python-sdk/commandlayer/client.py +++ b/python-sdk/commandlayer/client.py @@ -238,11 +238,17 @@ def call(self, verb: str, body: dict[str, Any]) -> Receipt: if not result["ok"]: raise CommandLayerError("Receipt verification failed", 422, result) - return data + return data # type: ignore[no-any-return] def close(self): self._http.close() + def __enter__(self): + return self + + def __exit__(self, *args: object): + self.close() + def create_client(**kwargs) -> CommandLayerClient: return CommandLayerClient(**kwargs) diff --git a/python-sdk/commandlayer/verify.py b/python-sdk/commandlayer/verify.py index d9ec9ce..f62b03b 100644 --- a/python-sdk/commandlayer/verify.py +++ b/python-sdk/commandlayer/verify.py @@ -3,7 +3,7 @@ import hashlib import json import re -from typing import Any +from typing import Any, Literal from nacl.signing import VerifyKey from nacl.exceptions import BadSignatureError @@ -83,7 +83,10 @@ def resolve_ens_ed25519_pubkey(name: str, rpc_url: str, pubkey_text_key: str = " return {"pubkey": None, "source": None, "error": "Unable to connect to RPC", "txt_key": pubkey_text_key} try: - txt = w3.ens.get_text(name, pubkey_text_key) # type: ignore[attr-defined] + 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} @@ -142,22 +145,24 @@ def verify_receipt(receipt: Receipt, public_key: str | None = None, ens: dict[st 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_matches = bool(claimed_hash and receipt_id == claimed_hash) - pubkey = None - pubkey_source = None - ens_error = None - ens_txt_key = None + pubkey: bytes | None = None + pubkey_source: Literal["explicit", "ens"] | None = None + ens_error: str | None = None + ens_txt_key: str | None = None if public_key: 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=ens.get("rpcUrl") or ens.get("rpc_url"), + 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") diff --git a/typescript-sdk/package.json b/typescript-sdk/package.json index 8e581c5..308ad6c 100644 --- a/typescript-sdk/package.json +++ b/typescript-sdk/package.json @@ -28,7 +28,9 @@ "dev": "tsup --watch", "typecheck": "tsc -p tsconfig.json --noEmit", "prepack": "npm run build", - "test:cli-smoke": "node scripts/cli-smoke.mjs" + "test:cli-smoke": "node scripts/cli-smoke.mjs", + "test:unit": "node scripts/unit-tests.mjs", + "test": "npm run test:unit && npm run test:cli-smoke" }, "dependencies": { "commander": "^12.1.0", diff --git a/typescript-sdk/scripts/unit-tests.mjs b/typescript-sdk/scripts/unit-tests.mjs new file mode 100644 index 0000000..425729a --- /dev/null +++ b/typescript-sdk/scripts/unit-tests.mjs @@ -0,0 +1,176 @@ +/** + * Unit tests for core SDK logic (canonicalization, hashing, verification). + * Uses tweetnacl for Ed25519 key generation — no external test framework needed. + */ +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 nacl = require("tweetnacl"); + +let passed = 0; +let failed = 0; + +function assert(condition, name) { + if (!condition) { + failed++; + console.error(`FAIL: ${name}`); + } else { + passed++; + console.log(`PASS: ${name}`); + } +} + +function assertThrows(fn, name) { + try { + fn(); + failed++; + console.error(`FAIL: ${name} (did not throw)`); + } catch { + passed++; + console.log(`PASS: ${name}`); + } +} + +// ---- Canonicalization ---- + +assert(canonicalizeStableJsonV1(null) === "null", "canonicalize null"); +assert(canonicalizeStableJsonV1(true) === "true", "canonicalize true"); +assert(canonicalizeStableJsonV1(false) === "false", "canonicalize false"); +assert(canonicalizeStableJsonV1(42) === "42", "canonicalize int"); +assert(canonicalizeStableJsonV1(3.14) === "3.14", "canonicalize float"); +assert(canonicalizeStableJsonV1("hello") === '"hello"', "canonicalize string"); +assert(canonicalizeStableJsonV1([1, 2, 3]) === "[1,2,3]", "canonicalize array"); +assert( + canonicalizeStableJsonV1({ b: 2, a: 1 }) === '{"a":1,"b":2}', + "canonicalize sorts keys" +); +assert( + canonicalizeStableJsonV1({ z: { b: 1, a: 2 } }) === '{"z":{"a":2,"b":1}}', + "canonicalize nested sorted" +); +assertThrows( + () => canonicalizeStableJsonV1(BigInt(1)), + "canonicalize rejects bigint" +); +assertThrows( + () => canonicalizeStableJsonV1(Infinity), + "canonicalize rejects Infinity" +); +assertThrows( + () => canonicalizeStableJsonV1({ a: undefined }), + "canonicalize rejects undefined value" +); + +// Negative zero +assert(canonicalizeStableJsonV1(-0) === "0", "canonicalize -0 => 0"); + +// ---- SHA-256 ---- + +const knownHash = sha256HexUtf8("hello"); +assert(knownHash.length === 64, "sha256 returns 64 hex chars"); +assert(sha256HexUtf8("hello") === knownHash, "sha256 deterministic"); +assert(sha256HexUtf8("hello") !== sha256HexUtf8("world"), "sha256 differs for different inputs"); + +// ---- 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"); + +const pk2 = parseEd25519Pubkey(`ed25519:${b64Key}`); +assert(pk2.length === 32, "parse ed25519: prefixed pubkey"); + +const pk3 = parseEd25519Pubkey(hexKey); +assert(pk3.length === 32, "parse hex pubkey"); + +const pk4 = parseEd25519Pubkey(`0x${hexKey}`); +assert(pk4.length === 32, "parse 0x-prefixed hex pubkey"); + +assertThrows( + () => parseEd25519Pubkey("not_valid_key!!"), + "rejects invalid pubkey" +); + +// ---- Signature verification ---- + +const hashHex = sha256HexUtf8('{"test":true}'); +const msg = Buffer.from(hashHex, "utf8"); +const sig = nacl.sign.detached(new Uint8Array(msg), kp.secretKey); +const sigB64 = Buffer.from(sig).toString("base64"); + +assert( + verifyEd25519SignatureOverUtf8HashString(hashHex, sigB64, kp.publicKey) === true, + "valid signature verifies" +); + +const badKp = nacl.sign.keyPair(); +assert( + verifyEd25519SignatureOverUtf8HashString(hashHex, sigB64, badKp.publicKey) === false, + "wrong key rejects" +); + +// ---- Receipt verification (end-to-end) ---- + +const receipt = { + status: "success", + x402: { verb: "summarize", version: "1.0.0", entry: "x402://summarizeagent.eth/summarize/v1.0.0" }, + result: { summary: "test" }, + metadata: { + proof: { + alg: "ed25519-sha256", + canonical: "cl-stable-json-v1", + signer_id: "runtime.commandlayer.eth", + }, + }, +}; + +const { hash_sha256 } = recomputeReceiptHashSha256(receipt); +const receiptMsg = Buffer.from(hash_sha256, "utf8"); +const receiptSig = nacl.sign.detached(new Uint8Array(receiptMsg), kp.secretKey); + +receipt.metadata.proof.hash_sha256 = hash_sha256; +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.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"); + +// Tampered receipt +const tamperedReceipt = JSON.parse(JSON.stringify(receipt)); +tamperedReceipt.result.summary = "tampered"; +const vr2 = await verifyReceipt(tamperedReceipt, { publicKey: `ed25519:${b64Key}` }); +assert(vr2.ok === false, "verifyReceipt rejects tampered receipt"); +assert(vr2.checks.hash_matches === false, "tampered receipt hash mismatch"); + +// ---- Client verb validation ---- + +const client = new CommandLayerClient(); +try { + await client.call("nonexistent", {}); + failed++; + console.error("FAIL: client.call accepts unknown verb"); +} catch (err) { + assert(err instanceof CommandLayerError, "client.call rejects unknown verb with CommandLayerError"); +} + +// ---- Summary ---- + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/typescript-sdk/src/cli.ts b/typescript-sdk/src/cli.ts index 2d87221..fdda2b4 100644 --- a/typescript-sdk/src/cli.ts +++ b/typescript-sdk/src/cli.ts @@ -2,6 +2,11 @@ import { Command } from "commander"; import { createClient, type Receipt } from "./index"; +function parseIntSafe(value: string, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + const program = new Command(); program @@ -41,14 +46,14 @@ withCommonOptions( const client = createClient({ runtime: root.runtime, actor: root.actor, - timeoutMs: Number(root.timeoutMs) + timeoutMs: parseIntSafe(root.timeoutMs, 30_000) }); const receipt = await client.summarize({ content: opts.content, style: opts.style, format: opts.format, - maxTokens: Number(opts.maxTokens) + maxTokens: parseIntSafe(opts.maxTokens, 1000) }); printResult(receipt, !!root.json); @@ -60,13 +65,13 @@ withCommonOptions(program.command("analyze").description("Analyze content").opti const client = createClient({ runtime: root.runtime, actor: root.actor, - timeoutMs: Number(root.timeoutMs) + timeoutMs: parseIntSafe(root.timeoutMs, 30_000) }); const receipt = await client.analyze({ content: opts.content, goal: opts.goal, - maxTokens: Number(opts.maxTokens) + maxTokens: parseIntSafe(opts.maxTokens, 1000) }); printResult(receipt, !!root.json); @@ -80,7 +85,7 @@ program.command("call").description("Call a verb with a raw JSON payload").requi const client = createClient({ runtime: root.runtime, actor: root.actor, - timeoutMs: Number(root.timeoutMs) + timeoutMs: parseIntSafe(root.timeoutMs, 30_000) }); const body = JSON.parse(opts.body) as Record; diff --git a/typescript-sdk/src/index.ts b/typescript-sdk/src/index.ts index 5b0eaa5..b67f913 100644 --- a/typescript-sdk/src/index.ts +++ b/typescript-sdk/src/index.ts @@ -614,6 +614,10 @@ export class CommandLayerClient { } async call(verb: Verb, body: Record): Promise { + if (!VERBS.includes(verb as any)) { + throw new CommandLayerError(`Unsupported verb: ${verb}`, 400); + } + const url = `${this.runtime}/${verb}/v1.0.0`; this.ensureVerifyConfigIfEnabled(); @@ -642,7 +646,15 @@ export class CommandLayerClient { signal: controller.signal }); - const data: any = await resp.json().catch(() => ({})); + let data: any; + try { + data = await resp.json(); + } catch { + if (!resp.ok) { + throw new CommandLayerError(`HTTP ${resp.status} (non-JSON response)`, resp.status); + } + throw new CommandLayerError("Runtime returned non-JSON response", resp.status); + } if (!resp.ok) { throw new CommandLayerError(