diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 000000000..b37b609bb --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,53 @@ +name: Node.js (jacsnpm) + +on: + push: + branches: [ "main" ] + paths: + - 'jacsnpm/**' + - 'jacs/**' # jacsnpm depends on jacs + - '.github/workflows/nodejs.yml' + pull_request: + branches: [ "main" ] + paths: + - 'jacsnpm/**' + - 'jacs/**' # jacsnpm depends on jacs + - '.github/workflows/nodejs.yml' + workflow_dispatch: # Allows manual triggering + +env: + CARGO_TERM_COLOR: always + +jobs: + test-jacsnpm: + name: Test jacsnpm (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + node-version: ['20'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + working-directory: jacsnpm + run: npm ci || npm install + + - name: Build native module + working-directory: jacsnpm + run: npm run build + + - name: Run tests + working-directory: jacsnpm + run: npm test diff --git a/.github/workflows/release-crate.yml b/.github/workflows/release-crate.yml new file mode 100644 index 000000000..18baeaa25 --- /dev/null +++ b/.github/workflows/release-crate.yml @@ -0,0 +1,48 @@ +name: Release crates.io + +on: + push: + tags: + - 'crate/v*' + +permissions: + contents: read + +jobs: + verify-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Extract version from tag + id: extract + run: | + TAG="${GITHUB_REF#refs/tags/crate/v}" + echo "version=$TAG" >> $GITHUB_OUTPUT + + - name: Check Cargo.toml version matches tag + run: | + CARGO_VERSION=$(grep '^version = ' jacs/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + TAG_VERSION="${{ steps.extract.outputs.version }}" + echo "Cargo version: $CARGO_VERSION" + echo "Tag version: $TAG_VERSION" + if [ "$CARGO_VERSION" = "$TAG_VERSION" ]; then + echo "Version match confirmed" + else + echo "::error::Version mismatch! Cargo.toml has $CARGO_VERSION but tag is $TAG_VERSION" + exit 1 + fi + + publish: + needs: verify-version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Publish to crates.io + working-directory: jacs + run: cargo publish --token ${{ secrets.CRATES_IO_TOKEN }} diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml new file mode 100644 index 000000000..73ebe3da8 --- /dev/null +++ b/.github/workflows/release-pypi.yml @@ -0,0 +1,122 @@ +name: Release PyPI + +on: + push: + tags: + - 'pypi/v*' + +permissions: + contents: read + id-token: write + +jobs: + verify-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Extract version from tag + id: extract + run: | + TAG="${GITHUB_REF#refs/tags/pypi/v}" + echo "version=$TAG" >> $GITHUB_OUTPUT + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Check pyproject.toml version matches tag + run: | + pip install toml + PKG_VERSION=$(python -c "import toml; print(toml.load('jacspy/pyproject.toml')['project']['version'])") + TAG_VERSION="${{ steps.extract.outputs.version }}" + echo "Package version: $PKG_VERSION" + echo "Tag version: $TAG_VERSION" + if [ "$PKG_VERSION" = "$TAG_VERSION" ]; then + echo "Version match confirmed" + else + echo "::error::Version mismatch! pyproject.toml has $PKG_VERSION but tag is $TAG_VERSION" + exit 1 + fi + + build-wheels: + needs: verify-version + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + - os: macos-latest + target: aarch64-apple-darwin + - os: macos-13 + target: x86_64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + + - name: Install maturin + run: pip install maturin + + - name: Build wheel + working-directory: jacspy + run: maturin build --release --target ${{ matrix.target }} --out dist + + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.target }} + path: jacspy/dist/*.whl + + build-sdist: + needs: verify-version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install maturin + run: pip install maturin + + - name: Build sdist + working-directory: jacspy + run: maturin sdist --out dist + + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: jacspy/dist/*.tar.gz + + publish: + needs: [build-wheels, build-sdist] + runs-on: ubuntu-latest + environment: pypi + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..1d42b70a7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,109 @@ +name: Release npm + +on: + push: + tags: + - 'npm/v*' + +permissions: + contents: write + id-token: write + +jobs: + verify-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract.outputs.version }} + matches: ${{ steps.check.outputs.matches }} + steps: + - uses: actions/checkout@v4 + + - name: Extract version from tag + id: extract + run: | + TAG="${GITHUB_REF#refs/tags/npm/v}" + echo "version=$TAG" >> $GITHUB_OUTPUT + + - name: Check package.json version matches tag + id: check + run: | + PKG_VERSION=$(node -p "require('./jacsnpm/package.json').version") + TAG_VERSION="${{ steps.extract.outputs.version }}" + echo "Package version: $PKG_VERSION" + echo "Tag version: $TAG_VERSION" + if [ "$PKG_VERSION" = "$TAG_VERSION" ]; then + echo "matches=true" >> $GITHUB_OUTPUT + else + echo "::error::Version mismatch! package.json has $PKG_VERSION but tag is $TAG_VERSION" + echo "matches=false" >> $GITHUB_OUTPUT + exit 1 + fi + + build: + needs: verify-version + strategy: + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + - os: macos-13 + target: x86_64-apple-darwin + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + - os: windows-latest + target: x86_64-pc-windows-msvc + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: nightly + target: ${{ matrix.target }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + working-directory: jacsnpm + + - name: Build native module + run: npx napi build --platform --release --target ${{ matrix.target }} + working-directory: jacsnpm + + - uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.target }} + path: jacsnpm/*.node + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: jacsnpm/artifacts + + - name: Copy binaries + run: cp artifacts/**/*.node . + working-directory: jacsnpm + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: 'https://registry.npmjs.org' + + - name: Publish to npm + run: npm publish --provenance + working-directory: jacsnpm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 46a97fe67..8e6e4369e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -3,14 +3,15 @@ name: Rust (jacs crate) on: push: branches: [ "main" ] - paths: # Optional: Trigger only on changes within jacs/ or relevant files - - 'JACS/jacs/**' - - '.github/workflows/rust.yml' + paths: + - 'jacs/**' + - '.github/workflows/rust.yml' pull_request: branches: [ "main" ] - paths: # Optional: Trigger only on changes within jacs/ or relevant files - - 'JACS/jacs/**' - - '.github/workflows/rust.yml' + paths: + - 'jacs/**' + - '.github/workflows/rust.yml' + workflow_dispatch: # Allows manual triggering env: CARGO_TERM_COLOR: always diff --git a/Cargo.toml b/Cargo.toml index ffe2ef77b..99c69d08a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "jacs", + "binding-core", "jacsnpm", "jacspy", "jacs-mcp", diff --git a/LINES_OF_CODE.md b/LINES_OF_CODE.md new file mode 100644 index 000000000..607ff800c --- /dev/null +++ b/LINES_OF_CODE.md @@ -0,0 +1,13 @@ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Language Files Lines Code Comments Blanks +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Go 11 3332 2464 397 471 + Python 151 32677 25473 1560 5644 + TypeScript 9 2407 1352 842 213 +───────────────────────────────────────────────────────────────────────────────── + Rust 337 85972 70992 4926 10054 + |- Markdown 238 23660 565 17413 5682 + (Total) 109632 71557 22339 15736 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Total 508 148048 100846 25138 22064 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/Makefile b/Makefile index cde3acbbf..2a9bf3e87 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,229 @@ -.PHONY: build-jacs test +.PHONY: build-jacs build-jacsbook test test-jacs test-jacs-cli test-jacs-observability test-jacspy \ + publish-jacs publish-jacspy publish-jacsnpm \ + release-jacs release-jacspy release-jacsnpm release-all \ + version versions check-versions check-version-jacs check-version-jacspy check-version-jacsnpm \ + help + +# ============================================================================ +# VERSION DETECTION +# ============================================================================ +# Extract versions from source files. These are used for release tagging. + +# Rust core library version (from jacs/Cargo.toml) +JACS_VERSION := $(shell grep '^version' jacs/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + +# Python bindings version (from jacspy/pyproject.toml) +JACSPY_VERSION := $(shell grep '^version' jacspy/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + +# Node.js bindings version (from jacsnpm/package.json) +JACSNPM_VERSION := $(shell grep '"version"' jacsnpm/package.json | head -1 | sed 's/.*: *"\(.*\)".*/\1/') + +# ============================================================================ +# BUILD +# ============================================================================ - build-jacs: - cd jacs && cargo install --path . --force --features cli - ~/.cargo/bin/jacs --help + cd jacs && cargo install --path . --force --features cli + ~/.cargo/bin/jacs --help ~/.cargo/bin/jacs version +build-jacspy: + cd jacspy && maturin develop + +build-jacsnpm: + cd jacsnpm && npm run build + +build-jacsbook: + cd jacs/docs/jacsbook && mdbook build + +# ============================================================================ +# TEST +# ============================================================================ + test-jacs: - cd jacs && RUST_BACKTRACE=1 cargo test --features cli -- --nocapture + cd jacs && RUST_BACKTRACE=1 cargo test --features cli -- --nocapture test-jacs-cli: - cd jacs && RUST_BACKTRACE=1 cargo test --features cli --test cli_tests -- --nocapture + cd jacs && RUST_BACKTRACE=1 cargo test --features cli --test cli_tests -- --nocapture test-jacs-observability: RUST_BACKTRACE=1 cargo test --features "cli observability-convenience otlp-logs otlp-metrics otlp-tracing" --test observability_tests --test observability_oltp_meter -- --nocapture +test-jacspy: + cd jacspy && maturin develop && python -m pytest tests/ -v + +test-jacsnpm: + cd jacsnpm && npm test + +test: test-jacs + +# ============================================================================ +# VERSION INFO +# ============================================================================ + +# Show all detected versions +versions: + @echo "Detected versions from source files:" + @echo " jacs (Cargo.toml): $(JACS_VERSION)" + @echo " jacspy (pyproject.toml): $(JACSPY_VERSION)" + @echo " jacsnpm (package.json): $(JACSNPM_VERSION)" + @echo "" + @if [ "$(JACS_VERSION)" = "$(JACSPY_VERSION)" ] && [ "$(JACS_VERSION)" = "$(JACSNPM_VERSION)" ]; then \ + echo "✓ All versions match: $(JACS_VERSION)"; \ + else \ + echo "⚠ WARNING: Versions do not match!"; \ + fi + +version: versions + +# Check that all versions match (fails if they don't) +check-versions: + @if [ "$(JACS_VERSION)" != "$(JACSPY_VERSION)" ]; then \ + echo "ERROR: jacs ($(JACS_VERSION)) != jacspy ($(JACSPY_VERSION))"; \ + exit 1; \ + fi + @if [ "$(JACS_VERSION)" != "$(JACSNPM_VERSION)" ]; then \ + echo "ERROR: jacs ($(JACS_VERSION)) != jacsnpm ($(JACSNPM_VERSION))"; \ + exit 1; \ + fi + @echo "✓ All versions match: $(JACS_VERSION)" + +# ============================================================================ +# DIRECT PUBLISH (requires local credentials) +# ============================================================================ + +# Publish to crates.io (requires ~/.cargo/credentials or CARGO_REGISTRY_TOKEN) publish-jacs: - cargo publish --features cli --dry-run -p jacs + cd jacs && cargo publish --features cli + +# Dry run for crates.io publish +publish-jacs-dry: + cd jacs && cargo publish --features cli --dry-run + +# Publish to PyPI (requires MATURIN_PYPI_TOKEN or ~/.pypirc) +publish-jacspy: + cd jacspy && maturin publish + +# Dry run for PyPI publish +publish-jacspy-dry: + cd jacspy && maturin build --release + +# Publish to npm (requires npm login or NPM_TOKEN) +publish-jacsnpm: + cd jacsnpm && npm publish --access public + +# Dry run for npm publish +publish-jacsnpm-dry: + cd jacsnpm && npm publish --access public --dry-run + +# ============================================================================ +# GITHUB CI RELEASE (via git tags) +# ============================================================================ +# These commands create git tags that trigger GitHub Actions release workflows. +# Versions are auto-detected from source files. Tags are verified before pushing. +# +# Required GitHub Secrets: +# - CRATES_IO_TOKEN (for crate/v* tags) +# - PYPI_API_TOKEN (for pypi/v* tags) +# - NPM_TOKEN (for npm/v* tags) +# ============================================================================ + +# Verify version and tag for crates.io release +check-version-jacs: + @echo "jacs version: $(JACS_VERSION)" + @if git tag -l | grep -q "^crate/v$(JACS_VERSION)$$"; then \ + echo "ERROR: Tag crate/v$(JACS_VERSION) already exists"; \ + exit 1; \ + fi + @echo "✓ Tag crate/v$(JACS_VERSION) is available" + +# Verify version and tag for PyPI release +check-version-jacspy: + @echo "jacspy version: $(JACSPY_VERSION)" + @if git tag -l | grep -q "^pypi/v$(JACSPY_VERSION)$$"; then \ + echo "ERROR: Tag pypi/v$(JACSPY_VERSION) already exists"; \ + exit 1; \ + fi + @echo "✓ Tag pypi/v$(JACSPY_VERSION) is available" + +# Verify version and tag for npm release +check-version-jacsnpm: + @echo "jacsnpm version: $(JACSNPM_VERSION)" + @if git tag -l | grep -q "^npm/v$(JACSNPM_VERSION)$$"; then \ + echo "ERROR: Tag npm/v$(JACSNPM_VERSION) already exists"; \ + exit 1; \ + fi + @echo "✓ Tag npm/v$(JACSNPM_VERSION) is available" + +# Tag and push to trigger crates.io release via GitHub CI +release-jacs: check-version-jacs + git tag crate/v$(JACS_VERSION) + git push origin crate/v$(JACS_VERSION) + @echo "Tagged crate/v$(JACS_VERSION) - GitHub CI will publish to crates.io" + +# Tag and push to trigger PyPI release via GitHub CI +release-jacspy: check-version-jacspy + git tag pypi/v$(JACSPY_VERSION) + git push origin pypi/v$(JACSPY_VERSION) + @echo "Tagged pypi/v$(JACSPY_VERSION) - GitHub CI will publish to PyPI" + +# Tag and push to trigger npm release via GitHub CI +release-jacsnpm: check-version-jacsnpm + git tag npm/v$(JACSNPM_VERSION) + git push origin npm/v$(JACSNPM_VERSION) + @echo "Tagged npm/v$(JACSNPM_VERSION) - GitHub CI will publish to npm" + +# Release all packages via GitHub CI (verifies all versions match first) +release-all: check-versions release-jacs release-jacspy release-jacsnpm + @echo "All release tags pushed for v$(JACS_VERSION). GitHub CI will handle publishing." + +# Delete release tags for current versions (use with caution - for fixing failed releases) +release-delete-tags: + @echo "Deleting tags for version $(JACS_VERSION)..." + -git tag -d crate/v$(JACS_VERSION) pypi/v$(JACSPY_VERSION) npm/v$(JACSNPM_VERSION) + -git push origin --delete crate/v$(JACS_VERSION) pypi/v$(JACSPY_VERSION) npm/v$(JACSNPM_VERSION) + @echo "Deleted release tags" +# ============================================================================ +# HELP +# ============================================================================ -test: test-jacs test-jacspy -# --test agent_tests --test document_tests --test key_tests --test task_tests --test agreement_test --test create_agent_test - - \ No newline at end of file +help: + @echo "JACS Makefile Commands" + @echo "" + @echo "VERSION INFO:" + @echo " make versions Show all detected versions from source files" + @echo " make check-versions Verify all package versions match" + @echo "" + @echo "BUILD:" + @echo " make build-jacs Build and install Rust CLI" + @echo " make build-jacspy Build Python bindings (dev mode)" + @echo " make build-jacsnpm Build Node.js bindings" + @echo " make build-jacsbook Generate jacsbook (mdbook build)" + @echo "" + @echo "TEST:" + @echo " make test Run all tests (alias for test-jacs)" + @echo " make test-jacs Run Rust library tests" + @echo " make test-jacs-cli Run CLI integration tests" + @echo " make test-jacspy Run Python binding tests" + @echo " make test-jacsnpm Run Node.js binding tests" + @echo "" + @echo "DIRECT PUBLISH (local credentials required):" + @echo " make publish-jacs Publish to crates.io" + @echo " make publish-jacs-dry Dry run crates.io publish" + @echo " make publish-jacspy Publish to PyPI" + @echo " make publish-jacspy-dry Dry run PyPI publish" + @echo " make publish-jacsnpm Publish to npm" + @echo " make publish-jacsnpm-dry Dry run npm publish" + @echo "" + @echo "GITHUB CI RELEASE (via git tags - versions auto-detected):" + @echo " make release-jacs Tag crate/v -> triggers crates.io release" + @echo " make release-jacspy Tag pypi/v -> triggers PyPI release" + @echo " make release-jacsnpm Tag npm/v -> triggers npm release" + @echo " make release-all Verify versions match, then release all packages" + @echo " make release-delete-tags Delete release tags (for fixing failed releases)" + @echo "" + @echo "Required GitHub Secrets:" + @echo " CRATES_IO_TOKEN - for crate/v* tags" + @echo " PYPI_API_TOKEN - for pypi/v* tags" + @echo " NPM_TOKEN - for npm/v* tags" diff --git a/README.md b/README.md index 0420c1d5b..661791104 100644 --- a/README.md +++ b/README.md @@ -1,227 +1,147 @@ # JACS -Welcome to JACS (JSON Agent Communication Standard). +**JSON Agent Communication Standard** - Cryptographic signing and verification for AI agents. -**[Documentation](https://humanassisted.github.io/JACS/)** | **[API Reference](https://humanassisted.github.io/JACS/nodejs/api.html)** | **[Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html)** +**[Documentation](https://humanassisted.github.io/JACS/)** | **[Quick Start](https://humanassisted.github.io/JACS/getting-started/quick-start.html)** | **[API Reference](https://humanassisted.github.io/JACS/nodejs/api.html)** -JACS is used by agents to validate the source and identity of data. The data may be ephemeral, changing, or idempotent such as files, identities, logs, http requests. +## What is JACS? -JACS is for data provenance. JACS is a set of JSON Schema definitions that provide headers for cryptographic signatures. The library is used to wrap data in an JSON envelope that can be used in a variety of untrusted where contexts every new or modified payload (web request, email, document, etc) needs to be verified. +JACS provides cryptographic signatures for AI agent communications. Every message, file, or artifact can be signed and verified, ensuring: +- **Authenticity**: Prove who created the data +- **Integrity**: Detect tampering +- **Non-repudiation**: Signed actions can't be denied -## In relation to MCP +## Quick Start -MCP standardizes how a client exposes tools/resources/prompts over JSON-RPC, but it’s intentionally light on identity and artifact-level provenance. JACS fills that gap by making every artifact (tasks, messages, agreements, files) a signed, self-verifiable record with stable schemas and audit trails—orthogonal to whether you call it via MCP or not. +### Python -## In relation to A2A - -JACS provides cryptographic document provenance for Google's A2A (Agent-to-Agent) protocol. While A2A handles agent discovery and communication, JACS adds document-level signatures with post-quantum support. - -### Quick Start with A2A - -```python -# Python -from jacs.a2a import JACSA2AIntegration -a2a = JACSA2AIntegration("jacs.config.json") -agent_card = a2a.export_agent_card(agent_data) -wrapped = a2a.wrap_artifact_with_provenance(artifact, "task") +```bash +pip install jacs ``` -```javascript -// Node.js -const { JACSA2AIntegration } = require('jacsnpm/a2a'); -const a2a = new JACSA2AIntegration(); -const agentCard = a2a.exportAgentCard(agentData); -const wrapped = a2a.wrapArtifactWithProvenance(artifact, 'task'); -``` +```python +from jacs import simple -```rust -// Rust -use jacs::a2a::{agent_card::*, provenance::*}; -let agent_card = export_agent_card(&agent)?; -let wrapped = wrap_artifact_with_provenance(&mut agent, artifact, "task", None)?; -``` +# Load your agent +simple.load('./jacs.config.json') -JACS extends A2A with: -- **Document signatures** that persist with data (not just transport security) -- **Post-quantum cryptography** for future-proof security -- **Chain of custody** tracking for multi-agent workflows -- **Self-verifying artifacts** that work offline +# Sign any data +signed = simple.sign_message({'action': 'approve', 'amount': 100}) -See [jacs/src/a2a/README.md](./jacs/src/a2a/README.md) for full integration guide +# Verify signatures +result = simple.verify(signed.raw) +print(f"Valid: {result.valid}, Signer: {result.signer_id}") +``` +### Node.js -Example uses: +```bash +npm install @hai-ai/jacs +``` - 1. A document is sitting on a server. Where did it come from? Who has access to it? - 2. An MCP server gets a request from an unknown agent, the oauth flow doesn't guarantee the identity of the client or the server after the initial handshake. - 3. a document is modified by multiple human and AI collaborators. Which one is latest, correct version? +```javascript +const jacs = require('@hai-ai/jacs/simple'); -This repo includes JACS available in several languages: - - 1. the main [rust jacs lib](./jacs/) and cli to bootstrap an agent or documents - 2. [Python library](./jacspy/) for use as middleware in any http and with MCP - 3. [Node JS library](./jacsnpm) cli, middleware, and use with MCP - 4. [Go library](./jacsgo) for use in Go applications with CGO bindings +jacs.load('./jacs.config.json'); -## Python quickstart +const signed = jacs.signMessage({ action: 'approve', amount: 100 }); +const result = jacs.verify(signed.raw); +console.log(`Valid: ${result.valid}, Signer: ${result.signerId}`); +``` -Install with `pip install jacs` with example using [fastmcp](https://github.com/jlowin/fastmcp) +### Go -```python -# server -import jacs -from jacs.mcp import JACSMCPServer, JACSMCPClient -from mcp.server.fastmcp import FastMCP +```go +import jacs "github.com/HumanAssisted/JACS/jacsgo" -# client -# client = JACSMCPClient(server_url) +jacs.Load(nil) -# setup -jacs_config_path = "jacs.server.config.json" -# set the secret -# os.environ["JACS_PRIVATE_KEY_PASSWORD"] = "hello" -jacs.load(str(jacs_config_path)) +signed, _ := jacs.SignMessage(map[string]interface{}{"action": "approve"}) +result, _ := jacs.Verify(signed.Raw) +fmt.Printf("Valid: %t, Signer: %s\n", result.Valid, result.SignerID) +``` -mcp = JACSMCPServer(FastMCP("Authenticated Echo Server")) +### Rust / CLI -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b +```bash +cargo install jacs -if __name__ == "__main__": - mcp.run() +# Create an agent +jacs init +# Sign a document +jacs document create -f mydata.json ``` -## Node JS - -Install with `npm install jacsnpm` - -```js -const jacs = require('jacsnpm'); +## Core API (All Languages) -// Load configuration (set JACS_PRIVATE_KEY_PASSWORD env var first) -jacs.load('jacs.config.json'); +| Function | Description | +|----------|-------------| +| `load(config)` | Load agent from config file | +| `sign_message(data)` | Sign any JSON data | +| `sign_file(path, embed)` | Sign a file | +| `verify(document)` | Verify a signed document | +| `verify_self()` | Verify agent integrity | +| `get_public_key()` | Get public key for sharing | -// Sign a document -const doc = { content: 'Hello from Node.js!' }; -const signed = jacs.signRequest(doc); -console.log('Signed:', signed); +## MCP Integration -// Verify a document -const result = jacs.verifyResponse(signed); -console.log('Valid:', result); +JACS integrates with Model Context Protocol for authenticated tool calls: -// Hash content -const hash = jacs.hashString('data to hash'); -console.log('Hash:', hash); -``` +```python +from jacs.mcp import JACSMCPServer +from mcp.server.fastmcp import FastMCP -## Go +jacs.load("jacs.config.json") +mcp = JACSMCPServer(FastMCP("My Server")) -```go -package main - -import ( - "fmt" - "log" - jacs "github.com/HumanAssisted/JACS/jacsgo" -) - -func main() { - // Load JACS configuration - err := jacs.Load("jacs.config.json") - if err != nil { - log.Fatal(err) - } - - // Create and sign a document - doc := map[string]interface{}{ - "content": "Hello from Go!", - } - - signed, err := jacs.SignRequest(doc) - if err != nil { - log.Fatal(err) - } - - fmt.Println(signed) -} +@mcp.tool() +def my_tool(data: dict) -> dict: + return {"result": "signed automatically"} ``` -## Rust - -The core library is used in all other implementations. +## A2A Integration -`cargo install jacs` is useful for it's cli, but to develop `cargo add jacs` is all that's needed. +JACS provides cryptographic provenance for Google's A2A protocol: +```python +from jacs.a2a import JACSA2AIntegration -## Post-Quantum Cryptography (2025) - -JACS now supports the NIST-standardized post-quantum algorithms for quantum-resistant security: - -### Algorithms +a2a = JACSA2AIntegration("jacs.config.json") +agent_card = a2a.export_agent_card(agent_data) +wrapped = a2a.wrap_artifact_with_provenance(artifact, "task") +``` -- **ML-DSA (FIPS-204)**: Module-Lattice Digital Signature Algorithm using ML-DSA-87 (NIST Security Level 5) - - Post-quantum signatures with ~2592 byte public keys and ~4595 byte signatures - - Provides strong security against both classical and quantum computers - -- **ML-KEM (FIPS-203)**: Module-Lattice Key Encapsulation Mechanism using ML-KEM-768 (NIST Security Level 3) - - Post-quantum key encapsulation for secure communication - - Used with HKDF + AES-256-GCM for authenticated encryption +## Post-Quantum Cryptography -### Usage +JACS supports NIST-standardized post-quantum algorithms: -Set `jacs_agent_key_algorithm: "pq2025"` in your `jacs.config.json`: +- **ML-DSA (FIPS-204)**: Quantum-resistant signatures +- **ML-KEM (FIPS-203)**: Quantum-resistant key encapsulation ```json { - "jacs_agent_key_algorithm": "pq2025", - "jacs_agent_private_key_filename": "agent.private.pem.enc", - "jacs_agent_public_key_filename": "agent.public.pem", - "jacs_private_key_password": "your-secure-password" + "jacs_agent_key_algorithm": "pq2025" } ``` -Or use the CLI: - -```bash -jacs config create -# Select "pq2025" when prompted for algorithm - -jacs agent create --create-keys -# Keys will be generated using ML-DSA-87 -``` - -### Backward Compatibility - -The original `pq-dilithium` (Dilithium5) algorithm remains fully supported for backward compatibility. All existing signatures and keys continue to work. You can: - -- Verify old `pq-dilithium` signatures with new code -- Gradually migrate agents to `pq2025` -- Run mixed environments with both algorithms +## Repository Structure -### Migration Path +| Directory | Description | +|-----------|-------------| +| [jacs/](./jacs/) | Core Rust library and CLI | +| [jacspy/](./jacspy/) | Python bindings | +| [jacsnpm/](./jacsnpm/) | Node.js bindings | +| [jacsgo/](./jacsgo/) | Go bindings | -1. **New agents**: Use `pq2025` by default (it's the new default in CLI prompts) -2. **Existing agents**: Continue using current algorithm, or regenerate keys with `pq2025` -3. **Verification**: Both algorithms can verify each other's documents (if signed with respective keys) - -### Security Considerations - -- ML-DSA-87 provides the highest post-quantum security level standardized by NIST -- Keys are encrypted at rest using AES-256-GCM with password-derived keys -- ML-KEM-768 provides quantum-resistant key establishment for future encrypted communications -- Both algorithms are designed to be secure against Grover's and Shor's quantum algorithms +## Version +Current version: **0.4.4** ## License -The [license][./LICENSE] is a *modified* Apache 2.0, with the [Common Clause](https://commonsclause.com/) preamble. -In simple terms, unless you are directly competing with HAI.AI, you can create commercial products with JACS. -This licensing doesn't work, please reach out to hello@hai.io. - ------- -2024, 2025 https://hai.ai +[Apache 2.0 with Common Clause](./LICENSE) - Free for most commercial uses. Contact hello@hai.io for licensing questions. + +--- +2024, 2025, 2026 https://hai.ai diff --git a/binding-core/Cargo.toml b/binding-core/Cargo.toml new file mode 100644 index 000000000..1fa5bd4e7 --- /dev/null +++ b/binding-core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "jacs-binding-core" +version = "0.5.0" +edition = "2024" +rust-version = "1.85" +resolver = "3" +description = "Shared core logic for JACS language bindings (Python, Node.js, etc.)" +readme = "../README.md" +authors = ["HAI.AI "] +license-file = "../LICENSE" + +[features] +default = ["hai"] +hai = ["dep:reqwest", "dep:tokio", "dep:serde", "dep:futures-util"] + +[dependencies] +jacs = { path = "../jacs" } +serde_json = "1.0" +base64 = "0.22.1" + +# HAI client dependencies (optional) +reqwest = { version = "0.12", features = ["json", "stream"], optional = true } +tokio = { version = "1.0", features = ["rt", "sync", "time"], optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } +futures-util = { version = "0.3", optional = true } diff --git a/binding-core/src/conversion.rs b/binding-core/src/conversion.rs new file mode 100644 index 000000000..81a424c81 --- /dev/null +++ b/binding-core/src/conversion.rs @@ -0,0 +1,107 @@ +//! Common type conversion utilities for bindings. +//! +//! This module provides shared functionality for converting between +//! language-native types and serde_json::Value. Bindings use these +//! helpers along with their language-specific conversion code. + +use base64::{Engine as _, engine::general_purpose}; +use serde_json::{Map as JsonMap, Value}; + +/// Marker key for specially encoded types in JSON objects. +pub const TYPE_MARKER_KEY: &str = "__type__"; +/// Data key for specially encoded types in JSON objects. +pub const DATA_KEY: &str = "data"; +/// Type marker for bytes/buffer data. +pub const TYPE_BYTES: &str = "bytes"; +/// Type marker for Node.js Buffer data. +pub const TYPE_BUFFER: &str = "buffer"; + +/// Check if a JSON object represents specially encoded bytes. +/// +/// Returns the decoded bytes if the object has the correct structure, +/// or None if it's a regular object. +pub fn try_decode_bytes_object(obj: &serde_json::Map) -> Option> { + if let (Some(Value::String(type_str)), Some(Value::String(data))) = + (obj.get(TYPE_MARKER_KEY), obj.get(DATA_KEY)) + { + if type_str == TYPE_BYTES || type_str == TYPE_BUFFER { + return general_purpose::STANDARD.decode(data).ok(); + } + } + None +} + +/// Encode bytes as a JSON object with type marker. +/// +/// This creates a portable representation that can be decoded on any platform. +pub fn encode_bytes_as_json(bytes: &[u8], type_marker: &str) -> Value { + let base64_str = general_purpose::STANDARD.encode(bytes); + let mut map = JsonMap::new(); + map.insert(TYPE_MARKER_KEY.to_string(), Value::String(type_marker.to_string())); + map.insert(DATA_KEY.to_string(), Value::String(base64_str)); + Value::Object(map) +} + +/// Encode bytes using the Python-style marker ("bytes"). +pub fn encode_bytes_python(bytes: &[u8]) -> Value { + encode_bytes_as_json(bytes, TYPE_BYTES) +} + +/// Encode bytes using the Node.js-style marker ("buffer"). +pub fn encode_bytes_nodejs(bytes: &[u8]) -> Value { + encode_bytes_as_json(bytes, TYPE_BUFFER) +} + +/// Base64 encode bytes to a string. +pub fn bytes_to_base64(bytes: &[u8]) -> String { + general_purpose::STANDARD.encode(bytes) +} + +/// Base64 decode a string to bytes. +pub fn base64_to_bytes(encoded: &str) -> Result, base64::DecodeError> { + general_purpose::STANDARD.decode(encoded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_decode_bytes_roundtrip() { + let original = vec![1u8, 2, 3, 4, 5, 255, 0, 128]; + + // Test Python-style encoding + let encoded = encode_bytes_python(&original); + if let Value::Object(obj) = &encoded { + let decoded = try_decode_bytes_object(obj).expect("Should decode"); + assert_eq!(decoded, original); + } else { + panic!("Expected object"); + } + + // Test Node.js-style encoding + let encoded_node = encode_bytes_nodejs(&original); + if let Value::Object(obj) = &encoded_node { + let decoded = try_decode_bytes_object(obj).expect("Should decode"); + assert_eq!(decoded, original); + } else { + panic!("Expected object"); + } + } + + #[test] + fn test_regular_object_not_decoded() { + let mut obj = JsonMap::new(); + obj.insert("key".to_string(), Value::String("value".to_string())); + + assert!(try_decode_bytes_object(&obj).is_none()); + } + + #[test] + fn test_base64_roundtrip() { + let original = b"Hello, World!"; + let encoded = bytes_to_base64(original); + let decoded = base64_to_bytes(&encoded).expect("Should decode"); + assert_eq!(decoded, original); + } +} diff --git a/binding-core/src/hai.rs b/binding-core/src/hai.rs new file mode 100644 index 000000000..1c3f30928 --- /dev/null +++ b/binding-core/src/hai.rs @@ -0,0 +1,1218 @@ +//! HAI client for interacting with HAI.ai +//! +//! This module provides a complete, clean API for connecting to HAI services: +//! +//! ## Construction +//! - `HaiClient::new()` - create client with endpoint URL +//! - `with_api_key()` - set API key for authentication +//! +//! ## Core Methods +//! - `testconnection()` - verify connectivity to the HAI server +//! - `register()` - register a JACS agent with HAI +//! - `status()` - check registration status of an agent +//! - `benchmark()` - run a benchmark suite on an agent +//! +//! ## SSE Streaming +//! - `connect()` / `disconnect()` - SSE event streaming for real-time updates +//! - `is_connected()` / `connection_state()` - check connection status +//! +//! # Example +//! +//! ```rust,ignore +//! use jacs_binding_core::hai::{HaiClient, HaiError, HaiEvent}; +//! use jacs_binding_core::AgentWrapper; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), HaiError> { +//! let client = HaiClient::new("https://api.hai.ai") +//! .with_api_key("your-api-key"); +//! +//! // Test connectivity +//! if client.testconnection().await? { +//! println!("Connected to HAI"); +//! } +//! +//! // Register an agent +//! let agent = AgentWrapper::new(); +//! agent.load("/path/to/config.json".to_string()).unwrap(); +//! let result = client.register(&agent).await?; +//! println!("Registered: {}", result.agent_id); +//! +//! // Connect to SSE stream and handle events +//! let mut receiver = client.connect().await?; +//! while let Some(event) = receiver.recv().await { +//! match event { +//! HaiEvent::BenchmarkJob(job) => println!("Job: {}", job.job_id), +//! HaiEvent::Heartbeat(hb) => println!("Heartbeat: {}", hb.timestamp), +//! HaiEvent::Unknown { event, data } => println!("Unknown: {} - {}", event, data), +//! } +//! } +//! +//! client.disconnect().await; +//! Ok(()) +//! } +//! ``` + +use crate::AgentWrapper; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{mpsc, RwLock}; + +// ============================================================================= +// Error Types +// ============================================================================= + +/// Errors that can occur when interacting with HAI services. +#[derive(Debug)] +pub enum HaiError { + /// Failed to connect to the HAI server. + ConnectionFailed(String), + /// Agent registration failed. + RegistrationFailed(String), + /// Authentication is required but not provided. + AuthRequired, + /// Invalid response from server. + InvalidResponse(String), + /// SSE stream disconnected. + StreamDisconnected(String), + /// Already connected to SSE stream. + AlreadyConnected, + /// Not connected to SSE stream. + NotConnected, +} + +impl fmt::Display for HaiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HaiError::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg), + HaiError::RegistrationFailed(msg) => write!(f, "Registration failed: {}", msg), + HaiError::AuthRequired => write!(f, "Authentication required: provide an API key"), + HaiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg), + HaiError::StreamDisconnected(msg) => write!(f, "SSE stream disconnected: {}", msg), + HaiError::AlreadyConnected => write!(f, "Already connected to SSE stream"), + HaiError::NotConnected => write!(f, "Not connected to SSE stream"), + } + } +} + +impl std::error::Error for HaiError {} + +// ============================================================================= +// SSE Event Types +// ============================================================================= + +/// A benchmark job received from the HAI event stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkJob { + /// Unique identifier for the job. + pub job_id: String, + /// The benchmark scenario to run. + pub scenario: String, + /// Optional additional parameters for the job. + #[serde(default)] + pub params: serde_json::Value, +} + +/// A heartbeat event from the HAI event stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Heartbeat { + /// ISO 8601 timestamp of the heartbeat. + pub timestamp: String, +} + +/// Events received from the HAI SSE stream. +#[derive(Debug, Clone)] +pub enum HaiEvent { + /// A new benchmark job to execute. + BenchmarkJob(BenchmarkJob), + /// Heartbeat to confirm connection is alive. + Heartbeat(Heartbeat), + /// Unknown event type (forward compatibility). + Unknown { + /// The event type name. + event: String, + /// The raw JSON data. + data: String, + }, +} + +// ============================================================================= +// Response Types +// ============================================================================= + +/// Signature information returned from HAI registration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HaiSignature { + /// Key identifier used for signing. + pub key_id: String, + /// Algorithm used (e.g., "Ed25519", "ECDSA-P256"). + pub algorithm: String, + /// Base64-encoded signature. + pub signature: String, + /// ISO 8601 timestamp of when the signature was created. + pub signed_at: String, +} + +/// Result of a successful agent registration with HAI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistrationResult { + /// The agent's unique identifier. + pub agent_id: String, + /// The JACS document ID assigned by HAI. + pub jacs_id: String, + /// Whether DNS verification was successful. + pub dns_verified: bool, + /// Signatures from HAI attesting to the registration. + pub signatures: Vec, +} + +/// Result of checking agent registration status with HAI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusResult { + /// Whether the agent is registered with HAI.ai. + pub registered: bool, + /// The agent's JACS ID (if registered). + #[serde(default)] + pub agent_id: String, + /// HAI.ai registration ID (if registered). + #[serde(default)] + pub registration_id: String, + /// When the agent was registered (if registered), as ISO 8601 timestamp. + #[serde(default)] + pub registered_at: String, + /// List of HAI signature IDs (if registered). + #[serde(default)] + pub hai_signatures: Vec, +} + +/// Result of a benchmark run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkResult { + /// Unique identifier for the benchmark run. + pub run_id: String, + /// The benchmark suite that was run. + pub suite: String, + /// Overall score (0.0 to 1.0). + pub score: f64, + /// Individual test results within the suite. + #[serde(default)] + pub results: Vec, + /// ISO 8601 timestamp of when the benchmark completed. + pub completed_at: String, +} + +/// Individual test result within a benchmark run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkTestResult { + /// Test name. + pub name: String, + /// Whether the test passed. + pub passed: bool, + /// Test score (0.0 to 1.0). + pub score: f64, + /// Optional message (e.g., error details). + #[serde(default)] + pub message: String, +} + +// ============================================================================= +// Internal Request/Response Types +// ============================================================================= + +#[derive(Serialize)] +struct RegisterRequest { + agent_json: String, +} + +#[derive(Serialize)] +struct BenchmarkRequest { + agent_id: String, + suite: String, +} + +#[derive(Deserialize)] +struct HealthResponse { + status: String, +} + +// ============================================================================= +// HAI Client +// ============================================================================= + +/// Handle to control an active SSE connection. +/// +/// Drop this handle or call `abort()` to stop the SSE stream. +#[derive(Clone)] +pub struct SseHandle { + shutdown_tx: mpsc::Sender<()>, +} + +impl SseHandle { + /// Signal the SSE stream to disconnect. + pub async fn abort(&self) { + let _ = self.shutdown_tx.send(()).await; + } +} + +/// SSE connection state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + /// Not connected to SSE stream. + Disconnected, + /// Attempting to connect. + Connecting, + /// Connected and receiving events. + Connected, + /// Reconnecting after a disconnect. + Reconnecting, +} + +/// Client for interacting with HAI.ai services. +/// +/// Use the builder pattern to configure the client: +/// ```rust,ignore +/// let client = HaiClient::new("https://api.hai.ai") +/// .with_api_key("your-key"); +/// ``` +pub struct HaiClient { + endpoint: String, + api_key: Option, + client: reqwest::Client, + /// Current SSE connection state. + connection_state: Arc>, + /// Handle to shutdown the SSE stream. + sse_handle: Arc>>, + /// Maximum reconnection attempts (0 = infinite). + max_reconnect_attempts: u32, + /// Base delay between reconnection attempts. + reconnect_delay: Duration, +} + +impl HaiClient { + /// Create a new HAI client targeting the specified endpoint. + /// + /// # Arguments + /// + /// * `endpoint` - Base URL of the HAI API (e.g., "https://api.hai.ai") + pub fn new(endpoint: &str) -> Self { + Self { + endpoint: endpoint.trim_end_matches('/').to_string(), + api_key: None, + client: reqwest::Client::new(), + connection_state: Arc::new(RwLock::new(ConnectionState::Disconnected)), + sse_handle: Arc::new(RwLock::new(None)), + max_reconnect_attempts: 0, // Infinite by default + reconnect_delay: Duration::from_secs(1), + } + } + + /// Set the API key for authentication. + /// + /// This is required for most operations. + pub fn with_api_key(mut self, api_key: &str) -> Self { + self.api_key = Some(api_key.to_string()); + self + } + + /// Set the maximum number of reconnection attempts. + /// + /// Set to 0 for infinite retries (default). + pub fn with_max_reconnect_attempts(mut self, attempts: u32) -> Self { + self.max_reconnect_attempts = attempts; + self + } + + /// Set the base delay between reconnection attempts. + /// + /// Default is 1 second. Uses exponential backoff up to 30 seconds. + pub fn with_reconnect_delay(mut self, delay: Duration) -> Self { + self.reconnect_delay = delay; + self + } + + /// Get the endpoint URL. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// Get the current SSE connection state. + pub async fn connection_state(&self) -> ConnectionState { + *self.connection_state.read().await + } + + /// Test connectivity to the HAI server. + /// + /// Returns `Ok(true)` if the server is reachable and healthy. + /// + /// # Errors + /// + /// Returns `HaiError::ConnectionFailed` if the server cannot be reached + /// or returns an unhealthy status. + pub async fn testconnection(&self) -> Result { + let url = format!("{}/health", self.endpoint); + + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| HaiError::ConnectionFailed(e.to_string()))?; + + if !response.status().is_success() { + return Err(HaiError::ConnectionFailed(format!( + "Server returned status: {}", + response.status() + ))); + } + + // Try to parse health response, but accept any 2xx as success + match response.json::().await { + Ok(health) => Ok(health.status == "ok" || health.status == "healthy"), + Err(_) => Ok(true), // 2xx without JSON body is still success + } + } + + /// Register a JACS agent with HAI. + /// + /// The agent must be loaded and have valid keys before registration. + /// + /// # Arguments + /// + /// * `agent` - A loaded `AgentWrapper` with valid cryptographic keys + /// + /// # Errors + /// + /// - `HaiError::AuthRequired` - No API key was provided + /// - `HaiError::RegistrationFailed` - The agent could not be registered + /// - `HaiError::InvalidResponse` - The server returned an unexpected response + pub async fn register(&self, agent: &AgentWrapper) -> Result { + let api_key = self + .api_key + .as_ref() + .ok_or(HaiError::AuthRequired)?; + + // Get the agent JSON from the wrapper + let agent_json = agent + .get_agent_json() + .map_err(|e| HaiError::RegistrationFailed(e.to_string()))?; + + let url = format!("{}/v1/agents/register", self.endpoint); + + let request = RegisterRequest { agent_json }; + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| HaiError::ConnectionFailed(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "No response body".to_string()); + return Err(HaiError::RegistrationFailed(format!( + "Status {}: {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| HaiError::InvalidResponse(e.to_string())) + } + + /// Check registration status of an agent with HAI. + /// + /// Queries the HAI API to determine if the agent is registered + /// and retrieves registration details if so. + /// + /// # Arguments + /// + /// * `agent` - A loaded `AgentWrapper` to check status for + /// + /// # Returns + /// + /// `StatusResult` with registration details. If the agent is not registered, + /// `registered` will be `false`. + /// + /// # Errors + /// + /// - `HaiError::AuthRequired` - No API key was provided + /// - `HaiError::ConnectionFailed` - Could not connect to HAI server + /// - `HaiError::InvalidResponse` - The server returned an unexpected response + pub async fn status(&self, agent: &AgentWrapper) -> Result { + let api_key = self + .api_key + .as_ref() + .ok_or(HaiError::AuthRequired)?; + + // Get the agent JSON and extract the ID + let agent_json = agent + .get_agent_json() + .map_err(|e| HaiError::InvalidResponse(format!("Failed to get agent JSON: {}", e)))?; + + let agent_value: serde_json::Value = serde_json::from_str(&agent_json) + .map_err(|e| HaiError::InvalidResponse(format!("Failed to parse agent JSON: {}", e)))?; + + let agent_id = agent_value + .get("jacsId") + .and_then(|v| v.as_str()) + .ok_or_else(|| HaiError::InvalidResponse("Agent JSON missing jacsId field".to_string()))? + .to_string(); + + let url = format!("{}/v1/agents/{}/status", self.endpoint, agent_id); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await + .map_err(|e| HaiError::ConnectionFailed(e.to_string()))?; + + // Handle 404 as "not registered" + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(StatusResult { + registered: false, + agent_id, + registration_id: String::new(), + registered_at: String::new(), + hai_signatures: Vec::new(), + }); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "No response body".to_string()); + return Err(HaiError::InvalidResponse(format!( + "Status {}: {}", + status, body + ))); + } + + response + .json::() + .await + .map(|mut result| { + // Ensure registered is true for successful responses + result.registered = true; + if result.agent_id.is_empty() { + result.agent_id = agent_id; + } + result + }) + .map_err(|e| HaiError::InvalidResponse(e.to_string())) + } + + /// Run a benchmark suite for an agent. + /// + /// Submits the agent to run a specific benchmark suite and waits for results. + /// + /// # Arguments + /// + /// * `agent` - A loaded `AgentWrapper` to benchmark + /// * `suite` - The benchmark suite name (e.g., "latency", "accuracy", "safety") + /// + /// # Returns + /// + /// `BenchmarkResult` with the benchmark run details and scores. + /// + /// # Errors + /// + /// - `HaiError::AuthRequired` - No API key was provided + /// - `HaiError::ConnectionFailed` - Could not connect to HAI server + /// - `HaiError::InvalidResponse` - The server returned an unexpected response + pub async fn benchmark( + &self, + agent: &AgentWrapper, + suite: &str, + ) -> Result { + let api_key = self.api_key.as_ref().ok_or(HaiError::AuthRequired)?; + + // Get the agent ID from the wrapper + let agent_json = agent + .get_agent_json() + .map_err(|e| HaiError::InvalidResponse(format!("Failed to get agent JSON: {}", e)))?; + + let agent_value: serde_json::Value = serde_json::from_str(&agent_json) + .map_err(|e| HaiError::InvalidResponse(format!("Failed to parse agent JSON: {}", e)))?; + + let agent_id = agent_value + .get("jacsId") + .and_then(|v| v.as_str()) + .ok_or_else(|| HaiError::InvalidResponse("Agent JSON missing jacsId field".to_string()))? + .to_string(); + + let url = format!("{}/v1/benchmarks/run", self.endpoint); + + let request = BenchmarkRequest { + agent_id, + suite: suite.to_string(), + }; + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| HaiError::ConnectionFailed(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "No response body".to_string()); + return Err(HaiError::InvalidResponse(format!( + "Status {}: {}", + status, body + ))); + } + + response + .json::() + .await + .map_err(|e| HaiError::InvalidResponse(e.to_string())) + } + + // ========================================================================= + // SSE Connection Methods + // ========================================================================= + + /// Connect to the HAI SSE event stream. + /// + /// Returns a channel receiver that yields `HaiEvent`s as they arrive. + /// The connection will automatically attempt to reconnect on disconnection. + /// + /// # Errors + /// + /// - `HaiError::AuthRequired` - No API key was provided + /// - `HaiError::AlreadyConnected` - Already connected to SSE stream + /// - `HaiError::ConnectionFailed` - Initial connection failed + /// + /// # Example + /// + /// ```rust,ignore + /// let mut receiver = client.connect().await?; + /// while let Some(event) = receiver.recv().await { + /// println!("Received: {:?}", event); + /// } + /// ``` + pub async fn connect(&self) -> Result, HaiError> { + self.connect_to_url(&format!("{}/api/v1/agents/events", self.endpoint)) + .await + } + + /// Connect to a custom SSE endpoint URL. + /// + /// This is useful for testing or connecting to alternative event streams. + pub async fn connect_to_url(&self, url: &str) -> Result, HaiError> { + let api_key = self.api_key.as_ref().ok_or(HaiError::AuthRequired)?; + + // Check if already connected + { + let state = self.connection_state.read().await; + if *state != ConnectionState::Disconnected { + return Err(HaiError::AlreadyConnected); + } + } + + // Update state to connecting + { + let mut state = self.connection_state.write().await; + *state = ConnectionState::Connecting; + } + + // Create channels + let (event_tx, event_rx) = mpsc::channel::(100); + let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); + + // Store the handle + { + let mut handle = self.sse_handle.write().await; + *handle = Some(SseHandle { + shutdown_tx: shutdown_tx.clone(), + }); + } + + // Clone values for the spawned task + let client = self.client.clone(); + let url = url.to_string(); + let api_key = api_key.clone(); + let connection_state = self.connection_state.clone(); + let max_attempts = self.max_reconnect_attempts; + let base_delay = self.reconnect_delay; + + // Spawn the SSE reader task + tokio::spawn(async move { + let mut reconnect_attempts = 0u32; + + 'reconnect: loop { + // Attempt connection + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .send() + .await; + + let response = match response { + Ok(r) if r.status().is_success() => { + // Reset reconnect attempts on successful connection + reconnect_attempts = 0; + { + let mut state = connection_state.write().await; + *state = ConnectionState::Connected; + } + r + } + Ok(r) => { + // Non-success status + let status = r.status(); + eprintln!("SSE connection failed with status: {}", status); + + if should_reconnect(max_attempts, reconnect_attempts) { + reconnect_attempts += 1; + let delay = calculate_backoff(base_delay, reconnect_attempts); + { + let mut state = connection_state.write().await; + *state = ConnectionState::Reconnecting; + } + + tokio::select! { + _ = tokio::time::sleep(delay) => continue 'reconnect, + _ = shutdown_rx.recv() => break 'reconnect, + } + } else { + break 'reconnect; + } + } + Err(e) => { + eprintln!("SSE connection error: {}", e); + + if should_reconnect(max_attempts, reconnect_attempts) { + reconnect_attempts += 1; + let delay = calculate_backoff(base_delay, reconnect_attempts); + { + let mut state = connection_state.write().await; + *state = ConnectionState::Reconnecting; + } + + tokio::select! { + _ = tokio::time::sleep(delay) => continue 'reconnect, + _ = shutdown_rx.recv() => break 'reconnect, + } + } else { + break 'reconnect; + } + } + }; + + // Process the SSE stream + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + let mut current_event = String::new(); + let mut current_data = String::new(); + + loop { + tokio::select! { + chunk = stream.next() => { + match chunk { + Some(Ok(bytes)) => { + buffer.push_str(&String::from_utf8_lossy(&bytes)); + + // Process complete lines + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].trim_end_matches('\r').to_string(); + buffer = buffer[newline_pos + 1..].to_string(); + + if line.is_empty() { + // Empty line = end of event + if !current_data.is_empty() { + let event = parse_sse_event(¤t_event, ¤t_data); + if event_tx.send(event).await.is_err() { + // Receiver dropped, exit + break 'reconnect; + } + } + current_event.clear(); + current_data.clear(); + } else if let Some(value) = line.strip_prefix("event:") { + current_event = value.trim().to_string(); + } else if let Some(value) = line.strip_prefix("data:") { + if !current_data.is_empty() { + current_data.push('\n'); + } + current_data.push_str(value.trim()); + } + // Ignore id: and retry: fields for simplicity + } + } + Some(Err(e)) => { + eprintln!("SSE stream error: {}", e); + break; // Break inner loop to attempt reconnect + } + None => { + // Stream ended + break; // Break inner loop to attempt reconnect + } + } + } + _ = shutdown_rx.recv() => { + break 'reconnect; + } + } + } + + // Stream ended, attempt reconnect + if should_reconnect(max_attempts, reconnect_attempts) { + reconnect_attempts += 1; + let delay = calculate_backoff(base_delay, reconnect_attempts); + { + let mut state = connection_state.write().await; + *state = ConnectionState::Reconnecting; + } + + tokio::select! { + _ = tokio::time::sleep(delay) => continue 'reconnect, + _ = shutdown_rx.recv() => break 'reconnect, + } + } else { + break 'reconnect; + } + } + + // Clean up + { + let mut state = connection_state.write().await; + *state = ConnectionState::Disconnected; + } + }); + + Ok(event_rx) + } + + /// Disconnect from the SSE event stream. + /// + /// This is a no-op if not connected. + pub async fn disconnect(&self) { + let handle = { + let mut guard = self.sse_handle.write().await; + guard.take() + }; + + if let Some(h) = handle { + h.abort().await; + } + + // Wait for state to become disconnected + loop { + let state = *self.connection_state.read().await; + if state == ConnectionState::Disconnected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + /// Check if currently connected to the SSE stream. + pub async fn is_connected(&self) -> bool { + let state = *self.connection_state.read().await; + matches!(state, ConnectionState::Connected | ConnectionState::Reconnecting) + } +} + +// ============================================================================= +// SSE Helper Functions +// ============================================================================= + +/// Parse an SSE event into a `HaiEvent`. +fn parse_sse_event(event_type: &str, data: &str) -> HaiEvent { + match event_type { + "benchmark_job" => { + match serde_json::from_str::(data) { + Ok(job) => HaiEvent::BenchmarkJob(job), + Err(_) => HaiEvent::Unknown { + event: event_type.to_string(), + data: data.to_string(), + }, + } + } + "heartbeat" => { + match serde_json::from_str::(data) { + Ok(hb) => HaiEvent::Heartbeat(hb), + Err(_) => HaiEvent::Unknown { + event: event_type.to_string(), + data: data.to_string(), + }, + } + } + _ => HaiEvent::Unknown { + event: if event_type.is_empty() { "message".to_string() } else { event_type.to_string() }, + data: data.to_string(), + }, + } +} + +/// Determine if reconnection should be attempted. +fn should_reconnect(max_attempts: u32, current_attempts: u32) -> bool { + max_attempts == 0 || current_attempts < max_attempts +} + +/// Calculate exponential backoff delay. +fn calculate_backoff(base: Duration, attempt: u32) -> Duration { + let multiplier = 2u64.saturating_pow(attempt.min(5)); // Cap at 2^5 = 32x + let delay = base.saturating_mul(multiplier as u32); + delay.min(Duration::from_secs(30)) // Cap at 30 seconds +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_builder() { + let client = HaiClient::new("https://api.hai.ai") + .with_api_key("test-key"); + + assert_eq!(client.endpoint, "https://api.hai.ai"); + assert_eq!(client.api_key, Some("test-key".to_string())); + } + + #[test] + fn test_endpoint_normalization() { + let client = HaiClient::new("https://api.hai.ai/"); + assert_eq!(client.endpoint, "https://api.hai.ai"); + } + + #[test] + fn test_error_display() { + let err = HaiError::ConnectionFailed("timeout".to_string()); + assert_eq!(format!("{}", err), "Connection failed: timeout"); + + let err = HaiError::AuthRequired; + assert_eq!( + format!("{}", err), + "Authentication required: provide an API key" + ); + } + + #[test] + fn test_registration_result_serialization() { + let result = RegistrationResult { + agent_id: "agent-123".to_string(), + jacs_id: "jacs-456".to_string(), + dns_verified: true, + signatures: vec![HaiSignature { + key_id: "key-1".to_string(), + algorithm: "Ed25519".to_string(), + signature: "c2lnbmF0dXJl".to_string(), + signed_at: "2024-01-15T10:30:00Z".to_string(), + }], + }; + + let json = serde_json::to_string(&result).unwrap(); + let parsed: RegistrationResult = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.agent_id, "agent-123"); + assert_eq!(parsed.signatures.len(), 1); + } + + #[test] + fn test_status_result_serialization() { + let result = StatusResult { + registered: true, + agent_id: "agent-123".to_string(), + registration_id: "reg-456".to_string(), + registered_at: "2024-01-15T10:30:00Z".to_string(), + hai_signatures: vec!["sig-1".to_string(), "sig-2".to_string()], + }; + + let json = serde_json::to_string(&result).unwrap(); + let parsed: StatusResult = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.registered, true); + assert_eq!(parsed.agent_id, "agent-123"); + assert_eq!(parsed.registration_id, "reg-456"); + assert_eq!(parsed.hai_signatures.len(), 2); + } + + #[test] + fn test_status_result_not_registered() { + let result = StatusResult { + registered: false, + agent_id: "agent-123".to_string(), + registration_id: String::new(), + registered_at: String::new(), + hai_signatures: Vec::new(), + }; + + let json = serde_json::to_string(&result).unwrap(); + let parsed: StatusResult = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.registered, false); + assert_eq!(parsed.agent_id, "agent-123"); + assert!(parsed.registration_id.is_empty()); + } + + // ========================================================================= + // SSE Tests + // ========================================================================= + + #[test] + fn test_parse_sse_event_benchmark_job() { + let data = r#"{"job_id": "job-123", "scenario": "latency-test"}"#; + let event = parse_sse_event("benchmark_job", data); + + match event { + HaiEvent::BenchmarkJob(job) => { + assert_eq!(job.job_id, "job-123"); + assert_eq!(job.scenario, "latency-test"); + } + _ => panic!("Expected BenchmarkJob event"), + } + } + + #[test] + fn test_parse_sse_event_heartbeat() { + let data = r#"{"timestamp": "2024-01-15T10:30:00Z"}"#; + let event = parse_sse_event("heartbeat", data); + + match event { + HaiEvent::Heartbeat(hb) => { + assert_eq!(hb.timestamp, "2024-01-15T10:30:00Z"); + } + _ => panic!("Expected Heartbeat event"), + } + } + + #[test] + fn test_parse_sse_event_unknown() { + let data = r#"{"custom": "data"}"#; + let event = parse_sse_event("custom_event", data); + + match event { + HaiEvent::Unknown { event, data: d } => { + assert_eq!(event, "custom_event"); + assert_eq!(d, r#"{"custom": "data"}"#); + } + _ => panic!("Expected Unknown event"), + } + } + + #[test] + fn test_parse_sse_event_empty_type_defaults_to_message() { + let data = r#"{"some": "data"}"#; + let event = parse_sse_event("", data); + + match event { + HaiEvent::Unknown { event, .. } => { + assert_eq!(event, "message"); + } + _ => panic!("Expected Unknown event with 'message' type"), + } + } + + #[test] + fn test_parse_sse_event_invalid_json_becomes_unknown() { + let data = "not valid json"; + let event = parse_sse_event("benchmark_job", data); + + match event { + HaiEvent::Unknown { event, data: d } => { + assert_eq!(event, "benchmark_job"); + assert_eq!(d, "not valid json"); + } + _ => panic!("Expected Unknown event due to invalid JSON"), + } + } + + #[test] + fn test_should_reconnect_infinite() { + // max_attempts = 0 means infinite retries + assert!(should_reconnect(0, 0)); + assert!(should_reconnect(0, 100)); + assert!(should_reconnect(0, u32::MAX - 1)); + } + + #[test] + fn test_should_reconnect_limited() { + assert!(should_reconnect(3, 0)); + assert!(should_reconnect(3, 1)); + assert!(should_reconnect(3, 2)); + assert!(!should_reconnect(3, 3)); + assert!(!should_reconnect(3, 4)); + } + + #[test] + fn test_calculate_backoff() { + let base = Duration::from_secs(1); + + // First attempt: 1 * 2^1 = 2 seconds + assert_eq!(calculate_backoff(base, 1), Duration::from_secs(2)); + + // Second attempt: 1 * 2^2 = 4 seconds + assert_eq!(calculate_backoff(base, 2), Duration::from_secs(4)); + + // Third attempt: 1 * 2^3 = 8 seconds + assert_eq!(calculate_backoff(base, 3), Duration::from_secs(8)); + + // High attempts should cap at 30 seconds + assert_eq!(calculate_backoff(base, 10), Duration::from_secs(30)); + assert_eq!(calculate_backoff(base, 100), Duration::from_secs(30)); + } + + #[test] + fn test_benchmark_job_serialization() { + let job = BenchmarkJob { + job_id: "job-123".to_string(), + scenario: "latency".to_string(), + params: serde_json::json!({"timeout": 30}), + }; + + let json = serde_json::to_string(&job).unwrap(); + let parsed: BenchmarkJob = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.job_id, "job-123"); + assert_eq!(parsed.scenario, "latency"); + assert_eq!(parsed.params["timeout"], 30); + } + + #[test] + fn test_benchmark_result_serialization() { + let result = BenchmarkResult { + run_id: "run-123".to_string(), + suite: "accuracy".to_string(), + score: 0.95, + results: vec![ + BenchmarkTestResult { + name: "test-1".to_string(), + passed: true, + score: 1.0, + message: String::new(), + }, + BenchmarkTestResult { + name: "test-2".to_string(), + passed: false, + score: 0.9, + message: "Minor deviation".to_string(), + }, + ], + completed_at: "2024-01-15T10:30:00Z".to_string(), + }; + + let json = serde_json::to_string(&result).unwrap(); + let parsed: BenchmarkResult = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.run_id, "run-123"); + assert_eq!(parsed.suite, "accuracy"); + assert!((parsed.score - 0.95).abs() < f64::EPSILON); + assert_eq!(parsed.results.len(), 2); + assert_eq!(parsed.results[0].name, "test-1"); + assert!(parsed.results[0].passed); + assert!(!parsed.results[1].passed); + assert_eq!(parsed.results[1].message, "Minor deviation"); + } + + #[test] + fn test_heartbeat_serialization() { + let hb = Heartbeat { + timestamp: "2024-01-15T10:30:00Z".to_string(), + }; + + let json = serde_json::to_string(&hb).unwrap(); + let parsed: Heartbeat = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.timestamp, "2024-01-15T10:30:00Z"); + } + + #[test] + fn test_sse_error_display() { + let err = HaiError::StreamDisconnected("timeout".to_string()); + assert_eq!(format!("{}", err), "SSE stream disconnected: timeout"); + + let err = HaiError::AlreadyConnected; + assert_eq!(format!("{}", err), "Already connected to SSE stream"); + + let err = HaiError::NotConnected; + assert_eq!(format!("{}", err), "Not connected to SSE stream"); + } + + #[test] + fn test_connection_state_default() { + let client = HaiClient::new("https://api.hai.ai"); + + // Can't test async state easily in sync test, but we can verify + // the client was created with default settings + assert_eq!(client.max_reconnect_attempts, 0); + assert_eq!(client.reconnect_delay, Duration::from_secs(1)); + } + + #[test] + fn test_client_builder_with_sse_options() { + let client = HaiClient::new("https://api.hai.ai") + .with_api_key("test-key") + .with_max_reconnect_attempts(5) + .with_reconnect_delay(Duration::from_millis(500)); + + assert_eq!(client.max_reconnect_attempts, 5); + assert_eq!(client.reconnect_delay, Duration::from_millis(500)); + } + + #[tokio::test] + async fn test_connect_requires_api_key() { + let client = HaiClient::new("https://api.hai.ai"); + // No API key set + + let result = client.connect().await; + assert!(matches!(result, Err(HaiError::AuthRequired))); + } + + #[tokio::test] + async fn test_connection_state_starts_disconnected() { + let client = HaiClient::new("https://api.hai.ai") + .with_api_key("test-key"); + + let state = client.connection_state().await; + assert_eq!(state, ConnectionState::Disconnected); + } + + #[tokio::test] + async fn test_is_connected_when_disconnected() { + let client = HaiClient::new("https://api.hai.ai") + .with_api_key("test-key"); + + assert!(!client.is_connected().await); + } + + #[tokio::test] + async fn test_disconnect_when_not_connected() { + let client = HaiClient::new("https://api.hai.ai") + .with_api_key("test-key"); + + // Should be a no-op, not panic + client.disconnect().await; + assert_eq!(client.connection_state().await, ConnectionState::Disconnected); + } +} diff --git a/binding-core/src/lib.rs b/binding-core/src/lib.rs new file mode 100644 index 000000000..7f08644a8 --- /dev/null +++ b/binding-core/src/lib.rs @@ -0,0 +1,615 @@ +//! # jacs-binding-core +//! +//! Shared core logic for JACS language bindings (Python, Node.js, etc.). +//! +//! This crate provides the binding-agnostic business logic that can be used +//! by any language binding. Each binding implements the `BindingError` trait +//! to convert errors to their native format. + +use jacs::agent::document::DocumentTraits; +use jacs::agent::payloads::PayloadTraits; +use jacs::agent::{Agent, AGENT_REGISTRATION_SIGNATURE_FIELDNAME, AGENT_SIGNATURE_FIELDNAME}; +use jacs::config::Config; +use jacs::crypt::hash::hash_string as jacs_hash_string; +use jacs::crypt::KeyManager; +use serde_json::Value; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +pub mod conversion; + +#[cfg(feature = "hai")] +pub mod hai; + +/// Error type for binding core operations. +/// +/// This is the internal error type that binding implementations convert +/// to their native error types (PyErr, napi::Error, etc.). +#[derive(Debug)] +pub struct BindingCoreError { + pub message: String, + pub kind: ErrorKind, +} + +/// Categories of errors for better handling by bindings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// Failed to acquire a mutex lock + LockFailed, + /// Agent loading or configuration failed + AgentLoad, + /// Validation failed (agent or document) + Validation, + /// Signature operation failed + SigningFailed, + /// Verification operation failed + VerificationFailed, + /// Document operation failed + DocumentFailed, + /// Agreement operation failed + AgreementFailed, + /// Serialization/deserialization failed + SerializationFailed, + /// Invalid argument provided + InvalidArgument, + /// Trust store operation failed + TrustFailed, + /// Generic failure + Generic, +} + +impl BindingCoreError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + message: message.into(), + kind, + } + } + + pub fn lock_failed(message: impl Into) -> Self { + Self::new(ErrorKind::LockFailed, message) + } + + pub fn agent_load(message: impl Into) -> Self { + Self::new(ErrorKind::AgentLoad, message) + } + + pub fn validation(message: impl Into) -> Self { + Self::new(ErrorKind::Validation, message) + } + + pub fn signing_failed(message: impl Into) -> Self { + Self::new(ErrorKind::SigningFailed, message) + } + + pub fn verification_failed(message: impl Into) -> Self { + Self::new(ErrorKind::VerificationFailed, message) + } + + pub fn document_failed(message: impl Into) -> Self { + Self::new(ErrorKind::DocumentFailed, message) + } + + pub fn agreement_failed(message: impl Into) -> Self { + Self::new(ErrorKind::AgreementFailed, message) + } + + pub fn serialization_failed(message: impl Into) -> Self { + Self::new(ErrorKind::SerializationFailed, message) + } + + pub fn invalid_argument(message: impl Into) -> Self { + Self::new(ErrorKind::InvalidArgument, message) + } + + pub fn trust_failed(message: impl Into) -> Self { + Self::new(ErrorKind::TrustFailed, message) + } + + pub fn generic(message: impl Into) -> Self { + Self::new(ErrorKind::Generic, message) + } +} + +impl std::fmt::Display for BindingCoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for BindingCoreError {} + +impl From> for BindingCoreError { + fn from(e: PoisonError) -> Self { + Self::lock_failed(format!("Failed to acquire lock: {}", e)) + } +} + +/// Result type for binding core operations. +pub type BindingResult = Result; + +// ============================================================================= +// Wrapper Type for Agent with Arc> +// ============================================================================= + +/// Thread-safe wrapper around a JACS Agent. +/// +/// This provides the core agent functionality that all bindings share. +/// Bindings wrap this in their own types and convert errors appropriately. +#[derive(Clone)] +pub struct AgentWrapper { + inner: Arc>, +} + +impl Default for AgentWrapper { + fn default() -> Self { + Self::new() + } +} + +impl AgentWrapper { + /// Create a new empty agent wrapper. + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(jacs::get_empty_agent())), + } + } + + /// Get a locked reference to the inner agent. + fn lock(&self) -> BindingResult> { + self.inner.lock().map_err(BindingCoreError::from) + } + + /// Load agent configuration from a file path. + pub fn load(&self, config_path: String) -> BindingResult { + let mut agent = self.lock()?; + agent + .load_by_config(config_path) + .map_err(|e| BindingCoreError::agent_load(format!("Failed to load agent: {}", e)))?; + Ok("Agent loaded".to_string()) + } + + /// Sign an external agent's document with this agent's registration signature. + pub fn sign_agent( + &self, + agent_string: &str, + public_key: Vec, + public_key_enc_type: String, + ) -> BindingResult { + let mut agent = self.lock()?; + + let mut external_agent: Value = agent + .validate_agent(agent_string) + .map_err(|e| BindingCoreError::validation(format!("Agent validation failed: {}", e)))?; + + agent + .signature_verification_procedure( + &external_agent, + None, + &AGENT_SIGNATURE_FIELDNAME.to_string(), + public_key, + Some(public_key_enc_type), + None, + None, + ) + .map_err(|e| { + BindingCoreError::verification_failed(format!( + "Signature verification failed: {}", + e + )) + })?; + + let registration_signature = agent + .signing_procedure( + &external_agent, + None, + &AGENT_REGISTRATION_SIGNATURE_FIELDNAME.to_string(), + ) + .map_err(|e| { + BindingCoreError::signing_failed(format!("Signing procedure failed: {}", e)) + })?; + + external_agent[AGENT_REGISTRATION_SIGNATURE_FIELDNAME] = registration_signature; + Ok(external_agent.to_string()) + } + + /// Verify a signature on arbitrary string data. + pub fn verify_string( + &self, + data: &str, + signature_base64: &str, + public_key: Vec, + public_key_enc_type: String, + ) -> BindingResult { + let agent = self.lock()?; + + if data.is_empty() + || signature_base64.is_empty() + || public_key.is_empty() + || public_key_enc_type.is_empty() + { + return Err(BindingCoreError::invalid_argument(format!( + "One parameter is empty: data={}, signature_base64={}, public_key_enc_type={}", + data.is_empty(), + signature_base64.is_empty(), + public_key_enc_type + ))); + } + + agent + .verify_string( + &data.to_string(), + &signature_base64.to_string(), + public_key, + Some(public_key_enc_type), + ) + .map_err(|e| { + BindingCoreError::verification_failed(format!( + "Signature verification failed: {}", + e + )) + })?; + + Ok(true) + } + + /// Sign arbitrary string data with this agent's private key. + pub fn sign_string(&self, data: &str) -> BindingResult { + let mut agent = self.lock()?; + + agent + .sign_string(&data.to_string()) + .map_err(|e| BindingCoreError::signing_failed(format!("Failed to sign string: {}", e))) + } + + /// Verify this agent's signature and hash. + pub fn verify_agent(&self, agentfile: Option) -> BindingResult { + let mut agent = self.lock()?; + + if let Some(file) = agentfile { + let loaded_agent = jacs::load_agent(Some(file)) + .map_err(|e| BindingCoreError::agent_load(format!("Failed to load agent: {}", e)))?; + *agent = loaded_agent; + } + + agent.verify_self_signature().map_err(|e| { + BindingCoreError::verification_failed(format!("Failed to verify agent signature: {}", e)) + })?; + + agent.verify_self_hash().map_err(|e| { + BindingCoreError::verification_failed(format!("Failed to verify agent hash: {}", e)) + })?; + + Ok(true) + } + + /// Update the agent document with new data. + pub fn update_agent(&self, new_agent_string: &str) -> BindingResult { + let mut agent = self.lock()?; + + agent + .update_self(new_agent_string) + .map_err(|e| BindingCoreError::agent_load(format!("Failed to update agent: {}", e))) + } + + /// Verify a document's signature and hash. + pub fn verify_document(&self, document_string: &str) -> BindingResult { + let mut agent = self.lock()?; + + let doc = agent.load_document(document_string).map_err(|e| { + BindingCoreError::document_failed(format!("Failed to load document: {}", e)) + })?; + + let document_key = doc.getkey(); + let value = doc.getvalue(); + + agent.verify_hash(value).map_err(|e| { + BindingCoreError::verification_failed(format!("Failed to verify document hash: {}", e)) + })?; + + agent + .verify_external_document_signature(&document_key) + .map_err(|e| { + BindingCoreError::verification_failed(format!( + "Failed to verify document signature: {}", + e + )) + })?; + + Ok(true) + } + + /// Update an existing document. + pub fn update_document( + &self, + document_key: &str, + new_document_string: &str, + attachments: Option>, + embed: Option, + ) -> BindingResult { + let mut agent = self.lock()?; + + let doc = agent + .update_document(document_key, new_document_string, attachments, embed) + .map_err(|e| { + BindingCoreError::document_failed(format!("Failed to update document: {}", e)) + })?; + + Ok(doc.to_string()) + } + + /// Verify a document's signature with an optional custom signature field. + pub fn verify_signature( + &self, + document_string: &str, + signature_field: Option, + ) -> BindingResult { + let mut agent = self.lock()?; + + let doc = agent.load_document(document_string).map_err(|e| { + BindingCoreError::document_failed(format!("Failed to load document: {}", e)) + })?; + + let document_key = doc.getkey(); + let sig_field_ref = signature_field.as_ref(); + + agent + .verify_document_signature( + &document_key, + sig_field_ref.map(|s| s.as_str()), + None, + None, + None, + ) + .map_err(|e| { + BindingCoreError::verification_failed(format!("Failed to verify signature: {}", e)) + })?; + + Ok(true) + } + + /// Create an agreement on a document. + pub fn create_agreement( + &self, + document_string: &str, + agentids: Vec, + question: Option, + context: Option, + agreement_fieldname: Option, + ) -> BindingResult { + let mut agent = self.lock()?; + + jacs::shared::document_add_agreement( + &mut agent, + document_string, + agentids, + None, + None, + question, + context, + None, + None, + false, + agreement_fieldname, + ) + .map_err(|e| { + BindingCoreError::agreement_failed(format!("Failed to create agreement: {}", e)) + }) + } + + /// Sign an agreement on a document. + pub fn sign_agreement( + &self, + document_string: &str, + agreement_fieldname: Option, + ) -> BindingResult { + let mut agent = self.lock()?; + + jacs::shared::document_sign_agreement( + &mut agent, + document_string, + None, + None, + None, + None, + false, + agreement_fieldname, + ) + .map_err(|e| { + BindingCoreError::agreement_failed(format!("Failed to sign agreement: {}", e)) + }) + } + + /// Create a new JACS document. + pub fn create_document( + &self, + document_string: &str, + custom_schema: Option, + outputfilename: Option, + no_save: bool, + attachments: Option<&str>, + embed: Option, + ) -> BindingResult { + let mut agent = self.lock()?; + + jacs::shared::document_create( + &mut agent, + document_string, + custom_schema, + outputfilename, + no_save, + attachments, + embed, + ) + .map_err(|e| { + BindingCoreError::document_failed(format!("Failed to create document: {}", e)) + }) + } + + /// Check an agreement on a document. + pub fn check_agreement( + &self, + document_string: &str, + agreement_fieldname: Option, + ) -> BindingResult { + let mut agent = self.lock()?; + + jacs::shared::document_check_agreement(&mut agent, document_string, None, agreement_fieldname) + .map_err(|e| { + BindingCoreError::agreement_failed(format!("Failed to check agreement: {}", e)) + }) + } + + /// Sign a request payload (wraps in a JACS document). + pub fn sign_request(&self, payload_value: Value) -> BindingResult { + let mut agent = self.lock()?; + + let wrapper_value = serde_json::json!({ + "jacs_payload": payload_value + }); + + let wrapper_string = serde_json::to_string(&wrapper_value).map_err(|e| { + BindingCoreError::serialization_failed(format!("Failed to serialize wrapper JSON: {}", e)) + })?; + + jacs::shared::document_create( + &mut agent, + &wrapper_string, + None, + None, + true, // no_save + None, + Some(false), + ) + .map_err(|e| { + BindingCoreError::document_failed(format!("Failed to create document: {}", e)) + }) + } + + /// Verify a response payload and return the payload value. + pub fn verify_response(&self, document_string: String) -> BindingResult { + let mut agent = self.lock()?; + + agent + .verify_payload(document_string, None) + .map_err(|e| BindingCoreError::verification_failed(e.to_string())) + } + + /// Verify a response payload and return (payload, agent_id). + pub fn verify_response_with_agent_id( + &self, + document_string: String, + ) -> BindingResult<(Value, String)> { + let mut agent = self.lock()?; + + agent + .verify_payload_with_agent_id(document_string, None) + .map_err(|e| BindingCoreError::verification_failed(e.to_string())) + } + + /// Get the agent's JSON representation as a string. + /// + /// Returns the agent's full JSON document, suitable for registration + /// with external services like HAI. + pub fn get_agent_json(&self) -> BindingResult { + let agent = self.lock()?; + match agent.get_value() { + Some(value) => Ok(value.to_string()), + None => Err(BindingCoreError::agent_load( + "Agent not loaded. Call load() first.", + )), + } + } +} + +// ============================================================================= +// Stateless Utility Functions +// ============================================================================= + +/// Hash a string using the JACS hash function (SHA-256). +pub fn hash_string(data: &str) -> String { + jacs_hash_string(&data.to_string()) +} + +/// Create a JACS configuration JSON string. +pub fn create_config( + jacs_use_security: Option, + jacs_data_directory: Option, + jacs_key_directory: Option, + jacs_agent_private_key_filename: Option, + jacs_agent_public_key_filename: Option, + jacs_agent_key_algorithm: Option, + jacs_private_key_password: Option, + jacs_agent_id_and_version: Option, + jacs_default_storage: Option, +) -> BindingResult { + let config = Config::new( + jacs_use_security, + jacs_data_directory, + jacs_key_directory, + jacs_agent_private_key_filename, + jacs_agent_public_key_filename, + jacs_agent_key_algorithm, + jacs_private_key_password, + jacs_agent_id_and_version, + jacs_default_storage, + ); + + serde_json::to_string_pretty(&config).map_err(|e| { + BindingCoreError::serialization_failed(format!("Failed to serialize config: {}", e)) + }) +} + +// ============================================================================= +// Trust Store Functions +// ============================================================================= + +/// Add an agent to the local trust store. +pub fn trust_agent(agent_json: &str) -> BindingResult { + jacs::trust::trust_agent(agent_json) + .map_err(|e| BindingCoreError::trust_failed(format!("Failed to trust agent: {}", e))) +} + +/// List all trusted agent IDs. +pub fn list_trusted_agents() -> BindingResult> { + jacs::trust::list_trusted_agents() + .map_err(|e| BindingCoreError::trust_failed(format!("Failed to list trusted agents: {}", e))) +} + +/// Remove an agent from the trust store. +pub fn untrust_agent(agent_id: &str) -> BindingResult<()> { + jacs::trust::untrust_agent(agent_id) + .map_err(|e| BindingCoreError::trust_failed(format!("Failed to untrust agent: {}", e))) +} + +/// Check if an agent is in the trust store. +pub fn is_trusted(agent_id: &str) -> bool { + jacs::trust::is_trusted(agent_id) +} + +/// Get a trusted agent's JSON document. +pub fn get_trusted_agent(agent_id: &str) -> BindingResult { + jacs::trust::get_trusted_agent(agent_id) + .map_err(|e| BindingCoreError::trust_failed(format!("Failed to get trusted agent: {}", e))) +} + +// ============================================================================= +// CLI Utility Functions +// ============================================================================= + +/// Create agent and config files interactively. +pub fn handle_agent_create(filename: Option<&String>, create_keys: bool) -> BindingResult<()> { + jacs::cli_utils::create::handle_agent_create(filename, create_keys) + .map_err(|e| BindingCoreError::generic(e.to_string())) +} + +/// Create a jacs.config.json file interactively. +pub fn handle_config_create() -> BindingResult<()> { + jacs::cli_utils::create::handle_config_create() + .map_err(|e| BindingCoreError::generic(e.to_string())) +} + +// ============================================================================= +// Re-exports for convenience +// ============================================================================= + +pub use jacs; diff --git a/jacs-mcp/Cargo.toml b/jacs-mcp/Cargo.toml index 78806000b..61f479a3e 100644 --- a/jacs-mcp/Cargo.toml +++ b/jacs-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-mcp" -version = "0.1.0" +version = "0.5.0" edition = "2024" [features] diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index bded9089d..58f45187e 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.4.0" +version = "0.5.0" edition = "2024" rust-version = "1.93" resolver = "3" @@ -71,6 +71,8 @@ difference = "2.0.0" rpassword = "7.3.1" validator = "0.20.0" uuid = { version = "1.16.0", features = ["v4", "v7", "js"] } +lazy_static = "1.5" +dirs = "5.0" env_logger = "0.11.8" futures-util = "0.3.31" referencing = "0.33.0" diff --git a/jacs/README.md b/jacs/README.md index 7c9e45eb2..213dda298 100644 --- a/jacs/README.md +++ b/jacs/README.md @@ -1,117 +1,79 @@ # JACS: JSON Agent Communication Standard -**JACS (JSON Agent Communication Standard)**, is a framework for creating, signing, and verifying JSON documents with cryptographic integrity, designed specifically for AI agent identity, authentication, authorization, communication, and task management. +Cryptographic signing and verification for AI agents. -## Use cases - -- **Create and sign** JSON documents with cryptographic signatures -- **Verify authenticity** and integrity of documents, requests, identities -- **Manage tasks and agreements** between multiple agents -- **Maintain audit trails** with modifications and versioning -- **Provide mutual opt-in identity and trust** in multi-agent systems - -## Features - -JACS provides middleware applicable to http, email, mpc, a2a, - -- **Cryptographic Security**: RSA, Ed25519, and post-quantum algorithms -- **JSON Schema Validation**: Enforced document structure -- **Multi-Agent Agreements**: Formats that allow for multiple signatures -- **Full Audit Trail**: Complete versioning and modification history -- **Multiple Languages**: Rust, Node.js, and Python implementations -- **MCP Integration**: Native Model Context Protocol support -- **A2A Integration**: Google's Agent-to-Agent protocol support -- **Observable**: Meta data available as OpenTelemetry - -## About - -JACS is a JSON document format for creating secure, verifiable documents that AI agents, ML pipelines, SaaS services, and UIs can exchange and process. The goal of JACS is to ensure that these documents remain unchanged (immutable), produce the same verification result every time (idempotent), and can be used flexibly by software. - -With JACS, data can be securely stored or shared, and different versions of the data can be tracked. One of the key features of JACS is its ability to provide instant verification of document ownership. Each version of a JACS document is signed with a unique digital signature, allowing an AI agent to prove its data claims. This enables trusted interactions between agents and provides flexibility in how documents are versioned and exchanged. Any person or software can modify a doc, but only agents with the private key can sign new versions, and only holders of the public key can verify. - -JACS also provides standardization for agreement between agents. While each document will only have been modified by one agent, any agent can create a new version. With a JACS document, one can guarantee that other agents agree to new versions is critical infrastructure for sharing tasks. - -Use JACS as is, embed in other projects or libraries, commercial or otherwise. - -Please note that the [license](./LICENSE) is *modified* Apache 2.0, with the [Common Clause](https://commonsclause.com/) preamble. In simple terms, unless you are competing with HAI.AI, you can create commercial products with JACS. - -## Basic use cases - -1. version json documents and test against a schema -2. capture metadata about files and versions and securely verify -3. sign documents -4. create agreements between human and ai agents -5. create agents and describe what they can do -6. create tasks manage their state -7. Use with [OpenAI structured output](https://openai.com/index/introducing-structured-outputs-in-the-api/) - -## Documentation - -- [Usage Docs](https://humanassisted.github.io/JACS/) -- [Rust API docs](https://docs.rs/jacs/latest/jacs/) -- [Python Package](https://pypi.org/project/jacs/) -- [Rust Crate](https://crates.io/crates/jacs) -- [Schema docs](./schemas) -- [example files](./examples) -- [use case examples (wip)](https://github.com/HumanAssisted/jacs-examples) -- [presentation on JACS](https://docs.google.com/presentation/d/18mO-tftG-9JnKd7rBtdipcX5t0dm4VfBPReKyWvrmXA/edit#slide=id.p) -- [changelog](./CHANGELOG.md) - -![Schemas](basic-schemas.png) - -## extensible - -Use any type of JSON document, and you can enforce structure of type of JSON document using -[JSON Schema](https://json-schema.org/). If you are just getting started with JSON schema - -1. [introduction](https://json-schema.org/understanding-json-schema) -2. [github page](https://github.com/json-schema-org) -3. [youtube channel](https://www.youtube.com/@JSONSchemaOrgOfficial) - -JSON isn't the only document you can work with. You can embed any document, so if you want to sign a gif or .csv, you can link or embed that document with JACS. - -## open source +```bash +cargo install jacs +``` -In addition, JACS also depends on the work of great other open source efforts in standards and encryption. +## Quick Start -See the [Cargo.toml](./Cargo.toml) +```rust +use jacs::simple::{load, sign_message, verify}; -# Quick Start +// Load agent +load(None)?; -To install the command line tool for creating and verifying agents and documents +// Sign a message +let signed = sign_message(&serde_json::json!({"action": "approve"}))?; - $ cargo install jacs - $ jacs --help - $ jacs init +// Verify it +let result = verify(&signed.raw)?; +assert!(result.valid); +``` -If you are working in Rust, add the rust lib to your project +## 6 Core Operations - cargo add jacs +| Operation | Description | +|-----------|-------------| +| `create()` | Create a new agent with keys | +| `load()` | Load agent from config | +| `verify_self()` | Verify agent integrity | +| `sign_message()` | Sign JSON data | +| `sign_file()` | Sign files with embedding | +| `verify()` | Verify any signed document | -For development you may want `cargo install cargo-outdated` and `cargo install cargo-edit` +## Features -## A2A Protocol Integration +- RSA, Ed25519, and post-quantum (ML-DSA) cryptography +- JSON Schema validation +- Multi-agent agreements +- MCP and A2A protocol support +- Python, Go, and NPM bindings -JACS provides native support for Google's A2A (Agent-to-Agent) protocol: +## CLI -```rust -use jacs::a2a::{agent_card::*, keys::*, extension::*, provenance::*}; +```bash +jacs create # Create new agent +jacs sign-message "hi" # Sign a message +jacs sign-file doc.pdf # Sign a file +jacs verify doc.json # Verify a document +``` -// Export JACS agent to A2A Agent Card -let agent_card = export_agent_card(&agent)?; +## Security -// Generate dual keys (PQC for JACS, RSA/ECDSA for A2A) -let dual_keys = create_jwk_keys(Some("dilithium"), Some("rsa"))?; +**Security Hardening**: This library includes: +- Password entropy validation for key encryption (minimum 28 bits, 35 bits for single character class) +- Thread-safe environment variable handling +- TLS certificate validation (warns by default; set `JACS_STRICT_TLS=true` for production) +- Private key zeroization on drop +- Algorithm identification embedded in signatures -// Wrap A2A artifacts with JACS provenance -let wrapped = wrap_artifact_with_provenance(&mut agent, artifact, "task", None)?; -``` +**Reporting Vulnerabilities**: Please report security issues responsibly. +- Email: security@hai.ai +- Do **not** open public issues for security vulnerabilities +- We aim to respond within 48 hours -See [src/a2a/README.md](./src/a2a/README.md) for complete integration guide +**Best Practices**: +- Use strong passwords (12+ characters with mixed case, numbers, symbols) +- Store private keys securely with appropriate file permissions +- Keep JACS and its dependencies updated ---- +## Links -Then start reading the [usage docs](https://humanassisted.github.io/JACS/) +- [Documentation](https://humanassisted.github.io/JACS/) +- [Rust API](https://docs.rs/jacs/latest/jacs/) +- [Python](https://pypi.org/project/jacs/) +- [Crates.io](https://crates.io/crates/jacs) -**Current Version**: 0.3.5+ (Active Development) -© 2024, 2025 https://hai.ai +**Version**: 0.4.0 | [HAI.AI](https://hai.ai) diff --git a/jacs/docs/jacsbook/book/404.html b/jacs/docs/jacsbook/book/404.html index 6d4167ee8..fe318441c 100644 --- a/jacs/docs/jacsbook/book/404.html +++ b/jacs/docs/jacsbook/book/404.html @@ -89,7 +89,7 @@