feat: run plugin tools through wasm runtime

This commit is contained in:
Keisuke Hirata 2026-06-18 21:29:41 +09:00
parent d32fb3bc3c
commit 10d12148dd
No known key found for this signature in database
5 changed files with 1010 additions and 43 deletions

129
Cargo.lock generated
View File

@ -1633,6 +1633,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.16"
@ -2360,6 +2366,8 @@ dependencies = [
"tools",
"tracing",
"uuid",
"wasmi",
"wat",
"workflow",
]
@ -3342,6 +3350,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@ -3354,6 +3368,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "string_cache"
version = "0.8.9"
@ -4229,7 +4253,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
"wasmparser 0.244.0",
]
[[package]]
name = "wasm-encoder"
version = "0.246.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7"
dependencies = [
"leb128fmt",
"wasmparser 0.246.2",
]
[[package]]
@ -4240,8 +4274,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
"wasm-encoder 0.244.0",
"wasmparser 0.244.0",
]
[[package]]
@ -4257,6 +4291,56 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasmi"
version = "0.51.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.228.0",
]
[[package]]
name = "wasmi_collections"
version = "0.51.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9b8e98e45a2a534489f8225e765cbf1cb9a3078072605e58158910cf4749172"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "0.51.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c25f375c0cdf14810eab07f532f61f14d4966f09c747a55067fdf3196e8512e6"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "0.51.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624e2a68a4293ecb8f564260b68394b29cf3b3edba6bce35532889a2cb33c3d9"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.228.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
dependencies = [
"bitflags 2.11.0",
"indexmap",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@ -4269,6 +4353,39 @@ dependencies = [
"semver",
]
[[package]]
name = "wasmparser"
version = "0.246.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d"
dependencies = [
"bitflags 2.11.0",
"indexmap",
"semver",
]
[[package]]
name = "wast"
version = "246.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder 0.246.2",
]
[[package]]
name = "wat"
version = "1.246.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c"
dependencies = [
"wast",
]
[[package]]
name = "web-sys"
version = "0.3.94"
@ -4730,9 +4847,9 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-encoder 0.244.0",
"wasm-metadata",
"wasmparser",
"wasmparser 0.244.0",
"wit-parser",
]
@ -4751,7 +4868,7 @@ dependencies = [
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
"wasmparser 0.244.0",
]
[[package]]

View File

@ -637,6 +637,146 @@ pub fn resolve_plugin_config_for_startup(
snapshot
}
/// Load the recorded WASM runtime module for a resolved plugin package.
///
/// Restore and execution paths use this helper instead of reading arbitrary
/// package paths directly so module selection remains tied to the resolved
/// package identity, runtime manifest entry, and deterministic package digest.
pub fn read_resolved_plugin_runtime_module(
record: &ResolvedPluginRecord,
limits: &PluginDiscoveryLimits,
) -> Result<Vec<u8>, PluginDiagnostic> {
let runtime = record.manifest.runtime.as_ref().ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Missing,
PluginDiagnosticPhase::Manifest,
"resolved plugin package does not declare a WASM runtime",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})?;
if runtime.kind != "wasm" {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Api,
PluginDiagnosticPhase::Manifest,
"plugin runtime kind is unsupported",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest));
}
if runtime.abi.as_deref() != Some("yoi-plugin-wasm-1") {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Api,
PluginDiagnosticPhase::Manifest,
"plugin WASM ABI is unsupported",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest));
}
let metadata = fs::metadata(&record.package_path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"resolved plugin package metadata could not be read: {}",
safe_io_error(&error)
),
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})?;
if !metadata.is_file() {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Discovery,
"resolved plugin package is not a regular file",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest));
}
if metadata.len() > limits.max_package_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"resolved plugin package exceeds the configured package size bound",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest));
}
let bytes = fs::read(&record.package_path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"resolved plugin package content could not be read: {}",
safe_io_error(&error)
),
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})?;
let archive = parse_stored_zip(&bytes, &record.package_label, record.source, limits)?;
let actual_digest = deterministic_digest(&archive.files);
if !digest_matches(&record.digest, &actual_digest) {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Digest,
PluginDiagnosticPhase::Resolution,
"resolved plugin package digest does not match current package content",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(actual_digest));
}
validate_manifest_path(
&runtime.entry,
&archive,
&record.package_label,
record.source,
&record.manifest.id,
)?;
let normalized = normalize_archive_path(&runtime.entry).ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Manifest,
"plugin manifest references a path outside the package root",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})?;
archive.files.get(&normalized).cloned().ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Missing,
PluginDiagnosticPhase::Manifest,
"plugin runtime module entry is missing from the package",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})
}
#[derive(Clone, Debug)]
struct PluginStore {
source: PluginSourceKind,

View File

@ -35,11 +35,13 @@ workflow-crate = { package = "workflow", path = "../workflow" }
uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true }
arc-swap = "1.9.1"
wasmi = { version = "0.51.1", default-features = false, features = ["std", "extra-checks"] }
[dev-dependencies]
dotenv = "0.15.0"
futures = { workspace = true }
tempfile = { workspace = true }
wat = "1.241.2"
[build-dependencies]
toml = { workspace = true }

View File

@ -1,17 +1,23 @@
//! Plugin package contributions for model-visible Tool schemas.
//!
//! This module registers *enabled* plugin package tool surface definitions as
//! unavailable Tool stubs. It deliberately does not execute plugin code or grant
//! plugin permissions; the runtime/WASM executor belongs to a later boundary.
//! This module registers *enabled* plugin package tool surface definitions and
//! executes Tool calls through the minimal sandboxed `yoi-plugin-wasm-1` WASM
//! ABI. It deliberately does not grant filesystem, network, environment, hook,
//! service, ingress, or richer host API authority; those remain follow-up
//! boundaries.
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use llm_worker::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
};
use manifest::plugin::{PluginConfig, PluginSurface, ResolvedPluginRecord};
use manifest::plugin::{
PluginConfig, PluginDiscoveryLimits, PluginSurface, ResolvedPluginRecord,
read_resolved_plugin_runtime_module,
};
use serde_json::Value;
use super::{
@ -115,7 +121,8 @@ impl FeatureModule for PluginToolFeature {
})?;
context.tools().register(ToolContribution::new(
tool.name.clone(),
plugin_runtime_missing_definition(
plugin_wasm_tool_definition(
self.record.clone(),
tool.name.clone(),
tool.description.clone(),
tool.input_schema.clone(),
@ -127,7 +134,18 @@ impl FeatureModule for PluginToolFeature {
}
}
fn plugin_runtime_missing_definition(
const PLUGIN_WASM_HOST_MODULE: &str = "yoi:tool";
const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call";
const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024;
const PLUGIN_WASM_MAX_OUTPUT_BYTES: usize = 64 * 1024;
const PLUGIN_WASM_MAX_SUMMARY_BYTES: usize = 1024;
const PLUGIN_WASM_FUEL: u64 = 5_000_000;
const PLUGIN_WASM_TIMEOUT: Duration = Duration::from_secs(1);
const PLUGIN_WASM_MEMORY_BYTES: usize = 2 * 1024 * 1024;
const PLUGIN_WASM_TABLE_ELEMENTS: usize = 256;
fn plugin_wasm_tool_definition(
record: ResolvedPluginRecord,
name: String,
description: String,
input_schema: Value,
@ -139,7 +157,8 @@ fn plugin_runtime_missing_definition(
.description(description.clone())
.input_schema(input_schema.clone())
.origin(origin.clone()),
Arc::new(PluginRuntimeMissingTool {
Arc::new(PluginWasmTool {
record: record.clone(),
name: name.clone(),
origin: origin.clone(),
}) as Arc<dyn Tool>,
@ -147,29 +166,390 @@ fn plugin_runtime_missing_definition(
})
}
struct PluginRuntimeMissingTool {
struct PluginWasmTool {
record: ResolvedPluginRecord,
name: String,
origin: ToolOrigin,
}
#[async_trait]
impl Tool for PluginRuntimeMissingTool {
impl Tool for PluginWasmTool {
async fn execute(
&self,
_input_json: &str,
input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
Err(ToolError::ExecutionFailed(format!(
"plugin tool runtime missing/unavailable for `{}` from `{}` (digest {}, package {} api {})",
self.name,
self.origin.plugin_ref,
self.origin.digest,
self.origin.package_version,
self.origin.package_api_version
)))
if input_json.len() > PLUGIN_WASM_MAX_INPUT_BYTES {
return Err(ToolError::InvalidArgument(format!(
"plugin tool `{}` input exceeds {} bytes",
self.name, PLUGIN_WASM_MAX_INPUT_BYTES
)));
}
serde_json::from_str::<Value>(input_json).map_err(|error| {
ToolError::InvalidArgument(format!(
"plugin tool `{}` input is not valid JSON: {}",
self.name,
bounded_message(error.to_string())
))
})?;
let record = self.record.clone();
let name = self.name.clone();
let plugin_ref = self.origin.plugin_ref.clone();
let digest = self.origin.digest.clone();
let input = input_json.as_bytes().to_vec();
let execution =
tokio::task::spawn_blocking(move || run_plugin_wasm_tool(record, name, input));
match tokio::time::timeout(PLUGIN_WASM_TIMEOUT, execution).await {
Ok(Ok(Ok(output))) => Ok(output),
Ok(Ok(Err(error))) => Err(ToolError::ExecutionFailed(format!(
"plugin WASM tool `{}` from `{}` (digest {}) failed closed: {}",
self.name,
plugin_ref,
digest,
error.bounded_message()
))),
Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!(
"plugin WASM tool `{}` from `{}` (digest {}) cancelled/failed to join: {}",
self.name,
plugin_ref,
digest,
bounded_message(error.to_string())
))),
Err(_) => Err(ToolError::ExecutionFailed(format!(
"plugin WASM tool `{}` from `{}` (digest {}) timed out after {:?}",
self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT
))),
}
}
}
#[derive(Debug)]
enum PluginWasmError {
Package(String),
Module(String),
Execution(String),
Output(String),
}
impl PluginWasmError {
fn bounded_message(&self) -> String {
match self {
Self::Package(message) => {
bounded_message(format!("package/module load error: {message}"))
}
Self::Module(message) => bounded_message(format!("WASM module error: {message}")),
Self::Execution(message) => bounded_message(format!("WASM execution error: {message}")),
Self::Output(message) => bounded_message(format!("WASM output error: {message}")),
}
}
}
#[derive(Debug)]
struct PluginWasmHostState {
tool_name: Vec<u8>,
input: Vec<u8>,
output: Vec<u8>,
output_error: Option<String>,
store_limits: wasmi::StoreLimits,
}
fn run_plugin_wasm_tool(
record: ResolvedPluginRecord,
tool_name: String,
input: Vec<u8>,
) -> Result<ToolOutput, PluginWasmError> {
let limits = PluginDiscoveryLimits::default();
let module_bytes = read_resolved_plugin_runtime_module(&record, &limits)
.map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?;
if module_bytes.len() > limits.max_file_size_bytes as usize {
return Err(PluginWasmError::Package(format!(
"WASM runtime module exceeds {} bytes",
limits.max_file_size_bytes
)));
}
let mut config = wasmi::Config::default();
config.consume_fuel(true);
config.set_max_recursion_depth(64);
config.set_max_stack_height(8 * 1024 * 1024);
let engine = wasmi::Engine::new(&config);
let module = wasmi::Module::new(&engine, &module_bytes[..])
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
validate_wasm_imports(&module)?;
let store_limits = wasmi::StoreLimitsBuilder::new()
.memory_size(PLUGIN_WASM_MEMORY_BYTES)
.table_elements(PLUGIN_WASM_TABLE_ELEMENTS)
.instances(1)
.tables(1)
.memories(1)
.trap_on_grow_failure(true)
.build();
let mut store = wasmi::Store::new(
&engine,
PluginWasmHostState {
tool_name: tool_name.into_bytes(),
input,
output: Vec::new(),
output_error: None,
store_limits,
},
);
store.limiter(|state| &mut state.store_limits);
store
.set_fuel(PLUGIN_WASM_FUEL)
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
let mut linker = wasmi::Linker::<PluginWasmHostState>::new(&engine);
define_plugin_wasm_host_imports(&mut linker)?;
let instance = linker
.instantiate_and_start(&mut store, &module)
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
let entry = instance
.get_typed_func::<(), ()>(&store, PLUGIN_WASM_ENTRYPOINT)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
entry
.call(&mut store, ())
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
if let Some(error) = store.data().output_error.clone() {
return Err(PluginWasmError::Output(error));
}
decode_plugin_wasm_output(&store.data().output)
}
fn validate_wasm_imports(module: &wasmi::Module) -> Result<(), PluginWasmError> {
for import in module.imports() {
if import.module() != PLUGIN_WASM_HOST_MODULE {
return Err(PluginWasmError::Module(format!(
"unsupported import module `{}`; only `{}` is available",
import.module(),
PLUGIN_WASM_HOST_MODULE
)));
}
match import.name() {
"tool_name_len" | "tool_name_read" | "input_len" | "input_read" | "output_write" => {}
other => {
return Err(PluginWasmError::Module(format!(
"unsupported host import `{}`; no filesystem, network, environment, or WASI imports are available",
other
)));
}
}
}
Ok(())
}
fn define_plugin_wasm_host_imports(
linker: &mut wasmi::Linker<PluginWasmHostState>,
) -> Result<(), PluginWasmError> {
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"tool_name_len",
|caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 {
caller.data().tool_name.len() as i32
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"input_len",
|caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 {
caller.data().input.len() as i32
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"tool_name_read",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::ToolName)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"input_read",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::Input)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
linker
.func_wrap(
PLUGIN_WASM_HOST_MODULE,
"output_write",
|mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 {
read_guest_output(&mut caller, ptr, len)
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
Ok(())
}
#[derive(Clone, Copy, Debug)]
enum HostBuffer {
ToolName,
Input,
}
fn write_host_bytes_to_guest(
caller: &mut wasmi::Caller<'_, PluginWasmHostState>,
ptr: i32,
len: i32,
buffer: HostBuffer,
) -> i32 {
if ptr < 0 || len < 0 {
return -1;
}
let bytes = match buffer {
HostBuffer::ToolName => caller.data().tool_name.clone(),
HostBuffer::Input => caller.data().input.clone(),
};
if len as usize != bytes.len() {
return -1;
}
let Some(memory) = caller
.get_export("memory")
.and_then(|export| export.into_memory())
else {
return -1;
};
match memory.write(caller, ptr as usize, &bytes) {
Ok(()) => bytes.len() as i32,
Err(_) => -1,
}
}
fn read_guest_output(
caller: &mut wasmi::Caller<'_, PluginWasmHostState>,
ptr: i32,
len: i32,
) -> i32 {
if ptr < 0 || len < 0 {
caller.data_mut().output_error = Some("guest output pointer/length is invalid".into());
return -1;
}
let len = len as usize;
if len > PLUGIN_WASM_MAX_OUTPUT_BYTES {
caller.data_mut().output_error = Some(format!(
"guest output exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
));
return -1;
}
let Some(memory) = caller
.get_export("memory")
.and_then(|export| export.into_memory())
else {
caller.data_mut().output_error = Some("guest did not export linear memory".into());
return -1;
};
let mut output = vec![0; len];
if memory.read(&*caller, ptr as usize, &mut output).is_err() {
caller.data_mut().output_error = Some("guest output memory range is invalid".into());
return -1;
}
caller.data_mut().output = output;
len as i32
}
fn decode_plugin_wasm_output(bytes: &[u8]) -> Result<ToolOutput, PluginWasmError> {
if bytes.is_empty() {
return Err(PluginWasmError::Output(
"guest did not call output_write".into(),
));
}
if bytes.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES {
return Err(PluginWasmError::Output(format!(
"guest output exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
)));
}
let text = std::str::from_utf8(bytes)
.map_err(|error| PluginWasmError::Output(format!("guest output is not UTF-8: {error}")))?;
let value: Value = serde_json::from_str(text).map_err(|error| {
PluginWasmError::Output(format!("guest output is not valid JSON: {error}"))
})?;
let Value::Object(map) = value else {
return Err(PluginWasmError::Output(
"guest output JSON must be an object".into(),
));
};
for key in map.keys() {
if key != "summary" && key != "content" {
return Err(PluginWasmError::Output(format!(
"guest output contains unsupported key `{key}`"
)));
}
}
let summary = match map.get("summary") {
Some(Value::String(summary)) if !summary.is_empty() => summary.clone(),
Some(Value::String(_)) => {
return Err(PluginWasmError::Output(
"guest output summary must not be empty".into(),
));
}
Some(_) => {
return Err(PluginWasmError::Output(
"guest output summary must be a string".into(),
));
}
None => {
return Err(PluginWasmError::Output(
"guest output must include a summary string".into(),
));
}
};
if summary.len() > PLUGIN_WASM_MAX_SUMMARY_BYTES {
return Err(PluginWasmError::Output(format!(
"guest output summary exceeds {} bytes",
PLUGIN_WASM_MAX_SUMMARY_BYTES
)));
}
let content = match map.get("content") {
Some(Value::String(content)) => {
if content.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES {
return Err(PluginWasmError::Output(format!(
"guest output content exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
)));
}
Some(content.clone())
}
Some(Value::Null) | None => None,
Some(_) => {
return Err(PluginWasmError::Output(
"guest output content must be a string or null".into(),
));
}
};
Ok(ToolOutput { summary, content })
}
fn bounded_message(message: impl Into<String>) -> String {
let message = message.into();
let mut sanitized = String::with_capacity(message.len().min(512));
for ch in message.chars() {
if ch.is_control() && ch != '\n' && ch != '\t' {
sanitized.push(' ');
} else {
sanitized.push(ch);
}
if sanitized.len() >= 512 {
sanitized.truncate(512);
sanitized.push('…');
break;
}
}
sanitized
}
fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> {
let mut seen = HashSet::new();
for tool in &record.manifest.tools {
@ -371,8 +751,14 @@ fn is_supported_schema_keyword(key: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use manifest::plugin::{PluginPackageManifest, SourceQualifiedPluginId};
use manifest::plugin::{
PluginDiscoveryOptions, PluginEnablementConfig, PluginPackageManifest,
PluginRuntimeManifest, SourceQualifiedPluginId, resolve_plugin_config_for_startup,
};
use serde_json::json;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn tool(name: &str) -> manifest::plugin::PluginToolManifest {
manifest::plugin::PluginToolManifest {
@ -645,27 +1031,349 @@ mod tests {
}
#[tokio::test]
async fn registered_tool_executes_as_runtime_missing_error() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.install_into_pending(&mut pending, &mut hooks);
assert!(
report
.reports
.iter()
.all(|feature_report| feature_report.diagnostics.is_empty()),
"{:#?}",
report.reports
);
async fn registered_plugin_tool_executes_wasm_and_returns_normal_tool_result() {
let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module());
let origin = PluginToolFeature::new(record.clone()).origin();
let tool = PluginWasmTool {
record,
name: "PluginEcho".into(),
origin,
};
let output = tool
.execute(r#"{"x":1}"#, ToolExecutionContext::default())
.await
.unwrap();
assert_eq!(output.summary, "input reached");
assert_eq!(output.content.as_deref(), Some("ordinary tool result path"));
let result = llm_worker::tool::ToolResult::from_output("call-1", output);
assert_eq!(result.summary, "input reached");
assert!(
result
.content
.unwrap()
.contains("ordinary tool result path")
);
}
#[tokio::test]
async fn malformed_input_json_fails_before_wasm_execution() {
let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module());
let origin = PluginToolFeature::new(record.clone()).origin();
let tool = PluginWasmTool {
record,
name: "PluginEcho".into(),
origin,
};
let error = tool
.execute("not json", ToolExecutionContext::default())
.await
.unwrap_err();
assert!(error.to_string().contains("input is not valid JSON"));
}
#[tokio::test]
async fn malformed_output_fails_closed() {
let (_dir, record) = resolved_record_with_wasm(output_module(b"not json"));
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
assert!(error.bounded_message().contains("not valid JSON"));
}
#[tokio::test]
async fn schema_mismatch_output_fails_closed() {
let (_dir, record) = resolved_record_with_wasm(output_module(br#"{"summary":1}"#));
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
assert!(error.bounded_message().contains("summary must be a string"));
}
#[tokio::test]
async fn oversize_output_fails_closed() {
let (_dir, record) = resolved_record_with_wasm(oversize_output_module());
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
assert!(error.bounded_message().contains("exceeds"));
}
#[tokio::test]
async fn nonterminating_execution_fails_closed_with_fuel_boundary() {
let (_dir, record) = resolved_record_with_wasm(nonterminating_module());
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
let message = error.bounded_message();
assert!(
message.contains("Execution")
|| message.contains("fuel")
|| message.contains("execution"),
"{message}"
);
}
#[tokio::test]
async fn missing_runtime_module_returns_safe_bounded_tool_error() {
let record = record_with_missing_package_runtime();
let origin = PluginToolFeature::new(record.clone()).origin();
let tool = PluginWasmTool {
record,
name: "PluginSearch".into(),
origin,
};
let (_, tool) = pending[0]();
let error = tool
.execute("{}", ToolExecutionContext::default())
.await
.unwrap_err();
assert!(error.to_string().contains("runtime missing/unavailable"));
assert!(error.to_string().contains("project:example"));
let message = error.to_string();
assert!(message.contains("failed closed"));
assert!(message.contains("metadata could not be read"));
assert!(message.len() < 900);
assert!(message.contains("project:example"));
}
#[tokio::test]
async fn ambient_wasi_fs_network_env_imports_are_unavailable() {
let (_dir, record) = resolved_record_with_wasm(wasi_import_module());
let error =
run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()).unwrap_err();
let message = error.bounded_message();
assert!(message.contains("unsupported import module"), "{message}");
assert!(message.contains("wasi_snapshot_preview1"), "{message}");
}
fn record_with_missing_package_runtime() -> ResolvedPluginRecord {
let mut record = record(vec![tool("PluginSearch")]);
record.manifest.runtime = Some(PluginRuntimeManifest {
kind: "wasm".into(),
entry: "plugin.wasm".into(),
abi: Some("yoi-plugin-wasm-1".into()),
});
record
}
fn resolved_record_with_wasm(wasm: Vec<u8>) -> (TempDir, ResolvedPluginRecord) {
let dir = TempDir::new().unwrap();
let package_dir = dir.path().join(".yoi/plugins");
fs::create_dir_all(&package_dir).unwrap();
let package_path = package_dir.join("example.yoi-plugin");
write_plugin_package(&package_path, &wasm);
let config = PluginConfig {
enabled: vec![PluginEnablementConfig {
id: "project:example".parse().unwrap(),
surfaces: vec![PluginSurface::Tool],
..PluginEnablementConfig::default()
}],
resolved: Vec::new(),
diagnostics: Vec::new(),
};
let options = PluginDiscoveryOptions::new(dir.path());
let resolved = resolve_plugin_config_for_startup(&config, &options);
assert!(
resolved.diagnostics.is_empty(),
"{:#?}",
resolved.diagnostics
);
assert_eq!(resolved.resolved.len(), 1);
(dir, resolved.resolved[0].clone())
}
fn write_plugin_package(path: &Path, wasm: &[u8]) {
let manifest = br#"schema_version = 1
id = "example"
name = "Example"
version = "1.0.0"
description = "Example plugin"
surfaces = ["tool"]
[runtime]
kind = "wasm"
entry = "plugin.wasm"
abi = "yoi-plugin-wasm-1"
[[tools]]
name = "PluginEcho"
description = "Echo plugin tool"
input_schema = { type = "object", additionalProperties = true }
"#;
write_stored_zip(
path,
&[("plugin.toml", manifest.as_slice()), ("plugin.wasm", wasm)],
);
}
fn input_reaches_guest_module() -> Vec<u8> {
let ok = br#"{"summary":"input reached","content":"ordinary tool result path"}"#;
let bad = br#"{"summary":"input missing"}"#;
let wat = format!(
r#"(module
(import "yoi:tool" "input_len" (func $input_len (result i32)))
(import "yoi:tool" "input_read" (func $input_read (param i32 i32) (result i32)))
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "{}")
(data (i32.const 128) "{}")
(func (export "yoi_tool_call")
(local $len i32)
(local.set $len (call $input_len))
(if (i32.eq (local.get $len) (i32.const 7))
(then
(drop (call $input_read (i32.const 512) (local.get $len)))
(if (i32.eq (i32.load8_u (i32.const 517)) (i32.const 49))
(then (drop (call $output_write (i32.const 0) (i32.const {}))))
(else (drop (call $output_write (i32.const 128) (i32.const {}))))
)
)
(else (drop (call $output_write (i32.const 128) (i32.const {}))))
)
)
)"#,
wat_bytes(ok),
wat_bytes(bad),
ok.len(),
bad.len(),
bad.len()
);
wat::parse_str(wat).unwrap()
}
fn output_module(output: &[u8]) -> Vec<u8> {
let wat = format!(
r#"(module
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "{}")
(func (export "yoi_tool_call")
(drop (call $output_write (i32.const 0) (i32.const {})))
)
)"#,
wat_bytes(output),
output.len()
);
wat::parse_str(wat).unwrap()
}
fn oversize_output_module() -> Vec<u8> {
let wat = format!(
r#"(module
(import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32)))
(memory (export "memory") 2)
(func (export "yoi_tool_call")
(drop (call $output_write (i32.const 0) (i32.const {})))
)
)"#,
PLUGIN_WASM_MAX_OUTPUT_BYTES + 1
);
wat::parse_str(wat).unwrap()
}
fn nonterminating_module() -> Vec<u8> {
wat::parse_str(
r#"(module
(memory (export "memory") 1)
(func (export "yoi_tool_call")
(local $remaining i32)
(local.set $remaining (i32.const 100000000))
(loop $again
(local.set $remaining (i32.sub (local.get $remaining) (i32.const 1)))
(br_if $again (local.get $remaining))
)
)
)"#,
)
.unwrap()
}
fn wasi_import_module() -> Vec<u8> {
wat::parse_str(
r#"(module
(import "wasi_snapshot_preview1" "fd_write" (func $fd_write))
(memory (export "memory") 1)
(func (export "yoi_tool_call"))
)"#,
)
.unwrap()
}
fn wat_bytes(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!(r#"\{:02x}"#, byte))
.collect()
}
fn write_stored_zip(path: &Path, files: &[(&str, &[u8])]) {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in files {
let offset = out.len() as u32;
let name_bytes = name.as_bytes();
let crc = crc32(data);
write_u32(&mut out, 0x0403_4b50);
write_u16(&mut out, 20);
write_u16(&mut out, 0);
write_u16(&mut out, 0);
write_u16(&mut out, 0);
write_u16(&mut out, 0);
write_u32(&mut out, crc);
write_u32(&mut out, data.len() as u32);
write_u32(&mut out, data.len() as u32);
write_u16(&mut out, name_bytes.len() as u16);
write_u16(&mut out, 0);
out.extend_from_slice(name_bytes);
out.extend_from_slice(data);
write_u32(&mut central, 0x0201_4b50);
write_u16(&mut central, 20);
write_u16(&mut central, 20);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u32(&mut central, crc);
write_u32(&mut central, data.len() as u32);
write_u32(&mut central, data.len() as u32);
write_u16(&mut central, name_bytes.len() as u16);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u16(&mut central, 0);
write_u32(&mut central, 0);
write_u32(&mut central, offset);
central.extend_from_slice(name_bytes);
}
let central_offset = out.len() as u32;
let central_size = central.len() as u32;
out.extend_from_slice(&central);
write_u32(&mut out, 0x0605_4b50);
write_u16(&mut out, 0);
write_u16(&mut out, 0);
write_u16(&mut out, files.len() as u16);
write_u16(&mut out, files.len() as u16);
write_u32(&mut out, central_size);
write_u32(&mut out, central_offset);
write_u16(&mut out, 0);
fs::write(path, out).unwrap();
}
fn write_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn write_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = if crc & 1 == 1 { 0xedb8_8320 } else { 0 };
crc = (crc >> 1) ^ mask;
}
}
!crc
}
}

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-Y1siH1oDe9It7ntx83DJO5fzV9LtC7+qq9V6RPlRxUY=";
cargoHash = "sha256-ud+3INcXnT5W26Bz0K4QXUqoqw3p/ER9c4F2Fhq3YuQ=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,