mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Implementing WASM runtime
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@ DATABASE_POOL_SIZE=10
|
||||
# NEAR AI provides a unified interface to all models with user authentication
|
||||
NEARAI_SESSION_TOKEN=sess_...
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://api.near.ai
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
|
||||
# Channel Configuration
|
||||
# CLI is always enabled
|
||||
|
||||
@@ -165,7 +165,7 @@ DATABASE_URL=postgres://user:pass@localhost/near_agent
|
||||
# NEAR AI (required)
|
||||
NEARAI_SESSION_TOKEN=sess_...
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://api.near.ai
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
|
||||
# Agent settings
|
||||
AGENT_NAME=near-agent
|
||||
|
||||
Generated
+974
-6
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,9 @@ secrecy = { version = "0.10", features = ["serde"] }
|
||||
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
|
||||
pgvector = { version = "0.4", features = ["postgres"] }
|
||||
|
||||
# WASM sandbox for untrusted tool execution
|
||||
wasmtime = { version = "28", features = ["component-model"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Configuration for the NEAR Agent.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
@@ -14,6 +15,7 @@ pub struct Config {
|
||||
pub channels: ChannelsConfig,
|
||||
pub agent: AgentConfig,
|
||||
pub safety: SafetyConfig,
|
||||
pub wasm: WasmConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -28,6 +30,7 @@ impl Config {
|
||||
channels: ChannelsConfig::from_env()?,
|
||||
agent: AgentConfig::from_env()?,
|
||||
safety: SafetyConfig::from_env()?,
|
||||
wasm: WasmConfig::from_env()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -230,6 +233,87 @@ impl SafetyConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM sandbox configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WasmConfig {
|
||||
/// Whether WASM tool execution is enabled.
|
||||
pub enabled: bool,
|
||||
/// Default memory limit in bytes (default: 10 MB).
|
||||
pub default_memory_limit: u64,
|
||||
/// Default execution timeout in seconds (default: 60).
|
||||
pub default_timeout_secs: u64,
|
||||
/// Default fuel limit for CPU metering (default: 10M).
|
||||
pub default_fuel_limit: u64,
|
||||
/// Whether to cache compiled modules.
|
||||
pub cache_compiled: bool,
|
||||
/// Directory for compiled module cache.
|
||||
pub cache_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for WasmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
default_memory_limit: 10 * 1024 * 1024, // 10 MB
|
||||
default_timeout_secs: 60,
|
||||
default_fuel_limit: 10_000_000,
|
||||
cache_compiled: true,
|
||||
cache_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WasmConfig {
|
||||
fn from_env() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: optional_env("WASM_ENABLED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "WASM_ENABLED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
default_memory_limit: parse_optional_env(
|
||||
"WASM_DEFAULT_MEMORY_LIMIT",
|
||||
10 * 1024 * 1024,
|
||||
)?,
|
||||
default_timeout_secs: parse_optional_env("WASM_DEFAULT_TIMEOUT_SECS", 60)?,
|
||||
default_fuel_limit: parse_optional_env("WASM_DEFAULT_FUEL_LIMIT", 10_000_000)?,
|
||||
cache_compiled: optional_env("WASM_CACHE_COMPILED")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "WASM_CACHE_COMPILED".to_string(),
|
||||
message: format!("must be 'true' or 'false': {e}"),
|
||||
})?
|
||||
.unwrap_or(true),
|
||||
cache_dir: optional_env("WASM_CACHE_DIR")?.map(PathBuf::from),
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert to WasmRuntimeConfig.
|
||||
pub fn to_runtime_config(&self) -> crate::tools::wasm::WasmRuntimeConfig {
|
||||
use crate::tools::wasm::{FuelConfig, ResourceLimits, WasmRuntimeConfig};
|
||||
use std::time::Duration;
|
||||
|
||||
WasmRuntimeConfig {
|
||||
default_limits: ResourceLimits {
|
||||
memory_bytes: self.default_memory_limit,
|
||||
fuel: self.default_fuel_limit,
|
||||
timeout: Duration::from_secs(self.default_timeout_secs),
|
||||
},
|
||||
fuel_config: FuelConfig {
|
||||
initial_fuel: self.default_fuel_limit,
|
||||
enabled: true,
|
||||
},
|
||||
cache_compiled: self.cache_compiled,
|
||||
cache_dir: self.cache_dir.clone(),
|
||||
optimization_level: wasmtime::OptLevel::Speed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
fn required_env(key: &str) -> Result<String, ConfigError> {
|
||||
|
||||
@@ -91,7 +91,7 @@ pub struct StateTransition {
|
||||
}
|
||||
|
||||
/// Context for a running job.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JobContext {
|
||||
/// Unique job ID.
|
||||
pub job_id: Uuid,
|
||||
|
||||
+2
-1
@@ -3,11 +3,12 @@
|
||||
//! Tools are the agent's interface to the outside world. They can:
|
||||
//! - Call external APIs
|
||||
//! - Interact with the marketplace
|
||||
//! - Execute sandboxed code
|
||||
//! - Execute sandboxed code (via WASM sandbox)
|
||||
//! - Delegate tasks to other services
|
||||
|
||||
pub mod builtin;
|
||||
pub mod mcp;
|
||||
pub mod wasm;
|
||||
|
||||
mod builder;
|
||||
mod registry;
|
||||
|
||||
@@ -8,6 +8,9 @@ use tokio::sync::RwLock;
|
||||
use crate::llm::ToolDefinition;
|
||||
use crate::tools::builtin::{EchoTool, HttpTool, JsonTool, TimeTool};
|
||||
use crate::tools::tool::Tool;
|
||||
use crate::tools::wasm::{
|
||||
Capabilities, ResourceLimits, WasmError, WasmToolRuntime, WasmToolWrapper,
|
||||
};
|
||||
|
||||
/// Registry of available tools.
|
||||
pub struct ToolRegistry {
|
||||
@@ -105,6 +108,68 @@ impl ToolRegistry {
|
||||
|
||||
tracing::info!("Registered {} built-in tools", self.count());
|
||||
}
|
||||
|
||||
/// Register a WASM tool from bytes.
|
||||
///
|
||||
/// This validates and compiles the WASM component, then registers it as a tool.
|
||||
/// The tool will be executed in a sandboxed environment with the given capabilities.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::default())?);
|
||||
/// let wasm_bytes = std::fs::read("my_tool.wasm")?;
|
||||
///
|
||||
/// registry.register_wasm(WasmToolRegistration {
|
||||
/// name: "my_tool",
|
||||
/// wasm_bytes: &wasm_bytes,
|
||||
/// runtime: &runtime,
|
||||
/// description: Some("My custom tool description"),
|
||||
/// ..Default::default()
|
||||
/// }).await?;
|
||||
/// ```
|
||||
pub async fn register_wasm(&self, reg: WasmToolRegistration<'_>) -> Result<(), WasmError> {
|
||||
// Prepare the module (validates and compiles)
|
||||
let prepared = reg
|
||||
.runtime
|
||||
.prepare(reg.name, reg.wasm_bytes, reg.limits)
|
||||
.await?;
|
||||
|
||||
// Create the wrapper
|
||||
let mut wrapper = WasmToolWrapper::new(Arc::clone(reg.runtime), prepared, reg.capabilities);
|
||||
|
||||
// Apply overrides if provided
|
||||
if let Some(desc) = reg.description {
|
||||
wrapper = wrapper.with_description(desc);
|
||||
}
|
||||
if let Some(s) = reg.schema {
|
||||
wrapper = wrapper.with_schema(s);
|
||||
}
|
||||
|
||||
// Register the tool
|
||||
self.register(Arc::new(wrapper)).await;
|
||||
|
||||
tracing::info!(name = reg.name, "Registered WASM tool");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for registering a WASM tool.
|
||||
pub struct WasmToolRegistration<'a> {
|
||||
/// Unique name for the tool.
|
||||
pub name: &'a str,
|
||||
/// Raw WASM component bytes.
|
||||
pub wasm_bytes: &'a [u8],
|
||||
/// WASM runtime for compilation and execution.
|
||||
pub runtime: &'a Arc<WasmToolRuntime>,
|
||||
/// Security capabilities to grant the tool.
|
||||
pub capabilities: Capabilities,
|
||||
/// Optional resource limits (uses defaults if None).
|
||||
pub limits: Option<ResourceLimits>,
|
||||
/// Optional description override.
|
||||
pub description: Option<&'a str>,
|
||||
/// Optional parameter schema override.
|
||||
pub schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for ToolRegistry {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! WASM sandbox error types.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during WASM tool execution.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WasmError {
|
||||
/// Failed to create the Wasmtime engine.
|
||||
#[error("Engine creation failed: {0}")]
|
||||
EngineCreationFailed(String),
|
||||
|
||||
/// Failed to compile WASM bytes into a component.
|
||||
#[error("Compilation failed: {0}")]
|
||||
CompilationFailed(String),
|
||||
|
||||
/// WASM validation failed (malformed or invalid component).
|
||||
#[error("Validation failed: {0}")]
|
||||
ValidationFailed(String),
|
||||
|
||||
/// Failed to instantiate the component.
|
||||
#[error("Instantiation failed: {0}")]
|
||||
InstantiationFailed(String),
|
||||
|
||||
/// Component execution trapped (e.g., unreachable, memory access violation).
|
||||
#[error("Execution trapped: {0}")]
|
||||
Trapped(String),
|
||||
|
||||
/// Component panicked during execution.
|
||||
#[error("Execution panicked: {0}")]
|
||||
ExecutionPanicked(String),
|
||||
|
||||
/// Fuel limit exhausted during execution.
|
||||
#[error("Fuel exhausted: execution exceeded {limit} fuel units")]
|
||||
FuelExhausted {
|
||||
/// The fuel limit that was exceeded.
|
||||
limit: u64,
|
||||
},
|
||||
|
||||
/// Memory limit exceeded during execution.
|
||||
#[error("Memory limit exceeded: {used} bytes used, {limit} bytes allowed")]
|
||||
MemoryExceeded {
|
||||
/// Bytes used when limit was hit.
|
||||
used: u64,
|
||||
/// Maximum allowed bytes.
|
||||
limit: u64,
|
||||
},
|
||||
|
||||
/// Required export not found in component.
|
||||
#[error("Missing export: {0}")]
|
||||
MissingExport(String),
|
||||
|
||||
/// IO error (e.g., reading WASM file).
|
||||
#[error("IO error: {0}")]
|
||||
IoError(String),
|
||||
|
||||
/// Configuration error.
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
/// Host function error.
|
||||
#[error("Host error: {0}")]
|
||||
HostError(String),
|
||||
|
||||
/// Execution timed out.
|
||||
#[error("Execution timed out after {0:?}")]
|
||||
Timeout(std::time::Duration),
|
||||
|
||||
/// Component returned an error response.
|
||||
#[error("Tool error: {0}")]
|
||||
ToolReturnedError(String),
|
||||
|
||||
/// Invalid JSON in tool response.
|
||||
#[error("Invalid response JSON: {0}")]
|
||||
InvalidResponseJson(String),
|
||||
|
||||
/// Path traversal attempt blocked.
|
||||
#[error("Path traversal blocked: {0}")]
|
||||
PathTraversalBlocked(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for WasmError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
WasmError::IoError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WasmError> for crate::tools::ToolError {
|
||||
fn from(e: WasmError) -> Self {
|
||||
crate::tools::ToolError::Sandbox(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Details about a trap that occurred during execution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrapInfo {
|
||||
/// Human-readable trap message.
|
||||
pub message: String,
|
||||
/// Trap code if available.
|
||||
pub code: Option<TrapCode>,
|
||||
}
|
||||
|
||||
impl fmt::Display for TrapInfo {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.code {
|
||||
Some(code) => write!(f, "{}: {}", code, self.message),
|
||||
None => write!(f, "{}", self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Known trap codes from Wasmtime.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrapCode {
|
||||
/// Out of bounds memory access.
|
||||
MemoryOutOfBounds,
|
||||
/// Out of bounds table access.
|
||||
TableOutOfBounds,
|
||||
/// Indirect call type mismatch.
|
||||
IndirectCallToNull,
|
||||
/// Signature mismatch on indirect call.
|
||||
BadSignature,
|
||||
/// Integer overflow.
|
||||
IntegerOverflow,
|
||||
/// Integer division by zero.
|
||||
IntegerDivisionByZero,
|
||||
/// Invalid conversion to integer.
|
||||
BadConversionToInteger,
|
||||
/// Unreachable instruction executed.
|
||||
UnreachableCodeReached,
|
||||
/// Call stack exhausted.
|
||||
StackOverflow,
|
||||
/// Out of fuel.
|
||||
OutOfFuel,
|
||||
/// Unknown trap code.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl fmt::Display for TrapCode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
TrapCode::MemoryOutOfBounds => "memory out of bounds",
|
||||
TrapCode::TableOutOfBounds => "table out of bounds",
|
||||
TrapCode::IndirectCallToNull => "indirect call to null",
|
||||
TrapCode::BadSignature => "bad signature",
|
||||
TrapCode::IntegerOverflow => "integer overflow",
|
||||
TrapCode::IntegerDivisionByZero => "integer division by zero",
|
||||
TrapCode::BadConversionToInteger => "bad conversion to integer",
|
||||
TrapCode::UnreachableCodeReached => "unreachable code reached",
|
||||
TrapCode::StackOverflow => "stack overflow",
|
||||
TrapCode::OutOfFuel => "out of fuel",
|
||||
TrapCode::Unknown => "unknown trap",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::error::{TrapCode, TrapInfo, WasmError};
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
let err = WasmError::FuelExhausted { limit: 1_000_000 };
|
||||
assert!(err.to_string().contains("1000000"));
|
||||
|
||||
let err = WasmError::MemoryExceeded {
|
||||
used: 20_000_000,
|
||||
limit: 10_000_000,
|
||||
};
|
||||
assert!(err.to_string().contains("20000000"));
|
||||
assert!(err.to_string().contains("10000000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trap_info_display() {
|
||||
let info = TrapInfo {
|
||||
message: "access at offset 0x1000".to_string(),
|
||||
code: Some(TrapCode::MemoryOutOfBounds),
|
||||
};
|
||||
let s = info.to_string();
|
||||
assert!(s.contains("memory out of bounds"));
|
||||
assert!(s.contains("access at offset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversion_to_tool_error() {
|
||||
let wasm_err = WasmError::Trapped("test trap".to_string());
|
||||
let tool_err: crate::tools::ToolError = wasm_err.into();
|
||||
match tool_err {
|
||||
crate::tools::ToolError::Sandbox(msg) => {
|
||||
assert!(msg.contains("test trap"));
|
||||
}
|
||||
_ => panic!("Expected Sandbox variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Host functions for WASM sandbox.
|
||||
//!
|
||||
//! Implements a minimal, security-focused host API following VMLogic patterns
|
||||
//! from NEAR blockchain. The principle is: deny by default, grant minimal capabilities.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::tools::wasm::error::WasmError;
|
||||
|
||||
/// Maximum log entries per execution (prevents log spam attacks).
|
||||
const MAX_LOG_ENTRIES: usize = 1000;
|
||||
|
||||
/// Maximum bytes per log message.
|
||||
const MAX_LOG_MESSAGE_BYTES: usize = 4096;
|
||||
|
||||
/// Log levels matching the WIT interface.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LogLevel {
|
||||
Trace,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LogLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LogLevel::Trace => write!(f, "TRACE"),
|
||||
LogLevel::Debug => write!(f, "DEBUG"),
|
||||
LogLevel::Info => write!(f, "INFO"),
|
||||
LogLevel::Warn => write!(f, "WARN"),
|
||||
LogLevel::Error => write!(f, "ERROR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single log entry from WASM execution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogEntry {
|
||||
pub level: LogLevel,
|
||||
pub message: String,
|
||||
pub timestamp_millis: u64,
|
||||
}
|
||||
|
||||
/// Capabilities that can be granted to a WASM tool.
|
||||
///
|
||||
/// By default, tools have NO capabilities. Each must be explicitly granted.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Capabilities {
|
||||
/// If Some, tool can read from workspace at these paths.
|
||||
/// Empty vec means workspace access granted but no paths allowed yet.
|
||||
/// None means workspace access completely disabled.
|
||||
pub workspace_read: Option<WorkspaceCapability>,
|
||||
}
|
||||
|
||||
/// Workspace read capability configuration.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct WorkspaceCapability {
|
||||
/// Allowed path prefixes (e.g., ["context/", "daily/"]).
|
||||
/// Empty means all paths allowed (within safety constraints).
|
||||
pub allowed_prefixes: Vec<String>,
|
||||
/// Function to actually read from workspace.
|
||||
/// This is injected by the runtime to avoid coupling to workspace impl.
|
||||
pub reader: Option<Arc<dyn WorkspaceReader>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkspaceCapability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WorkspaceCapability")
|
||||
.field("allowed_prefixes", &self.allowed_prefixes)
|
||||
.field("reader", &self.reader.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for reading from workspace (allows mocking in tests).
|
||||
pub trait WorkspaceReader: Send + Sync {
|
||||
fn read(&self, path: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
/// Host state maintained during WASM execution.
|
||||
///
|
||||
/// This is the "VMLogic" equivalent, it tracks all side effects and enforces limits.
|
||||
#[derive(Debug)]
|
||||
pub struct HostState {
|
||||
/// Collected log entries.
|
||||
logs: Vec<LogEntry>,
|
||||
/// Whether logging is still allowed (false after MAX_LOG_ENTRIES).
|
||||
logging_enabled: bool,
|
||||
/// Granted capabilities.
|
||||
capabilities: Capabilities,
|
||||
/// Count of log entries dropped due to rate limiting.
|
||||
logs_dropped: usize,
|
||||
}
|
||||
|
||||
impl HostState {
|
||||
/// Create a new host state with the given capabilities.
|
||||
pub fn new(capabilities: Capabilities) -> Self {
|
||||
Self {
|
||||
logs: Vec::new(),
|
||||
logging_enabled: true,
|
||||
capabilities,
|
||||
logs_dropped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal host state with no capabilities.
|
||||
pub fn minimal() -> Self {
|
||||
Self::new(Capabilities::default())
|
||||
}
|
||||
|
||||
/// Log a message from WASM.
|
||||
///
|
||||
/// Returns Ok(()) if logged, Err if rate limited or too long.
|
||||
pub fn log(&mut self, level: LogLevel, message: String) -> Result<(), WasmError> {
|
||||
if !self.logging_enabled {
|
||||
self.logs_dropped += 1;
|
||||
return Ok(()); // Silently drop, don't fail execution
|
||||
}
|
||||
|
||||
if self.logs.len() >= MAX_LOG_ENTRIES {
|
||||
self.logging_enabled = false;
|
||||
self.logs_dropped += 1;
|
||||
tracing::warn!(
|
||||
"WASM log limit reached ({} entries), further logs dropped",
|
||||
MAX_LOG_ENTRIES
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Truncate overly long messages
|
||||
let message = if message.len() > MAX_LOG_MESSAGE_BYTES {
|
||||
let mut truncated = message[..MAX_LOG_MESSAGE_BYTES].to_string();
|
||||
truncated.push_str("... (truncated)");
|
||||
truncated
|
||||
} else {
|
||||
message
|
||||
};
|
||||
|
||||
let timestamp_millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
self.logs.push(LogEntry {
|
||||
level,
|
||||
message,
|
||||
timestamp_millis,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current timestamp in milliseconds.
|
||||
pub fn now_millis(&self) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read from workspace if capability granted.
|
||||
pub fn workspace_read(&self, path: &str) -> Result<Option<String>, WasmError> {
|
||||
// Check if workspace capability is granted
|
||||
let capability = match &self.capabilities.workspace_read {
|
||||
Some(cap) => cap,
|
||||
None => return Ok(None), // No capability, return None
|
||||
};
|
||||
|
||||
// Validate path (security critical)
|
||||
validate_workspace_path(path)?;
|
||||
|
||||
// Check allowed prefixes if any are specified
|
||||
if !capability.allowed_prefixes.is_empty() {
|
||||
let allowed = capability
|
||||
.allowed_prefixes
|
||||
.iter()
|
||||
.any(|prefix| path.starts_with(prefix));
|
||||
if !allowed {
|
||||
tracing::debug!(
|
||||
path = path,
|
||||
allowed = ?capability.allowed_prefixes,
|
||||
"WASM workspace read denied: path not in allowed prefixes"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Actually read from workspace
|
||||
match &capability.reader {
|
||||
Some(reader) => Ok(reader.read(path)),
|
||||
None => Ok(None), // No reader configured
|
||||
}
|
||||
}
|
||||
|
||||
/// Get collected logs after execution.
|
||||
pub fn take_logs(&mut self) -> Vec<LogEntry> {
|
||||
std::mem::take(&mut self.logs)
|
||||
}
|
||||
|
||||
/// Get number of logs dropped due to rate limiting.
|
||||
pub fn logs_dropped(&self) -> usize {
|
||||
self.logs_dropped
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a workspace path for security.
|
||||
///
|
||||
/// Blocks path traversal attacks and absolute paths.
|
||||
fn validate_workspace_path(path: &str) -> Result<(), WasmError> {
|
||||
// Block absolute paths
|
||||
if path.starts_with('/') {
|
||||
return Err(WasmError::PathTraversalBlocked(
|
||||
"absolute paths not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Block path traversal
|
||||
if path.contains("..") {
|
||||
return Err(WasmError::PathTraversalBlocked(
|
||||
"parent directory references not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Block null bytes
|
||||
if path.contains('\0') {
|
||||
return Err(WasmError::PathTraversalBlocked(
|
||||
"null bytes not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Block Windows-style absolute paths (just in case)
|
||||
if path.len() >= 2 && path.chars().nth(1) == Some(':') {
|
||||
return Err(WasmError::PathTraversalBlocked(
|
||||
"Windows-style paths not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::host::{
|
||||
Capabilities, HostState, LogLevel, MAX_LOG_ENTRIES, MAX_LOG_MESSAGE_BYTES,
|
||||
WorkspaceCapability, WorkspaceReader, validate_workspace_path,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct MockReader {
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl WorkspaceReader for MockReader {
|
||||
fn read(&self, _path: &str) -> Option<String> {
|
||||
Some(self.content.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logging_basic() {
|
||||
let mut state = HostState::minimal();
|
||||
state
|
||||
.log(LogLevel::Info, "test message".to_string())
|
||||
.unwrap();
|
||||
|
||||
let logs = state.take_logs();
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(logs[0].level, LogLevel::Info);
|
||||
assert_eq!(logs[0].message, "test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logging_rate_limit() {
|
||||
let mut state = HostState::minimal();
|
||||
|
||||
// Fill up to limit
|
||||
for i in 0..MAX_LOG_ENTRIES {
|
||||
state
|
||||
.log(LogLevel::Debug, format!("message {}", i))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// This should be dropped silently
|
||||
state
|
||||
.log(LogLevel::Info, "should be dropped".to_string())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.take_logs().len(), MAX_LOG_ENTRIES);
|
||||
assert_eq!(state.logs_dropped(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logging_truncation() {
|
||||
let mut state = HostState::minimal();
|
||||
|
||||
let long_message = "x".repeat(MAX_LOG_MESSAGE_BYTES + 1000);
|
||||
state.log(LogLevel::Info, long_message).unwrap();
|
||||
|
||||
let logs = state.take_logs();
|
||||
assert!(logs[0].message.len() <= MAX_LOG_MESSAGE_BYTES + 20); // +20 for truncation suffix
|
||||
assert!(logs[0].message.ends_with("... (truncated)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_now_millis() {
|
||||
let state = HostState::minimal();
|
||||
let now = state.now_millis();
|
||||
// Should be a reasonable timestamp (after 2020)
|
||||
assert!(now > 1577836800000); // Jan 1, 2020
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_read_no_capability() {
|
||||
let state = HostState::minimal();
|
||||
let result = state.workspace_read("context/test.md").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_read_with_capability() {
|
||||
let reader = Arc::new(MockReader {
|
||||
content: "test content".to_string(),
|
||||
});
|
||||
|
||||
let capabilities = Capabilities {
|
||||
workspace_read: Some(WorkspaceCapability {
|
||||
allowed_prefixes: vec![],
|
||||
reader: Some(reader),
|
||||
}),
|
||||
};
|
||||
|
||||
let state = HostState::new(capabilities);
|
||||
let result = state.workspace_read("context/test.md").unwrap();
|
||||
assert_eq!(result, Some("test content".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_read_prefix_restriction() {
|
||||
let reader = Arc::new(MockReader {
|
||||
content: "test content".to_string(),
|
||||
});
|
||||
|
||||
let capabilities = Capabilities {
|
||||
workspace_read: Some(WorkspaceCapability {
|
||||
allowed_prefixes: vec!["context/".to_string()],
|
||||
reader: Some(reader),
|
||||
}),
|
||||
};
|
||||
|
||||
let state = HostState::new(capabilities);
|
||||
|
||||
// Allowed prefix
|
||||
let result = state.workspace_read("context/test.md").unwrap();
|
||||
assert!(result.is_some());
|
||||
|
||||
// Disallowed prefix
|
||||
let result = state.workspace_read("secrets/api_key.txt").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_validation_blocks_traversal() {
|
||||
assert!(validate_workspace_path("../etc/passwd").is_err());
|
||||
assert!(validate_workspace_path("context/../secrets").is_err());
|
||||
assert!(validate_workspace_path("context/test/../../secrets").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_validation_blocks_absolute() {
|
||||
assert!(validate_workspace_path("/etc/passwd").is_err());
|
||||
assert!(validate_workspace_path("/context/test.md").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_validation_blocks_null_bytes() {
|
||||
assert!(validate_workspace_path("context/test\0.md").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_validation_blocks_windows_paths() {
|
||||
assert!(validate_workspace_path("C:\\Windows\\System32").is_err());
|
||||
assert!(validate_workspace_path("D:secrets").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_validation_allows_valid_paths() {
|
||||
assert!(validate_workspace_path("context/test.md").is_ok());
|
||||
assert!(validate_workspace_path("daily/2024-01-15.md").is_ok());
|
||||
assert!(validate_workspace_path("projects/alpha/notes.md").is_ok());
|
||||
assert!(validate_workspace_path("MEMORY.md").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Resource limits for WASM sandbox execution.
|
||||
//!
|
||||
//! Provides memory and fuel (CPU) limits following NEAR blockchain patterns.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use wasmtime::ResourceLimiter;
|
||||
|
||||
/// Default memory limit: 10 MB (conservative for untrusted code).
|
||||
pub const DEFAULT_MEMORY_LIMIT: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Default fuel limit: 10 million instructions.
|
||||
pub const DEFAULT_FUEL_LIMIT: u64 = 10_000_000;
|
||||
|
||||
/// Default execution timeout: 60 seconds.
|
||||
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Resource limits for a single WASM execution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceLimits {
|
||||
/// Maximum memory in bytes.
|
||||
pub memory_bytes: u64,
|
||||
/// Maximum fuel (instruction count).
|
||||
pub fuel: u64,
|
||||
/// Maximum wall-clock execution time.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for ResourceLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
memory_bytes: DEFAULT_MEMORY_LIMIT,
|
||||
fuel: DEFAULT_FUEL_LIMIT,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceLimits {
|
||||
/// Create limits with custom memory.
|
||||
pub fn with_memory(mut self, bytes: u64) -> Self {
|
||||
self.memory_bytes = bytes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create limits with custom fuel.
|
||||
pub fn with_fuel(mut self, fuel: u64) -> Self {
|
||||
self.fuel = fuel;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create limits with custom timeout.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Wasmtime ResourceLimiter implementation for enforcing memory limits.
|
||||
///
|
||||
/// This is attached to the Store to limit memory growth during execution.
|
||||
#[derive(Debug)]
|
||||
pub struct WasmResourceLimiter {
|
||||
/// Maximum memory allowed.
|
||||
memory_limit: u64,
|
||||
/// Current memory usage (tracked across all memories).
|
||||
memory_used: u64,
|
||||
/// Maximum tables allowed.
|
||||
max_tables: u32,
|
||||
/// Current table count.
|
||||
tables_created: u32,
|
||||
/// Maximum instances allowed.
|
||||
max_instances: u32,
|
||||
/// Current instance count.
|
||||
instances_created: u32,
|
||||
}
|
||||
|
||||
impl WasmResourceLimiter {
|
||||
/// Create a new limiter with the given memory limit.
|
||||
pub fn new(memory_limit: u64) -> Self {
|
||||
Self {
|
||||
memory_limit,
|
||||
memory_used: 0,
|
||||
max_tables: 10,
|
||||
tables_created: 0,
|
||||
max_instances: 1,
|
||||
instances_created: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current memory usage.
|
||||
pub fn memory_used(&self) -> u64 {
|
||||
self.memory_used
|
||||
}
|
||||
|
||||
/// Get the memory limit.
|
||||
pub fn memory_limit(&self) -> u64 {
|
||||
self.memory_limit
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceLimiter for WasmResourceLimiter {
|
||||
fn memory_growing(
|
||||
&mut self,
|
||||
current: usize,
|
||||
desired: usize,
|
||||
_maximum: Option<usize>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let desired_u64 = desired as u64;
|
||||
|
||||
if desired_u64 > self.memory_limit {
|
||||
tracing::warn!(
|
||||
current = current,
|
||||
desired = desired,
|
||||
limit = self.memory_limit,
|
||||
"WASM memory growth denied: would exceed limit"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.memory_used = desired_u64;
|
||||
tracing::trace!(
|
||||
current = current,
|
||||
desired = desired,
|
||||
limit = self.memory_limit,
|
||||
"WASM memory growth allowed"
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn table_growing(
|
||||
&mut self,
|
||||
current: usize,
|
||||
desired: usize,
|
||||
_maximum: Option<usize>,
|
||||
) -> anyhow::Result<bool> {
|
||||
// Allow reasonable table growth
|
||||
if desired > 10_000 {
|
||||
tracing::warn!(
|
||||
current = current,
|
||||
desired = desired,
|
||||
"WASM table growth denied: too large"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn instances(&self) -> usize {
|
||||
self.max_instances as usize
|
||||
}
|
||||
|
||||
fn tables(&self) -> usize {
|
||||
self.max_tables as usize
|
||||
}
|
||||
|
||||
fn memories(&self) -> usize {
|
||||
// Allow one memory per instance
|
||||
self.max_instances as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for fuel metering.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FuelConfig {
|
||||
/// Initial fuel to provide.
|
||||
pub initial_fuel: u64,
|
||||
/// Whether to enable fuel consumption.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for FuelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_fuel: DEFAULT_FUEL_LIMIT,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FuelConfig {
|
||||
/// Create a disabled fuel config (no CPU limits).
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
initial_fuel: 0,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fuel config with a custom limit.
|
||||
pub fn with_limit(fuel: u64) -> Self {
|
||||
Self {
|
||||
initial_fuel: fuel,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::limits::{
|
||||
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
|
||||
WasmResourceLimiter,
|
||||
};
|
||||
use wasmtime::ResourceLimiter;
|
||||
|
||||
#[test]
|
||||
fn test_default_limits() {
|
||||
let limits = ResourceLimits::default();
|
||||
assert_eq!(limits.memory_bytes, DEFAULT_MEMORY_LIMIT);
|
||||
assert_eq!(limits.fuel, DEFAULT_FUEL_LIMIT);
|
||||
assert_eq!(limits.timeout, DEFAULT_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_limits_builder() {
|
||||
let limits = ResourceLimits::default()
|
||||
.with_memory(5 * 1024 * 1024)
|
||||
.with_fuel(1_000_000)
|
||||
.with_timeout(std::time::Duration::from_secs(30));
|
||||
|
||||
assert_eq!(limits.memory_bytes, 5 * 1024 * 1024);
|
||||
assert_eq!(limits.fuel, 1_000_000);
|
||||
assert_eq!(limits.timeout, std::time::Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resource_limiter_allows_growth_within_limit() {
|
||||
let mut limiter = WasmResourceLimiter::new(10 * 1024 * 1024);
|
||||
|
||||
// Growth within limit should be allowed
|
||||
let result = limiter.memory_growing(0, 1024 * 1024, None).unwrap();
|
||||
assert!(result);
|
||||
assert_eq!(limiter.memory_used(), 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resource_limiter_denies_growth_beyond_limit() {
|
||||
let mut limiter = WasmResourceLimiter::new(10 * 1024 * 1024);
|
||||
|
||||
// Growth beyond limit should be denied
|
||||
let result = limiter.memory_growing(0, 20 * 1024 * 1024, None).unwrap();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuel_config() {
|
||||
let config = FuelConfig::default();
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.initial_fuel, DEFAULT_FUEL_LIMIT);
|
||||
|
||||
let disabled = FuelConfig::disabled();
|
||||
assert!(!disabled.enabled);
|
||||
|
||||
let custom = FuelConfig::with_limit(5_000_000);
|
||||
assert!(custom.enabled);
|
||||
assert_eq!(custom.initial_fuel, 5_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! WASM sandbox for untrusted tool execution.
|
||||
//!
|
||||
//! This module provides Wasmtime-based sandboxed execution for tools,
|
||||
//! following patterns from NEAR blockchain and modern WASM best practices:
|
||||
//!
|
||||
//! - **Compile once, instantiate fresh**: Tools are validated and compiled
|
||||
//! at registration time. Each execution creates a fresh instance.
|
||||
//!
|
||||
//! - **Fuel metering**: CPU usage is limited via Wasmtime's fuel system.
|
||||
//!
|
||||
//! - **Memory limits**: Memory growth is bounded via ResourceLimiter.
|
||||
//!
|
||||
//! - **Minimal host API**: Only log, time, and optional workspace read.
|
||||
//!
|
||||
//! - **Capability-based security**: Features are opt-in via Capabilities.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────────────┐
|
||||
//! │ Tool Registration │
|
||||
//! │ WASM bytes → Validate → Compile (AOT) → PreparedModule (cached) │
|
||||
//! └─────────────────────────────────────────────────────────────────────┘
|
||||
//! │
|
||||
//! ▼
|
||||
//! ┌─────────────────────────────────────────────────────────────────────┐
|
||||
//! │ Tool Execution │
|
||||
//! │ JSON params → WasmToolWrapper → Fresh Instance → Execute → Result │
|
||||
//! │ ↓ ↓ │
|
||||
//! │ ResourceLimiter HostState │
|
||||
//! │ (memory, fuel) (log, time, workspace) │
|
||||
//! └─────────────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! # Security Constraints
|
||||
//!
|
||||
//! | Threat | Mitigation |
|
||||
//! |--------|------------|
|
||||
//! | CPU exhaustion | Fuel metering |
|
||||
//! | Memory exhaustion | ResourceLimiter, 10MB default |
|
||||
//! | Infinite loops | Epoch interruption + tokio timeout |
|
||||
//! | Filesystem access | No WASI FS, only host workspace_read |
|
||||
//! | Network access | No network host functions |
|
||||
//! | Log spam | Max 1000 entries, 4KB per message |
|
||||
//! | Path traversal | Validate paths (no `..`, no `/` prefix) |
|
||||
//! | Trap recovery | Discard instance, never reuse |
|
||||
//! | Side channels | Fresh instance per execution |
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use near_agent::tools::wasm::{WasmToolRuntime, WasmRuntimeConfig, WasmToolWrapper};
|
||||
//! use near_agent::tools::wasm::host::Capabilities;
|
||||
//! use std::sync::Arc;
|
||||
//!
|
||||
//! // Create runtime
|
||||
//! let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::default())?);
|
||||
//!
|
||||
//! // Prepare a tool from WASM bytes
|
||||
//! let wasm_bytes = std::fs::read("my_tool.wasm")?;
|
||||
//! let prepared = runtime.prepare("my_tool", &wasm_bytes, None).await?;
|
||||
//!
|
||||
//! // Create wrapper with minimal capabilities
|
||||
//! let tool = WasmToolWrapper::new(runtime, prepared, Capabilities::default());
|
||||
//!
|
||||
//! // Execute (implements Tool trait)
|
||||
//! let output = tool.execute(serde_json::json!({"input": "test"}), &ctx).await?;
|
||||
//! ```
|
||||
|
||||
mod error;
|
||||
mod host;
|
||||
mod limits;
|
||||
mod runtime;
|
||||
mod wrapper;
|
||||
|
||||
pub use error::{TrapCode, TrapInfo, WasmError};
|
||||
pub use host::{Capabilities, HostState, LogEntry, LogLevel, WorkspaceCapability, WorkspaceReader};
|
||||
pub use limits::{
|
||||
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
|
||||
WasmResourceLimiter,
|
||||
};
|
||||
pub use runtime::{PreparedModule, WasmRuntimeConfig, WasmToolRuntime};
|
||||
pub use wrapper::WasmToolWrapper;
|
||||
@@ -0,0 +1,310 @@
|
||||
//! WASM tool runtime for managing compiled components.
|
||||
//!
|
||||
//! Follows the principle: compile once at registration, instantiate fresh per execution.
|
||||
//! This matches NEAR blockchain patterns for deterministic, isolated execution.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use wasmtime::{Config, Engine, OptLevel};
|
||||
|
||||
use crate::tools::wasm::error::WasmError;
|
||||
use crate::tools::wasm::limits::{FuelConfig, ResourceLimits};
|
||||
|
||||
/// Configuration for the WASM runtime.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WasmRuntimeConfig {
|
||||
/// Default resource limits for tools.
|
||||
pub default_limits: ResourceLimits,
|
||||
/// Fuel configuration.
|
||||
pub fuel_config: FuelConfig,
|
||||
/// Whether to cache compiled modules.
|
||||
pub cache_compiled: bool,
|
||||
/// Directory for compiled module cache.
|
||||
pub cache_dir: Option<PathBuf>,
|
||||
/// Cranelift optimization level.
|
||||
pub optimization_level: OptLevel,
|
||||
}
|
||||
|
||||
impl Default for WasmRuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_limits: ResourceLimits::default(),
|
||||
fuel_config: FuelConfig::default(),
|
||||
cache_compiled: true,
|
||||
cache_dir: None,
|
||||
optimization_level: OptLevel::Speed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WasmRuntimeConfig {
|
||||
/// Create a minimal config for testing.
|
||||
pub fn for_testing() -> Self {
|
||||
Self {
|
||||
default_limits: ResourceLimits::default()
|
||||
.with_memory(1024 * 1024) // 1 MB
|
||||
.with_fuel(100_000)
|
||||
.with_timeout(Duration::from_secs(5)),
|
||||
fuel_config: FuelConfig::with_limit(100_000),
|
||||
cache_compiled: false,
|
||||
cache_dir: None,
|
||||
optimization_level: OptLevel::None, // Faster compilation for tests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A compiled WASM component ready for instantiation.
|
||||
///
|
||||
/// Contains the pre-compiled component plus cached metadata extracted
|
||||
/// from the component during preparation.
|
||||
#[derive(Debug)]
|
||||
pub struct PreparedModule {
|
||||
/// Tool name.
|
||||
pub name: String,
|
||||
/// Tool description (cached from component).
|
||||
pub description: String,
|
||||
/// Parameter schema JSON (cached from component).
|
||||
pub schema: serde_json::Value,
|
||||
/// Compiled component bytes (can be serialized for caching).
|
||||
component_bytes: Vec<u8>,
|
||||
/// Resource limits for this tool.
|
||||
pub limits: ResourceLimits,
|
||||
}
|
||||
|
||||
impl PreparedModule {
|
||||
/// Get the compiled component bytes.
|
||||
pub fn component_bytes(&self) -> &[u8] {
|
||||
&self.component_bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM tool runtime.
|
||||
///
|
||||
/// Manages the Wasmtime engine and a cache of prepared modules.
|
||||
pub struct WasmToolRuntime {
|
||||
/// Wasmtime engine with configured settings.
|
||||
engine: Engine,
|
||||
/// Runtime configuration.
|
||||
config: WasmRuntimeConfig,
|
||||
/// Cache of prepared modules by name.
|
||||
modules: RwLock<HashMap<String, Arc<PreparedModule>>>,
|
||||
}
|
||||
|
||||
impl WasmToolRuntime {
|
||||
/// Create a new runtime with the given configuration.
|
||||
pub fn new(config: WasmRuntimeConfig) -> Result<Self, WasmError> {
|
||||
let mut wasmtime_config = Config::new();
|
||||
|
||||
// Enable fuel consumption for CPU limiting
|
||||
if config.fuel_config.enabled {
|
||||
wasmtime_config.consume_fuel(true);
|
||||
}
|
||||
|
||||
// Enable epoch interruption as a backup timeout mechanism
|
||||
wasmtime_config.epoch_interruption(true);
|
||||
|
||||
// Enable component model (WASI Preview 2)
|
||||
wasmtime_config.wasm_component_model(true);
|
||||
|
||||
// Disable threads (simplifies security model)
|
||||
wasmtime_config.wasm_threads(false);
|
||||
|
||||
// Set optimization level
|
||||
wasmtime_config.cranelift_opt_level(config.optimization_level);
|
||||
|
||||
// Disable debug info in production for smaller modules
|
||||
wasmtime_config.debug_info(false);
|
||||
|
||||
let engine = Engine::new(&wasmtime_config).map_err(|e| {
|
||||
WasmError::EngineCreationFailed(format!("Failed to create Wasmtime engine: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
engine,
|
||||
config,
|
||||
modules: RwLock::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the Wasmtime engine.
|
||||
pub fn engine(&self) -> &Engine {
|
||||
&self.engine
|
||||
}
|
||||
|
||||
/// Get the runtime configuration.
|
||||
pub fn config(&self) -> &WasmRuntimeConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Prepare a WASM component for execution.
|
||||
///
|
||||
/// This validates and compiles the component, extracting metadata.
|
||||
/// The compiled component is cached for fast instantiation.
|
||||
pub async fn prepare(
|
||||
&self,
|
||||
name: &str,
|
||||
wasm_bytes: &[u8],
|
||||
limits: Option<ResourceLimits>,
|
||||
) -> Result<Arc<PreparedModule>, WasmError> {
|
||||
// Check if already prepared
|
||||
if let Some(module) = self.modules.read().await.get(name) {
|
||||
return Ok(Arc::clone(module));
|
||||
}
|
||||
|
||||
let name = name.to_string();
|
||||
let wasm_bytes = wasm_bytes.to_vec();
|
||||
let engine = self.engine.clone();
|
||||
let default_limits = self.config.default_limits.clone();
|
||||
|
||||
// Compile in blocking task (Wasmtime compilation is synchronous)
|
||||
let prepared = tokio::task::spawn_blocking(move || {
|
||||
// Validate and compile the component
|
||||
let component = wasmtime::component::Component::new(&engine, &wasm_bytes)
|
||||
.map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
|
||||
|
||||
// We need to instantiate briefly to extract metadata.
|
||||
// In a full implementation, we'd use WIT bindgen to get typed access.
|
||||
// For now, we extract what we can from the component.
|
||||
let description = extract_tool_description(&engine, &component)?;
|
||||
let schema = extract_tool_schema(&engine, &component)?;
|
||||
|
||||
Ok::<_, WasmError>(PreparedModule {
|
||||
name: name.clone(),
|
||||
description,
|
||||
schema,
|
||||
component_bytes: wasm_bytes,
|
||||
limits: limits.unwrap_or(default_limits),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WasmError::ExecutionPanicked(format!("Preparation task panicked: {}", e)))??;
|
||||
|
||||
let prepared = Arc::new(prepared);
|
||||
|
||||
// Cache the prepared module
|
||||
if self.config.cache_compiled {
|
||||
self.modules
|
||||
.write()
|
||||
.await
|
||||
.insert(prepared.name.clone(), Arc::clone(&prepared));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
name = %prepared.name,
|
||||
"Prepared WASM tool for execution"
|
||||
);
|
||||
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
/// Get a prepared module by name.
|
||||
pub async fn get(&self, name: &str) -> Option<Arc<PreparedModule>> {
|
||||
self.modules.read().await.get(name).cloned()
|
||||
}
|
||||
|
||||
/// Remove a prepared module from the cache.
|
||||
pub async fn remove(&self, name: &str) -> Option<Arc<PreparedModule>> {
|
||||
self.modules.write().await.remove(name)
|
||||
}
|
||||
|
||||
/// List all prepared module names.
|
||||
pub async fn list(&self) -> Vec<String> {
|
||||
self.modules.read().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Clear all cached modules.
|
||||
pub async fn clear(&self) {
|
||||
self.modules.write().await.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract tool description from a compiled component.
|
||||
///
|
||||
/// In a full implementation, this would use WIT bindgen to call the description() export.
|
||||
/// For now, we return a placeholder since we can't easily introspect without more setup.
|
||||
fn extract_tool_description(
|
||||
_engine: &Engine,
|
||||
_component: &wasmtime::component::Component,
|
||||
) -> Result<String, WasmError> {
|
||||
// TODO: Use WIT bindgen to properly extract description
|
||||
// This requires instantiating with a linker, which needs host functions.
|
||||
// For now, tools should have their description set externally.
|
||||
Ok("WASM sandboxed tool".to_string())
|
||||
}
|
||||
|
||||
/// Extract tool schema from a compiled component.
|
||||
///
|
||||
/// In a full implementation, this would use WIT bindgen to call the schema() export.
|
||||
fn extract_tool_schema(
|
||||
_engine: &Engine,
|
||||
_component: &wasmtime::component::Component,
|
||||
) -> Result<serde_json::Value, WasmError> {
|
||||
// TODO: Use WIT bindgen to properly extract schema
|
||||
// For now, return a minimal schema that accepts any object.
|
||||
Ok(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": true
|
||||
}))
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WasmToolRuntime {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WasmToolRuntime")
|
||||
.field("config", &self.config)
|
||||
.field("modules", &"<RwLock<HashMap>>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::limits::ResourceLimits;
|
||||
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_default() {
|
||||
let config = WasmRuntimeConfig::default();
|
||||
assert!(config.cache_compiled);
|
||||
assert!(config.fuel_config.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_for_testing() {
|
||||
let config = WasmRuntimeConfig::for_testing();
|
||||
assert!(!config.cache_compiled);
|
||||
assert_eq!(config.default_limits.memory_bytes, 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_creation() {
|
||||
let config = WasmRuntimeConfig::for_testing();
|
||||
let runtime = WasmToolRuntime::new(config).unwrap();
|
||||
// Engine was created successfully, which validates the config
|
||||
assert!(runtime.config().fuel_config.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_module_cache_operations() {
|
||||
let config = WasmRuntimeConfig::for_testing();
|
||||
let runtime = WasmToolRuntime::new(config).unwrap();
|
||||
|
||||
// Initially empty
|
||||
assert!(runtime.list().await.is_empty());
|
||||
assert!(runtime.get("test").await.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prepared_module_limits() {
|
||||
let limits = ResourceLimits::default()
|
||||
.with_memory(5 * 1024 * 1024)
|
||||
.with_fuel(500_000);
|
||||
|
||||
assert_eq!(limits.memory_bytes, 5 * 1024 * 1024);
|
||||
assert_eq!(limits.fuel, 500_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! WASM tool wrapper implementing the Tool trait.
|
||||
//!
|
||||
//! Each execution creates a fresh instance (NEAR pattern) to ensure
|
||||
//! isolation and deterministic behavior.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use wasmtime::Store;
|
||||
use wasmtime::component::{Component, Linker, Val};
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::wasm::error::WasmError;
|
||||
use crate::tools::wasm::host::{Capabilities, HostState, LogLevel};
|
||||
use crate::tools::wasm::limits::{ResourceLimits, WasmResourceLimiter};
|
||||
use crate::tools::wasm::runtime::{PreparedModule, WasmToolRuntime};
|
||||
|
||||
/// Store data for WASM execution.
|
||||
///
|
||||
/// Contains both the resource limiter and host state.
|
||||
struct StoreData {
|
||||
limiter: WasmResourceLimiter,
|
||||
host_state: HostState,
|
||||
}
|
||||
|
||||
impl StoreData {
|
||||
fn new(memory_limit: u64, capabilities: Capabilities) -> Self {
|
||||
Self {
|
||||
limiter: WasmResourceLimiter::new(memory_limit),
|
||||
host_state: HostState::new(capabilities),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Tool implementation backed by a WASM component.
|
||||
///
|
||||
/// Each call to `execute` creates a fresh instance for isolation.
|
||||
pub struct WasmToolWrapper {
|
||||
/// Runtime for engine access.
|
||||
runtime: Arc<WasmToolRuntime>,
|
||||
/// Prepared module with compiled component.
|
||||
prepared: Arc<PreparedModule>,
|
||||
/// Capabilities to grant to this tool.
|
||||
capabilities: Capabilities,
|
||||
/// Cached description (from PreparedModule or override).
|
||||
description: String,
|
||||
/// Cached schema (from PreparedModule or override).
|
||||
schema: serde_json::Value,
|
||||
}
|
||||
|
||||
impl WasmToolWrapper {
|
||||
/// Create a new WASM tool wrapper.
|
||||
pub fn new(
|
||||
runtime: Arc<WasmToolRuntime>,
|
||||
prepared: Arc<PreparedModule>,
|
||||
capabilities: Capabilities,
|
||||
) -> Self {
|
||||
Self {
|
||||
description: prepared.description.clone(),
|
||||
schema: prepared.schema.clone(),
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the tool description.
|
||||
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = description.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the parameter schema.
|
||||
pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
|
||||
self.schema = schema;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the resource limits for this tool.
|
||||
pub fn limits(&self) -> &ResourceLimits {
|
||||
&self.prepared.limits
|
||||
}
|
||||
|
||||
/// Execute the WASM tool synchronously (called from spawn_blocking).
|
||||
fn execute_sync(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context_json: Option<String>,
|
||||
) -> Result<(String, Vec<crate::tools::wasm::host::LogEntry>), WasmError> {
|
||||
let engine = self.runtime.engine();
|
||||
let limits = &self.prepared.limits;
|
||||
|
||||
// Create store with fresh state (NEAR pattern: fresh instance per call)
|
||||
let store_data = StoreData::new(limits.memory_bytes, self.capabilities.clone());
|
||||
let mut store = Store::new(engine, store_data);
|
||||
|
||||
// Configure fuel if enabled
|
||||
if self.runtime.config().fuel_config.enabled {
|
||||
store
|
||||
.set_fuel(limits.fuel)
|
||||
.map_err(|e| WasmError::ConfigError(format!("Failed to set fuel: {}", e)))?;
|
||||
}
|
||||
|
||||
// Configure epoch deadline for timeout backup
|
||||
store.epoch_deadline_trap();
|
||||
store.set_epoch_deadline(1);
|
||||
|
||||
// Set up resource limiter
|
||||
store.limiter(|data| &mut data.limiter);
|
||||
|
||||
// Compile the component (uses cached bytes)
|
||||
let component = Component::new(engine, self.prepared.component_bytes())
|
||||
.map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
|
||||
|
||||
// Create linker and add host functions
|
||||
let mut linker = Linker::new(engine);
|
||||
self.add_host_functions(&mut linker)?;
|
||||
|
||||
// Instantiate the component
|
||||
let instance = linker
|
||||
.instantiate(&mut store, &component)
|
||||
.map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
|
||||
|
||||
// Get the execute function
|
||||
let execute_func = instance
|
||||
.get_func(&mut store, "execute")
|
||||
.ok_or_else(|| WasmError::MissingExport("execute".to_string()))?;
|
||||
|
||||
// Prepare request
|
||||
let params_json = serde_json::to_string(¶ms)
|
||||
.map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?;
|
||||
|
||||
// Build request record
|
||||
// Note: The exact calling convention depends on how WIT records are lowered.
|
||||
// With component model, we'd use typed bindings from wit-bindgen.
|
||||
// For now, we use the lower-level Val API.
|
||||
let request_params = Val::String(params_json);
|
||||
let request_context = match context_json {
|
||||
Some(ctx) => Val::Option(Some(Box::new(Val::String(ctx)))),
|
||||
None => Val::Option(None),
|
||||
};
|
||||
|
||||
// Create request record (params, context)
|
||||
let request = Val::Record(vec![
|
||||
("params".to_string(), request_params),
|
||||
("context".to_string(), request_context),
|
||||
]);
|
||||
|
||||
// Call the function
|
||||
let mut results = vec![Val::Bool(false)]; // Placeholder for response
|
||||
execute_func
|
||||
.call(&mut store, &[request], &mut results)
|
||||
.map_err(|e| {
|
||||
// Check for specific trap types
|
||||
let error_str = e.to_string();
|
||||
if error_str.contains("out of fuel") {
|
||||
WasmError::FuelExhausted { limit: limits.fuel }
|
||||
} else if error_str.contains("unreachable") {
|
||||
WasmError::Trapped("unreachable code executed".to_string())
|
||||
} else {
|
||||
WasmError::Trapped(error_str)
|
||||
}
|
||||
})?;
|
||||
|
||||
// Post-call completion (cleanup)
|
||||
execute_func
|
||||
.post_return(&mut store)
|
||||
.map_err(|e| WasmError::Trapped(format!("post_return failed: {}", e)))?;
|
||||
|
||||
// Extract response
|
||||
let response = &results[0];
|
||||
let (result_str, error_str) = extract_response(response)?;
|
||||
|
||||
// Get logs from host state
|
||||
let logs = store.data_mut().host_state.take_logs();
|
||||
|
||||
// Check for tool-level error
|
||||
if let Some(err) = error_str {
|
||||
return Err(WasmError::ToolReturnedError(err));
|
||||
}
|
||||
|
||||
// Return result (or empty string if none)
|
||||
Ok((result_str.unwrap_or_default(), logs))
|
||||
}
|
||||
|
||||
/// Add host functions to the linker.
|
||||
fn add_host_functions(&self, linker: &mut Linker<StoreData>) -> Result<(), WasmError> {
|
||||
// Note: With WIT bindgen, these would be generated automatically.
|
||||
// For now, we manually define the host functions.
|
||||
//
|
||||
// Component model func_wrap signature: F: Fn(StoreContextMut<T>, Params) -> Result<Return>
|
||||
// where Params is a tuple of the function arguments.
|
||||
|
||||
// host.log(level: log-level, message: string)
|
||||
linker
|
||||
.root()
|
||||
.func_wrap(
|
||||
"log",
|
||||
|mut ctx: wasmtime::StoreContextMut<'_, StoreData>,
|
||||
(level, message): (i32, String)| {
|
||||
let log_level = match level {
|
||||
0 => LogLevel::Trace,
|
||||
1 => LogLevel::Debug,
|
||||
2 => LogLevel::Info,
|
||||
3 => LogLevel::Warn,
|
||||
4 => LogLevel::Error,
|
||||
_ => LogLevel::Info,
|
||||
};
|
||||
// Ignore errors from logging (rate limiting)
|
||||
let _ = ctx.data_mut().host_state.log(log_level, message);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|e| WasmError::ConfigError(format!("Failed to add log function: {}", e)))?;
|
||||
|
||||
// host.now-millis() -> u64
|
||||
linker
|
||||
.root()
|
||||
.func_wrap(
|
||||
"now-millis",
|
||||
|ctx: wasmtime::StoreContextMut<'_, StoreData>, (): ()| -> anyhow::Result<(u64,)> {
|
||||
Ok((ctx.data().host_state.now_millis(),))
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
WasmError::ConfigError(format!("Failed to add now-millis function: {}", e))
|
||||
})?;
|
||||
|
||||
// host.workspace-read(path: string) -> option<string>
|
||||
linker
|
||||
.root()
|
||||
.func_wrap(
|
||||
"workspace-read",
|
||||
|ctx: wasmtime::StoreContextMut<'_, StoreData>,
|
||||
(path,): (String,)|
|
||||
-> anyhow::Result<(Option<String>,)> {
|
||||
let result = ctx.data().host_state.workspace_read(&path).ok().flatten();
|
||||
Ok((result,))
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
WasmError::ConfigError(format!("Failed to add workspace-read function: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract result and error from a WIT response record.
|
||||
fn extract_response(response: &Val) -> Result<(Option<String>, Option<String>), WasmError> {
|
||||
match response {
|
||||
Val::Record(fields) => {
|
||||
let mut result = None;
|
||||
let mut error = None;
|
||||
|
||||
for (name, val) in fields {
|
||||
match name.as_str() {
|
||||
"result" => {
|
||||
if let Val::Option(Some(inner)) = val {
|
||||
if let Val::String(s) = inner.as_ref() {
|
||||
result = Some(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"error" => {
|
||||
if let Val::Option(Some(inner)) = val {
|
||||
if let Val::String(s) = inner.as_ref() {
|
||||
error = Some(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((result, error))
|
||||
}
|
||||
_ => Err(WasmError::InvalidResponseJson(
|
||||
"Expected record response".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WasmToolWrapper {
|
||||
fn name(&self) -> &str {
|
||||
&self.prepared.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
self.schema.clone()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = Instant::now();
|
||||
let timeout = self.prepared.limits.timeout;
|
||||
|
||||
// Serialize context for WASM
|
||||
let context_json = serde_json::to_string(ctx).ok();
|
||||
|
||||
// Clone what we need for the blocking task
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
let prepared = Arc::clone(&self.prepared);
|
||||
let capabilities = self.capabilities.clone();
|
||||
let description = self.description.clone();
|
||||
let schema = self.schema.clone();
|
||||
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
let wrapper = WasmToolWrapper {
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
description,
|
||||
schema,
|
||||
};
|
||||
|
||||
tokio::task::spawn_blocking(move || wrapper.execute_sync(params, context_json))
|
||||
.await
|
||||
.map_err(|e| WasmError::ExecutionPanicked(e.to_string()))?
|
||||
})
|
||||
.await;
|
||||
|
||||
let duration = start.elapsed();
|
||||
|
||||
match result {
|
||||
Ok(Ok((result_json, logs))) => {
|
||||
// Emit collected logs
|
||||
for log in logs {
|
||||
match log.level {
|
||||
LogLevel::Trace => tracing::trace!(target: "wasm_tool", "{}", log.message),
|
||||
LogLevel::Debug => tracing::debug!(target: "wasm_tool", "{}", log.message),
|
||||
LogLevel::Info => tracing::info!(target: "wasm_tool", "{}", log.message),
|
||||
LogLevel::Warn => tracing::warn!(target: "wasm_tool", "{}", log.message),
|
||||
LogLevel::Error => tracing::error!(target: "wasm_tool", "{}", log.message),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse result JSON
|
||||
let result: serde_json::Value = serde_json::from_str(&result_json)
|
||||
.unwrap_or(serde_json::Value::String(result_json));
|
||||
|
||||
Ok(ToolOutput::success(result, duration))
|
||||
}
|
||||
Ok(Err(wasm_err)) => Err(wasm_err.into()),
|
||||
Err(_) => Err(WasmError::Timeout(timeout).into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
// WASM tools always require sanitization - they're untrusted by definition
|
||||
true
|
||||
}
|
||||
|
||||
fn estimated_duration(&self, _params: &serde_json::Value) -> Option<Duration> {
|
||||
// Use the timeout as a conservative estimate
|
||||
Some(self.prepared.limits.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WasmToolWrapper {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WasmToolWrapper")
|
||||
.field("name", &self.prepared.name)
|
||||
.field("description", &self.description)
|
||||
.field("limits", &self.prepared.limits)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::host::Capabilities;
|
||||
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn test_wrapper_creation() {
|
||||
// This test verifies the runtime can be created
|
||||
// Actual execution tests require a valid WASM component
|
||||
let config = WasmRuntimeConfig::for_testing();
|
||||
let runtime = Arc::new(WasmToolRuntime::new(config).unwrap());
|
||||
|
||||
// Runtime was created successfully
|
||||
assert!(runtime.config().fuel_config.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_default() {
|
||||
let caps = Capabilities::default();
|
||||
assert!(caps.workspace_read.is_none());
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ use std::sync::Arc;
|
||||
use near_agent::workspace::{MockEmbeddings, SearchConfig, Workspace, paths};
|
||||
|
||||
fn get_pool() -> deadpool_postgres::Pool {
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgres://localhost/near_agent_test".to_string());
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://localhost/near_agent_test".to_string());
|
||||
|
||||
let config: tokio_postgres::Config = database_url.parse().expect("Invalid DATABASE_URL");
|
||||
|
||||
@@ -194,7 +194,10 @@ async fn test_workspace_daily_log() {
|
||||
.expect("Failed to append daily log");
|
||||
|
||||
// Read today's log
|
||||
let log = workspace.today_log().await.expect("Failed to get today log");
|
||||
let log = workspace
|
||||
.today_log()
|
||||
.await
|
||||
.expect("Failed to get today log");
|
||||
assert!(log.content.contains("feature X"));
|
||||
// Should have timestamp prefix like [HH:MM:SS]
|
||||
assert!(log.content.contains("["));
|
||||
@@ -272,11 +275,17 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() {
|
||||
|
||||
// Write documents
|
||||
workspace
|
||||
.write("memory.md", "The user prefers dark mode and vim keybindings.")
|
||||
.write(
|
||||
"memory.md",
|
||||
"The user prefers dark mode and vim keybindings.",
|
||||
)
|
||||
.await
|
||||
.expect("write failed");
|
||||
workspace
|
||||
.write("prefs.md", "Settings: theme=dark, editor=vim, font=monospace")
|
||||
.write(
|
||||
"prefs.md",
|
||||
"Settings: theme=dark, editor=vim, font=monospace",
|
||||
)
|
||||
.await
|
||||
.expect("write failed");
|
||||
|
||||
@@ -335,16 +344,22 @@ async fn test_workspace_system_prompt() {
|
||||
.write(paths::SOUL, "Be kind and thorough.")
|
||||
.await
|
||||
.unwrap();
|
||||
workspace
|
||||
.write(paths::USER, "Name: Alice")
|
||||
.await
|
||||
.unwrap();
|
||||
workspace.write(paths::USER, "Name: Alice").await.unwrap();
|
||||
|
||||
// Get system prompt
|
||||
let prompt = workspace.system_prompt().await.expect("system_prompt failed");
|
||||
let prompt = workspace
|
||||
.system_prompt()
|
||||
.await
|
||||
.expect("system_prompt failed");
|
||||
|
||||
assert!(prompt.contains("helpful assistant"), "Should include AGENTS.md");
|
||||
assert!(prompt.contains("kind and thorough"), "Should include SOUL.md");
|
||||
assert!(
|
||||
prompt.contains("helpful assistant"),
|
||||
"Should include AGENTS.md"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("kind and thorough"),
|
||||
"Should include SOUL.md"
|
||||
);
|
||||
assert!(prompt.contains("Alice"), "Should include USER.md");
|
||||
|
||||
cleanup_user(&pool, user_id).await;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// WASM Tool Sandbox Interface
|
||||
//
|
||||
// Defines the contract between sandboxed tools and the host runtime.
|
||||
// Tools export the `tool` interface; the host provides the `host` interface.
|
||||
|
||||
package near:agent;
|
||||
|
||||
/// Host-provided capabilities for sandboxed tools.
|
||||
///
|
||||
/// These are the only ways a sandboxed tool can interact with the outside world.
|
||||
/// The set is intentionally minimal to reduce attack surface.
|
||||
interface host {
|
||||
/// Log levels for structured logging.
|
||||
enum log-level {
|
||||
trace,
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
|
||||
/// Emit a log message.
|
||||
///
|
||||
/// Messages are collected and emitted after execution completes.
|
||||
/// Rate-limited to 1000 entries per execution, 4KB per message.
|
||||
log: func(level: log-level, message: string);
|
||||
|
||||
/// Get the current timestamp in milliseconds since Unix epoch.
|
||||
now-millis: func() -> u64;
|
||||
|
||||
/// Read a file from the workspace (if capability granted).
|
||||
///
|
||||
/// Path must be relative (no leading /) and cannot contain "..".
|
||||
/// Returns None if the file doesn't exist or capability not granted.
|
||||
workspace-read: func(path: string) -> option<string>;
|
||||
}
|
||||
|
||||
/// Tool interface that sandboxed tools must implement.
|
||||
interface tool {
|
||||
/// Request payload for tool execution.
|
||||
record request {
|
||||
/// JSON-encoded parameters matching the tool's schema.
|
||||
params: string,
|
||||
/// Optional JSON-encoded job context for stateful operations.
|
||||
context: option<string>,
|
||||
}
|
||||
|
||||
/// Response from tool execution.
|
||||
record response {
|
||||
/// JSON-encoded result on success.
|
||||
result: option<string>,
|
||||
/// Error message on failure.
|
||||
error: option<string>,
|
||||
}
|
||||
|
||||
/// Execute the tool with the given request.
|
||||
///
|
||||
/// This is the main entry point. The tool should:
|
||||
/// 1. Parse params as JSON according to its schema
|
||||
/// 2. Perform the operation
|
||||
/// 3. Return a response with either result or error set
|
||||
execute: func(req: request) -> response;
|
||||
|
||||
/// Get the JSON Schema for this tool's parameters.
|
||||
///
|
||||
/// Must return a valid JSON Schema object describing the expected
|
||||
/// structure of the `params` field in requests.
|
||||
schema: func() -> string;
|
||||
|
||||
/// Get a human-readable description of what this tool does.
|
||||
///
|
||||
/// Used by the LLM to understand when to invoke the tool.
|
||||
description: func() -> string;
|
||||
}
|
||||
|
||||
/// World definition for sandboxed tools.
|
||||
///
|
||||
/// Tools import host capabilities and export the tool interface.
|
||||
world sandboxed-tool {
|
||||
import host;
|
||||
export tool;
|
||||
}
|
||||
Reference in New Issue
Block a user