feat: WASM extension versioning with WIT compat checks (#592)

* feat: add WASM extension versioning with WIT compat checks and CI enforcement

Phase 1 — WIT Versioning & Compatibility Checks:
- Version WIT packages as `package near:[email protected];`
- 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-06 04:38:07 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a516e92156
commit 04c5c3fe9f
52 changed files with 1519 additions and 99 deletions
+20 -1
View File
@@ -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
Generated
+1
View File
@@ -2880,6 +2880,7 @@ dependencies = [
"secrecy",
"secret-service",
"security-framework",
"semver",
"serde",
"serde_json",
"serde_yml",
+3
View File
@@ -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"] }
@@ -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",
@@ -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",
@@ -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",
@@ -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",
+19
View File
@@ -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)
);
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+251
View File
@@ -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:[email protected];
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:-<none>} -> ${NEW_VER:-<missing>}"
if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then
echo " ERROR: wit/tool.wit package version was not bumped (${OLD_VER} -> ${NEW_VER:-<missing>})."
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:-<none>} -> ${NEW_VER:-<missing>}"
if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then
echo " ERROR: wit/channel.wit package version was not bumped (${OLD_VER} -> ${NEW_VER:-<missing>})."
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:-<none>} -> ${NEW_VER:-<missing>}"
if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then
echo " ERROR: ${REGISTRY_FILE} version was not bumped (${OLD_VER} -> ${NEW_VER:-<missing>}). 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:-<none>} -> ${NEW_VER:-<missing>}"
if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then
echo " ERROR: ${REGISTRY_FILE} version was not bumped (${OLD_VER} -> ${NEW_VER:-<missing>}). 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
+3
View File
@@ -80,6 +80,9 @@ pub enum WasmChannelError {
#[error("HTTP request error: {0}")]
HttpRequest(String),
#[error("WIT version mismatch: {0}")]
IncompatibleWitVersion(String),
}
impl From<crate::tools::wasm::WasmError> for WasmChannelError {
+8
View File
@@ -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
+2
View File
@@ -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
+8
View File
@@ -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<String>,
/// WIT interface version this channel was compiled against (semver).
#[serde(default)]
pub wit_version: Option<String>,
/// File type, must be "channel".
#[serde(default = "default_type")]
pub r#type: String,
+690
View File
@@ -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<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Full channel data including binary.
#[derive(Debug)]
pub struct StoredWasmChannelWithBinary {
pub channel: StoredWasmChannel,
pub wasm_binary: Vec<u8>,
pub binary_hash: Vec<u8>,
}
/// 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<u8>,
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<StoredWasmChannel, WasmChannelStoreError>;
/// Get channel metadata (without binary).
async fn get(
&self,
user_id: &str,
name: &str,
) -> Result<StoredWasmChannel, WasmChannelStoreError>;
/// Get channel with binary (verifies integrity).
async fn get_with_binary(
&self,
user_id: &str,
name: &str,
) -> Result<StoredWasmChannelWithBinary, WasmChannelStoreError>;
/// List all channels for a user.
async fn list(&self, user_id: &str) -> Result<Vec<StoredWasmChannel>, WasmChannelStoreError>;
/// Delete a channel.
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, WasmChannelStoreError>;
}
// ==================== 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<StoredWasmChannel, WasmChannelStoreError> {
let mut client = self
.pool
.get()
.await
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
let binary_hash = compute_binary_hash(&params.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",
&[&params.user_id, &params.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,
&params.user_id,
&params.name,
&params.version,
&params.wit_version,
&params.description,
&params.wasm_binary,
&binary_hash,
&params.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<StoredWasmChannel, WasmChannelStoreError> {
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<StoredWasmChannelWithBinary, WasmChannelStoreError> {
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<u8> = r.get("wasm_binary");
let binary_hash: Vec<u8> = 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<Vec<StoredWasmChannel>, 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<bool, WasmChannelStoreError> {
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<StoredWasmChannel, WasmChannelStoreError> {
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<Database>` 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<libsql::Database>,
}
#[cfg(feature = "libsql")]
impl LibSqlWasmChannelStore {
pub fn new(db: std::sync::Arc<libsql::Database>) -> Self {
Self { db }
}
async fn connect(&self) -> Result<libsql::Connection, WasmChannelStoreError> {
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<StoredWasmChannel, WasmChannelStoreError> {
let binary_hash = compute_binary_hash(&params.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<StoredWasmChannel, 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 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<StoredWasmChannelWithBinary, WasmChannelStoreError> {
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<u8> = row
.get(6)
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
let binary_hash: Vec<u8> = 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<Vec<StoredWasmChannel>, 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<bool, WasmChannelStoreError> {
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<DateTime<Utc>, 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<StoredWasmChannel, WasmChannelStoreError> {
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<StoredWasmChannel, WasmChannelStoreError> {
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)?,
})
}
+13 -2
View File
@@ -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)
}
+19
View File
@@ -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 (
+72
View File
@@ -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<serde_json::Value, ExtensionError> {
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(
+67
View File
@@ -496,6 +496,61 @@ impl Tool for ToolRemoveTool {
}
}
// ── extension_info ────────────────────────────────────────────────────
pub struct ExtensionInfoTool {
manager: Arc<ExtensionManager>,
}
impl ExtensionInfoTool {
pub fn new(manager: Arc<ExtensionManager>) -> 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<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "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<ExtensionManager> {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
+2 -1
View File
@@ -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;
+9 -7
View File
@@ -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).
+8
View File
@@ -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<String>,
/// WIT interface version this extension was compiled against (semver).
#[serde(default)]
pub wit_version: Option<String>,
/// HTTP request capability.
#[serde(default)]
pub http: Option<HttpCapabilitySchema>,
+106 -1
View File
@@ -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() {
+11 -2
View File
@@ -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
+65 -59
View File
@@ -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<String>,
@@ -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<u8>,
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<StoredWasmTool, WasmStorageError> {
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",
&[&params.user_id, &params.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 {
&params.user_id,
&params.name,
&params.version,
&params.wit_version,
&params.description,
&params.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<StoredWasmTool, WasmStorageError> {
@@ -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<StoredWasmTool, WasmStorageE
user_id: row.get("user_id"),
name: row.get("name"),
version: row.get("version"),
wit_version: row.get("wit_version"),
description: row.get("description"),
parameters_schema: row.get("parameters_schema"),
source_url: row.get("source_url"),
@@ -605,33 +618,35 @@ impl WasmToolStore for LibSqlWasmToolStore {
let schema_str = serde_json::to_string(&params.parameters_schema)
.map_err(|e| WasmStorageError::InvalidData(e.to_string()))?;
// Wrap INSERT + read-back in a transaction to prevent TOCTOU races
// Wrap delete + INSERT + read-back in a transaction
let conn = self.connect().await?;
let tx = conn
.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",
libsql::params![params.user_id.as_str(), params.name.as_str()],
)
.await
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
tx.execute(
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 = ?11
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'active', ?12, ?12)
"#,
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),
@@ -648,12 +663,10 @@ impl WasmToolStore for LibSqlWasmToolStore {
let mut rows = tx
.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 name = ?2
ORDER BY version DESC
LIMIT 1
"#,
libsql::params![params.user_id.as_str(), params.name.as_str()],
)
@@ -682,12 +695,10 @@ impl WasmToolStore for LibSqlWasmToolStore {
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 name = ?2 AND status = 'active'
ORDER BY version DESC
LIMIT 1
"#,
libsql::params![user_id, name],
)
@@ -720,12 +731,10 @@ impl WasmToolStore for LibSqlWasmToolStore {
let mut rows = conn
.query(
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
"#,
libsql::params![user_id, name],
)
@@ -739,10 +748,10 @@ impl WasmToolStore for LibSqlWasmToolStore {
{
Some(row) => {
let wasm_binary: Vec<u8> = row
.get(5)
.get(6)
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
let binary_hash: Vec<u8> = 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<Vec<StoredWasmTool>, 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<DateTime<Utc>, 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<StoredWasmTool, WasmStorageError> {
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<StoredWasmTool, WasmStorageError> {
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()))?,
+14 -2
View File
@@ -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).
+84 -22
View File
@@ -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/[email protected]"] {
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/[email protected]")
.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:[email protected];')"
);
}
}
#[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
);
}
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"capabilities": {
"http": {
"allowlist": [
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"http": {
"allowlist": [
{
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"http": {
"allowlist": [
{
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"http": {
"allowlist": [
{
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"http": {
"allowlist": [
{
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"http": {
"allowlist": [
{
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"http": {
"allowlist": [
{
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"http": {
"allowlist": [
{
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"http": {
"allowlist": [
{
@@ -1,4 +1,6 @@
{
"version": "0.1.0",
"wit_version": "0.2.0",
"capabilities": {
"http": {
"allowlist": [
+1 -1
View File
@@ -38,7 +38,7 @@
// - Workspace writes are prefixed with channels/<name>/ to prevent escape
// - Message emission is rate-limited
package near:agent;
package near:agent@0.2.0;
/// Host-provided capabilities for sandboxed channels.
///
+1 -1
View File
@@ -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.
///