From 04c5c3fe9f566be238a8c29ee69c4ffd80081764 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 6 Mar 2026 04:38:07 +0000 Subject: [PATCH] feat: WASM extension versioning with WIT compat checks (#592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add WASM extension versioning with WIT compat checks and CI enforcement Phase 1 — WIT Versioning & Compatibility Checks: - Version WIT packages as `package near:agent@0.2.0;` - Add `semver` crate for version parsing and comparison - Add `WIT_TOOL_VERSION` / `WIT_CHANNEL_VERSION` host constants - Add `version` and `wit_version` fields to capabilities schemas - Add `wit_version` column to `wasm_tools` DB table (both backends) - Add load-time `check_wit_version_compat()` with semver rules - Add `IncompatibleWitVersion` error variants for tools and channels - Enhance instantiation errors with WIT version mismatch hints - Update all 14 capabilities JSON and 14 registry JSON files Phase 2 — Upgrade-in-Place & Channel DB Storage: - Change tool store to DELETE-before-INSERT (one version per extension) - Create `wasm_channels` table (PostgreSQL migration + libSQL schema) - Add `WasmChannelStore` trait with PostgreSQL and libSQL backends - Add `extension_info` tool showing version, WIT version, and status - Wire `ExtensionInfoTool` into tool registry (7 extension tools) Phase 3 — CI Version-Bump Enforcement: - Add `scripts/check-version-bumps.sh` checking WIT/tool/channel versions - Add `version-check` CI job (PR-only) to `.github/workflows/test.yml` - Support `[skip-version-check]` label/commit message bypass Includes 7 regression tests for WIT version compatibility checking and 2 integration tests for WIT version annotation verification. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address PR review feedback for WASM extension versioning - Wrap PostgreSQL DELETE+INSERT in transactions for both tool and channel store() methods to prevent data loss on partial failure (Gemini, Copilot) - Rename StoredWasmChannelWithBinary.tool → .channel (copy-paste fix) - Remove unused WasmError::IncompatibleWitVersion variant (dead code) - Map channel loader WIT mismatch to IncompatibleWitVersion instead of generic Config error, simplify variant to single String message - Fix extension_info description to match actual returned fields - Add schema test for ExtensionInfoTool matching existing test pattern - Fix CI script to fail fast on git errors instead of silent bypass [skip-regression-check] Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/test.yml | 21 +- Cargo.lock | 1 + Cargo.toml | 3 + .../discord/discord.capabilities.json | 2 + channels-src/slack/slack.capabilities.json | 2 + .../telegram/telegram.capabilities.json | 2 + .../whatsapp/whatsapp.capabilities.json | 2 + migrations/V10__wasm_versioning.sql | 19 + registry/channels/discord.json | 1 + registry/channels/slack.json | 1 + registry/channels/telegram.json | 1 + registry/channels/whatsapp.json | 1 + registry/tools/github.json | 1 + registry/tools/gmail.json | 1 + registry/tools/google-calendar.json | 1 + registry/tools/google-docs.json | 1 + registry/tools/google-drive.json | 1 + registry/tools/google-sheets.json | 1 + registry/tools/google-slides.json | 1 + registry/tools/slack.json | 1 + registry/tools/telegram.json | 1 + registry/tools/web-search.json | 1 + scripts/check-version-bumps.sh | 251 +++++++ src/channels/wasm/error.rs | 3 + src/channels/wasm/loader.rs | 8 + src/channels/wasm/mod.rs | 2 + src/channels/wasm/schema.rs | 8 + src/channels/wasm/storage.rs | 690 ++++++++++++++++++ src/channels/wasm/wrapper.rs | 15 +- src/db/libsql_migrations.rs | 19 + src/extensions/manager.rs | 72 ++ src/tools/builtin/extension_tools.rs | 67 ++ src/tools/builtin/mod.rs | 3 +- src/tools/registry.rs | 16 +- src/tools/wasm/capabilities_schema.rs | 8 + src/tools/wasm/loader.rs | 107 ++- src/tools/wasm/mod.rs | 13 +- src/tools/wasm/storage.rs | 124 ++-- src/tools/wasm/wrapper.rs | 16 +- tests/wit_compat.rs | 106 ++- .../github/github-tool.capabilities.json | 2 + tools-src/gmail/gmail-tool.capabilities.json | 2 + .../google-calendar-tool.capabilities.json | 2 + .../google-docs-tool.capabilities.json | 2 + .../google-drive-tool.capabilities.json | 2 + .../google-sheets-tool.capabilities.json | 2 + .../google-slides-tool.capabilities.json | 2 + tools-src/slack/slack-tool.capabilities.json | 2 + .../telegram/telegram-tool.capabilities.json | 2 + .../web-search-tool.capabilities.json | 2 + wit/channel.wit | 2 +- wit/tool.wit | 2 +- 52 files changed, 1519 insertions(+), 99 deletions(-) create mode 100644 migrations/V10__wasm_versioning.sql create mode 100755 scripts/check-version-bumps.sh create mode 100644 src/channels/wasm/storage.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c380a07..73b39261 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,15 +81,34 @@ jobs: - name: Build Docker image run: docker build -t ironclaw-test:ci . + version-check: + name: Version Bump Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Check version bumps for changed extensions + env: + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ./scripts/check-version-bumps.sh + # Roll-up job for branch protection run-tests: name: Run Tests runs-on: ubuntu-latest if: always() - needs: [tests, telegram-tests, wasm-wit-compat, docker-build] + needs: [tests, telegram-tests, wasm-wit-compat, docker-build, version-check] steps: - run: | if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" ]]; then echo "One or more jobs failed" exit 1 fi + # version-check only runs on PRs, so skip/success are both acceptable + if [[ "${{ needs.version-check.result }}" == "failure" ]]; then + echo "Version bump check failed" + exit 1 + fi diff --git a/Cargo.lock b/Cargo.lock index 3892d1a7..c052998c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2880,6 +2880,7 @@ dependencies = [ "secrecy", "secret-service", "security-framework", + "semver", "serde", "serde_json", "serde_yml", diff --git a/Cargo.toml b/Cargo.toml index 5cec54b1..31372db8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,9 @@ serde_yml = "0.0.12" dirs = "6" fs4 = "0.6" +# Semantic versioning +semver = "1" + # Secrecy for sensitive values secrecy = { version = "0.10", features = ["serde"] } diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json index b5708e70..f2d3e69e 100644 --- a/channels-src/discord/discord.capabilities.json +++ b/channels-src/discord/discord.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "type": "channel", "name": "discord", "description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages", diff --git a/channels-src/slack/slack.capabilities.json b/channels-src/slack/slack.capabilities.json index 60ef5319..9a16fcd9 100644 --- a/channels-src/slack/slack.capabilities.json +++ b/channels-src/slack/slack.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "type": "channel", "name": "slack", "description": "Slack Events API channel for receiving and responding to Slack messages", diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json index e94009aa..c6a08f27 100644 --- a/channels-src/telegram/telegram.capabilities.json +++ b/channels-src/telegram/telegram.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "type": "channel", "name": "telegram", "description": "Telegram Bot API channel for receiving and responding to Telegram messages", diff --git a/channels-src/whatsapp/whatsapp.capabilities.json b/channels-src/whatsapp/whatsapp.capabilities.json index 6a60a8d7..78786305 100644 --- a/channels-src/whatsapp/whatsapp.capabilities.json +++ b/channels-src/whatsapp/whatsapp.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "type": "channel", "name": "whatsapp", "description": "WhatsApp Cloud API channel for receiving and responding to WhatsApp messages", diff --git a/migrations/V10__wasm_versioning.sql b/migrations/V10__wasm_versioning.sql new file mode 100644 index 00000000..d7404ac3 --- /dev/null +++ b/migrations/V10__wasm_versioning.sql @@ -0,0 +1,19 @@ +-- Add wit_version column to wasm_tools for WIT interface version tracking +ALTER TABLE wasm_tools ADD COLUMN IF NOT EXISTS wit_version TEXT NOT NULL DEFAULT '0.1.0'; + +-- Create wasm_channels table for DB-stored channel extensions +CREATE TABLE IF NOT EXISTS wasm_channels ( + id UUID PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '0.1.0', + wit_version TEXT NOT NULL DEFAULT '0.1.0', + description TEXT NOT NULL DEFAULT '', + wasm_binary BYTEA NOT NULL, + binary_hash BYTEA NOT NULL, + capabilities_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT unique_wasm_channel UNIQUE (user_id, name) +); diff --git a/registry/channels/discord.json b/registry/channels/discord.json index e836f4dc..2e57583d 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -3,6 +3,7 @@ "display_name": "Discord Channel", "kind": "channel", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Talk to your agent in Discord", "keywords": [ "messaging", diff --git a/registry/channels/slack.json b/registry/channels/slack.json index 901c9ff3..60a3805a 100644 --- a/registry/channels/slack.json +++ b/registry/channels/slack.json @@ -3,6 +3,7 @@ "display_name": "Slack Channel", "kind": "channel", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Talk to your agent in Slack", "keywords": [ "messaging", diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index 1f6111bf..87084b33 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -3,6 +3,7 @@ "display_name": "Telegram Channel", "kind": "channel", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Talk to your agent through a Telegram bot", "keywords": [ "messaging", diff --git a/registry/channels/whatsapp.json b/registry/channels/whatsapp.json index 21cf95bd..101ed9f8 100644 --- a/registry/channels/whatsapp.json +++ b/registry/channels/whatsapp.json @@ -3,6 +3,7 @@ "display_name": "WhatsApp Channel", "kind": "channel", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Talk to your agent through WhatsApp", "keywords": [ "messaging", diff --git a/registry/tools/github.json b/registry/tools/github.json index d9f898e1..c33dbd64 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -3,6 +3,7 @@ "display_name": "GitHub", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "GitHub integration for issues, PRs, repos, and code search", "keywords": [ "git", diff --git a/registry/tools/gmail.json b/registry/tools/gmail.json index 4309b666..fcb30bfb 100644 --- a/registry/tools/gmail.json +++ b/registry/tools/gmail.json @@ -3,6 +3,7 @@ "display_name": "Gmail", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Read, send, and manage Gmail messages and threads", "keywords": [ "email", diff --git a/registry/tools/google-calendar.json b/registry/tools/google-calendar.json index 80056449..ff35a6d6 100644 --- a/registry/tools/google-calendar.json +++ b/registry/tools/google-calendar.json @@ -3,6 +3,7 @@ "display_name": "Google Calendar", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Create, read, update, and delete Google Calendar events", "keywords": [ "calendar", diff --git a/registry/tools/google-docs.json b/registry/tools/google-docs.json index 94ca126b..8a524006 100644 --- a/registry/tools/google-docs.json +++ b/registry/tools/google-docs.json @@ -3,6 +3,7 @@ "display_name": "Google Docs", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Create and edit Google Docs documents", "keywords": [ "documents", diff --git a/registry/tools/google-drive.json b/registry/tools/google-drive.json index c4a42968..bb775318 100644 --- a/registry/tools/google-drive.json +++ b/registry/tools/google-drive.json @@ -3,6 +3,7 @@ "display_name": "Google Drive", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Upload, download, search, and manage Google Drive files and folders", "keywords": [ "storage", diff --git a/registry/tools/google-sheets.json b/registry/tools/google-sheets.json index ee22e24e..9350b2d3 100644 --- a/registry/tools/google-sheets.json +++ b/registry/tools/google-sheets.json @@ -3,6 +3,7 @@ "display_name": "Google Sheets", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Read and write Google Sheets spreadsheet data", "keywords": [ "spreadsheets", diff --git a/registry/tools/google-slides.json b/registry/tools/google-slides.json index cbfae581..7b4e8aef 100644 --- a/registry/tools/google-slides.json +++ b/registry/tools/google-slides.json @@ -3,6 +3,7 @@ "display_name": "Google Slides", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Create and edit Google Slides presentations", "keywords": [ "presentations", diff --git a/registry/tools/slack.json b/registry/tools/slack.json index e4c65369..6aa118c9 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -3,6 +3,7 @@ "display_name": "Slack Tool", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Your agent uses Slack to post and read messages in your workspace", "keywords": [ "messaging", diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index 3a96ac95..89454e87 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -3,6 +3,7 @@ "display_name": "Telegram Tool", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Your agent uses your Telegram account to read and send messages", "keywords": [ "messaging", diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 5dbabb86..b284650b 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -3,6 +3,7 @@ "display_name": "Web Search", "kind": "tool", "version": "0.1.0", + "wit_version": "0.2.0", "description": "Search the web using Brave Search API", "keywords": [ "search", diff --git a/scripts/check-version-bumps.sh b/scripts/check-version-bumps.sh new file mode 100755 index 00000000..42b6704a --- /dev/null +++ b/scripts/check-version-bumps.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +set -euo pipefail + +# CI script: check that version bumps accompany WIT or extension source changes. +# Exit 0 if all checks pass, exit 1 if any version wasn't bumped. + +ERRORS=0 + +# --- Skip mechanism ----------------------------------------------------------- + +if [[ "${PR_LABELS:-}" == *"skip-version-check"* ]]; then + echo "skip-version-check label detected — skipping all version checks." + exit 0 +fi + +# Check commit messages for [skip-version-check] +if git log "origin/${GITHUB_BASE_REF:-main}...HEAD" --pretty=format:"%s %b" 2>/dev/null \ + | grep -qF '[skip-version-check]'; then + echo "[skip-version-check] found in commit message — skipping all version checks." + exit 0 +fi + +# --- Determine base branch and changed files ---------------------------------- + +BASE_BRANCH="${GITHUB_BASE_REF:-main}" +echo "Base branch: $BASE_BRANCH" + +# Ensure the base branch ref is available +if ! git rev-parse "origin/${BASE_BRANCH}" >/dev/null 2>&1; then + echo "Fetching origin/${BASE_BRANCH}..." + git fetch origin "$BASE_BRANCH" --depth=1 +fi + +CHANGED_FILES=$(git diff --name-only "origin/${BASE_BRANCH}...HEAD") + +if [[ -z "$CHANGED_FILES" ]]; then + echo "No changed files detected. Nothing to check." + exit 0 +fi + +# --- Helper functions --------------------------------------------------------- + +# Extract the version from a WIT package line like: package near:agent@1.2.3; +extract_wit_version() { + local file="$1" + if [[ ! -f "$file" ]]; then + echo "" + return + fi + sed -n 's/^[[:space:]]*package[[:space:]]\+[^@]*@\([0-9][0-9.]*[0-9]\)[[:space:]]*;.*/\1/p' "$file" \ + | head -n1 +} + +# Extract version from the base branch copy of a file +extract_wit_version_base() { + local file="$1" + git show "origin/${BASE_BRANCH}:${file}" 2>/dev/null \ + | sed -n 's/^[[:space:]]*package[[:space:]]\+[^@]*@\([0-9][0-9.]*[0-9]\)[[:space:]]*;.*/\1/p' \ + | head -n1 || true +} + +# Extract a Rust string constant value: pub const NAME: &str = "value"; +extract_rust_const() { + local file="$1" + local const_name="$2" + if [[ ! -f "$file" ]]; then + echo "" + return + fi + sed -n "s/^.*${const_name}[[:space:]]*:[[:space:]]*&str[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$file" \ + | head -n1 +} + +# Extract JSON "version" field using jq +extract_json_version() { + local file="$1" + if [[ ! -f "$file" ]]; then + echo "" + return + fi + jq -r '.version // empty' "$file" 2>/dev/null || true +} + +# Extract JSON "version" from the base branch copy of a file +extract_json_version_base() { + local file="$1" + git show "origin/${BASE_BRANCH}:${file}" 2>/dev/null | jq -r '.version // empty' 2>/dev/null || true +} + +# Return 0 if $1 (new) is strictly greater than $2 (old) via sort -V, or old is empty. +version_was_bumped() { + local new="$1" + local old="$2" + if [[ -z "$old" ]]; then + # No prior version — treat as new, no bump required + return 0 + fi + if [[ -z "$new" ]]; then + # Version was removed — that's a problem + return 1 + fi + if [[ "$new" == "$old" ]]; then + return 1 + fi + # Check new > old via sort -V + local highest + highest=$(printf '%s\n%s\n' "$new" "$old" | sort -V | tail -n1) + [[ "$highest" == "$new" ]] +} + +# --- 1. WIT changes ---------------------------------------------------------- + +WIT_TOOL_CHANGED=false +WIT_CHANNEL_CHANGED=false + +if echo "$CHANGED_FILES" | grep -qx 'wit/tool\.wit'; then + WIT_TOOL_CHANGED=true +fi +if echo "$CHANGED_FILES" | grep -qx 'wit/channel\.wit'; then + WIT_CHANNEL_CHANGED=true +fi + +if $WIT_TOOL_CHANGED; then + echo "" + echo "=== wit/tool.wit changed ===" + + NEW_VER=$(extract_wit_version "wit/tool.wit") + OLD_VER=$(extract_wit_version_base "wit/tool.wit") + echo " WIT package version: ${OLD_VER:-} -> ${NEW_VER:-}" + + if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then + echo " ERROR: wit/tool.wit package version was not bumped (${OLD_VER} -> ${NEW_VER:-})." + ERRORS=$((ERRORS + 1)) + else + echo " OK: WIT package version bumped." + fi + + # Check WIT_TOOL_VERSION constant matches + CONST_VER=$(extract_rust_const "src/tools/wasm/mod.rs" "WIT_TOOL_VERSION") + if [[ -n "$NEW_VER" && "$CONST_VER" != "$NEW_VER" ]]; then + echo " ERROR: WIT_TOOL_VERSION in src/tools/wasm/mod.rs is '${CONST_VER}' but wit/tool.wit has '${NEW_VER}'. They must match." + ERRORS=$((ERRORS + 1)) + elif [[ -n "$NEW_VER" ]]; then + echo " OK: WIT_TOOL_VERSION matches wit/tool.wit." + fi +fi + +if $WIT_CHANNEL_CHANGED; then + echo "" + echo "=== wit/channel.wit changed ===" + + NEW_VER=$(extract_wit_version "wit/channel.wit") + OLD_VER=$(extract_wit_version_base "wit/channel.wit") + echo " WIT package version: ${OLD_VER:-} -> ${NEW_VER:-}" + + if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then + echo " ERROR: wit/channel.wit package version was not bumped (${OLD_VER} -> ${NEW_VER:-})." + ERRORS=$((ERRORS + 1)) + else + echo " OK: WIT package version bumped." + fi + + # Check WIT_CHANNEL_VERSION constant matches + CONST_VER=$(extract_rust_const "src/tools/wasm/mod.rs" "WIT_CHANNEL_VERSION") + if [[ -n "$NEW_VER" && "$CONST_VER" != "$NEW_VER" ]]; then + echo " ERROR: WIT_CHANNEL_VERSION in src/tools/wasm/mod.rs is '${CONST_VER}' but wit/channel.wit has '${NEW_VER}'. They must match." + ERRORS=$((ERRORS + 1)) + elif [[ -n "$NEW_VER" ]]; then + echo " OK: WIT_CHANNEL_VERSION matches wit/channel.wit." + fi +fi + +if $WIT_TOOL_CHANGED || $WIT_CHANNEL_CHANGED; then + echo "" + echo " WARNING: WIT interface changed. All published registry extensions should bump their versions for compatibility." +fi + +# --- 2. Tool source changes --------------------------------------------------- + +TOOL_NAMES=$(echo "$CHANGED_FILES" | sed -n 's|^tools-src/\([^/]*\)/.*|\1|p' | sort -u) + +if [[ -n "$TOOL_NAMES" ]]; then + echo "" + echo "=== Tool source changes ===" +fi + +for tool in $TOOL_NAMES; do + REGISTRY_FILE="registry/tools/${tool}.json" + echo "" + echo " --- tools-src/${tool}/ changed ---" + + if [[ ! -f "$REGISTRY_FILE" ]]; then + echo " SKIP: ${REGISTRY_FILE} does not exist yet (new extension?)." + continue + fi + + NEW_VER=$(extract_json_version "$REGISTRY_FILE") + OLD_VER=$(extract_json_version_base "$REGISTRY_FILE") + + echo " Registry version: ${OLD_VER:-} -> ${NEW_VER:-}" + + if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then + echo " ERROR: ${REGISTRY_FILE} version was not bumped (${OLD_VER} -> ${NEW_VER:-}). Bump the version when changing tools-src/${tool}/." + ERRORS=$((ERRORS + 1)) + else + echo " OK: version bumped." + fi +done + +# --- 3. Channel source changes ------------------------------------------------ + +CHANNEL_NAMES=$(echo "$CHANGED_FILES" | sed -n 's|^channels-src/\([^/]*\)/.*|\1|p' | sort -u) + +if [[ -n "$CHANNEL_NAMES" ]]; then + echo "" + echo "=== Channel source changes ===" +fi + +for channel in $CHANNEL_NAMES; do + REGISTRY_FILE="registry/channels/${channel}.json" + echo "" + echo " --- channels-src/${channel}/ changed ---" + + if [[ ! -f "$REGISTRY_FILE" ]]; then + echo " SKIP: ${REGISTRY_FILE} does not exist yet (new extension?)." + continue + fi + + NEW_VER=$(extract_json_version "$REGISTRY_FILE") + OLD_VER=$(extract_json_version_base "$REGISTRY_FILE") + + echo " Registry version: ${OLD_VER:-} -> ${NEW_VER:-}" + + if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then + echo " ERROR: ${REGISTRY_FILE} version was not bumped (${OLD_VER} -> ${NEW_VER:-}). Bump the version when changing channels-src/${channel}/." + ERRORS=$((ERRORS + 1)) + else + echo " OK: version bumped." + fi +done + +# --- Summary ------------------------------------------------------------------ + +echo "" +if [[ $ERRORS -gt 0 ]]; then + echo "FAILED: ${ERRORS} version check(s) did not pass. See errors above." + exit 1 +else + echo "All version checks passed." + exit 0 +fi diff --git a/src/channels/wasm/error.rs b/src/channels/wasm/error.rs index aa0f717a..17fbeb8d 100644 --- a/src/channels/wasm/error.rs +++ b/src/channels/wasm/error.rs @@ -80,6 +80,9 @@ pub enum WasmChannelError { #[error("HTTP request error: {0}")] HttpRequest(String), + + #[error("WIT version mismatch: {0}")] + IncompatibleWitVersion(String), } impl From for WasmChannelError { diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 5f5e80e7..cf1a507f 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -90,6 +90,14 @@ impl WasmChannelLoader { "Parsed capabilities file" ); + // Check WIT version compatibility + crate::tools::wasm::loader::check_wit_version_compat( + name, + cap_file.wit_version.as_deref(), + crate::tools::wasm::WIT_CHANNEL_VERSION, + ) + .map_err(|e| WasmChannelError::IncompatibleWitVersion(e.to_string()))?; + let caps = cap_file.to_capabilities(); // Debug: log resulting capabilities diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 7c74d1aa..29c7632b 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -87,6 +87,8 @@ mod router; mod runtime; mod schema; pub(crate) mod signature; +#[allow(dead_code)] +pub(crate) mod storage; mod wrapper; // Core types diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index d1cbe705..b5081426 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -51,6 +51,14 @@ use crate::tools::wasm::{CapabilitiesFile as ToolCapabilitiesFile, RateLimitSche /// Root schema for a channel capabilities JSON file. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ChannelCapabilitiesFile { + /// Extension version (semver). + #[serde(default)] + pub version: Option, + + /// WIT interface version this channel was compiled against (semver). + #[serde(default)] + pub wit_version: Option, + /// File type, must be "channel". #[serde(default = "default_type")] pub r#type: String, diff --git a/src/channels/wasm/storage.rs b/src/channels/wasm/storage.rs new file mode 100644 index 00000000..189ff709 --- /dev/null +++ b/src/channels/wasm/storage.rs @@ -0,0 +1,690 @@ +//! WASM channel binary storage with integrity verification. +//! +//! Stores compiled WASM channels in the database with BLAKE3 hash verification. +//! Mirrors the pattern in `crate::tools::wasm::storage` but without capabilities table. +//! +//! # Storage Flow +//! +//! ```text +//! WASM bytes ──► BLAKE3 hash ──► Store in database +//! │ (binary + hash) +//! │ +//! └──► Later: Load ──► Verify hash ──► Return bytes +//! ``` + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +#[cfg(feature = "postgres")] +use deadpool_postgres::Pool; +use uuid::Uuid; + +use crate::tools::wasm::storage::{compute_binary_hash, verify_binary_integrity}; + +/// A stored WASM channel (metadata only, no binary). +#[derive(Debug, Clone)] +pub struct StoredWasmChannel { + pub id: Uuid, + pub user_id: String, + pub name: String, + pub version: String, + pub wit_version: String, + pub description: String, + pub capabilities_json: String, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Full channel data including binary. +#[derive(Debug)] +pub struct StoredWasmChannelWithBinary { + pub channel: StoredWasmChannel, + pub wasm_binary: Vec, + pub binary_hash: Vec, +} + +/// Parameters for storing a new WASM channel. +pub struct StoreChannelParams { + pub user_id: String, + pub name: String, + pub version: String, + pub wit_version: String, + pub description: String, + pub wasm_binary: Vec, + pub capabilities_json: String, +} + +/// Error from WASM channel storage operations. +#[derive(Debug, Clone, thiserror::Error)] +pub enum WasmChannelStoreError { + #[error("Channel not found: {0}")] + NotFound(String), + + #[error("Binary integrity check failed: hash mismatch")] + IntegrityCheckFailed, + + #[error("Database error: {0}")] + Database(String), + + #[error("Invalid data: {0}")] + InvalidData(String), +} + +/// Trait for WASM channel storage. +#[async_trait] +pub trait WasmChannelStore: Send + Sync { + /// Store a new WASM channel. + async fn store( + &self, + params: StoreChannelParams, + ) -> Result; + + /// Get channel metadata (without binary). + async fn get( + &self, + user_id: &str, + name: &str, + ) -> Result; + + /// Get channel with binary (verifies integrity). + async fn get_with_binary( + &self, + user_id: &str, + name: &str, + ) -> Result; + + /// List all channels for a user. + async fn list(&self, user_id: &str) -> Result, WasmChannelStoreError>; + + /// Delete a channel. + async fn delete(&self, user_id: &str, name: &str) -> Result; +} + +// ==================== PostgreSQL implementation ==================== + +/// PostgreSQL implementation of WasmChannelStore. +#[cfg(feature = "postgres")] +pub struct PostgresWasmChannelStore { + pool: Pool, +} + +#[cfg(feature = "postgres")] +impl PostgresWasmChannelStore { + pub fn new(pool: Pool) -> Self { + Self { pool } + } +} + +#[cfg(feature = "postgres")] +#[async_trait] +impl WasmChannelStore for PostgresWasmChannelStore { + async fn store( + &self, + params: StoreChannelParams, + ) -> Result { + let mut client = self + .pool + .get() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let binary_hash = compute_binary_hash(¶ms.wasm_binary); + let id = Uuid::new_v4(); + let now = Utc::now(); + + // Wrap delete + insert in a transaction for atomicity + let tx = client + .transaction() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + // Delete any existing version for this (user_id, name) — upgrade-in-place + tx.execute( + "DELETE FROM wasm_channels WHERE user_id = $1 AND name = $2", + &[¶ms.user_id, ¶ms.name], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let row = tx + .query_one( + r#" + INSERT INTO wasm_channels ( + id, user_id, name, version, wit_version, description, wasm_binary, binary_hash, + capabilities_json, status, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', $10, $10) + RETURNING id, user_id, name, version, wit_version, description, + capabilities_json, status, created_at, updated_at + "#, + &[ + &id, + ¶ms.user_id, + ¶ms.name, + ¶ms.version, + ¶ms.wit_version, + ¶ms.description, + ¶ms.wasm_binary, + &binary_hash, + ¶ms.capabilities_json, + &now, + ], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let channel = pg_row_to_channel(&row)?; + + tx.commit() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + Ok(channel) + } + + async fn get( + &self, + user_id: &str, + name: &str, + ) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let row = client + .query_opt( + r#" + SELECT id, user_id, name, version, wit_version, description, + capabilities_json, status, created_at, updated_at + FROM wasm_channels + WHERE user_id = $1 AND name = $2 + "#, + &[&user_id, &name], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + match row { + Some(r) => pg_row_to_channel(&r), + None => Err(WasmChannelStoreError::NotFound(name.to_string())), + } + } + + async fn get_with_binary( + &self, + user_id: &str, + name: &str, + ) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let row = client + .query_opt( + r#" + SELECT id, user_id, name, version, wit_version, description, + wasm_binary, binary_hash, + capabilities_json, status, created_at, updated_at + FROM wasm_channels + WHERE user_id = $1 AND name = $2 + "#, + &[&user_id, &name], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + match row { + Some(r) => { + let wasm_binary: Vec = r.get("wasm_binary"); + let binary_hash: Vec = r.get("binary_hash"); + + if !verify_binary_integrity(&wasm_binary, &binary_hash) { + tracing::error!( + user_id = user_id, + name = name, + "WASM channel binary integrity check failed" + ); + return Err(WasmChannelStoreError::IntegrityCheckFailed); + } + + let channel = StoredWasmChannel { + id: r.get("id"), + user_id: r.get("user_id"), + name: r.get("name"), + version: r.get("version"), + wit_version: r.get("wit_version"), + description: r.get("description"), + capabilities_json: r.get("capabilities_json"), + status: r.get("status"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + }; + + Ok(StoredWasmChannelWithBinary { + channel, + wasm_binary, + binary_hash, + }) + } + None => Err(WasmChannelStoreError::NotFound(name.to_string())), + } + } + + async fn list(&self, user_id: &str) -> Result, WasmChannelStoreError> { + let client = self + .pool + .get() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let rows = client + .query( + r#" + SELECT id, user_id, name, version, wit_version, description, + capabilities_json, status, created_at, updated_at + FROM wasm_channels + WHERE user_id = $1 + ORDER BY name + "#, + &[&user_id], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + rows.into_iter().map(|r| pg_row_to_channel(&r)).collect() + } + + async fn delete(&self, user_id: &str, name: &str) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let result = client + .execute( + "DELETE FROM wasm_channels WHERE user_id = $1 AND name = $2", + &[&user_id, &name], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + Ok(result > 0) + } +} + +#[cfg(feature = "postgres")] +fn pg_row_to_channel( + row: &tokio_postgres::Row, +) -> Result { + Ok(StoredWasmChannel { + id: row.get("id"), + user_id: row.get("user_id"), + name: row.get("name"), + version: row.get("version"), + wit_version: row.get("wit_version"), + description: row.get("description"), + capabilities_json: row.get("capabilities_json"), + status: row.get("status"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) +} + +// ==================== libSQL implementation ==================== + +/// libSQL/Turso implementation of WasmChannelStore. +/// +/// Holds an `Arc` handle and creates a fresh connection per operation, +/// matching the connection-per-request pattern used by the main `LibSqlBackend`. +#[cfg(feature = "libsql")] +pub struct LibSqlWasmChannelStore { + db: std::sync::Arc, +} + +#[cfg(feature = "libsql")] +impl LibSqlWasmChannelStore { + pub fn new(db: std::sync::Arc) -> Self { + Self { db } + } + + async fn connect(&self) -> Result { + let conn = self + .db + .connect() + .map_err(|e| WasmChannelStoreError::Database(format!("Connection failed: {}", e)))?; + conn.query("PRAGMA busy_timeout = 5000", ()) + .await + .map_err(|e| { + WasmChannelStoreError::Database(format!("Failed to set busy_timeout: {}", e)) + })?; + Ok(conn) + } +} + +#[cfg(feature = "libsql")] +#[async_trait] +impl WasmChannelStore for LibSqlWasmChannelStore { + async fn store( + &self, + params: StoreChannelParams, + ) -> Result { + let binary_hash = compute_binary_hash(¶ms.wasm_binary); + let id = Uuid::new_v4(); + let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + + let conn = self.connect().await?; + let tx = conn + .transaction() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + // Delete any existing version for this (user_id, name) — upgrade-in-place + tx.execute( + "DELETE FROM wasm_channels WHERE user_id = ?1 AND name = ?2", + libsql::params![params.user_id.as_str(), params.name.as_str()], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + tx.execute( + r#" + INSERT INTO wasm_channels ( + id, user_id, name, version, wit_version, description, wasm_binary, binary_hash, + capabilities_json, status, created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'active', ?10, ?10) + "#, + libsql::params![ + id.to_string(), + params.user_id.as_str(), + params.name.as_str(), + params.version.as_str(), + params.wit_version.as_str(), + params.description.as_str(), + libsql::Value::Blob(params.wasm_binary), + libsql::Value::Blob(binary_hash), + params.capabilities_json.as_str(), + now.as_str(), + ], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + // Read back the row within the same transaction + let mut rows = tx + .query( + r#" + SELECT id, user_id, name, version, wit_version, description, + capabilities_json, status, created_at, updated_at + FROM wasm_channels + WHERE user_id = ?1 AND name = ?2 + "#, + libsql::params![params.user_id.as_str(), params.name.as_str()], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let row = rows + .next() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))? + .ok_or_else(|| { + WasmChannelStoreError::Database("Insert succeeded but row not found".into()) + })?; + + let channel = libsql_row_to_channel(&row)?; + + tx.commit() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + Ok(channel) + } + + async fn get( + &self, + user_id: &str, + name: &str, + ) -> Result { + let conn = self.connect().await?; + let mut rows = conn + .query( + r#" + SELECT id, user_id, name, version, wit_version, description, + capabilities_json, status, created_at, updated_at + FROM wasm_channels + WHERE user_id = ?1 AND name = ?2 + "#, + libsql::params![user_id, name], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + match rows + .next() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))? + { + Some(row) => libsql_row_to_channel(&row), + None => Err(WasmChannelStoreError::NotFound(name.to_string())), + } + } + + async fn get_with_binary( + &self, + user_id: &str, + name: &str, + ) -> Result { + let conn = self.connect().await?; + let mut rows = conn + .query( + r#" + SELECT id, user_id, name, version, wit_version, description, + wasm_binary, binary_hash, + capabilities_json, status, created_at, updated_at + FROM wasm_channels + WHERE user_id = ?1 AND name = ?2 + "#, + libsql::params![user_id, name], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + match rows + .next() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))? + { + Some(row) => { + let wasm_binary: Vec = row + .get(6) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + let binary_hash: Vec = row + .get(7) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + if !verify_binary_integrity(&wasm_binary, &binary_hash) { + tracing::error!( + user_id = user_id, + name = name, + "WASM channel binary integrity check failed" + ); + return Err(WasmChannelStoreError::IntegrityCheckFailed); + } + + let channel = libsql_row_to_channel_with_offset(&row)?; + + Ok(StoredWasmChannelWithBinary { + channel, + wasm_binary, + binary_hash, + }) + } + None => Err(WasmChannelStoreError::NotFound(name.to_string())), + } + } + + async fn list(&self, user_id: &str) -> Result, WasmChannelStoreError> { + let conn = self.connect().await?; + let mut rows = conn + .query( + r#" + SELECT id, user_id, name, version, wit_version, description, + capabilities_json, status, created_at, updated_at + FROM wasm_channels + WHERE user_id = ?1 + ORDER BY name + "#, + libsql::params![user_id], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + let mut channels = Vec::new(); + while let Some(row) = rows + .next() + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))? + { + channels.push(libsql_row_to_channel(&row)?); + } + Ok(channels) + } + + async fn delete(&self, user_id: &str, name: &str) -> Result { + let conn = self.connect().await?; + let result = conn + .execute( + "DELETE FROM wasm_channels WHERE user_id = ?1 AND name = ?2", + libsql::params![user_id, name], + ) + .await + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + Ok(result > 0) + } +} + +#[cfg(feature = "libsql")] +#[allow(dead_code)] +fn libsql_channel_opt_text(s: Option<&str>) -> libsql::Value { + match s { + Some(s) => libsql::Value::Text(s.to_string()), + None => libsql::Value::Null, + } +} + +#[cfg(feature = "libsql")] +fn libsql_channel_parse_ts(s: &str) -> Result, WasmChannelStoreError> { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { + return Ok(dt.with_timezone(&Utc)); + } + if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") { + return Ok(ndt.and_utc()); + } + if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return Ok(ndt.and_utc()); + } + Err(WasmChannelStoreError::InvalidData(format!( + "unparseable timestamp: {:?}", + s + ))) +} + +/// Parse a channel row with standard column order (no binary columns). +/// Columns: id(0), user_id(1), name(2), version(3), wit_version(4), description(5), +/// capabilities_json(6), status(7), created_at(8), updated_at(9) +#[cfg(feature = "libsql")] +fn libsql_row_to_channel(row: &libsql::Row) -> Result { + let id_str: String = row + .get(0) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + let created_at_str: String = row + .get(8) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + let updated_at_str: String = row + .get(9) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + Ok(StoredWasmChannel { + id: id_str + .parse() + .map_err(|e: uuid::Error| WasmChannelStoreError::InvalidData(e.to_string()))?, + user_id: row + .get(1) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + name: row + .get(2) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + version: row + .get(3) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + wit_version: row + .get(4) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + description: row + .get(5) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + capabilities_json: row + .get(6) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + status: row + .get(7) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + created_at: libsql_channel_parse_ts(&created_at_str)?, + updated_at: libsql_channel_parse_ts(&updated_at_str)?, + }) +} + +/// Parse a channel row when binary columns are present (get_with_binary query). +/// Columns: id(0), user_id(1), name(2), version(3), wit_version(4), description(5), +/// wasm_binary(6), binary_hash(7), +/// capabilities_json(8), status(9), created_at(10), updated_at(11) +#[cfg(feature = "libsql")] +fn libsql_row_to_channel_with_offset( + row: &libsql::Row, +) -> Result { + let id_str: String = row + .get(0) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + let created_at_str: String = row + .get(10) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + let updated_at_str: String = row + .get(11) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?; + + Ok(StoredWasmChannel { + id: id_str + .parse() + .map_err(|e: uuid::Error| WasmChannelStoreError::InvalidData(e.to_string()))?, + user_id: row + .get(1) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + name: row + .get(2) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + version: row + .get(3) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + wit_version: row + .get(4) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + description: row + .get(5) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + capabilities_json: row + .get(8) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + status: row + .get(9) + .map_err(|e| WasmChannelStoreError::Database(e.to_string()))?, + created_at: libsql_channel_parse_ts(&created_at_str)?, + updated_at: libsql_channel_parse_ts(&updated_at_str)?, + }) +} diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index e559aca4..28272769 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -933,8 +933,19 @@ impl WasmChannel { Self::add_host_functions(&mut linker)?; // Instantiate using the generated bindings - let instance = SandboxedChannel::instantiate(store, &component, &linker) - .map_err(|e| WasmChannelError::Instantiation(e.to_string()))?; + let instance = SandboxedChannel::instantiate(store, &component, &linker).map_err(|e| { + let msg = e.to_string(); + if msg.contains("near:agent") || msg.contains("import") { + WasmChannelError::Instantiation(format!( + "{msg}. This may indicate a WIT version mismatch — \ + the channel was compiled against a different WIT than the host supports \ + (host WIT: {}). Rebuild the channel against the current WIT.", + crate::tools::wasm::WIT_CHANNEL_VERSION + )) + } else { + WasmChannelError::Instantiation(msg) + } + })?; Ok(instance) } diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs index 1480ed7d..6117e8ae 100644 --- a/src/db/libsql_migrations.rs +++ b/src/db/libsql_migrations.rs @@ -298,6 +298,7 @@ CREATE TABLE IF NOT EXISTS wasm_tools ( user_id TEXT NOT NULL, name TEXT NOT NULL, version TEXT NOT NULL DEFAULT '1.0.0', + wit_version TEXT NOT NULL DEFAULT '0.1.0', description TEXT NOT NULL, wasm_binary BLOB NOT NULL, binary_hash BLOB NOT NULL, @@ -314,6 +315,24 @@ CREATE INDEX IF NOT EXISTS idx_wasm_tools_user ON wasm_tools(user_id); CREATE INDEX IF NOT EXISTS idx_wasm_tools_name ON wasm_tools(user_id, name); CREATE INDEX IF NOT EXISTS idx_wasm_tools_status ON wasm_tools(status); +-- ==================== WASM Channel Extensions ==================== + +CREATE TABLE IF NOT EXISTS wasm_channels ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '0.1.0', + wit_version TEXT NOT NULL DEFAULT '0.1.0', + description TEXT NOT NULL DEFAULT '', + wasm_binary BLOB NOT NULL, + binary_hash BLOB NOT NULL, + capabilities_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, name) +); + -- ==================== Tool Capabilities ==================== CREATE TABLE IF NOT EXISTS tool_capabilities ( diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index c3e77e0d..664a9d16 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -637,6 +637,78 @@ impl ExtensionManager { } } + /// Get detailed info about an installed extension (version, wit_version, host compatibility). + pub async fn extension_info(&self, name: &str) -> Result { + Self::validate_extension_name(name)?; + let kind = self.determine_installed_kind(name).await?; + + match kind { + ExtensionKind::WasmTool => { + let cap_path = self + .wasm_tools_dir + .join(format!("{}.capabilities.json", name)); + let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name)); + + let mut info = serde_json::json!({ + "name": name, + "kind": "wasm_tool", + "installed": wasm_path.exists(), + }); + + if cap_path.exists() + && let Ok(bytes) = tokio::fs::read(&cap_path).await + && let Ok(cap) = crate::tools::wasm::CapabilitiesFile::from_bytes(&bytes) + { + info["version"] = + serde_json::json!(cap.version.unwrap_or_else(|| "unknown".into())); + info["wit_version"] = + serde_json::json!(cap.wit_version.unwrap_or_else(|| "unknown".into())); + } + + info["host_wit_version"] = serde_json::json!(crate::tools::wasm::WIT_TOOL_VERSION); + + Ok(info) + } + ExtensionKind::WasmChannel => { + let cap_path = self + .wasm_channels_dir + .join(format!("{}.capabilities.json", name)); + let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name)); + + let mut info = serde_json::json!({ + "name": name, + "kind": "wasm_channel", + "installed": wasm_path.exists(), + "active": self.active_channel_names.read().await.contains(name), + }); + + if cap_path.exists() + && let Ok(bytes) = tokio::fs::read(&cap_path).await + && let Ok(cap) = + crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes) + { + info["version"] = + serde_json::json!(cap.version.unwrap_or_else(|| "unknown".into())); + info["wit_version"] = + serde_json::json!(cap.wit_version.unwrap_or_else(|| "unknown".into())); + } + + info["host_wit_version"] = + serde_json::json!(crate::tools::wasm::WIT_CHANNEL_VERSION); + + Ok(info) + } + ExtensionKind::McpServer => { + let info = serde_json::json!({ + "name": name, + "kind": "mcp_server", + "connected": self.mcp_clients.read().await.contains_key(name), + }); + Ok(info) + } + } + } + // ── MCP config helpers (DB with disk fallback) ───────────────────── async fn load_mcp_servers( diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs index f82049e9..6943d935 100644 --- a/src/tools/builtin/extension_tools.rs +++ b/src/tools/builtin/extension_tools.rs @@ -496,6 +496,61 @@ impl Tool for ToolRemoveTool { } } +// ── extension_info ──────────────────────────────────────────────────── + +pub struct ExtensionInfoTool { + manager: Arc, +} + +impl ExtensionInfoTool { + pub fn new(manager: Arc) -> Self { + Self { manager } + } +} + +#[async_trait] +impl Tool for ExtensionInfoTool { + fn name(&self) -> &str { + "extension_info" + } + + fn description(&self) -> &str { + "Show detailed information about an installed extension, including version \ + and WIT version compatibility." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Extension name to get info about" + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = require_str(¶ms, "name")?; + + let info = self + .manager + .extension_info(name) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + + Ok(ToolOutput::success(info, start.elapsed())) + } +} + #[cfg(test)] mod tests { use super::*; @@ -588,6 +643,18 @@ mod tests { ); } + #[test] + fn test_extension_info_schema() { + let tool = ExtensionInfoTool { + manager: test_manager_stub(), + }; + assert_eq!(tool.name(), "extension_info"); + let schema = tool.parameters_schema(); + assert!(schema["properties"].get("name").is_some()); + let required = schema["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v.as_str() == Some("name"))); + } + /// Create a stub manager for schema tests (these don't call execute). fn test_manager_stub() -> Arc { use crate::secrets::{InMemorySecretsStore, SecretsCrypto}; diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs index 703f972a..4931e5b8 100644 --- a/src/tools/builtin/mod.rs +++ b/src/tools/builtin/mod.rs @@ -18,7 +18,8 @@ mod time; pub use echo::EchoTool; pub use extension_tools::{ - ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, + ExtensionInfoTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, + ToolRemoveTool, ToolSearchTool, }; pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool}; pub use http::HttpTool; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index a7b09b3f..62f1b05c 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -16,11 +16,12 @@ use crate::skills::catalog::SkillCatalog; use crate::skills::registry::SkillRegistry; use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder}; use crate::tools::builtin::{ - ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobEventsTool, JobPromptTool, - JobStatusTool, JsonTool, ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, - MemoryTreeTool, MemoryWriteTool, PromptQueue, ReadFileTool, ShellTool, SkillInstallTool, - SkillListTool, SkillRemoveTool, SkillSearchTool, TimeTool, ToolActivateTool, ToolAuthTool, - ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool, + ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool, + JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool, + MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool, PromptQueue, ReadFileTool, + ShellTool, SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool, TimeTool, + ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, + WriteFileTool, }; use crate::tools::rate_limiter::RateLimiter; use crate::tools::tool::{Tool, ToolDomain}; @@ -386,8 +387,9 @@ impl ToolRegistry { self.register_sync(Arc::new(ToolAuthTool::new(Arc::clone(&manager)))); self.register_sync(Arc::new(ToolActivateTool::new(Arc::clone(&manager)))); self.register_sync(Arc::new(ToolListTool::new(Arc::clone(&manager)))); - self.register_sync(Arc::new(ToolRemoveTool::new(manager))); - tracing::info!("Registered 6 extension management tools"); + self.register_sync(Arc::new(ToolRemoveTool::new(Arc::clone(&manager)))); + self.register_sync(Arc::new(ExtensionInfoTool::new(manager))); + tracing::info!("Registered 7 extension management tools"); } /// Register skill management tools (list, search, install, remove). diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index e5ff556d..9fa6e241 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -41,6 +41,14 @@ use crate::tools::wasm::{ /// Root schema for a capabilities JSON file. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CapabilitiesFile { + /// Extension version (semver). + #[serde(default)] + pub version: Option, + + /// WIT interface version this extension was compiled against (semver). + #[serde(default)] + pub wit_version: Option, + /// HTTP request capability. #[serde(default)] pub http: Option, diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index d332e25c..7c87e568 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -72,6 +72,9 @@ pub enum WasmLoadError { #[error("Invalid tool name: {0}")] InvalidName(String), + + #[error("WIT version mismatch: {0}")] + WitVersionMismatch(String), } /// Loads WASM tools from files or storage into the registry. @@ -127,6 +130,14 @@ impl WasmToolLoader { let cap_file = CapabilitiesFile::from_bytes(&cap_bytes) .map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?; cap_file.validate(name); + + // Check WIT version compatibility + check_wit_version_compat( + name, + cap_file.wit_version.as_deref(), + crate::tools::wasm::WIT_TOOL_VERSION, + )?; + let caps = cap_file.to_capabilities(); let oauth = resolve_oauth_refresh_config(&cap_file); (caps, oauth) @@ -310,6 +321,61 @@ impl WasmToolLoader { } } +/// Check that a declared WIT version is compatible with the host WIT version. +/// +/// Compatibility rules (semver): +/// - Same major version required (0.x is special: same minor required) +/// - Extension WIT version must not be greater than host version +/// +/// If `declared` is `None`, the check is skipped (pre-versioning extension). +pub(crate) fn check_wit_version_compat( + name: &str, + declared: Option<&str>, + host_version: &str, +) -> Result<(), WasmLoadError> { + let Some(declared_str) = declared else { + return Ok(()); + }; + + let declared = semver::Version::parse(declared_str).map_err(|e| { + WasmLoadError::WitVersionMismatch(format!( + "Extension '{name}' has invalid wit_version '{declared_str}': {e}" + )) + })?; + + let host = semver::Version::parse(host_version).map_err(|e| { + WasmLoadError::WitVersionMismatch(format!( + "Host WIT version '{host_version}' is invalid: {e}" + )) + })?; + + // Major version must match + if declared.major != host.major { + return Err(WasmLoadError::WitVersionMismatch(format!( + "Extension '{name}' compiled against WIT {declared}, but host supports WIT {host}. \ + Major version mismatch — rebuild the extension." + ))); + } + + // For 0.x versions, minor must also match (semver: 0.x.y has no compatibility guarantees) + if declared.major == 0 && declared.minor != host.minor { + return Err(WasmLoadError::WitVersionMismatch(format!( + "Extension '{name}' compiled against WIT {declared}, but host supports WIT {host}. \ + Rebuild the extension against the current WIT." + ))); + } + + // Extension cannot be newer than host + if declared > host { + return Err(WasmLoadError::WitVersionMismatch(format!( + "Extension '{name}' compiled against WIT {declared}, but host only supports WIT {host}. \ + Update the host or rebuild with an older WIT." + ))); + } + + Ok(()) +} + /// Extract OAuth refresh configuration from a parsed capabilities file. /// /// Returns `None` if there's no `auth.oauth` section or if the client_id @@ -615,7 +681,46 @@ mod tests { use tempfile::TempDir; - use crate::tools::wasm::loader::{WasmLoadError, discover_tools}; + use crate::tools::wasm::loader::{WasmLoadError, check_wit_version_compat, discover_tools}; + + #[test] + fn wit_version_compat_none_is_ok() { + // Pre-versioning extensions (no wit_version declared) should always pass + assert!(check_wit_version_compat("test", None, "0.2.0").is_ok()); + } + + #[test] + fn wit_version_compat_exact_match() { + assert!(check_wit_version_compat("test", Some("0.2.0"), "0.2.0").is_ok()); + } + + #[test] + fn wit_version_compat_patch_older_ok() { + // Extension on older patch of same minor is compatible + assert!(check_wit_version_compat("test", Some("0.2.0"), "0.2.1").is_ok()); + } + + #[test] + fn wit_version_compat_minor_mismatch_0x() { + // For 0.x, different minor is breaking + assert!(check_wit_version_compat("test", Some("0.1.0"), "0.2.0").is_err()); + assert!(check_wit_version_compat("test", Some("0.3.0"), "0.2.0").is_err()); + } + + #[test] + fn wit_version_compat_major_mismatch() { + assert!(check_wit_version_compat("test", Some("1.0.0"), "2.0.0").is_err()); + } + + #[test] + fn wit_version_compat_extension_newer_than_host() { + assert!(check_wit_version_compat("test", Some("0.2.1"), "0.2.0").is_err()); + } + + #[test] + fn wit_version_compat_invalid_version() { + assert!(check_wit_version_compat("test", Some("not-a-version"), "0.2.0").is_err()); + } #[tokio::test] async fn test_discover_tools_empty_dir() { diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index a3fe0b24..bd4f8ca3 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -73,6 +73,15 @@ //! let output = tool.execute(serde_json::json!({"input": "test"}), &ctx).await?; //! ``` +/// Host WIT version for tool extensions. +/// +/// Extensions declaring a `wit_version` in their capabilities file are checked +/// against this at load time: same major, not greater than host. +pub const WIT_TOOL_VERSION: &str = "0.2.0"; + +/// Host WIT version for channel extensions. +pub const WIT_CHANNEL_VERSION: &str = "0.2.0"; + mod allowlist; mod capabilities; mod capabilities_schema; @@ -80,10 +89,10 @@ pub(crate) mod credential_injector; mod error; mod host; mod limits; -mod loader; +pub(crate) mod loader; mod rate_limiter; mod runtime; -mod storage; +pub(crate) mod storage; mod wrapper; // Core types diff --git a/src/tools/wasm/storage.rs b/src/tools/wasm/storage.rs index a223c247..4e21104d 100644 --- a/src/tools/wasm/storage.rs +++ b/src/tools/wasm/storage.rs @@ -100,6 +100,7 @@ pub struct StoredWasmTool { pub user_id: String, pub name: String, pub version: String, + pub wit_version: String, pub description: String, pub parameters_schema: serde_json::Value, pub source_url: Option, @@ -244,6 +245,7 @@ pub struct StoreToolParams { pub user_id: String, pub name: String, pub version: String, + pub wit_version: String, pub description: String, pub wasm_binary: Vec, pub parameters_schema: serde_json::Value, @@ -280,7 +282,7 @@ impl PostgresWasmToolStore { #[async_trait] impl WasmToolStore for PostgresWasmToolStore { async fn store(&self, params: StoreToolParams) -> Result { - let client = self + let mut client = self .pool .get() .await @@ -290,22 +292,29 @@ impl WasmToolStore for PostgresWasmToolStore { let id = Uuid::new_v4(); let now = Utc::now(); - let row = client + // Wrap delete + insert in a transaction for atomicity + let tx = client + .transaction() + .await + .map_err(|e| WasmStorageError::Database(e.to_string()))?; + + // Delete any existing version for this (user_id, name) — upgrade-in-place + tx.execute( + "DELETE FROM wasm_tools WHERE user_id = $1 AND name = $2", + &[¶ms.user_id, ¶ms.name], + ) + .await + .map_err(|e| WasmStorageError::Database(e.to_string()))?; + + let row = tx .query_one( r#" INSERT INTO wasm_tools ( - id, user_id, name, version, description, wasm_binary, binary_hash, + id, user_id, name, version, wit_version, description, wasm_binary, binary_hash, parameters_schema, source_url, trust_level, status, created_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'active', $11, $11) - ON CONFLICT (user_id, name, version) DO UPDATE SET - description = EXCLUDED.description, - wasm_binary = EXCLUDED.wasm_binary, - binary_hash = EXCLUDED.binary_hash, - parameters_schema = EXCLUDED.parameters_schema, - source_url = EXCLUDED.source_url, - updated_at = NOW() - RETURNING id, user_id, name, version, description, parameters_schema, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', $12, $12) + RETURNING id, user_id, name, version, wit_version, description, parameters_schema, source_url, trust_level, status, created_at, updated_at "#, &[ @@ -313,6 +322,7 @@ impl WasmToolStore for PostgresWasmToolStore { ¶ms.user_id, ¶ms.name, ¶ms.version, + ¶ms.wit_version, ¶ms.description, ¶ms.wasm_binary, &binary_hash, @@ -325,7 +335,13 @@ impl WasmToolStore for PostgresWasmToolStore { .await .map_err(|e| WasmStorageError::Database(e.to_string()))?; - row_to_tool(&row) + let tool = row_to_tool(&row)?; + + tx.commit() + .await + .map_err(|e| WasmStorageError::Database(e.to_string()))?; + + Ok(tool) } async fn get(&self, user_id: &str, name: &str) -> Result { @@ -338,12 +354,10 @@ impl WasmToolStore for PostgresWasmToolStore { let row = client .query_opt( r#" - SELECT id, user_id, name, version, description, parameters_schema, + SELECT id, user_id, name, version, wit_version, description, parameters_schema, source_url, trust_level, status, created_at, updated_at FROM wasm_tools WHERE user_id = $1 AND name = $2 AND status = 'active' - ORDER BY version DESC - LIMIT 1 "#, &[&user_id, &name], ) @@ -377,12 +391,10 @@ impl WasmToolStore for PostgresWasmToolStore { let row = client .query_opt( r#" - SELECT id, user_id, name, version, description, wasm_binary, binary_hash, + SELECT id, user_id, name, version, wit_version, description, wasm_binary, binary_hash, parameters_schema, source_url, trust_level, status, created_at, updated_at FROM wasm_tools WHERE user_id = $1 AND name = $2 AND status = 'active' - ORDER BY version DESC - LIMIT 1 "#, &[&user_id, &name], ) @@ -482,11 +494,11 @@ impl WasmToolStore for PostgresWasmToolStore { let rows = client .query( r#" - SELECT DISTINCT ON (name) id, user_id, name, version, description, + SELECT id, user_id, name, version, wit_version, description, parameters_schema, source_url, trust_level, status, created_at, updated_at FROM wasm_tools WHERE user_id = $1 - ORDER BY name, version DESC + ORDER BY name "#, &[&user_id], ) @@ -552,6 +564,7 @@ fn row_to_tool(row: &tokio_postgres::Row) -> Result { let wasm_binary: Vec = row - .get(5) + .get(6) .map_err(|e| WasmStorageError::Database(e.to_string()))?; let binary_hash: Vec = row - .get(6) + .get(7) .map_err(|e| WasmStorageError::Database(e.to_string()))?; if !verify_binary_integrity(&wasm_binary, &binary_hash) { @@ -844,21 +853,14 @@ impl WasmToolStore for LibSqlWasmToolStore { } async fn list(&self, user_id: &str) -> Result, WasmStorageError> { - // SQLite doesn't have DISTINCT ON, so we use a subquery to get latest version per name let conn = self.connect().await?; let mut rows = conn .query( r#" - SELECT id, user_id, name, version, description, parameters_schema, + SELECT id, user_id, name, version, wit_version, description, parameters_schema, source_url, trust_level, status, created_at, updated_at FROM wasm_tools WHERE user_id = ?1 - AND rowid IN ( - SELECT MAX(rowid) - FROM wasm_tools - WHERE user_id = ?1 - GROUP BY name - ) ORDER BY name "#, libsql::params![user_id], @@ -941,22 +943,22 @@ fn libsql_wasm_parse_ts(s: &str) -> Result, WasmStorageError> { } /// Parse a tool row with standard column order (no binary columns). -/// Columns: id(0), user_id(1), name(2), version(3), description(4), -/// parameters_schema(5), source_url(6), trust_level(7), status(8), -/// created_at(9), updated_at(10) +/// Columns: id(0), user_id(1), name(2), version(3), wit_version(4), description(5), +/// parameters_schema(6), source_url(7), trust_level(8), status(9), +/// created_at(10), updated_at(11) #[cfg(feature = "libsql")] fn libsql_row_to_tool(row: &libsql::Row) -> Result { - libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) } /// Parse a tool row when binary columns are present (get_with_binary query). -/// Columns: id(0), user_id(1), name(2), version(3), description(4), -/// wasm_binary(5), binary_hash(6), -/// parameters_schema(7), source_url(8), trust_level(9), status(10), -/// created_at(11), updated_at(12) +/// Columns: id(0), user_id(1), name(2), version(3), wit_version(4), description(5), +/// wasm_binary(6), binary_hash(7), +/// parameters_schema(8), source_url(9), trust_level(10), status(11), +/// created_at(12), updated_at(13) #[cfg(feature = "libsql")] fn libsql_row_to_tool_with_offset(row: &libsql::Row) -> Result { - libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 7, 8, 9, 10, 11, 12) + libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13) } #[cfg(feature = "libsql")] @@ -967,6 +969,7 @@ fn libsql_row_to_tool_at( user_id_idx: i32, name_idx: i32, version_idx: i32, + wit_version_idx: i32, description_idx: i32, schema_idx: i32, source_url_idx: i32, @@ -1007,6 +1010,9 @@ fn libsql_row_to_tool_at( version: row .get(version_idx) .map_err(|e| WasmStorageError::Database(e.to_string()))?, + wit_version: row + .get(wit_version_idx) + .map_err(|e| WasmStorageError::Database(e.to_string()))?, description: row .get(description_idx) .map_err(|e| WasmStorageError::Database(e.to_string()))?, diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index 4328eb9e..a09c1c4f 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -589,8 +589,20 @@ impl WasmToolWrapper { Self::add_host_functions(&mut linker)?; // Instantiate using the generated bindings - let instance = SandboxedTool::instantiate(&mut store, &component, &linker) - .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?; + let instance = + SandboxedTool::instantiate(&mut store, &component, &linker).map_err(|e| { + let msg = e.to_string(); + if msg.contains("near:agent") || msg.contains("import") { + WasmError::InstantiationFailed(format!( + "{msg}. This usually means the extension was compiled against \ + a different WIT version than the host supports. \ + Rebuild the extension against the current WIT (host: {}).", + crate::tools::wasm::WIT_TOOL_VERSION + )) + } else { + WasmError::InstantiationFailed(msg) + } + })?; // Coerce string-encoded values to their schema-declared types. // LLMs frequently pass numeric values as strings (e.g. "5" instead of 5). diff --git a/tests/wit_compat.rs b/tests/wit_compat.rs index c317d5ba..ad302b38 100644 --- a/tests/wit_compat.rs +++ b/tests/wit_compat.rs @@ -214,22 +214,21 @@ fn instantiate_tool_component( // If the WIT added/removed/renamed a function, stub registration // or instantiation will fail. - { + // Register stubs for both versioned (0.2.0+) and unversioned (pre-0.2.0) interface + // paths so that both old and new WASM artifacts can instantiate. + for interface in &["near:agent/host", "near:agent/host@0.2.0"] { let mut root = linker.root(); - let mut host = root - .instance("near:agent/host") - .map_err(|e| format!("failed to create host instance: {e}"))?; + if let Ok(mut host) = root.instance(interface) { + stub_shared_host_functions(&mut host)?; - stub_shared_host_functions(&mut host)?; - - // tool-invoke is only in the tool host interface, not channel-host - host.func_new("tool-invoke", |_ctx, _args, results| { - results[0] = wasmtime::component::Val::Result(Err(Some(Box::new( - wasmtime::component::Val::String("stub".into()), - )))); - Ok(()) - }) - .map_err(|e| format!("stub 'tool-invoke': {e}"))?; + host.func_new("tool-invoke", |_ctx, _args, results| { + results[0] = wasmtime::component::Val::Result(Err(Some(Box::new( + wasmtime::component::Val::String("stub".into()), + )))); + Ok(()) + }) + .map_err(|e| format!("stub 'tool-invoke': {e}"))?; + } } let mut store = Store::new(engine, TestStoreData::new()); @@ -253,15 +252,15 @@ fn instantiate_channel_component( wasmtime_wasi::add_to_linker_sync(&mut linker) .map_err(|e| format!("WASI linker failed: {e}"))?; - { - let mut root = linker.root(); - let mut host = root - .instance("near:agent/channel-host") - .map_err(|e| format!("failed to create channel-host instance: {e}"))?; + // Register stubs for both versioned (0.2.0+) and unversioned (pre-0.2.0) interface + // paths so that both old and new WASM artifacts can instantiate. + // Register stubs under both versioned and unversioned interface paths. + // This helper avoids repeating the stub registration code. + fn stub_channel_host( + host: &mut wasmtime::component::LinkerInstance<'_, TestStoreData>, + ) -> Result<(), String> { + stub_shared_host_functions(host)?; - stub_shared_host_functions(&mut host)?; - - // Channel-specific host functions host.func_new("emit-message", |_ctx, _args, _results| Ok(())) .map_err(|e| format!("stub 'emit-message': {e}"))?; @@ -294,6 +293,23 @@ fn instantiate_channel_component( Ok(()) }) .map_err(|e| format!("stub 'pairing-read-allow-from': {e}"))?; + + Ok(()) + } + + { + let mut root = linker.root(); + let mut host = root + .instance("near:agent/channel-host") + .map_err(|e| format!("failed to create unversioned channel-host: {e}"))?; + stub_channel_host(&mut host)?; + } + { + let mut root = linker.root(); + let mut host = root + .instance("near:agent/channel-host@0.2.0") + .map_err(|e| format!("failed to create versioned channel-host: {e}"))?; + stub_channel_host(&mut host)?; } let mut store = Store::new(engine, TestStoreData::new()); @@ -477,3 +493,49 @@ fn wit_compat_all_registry_extensions_have_source() { missing.join("\n") ); } + +#[test] +fn wit_files_contain_version_annotation() { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + for wit_file in &["wit/tool.wit", "wit/channel.wit"] { + let path = repo_root.join(wit_file); + let content = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("failed to read {wit_file}: {e}")); + + assert!( + content.contains("package near:agent@"), + "{wit_file} must contain a versioned package declaration (e.g., 'package near:agent@0.2.0;')" + ); + } +} + +#[test] +fn wit_version_constants_match_wit_files() { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + let tool_wit = std::fs::read_to_string(repo_root.join("wit/tool.wit")) + .expect("failed to read wit/tool.wit"); + let channel_wit = std::fs::read_to_string(repo_root.join("wit/channel.wit")) + .expect("failed to read wit/channel.wit"); + + let expected_tool = format!( + "package near:agent@{};", + ironclaw::tools::wasm::WIT_TOOL_VERSION + ); + let expected_channel = format!( + "package near:agent@{};", + ironclaw::tools::wasm::WIT_CHANNEL_VERSION + ); + + assert!( + tool_wit.contains(&expected_tool), + "wit/tool.wit version must match WIT_TOOL_VERSION constant ({})", + ironclaw::tools::wasm::WIT_TOOL_VERSION + ); + assert!( + channel_wit.contains(&expected_channel), + "wit/channel.wit version must match WIT_CHANNEL_VERSION constant ({})", + ironclaw::tools::wasm::WIT_CHANNEL_VERSION + ); +} diff --git a/tools-src/github/github-tool.capabilities.json b/tools-src/github/github-tool.capabilities.json index 0c37b006..bd92dcf5 100644 --- a/tools-src/github/github-tool.capabilities.json +++ b/tools-src/github/github-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "capabilities": { "http": { "allowlist": [ diff --git a/tools-src/gmail/gmail-tool.capabilities.json b/tools-src/gmail/gmail-tool.capabilities.json index e3f8a79b..1ddafe7e 100644 --- a/tools-src/gmail/gmail-tool.capabilities.json +++ b/tools-src/gmail/gmail-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "http": { "allowlist": [ { diff --git a/tools-src/google-calendar/google-calendar-tool.capabilities.json b/tools-src/google-calendar/google-calendar-tool.capabilities.json index 0a37772b..86dd0c3c 100644 --- a/tools-src/google-calendar/google-calendar-tool.capabilities.json +++ b/tools-src/google-calendar/google-calendar-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "http": { "allowlist": [ { diff --git a/tools-src/google-docs/google-docs-tool.capabilities.json b/tools-src/google-docs/google-docs-tool.capabilities.json index 2bab1abb..386b0ba3 100644 --- a/tools-src/google-docs/google-docs-tool.capabilities.json +++ b/tools-src/google-docs/google-docs-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "http": { "allowlist": [ { diff --git a/tools-src/google-drive/google-drive-tool.capabilities.json b/tools-src/google-drive/google-drive-tool.capabilities.json index cc49db8c..aa741fd6 100644 --- a/tools-src/google-drive/google-drive-tool.capabilities.json +++ b/tools-src/google-drive/google-drive-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "http": { "allowlist": [ { diff --git a/tools-src/google-sheets/google-sheets-tool.capabilities.json b/tools-src/google-sheets/google-sheets-tool.capabilities.json index 23f7f46b..97da6197 100644 --- a/tools-src/google-sheets/google-sheets-tool.capabilities.json +++ b/tools-src/google-sheets/google-sheets-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "http": { "allowlist": [ { diff --git a/tools-src/google-slides/google-slides-tool.capabilities.json b/tools-src/google-slides/google-slides-tool.capabilities.json index e5920c71..31e5c734 100644 --- a/tools-src/google-slides/google-slides-tool.capabilities.json +++ b/tools-src/google-slides/google-slides-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "http": { "allowlist": [ { diff --git a/tools-src/slack/slack-tool.capabilities.json b/tools-src/slack/slack-tool.capabilities.json index d6119e45..742e349a 100644 --- a/tools-src/slack/slack-tool.capabilities.json +++ b/tools-src/slack/slack-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "http": { "allowlist": [ { diff --git a/tools-src/telegram/telegram-tool.capabilities.json b/tools-src/telegram/telegram-tool.capabilities.json index 869081e9..cd42b5be 100644 --- a/tools-src/telegram/telegram-tool.capabilities.json +++ b/tools-src/telegram/telegram-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "http": { "allowlist": [ { diff --git a/tools-src/web-search/web-search-tool.capabilities.json b/tools-src/web-search/web-search-tool.capabilities.json index 56455114..8ee5b4ac 100644 --- a/tools-src/web-search/web-search-tool.capabilities.json +++ b/tools-src/web-search/web-search-tool.capabilities.json @@ -1,4 +1,6 @@ { + "version": "0.1.0", + "wit_version": "0.2.0", "capabilities": { "http": { "allowlist": [ diff --git a/wit/channel.wit b/wit/channel.wit index 6333e3cd..f41db16d 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -38,7 +38,7 @@ // - Workspace writes are prefixed with channels// to prevent escape // - Message emission is rate-limited -package near:agent; +package near:agent@0.2.0; /// Host-provided capabilities for sandboxed channels. /// diff --git a/wit/tool.wit b/wit/tool.wit index 743a0121..aef3e22d 100644 --- a/wit/tool.wit +++ b/wit/tool.wit @@ -9,7 +9,7 @@ // - Secrets are NEVER exposed to WASM; credentials are injected at host boundary // - All outputs are scanned for secret leakage before returning to WASM -package near:agent; +package near:agent@0.2.0; /// Host-provided capabilities for sandboxed tools. ///