Files
optimclaw/tests/wit_compat.rs
T
04c5c3fe9f 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]>
2026-03-06 04:38:07 +00:00

542 lines
18 KiB
Rust

//! WIT compatibility tests for WASM tools and channels.
//!
//! These tests verify that pre-built WASM components can be compiled and
//! instantiated against the current host linker. If the WIT interface
//! changes, these tests catch any breakage in existing tools/channels.
//!
//! Prerequisites: build WASM extensions first with:
//! ./scripts/build-wasm-extensions.sh
//!
//! The tests are skipped (not failed) when no WASM artifacts are found,
//! so `cargo test` still passes without building extensions first.
//! CI runs the build script before these tests.
use std::path::{Path, PathBuf};
use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
/// Minimal store data that satisfies WasiView for component instantiation.
struct TestStoreData {
wasi: WasiCtx,
table: ResourceTable,
}
impl TestStoreData {
fn new() -> Self {
Self {
wasi: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
}
}
}
impl WasiView for TestStoreData {
fn ctx(&mut self) -> &mut WasiCtx {
&mut self.wasi
}
fn table(&mut self) -> &mut ResourceTable {
&mut self.table
}
}
/// Extension kind from the registry manifest.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExtensionKind {
Tool,
Channel,
}
/// A discovered WASM extension from the registry.
struct DiscoveredExtension {
name: String,
source_dir: PathBuf,
crate_name: String,
kind: ExtensionKind,
}
/// Search paths for WASM artifacts produced by cargo-component.
fn find_wasm_artifact(source_dir: &Path, crate_name: &str) -> Option<PathBuf> {
let artifact_name = crate_name.replace('-', "_");
// Crate-local target dir (CI, default cargo)
for target_triple in &["wasm32-wasip2", "wasm32-wasip1", "wasm32-wasi"] {
let candidate = source_dir
.join("target")
.join(target_triple)
.join("release")
.join(format!("{artifact_name}.wasm"));
if candidate.exists() {
return Some(candidate);
}
}
// Shared target dir (CARGO_TARGET_DIR env)
if let Ok(shared) = std::env::var("CARGO_TARGET_DIR") {
for target_triple in &["wasm32-wasip2", "wasm32-wasip1", "wasm32-wasi"] {
let candidate = Path::new(&shared)
.join(target_triple)
.join("release")
.join(format!("{artifact_name}.wasm"));
if candidate.exists() {
return Some(candidate);
}
}
}
// Common shared target location (~/.cargo/shared-target)
if let Some(home) = dirs::home_dir() {
let shared = home.join(".cargo/shared-target");
if shared.exists() {
for target_triple in &["wasm32-wasip2", "wasm32-wasip1", "wasm32-wasi"] {
let candidate = shared
.join(target_triple)
.join("release")
.join(format!("{artifact_name}.wasm"));
if candidate.exists() {
return Some(candidate);
}
}
}
}
None
}
/// Parse registry manifests to discover all WASM extensions.
fn discover_extensions() -> Vec<DiscoveredExtension> {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut extensions = Vec::new();
for dir in &["registry/tools", "registry/channels"] {
let registry_dir = repo_root.join(dir);
if !registry_dir.exists() {
continue;
}
for entry in std::fs::read_dir(&registry_dir).expect("failed to read registry dir") {
let entry = entry.expect("failed to read directory entry");
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = std::fs::read_to_string(&path).expect("failed to read manifest");
let manifest: serde_json::Value =
serde_json::from_str(&content).expect("failed to parse manifest");
let name = manifest["name"].as_str().unwrap_or("unknown").to_string();
let kind = match manifest["kind"].as_str() {
Some("tool") => ExtensionKind::Tool,
Some("channel") => ExtensionKind::Channel,
_ => continue,
};
let source_dir = manifest["source"]["dir"]
.as_str()
.map(|d| repo_root.join(d));
let crate_name = manifest["source"]["crate_name"]
.as_str()
.map(|s| s.to_string());
if let (Some(source_dir), Some(crate_name)) = (source_dir, crate_name)
&& source_dir.exists()
{
extensions.push(DiscoveredExtension {
name,
source_dir,
crate_name,
kind,
});
}
}
}
extensions
}
fn compile_component(
engine: &wasmtime::Engine,
wasm_bytes: &[u8],
) -> Result<wasmtime::component::Component, String> {
wasmtime::component::Component::new(engine, wasm_bytes)
.map_err(|e| format!("compilation failed: {e}"))
}
/// Stub host functions shared between tool and channel interfaces:
/// log, now-millis, workspace-read, http-request, secret-exists.
fn stub_shared_host_functions(
host: &mut wasmtime::component::LinkerInstance<'_, TestStoreData>,
) -> Result<(), String> {
host.func_new("log", |_ctx, _args, _results| Ok(()))
.map_err(|e| format!("stub 'log': {e}"))?;
host.func_new("now-millis", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::U64(0);
Ok(())
})
.map_err(|e| format!("stub 'now-millis': {e}"))?;
host.func_new("workspace-read", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Option(None);
Ok(())
})
.map_err(|e| format!("stub 'workspace-read': {e}"))?;
host.func_new("http-request", |_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 'http-request': {e}"))?;
host.func_new("secret-exists", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Bool(false);
Ok(())
})
.map_err(|e| format!("stub 'secret-exists': {e}"))?;
Ok(())
}
/// Instantiate a tool component (world: sandboxed-tool, imports: near:agent/host).
fn instantiate_tool_component(
engine: &wasmtime::Engine,
component: &wasmtime::component::Component,
) -> Result<(), String> {
use wasmtime::Store;
use wasmtime::component::Linker;
let mut linker: Linker<TestStoreData> = Linker::new(engine);
wasmtime_wasi::add_to_linker_sync(&mut linker)
.map_err(|e| format!("WASI linker failed: {e}"))?;
// 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();
if let Ok(mut host) = root.instance(interface) {
stub_shared_host_functions(&mut 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}"))?;
}
}
let mut store = Store::new(engine, TestStoreData::new());
linker
.instantiate(&mut store, component)
.map_err(|e| format!("instantiation failed: {e}"))?;
Ok(())
}
/// Instantiate a channel component (world: sandboxed-channel, imports: near:agent/channel-host).
fn instantiate_channel_component(
engine: &wasmtime::Engine,
component: &wasmtime::component::Component,
) -> Result<(), String> {
use wasmtime::Store;
use wasmtime::component::Linker;
let mut linker: Linker<TestStoreData> = Linker::new(engine);
wasmtime_wasi::add_to_linker_sync(&mut linker)
.map_err(|e| format!("WASI linker failed: {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)?;
host.func_new("emit-message", |_ctx, _args, _results| Ok(()))
.map_err(|e| format!("stub 'emit-message': {e}"))?;
host.func_new("workspace-write", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Ok(None));
Ok(())
})
.map_err(|e| format!("stub 'workspace-write': {e}"))?;
host.func_new("pairing-upsert-request", |_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 'pairing-upsert-request': {e}"))?;
host.func_new("pairing-is-allowed", |_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 'pairing-is-allowed': {e}"))?;
host.func_new("pairing-read-allow-from", |_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 '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());
linker
.instantiate(&mut store, component)
.map_err(|e| format!("instantiation failed: {e}"))?;
Ok(())
}
fn create_engine() -> wasmtime::Engine {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
config.wasm_threads(false);
wasmtime::Engine::new(&config).expect("failed to create wasmtime engine")
}
#[test]
fn wit_compat_tool_components_compile_and_instantiate() {
let extensions = discover_extensions();
let engine = create_engine();
let tool_extensions: Vec<_> = extensions
.iter()
.filter(|ext| ext.kind == ExtensionKind::Tool)
.collect();
if tool_extensions.is_empty() {
eprintln!("SKIP: no tool extensions found in registry");
return;
}
let mut found_any = false;
let mut failures: Vec<String> = Vec::new();
for ext in &tool_extensions {
let wasm_path = match find_wasm_artifact(&ext.source_dir, &ext.crate_name) {
Some(p) => p,
None => {
eprintln!(
" SKIP {}: no built WASM artifact (run ./scripts/build-wasm-extensions.sh)",
ext.name
);
continue;
}
};
found_any = true;
eprintln!(" TEST {}: {}", ext.name, wasm_path.display());
let wasm_bytes = std::fs::read(&wasm_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", wasm_path.display()));
let component = match compile_component(&engine, &wasm_bytes) {
Ok(c) => c,
Err(e) => {
failures.push(format!("{}: {e}", ext.name));
continue;
}
};
if let Err(e) = instantiate_tool_component(&engine, &component) {
failures.push(format!("{}: {e}", ext.name));
}
}
if !found_any {
eprintln!("SKIP: no WASM artifacts found (build extensions first)");
return;
}
assert!(
failures.is_empty(),
"WIT compatibility failures for tools:\n{}",
failures.join("\n")
);
}
#[test]
fn wit_compat_channel_components_compile_and_instantiate() {
let extensions = discover_extensions();
let engine = create_engine();
let channel_extensions: Vec<_> = extensions
.iter()
.filter(|ext| ext.kind == ExtensionKind::Channel)
.collect();
if channel_extensions.is_empty() {
eprintln!("SKIP: no channel extensions found in registry");
return;
}
let mut found_any = false;
let mut failures: Vec<String> = Vec::new();
for ext in &channel_extensions {
let wasm_path = match find_wasm_artifact(&ext.source_dir, &ext.crate_name) {
Some(p) => p,
None => {
eprintln!(
" SKIP {}: no built WASM artifact (run ./scripts/build-wasm-extensions.sh)",
ext.name
);
continue;
}
};
found_any = true;
eprintln!(" TEST {}: {}", ext.name, wasm_path.display());
let wasm_bytes = std::fs::read(&wasm_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", wasm_path.display()));
let component = match compile_component(&engine, &wasm_bytes) {
Ok(c) => c,
Err(e) => {
failures.push(format!("{}: {e}", ext.name));
continue;
}
};
if let Err(e) = instantiate_channel_component(&engine, &component) {
failures.push(format!("{}: {e}", ext.name));
}
}
if !found_any {
eprintln!("SKIP: no WASM artifacts found (build extensions first)");
return;
}
assert!(
failures.is_empty(),
"WIT compatibility failures for channels:\n{}",
failures.join("\n")
);
}
#[test]
fn wit_compat_all_registry_extensions_have_source() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut missing = Vec::new();
for dir in &["registry/tools", "registry/channels"] {
let registry_dir = repo_root.join(dir);
if !registry_dir.exists() {
continue;
}
for entry in std::fs::read_dir(&registry_dir).expect("failed to read registry dir") {
let entry = entry.expect("failed to read directory entry");
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = std::fs::read_to_string(&path).unwrap();
let manifest: serde_json::Value = serde_json::from_str(&content).unwrap();
let name = manifest["name"].as_str().unwrap_or("unknown");
let source_dir = manifest["source"]["dir"].as_str();
let crate_name = manifest["source"]["crate_name"].as_str();
match (source_dir, crate_name) {
(Some(d), Some(_)) => {
if !repo_root.join(d).exists() {
missing.push(format!("{name}: source dir '{d}' does not exist"));
}
}
_ => {
missing.push(format!("{name}: missing source.dir or source.crate_name"));
}
}
}
}
assert!(
missing.is_empty(),
"Registry entries with missing sources:\n{}",
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
);
}