diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0d7cc773..783c1c50 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,6 +46,27 @@ jobs: - name: Run Telegram Channel Tests run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture + wasm-wit-compat: + name: WASM WIT Compatibility + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + profile: minimal + targets: wasm32-wasip2 + - uses: Swatinem/rust-cache@v2 + with: + key: wasm-extensions + - name: Install cargo-component + run: cargo install cargo-component --locked || true + - name: Build all WASM extensions against current WIT + run: ./scripts/build-wasm-extensions.sh + - name: Instantiation test (host linker compatibility) + run: cargo test --all-features wit_compat -- --nocapture + docker-build: name: Docker Build runs-on: ubuntu-latest @@ -60,10 +81,10 @@ jobs: name: Run Tests runs-on: ubuntu-latest if: always() - needs: [tests, telegram-tests, docker-build] + needs: [tests, telegram-tests, wasm-wit-compat, docker-build] steps: - run: | - if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" ]]; then + 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 diff --git a/scripts/build-wasm-extensions.sh b/scripts/build-wasm-extensions.sh new file mode 100755 index 00000000..165bd6de --- /dev/null +++ b/scripts/build-wasm-extensions.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Build all WASM tools and channels from source. +# +# Verifies that every tool/channel in the registry compiles against the +# current WIT definitions. Used by CI and can be run locally. +# +# Prerequisites: +# rustup target add wasm32-wasip2 +# cargo install cargo-component --locked +# +# Usage: +# ./scripts/build-wasm-extensions.sh # build all +# ./scripts/build-wasm-extensions.sh --tools # tools only +# ./scripts/build-wasm-extensions.sh --channels # channels only + +set -euo pipefail + +cd "$(dirname "$0")/.." + +BUILD_TOOLS=true +BUILD_CHANNELS=true +FAILED=() + +if [[ "${1:-}" == "--tools" ]]; then + BUILD_CHANNELS=false +elif [[ "${1:-}" == "--channels" ]]; then + BUILD_TOOLS=false +fi + +build_extension() { + local manifest_path="$1" + local source_dir + local crate_name + + source_dir=$(jq -r '.source.dir' "$manifest_path") + crate_name=$(jq -r '.source.crate_name' "$manifest_path") + local name + name=$(basename "$manifest_path" .json) + + if [ ! -d "$source_dir" ]; then + echo " SKIP $name (source dir $source_dir not found)" + return 0 + fi + + echo " BUILD $name ($crate_name) from $source_dir" + if ! cargo component build --release --manifest-path "$source_dir/Cargo.toml" 2>&1; then + echo " FAIL $name" + FAILED+=("$name") + return 1 + fi + echo " OK $name" +} + +if $BUILD_TOOLS; then + echo "Building WASM tools..." + for manifest in registry/tools/*.json; do + build_extension "$manifest" || true + done +fi + +if $BUILD_CHANNELS; then + echo "Building WASM channels..." + for manifest in registry/channels/*.json; do + build_extension "$manifest" || true + done +fi + +echo "" +if [ ${#FAILED[@]} -gt 0 ]; then + echo "FAILED: ${FAILED[*]}" + exit 1 +else + echo "All WASM extensions built successfully." +fi diff --git a/tests/wit_compat.rs b/tests/wit_compat.rs new file mode 100644 index 00000000..c317d5ba --- /dev/null +++ b/tests/wit_compat.rs @@ -0,0 +1,479 @@ +//! 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 { + 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 { + 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(®istry_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::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 = 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. + { + let mut root = linker.root(); + let mut host = root + .instance("near:agent/host") + .map_err(|e| format!("failed to create host instance: {e}"))?; + + stub_shared_host_functions(&mut host)?; + + // tool-invoke is only in the tool host interface, not channel-host + host.func_new("tool-invoke", |_ctx, _args, results| { + results[0] = wasmtime::component::Val::Result(Err(Some(Box::new( + wasmtime::component::Val::String("stub".into()), + )))); + Ok(()) + }) + .map_err(|e| format!("stub 'tool-invoke': {e}"))?; + } + + 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 = Linker::new(engine); + + 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}"))?; + + 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}"))?; + + 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}"))?; + } + + 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 = 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 = 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(®istry_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") + ); +}