Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/python-sdk-tests.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .github/workflows/typescript-sdk-cli-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion python-sdk/commandlayer/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
19 changes: 12 additions & 7 deletions python-sdk/commandlayer/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion typescript-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build artifacts before running npm test

The new test script invokes test:unit immediately, but scripts/unit-tests.mjs loads ../dist/index.cjs; on a clean checkout where dist/ has not been built yet, npm test now fails with MODULE_NOT_FOUND. This makes the default test entrypoint unusable for local runs and CI jobs that call npm test without a prior explicit build step.

Useful? React with 👍 / 👎.

},
"dependencies": {
"commander": "^12.1.0",
Expand Down
176 changes: 176 additions & 0 deletions typescript-sdk/scripts/unit-tests.mjs
Original file line number Diff line number Diff line change
@@ -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);
15 changes: 10 additions & 5 deletions typescript-sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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<string, unknown>;
Expand Down
14 changes: 13 additions & 1 deletion typescript-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,10 @@ export class CommandLayerClient {
}

async call(verb: Verb, body: Record<string, any>): Promise<Receipt> {
if (!VERBS.includes(verb as any)) {
throw new CommandLayerError(`Unsupported verb: ${verb}`, 400);
}

const url = `${this.runtime}/${verb}/v1.0.0`;

this.ensureVerifyConfigIfEnabled();
Expand Down Expand Up @@ -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(
Expand Down