mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
a516e92156
commit
04c5c3fe9f
@@ -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
@@ -2880,6 +2880,7 @@ dependencies = [
|
||||
"secrecy",
|
||||
"secret-service",
|
||||
"security-framework",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yml",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+251
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶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<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(¶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<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)?,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(¶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<ExtensionManager> {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
&[¶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<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(¶ms.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()))?,
|
||||
|
||||
@@ -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).
|
||||
|
||||
+76
-14
@@ -214,15 +214,13 @@ 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)?;
|
||||
|
||||
// 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()),
|
||||
@@ -231,6 +229,7 @@ fn instantiate_tool_component(
|
||||
})
|
||||
.map_err(|e| format!("stub 'tool-invoke': {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut store = Store::new(engine, TestStoreData::new());
|
||||
linker
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user