diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7339869 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Release + +on: + push: + tags: + - 'sdk-v*' + - 'v*' + release: + types: [published] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: typescript-sdk/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install root npm metadata + run: npm install + + - name: Install TypeScript dependencies + working-directory: typescript-sdk + run: npm ci + + - name: TypeScript typecheck + working-directory: typescript-sdk + run: npm run typecheck + + - name: TypeScript build + working-directory: typescript-sdk + run: npm run build + + - name: TypeScript tests + working-directory: typescript-sdk + run: npm run test + + - name: Install Python dependencies + working-directory: python-sdk + run: pip install -e '.[dev]' + + - name: Python lint + working-directory: python-sdk + run: python -m ruff check . + + - name: Python type check + working-directory: python-sdk + run: python -m mypy commandlayer + + - name: Python tests + working-directory: python-sdk + run: python -m pytest tests/ -v + + - name: Build npm package + working-directory: typescript-sdk + run: npm pack --dry-run + + - name: Build Python package + working-directory: python-sdk + run: python -m build + + - name: Validate Python package + working-directory: python-sdk + run: python -m twine check dist/* + + - name: Publish npm + if: github.event_name == 'push' || github.event_name == 'release' + working-directory: typescript-sdk + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish PyPI + if: github.event_name == 'push' || github.event_name == 'release' + working-directory: python-sdk + run: python -m twine upload dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fc9b5a2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +## Repo structure + +- `typescript-sdk/` — npm package, CLI, and runtime-facing verification code. +- `python-sdk/` — PyPI package and Python verification code. +- `runtime/tests/` — cross-SDK tests that execute against `typescript-sdk/dist`. +- `test_vectors/` — shared fixtures for receipts, ENS cases, malformed inputs, and rotation cases. +- root docs — public usage docs plus maintainer/release policy docs. + +## Install dependencies + +```bash +npm install +cd python-sdk && pip install -e '.[dev]' +``` + +## Run TypeScript tests + +```bash +npm run build +npm run test +``` + +## Run Python tests + +```bash +cd python-sdk +pytest +``` + +## Run runtime tests without guessing about build order + +```bash +npm run test:full +``` + +## `test_vectors/` + +`test_vectors/` contains shared canonical receipts, ENS resolution cases, invalid signature cases, key rotation cases, and envelope-vs-receipt coverage used by both SDKs and the runtime tests. + +## Pull requests + +- keep changes scoped to the task, +- update shared docs and fixtures when behavior changes, +- run the relevant test commands before opening the PR, +- describe user-visible behavior changes and release impact clearly. + +## Release rules + +Release process and publish requirements live in `RELEASE_GUIDE.md`. diff --git a/EXAMPLES.md b/EXAMPLES.md index 8351f8a..4d12e40 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -124,7 +124,8 @@ const response = await client.explain({ ```ts const response = await client.format({ - content: "a: 1\nb: 2", + content: "a: 1 +b: 2", to: "table" }); ``` @@ -162,7 +163,8 @@ cleaned = client.clean(content=" test@example.com ", operations=["trim", "red converted = client.convert(content='{"a":1}', from_format="json", to_format="csv") description = client.describe(subject="receipt verification") explanation = client.explain(subject="receipt verification", style="step-by-step") -formatted = client.format(content="a: 1\nb: 2", to="table") +formatted = client.format(content="a: 1 +b: 2", to="table") parsed = client.parse(content='{ "a": 1 }', content_type="json", mode="strict") fetched = client.fetch(source="https://example.com", include_metadata=True) ``` @@ -207,30 +209,36 @@ result = verify_receipt( ## 5. CLI examples +The CLI has two usage layers: +- verb-specific commands such as `summarize` and `analyze` for the fast, common paths, +- `call` for generic or less common verbs when you want to provide the raw JSON body yourself. + ### Summarize ```bash -commandlayer summarize \ - --content "CommandLayer defines semantic verbs." \ - --style bullet_points \ - --json +commandlayer summarize --content "CommandLayer defines semantic verbs." --style bullet_points --json ``` ### Analyze ```bash -commandlayer analyze \ - --content "Invoice total: $500" \ - --goal "detect finance intent" \ - --json +commandlayer analyze --content "Invoice total: $500" --goal "detect finance intent" --json +``` + +### Generic call + +```bash +commandlayer call --verb classify --body '{"content":"Contact support@example.com"}' --json ``` ### Verify a saved receipt +`commandlayer verify` accepts either: +- a canonical receipt JSON object, or +- a full response envelope JSON object with a top-level `receipt` field. + ```bash -commandlayer verify \ - --file receipt.json \ - --public-key "ed25519:BASE64_PUBLIC_KEY" +commandlayer verify --file receipt.json --public-key "ed25519:BASE64_PUBLIC_KEY" ``` ## 6. Runtime override diff --git a/DEVELOPER_EXPERIENCE.md b/MAINTAINER_GUIDE.md similarity index 64% rename from DEVELOPER_EXPERIENCE.md rename to MAINTAINER_GUIDE.md index 5fd6e29..f7e15f6 100644 --- a/DEVELOPER_EXPERIENCE.md +++ b/MAINTAINER_GUIDE.md @@ -1,6 +1,6 @@ -# Developer Experience Guide +# Maintainer Guide -This document is for maintainers and advanced integrators. Start with `README.md` or `QUICKSTART.md` if you are adopting the SDK. +This document is for maintainers changing SDK internals, fixtures, CI, or release mechanics. It is not for application developers using the SDK in their own projects; use `README.md`, `QUICKSTART.md`, or the package READMEs instead. ## Product rules this repo now enforces @@ -15,7 +15,7 @@ This document is for maintainers and advanced integrators. Start with `README.md - `python-sdk/`: PyPI package and Python verification helpers. - `test_vectors/`: shared receipt fixtures used across SDKs. - `runtime/tests/`: cross-SDK protocol checks run against the built TypeScript package. -- root docs: public landing page, quickstart, examples, and release guide. +- root docs: public landing page, quickstart, examples, contributor guide, versioning policy, and release guide. ## Shared protocol model @@ -40,8 +40,8 @@ Clients normalize runtime responses into: ```json { - "receipt": { ...canonical signed receipt... }, - "runtime_metadata": { ...optional unsigned context... } + "receipt": { "...": "canonical signed receipt" }, + "runtime_metadata": { "...": "optional unsigned context" } } ``` @@ -66,17 +66,31 @@ Both SDKs use the same verification contract: 3. recompute `sha256`, 4. compare against `metadata.proof.hash_sha256`, 5. verify the Ed25519 signature over the UTF-8 hash string, -6. optionally discover the signing key via ENS. +6. resolve signer keys via ENS when an explicit key is not provided, +7. when a receipt carries `kid`, prefer the matching `cl.sig.pub.` record so older receipts still verify after key rotation. ## CLI rules -The npm package owns the primary `commandlayer` CLI. +The npm package owns the only supported `commandlayer` CLI. The Python package does not ship a CLI. The CLI should remain: - installable with `npm install -g @commandlayer/sdk`, - aligned with SDK examples, - useful for CI smoke tests, -- capable of verifying saved receipts. +- capable of verifying saved receipts or full response envelopes. + +## Test execution and ordering + +`runtime/tests` import `typescript-sdk/dist/index.cjs`, so a fresh clone must build the TypeScript SDK before running runtime tests. + +Use the root scripts to avoid hidden ordering requirements: + +```bash +npm install +npm run test:full +``` + +`npm run test:full` runs the TypeScript install/build/tests, then the runtime tests against the built output. ## Maintenance checklist @@ -85,4 +99,4 @@ When protocol versions change: 2. update root docs and per-package READMEs, 3. regenerate or update shared fixtures, 4. run both SDK test suites plus `runtime/tests`, -5. confirm release instructions in `DEPLOYMENT_GUIDE.md` still match reality. +5. confirm release automation and `RELEASE_GUIDE.md` still match reality. diff --git a/QUICKSTART.md b/QUICKSTART.md index e80b027..05fb51c 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -18,12 +18,14 @@ pip install commandlayer ### CLI -The CLI ships with the npm package: +The CLI ships with the TypeScript/npm package only: ```bash npm install -g @commandlayer/sdk ``` +The Python SDK does not ship a CLI. Python users should use the TypeScript CLI or call the Python API directly. + ## 2. Make your first call ### TypeScript @@ -117,24 +119,22 @@ 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` +- signer ENS TXT: `cl.sig.pub.` for older receipts after key rotation ## 5. Try the CLI ```bash -commandlayer summarize \ - --content "CommandLayer makes agent execution verifiable." \ - --style bullet_points \ - --json +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" +commandlayer verify --file receipt.json --public-key "ed25519:BASE64_PUBLIC_KEY" ``` +`commandlayer verify` accepts either a canonical receipt JSON object or a full response envelope with a top-level `receipt` field. New integrations should pass the canonical receipt explicitly. + ## 6. What is stable today? Stable in this repo: @@ -152,5 +152,6 @@ Not claimed as first-class SDK support here: - More recipes: `EXAMPLES.md` - Package docs: `typescript-sdk/README.md`, `python-sdk/README.md` -- Maintainer notes: `DEVELOPER_EXPERIENCE.md` -- Release flow: `DEPLOYMENT_GUIDE.md` +- Contributor workflow: `CONTRIBUTING.md` +- Maintainer notes: `MAINTAINER_GUIDE.md` +- Release flow: `RELEASE_GUIDE.md` diff --git a/README.md b/README.md index 34dfd12..e650704 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,8 @@ const result = await verifyReceipt(response.receipt, { 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. +3. `cl.sig.kid` on the signer ENS name, +4. `cl.sig.pub.` on the signer ENS name when verifying an older receipt after key rotation. ## CLI @@ -158,13 +159,21 @@ commandlayer summarize --content "Test text" --style bullet_points --json commandlayer verify --file receipt.json --public-key "ed25519:BASE64_PUBLIC_KEY" ``` +The TypeScript SDK includes the `commandlayer` CLI. The Python SDK does not include a CLI. + +Python users should either: +- use the TypeScript CLI for smoke tests, demos, and CI workflows, or +- use the Python API directly inside Python applications and scripts. + The CLI is intended for demos, CI smoke tests, debugging, and reproducing SDK flows without writing app code. ## Repo guide - Fast onboarding: `QUICKSTART.md` - Cookbook examples: `EXAMPLES.md` -- Maintainer / architecture notes: `DEVELOPER_EXPERIENCE.md` -- Build, release, and publish flow: `DEPLOYMENT_GUIDE.md` +- Contributor workflow: `CONTRIBUTING.md` +- Maintainer / release operations: `MAINTAINER_GUIDE.md` +- Build, release, and publish flow: `RELEASE_GUIDE.md` +- Versioning policy: `VERSIONING.md` - TypeScript package docs: `typescript-sdk/README.md` - Python package docs: `python-sdk/README.md` diff --git a/DEPLOYMENT_GUIDE.md b/RELEASE_GUIDE.md similarity index 50% rename from DEPLOYMENT_GUIDE.md rename to RELEASE_GUIDE.md index 2bddc27..36035f4 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/RELEASE_GUIDE.md @@ -1,4 +1,6 @@ -# Deployment and Release Guide +# Release Guide + +This document is for maintainers publishing SDK releases and operating release automation. It is not for developers using the SDK in applications; use `README.md`, `QUICKSTART.md`, or `CONTRIBUTING.md` for local development and integration work. This repo publishes two SDK packages from one protocol-aligned codebase: - npm: `@commandlayer/sdk` @@ -9,23 +11,27 @@ Current release line: - Supported protocol line: Protocol-Commons v1.1.0 - ENS / Agent-Card alignment: v1.1.0 signer-discovery flow -## 1. Preconditions +## 1. Release trigger + +Releases are enforced by `.github/workflows/release.yml`. Publish from a signed tag or GitHub release; do not bypass the workflow with manual package uploads. + +## 2. Preconditions Before cutting a release: - confirm both SDK packages are on the same version, - confirm docs reference the same protocol version and receipt model, - confirm shared test vectors still represent the current signed receipt truth, -- decide whether the release is docs-only or publishable. +- confirm npm and PyPI publish credentials are configured in GitHub Actions secrets. -## 2. Local quality gates +## 3. Local quality gates -### TypeScript SDK +### Root scripts ```bash -cd typescript-sdk -npm ci -npm run typecheck -npm test +npm install +npm run build +npm run test +npm run test:full ``` ### Python SDK @@ -35,12 +41,12 @@ cd python-sdk python -m venv .venv source .venv/bin/activate pip install -e '.[dev]' -ruff check . -mypy commandlayer +python -m build +python -m twine check dist/* pytest ``` -## 3. Packaging checks +## 4. Packaging checks ### npm package @@ -64,31 +70,28 @@ python -m build python -m twine check dist/* ``` -## 4. Publish flow - -### npm - -```bash -cd typescript-sdk -npm publish --access public -``` +## 5. Automated publish flow -### PyPI +The release workflow must complete all of the following before publish succeeds: +1. install dependencies, +2. typecheck, build, and test the TypeScript SDK, +3. run the runtime protocol tests against built TypeScript output, +4. lint/typecheck/test the Python SDK, +5. build both packages, +6. validate artifacts, +7. publish to npm, +8. publish to PyPI. -```bash -cd python-sdk -python -m build -python -m twine upload dist/* -``` +Any failure blocks the release. -## 5. Git and release metadata +## 6. Git and release metadata Release steps: -1. merge the release branch, -2. create a git tag matching the SDK version, for example `sdk-v1.1.0`, -3. publish npm, -4. publish PyPI, -5. create GitHub release notes summarizing protocol line, SDK changes, and any migration notes. +1. merge the release-ready changes, +2. create a git tag matching the SDK version, for example `sdk-v1.1.0`, or publish a GitHub release for that tag, +3. let GitHub Actions run the enforced release workflow, +4. verify the npm and PyPI publishes succeeded, +5. publish GitHub release notes summarizing protocol line, SDK changes, and migration notes. Release notes should call out: - supported protocol version, @@ -97,19 +100,10 @@ Release notes should call out: - runtime compatibility notes, - any explicit legacy compatibility retained. -## 6. commandlayer.org coordination +## 7. commandlayer.org coordination If the public docs site references installation or verification examples, update it in the same release window so that: - package versions match, - receipt examples match the repo, - verification examples use the same API shapes, - CLI examples are reproducible. - -## 7. CI expectations - -CI should stay green for: -- TypeScript typecheck/build/tests, -- Python lint/typecheck/tests, -- cross-SDK runtime fixture checks. - -Do not publish if any of those lanes are red. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..3c4c1fd --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,42 @@ +# Versioning Policy + +## SemVer rules + +This repo uses semantic versioning for both published SDK packages. + +- **Major**: breaking API changes, breaking CLI behavior, breaking verification semantics, removal of documented compatibility behavior, or a required migration for supported integrations. +- **Minor**: new backwards-compatible SDK APIs, new CLI commands or flags, new protocol features that do not break existing integrations, or additive verification capabilities such as new resolver lookup paths. +- **Patch**: bug fixes, fixture corrections, documentation corrections, build fixes, and internal changes that do not change documented behavior. + +## What counts as breaking + +The following are breaking unless explicitly documented otherwise: +- removing or renaming public exports, +- changing request or response shapes returned by public SDK methods, +- changing receipt verification success or failure rules for existing valid receipts, +- removing CLI commands, flags, or output fields that users are told to depend on, +- dropping a supported Node.js or Python runtime version, +- changing normalization behavior in a way that breaks existing callers. + +## SDK version vs protocol version + +The SDK version is not the protocol version. + +- Protocol compatibility is documented release-by-release in `README.md` and release notes. +- SDK minor or patch releases may still target the same protocol line. +- A protocol change that forces integration changes is treated as an SDK breaking change and requires a major SDK release. + +## Minor vs patch guidance + +Use a **minor** release when behavior expands but existing integrations keep working. + +Use a **patch** release when correcting bugs, tightening docs, fixing fixtures, or repairing release/test discipline without changing documented public behavior. + +## Legacy normalization policy + +Legacy blended response normalization is compatibility-only. + +- The canonical current contract is the response envelope with top-level `receipt` and optional `runtime_metadata`. +- Legacy normalization exists to avoid breaking older runtime payloads immediately. +- It is not a long-term guarantee that every historical response shape will remain supported forever. +- If legacy normalization is removed, that removal is a breaking change and requires a major release. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bb404c8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "commandlayer-sdk-repo", + "version": "1.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "commandlayer-sdk-repo", + "version": "1.1.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5b55432 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "commandlayer-sdk-repo", + "private": true, + "version": "1.1.0", + "scripts": { + "build": "npm --prefix typescript-sdk run build", + "test": "npm --prefix typescript-sdk run test", + "test:full": "npm --prefix typescript-sdk ci && npm --prefix typescript-sdk run build && node --test runtime/tests/*.mjs" + } +} diff --git a/python-sdk/commandlayer/verify.py b/python-sdk/commandlayer/verify.py index 80d69d7..4d86aae 100644 --- a/python-sdk/commandlayer/verify.py +++ b/python-sdk/commandlayer/verify.py @@ -125,6 +125,7 @@ def verify_ed25519_signature_over_utf8_hash_string( def resolve_signer_key( name: str, rpc_url: str, + requested_kid: str | None = None, *, resolver: EnsTextResolver | None = None, ) -> SignerKeyResolution: @@ -134,21 +135,31 @@ def resolve_signer_key( signer_name = txt_resolver.get_text(name, "cl.receipt.signer") if not signer_name: raise ValueError(f"ENS TXT cl.receipt.signer missing for agent ENS name: {name}") - pub_key_text = txt_resolver.get_text(signer_name, "cl.sig.pub") - if not pub_key_text: - raise ValueError(f"ENS TXT cl.sig.pub missing for signer ENS name: {signer_name}") - kid = txt_resolver.get_text(signer_name, "cl.sig.kid") - if not kid: + current_kid = txt_resolver.get_text(signer_name, "cl.sig.kid") + if not current_kid: raise ValueError(f"ENS TXT cl.sig.kid missing for signer ENS name: {signer_name}") + current_pub_key_text = txt_resolver.get_text(signer_name, "cl.sig.pub") + if not current_pub_key_text: + raise ValueError(f"ENS TXT cl.sig.pub missing for signer ENS name: {signer_name}") + + effective_kid = (requested_kid or "").strip() or current_kid + pub_key_text = current_pub_key_text + pub_key_record = "cl.sig.pub" + if effective_kid != current_kid: + pub_key_record = f"cl.sig.pub.{effective_kid}" + pub_key_text = txt_resolver.get_text(signer_name, pub_key_record) + if not pub_key_text: + raise ValueError(f"ENS unknown key id for signer ENS name: {signer_name}: {effective_kid}") + 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}" + f"ENS TXT {pub_key_record} malformed for signer ENS name: {signer_name}. {err}" ) from err return SignerKeyResolution( algorithm="ed25519", - kid=kid, + kid=effective_kid, raw_public_key_bytes=raw_public_key_bytes, ) @@ -232,7 +243,11 @@ def verify_receipt( ens_error = "ens.name is required" else: try: - signer_key = resolve_signer_key(ens_name, _extract_rpc_url(ens)) + signer_key = resolve_signer_key( + ens_name, + _extract_rpc_url(ens), + target.get("kid") if isinstance(target, dict) and isinstance(target.get("kid"), str) else None, + ) pubkey = signer_key.raw_public_key_bytes pubkey_source = "ens" except Exception as err: # noqa: BLE001 diff --git a/python-sdk/tests/test_verification.py b/python-sdk/tests/test_verification.py index 6803fa4..7d73d90 100644 --- a/python-sdk/tests/test_verification.py +++ b/python-sdk/tests/test_verification.py @@ -23,25 +23,25 @@ def load_fixture(name: str) -> dict: return json.loads((VECTORS / name).read_text(encoding="utf-8")) -def load_pubkey() -> str: - return (VECTORS / "public_key_base64.txt").read_text(encoding="utf-8").strip() +def load_text(name: str) -> str: + return (VECTORS / name).read_text(encoding="utf-8").strip() def test_valid_receipt_verifies() -> None: receipt = load_fixture("receipt_valid.json") - result = verify_receipt(receipt, public_key=f"ed25519:{load_pubkey()}") + result = verify_receipt(receipt, public_key=f"ed25519:{load_text('public_key_base64.txt')}") assert result["ok"] is True def test_valid_envelope_verifies() -> None: receipt = load_fixture("receipt_valid.json") - result = verify_receipt({"receipt": receipt}, public_key=f"ed25519:{load_pubkey()}") + result = verify_receipt({"receipt": receipt}, public_key=f"ed25519:{load_text('public_key_base64.txt')}") assert result["ok"] is True def test_invalid_signature_fails() -> None: receipt = load_fixture("receipt_invalid_sig.json") - result = verify_receipt(receipt, public_key=f"ed25519:{load_pubkey()}") + result = verify_receipt(receipt, public_key=f"ed25519:{load_text('public_key_base64.txt')}") assert result["ok"] is False @@ -64,12 +64,24 @@ def test_malformed_pubkey_fails() -> None: def test_wrong_kid_detected() -> None: + resolver = FakeResolver( + { + ("rotatingagent.eth", "cl.receipt.signer"): "rotating-runtime.commandlayer.eth", + ("rotating-runtime.commandlayer.eth", "cl.sig.pub"): f"ed25519:{load_text('public_key_v2_base64.txt')}", + ("rotating-runtime.commandlayer.eth", "cl.sig.kid"): "v2", + ("rotating-runtime.commandlayer.eth", "cl.sig.pub.v1"): f"ed25519:{load_text('public_key_v1_base64.txt')}", + ("rotating-runtime.commandlayer.eth", "cl.sig.pub.v2"): f"ed25519:{load_text('public_key_v2_base64.txt')}", + } + ) receipt = load_fixture("receipt_wrong_kid.json") - assert receipt["kid"] == "v2" - with pytest.raises(ValueError, match="Unknown key id"): - if receipt["kid"] != "v1": - raise ValueError("Unknown key id") + with pytest.raises(ValueError, match="unknown key id"): + resolve_signer_key( + "rotatingagent.eth", + "https://rpc.example", + receipt["kid"], + resolver=resolver, + ) def test_parse_pubkey_fixture_length() -> None: - assert len(parse_ed25519_pubkey(f"ed25519:{load_pubkey()}")) == 32 + assert len(parse_ed25519_pubkey(f"ed25519:{load_text('public_key_base64.txt')}")) == 32 diff --git a/runtime/tests/key-rotation.test.mjs b/runtime/tests/key-rotation.test.mjs index 5f68c10..cb466cb 100644 --- a/runtime/tests/key-rotation.test.mjs +++ b/runtime/tests/key-rotation.test.mjs @@ -1,15 +1,40 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; -import { loadFixture, loadTextFixture } from "../../typescript-sdk/tests/helpers.mjs"; +import { installMockEns, loadFixture } from "../../typescript-sdk/tests/helpers.mjs"; const require = createRequire(import.meta.url); const { verifyReceipt } = require("../../typescript-sdk/dist/index.cjs"); -const publicKey = `ed25519:${loadTextFixture("public_key_base64.txt")}`; +installMockEns(); -test("v1 receipt still verifies after v2 key added", async () => { - const receipt = loadFixture("receipt_valid_v1.json"); - const result = await verifyReceipt(receipt, { publicKey }); - assert.equal(result.ok, true); +test("routes rotated ENS keys by receipt kid and rejects unknown kids", async () => { + const receiptV1 = loadFixture("receipt_valid_v1.json"); + const receiptV2 = loadFixture("receipt_valid_v2.json"); + const wrongKidReceipt = loadFixture("receipt_wrong_kid.json"); + const removedKidReceipt = loadFixture("receipt_removed_kid.json"); + + const v1Result = await verifyReceipt(receiptV1, { + ens: { name: "rotatingagent.eth", rpcUrl: "http://mock-rpc.local" } + }); + assert.equal(v1Result.ok, true, "v1 receipt should verify using cl.sig.pub.v1 after rotation"); + assert.equal(v1Result.values.pubkey_source, "ens"); + + const v2Result = await verifyReceipt(receiptV2, { + ens: { name: "rotatingagent.eth", rpcUrl: "http://mock-rpc.local" } + }); + assert.equal(v2Result.ok, true, "v2 receipt should verify using the current rotated key"); + assert.equal(v2Result.values.pubkey_source, "ens"); + + const wrongKidResult = await verifyReceipt(wrongKidReceipt, { + ens: { name: "rotatingagent.eth", rpcUrl: "http://mock-rpc.local" } + }); + assert.equal(wrongKidResult.ok, false); + assert.match(wrongKidResult.errors.signature_error ?? "", /unknown key id/i); + + const removedKidResult = await verifyReceipt(removedKidReceipt, { + ens: { name: "removed-agent.eth", rpcUrl: "http://mock-rpc.local" } + }); + assert.equal(removedKidResult.ok, false); + assert.match(removedKidResult.errors.signature_error ?? "", /unknown key id/i); }); diff --git a/runtime/tests/receipt-verification.test.mjs b/runtime/tests/receipt-verification.test.mjs index 83881c3..9353d19 100644 --- a/runtime/tests/receipt-verification.test.mjs +++ b/runtime/tests/receipt-verification.test.mjs @@ -1,38 +1,32 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; -import { loadFixture, loadTextFixture } from "../../typescript-sdk/tests/helpers.mjs"; +import { installMockEns, loadFixture, loadTextFixture } from "../../typescript-sdk/tests/helpers.mjs"; + +installMockEns(); const require = createRequire(import.meta.url); const { verifyReceipt } = require("../../typescript-sdk/dist/index.cjs"); const publicKey = `ed25519:${loadTextFixture("public_key_base64.txt")}`; -async function verifyReceiptWithKid(receipt) { - if (receipt.kid && receipt.kid !== "v1") { - return { valid: false, error: "Unknown key id" }; - } - const result = await verifyReceipt(receipt, { publicKey }); - return { - valid: result.ok, - error: result.errors.signature_error ?? result.errors.verify_error ?? "" - }; -} - test("valid receipt verifies", async () => { const receipt = loadFixture("receipt_valid.json"); - const result = await verifyReceiptWithKid(receipt); - assert.equal(result.valid, true); + const result = await verifyReceipt(receipt, { publicKey }); + assert.equal(result.ok, true); }); test("invalid signature fails", async () => { const receipt = loadFixture("receipt_invalid_sig.json"); - const result = await verifyReceiptWithKid(receipt); - assert.equal(result.valid, false); + const result = await verifyReceipt(receipt, { publicKey }); + assert.equal(result.ok, false); }); -test("wrong kid fails", async () => { +test("wrong kid fails when ENS cannot route to a matching key", async () => { const receipt = loadFixture("receipt_wrong_kid.json"); - const result = await verifyReceiptWithKid(receipt); - assert.match(result.error, /Unknown key id/); + const result = await verifyReceipt(receipt, { + ens: { name: "rotatingagent.eth", rpcUrl: "http://mock-rpc.local" } + }); + assert.equal(result.ok, false); + assert.match(result.errors.signature_error ?? "", /unknown key id/i); }); diff --git a/test_vectors/README.md b/test_vectors/README.md new file mode 100644 index 0000000..fd5d8d8 --- /dev/null +++ b/test_vectors/README.md @@ -0,0 +1,15 @@ +# Test vectors + +## Categories + +- **Canonical receipts**: known-good signed receipts such as `receipt_valid.json`, `receipt_valid_v1.json`, and `receipt_valid_v2.json`. +- **ENS resolution cases**: fixtures and runtime tests that cover missing signer TXT records, malformed public keys, and key lookup by `kid`. +- **Invalid signature cases**: fixtures such as `receipt_invalid_sig.json` that keep the receipt payload intact but break the signature. +- **Key rotation cases**: `public_key_v1_base64.txt`, `public_key_v2_base64.txt`, `receipt_valid_v1.json`, `receipt_valid_v2.json`, `receipt_wrong_kid.json`, and `receipt_removed_kid.json`. +- **Envelope vs receipt tests**: verification tests that pass both a canonical receipt and an envelope with top-level `receipt`. + +## Naming rules + +- Receipt fixtures are named for the behavior they test. +- ENS-specific malformed cases use `ens_...` prefixes instead of pretending the receipt itself is malformed. +- Rotation fixtures must include real key material and distinct signatures for each `kid`. diff --git a/test_vectors/ens_malformed_pubkey_case.json b/test_vectors/ens_malformed_pubkey_case.json new file mode 100644 index 0000000..d306638 --- /dev/null +++ b/test_vectors/ens_malformed_pubkey_case.json @@ -0,0 +1,6 @@ +{ + "scenario": "signer ENS TXT record cl.sig.pub is present but malformed base64", + "agent_name": "malformed.eth", + "signer_name": "malformed-signer.eth", + "expected_error": "cl.sig.pub malformed" +} diff --git a/test_vectors/public_key_v1_base64.txt b/test_vectors/public_key_v1_base64.txt new file mode 100644 index 0000000..bd7c695 --- /dev/null +++ b/test_vectors/public_key_v1_base64.txt @@ -0,0 +1 @@ +Dl84JQSGB/DC0OBWolXSO9eNlwL1XeDQLHPEAPxNGUo= diff --git a/test_vectors/public_key_v2_base64.txt b/test_vectors/public_key_v2_base64.txt new file mode 100644 index 0000000..96469d8 --- /dev/null +++ b/test_vectors/public_key_v2_base64.txt @@ -0,0 +1 @@ +Lj/SCXvKQPlFTsNuI7yBrulTdgGvOdbrucVAXr7QFkc= diff --git a/test_vectors/receipt_malformed_pubkey.json b/test_vectors/receipt_malformed_pubkey.json deleted file mode 100644 index 0350f6d..0000000 --- a/test_vectors/receipt_malformed_pubkey.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "kid": "v1", - "status": "success", - "x402": { - "verb": "summarize", - "version": "1.1.0", - "entry": "x402://parseagent.eth/summarize/v1.1.0" - }, - "result": { - "summary": "fixture" - }, - "metadata": { - "proof": { - "alg": "ed25519-sha256", - "canonical": "cl-stable-json-v1", - "signer_id": "runtime.commandlayer.eth", - "hash_sha256": "915ce5bd5d71ac3622a1d3918630dd69ff0d6d22218de84a751817ae60bd54e1", - "signature_b64": "JwwLYGdplh7Yy5U42DAov6V30trEdRbA5Qzh8owG6X0DkGl2/TsaOhtWRJU9Z1uykJVeDFdqijR9ZqoktqjhAg==" - }, - "receipt_id": "915ce5bd5d71ac3622a1d3918630dd69ff0d6d22218de84a751817ae60bd54e1" - } -} diff --git a/test_vectors/receipt_removed_kid.json b/test_vectors/receipt_removed_kid.json new file mode 100644 index 0000000..5c06e5b --- /dev/null +++ b/test_vectors/receipt_removed_kid.json @@ -0,0 +1,22 @@ +{ + "status": "success", + "x402": { + "verb": "summarize", + "version": "1.1.0", + "entry": "x402://parseagent.eth/summarize/v1.1.0" + }, + "result": { + "summary": "fixture v2 signed with v2 key" + }, + "metadata": { + "proof": { + "alg": "ed25519-sha256", + "canonical": "cl-stable-json-v1", + "signer_id": "runtime.commandlayer.eth", + "hash_sha256": "bb9e09b83910b4e0145ea3f7a339765a90e2bb9ff238984fe6af07df3f01eed8", + "signature_b64": "qKWA+M2sF01CxZPWtWy+IrtuXCZJ8p1QJFjZKzyCDJabP+mW2GOQevUZ76F/qpn17SUY2A/EZ+tDB8Pukeb5AA==" + }, + "receipt_id": "bb9e09b83910b4e0145ea3f7a339765a90e2bb9ff238984fe6af07df3f01eed8" + }, + "kid": "removed-v2" +} diff --git a/test_vectors/receipt_valid_v1.json b/test_vectors/receipt_valid_v1.json index 0350f6d..b55e0d7 100644 --- a/test_vectors/receipt_valid_v1.json +++ b/test_vectors/receipt_valid_v1.json @@ -1,5 +1,4 @@ { - "kid": "v1", "status": "success", "x402": { "verb": "summarize", @@ -7,16 +6,17 @@ "entry": "x402://parseagent.eth/summarize/v1.1.0" }, "result": { - "summary": "fixture" + "summary": "fixture v1 signed with v1 key" }, "metadata": { "proof": { "alg": "ed25519-sha256", "canonical": "cl-stable-json-v1", "signer_id": "runtime.commandlayer.eth", - "hash_sha256": "915ce5bd5d71ac3622a1d3918630dd69ff0d6d22218de84a751817ae60bd54e1", - "signature_b64": "JwwLYGdplh7Yy5U42DAov6V30trEdRbA5Qzh8owG6X0DkGl2/TsaOhtWRJU9Z1uykJVeDFdqijR9ZqoktqjhAg==" + "hash_sha256": "a46b1889ee1c0a2cb59a07cccee9dc079da9d4bb377e0099f4bf2a1f56b7af9a", + "signature_b64": "YfPk/Oue2xN0OUq0J9+M7B4NlnOpzVZBDXpcrpxHYWASsmMRrNkj5nZwro+p41qB4Zgj7ZYKDj+mw6DY7DEmAQ==" }, - "receipt_id": "915ce5bd5d71ac3622a1d3918630dd69ff0d6d22218de84a751817ae60bd54e1" - } + "receipt_id": "a46b1889ee1c0a2cb59a07cccee9dc079da9d4bb377e0099f4bf2a1f56b7af9a" + }, + "kid": "v1" } diff --git a/test_vectors/receipt_valid_v2.json b/test_vectors/receipt_valid_v2.json new file mode 100644 index 0000000..9225c13 --- /dev/null +++ b/test_vectors/receipt_valid_v2.json @@ -0,0 +1,22 @@ +{ + "status": "success", + "x402": { + "verb": "summarize", + "version": "1.1.0", + "entry": "x402://parseagent.eth/summarize/v1.1.0" + }, + "result": { + "summary": "fixture v2 signed with v2 key" + }, + "metadata": { + "proof": { + "alg": "ed25519-sha256", + "canonical": "cl-stable-json-v1", + "signer_id": "runtime.commandlayer.eth", + "hash_sha256": "bb9e09b83910b4e0145ea3f7a339765a90e2bb9ff238984fe6af07df3f01eed8", + "signature_b64": "qKWA+M2sF01CxZPWtWy+IrtuXCZJ8p1QJFjZKzyCDJabP+mW2GOQevUZ76F/qpn17SUY2A/EZ+tDB8Pukeb5AA==" + }, + "receipt_id": "bb9e09b83910b4e0145ea3f7a339765a90e2bb9ff238984fe6af07df3f01eed8" + }, + "kid": "v2" +} diff --git a/test_vectors/receipt_wrong_kid.json b/test_vectors/receipt_wrong_kid.json index e0bc10e..e9e4ed8 100644 --- a/test_vectors/receipt_wrong_kid.json +++ b/test_vectors/receipt_wrong_kid.json @@ -1,5 +1,4 @@ { - "kid": "v2", "status": "success", "x402": { "verb": "summarize", @@ -7,16 +6,17 @@ "entry": "x402://parseagent.eth/summarize/v1.1.0" }, "result": { - "summary": "fixture" + "summary": "fixture v2 signed with v2 key but labeled with an unknown kid" }, "metadata": { "proof": { "alg": "ed25519-sha256", "canonical": "cl-stable-json-v1", "signer_id": "runtime.commandlayer.eth", - "hash_sha256": "915ce5bd5d71ac3622a1d3918630dd69ff0d6d22218de84a751817ae60bd54e1", - "signature_b64": "JwwLYGdplh7Yy5U42DAov6V30trEdRbA5Qzh8owG6X0DkGl2/TsaOhtWRJU9Z1uykJVeDFdqijR9ZqoktqjhAg==" + "hash_sha256": "f4e0f174a33c927f0f3f923365815d03ef5896fb229f20f54fcd7ddcb294fd8b", + "signature_b64": "qKWA+M2sF01CxZPWtWy+IrtuXCZJ8p1QJFjZKzyCDJabP+mW2GOQevUZ76F/qpn17SUY2A/EZ+tDB8Pukeb5AA==" }, - "receipt_id": "915ce5bd5d71ac3622a1d3918630dd69ff0d6d22218de84a751817ae60bd54e1" - } + "receipt_id": "f4e0f174a33c927f0f3f923365815d03ef5896fb229f20f54fcd7ddcb294fd8b" + }, + "kid": "v3" } diff --git a/typescript-sdk/README.md b/typescript-sdk/README.md index 2c3a7f8..157747c 100644 --- a/typescript-sdk/README.md +++ b/typescript-sdk/README.md @@ -98,12 +98,19 @@ const result = await verifyReceipt(response.receipt, { 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. +3. `cl.sig.kid` on the signer ENS name, +4. `cl.sig.pub.` when verifying an older receipt after key rotation. ## CLI The package ships the `commandlayer` CLI. +The CLI has two usage layers: +- verb-specific commands such as `summarize` and `analyze` for the fast/common paths, +- `call` for generic usage when you want to supply the raw JSON payload for any verb. + +`commandlayer verify` accepts either a canonical receipt JSON object or a full response envelope with a top-level `receipt` field. + ```bash commandlayer summarize --content "hello" --style bullet_points --json commandlayer verify --file receipt.json --public-key "ed25519:BASE64_PUBLIC_KEY" diff --git a/typescript-sdk/scripts/cli-smoke.mjs b/typescript-sdk/scripts/cli-smoke.mjs index 9ea7aaa..1edb72a 100644 --- a/typescript-sdk/scripts/cli-smoke.mjs +++ b/typescript-sdk/scripts/cli-smoke.mjs @@ -26,7 +26,7 @@ function runCase(name, args, expected) { runCase("help output", ["--help"], { exitCode: 0, - includes: ["Usage: commandlayer", "CommandLayer CLI for calling Commons verbs and verifying signed receipts"] + includes: ["Usage: commandlayer", "verb-specific commands for fast common usage", "verify for receipts/envelopes"] }); runCase("argument validation", ["summarize"], { diff --git a/typescript-sdk/src/cli.ts b/typescript-sdk/src/cli.ts index c8815ec..ea8f02d 100644 --- a/typescript-sdk/src/cli.ts +++ b/typescript-sdk/src/cli.ts @@ -10,7 +10,7 @@ function parseIntSafe(value: string, fallback: number): number { const program = new Command(); program .name("commandlayer") - .description("CommandLayer CLI for calling Commons verbs and verifying signed receipts") + .description("CommandLayer CLI: verb-specific commands for fast common usage, plus call for generic JSON requests, and verify for receipts/envelopes") .option("--runtime ", "CommandLayer runtime base URL", "https://runtime.commandlayer.org") .option("--actor ", "Actor id used in requests", "sdk-cli") .option("--timeout-ms ", "Request timeout in milliseconds", "30000") @@ -60,12 +60,12 @@ withCommonOptions(program.command("analyze").description("Analyze content").opti printCommandResponse(response, !!program.opts().json); }); -program.command("call").description("Call a verb with a raw JSON payload").requiredOption("--verb ", "Verb name").requiredOption("--body ", "Request body JSON").action(async (opts) => { +program.command("call").description("Generic command for any verb when the verb-specific commands are too narrow").requiredOption("--verb ", "Verb name").requiredOption("--body ", "Request body JSON").action(async (opts) => { const response = await createConfiguredClient().call(opts.verb, JSON.parse(opts.body) as Record); printCommandResponse(response, !!program.opts().json); }); -program.command("verify").description("Verify a saved receipt or response envelope").requiredOption("--file ", "Path to receipt JSON file").option("--public-key ", "Explicit Ed25519 public key").option("--ens-name ", "ENS name that publishes cl.receipt.signer").option("--rpc-url ", "RPC URL for ENS lookups").action(async (opts) => { +program.command("verify").description("Verify a canonical receipt JSON object or a full response envelope with a top-level receipt field").requiredOption("--file ", "Path to receipt or response envelope JSON file").option("--public-key ", "Explicit Ed25519 public key").option("--ens-name ", "ENS name that publishes cl.receipt.signer").option("--rpc-url ", "RPC URL for ENS lookups").action(async (opts) => { const fs = await import("node:fs/promises"); const raw = JSON.parse(await fs.readFile(opts.file, "utf8")) as Record; const result = await verifyReceipt(raw as any, { diff --git a/typescript-sdk/src/index.ts b/typescript-sdk/src/index.ts index 56b924a..14d8a37 100644 --- a/typescript-sdk/src/index.ts +++ b/typescript-sdk/src/index.ts @@ -228,7 +228,7 @@ export function verifyEd25519SignatureOverUtf8HashString( return nacl.sign.detached.verify(new Uint8Array(msg), sig, pubkey32); } -export async function resolveSignerKey(name: string, rpcUrl: string): Promise { +export async function resolveSignerKey(name: string, rpcUrl: string, requestedKid?: string | null): 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}`); @@ -239,17 +239,29 @@ export async function resolveSignerKey(name: string, rpcUrl: string): Promise