plugin: implement component model runtime
This commit is contained in:
+258
-25
@@ -378,12 +378,28 @@ impl PluginPackageManifest {
|
||||
}
|
||||
}
|
||||
|
||||
pub const PLUGIN_RUNTIME_WASM_KIND: &str = "wasm";
|
||||
pub const PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1";
|
||||
/// Manifest runtime kind for WebAssembly Component Model Tool packages.
|
||||
///
|
||||
/// Component runtime manifests must set `component` to the packaged component
|
||||
/// artifact path and `world` to [`PLUGIN_COMPONENT_TOOL_WORLD`]. Raw core-Wasm
|
||||
/// packages remain explicit `kind = "wasm"` plus `abi = "yoi-plugin-wasm-1"`.
|
||||
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
|
||||
pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginRuntimeManifest {
|
||||
pub kind: String,
|
||||
pub entry: String,
|
||||
#[serde(default)]
|
||||
pub entry: Option<String>,
|
||||
#[serde(default)]
|
||||
pub abi: Option<String>,
|
||||
#[serde(default)]
|
||||
pub component: Option<String>,
|
||||
#[serde(default)]
|
||||
pub world: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -837,7 +853,7 @@ pub fn read_resolved_plugin_runtime_module(
|
||||
.with_digest(&record.digest)
|
||||
})?;
|
||||
|
||||
if runtime.kind != "wasm" {
|
||||
if runtime.kind != PLUGIN_RUNTIME_WASM_KIND {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
@@ -848,7 +864,7 @@ pub fn read_resolved_plugin_runtime_module(
|
||||
.with_package(&record.package_label)
|
||||
.with_digest(&record.digest));
|
||||
}
|
||||
if runtime.abi.as_deref() != Some("yoi-plugin-wasm-1") {
|
||||
if runtime.abi.as_deref() != Some(PLUGIN_RUNTIME_WASM_ABI) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
@@ -860,6 +876,18 @@ pub fn read_resolved_plugin_runtime_module(
|
||||
.with_digest(&record.digest));
|
||||
}
|
||||
|
||||
let entry = runtime.entry.as_deref().ok_or_else(|| {
|
||||
PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Missing,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin WASM runtime entry is required",
|
||||
)
|
||||
.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,
|
||||
@@ -926,13 +954,13 @@ pub fn read_resolved_plugin_runtime_module(
|
||||
}
|
||||
|
||||
validate_manifest_path(
|
||||
&runtime.entry,
|
||||
entry,
|
||||
&archive,
|
||||
&record.package_label,
|
||||
record.source,
|
||||
&record.manifest.id,
|
||||
)?;
|
||||
let normalized = normalize_archive_path(&runtime.entry).ok_or_else(|| {
|
||||
let normalized = normalize_archive_path(entry).ok_or_else(|| {
|
||||
PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Traversal,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
@@ -956,6 +984,154 @@ pub fn read_resolved_plugin_runtime_module(
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the WebAssembly Component Model artifact selected by a resolved plugin
|
||||
/// package manifest while preserving package digest pinning.
|
||||
pub fn read_resolved_plugin_runtime_component(
|
||||
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 component runtime",
|
||||
)
|
||||
.with_source(record.source)
|
||||
.with_identity(&record.identity)
|
||||
.with_package(&record.package_label)
|
||||
.with_digest(&record.digest)
|
||||
})?;
|
||||
|
||||
if runtime.kind != PLUGIN_RUNTIME_COMPONENT_KIND {
|
||||
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.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin component world is unsupported",
|
||||
)
|
||||
.with_source(record.source)
|
||||
.with_identity(&record.identity)
|
||||
.with_package(&record.package_label)
|
||||
.with_digest(&record.digest));
|
||||
}
|
||||
let component = runtime.component.as_deref().ok_or_else(|| {
|
||||
PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Missing,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin component runtime artifact is required",
|
||||
)
|
||||
.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(
|
||||
component,
|
||||
&archive,
|
||||
&record.package_label,
|
||||
record.source,
|
||||
&record.manifest.id,
|
||||
)?;
|
||||
let normalized = normalize_archive_path(component).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 component artifact 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,
|
||||
@@ -1237,27 +1413,84 @@ fn validate_manifest(
|
||||
.with_package(label));
|
||||
}
|
||||
if let Some(runtime) = &manifest.runtime {
|
||||
if runtime.kind != "wasm" {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin runtime kind is unsupported",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
match runtime.kind.as_str() {
|
||||
PLUGIN_RUNTIME_WASM_KIND => {
|
||||
if runtime.abi.as_deref() != Some(PLUGIN_RUNTIME_WASM_ABI) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin WASM ABI is unsupported",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
let Some(entry) = runtime.entry.as_deref() else {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Missing,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin WASM runtime entry is required",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
};
|
||||
if runtime.component.is_some() || runtime.world.is_some() {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Malformed,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin WASM runtime must not declare component metadata",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
validate_manifest_path(entry, archive, label, source, &manifest.id)?;
|
||||
}
|
||||
PLUGIN_RUNTIME_COMPONENT_KIND => {
|
||||
if runtime.abi.is_some() || runtime.entry.is_some() {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Malformed,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin component runtime must not declare raw WASM ABI metadata",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin component world is unsupported",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
let Some(component) = runtime.component.as_deref() else {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Missing,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin component runtime artifact is required",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
};
|
||||
validate_manifest_path(component, archive, label, source, &manifest.id)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(PluginDiagnostic::new(
|
||||
PluginDiagnosticKind::Api,
|
||||
PluginDiagnosticPhase::Manifest,
|
||||
"plugin runtime kind is unsupported",
|
||||
)
|
||||
.with_source(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
}
|
||||
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(source)
|
||||
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
|
||||
.with_package(label));
|
||||
}
|
||||
validate_manifest_path(&runtime.entry, archive, label, source, &manifest.id)?;
|
||||
}
|
||||
for hook in &manifest.hooks {
|
||||
if !is_safe_id(&hook.id) {
|
||||
|
||||
@@ -37,6 +37,7 @@ 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"] }
|
||||
wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] }
|
||||
|
||||
[dev-dependencies]
|
||||
dotenv = "0.15.0"
|
||||
|
||||
@@ -21,8 +21,10 @@ use llm_worker::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
|
||||
};
|
||||
use manifest::plugin::{
|
||||
PluginConfig, PluginDiscoveryLimits, PluginFsGrant, PluginFsOperation, PluginHostApi,
|
||||
PluginPermission, PluginSurface, PluginToolManifest, ResolvedPluginRecord,
|
||||
PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, PLUGIN_RUNTIME_WASM_ABI,
|
||||
PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, PluginFsGrant,
|
||||
PluginFsOperation, PluginHostApi, PluginPermission, PluginSurface, PluginToolManifest,
|
||||
ResolvedPluginRecord, read_resolved_plugin_runtime_component,
|
||||
read_resolved_plugin_runtime_module,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -134,26 +136,51 @@ pub struct PluginToolEligibility {
|
||||
pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection {
|
||||
let runtime = match &record.manifest.runtime {
|
||||
Some(runtime)
|
||||
if runtime.kind == "wasm" && runtime.abi.as_deref() == Some("yoi-plugin-wasm-1") =>
|
||||
if runtime.kind == PLUGIN_RUNTIME_WASM_KIND
|
||||
&& runtime.abi.as_deref() == Some(PLUGIN_RUNTIME_WASM_ABI)
|
||||
&& runtime.entry.is_some() =>
|
||||
{
|
||||
PluginRuntimeEligibility {
|
||||
eligible: true,
|
||||
status: "wasm/yoi-plugin-wasm-1".to_string(),
|
||||
status: format!("{PLUGIN_RUNTIME_WASM_KIND}/{PLUGIN_RUNTIME_WASM_ABI}"),
|
||||
diagnostic: None,
|
||||
}
|
||||
}
|
||||
Some(runtime) if runtime.kind == "wasm" => {
|
||||
Some(runtime) if runtime.kind == PLUGIN_RUNTIME_WASM_KIND => {
|
||||
let status = runtime
|
||||
.abi
|
||||
.as_deref()
|
||||
.map(|abi| format!("wasm/{abi}"))
|
||||
.unwrap_or_else(|| "wasm/<missing-abi>".to_string());
|
||||
.map(|abi| format!("{PLUGIN_RUNTIME_WASM_KIND}/{abi}"))
|
||||
.unwrap_or_else(|| format!("{PLUGIN_RUNTIME_WASM_KIND}/<missing-abi>"));
|
||||
PluginRuntimeEligibility {
|
||||
eligible: false,
|
||||
status,
|
||||
diagnostic: Some("unsupported or missing plugin runtime ABI".to_string()),
|
||||
}
|
||||
}
|
||||
Some(runtime)
|
||||
if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND
|
||||
&& runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD)
|
||||
&& runtime.component.is_some() =>
|
||||
{
|
||||
PluginRuntimeEligibility {
|
||||
eligible: true,
|
||||
status: format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}"),
|
||||
diagnostic: None,
|
||||
}
|
||||
}
|
||||
Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND => {
|
||||
let status = runtime
|
||||
.world
|
||||
.as_deref()
|
||||
.map(|world| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{world}"))
|
||||
.unwrap_or_else(|| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/<missing-world>"));
|
||||
PluginRuntimeEligibility {
|
||||
eligible: false,
|
||||
status,
|
||||
diagnostic: Some("unsupported or missing plugin component world".to_string()),
|
||||
}
|
||||
}
|
||||
Some(runtime) => PluginRuntimeEligibility {
|
||||
eligible: false,
|
||||
status: runtime.kind.clone(),
|
||||
@@ -1708,26 +1735,25 @@ impl Tool for PluginWasmTool {
|
||||
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));
|
||||
let execution = tokio::task::spawn_blocking(move || run_plugin_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: {}",
|
||||
"plugin 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: {}",
|
||||
"plugin 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 {:?}",
|
||||
"plugin tool `{}` from `{}` (digest {}) timed out after {:?}",
|
||||
self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT
|
||||
))),
|
||||
}
|
||||
@@ -1767,6 +1793,28 @@ struct PluginWasmHostState {
|
||||
store_limits: wasmi::StoreLimits,
|
||||
}
|
||||
|
||||
fn run_plugin_tool(
|
||||
record: ResolvedPluginRecord,
|
||||
tool_name: String,
|
||||
input: Vec<u8>,
|
||||
) -> Result<ToolOutput, PluginWasmError> {
|
||||
match record
|
||||
.manifest
|
||||
.runtime
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.kind.as_str())
|
||||
{
|
||||
Some(PLUGIN_RUNTIME_WASM_KIND) => run_plugin_wasm_tool(record, tool_name, input),
|
||||
Some(PLUGIN_RUNTIME_COMPONENT_KIND) => run_plugin_component_tool(record, tool_name, input),
|
||||
Some(other) => Err(PluginWasmError::Module(format!(
|
||||
"unsupported plugin runtime kind `{other}`"
|
||||
))),
|
||||
None => Err(PluginWasmError::Package(
|
||||
"plugin runtime is not declared".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_plugin_wasm_tool(
|
||||
record: ResolvedPluginRecord,
|
||||
tool_name: String,
|
||||
@@ -1864,6 +1912,216 @@ fn run_plugin_wasm_tool_with_https_client(
|
||||
decode_plugin_wasm_output(&store.data().output)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PluginComponentHostState {
|
||||
record: ResolvedPluginRecord,
|
||||
https_client: Arc<dyn PluginHttpsClient>,
|
||||
}
|
||||
|
||||
fn run_plugin_component_tool(
|
||||
record: ResolvedPluginRecord,
|
||||
tool_name: String,
|
||||
input: Vec<u8>,
|
||||
) -> Result<ToolOutput, PluginWasmError> {
|
||||
run_plugin_component_tool_with_https_client(
|
||||
record,
|
||||
tool_name,
|
||||
input,
|
||||
Arc::new(ReqwestPluginHttpsClient),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_plugin_component_tool_with_https_client(
|
||||
record: ResolvedPluginRecord,
|
||||
tool_name: String,
|
||||
input: Vec<u8>,
|
||||
https_client: Arc<dyn PluginHttpsClient>,
|
||||
) -> Result<ToolOutput, PluginWasmError> {
|
||||
let tool = record
|
||||
.manifest
|
||||
.tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == tool_name)
|
||||
.ok_or_else(|| {
|
||||
PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string())
|
||||
})?;
|
||||
authorize_plugin_tool(&record, tool).map_err(|error| {
|
||||
PluginWasmError::Module(format!(
|
||||
"plugin permission denied: {}",
|
||||
error.bounded_message()
|
||||
))
|
||||
})?;
|
||||
|
||||
let limits = PluginDiscoveryLimits::default();
|
||||
let component_bytes = read_resolved_plugin_runtime_component(&record, &limits)
|
||||
.map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?;
|
||||
if component_bytes.len() > limits.max_file_size_bytes as usize {
|
||||
return Err(PluginWasmError::Package(format!(
|
||||
"WASM component runtime artifact exceeds {} bytes",
|
||||
limits.max_file_size_bytes
|
||||
)));
|
||||
}
|
||||
|
||||
let mut config = wasmtime::Config::new();
|
||||
config.wasm_component_model(true);
|
||||
config.consume_fuel(true);
|
||||
config.max_wasm_stack(8 * 1024 * 1024);
|
||||
let engine = wasmtime::Engine::new(&config)
|
||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
||||
let component =
|
||||
wasmtime::component::Component::new(&engine, &component_bytes).map_err(|error| {
|
||||
PluginWasmError::Module(format!("component is incompatible: {error:?}"))
|
||||
})?;
|
||||
validate_component_imports(&record, &engine, &component)?;
|
||||
|
||||
let mut linker = wasmtime::component::Linker::<PluginComponentHostState>::new(&engine);
|
||||
define_plugin_component_host_imports(&mut linker)?;
|
||||
let mut store = wasmtime::Store::new(
|
||||
&engine,
|
||||
PluginComponentHostState {
|
||||
record: record.clone(),
|
||||
https_client,
|
||||
},
|
||||
);
|
||||
store
|
||||
.set_fuel(PLUGIN_WASM_FUEL)
|
||||
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
|
||||
let instance = linker
|
||||
.instantiate(&mut store, &component)
|
||||
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
|
||||
let call = instance
|
||||
.get_typed_func::<(&str, &str), (String,)>(&mut store, "call")
|
||||
.map_err(|error| {
|
||||
PluginWasmError::Module(format!(
|
||||
"component does not export expected `{}` call function: {error}",
|
||||
PLUGIN_COMPONENT_TOOL_WORLD
|
||||
))
|
||||
})?;
|
||||
let input_json = std::str::from_utf8(&input).map_err(|error| {
|
||||
PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}"))
|
||||
})?;
|
||||
let (output,) = call
|
||||
.call(&mut store, (&tool_name, input_json))
|
||||
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
|
||||
decode_plugin_wasm_output(output.as_bytes())
|
||||
}
|
||||
|
||||
fn validate_component_imports(
|
||||
record: &ResolvedPluginRecord,
|
||||
engine: &wasmtime::Engine,
|
||||
component: &wasmtime::component::Component,
|
||||
) -> Result<(), PluginWasmError> {
|
||||
for (name, _) in component.component_type().imports(engine) {
|
||||
match name {
|
||||
"yoi:host/https@1.0.0" => {
|
||||
authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| {
|
||||
PluginWasmError::Module(format!(
|
||||
"plugin host API dispatch denied: {}",
|
||||
error.bounded_message()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
"yoi:host/fs@1.0.0" => {
|
||||
authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| {
|
||||
PluginWasmError::Module(format!(
|
||||
"plugin host API dispatch denied: {}",
|
||||
error.bounded_message()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
other => {
|
||||
return Err(PluginWasmError::Module(format!(
|
||||
"unsupported component import `{other}`; no WASI filesystem, ambient network, environment, or other imports are available"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn define_plugin_component_host_imports(
|
||||
linker: &mut wasmtime::component::Linker<PluginComponentHostState>,
|
||||
) -> Result<(), PluginWasmError> {
|
||||
linker
|
||||
.root()
|
||||
.instance("yoi:host/https@1.0.0")
|
||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?
|
||||
.func_wrap(
|
||||
"request",
|
||||
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
|
||||
(request,): (String,)|
|
||||
-> wasmtime::Result<(String,)> {
|
||||
authorize_plugin_host_api(&store.data().record, PluginHostApi::Https)
|
||||
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
|
||||
let response = execute_plugin_https_request(
|
||||
&store.data().record,
|
||||
store.data().https_client.as_ref(),
|
||||
request.as_bytes(),
|
||||
)
|
||||
.map_err(|error| wasmtime::Error::msg(error.0))?;
|
||||
Ok((String::from_utf8_lossy(&response).into_owned(),))
|
||||
},
|
||||
)
|
||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
||||
|
||||
let mut root = linker.root();
|
||||
let mut fs = root
|
||||
.instance("yoi:host/fs@1.0.0")
|
||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
||||
fs.func_wrap(
|
||||
"read",
|
||||
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
|
||||
(request,): (String,)|
|
||||
-> wasmtime::Result<(String,)> {
|
||||
authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs)
|
||||
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
|
||||
execute_plugin_fs_request(
|
||||
&store.data().record,
|
||||
PluginFsRuntimeOperation::Read,
|
||||
request.as_bytes(),
|
||||
)
|
||||
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
|
||||
.map_err(|error| wasmtime::Error::msg(error.message))
|
||||
},
|
||||
)
|
||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
||||
fs.func_wrap(
|
||||
"list",
|
||||
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
|
||||
(request,): (String,)|
|
||||
-> wasmtime::Result<(String,)> {
|
||||
authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs)
|
||||
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
|
||||
execute_plugin_fs_request(
|
||||
&store.data().record,
|
||||
PluginFsRuntimeOperation::List,
|
||||
request.as_bytes(),
|
||||
)
|
||||
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
|
||||
.map_err(|error| wasmtime::Error::msg(error.message))
|
||||
},
|
||||
)
|
||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
||||
fs.func_wrap(
|
||||
"write",
|
||||
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
|
||||
(request,): (String,)|
|
||||
-> wasmtime::Result<(String,)> {
|
||||
authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs)
|
||||
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
|
||||
execute_plugin_fs_request(
|
||||
&store.data().record,
|
||||
PluginFsRuntimeOperation::Write,
|
||||
request.as_bytes(),
|
||||
)
|
||||
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
|
||||
.map_err(|error| wasmtime::Error::msg(error.message))
|
||||
},
|
||||
)
|
||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_wasm_imports(
|
||||
record: &ResolvedPluginRecord,
|
||||
module: &wasmi::Module,
|
||||
@@ -3679,9 +3937,11 @@ mod tests {
|
||||
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()),
|
||||
kind: PLUGIN_RUNTIME_WASM_KIND.into(),
|
||||
entry: Some("plugin.wasm".into()),
|
||||
abi: Some(PLUGIN_RUNTIME_WASM_ABI.into()),
|
||||
component: None,
|
||||
world: None,
|
||||
});
|
||||
record
|
||||
}
|
||||
@@ -3736,6 +3996,269 @@ mod tests {
|
||||
(dir, record)
|
||||
}
|
||||
|
||||
fn write_component_plugin_package(path: &Path, component: &[u8], world: &str) {
|
||||
let manifest = format!(
|
||||
r#"schema_version = 1
|
||||
id = "example"
|
||||
name = "Example"
|
||||
version = "1.0.0"
|
||||
description = "Example component plugin"
|
||||
surfaces = ["tool"]
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
component = "plugin.component.wasm"
|
||||
world = "{}"
|
||||
|
||||
[[permissions]]
|
||||
kind = "surface"
|
||||
surface = "tool"
|
||||
|
||||
[[permissions]]
|
||||
kind = "tool"
|
||||
name = "PluginEcho"
|
||||
|
||||
[[tools]]
|
||||
name = "PluginEcho"
|
||||
description = "Echo plugin tool"
|
||||
input_schema = {{ type = "object", additionalProperties = true }}
|
||||
"#,
|
||||
world
|
||||
);
|
||||
write_stored_zip(
|
||||
path,
|
||||
&[
|
||||
("plugin.toml", manifest.as_bytes()),
|
||||
("plugin.component.wasm", component),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn resolved_record_with_component(component: 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("component.yoi-plugin");
|
||||
write_component_plugin_package(&package_path, &component, PLUGIN_COMPONENT_TOOL_WORLD);
|
||||
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);
|
||||
let mut record = resolved.resolved[0].clone();
|
||||
record.grants = PluginGrantConfig {
|
||||
id: Some(record.identity.to_string()),
|
||||
version: Some(PluginExactVersion(record.version.clone())),
|
||||
digest: Some(record.digest.clone()),
|
||||
permissions: tool_permissions(&record.manifest.tools),
|
||||
https: Vec::new(),
|
||||
fs: Vec::new(),
|
||||
};
|
||||
(dir, record)
|
||||
}
|
||||
|
||||
fn component_tool_that_returns(output: &[u8]) -> Vec<u8> {
|
||||
wat::parse_str(format!(
|
||||
r#"(component
|
||||
(core module $m
|
||||
(memory (export "memory") 1)
|
||||
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
|
||||
(if (result i32) (i32.eqz (local.get 0))
|
||||
(then (i32.const 8192))
|
||||
(else (local.get 0))))
|
||||
(data (i32.const 1024) "{}")
|
||||
(func (export "call") (param i32 i32 i32 i32) (result i32)
|
||||
(i32.store (i32.const 2048) (i32.const 1024))
|
||||
(i32.store (i32.const 2052) (i32.const {}))
|
||||
(i32.const 2048))
|
||||
)
|
||||
(core instance $i (instantiate $m))
|
||||
(alias core export $i "memory" (core memory $mem))
|
||||
(alias core export $i "realloc" (core func $realloc))
|
||||
(alias core export $i "call" (core func $call_core))
|
||||
(type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string)))
|
||||
(func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
|
||||
(export "call" (func $call))
|
||||
)"#,
|
||||
wat_bytes(output),
|
||||
output.len()
|
||||
))
|
||||
.expect("valid component wat")
|
||||
}
|
||||
|
||||
fn component_tool_importing_https(output: &[u8]) -> Vec<u8> {
|
||||
wat::parse_str(format!(
|
||||
r#"(component
|
||||
(import "yoi:host/https@1.0.0" (instance $https (export "request" (func $request (param "request-json" string) (result string)))))
|
||||
(core module $m
|
||||
(memory (export "memory") 1)
|
||||
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
|
||||
(if (result i32) (i32.eqz (local.get 0))
|
||||
(then (i32.const 8192))
|
||||
(else (local.get 0))))
|
||||
(data (i32.const 1024) "{}")
|
||||
(func (export "call") (param i32 i32 i32 i32) (result i32)
|
||||
(i32.store (i32.const 2048) (i32.const 1024))
|
||||
(i32.store (i32.const 2052) (i32.const {}))
|
||||
(i32.const 2048))
|
||||
)
|
||||
(core instance $i (instantiate $m))
|
||||
(alias core export $i "memory" (core memory $mem))
|
||||
(alias core export $i "realloc" (core func $realloc))
|
||||
(alias core export $i "call" (core func $call_core))
|
||||
(type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string)))
|
||||
(func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
|
||||
(export "call" (func $call))
|
||||
)"#,
|
||||
wat_bytes(output),
|
||||
output.len()
|
||||
))
|
||||
.expect("valid component wat")
|
||||
}
|
||||
|
||||
fn component_without_call_export() -> Vec<u8> {
|
||||
wat::parse_str(r#"(component (core module $m) (core instance $i (instantiate $m)))"#)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn raw_module_bytes() -> Vec<u8> {
|
||||
wat::parse_str(r#"(module (func (export "call")))"#).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_tool_executes_through_ordinary_tool_result_path() {
|
||||
let (_dir, record) = resolved_record_with_component(component_tool_that_returns(
|
||||
br#"{"summary":"component ok","content":"ordinary tool result path"}"#,
|
||||
));
|
||||
|
||||
let output = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
|
||||
.expect("component tool output");
|
||||
|
||||
assert_eq!(output.summary, "component ok");
|
||||
assert_eq!(output.content.as_deref(), Some("ordinary tool result path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_tool_denies_host_import_without_matching_grant() {
|
||||
let (_dir, record) = resolved_record_with_component(component_tool_importing_https(
|
||||
br#"{"summary":"component ok"}"#,
|
||||
));
|
||||
|
||||
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
|
||||
.expect_err("host import without grant is denied");
|
||||
|
||||
assert!(
|
||||
format!("{error:?}").contains("plugin host API dispatch denied"),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_tool_missing_export_fails_closed() {
|
||||
let (_dir, record) = resolved_record_with_component(component_without_call_export());
|
||||
|
||||
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
|
||||
.expect_err("missing export fails closed");
|
||||
|
||||
assert!(
|
||||
format!("{error:?}").contains("does not export expected"),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_wasm_is_not_silently_reinterpreted_as_component() {
|
||||
let (_dir, record) = resolved_record_with_component(raw_module_bytes());
|
||||
|
||||
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
|
||||
.expect_err("core module is incompatible with component runtime");
|
||||
|
||||
assert!(
|
||||
format!("{error:?}").contains("component is incompatible"),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_wrong_world_fails_closed_during_discovery() {
|
||||
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("component.yoi-plugin");
|
||||
write_component_plugin_package(
|
||||
&package_path,
|
||||
&component_tool_that_returns(br#"{"summary":"component ok"}"#),
|
||||
"example:other/world@1.0.0",
|
||||
);
|
||||
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.resolved.is_empty());
|
||||
assert!(
|
||||
resolved.diagnostics.iter().any(|diagnostic| diagnostic
|
||||
.message
|
||||
.contains("component world is unsupported")),
|
||||
"{:#?}",
|
||||
resolved.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_tool_registration_uses_existing_tool_registry_path() {
|
||||
let (_dir, record) = resolved_record_with_component(component_tool_that_returns(
|
||||
br#"{"summary":"component ok"}"#,
|
||||
));
|
||||
let (report, pending) = install_plugin_record(record);
|
||||
|
||||
assert_eq!(skipped_count(&report), 0, "{report:#?}");
|
||||
assert_eq!(pending.len(), 1);
|
||||
let (meta, _) = pending[0]();
|
||||
assert_eq!(meta.name, "PluginEcho");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_static_inspection_reports_component_runtime_without_execution() {
|
||||
let mut record = record(vec![tool("Echo")]);
|
||||
record.package_path = std::path::PathBuf::from("/no/such/component.wasm");
|
||||
record.manifest.runtime = Some(PluginRuntimeManifest {
|
||||
kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(),
|
||||
entry: None,
|
||||
abi: None,
|
||||
component: Some("plugin.component.wasm".to_string()),
|
||||
world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()),
|
||||
});
|
||||
|
||||
let inspection = inspect_resolved_plugin_static(&record);
|
||||
|
||||
assert!(inspection.runtime.eligible);
|
||||
assert_eq!(
|
||||
inspection.runtime.status,
|
||||
format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}")
|
||||
);
|
||||
assert!(inspection.runtime.diagnostic.is_none());
|
||||
}
|
||||
|
||||
fn write_plugin_package(path: &Path, wasm: &[u8]) {
|
||||
let manifest = br#"schema_version = 1
|
||||
id = "example"
|
||||
@@ -3884,9 +4407,11 @@ input_schema = { type = "object", additionalProperties = true }
|
||||
let mut record = record(vec![tool("Echo")]);
|
||||
record.package_path = std::path::PathBuf::from("/no/such/plugin.wasm");
|
||||
record.manifest.runtime = Some(PluginRuntimeManifest {
|
||||
kind: "wasm".to_string(),
|
||||
entry: "plugin.wasm".to_string(),
|
||||
abi: Some("yoi-plugin-wasm-1".to_string()),
|
||||
kind: PLUGIN_RUNTIME_WASM_KIND.to_string(),
|
||||
entry: Some("plugin.wasm".to_string()),
|
||||
abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()),
|
||||
component: None,
|
||||
world: None,
|
||||
});
|
||||
|
||||
let inspection = inspect_resolved_plugin_static(&record);
|
||||
@@ -3901,9 +4426,11 @@ input_schema = { type = "object", additionalProperties = true }
|
||||
fn static_inspection_reports_missing_tool_grant() {
|
||||
let mut record = record(vec![tool("Echo")]);
|
||||
record.manifest.runtime = Some(PluginRuntimeManifest {
|
||||
kind: "wasm".to_string(),
|
||||
entry: "plugin.wasm".to_string(),
|
||||
abi: Some("yoi-plugin-wasm-1".to_string()),
|
||||
kind: PLUGIN_RUNTIME_WASM_KIND.to_string(),
|
||||
entry: Some("plugin.wasm".to_string()),
|
||||
abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()),
|
||||
component: None,
|
||||
world: None,
|
||||
});
|
||||
record.grants.permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
|
||||
|
||||
@@ -3926,9 +4453,11 @@ input_schema = { type = "object", additionalProperties = true }
|
||||
bad_schema.input_schema = json!({"type":"string"});
|
||||
let mut record = record(vec![bad_schema]);
|
||||
record.manifest.runtime = Some(PluginRuntimeManifest {
|
||||
kind: "wasm".to_string(),
|
||||
entry: "plugin.wasm".to_string(),
|
||||
abi: Some("yoi-plugin-wasm-1".to_string()),
|
||||
kind: PLUGIN_RUNTIME_WASM_KIND.to_string(),
|
||||
entry: Some("plugin.wasm".to_string()),
|
||||
abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()),
|
||||
component: None,
|
||||
world: None,
|
||||
});
|
||||
|
||||
let inspection = inspect_resolved_plugin_static(&record);
|
||||
@@ -3953,9 +4482,11 @@ input_schema = { type = "object", additionalProperties = true }
|
||||
second_duplicate.input_schema = json!({"type":"object"});
|
||||
let mut record = record(vec![invalid, first_duplicate, second_duplicate]);
|
||||
record.manifest.runtime = Some(PluginRuntimeManifest {
|
||||
kind: "wasm".to_string(),
|
||||
entry: "plugin.wasm".to_string(),
|
||||
abi: Some("yoi-plugin-wasm-1".to_string()),
|
||||
kind: PLUGIN_RUNTIME_WASM_KIND.to_string(),
|
||||
entry: Some("plugin.wasm".to_string()),
|
||||
abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()),
|
||||
component: None,
|
||||
world: None,
|
||||
});
|
||||
|
||||
let inspection = inspect_resolved_plugin_static(&record);
|
||||
|
||||
Reference in New Issue
Block a user