yoi/crates/worker/src/feature/plugin.rs

8861 lines
318 KiB
Rust

//! Plugin package contributions for model-visible Tool schemas.
//!
//! This module registers *enabled* plugin package tool surface definitions and
//! executes Tool calls through the sandboxed Component Model `wasm-component`
//! runtime. It deliberately does not grant filesystem, environment, hook,
//! service, ingress, or ambient network authority. Components can only reach
//! host APIs through explicit imports with matching permissions and scoped
//! allowlist grants.
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::io::{Read as _, Write as _};
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::path::{Component, Path, PathBuf};
use std::sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, Ordering},
};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use llm_engine::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
};
use manifest::plugin::{
PLUGIN_COMPONENT_INSTANCE_WORLD, PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND,
PluginConfig, PluginDiscoveryLimits, PluginFsGrant, PluginFsOperation, PluginHostApi,
PluginPermission, PluginRequestGrant, PluginSurface, PluginToolManifest, PluginWebSocketGrant,
ResolvedPluginRecord, read_resolved_plugin_runtime_component,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::runtime::{
Builder as TokioRuntimeBuilder, Handle as TokioHandle, Runtime as TokioRuntime,
};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::protocol::{Message, WebSocketConfig};
const LEGACY_PLUGIN_RUNTIME_WASM_KIND: &str = "wasm";
const PLUGIN_SERVICE_WEBSOCKET_RECV_TIMEOUT: Duration = Duration::from_millis(250);
use super::{
FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule,
FeatureRuntimeKind, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
};
/// Build Feature modules for enabled plugin packages when the profile exposes
/// the plugin Tool surface feature.
pub fn plugin_tool_features_if_enabled(
feature_enabled: bool,
config: &PluginConfig,
) -> Vec<PluginToolFeature> {
if !feature_enabled {
return Vec::new();
}
plugin_tool_features(config)
}
/// Build Feature modules for enabled plugin packages that declare Tool/Service/Ingress surfaces.
pub fn plugin_tool_features(config: &PluginConfig) -> Vec<PluginToolFeature> {
config
.resolved
.iter()
.filter(|record| {
record.enabled_surfaces.contains(&PluginSurface::Tool)
|| record.enabled_surfaces.contains(&PluginSurface::Service)
|| record.enabled_surfaces.contains(&PluginSurface::Ingress)
})
.filter(|record| {
!record.manifest.tools.is_empty()
|| !record.manifest.services.is_empty()
|| !record.manifest.ingresses.is_empty()
})
.cloned()
.map(PluginToolFeature::new)
.collect()
}
#[derive(Clone)]
pub struct PluginToolFeature {
record: ResolvedPluginRecord,
feature_id: FeatureId,
registry: PluginInstanceRegistry,
}
impl PluginToolFeature {
pub fn new(record: ResolvedPluginRecord) -> Self {
let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity))
.expect("source-qualified plugin identity yields non-empty feature id");
Self {
record,
feature_id,
registry: PluginInstanceRegistry::default(),
}
}
fn ensure_instance(&self) -> Result<PluginInstanceHandle, FeatureInstallError> {
self.registry.register(self.record.clone())
}
pub fn instance_status(&self) -> Option<PluginInstanceStatus> {
self.registry.status(&self.record.identity.to_string())
}
pub fn dispatch_ingress(
&self,
ingress_name: &str,
event: PluginIngressEvent,
) -> Result<PluginIngressDispatchReport, PluginIngressDispatchError> {
if !surface_enabled(&self.record, PluginSurface::Ingress) {
return Err(PluginIngressDispatchError::InvalidEvent(
"plugin ingress surface is not enabled".to_string(),
));
}
let handle = self
.registry
.handle(&self.record.identity.to_string())
.ok_or_else(|| PluginIngressDispatchError::ServiceUnavailable {
state: PluginInstanceLifecycleState::Stopped,
})?;
handle.deliver_ingress(ingress_name, event)
}
pub fn origin(&self) -> ToolOrigin {
ToolOrigin {
kind: "plugin".into(),
plugin_id: self.record.manifest.id.clone(),
plugin_ref: self.record.identity.to_string(),
source: self.record.identity.source.to_string(),
digest: self.record.digest.clone(),
package_version: self.record.version.clone(),
package_api_version: self.record.manifest.schema_version,
surface: "tool".into(),
}
}
}
fn surface_enabled(record: &ResolvedPluginRecord, surface: PluginSurface) -> bool {
record.enabled_surfaces.contains(&surface)
}
fn plugin_tool_origin(record: &ResolvedPluginRecord) -> ToolOrigin {
ToolOrigin {
kind: "plugin".into(),
plugin_id: record.manifest.id.clone(),
plugin_ref: record.identity.to_string(),
source: record.identity.source.to_string(),
digest: record.digest.clone(),
package_version: record.version.clone(),
package_api_version: record.manifest.schema_version,
surface: "tool".into(),
}
}
fn plugin_service_id(record: &ResolvedPluginRecord, name: &str) -> ServiceId {
ServiceId::new(format!("plugin:{}:{name}", record.identity.to_string()))
.expect("plugin service id is generated from safe plugin identity/name")
}
/// Static, read-only eligibility information for a resolved plugin package.
///
/// This inspection mirrors the registration-time permission checks without
/// loading the WASM module, calling a plugin Tool, or executing plugin code.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginStaticInspection {
pub runtime: PluginRuntimeEligibility,
pub host_apis: Vec<PluginPermissionEligibility>,
pub tools: Vec<PluginToolEligibility>,
pub services: Vec<PluginSurfaceEligibility>,
pub ingresses: Vec<PluginSurfaceEligibility>,
}
impl PluginStaticInspection {
pub fn statically_eligible(&self) -> bool {
self.runtime.eligible
&& self.host_apis.iter().all(|api| api.eligible)
&& self.tools.iter().all(|tool| tool.eligible)
&& self.services.iter().all(|service| service.eligible)
&& self.ingresses.iter().all(|ingress| ingress.eligible)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginRuntimeEligibility {
pub eligible: bool,
pub status: String,
pub diagnostic: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginPermissionEligibility {
pub permission: String,
pub requested: bool,
pub granted: bool,
pub eligible: bool,
pub diagnostic: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginToolEligibility {
pub name: String,
pub permission: String,
pub requested: bool,
pub granted: bool,
pub eligible: bool,
pub external_write: bool,
pub diagnostic: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginSurfaceEligibility {
pub name: String,
pub permission: String,
pub requested: bool,
pub granted: bool,
pub eligible: bool,
pub diagnostic: Option<String>,
}
/// Inspect static plugin runtime/tool eligibility without executing plugin code.
pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection {
let runtime = match &record.manifest.runtime {
Some(runtime)
if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND
&& matches!(
runtime.world.as_deref(),
Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
)
&& runtime.component.is_some() =>
{
PluginRuntimeEligibility {
eligible: true,
status: format!(
"{}/{}",
PLUGIN_RUNTIME_COMPONENT_KIND,
runtime
.world
.as_deref()
.unwrap_or(PLUGIN_COMPONENT_TOOL_WORLD)
),
diagnostic: None,
}
}
Some(runtime) if runtime.kind == LEGACY_PLUGIN_RUNTIME_WASM_KIND => {
let status = runtime
.abi
.as_deref()
.map(|abi| format!("{LEGACY_PLUGIN_RUNTIME_WASM_KIND}/{abi}"))
.unwrap_or_else(|| format!("{LEGACY_PLUGIN_RUNTIME_WASM_KIND}/<missing-abi>"));
PluginRuntimeEligibility {
eligible: false,
status,
diagnostic: Some(
"legacy raw wasm plugin runtime is not an active execution path; use wasm-component"
.to_string(),
),
}
}
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(),
diagnostic: Some(format!(
"unsupported plugin runtime kind `{}`",
runtime.kind
)),
},
None => PluginRuntimeEligibility {
eligible: false,
status: "none".to_string(),
diagnostic: Some("plugin runtime is not declared".to_string()),
},
};
let mut host_apis: Vec<_> = [
PluginHostApi::Request,
PluginHostApi::WebSocket,
PluginHostApi::Fs,
]
.into_iter()
.filter_map(|api| {
let permission = PluginPermission::host_api(api);
let requested = permission_requested(record, &permission);
let granted = grant_allows(record, &permission);
if !requested && !granted {
return None;
}
let diagnostic = authorize_plugin_host_api(record, api)
.err()
.map(|error| error.bounded_message());
Some(PluginPermissionEligibility {
permission: permission.label(),
requested,
granted,
eligible: diagnostic.is_none(),
diagnostic,
})
})
.collect();
append_request_target_inspection(record, &mut host_apis);
append_websocket_target_inspection(record, &mut host_apis);
let duplicate_tool_names = duplicate_tool_names(record);
let tools = if surface_enabled(record, PluginSurface::Tool) {
record
.manifest
.tools
.iter()
.map(|tool| {
let permission = PluginPermission::tool(&tool.name);
let requested = permission_requested(record, &permission);
let granted = grant_allows(record, &permission);
let mut diagnostics = validate_plugin_tool_definition(tool, &duplicate_tool_names);
if let Err(error) = authorize_plugin_tool(record, tool) {
diagnostics.push(error.bounded_message());
}
let diagnostic = join_tool_diagnostics(diagnostics);
PluginToolEligibility {
name: tool.name.clone(),
permission: permission.label(),
requested,
granted,
eligible: diagnostic.is_none(),
external_write: tool.external_write,
diagnostic,
}
})
.collect()
} else {
Vec::new()
};
let instance_world = record.manifest.runtime.as_ref().is_some_and(|runtime| {
runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND
&& runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
});
let services = if surface_enabled(record, PluginSurface::Service) {
record
.manifest
.services
.iter()
.map(|service| {
let permission = PluginPermission::service(&service.name);
let requested = permission_requested(record, &permission);
let granted = grant_allows(record, &permission);
let mut diagnostics = Vec::new();
if !instance_world {
diagnostics
.push("service requires instance-capable component world".to_string());
}
if let Err(error) = authorize_plugin_service(record, &service.name) {
diagnostics.push(error.bounded_message());
}
let diagnostic = join_tool_diagnostics(diagnostics);
PluginSurfaceEligibility {
name: service.name.clone(),
permission: permission.label(),
requested,
granted,
eligible: diagnostic.is_none(),
diagnostic,
}
})
.collect()
} else {
Vec::new()
};
let ingresses = if surface_enabled(record, PluginSurface::Ingress) {
record
.manifest
.ingresses
.iter()
.map(|ingress| {
let permission = PluginPermission::ingress(&ingress.name);
let requested = permission_requested(record, &permission);
let granted = grant_allows(record, &permission);
let mut diagnostics = Vec::new();
if !instance_world {
diagnostics
.push("ingress requires instance-capable component world".to_string());
}
if let Err(error) = authorize_plugin_ingress(record, &ingress.name) {
diagnostics.push(error.bounded_message());
}
let diagnostic = join_tool_diagnostics(diagnostics);
PluginSurfaceEligibility {
name: ingress.name.clone(),
permission: permission.label(),
requested,
granted,
eligible: diagnostic.is_none(),
diagnostic,
}
})
.collect()
} else {
Vec::new()
};
PluginStaticInspection {
runtime,
host_apis,
tools,
services,
ingresses,
}
}
fn permission_requested(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool {
record
.manifest
.permissions
.iter()
.any(|requested| requested == permission)
}
fn append_request_target_inspection(
record: &ResolvedPluginRecord,
host_apis: &mut Vec<PluginPermissionEligibility>,
) {
for target in &record.manifest.request {
let covering_grant = record
.grants
.request
.iter()
.find(|grant| request_target_covers(grant, target));
let intersecting_grant = covering_grant.or_else(|| {
record
.grants
.request
.iter()
.find(|grant| request_targets_intersect(target, grant))
});
let granted = intersecting_grant.is_some();
let diagnostic = match (granted, covering_grant, target.is_broad()) {
(false, _, broad) => Some(format!(
"missing enabled request grant for manifest target{}",
if broad { "; broad/arbitrary target" } else { "" }
)),
(true, None, true) => Some(
"partially covered by enabled request grant; broad manifest target is constrained by narrower grants"
.to_string(),
),
(true, None, false) => Some(
"partially covered by enabled request grant; only intersecting URLs are allowed"
.to_string(),
),
(true, Some(grant), _) if grant.is_broad() => {
Some("covered by broad/arbitrary enabled request grant".to_string())
}
_ => None,
};
host_apis.push(PluginPermissionEligibility {
permission: format!("host_api.request target {}", target.label()),
requested: true,
granted,
eligible: granted,
diagnostic,
});
}
for grant in &record.grants.request {
let matching_manifest = record
.manifest
.request
.iter()
.find(|target| request_targets_intersect(target, grant));
if let Some(target) = matching_manifest {
let diagnostic = if grant.is_broad() {
Some(
"broad/arbitrary enabled request grant is constrained by manifest declarations"
.to_string(),
)
} else if !request_target_covers(target, grant) {
Some(
"enabled request grant is only usable where it intersects manifest declarations"
.to_string(),
)
} else {
None
};
host_apis.push(PluginPermissionEligibility {
permission: format!("host_api.request grant {}", grant.label()),
requested: true,
granted: true,
eligible: true,
diagnostic,
});
} else {
let broad = if grant.is_broad() {
"; broad/arbitrary target"
} else {
""
};
host_apis.push(PluginPermissionEligibility {
permission: format!("host_api.request grant-only {}", grant.label()),
requested: false,
granted: true,
eligible: false,
diagnostic: Some(format!(
"enabled request grant has no matching manifest declaration{broad}"
)),
});
}
}
}
fn append_websocket_target_inspection(
record: &ResolvedPluginRecord,
host_apis: &mut Vec<PluginPermissionEligibility>,
) {
for target in &record.manifest.websocket {
let covering_grant = record
.grants
.websocket
.iter()
.find(|grant| websocket_target_covers(grant, target));
let intersecting_grant = covering_grant.or_else(|| {
record
.grants
.websocket
.iter()
.find(|grant| websocket_targets_intersect(target, grant))
});
let granted = intersecting_grant.is_some();
let diagnostic = match (granted, covering_grant, target.is_broad()) {
(false, _, broad) => Some(format!(
"missing enabled WebSocket grant for manifest target{}",
if broad { "; broad/arbitrary target" } else { "" }
)),
(true, None, true) => Some(
"partially covered by enabled WebSocket grant; broad manifest target is constrained by narrower grants"
.to_string(),
),
(true, None, false) => Some(
"partially covered by enabled WebSocket grant; only intersecting URLs are allowed"
.to_string(),
),
(true, Some(grant), _) if grant.is_broad() => {
Some("covered by broad/arbitrary enabled WebSocket grant".to_string())
}
_ => None,
};
host_apis.push(PluginPermissionEligibility {
permission: format!("host_api.websocket target {}", target.label()),
requested: true,
granted,
eligible: granted,
diagnostic,
});
}
for grant in &record.grants.websocket {
let matching_manifest = record
.manifest
.websocket
.iter()
.find(|target| websocket_targets_intersect(target, grant));
if let Some(target) = matching_manifest {
let diagnostic = if grant.is_broad() {
Some(
"broad/arbitrary enabled WebSocket grant is constrained by manifest declarations"
.to_string(),
)
} else if !websocket_target_covers(target, grant) {
Some(
"enabled WebSocket grant is only usable where it intersects manifest declarations"
.to_string(),
)
} else {
None
};
host_apis.push(PluginPermissionEligibility {
permission: format!("host_api.websocket grant {}", grant.label()),
requested: true,
granted: true,
eligible: true,
diagnostic,
});
} else {
let broad = if grant.is_broad() {
"; broad/arbitrary target"
} else {
""
};
host_apis.push(PluginPermissionEligibility {
permission: format!("host_api.websocket grant-only {}", grant.label()),
requested: false,
granted: true,
eligible: false,
diagnostic: Some(format!(
"enabled WebSocket grant has no matching manifest declaration{broad}"
)),
});
}
}
}
fn grant_allows(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool {
record
.grants
.permissions
.iter()
.any(|granted| granted == permission)
}
fn duplicate_tool_names(record: &ResolvedPluginRecord) -> HashSet<String> {
let mut seen = HashSet::new();
let mut duplicates = HashSet::new();
for tool in &record.manifest.tools {
if !seen.insert(tool.name.clone()) {
duplicates.insert(tool.name.clone());
}
}
duplicates
}
fn validate_plugin_tool_definition(
tool: &PluginToolManifest,
duplicate_tool_names: &HashSet<String>,
) -> Vec<String> {
let mut diagnostics = Vec::new();
if duplicate_tool_names.contains(&tool.name) {
diagnostics.push(format!(
"tool `{}` has duplicate name within plugin manifest",
tool.name
));
}
if let Err(reason) = validate_tool_name(&tool.name) {
diagnostics.push(format!("tool `{}` has invalid name: {reason}", tool.name));
}
if let Err(reason) = validate_input_schema(&tool.input_schema) {
diagnostics.push(format!(
"tool `{}` has invalid input_schema: {reason}",
tool.name
));
}
diagnostics
}
fn join_tool_diagnostics(diagnostics: Vec<String>) -> Option<String> {
if diagnostics.is_empty() {
None
} else {
Some(bounded_message(diagnostics.join("; ")))
}
}
impl FeatureModule for PluginToolFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor =
FeatureDescriptor {
id: self.feature_id.clone(),
runtime: FeatureRuntimeKind::ExternalPlugin,
display_name: self.record.manifest.name.clone(),
version: self.record.manifest.version.clone(),
description: self.record.manifest.description.clone().unwrap_or_else(|| {
format!("Plugin tool surface from {}", self.record.identity)
}),
tools: Vec::new(),
hooks: Vec::new(),
background_tasks: Vec::new(),
provides_services: Vec::new(),
requires_services: Vec::new(),
protocol_providers: Vec::new(),
};
if surface_enabled(&self.record, PluginSurface::Service) {
for service in &self.record.manifest.services {
descriptor.provides_services.push(ServiceDeclaration::new(
plugin_service_id(&self.record, &service.name),
self.record.manifest.version.clone(),
service.description.clone(),
));
}
}
if surface_enabled(&self.record, PluginSurface::Tool) {
for tool in &self.record.manifest.tools {
descriptor = descriptor.with_tool(ToolDeclaration::new(
tool.name.clone(),
tool.description.clone(),
));
}
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if surface_enabled(&self.record, PluginSurface::Tool) {
validate_declared_tool_names(&self.record)?;
}
let mut instance: Option<PluginInstanceHandle> = None;
let mut exposed = 0usize;
let mut denied = Vec::new();
if surface_enabled(&self.record, PluginSurface::Service) {
for service in &self.record.manifest.services {
validate_tool_name(&service.name).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin {} service {} has invalid name: {reason}",
self.record.identity, service.name
))
})?;
if let Err(error) = authorize_plugin_service(&self.record, &service.name) {
let message = format!(
"plugin {} service {} registration denied: {}",
self.record.identity,
service.name,
error.bounded_message()
);
context.diagnostics().warning(message.clone());
denied.push(message);
continue;
}
if instance.is_none() {
instance = Some(self.ensure_instance()?);
}
context.services().provide(ServiceDeclaration::new(
plugin_service_id(&self.record, &service.name),
self.record.manifest.version.clone(),
service.description.clone(),
))?;
exposed += 1;
}
}
if surface_enabled(&self.record, PluginSurface::Ingress) {
for ingress in &self.record.manifest.ingresses {
validate_tool_name(&ingress.name).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin {} ingress {} has invalid name: {reason}",
self.record.identity, ingress.name
))
})?;
if let Err(error) = authorize_plugin_ingress(&self.record, &ingress.name) {
let message = format!(
"plugin {} ingress {} registration denied: {}",
self.record.identity,
ingress.name,
error.bounded_message()
);
context.diagnostics().warning(message.clone());
denied.push(message);
} else {
if instance.is_none() {
instance = Some(self.ensure_instance()?);
}
exposed += 1;
}
}
}
if surface_enabled(&self.record, PluginSurface::Tool) {
for tool in &self.record.manifest.tools {
validate_tool_name(&tool.name).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin {} tool {} has invalid name: {reason}",
self.record.identity, tool.name
))
})?;
validate_input_schema(&tool.input_schema).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin {} tool {} has invalid input_schema: {reason}",
self.record.identity, tool.name
))
})?;
if let Err(error) = authorize_plugin_tool(&self.record, tool) {
let message = format!(
"plugin {} tool {} registration denied: {}",
self.record.identity,
tool.name,
error.bounded_message()
);
context.diagnostics().warning(message.clone());
denied.push(message);
continue;
}
let tool_instance = match &instance {
Some(instance) => instance.clone(),
None => {
let created = self.ensure_instance()?;
instance = Some(created.clone());
created
}
};
context.tools().register(ToolContribution::new(
tool.name.clone(),
plugin_instance_tool_definition(
tool_instance,
tool.name.clone(),
tool.description.clone(),
tool.input_schema.clone(),
),
))?;
exposed += 1;
}
}
if exposed == 0 && !denied.is_empty() {
let summary = if denied.len() == 1 {
denied.remove(0)
} else {
format!(
"{} plugin tool registrations denied; first denial: {}",
denied.len(),
denied[0]
)
};
return Err(FeatureInstallError::Install(bounded_message(summary)));
}
Ok(())
}
}
impl PluginRequestClient for ReqwestPluginRequestClient {
fn execute(
&self,
request: &PluginRequestRequest,
url: &reqwest::Url,
limits: PluginRequestLimits,
) -> Result<PluginRequestResponse, PluginRequestError> {
let pinned_resolution =
resolve_request_target_for_client(url, &SystemPluginRequestResolver)?;
let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| {
PluginRequestError::new(format!("unsupported request method `{}`", request.method))
})?;
let mut client_builder = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(limits.timeout)
.no_proxy()
.user_agent("yoi-plugin-request-host-api/0.1");
if let Some(pinned_resolution) = &pinned_resolution {
for domain in &pinned_resolution.domains {
client_builder = client_builder.resolve_to_addrs(domain, &pinned_resolution.addrs);
}
}
let client = client_builder.build().map_err(|error| {
PluginRequestError::new(format!("request client build failed: {error}"))
})?;
let mut builder = client.request(method, url.clone()).timeout(limits.timeout);
for header in &request.headers {
let name =
reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| {
PluginRequestError::new(format!(
"invalid request header name `{}`",
header.name
))
})?;
let value = reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| {
PluginRequestError::new(format!(
"invalid request header value for `{}`",
header.name
))
})?;
builder = builder.header(name, value);
}
if let Some(body) = &request.body {
builder = builder.body(body.clone());
}
let mut response = builder.send().map_err(|error| {
if error.is_timeout() {
PluginRequestError::new(format!("request to {} timed out", safe_url(url)))
} else {
PluginRequestError::new(format!("request to {} failed: {error}", safe_url(url)))
}
})?;
let status = response.status().as_u16();
let headers = collect_request_response_headers(response.headers());
let mut body = Vec::new();
let read_limit = limits.max_response_bytes.saturating_add(1) as u64;
response
.by_ref()
.take(read_limit)
.read_to_end(&mut body)
.map_err(|error| {
PluginRequestError::new(format!("request response read failed: {error}"))
})?;
let truncated = body.len() > limits.max_response_bytes;
if truncated {
body.truncate(limits.max_response_bytes);
}
Ok(PluginRequestResponse {
status,
headers,
body: String::from_utf8_lossy(&body).into_owned(),
truncated,
})
}
}
fn execute_plugin_request_request(
record: &ResolvedPluginRecord,
client: &dyn PluginRequestClient,
request_bytes: &[u8],
) -> Result<Vec<u8>, PluginRequestError> {
if request_bytes.len() > PLUGIN_REQUEST_MAX_REQUEST_BYTES {
return Err(PluginRequestError::new(format!(
"request descriptor exceeds {} bytes",
PLUGIN_REQUEST_MAX_REQUEST_BYTES
)));
}
authorize_plugin_host_api(record, PluginHostApi::Request).map_err(|error| {
PluginRequestError::new(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
let request: PluginRequestRequest = serde_json::from_slice(request_bytes)
.map_err(|error| PluginRequestError::new(format!("invalid request JSON: {error}")))?;
let url = validate_plugin_request_request(record, &request)?;
let mut response = client.execute(&request, &url, PluginRequestLimits::default())?;
enforce_request_response_bounds(&mut response, PluginRequestLimits::default());
serde_json::to_vec(&response).map_err(|error| {
PluginRequestError::new(format!("failed to encode request response: {error}"))
})
}
fn execute_plugin_websocket_open(
record: &ResolvedPluginRecord,
client: &dyn PluginWebSocketClient,
handles: &PluginWebSocketHandles,
bytes: &[u8],
) -> Result<Vec<u8>, PluginWebSocketError> {
let (request, url) = validate_plugin_websocket_open_request(record, bytes)?;
let limits = PluginWebSocketLimits::default();
if !client.supports_bounded_open() {
return Err(PluginWebSocketError::new(
"host_api.websocket client cannot guarantee bounded/cancellable open; refusing to dial",
));
}
let reservation = handles.reserve_open()?;
let connection = client.open(&request, &url, limits)?;
let handle = reservation.commit(connection)?;
serde_json::to_vec(&PluginWebSocketOpenResponse {
handle,
url: safe_url(&url),
})
.map_err(|error| PluginWebSocketError::new(error.to_string()))
}
fn execute_plugin_websocket_send_text(
handles: &PluginWebSocketHandles,
handle: u32,
bytes: &[u8],
) -> Result<Vec<u8>, PluginWebSocketError> {
if bytes.len() > PLUGIN_WEBSOCKET_MAX_TEXT_BYTES {
return Err(PluginWebSocketError::new(format!(
"WebSocket text frame exceeds {} bytes",
PLUGIN_WEBSOCKET_MAX_TEXT_BYTES
)));
}
let text = std::str::from_utf8(bytes)
.map_err(|_| PluginWebSocketError::new("WebSocket send_text requires UTF-8 text"))?;
handles.with_connection(handle, |connection| connection.send_text(text))?;
serde_json::to_vec(&PluginWebSocketSendResponse {
sent: true,
bytes: bytes.len(),
})
.map_err(|error| PluginWebSocketError::new(error.to_string()))
}
fn execute_plugin_websocket_recv(
handles: &PluginWebSocketHandles,
handle: u32,
timeout_ms: u32,
) -> Result<Vec<u8>, PluginWebSocketError> {
let timeout = websocket_timeout(timeout_ms);
let response = handles.with_connection(handle, |connection| {
connection.recv_text(timeout, PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES)
})?;
serde_json::to_vec(&response).map_err(|error| PluginWebSocketError::new(error.to_string()))
}
fn execute_plugin_websocket_close(
handles: &PluginWebSocketHandles,
handle: u32,
) -> Result<Vec<u8>, PluginWebSocketError> {
let closed = handles.close(handle)?;
serde_json::to_vec(&PluginWebSocketCloseResponse { closed })
.map_err(|error| PluginWebSocketError::new(error.to_string()))
}
fn execute_plugin_fs_request(
record: &ResolvedPluginRecord,
operation: PluginFsRuntimeOperation,
request_bytes: &[u8],
) -> Result<Vec<u8>, PluginFsError> {
if request_bytes.len() > PLUGIN_FS_MAX_REQUEST_BYTES {
return Err(PluginFsError::new(format!(
"FS request descriptor exceeds {} bytes",
PLUGIN_FS_MAX_REQUEST_BYTES
)));
}
authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| {
PluginFsError::new(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
match operation {
PluginFsRuntimeOperation::Read => {
let request: PluginFsPathRequest =
serde_json::from_slice(request_bytes).map_err(|error| {
PluginFsError::new(format!("invalid FS read request JSON: {error}"))
})?;
execute_plugin_fs_read(record, &request.path)
}
PluginFsRuntimeOperation::List => {
let request: PluginFsPathRequest =
serde_json::from_slice(request_bytes).map_err(|error| {
PluginFsError::new(format!("invalid FS list request JSON: {error}"))
})?;
execute_plugin_fs_list(record, &request.path)
}
PluginFsRuntimeOperation::Write => {
let request: PluginFsWriteRequest =
serde_json::from_slice(request_bytes).map_err(|error| {
PluginFsError::new(format!("invalid FS write request JSON: {error}"))
})?;
execute_plugin_fs_write(record, &request.path, request.content.as_bytes())
}
}
}
fn execute_plugin_fs_read(
record: &ResolvedPluginRecord,
path: &str,
) -> Result<Vec<u8>, PluginFsError> {
let target = authorize_fs_path(record, PluginFsRuntimeOperation::Read, path)?;
let meta = fs::metadata(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS read metadata failed for {}: {error}",
safe_fs_path(path)
))
})?;
if !meta.is_file() {
return Err(PluginFsError::new(format!(
"FS read target is not a regular file: {}",
safe_fs_path(path)
)));
}
let mut file = fs::File::open(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS read failed for {}: {error}",
safe_fs_path(path)
))
})?;
let mut bytes = Vec::new();
std::io::Read::by_ref(&mut file)
.take((PLUGIN_FS_MAX_READ_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| {
PluginFsError::new(format!(
"FS read failed for {}: {error}",
safe_fs_path(path)
))
})?;
let truncated = bytes.len() > PLUGIN_FS_MAX_READ_BYTES;
if truncated {
bytes.truncate(PLUGIN_FS_MAX_READ_BYTES);
}
let response = PluginFsReadResponse {
path: safe_fs_path(path),
content: String::from_utf8_lossy(&bytes).into_owned(),
truncated,
};
serde_json::to_vec(&response)
.map_err(|error| PluginFsError::new(format!("failed to encode FS read response: {error}")))
}
fn execute_plugin_fs_list(
record: &ResolvedPluginRecord,
path: &str,
) -> Result<Vec<u8>, PluginFsError> {
let target = authorize_fs_path(record, PluginFsRuntimeOperation::List, path)?;
let meta = fs::metadata(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS list metadata failed for {}: {error}",
safe_fs_path(path)
))
})?;
if !meta.is_dir() {
return Err(PluginFsError::new(format!(
"FS list target is not a directory: {}",
safe_fs_path(path)
)));
}
let mut entries = Vec::new();
let mut truncated = false;
for entry in fs::read_dir(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS list failed for {}: {error}",
safe_fs_path(path)
))
})? {
let entry = entry.map_err(|error| {
PluginFsError::new(format!(
"FS list failed for {}: {error}",
safe_fs_path(path)
))
})?;
if entries.len() >= PLUGIN_FS_MAX_LIST_ENTRIES {
truncated = true;
break;
}
let name = entry.file_name().to_string_lossy().into_owned();
let file_type = entry.file_type().map_err(|error| {
PluginFsError::new(format!(
"FS list failed for {}: {error}",
safe_fs_path(path)
))
})?;
let kind = if file_type.is_dir() {
"dir"
} else if file_type.is_file() {
"file"
} else if file_type.is_symlink() {
"symlink"
} else {
"other"
};
entries.push(PluginFsDirEntry {
name,
kind: kind.to_string(),
});
}
entries.sort_by(|left, right| left.name.cmp(&right.name));
let response = PluginFsListResponse {
path: safe_fs_path(path),
entries,
truncated,
};
serde_json::to_vec(&response)
.map_err(|error| PluginFsError::new(format!("failed to encode FS list response: {error}")))
}
fn execute_plugin_fs_write(
record: &ResolvedPluginRecord,
path: &str,
content: &[u8],
) -> Result<Vec<u8>, PluginFsError> {
if content.len() > PLUGIN_FS_MAX_WRITE_BYTES {
return Err(PluginFsError::new(format!(
"FS write content exceeds {} bytes",
PLUGIN_FS_MAX_WRITE_BYTES
)));
}
let target = authorize_fs_write_path(record, path)?;
let lock = plugin_fs_write_lock(target.lock_key.clone());
let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut file = options.open(&target.resolved).map_err(|error| {
PluginFsError::new(format!(
"FS write failed for {}: {error}",
safe_fs_path(path)
))
})?;
file.write_all(content).map_err(|error| {
PluginFsError::new(format!(
"FS write failed for {}: {error}",
safe_fs_path(path)
))
})?;
file.sync_all().map_err(|error| {
PluginFsError::new(format!(
"FS write failed for {}: {error}",
safe_fs_path(path)
))
})?;
let response = PluginFsWriteResponse {
path: safe_fs_path(path),
bytes_written: content.len(),
};
serde_json::to_vec(&response)
.map_err(|error| PluginFsError::new(format!("failed to encode FS write response: {error}")))
}
fn enforce_request_response_bounds(
response: &mut PluginRequestResponse,
limits: PluginRequestLimits,
) {
if response.body.len() > limits.max_response_bytes {
truncate_string_to_boundary(&mut response.body, limits.max_response_bytes);
response.truncated = true;
}
if response.headers.len() > PLUGIN_REQUEST_MAX_RESPONSE_HEADERS {
response
.headers
.truncate(PLUGIN_REQUEST_MAX_RESPONSE_HEADERS);
}
for header in &mut response.headers {
header.value = bounded_header_value(&header.value);
}
}
fn truncate_string_to_boundary(value: &mut String, max_len: usize) {
if value.len() <= max_len {
return;
}
let mut boundary = max_len;
while boundary > 0 && !value.is_char_boundary(boundary) {
boundary -= 1;
}
value.truncate(boundary);
}
#[derive(Clone, Debug)]
struct PluginFsResolvedPath {
resolved: PathBuf,
}
#[derive(Clone, Debug)]
struct PluginFsWritePath {
resolved: PathBuf,
lock_key: PathBuf,
}
fn authorize_fs_path(
record: &ResolvedPluginRecord,
operation: PluginFsRuntimeOperation,
path: &str,
) -> Result<PluginFsResolvedPath, PluginFsError> {
let relative = parse_plugin_fs_relative_path(path)?;
let mut saw_operation_grant = false;
let mut last_error: Option<PluginFsError> = None;
for grant in &record.grants.fs {
if !grant_allows_operation(grant, operation) {
continue;
}
saw_operation_grant = true;
match resolve_plugin_fs_grant_root(grant)
.and_then(|root| resolve_existing_plugin_fs_path(&root, &relative, safe_fs_path(path)))
{
Ok(resolved) => return Ok(PluginFsResolvedPath { resolved }),
Err(error) => last_error = Some(error),
}
}
if !saw_operation_grant {
return Err(PluginFsError::new(format!(
"host_api.fs {} denied: no matching operation grant",
operation.as_str()
)));
}
Err(last_error.unwrap_or_else(|| {
PluginFsError::new(format!(
"host_api.fs {} denied by scoped path policy",
operation.as_str()
))
}))
}
fn authorize_fs_write_path(
record: &ResolvedPluginRecord,
path: &str,
) -> Result<PluginFsWritePath, PluginFsError> {
let relative = parse_plugin_fs_relative_path(path)?;
if relative.as_os_str().is_empty() || relative.file_name().is_none() {
return Err(PluginFsError::new("FS write path must name a file"));
}
let mut saw_operation_grant = false;
let mut last_error: Option<PluginFsError> = None;
for grant in &record.grants.fs {
if !grant_allows_operation(grant, PluginFsRuntimeOperation::Write) {
continue;
}
saw_operation_grant = true;
match resolve_plugin_fs_grant_root(grant)
.and_then(|root| resolve_writable_plugin_fs_path(&root, &relative, safe_fs_path(path)))
{
Ok(resolved) => return Ok(resolved),
Err(error) => last_error = Some(error),
}
}
if !saw_operation_grant {
return Err(PluginFsError::new(
"host_api.fs write denied: no matching operation grant",
));
}
Err(last_error
.unwrap_or_else(|| PluginFsError::new("host_api.fs write denied by scoped path policy")))
}
fn grant_allows_operation(grant: &PluginFsGrant, operation: PluginFsRuntimeOperation) -> bool {
let requested = operation.grant_operation();
grant.operations.iter().any(|allowed| *allowed == requested)
}
fn parse_plugin_fs_relative_path(path: &str) -> Result<PathBuf, PluginFsError> {
if path.as_bytes().len() > PLUGIN_FS_MAX_PATH_BYTES {
return Err(PluginFsError::new(format!(
"FS path exceeds {} bytes",
PLUGIN_FS_MAX_PATH_BYTES
)));
}
if path.is_empty() {
return Err(PluginFsError::new("FS path must not be empty"));
}
if path.contains('\0') {
return Err(PluginFsError::new("FS path contains a NUL byte"));
}
let input = Path::new(path);
if input.is_absolute() {
return Err(PluginFsError::new(
"FS request paths must be relative to a configured fs grant root",
));
}
let mut relative = PathBuf::new();
for component in input.components() {
match component {
Component::Normal(component) => relative.push(component),
Component::CurDir => {}
Component::ParentDir => {
return Err(PluginFsError::new(
"FS path traversal outside the grant root is denied",
));
}
Component::RootDir | Component::Prefix(_) => {
return Err(PluginFsError::new(
"FS request paths must be relative to a configured fs grant root",
));
}
}
}
Ok(relative)
}
fn resolve_plugin_fs_grant_root(grant: &PluginFsGrant) -> Result<PathBuf, PluginFsError> {
let root = grant.root.trim();
if root.is_empty() || root.as_bytes().len() > PLUGIN_FS_MAX_PATH_BYTES {
return Err(PluginFsError::new("configured fs grant root is invalid"));
}
let root_path = Path::new(root);
if !root_path.is_absolute() {
return Err(PluginFsError::new(
"configured fs grant root must be an absolute path",
));
}
let root_meta = fs::symlink_metadata(root_path)
.map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?;
if root_meta.file_type().is_symlink() {
return Err(PluginFsError::new(
"configured fs grant root must not be a symlink",
));
}
let canonical = fs::canonicalize(root_path)
.map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?;
let metadata = fs::metadata(&canonical)
.map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?;
if !metadata.is_dir() {
return Err(PluginFsError::new(
"configured fs grant root must be a directory",
));
}
Ok(canonical)
}
fn resolve_existing_plugin_fs_path(
root: &Path,
relative: &Path,
requested: String,
) -> Result<PathBuf, PluginFsError> {
let target = root.join(relative);
reject_symlink_components(root, relative, true, &requested)?;
let canonical = fs::canonicalize(&target).map_err(|error| {
PluginFsError::new(format!("FS path is unavailable for {requested}: {error}"))
})?;
if !canonical.starts_with(root) {
return Err(PluginFsError::new(format!(
"FS path escapes the configured grant root: {requested}"
)));
}
Ok(canonical)
}
fn resolve_writable_plugin_fs_path(
root: &Path,
relative: &Path,
requested: String,
) -> Result<PluginFsWritePath, PluginFsError> {
let Some(file_name) = relative.file_name() else {
return Err(PluginFsError::new("FS write path must name a file"));
};
let parent_relative = relative.parent().unwrap_or_else(|| Path::new(""));
reject_symlink_components(root, parent_relative, true, &requested)?;
let parent = fs::canonicalize(root.join(parent_relative)).map_err(|error| {
PluginFsError::new(format!(
"FS write parent is unavailable for {requested}: {error}"
))
})?;
if !parent.starts_with(root) {
return Err(PluginFsError::new(format!(
"FS write parent escapes the configured grant root: {requested}"
)));
}
let resolved = parent.join(file_name);
if let Ok(meta) = fs::symlink_metadata(&resolved) {
if meta.file_type().is_symlink() {
return Err(PluginFsError::new(format!(
"FS write target is a symlink: {requested}"
)));
}
if meta.is_dir() {
return Err(PluginFsError::new(format!(
"FS write target is a directory: {requested}"
)));
}
let canonical = fs::canonicalize(&resolved).map_err(|error| {
PluginFsError::new(format!(
"FS write target is unavailable for {requested}: {error}"
))
})?;
if !canonical.starts_with(root) {
return Err(PluginFsError::new(format!(
"FS write target escapes the configured grant root: {requested}"
)));
}
return Ok(PluginFsWritePath {
resolved,
lock_key: canonical,
});
}
if !resolved.starts_with(root) {
return Err(PluginFsError::new(format!(
"FS write target escapes the configured grant root: {requested}"
)));
}
Ok(PluginFsWritePath {
lock_key: resolved.clone(),
resolved,
})
}
fn reject_symlink_components(
root: &Path,
relative: &Path,
include_leaf: bool,
requested: &str,
) -> Result<(), PluginFsError> {
let mut current = root.to_path_buf();
let components = relative.components().collect::<Vec<_>>();
for (index, component) in components.iter().enumerate() {
let Component::Normal(name) = component else {
continue;
};
current.push(name);
if !include_leaf && index + 1 == components.len() {
break;
}
let metadata = fs::symlink_metadata(&current).map_err(|error| {
PluginFsError::new(format!(
"FS path component is unavailable for {requested}: {error}"
))
})?;
if metadata.file_type().is_symlink() {
return Err(PluginFsError::new(format!(
"FS path symlink escape is denied: {requested}"
)));
}
}
Ok(())
}
fn plugin_fs_write_lock(path: PathBuf) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
let map = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut map = map.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
map.entry(path)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
fn safe_fs_path(path: &str) -> String {
let mut sanitized = redact_secret_like(path).replace('\0', " ");
if sanitized.len() > 160 {
let mut boundary = 160;
while boundary > 0 && !sanitized.is_char_boundary(boundary) {
boundary -= 1;
}
sanitized.truncate(boundary);
sanitized.push('…');
}
if sanitized.is_empty() {
".".to_string()
} else {
sanitized
}
}
fn validate_plugin_request_request(
record: &ResolvedPluginRecord,
request: &PluginRequestRequest,
) -> Result<reqwest::Url, PluginRequestError> {
let method = request.method.trim().to_ascii_uppercase();
if method != request.method || !PLUGIN_REQUEST_ALLOWED_METHODS.contains(&method.as_str()) {
return Err(PluginRequestError::new(format!(
"request method `{}` is not allowed",
request.method
)));
}
if request.headers.len() > PLUGIN_REQUEST_MAX_REQUEST_HEADERS {
return Err(PluginRequestError::new(format!(
"request descriptor has too many headers (max {})",
PLUGIN_REQUEST_MAX_REQUEST_HEADERS
)));
}
for header in &request.headers {
validate_request_header(header)?;
}
if let Some(body) = &request.body {
if body.len() > PLUGIN_REQUEST_MAX_REQUEST_BODY_BYTES {
return Err(PluginRequestError::new(format!(
"request body exceeds {} bytes",
PLUGIN_REQUEST_MAX_REQUEST_BODY_BYTES
)));
}
}
let url = reqwest::Url::parse(&request.url)
.map_err(|error| PluginRequestError::new(format!("invalid request URL: {error}")))?;
match url.scheme() {
"http" | "https" => {}
"ws" | "wss" => {
return Err(PluginRequestError::new(
"WebSocket URLs are not supported by host_api.request",
));
}
scheme => {
return Err(PluginRequestError::new(format!(
"unsupported request URL scheme {scheme:?}; only http and https are allowed"
)));
}
}
if url.host_str().is_none() {
return Err(PluginRequestError::new("request URL must include a host"));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(PluginRequestError::new(
"request URLs with embedded credentials are not allowed",
));
}
validate_static_request_target(&url)?;
authorize_request_allowlist(record, &method, &url)?;
Ok(url)
}
fn validate_request_header(header: &PluginRequestHeader) -> Result<(), PluginRequestError> {
if header.name.is_empty() || header.name.len() > PLUGIN_REQUEST_MAX_HEADER_NAME_BYTES {
return Err(PluginRequestError::new("request header name is invalid"));
}
if header.value.len() > PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES {
return Err(PluginRequestError::new(format!(
"request header `{}` exceeds {} bytes",
header.name, PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES
)));
}
if is_sensitive_header(&header.name) {
return Err(PluginRequestError::new(format!(
"request header `{}` is credential-like and must be supplied by an explicit future secret-ref grant, not guest memory",
header.name
)));
}
if header.name.eq_ignore_ascii_case("upgrade")
|| (header.name.eq_ignore_ascii_case("connection")
&& header
.value
.split(',')
.any(|value| value.trim().eq_ignore_ascii_case("upgrade")))
{
return Err(PluginRequestError::new(
"persistent connection upgrade headers are not supported by host_api.request",
));
}
if header.name.eq_ignore_ascii_case("accept")
&& header
.value
.split(',')
.any(|value| value.trim().eq_ignore_ascii_case("text/event-stream"))
{
return Err(PluginRequestError::new(
"SSE/event-stream requests are not supported by host_api.request",
));
}
reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| {
PluginRequestError::new(format!("invalid request header name `{}`", header.name))
})?;
reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| {
PluginRequestError::new(format!(
"invalid request header value for `{}`",
header.name
))
})?;
Ok(())
}
fn authorize_request_allowlist(
record: &ResolvedPluginRecord,
method: &str,
url: &reqwest::Url,
) -> Result<(), PluginRequestError> {
if !request_targets_allow(&record.manifest.request, method, url) {
return Err(PluginRequestError::new(format!(
"host_api.request target {} {} is not declared by the plugin manifest",
method,
safe_url(url)
)));
}
if !request_targets_allow(&record.grants.request, method, url) {
return Err(PluginRequestError::new(format!(
"host_api.request target {} {} is not covered by enabled request grants",
method,
safe_url(url)
)));
}
Ok(())
}
fn validate_plugin_websocket_open_request(
record: &ResolvedPluginRecord,
bytes: &[u8],
) -> Result<(PluginWebSocketOpenRequest, reqwest::Url), PluginWebSocketError> {
if bytes.len() > PLUGIN_WEBSOCKET_MAX_OPEN_REQUEST_BYTES {
return Err(PluginWebSocketError::new(format!(
"WebSocket open descriptor exceeds {} bytes",
PLUGIN_WEBSOCKET_MAX_OPEN_REQUEST_BYTES
)));
}
let request: PluginWebSocketOpenRequest = serde_json::from_slice(bytes).map_err(|error| {
PluginWebSocketError::new(format!("invalid WebSocket open request JSON: {error}"))
})?;
if !request.protocols.is_empty() {
return Err(PluginWebSocketError::new(
"WebSocket subprotocol negotiation is not supported by host_api.websocket v1",
));
}
if !request.headers.is_empty() {
return Err(PluginWebSocketError::new(
"WebSocket handshake headers from guest memory are not supported; future secret-ref grants must inject credential-bearing headers explicitly",
));
}
let url = reqwest::Url::parse(&request.url)
.map_err(|error| PluginWebSocketError::new(format!("invalid WebSocket URL: {error}")))?;
match url.scheme() {
"ws" | "wss" => {}
"http" | "https" => {
return Err(PluginWebSocketError::new(
"HTTP URLs are not supported by host_api.websocket",
));
}
scheme => {
return Err(PluginWebSocketError::new(format!(
"unsupported WebSocket URL scheme {scheme:?}; only ws and wss are allowed"
)));
}
}
if url.host_str().is_none() {
return Err(PluginWebSocketError::new(
"WebSocket URL must include a host",
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(PluginWebSocketError::new(
"WebSocket URLs with embedded credentials are not allowed",
));
}
validate_static_request_target(&url).map_err(|error| PluginWebSocketError::new(error.0))?;
authorize_websocket_allowlist(record, &url)?;
Ok((request, url))
}
fn authorize_websocket_allowlist(
record: &ResolvedPluginRecord,
url: &reqwest::Url,
) -> Result<(), PluginWebSocketError> {
if !websocket_targets_allow(&record.manifest.websocket, url) {
return Err(PluginWebSocketError::new(format!(
"host_api.websocket target {} is not declared by the plugin manifest",
safe_url(url)
)));
}
if !websocket_targets_allow(&record.grants.websocket, url) {
return Err(PluginWebSocketError::new(format!(
"host_api.websocket target {} is not covered by enabled WebSocket grants",
safe_url(url)
)));
}
Ok(())
}
fn websocket_timeout(timeout_ms: u32) -> Duration {
if timeout_ms == 0 {
return PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT;
}
let requested = Duration::from_millis(u64::from(timeout_ms));
requested.min(PLUGIN_WEBSOCKET_MAX_TIMEOUT)
}
fn request_targets_allow(targets: &[PluginRequestGrant], method: &str, url: &reqwest::Url) -> bool {
targets
.iter()
.any(|target| request_target_allows(target, method, url))
}
fn request_target_allows(target: &PluginRequestGrant, method: &str, url: &reqwest::Url) -> bool {
let scheme = target.scheme.trim().to_ascii_lowercase();
if scheme.is_empty() || (scheme != "*" && scheme != url.scheme()) {
return false;
}
let Ok(host) = canonical_host(url) else {
return false;
};
let target_host = normalize_host_literal(&target.host);
if target_host.is_empty() || (target_host != "*" && target_host != host) {
return false;
}
if let Some(port) = target.port {
if url.port_or_known_default() != Some(port) {
return false;
}
}
if target.methods.is_empty()
|| !target
.methods
.iter()
.any(|allowed_method| allowed_method.trim().eq_ignore_ascii_case(method))
{
return false;
}
target.path_prefixes.is_empty()
|| target
.path_prefixes
.iter()
.any(|prefix| !prefix.is_empty() && url.path().starts_with(prefix))
}
fn request_targets_intersect(left: &PluginRequestGrant, right: &PluginRequestGrant) -> bool {
request_scheme_intersects(&left.scheme, &right.scheme)
&& request_host_intersects(&left.host, &right.host)
&& request_port_intersects(left.port, right.port)
&& request_methods_intersect(&left.methods, &right.methods)
&& request_paths_intersect(&left.path_prefixes, &right.path_prefixes)
}
fn request_target_covers(covering: &PluginRequestGrant, covered: &PluginRequestGrant) -> bool {
request_scheme_covers(&covering.scheme, &covered.scheme)
&& request_host_covers(&covering.host, &covered.host)
&& request_port_covers(covering.port, covered.port)
&& request_methods_cover(&covering.methods, &covered.methods)
&& request_paths_cover(&covering.path_prefixes, &covered.path_prefixes)
}
fn websocket_targets_allow(targets: &[PluginWebSocketGrant], url: &reqwest::Url) -> bool {
targets
.iter()
.any(|target| websocket_target_allows(target, url))
}
fn websocket_target_allows(target: &PluginWebSocketGrant, url: &reqwest::Url) -> bool {
let scheme = target.scheme.trim().to_ascii_lowercase();
if scheme.is_empty() || (scheme != "*" && scheme != url.scheme()) {
return false;
}
let Ok(host) = canonical_host(url) else {
return false;
};
let target_host = normalize_host_literal(&target.host);
if target_host.is_empty() || (target_host != "*" && target_host != host) {
return false;
}
if let Some(port) = target.port {
if url.port_or_known_default() != Some(port) {
return false;
}
}
target.path_prefixes.is_empty()
|| target
.path_prefixes
.iter()
.any(|prefix| !prefix.is_empty() && url.path().starts_with(prefix))
}
fn websocket_targets_intersect(left: &PluginWebSocketGrant, right: &PluginWebSocketGrant) -> bool {
request_scheme_intersects(&left.scheme, &right.scheme)
&& request_host_intersects(&left.host, &right.host)
&& request_port_intersects(left.port, right.port)
&& request_paths_intersect(&left.path_prefixes, &right.path_prefixes)
}
fn websocket_target_covers(
covering: &PluginWebSocketGrant,
covered: &PluginWebSocketGrant,
) -> bool {
request_scheme_covers(&covering.scheme, &covered.scheme)
&& request_host_covers(&covering.host, &covered.host)
&& request_port_covers(covering.port, covered.port)
&& request_paths_cover(&covering.path_prefixes, &covered.path_prefixes)
}
fn request_scheme_intersects(left: &str, right: &str) -> bool {
let left = left.trim().to_ascii_lowercase();
let right = right.trim().to_ascii_lowercase();
!left.is_empty() && !right.is_empty() && (left == "*" || right == "*" || left == right)
}
fn request_scheme_covers(covering: &str, covered: &str) -> bool {
let covering = covering.trim().to_ascii_lowercase();
let covered = covered.trim().to_ascii_lowercase();
!covering.is_empty() && !covered.is_empty() && (covering == "*" || covering == covered)
}
fn request_host_intersects(left: &str, right: &str) -> bool {
let left = normalize_host_literal(left);
let right = normalize_host_literal(right);
!left.is_empty() && !right.is_empty() && (left == "*" || right == "*" || left == right)
}
fn request_host_covers(covering: &str, covered: &str) -> bool {
let covering = normalize_host_literal(covering);
let covered = normalize_host_literal(covered);
!covering.is_empty() && !covered.is_empty() && (covering == "*" || covering == covered)
}
fn request_port_intersects(left: Option<u16>, right: Option<u16>) -> bool {
left.is_none() || right.is_none() || left == right
}
fn request_port_covers(covering: Option<u16>, covered: Option<u16>) -> bool {
covering.is_none() || covering == covered
}
fn request_methods_intersect(left: &[String], right: &[String]) -> bool {
!left.is_empty()
&& !right.is_empty()
&& left.iter().any(|left_method| {
right
.iter()
.any(|right_method| left_method.trim().eq_ignore_ascii_case(right_method.trim()))
})
}
fn request_methods_cover(covering: &[String], covered: &[String]) -> bool {
!covering.is_empty()
&& !covered.is_empty()
&& covered.iter().all(|covered_method| {
covering.iter().any(|covering_method| {
covering_method
.trim()
.eq_ignore_ascii_case(covered_method.trim())
})
})
}
fn request_paths_intersect(left: &[String], right: &[String]) -> bool {
left.is_empty()
|| right.is_empty()
|| left.iter().any(|left_prefix| {
!left_prefix.is_empty()
&& right.iter().any(|right_prefix| {
!right_prefix.is_empty()
&& (left_prefix.starts_with(right_prefix)
|| right_prefix.starts_with(left_prefix))
})
})
}
fn request_paths_cover(covering: &[String], covered: &[String]) -> bool {
if covering.is_empty() {
return true;
}
if covered.is_empty() {
return false;
}
covered.iter().all(|covered_prefix| {
!covered_prefix.is_empty()
&& covering.iter().any(|covering_prefix| {
!covering_prefix.is_empty() && covered_prefix.starts_with(covering_prefix)
})
})
}
fn normalize_host_literal(host: &str) -> String {
host.trim_end_matches('.')
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase()
}
fn has_usable_request_grant(record: &ResolvedPluginRecord) -> bool {
record.grants.request.iter().any(|grant| {
!grant.scheme.trim().is_empty()
&& !grant.host.trim().is_empty()
&& grant.methods.iter().any(|method| {
let method = method.trim().to_ascii_uppercase();
PLUGIN_REQUEST_ALLOWED_METHODS.contains(&method.as_str())
})
})
}
fn has_declared_request_target(record: &ResolvedPluginRecord) -> bool {
record.manifest.request.iter().any(|target| {
!target.scheme.trim().is_empty()
&& !target.host.trim().is_empty()
&& target.methods.iter().any(|method| {
let method = method.trim().to_ascii_uppercase();
PLUGIN_REQUEST_ALLOWED_METHODS.contains(&method.as_str())
})
})
}
fn has_usable_websocket_grant(record: &ResolvedPluginRecord) -> bool {
record.grants.websocket.iter().any(|grant| {
let scheme = grant.scheme.trim().to_ascii_lowercase();
(scheme == "ws" || scheme == "wss" || scheme == "*") && !grant.host.trim().is_empty()
})
}
fn has_declared_websocket_target(record: &ResolvedPluginRecord) -> bool {
record.manifest.websocket.iter().any(|target| {
let scheme = target.scheme.trim().to_ascii_lowercase();
(scheme == "ws" || scheme == "wss" || scheme == "*") && !target.host.trim().is_empty()
})
}
fn has_usable_fs_grant(record: &ResolvedPluginRecord) -> bool {
record.grants.fs.iter().any(|grant| {
!grant.root.trim().is_empty()
&& Path::new(&grant.root).is_absolute()
&& !grant.operations.is_empty()
})
}
fn canonical_host(url: &reqwest::Url) -> Result<String, PluginRequestError> {
url.host_str()
.map(normalize_host_literal)
.filter(|host| !host.is_empty())
.ok_or_else(|| PluginRequestError::new("request URL must include a host"))
}
fn validate_static_request_target(url: &reqwest::Url) -> Result<(), PluginRequestError> {
let _host = canonical_host(url)?;
if url.cannot_be_a_base() {
return Err(PluginRequestError::new(
"request URL target is not hierarchical",
));
}
Ok(())
}
fn resolve_request_target_for_client(
url: &reqwest::Url,
resolver: &dyn PluginRequestResolver,
) -> Result<Option<PinnedRequestResolution>, PluginRequestError> {
let host = canonical_host(url)?;
if host.parse::<IpAddr>().is_ok() {
return Ok(None);
}
let port = url.port_or_known_default().ok_or_else(|| {
PluginRequestError::new("request URL uses a scheme without a default port")
})?;
let addrs = resolver.resolve(&host, port)?;
if addrs.is_empty() {
return Err(PluginRequestError::new(format!(
"DNS lookup for {:?} returned no addresses",
host
)));
}
let mut domains = Vec::new();
if let Some(raw_host) = url.host_str() {
let raw_host = raw_host
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
if !raw_host.is_empty() {
domains.push(raw_host);
}
}
if !domains.contains(&host) {
domains.push(host);
}
Ok(Some(PinnedRequestResolution { domains, addrs }))
}
fn collect_request_response_headers(
headers: &reqwest::header::HeaderMap,
) -> Vec<PluginRequestHeader> {
headers
.iter()
.filter(|(name, _)| !is_sensitive_header(name.as_str()))
.take(PLUGIN_REQUEST_MAX_RESPONSE_HEADERS)
.filter_map(|(name, value)| {
value.to_str().ok().map(|value| PluginRequestHeader {
name: name.as_str().to_string(),
value: bounded_header_value(value),
})
})
.collect()
}
fn bounded_header_value(value: &str) -> String {
let mut redacted = redact_secret_like(value);
if redacted.len() > PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES {
truncate_string_to_boundary(&mut redacted, PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES);
redacted.push('…');
}
redacted
}
fn is_sensitive_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"authorization"
| "proxy-authorization"
| "cookie"
| "set-cookie"
| "x-api-key"
| "x-auth-token"
| "api-key"
| "apikey"
)
}
fn safe_url(url: &reqwest::Url) -> String {
let host = url.host_str().unwrap_or("<missing-host>");
let mut path = url.path().to_string();
if path.len() > 120 {
truncate_string_to_boundary(&mut path, 120);
path.push('…');
}
match url.port() {
Some(port) => format!("{}://{host}:{port}{path}", url.scheme()),
None => format!("{}://{host}{path}", url.scheme()),
}
}
fn redact_secret_like(message: &str) -> String {
let mut value = message.to_string();
for needle in [
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
"x-auth-token",
"api-key",
"token",
"secret",
"password",
] {
value = redact_after_secret_word(&value, needle);
}
value
}
fn redact_after_secret_word(input: &str, needle: &str) -> String {
let lower = input.to_ascii_lowercase();
let mut out = String::new();
let mut cursor = 0usize;
while let Some(relative) = lower[cursor..].find(needle) {
let start = cursor + relative;
let mut end = start + needle.len();
out.push_str(&input[cursor..end]);
let bytes = input.as_bytes();
while end < input.len() && matches!(bytes[end], b' ' | b'=' | b':' | b'\t') {
out.push(bytes[end] as char);
end += 1;
}
let secret_start = end;
while end < input.len() && !matches!(bytes[end], b' ' | b',' | b';' | b'\n' | b'\r') {
end += 1;
}
if end > secret_start {
out.push_str(PLUGIN_REQUEST_REDACTION);
}
cursor = end;
}
out.push_str(&input[cursor..]);
out
}
#[derive(Debug)]
struct PluginPermissionError(String);
impl PluginPermissionError {
fn bounded_message(&self) -> String {
bounded_message(&self.0)
}
}
fn authorize_plugin_tool(
record: &ResolvedPluginRecord,
tool: &PluginToolManifest,
) -> Result<(), PluginPermissionError> {
validate_grant_binding(record)?;
require_permission(
&record.manifest.permissions,
&PluginPermission::surface(PluginSurface::Tool),
"requested surfaces.tool permission is missing",
)?;
require_permission(
&record.grants.permissions,
&PluginPermission::surface(PluginSurface::Tool),
"granted surfaces.tool permission is missing",
)?;
if !permission_allows_tool(&record.manifest.permissions, &tool.name) {
return Err(PluginPermissionError(format!(
"requested tool permission for `{}` is missing",
tool.name
)));
}
if !permission_allows_tool(&record.grants.permissions, &tool.name) {
return Err(PluginPermissionError(format!(
"granted tool permission for `{}` is missing",
tool.name
)));
}
if tool.external_write {
require_permission(
&record.manifest.permissions,
&PluginPermission::ExternalWrite,
"requested external_write permission is missing",
)?;
require_permission(
&record.grants.permissions,
&PluginPermission::ExternalWrite,
"granted external_write permission is missing",
)?;
}
Ok(())
}
fn authorize_plugin_service(
record: &ResolvedPluginRecord,
service_name: &str,
) -> Result<(), PluginPermissionError> {
validate_grant_binding(record)?;
require_permission(
&record.manifest.permissions,
&PluginPermission::surface(PluginSurface::Service),
"requested surfaces.service permission is missing",
)?;
require_permission(
&record.grants.permissions,
&PluginPermission::surface(PluginSurface::Service),
"granted surfaces.service permission is missing",
)?;
let permission = PluginPermission::service(service_name);
require_permission(
&record.manifest.permissions,
&permission,
&format!("requested service permission for `{service_name}` is missing"),
)?;
require_permission(
&record.grants.permissions,
&permission,
&format!("granted service permission for `{service_name}` is missing"),
)?;
Ok(())
}
fn authorize_plugin_ingress(
record: &ResolvedPluginRecord,
ingress_name: &str,
) -> Result<(), PluginPermissionError> {
validate_grant_binding(record)?;
require_permission(
&record.manifest.permissions,
&PluginPermission::surface(PluginSurface::Ingress),
"requested surfaces.ingress permission is missing",
)?;
require_permission(
&record.grants.permissions,
&PluginPermission::surface(PluginSurface::Ingress),
"granted surfaces.ingress permission is missing",
)?;
let permission = PluginPermission::ingress(ingress_name);
require_permission(
&record.manifest.permissions,
&permission,
&format!("requested ingress permission for `{ingress_name}` is missing"),
)?;
require_permission(
&record.grants.permissions,
&permission,
&format!("granted ingress permission for `{ingress_name}` is missing"),
)?;
Ok(())
}
fn authorize_plugin_host_api(
record: &ResolvedPluginRecord,
api: PluginHostApi,
) -> Result<(), PluginPermissionError> {
validate_grant_binding(record)?;
let permission = PluginPermission::host_api(api);
require_permission(
&record.manifest.permissions,
&permission,
&format!("requested host_api.{api} permission is missing"),
)?;
require_permission(
&record.grants.permissions,
&permission,
&format!("granted host_api.{api} permission is missing"),
)?;
match api {
PluginHostApi::Request => {
if !has_declared_request_target(record) {
return Err(PluginPermissionError(
"manifest host_api.request target declaration is missing".to_string(),
));
}
if !has_usable_request_grant(record) {
return Err(PluginPermissionError(
"enabled host_api.request grants are missing".to_string(),
));
}
Ok(())
}
PluginHostApi::WebSocket => {
if !has_declared_websocket_target(record) {
return Err(PluginPermissionError(
"manifest host_api.websocket target declaration is missing".to_string(),
));
}
if !has_usable_websocket_grant(record) {
return Err(PluginPermissionError(
"enabled host_api.websocket grants are missing".to_string(),
));
}
Ok(())
}
PluginHostApi::Fs => {
if !has_usable_fs_grant(record) {
return Err(PluginPermissionError(
"granted host_api.fs scope allowlist is missing".to_string(),
));
}
Ok(())
}
}
}
fn validate_grant_binding(record: &ResolvedPluginRecord) -> Result<(), PluginPermissionError> {
if let Some(message) =
record
.grants
.binding_error(&record.identity, &record.digest, &record.manifest.version)
{
return Err(PluginPermissionError(message.to_string()));
}
Ok(())
}
fn require_permission(
permissions: &[PluginPermission],
expected: &PluginPermission,
missing_message: &str,
) -> Result<(), PluginPermissionError> {
if permissions.iter().any(|permission| permission == expected) {
return Ok(());
}
Err(PluginPermissionError(missing_message.to_string()))
}
fn permission_allows_tool(permissions: &[PluginPermission], tool_name: &str) -> bool {
permissions.iter().any(|permission| match permission {
PluginPermission::Tool { name } => name == tool_name,
PluginPermission::ToolNamespace { namespace } => {
!namespace.is_empty() && tool_name.starts_with(namespace)
}
_ => false,
})
}
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_SERVICE_INGRESS_QUEUE_CAPACITY: usize = 32;
const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT: usize = 16;
const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES: usize = 16 * 1024;
const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS: usize = 32;
#[cfg(test)]
const PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT: Duration = Duration::from_millis(25);
#[cfg(not(test))]
const PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT: Duration = Duration::from_secs(1);
const PLUGIN_WASM_MEMORY_BYTES: usize = 2 * 1024 * 1024;
const PLUGIN_WASM_TABLE_ELEMENTS: usize = 256;
const PLUGIN_REQUEST_MAX_REQUEST_BYTES: usize = 48 * 1024;
const PLUGIN_REQUEST_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024;
const PLUGIN_REQUEST_MAX_REQUEST_HEADERS: usize = 16;
const PLUGIN_REQUEST_MAX_RESPONSE_HEADERS: usize = 16;
const PLUGIN_REQUEST_MAX_HEADER_NAME_BYTES: usize = 64;
const PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES: usize = 1024;
const PLUGIN_REQUEST_MAX_RESPONSE_BYTES: usize = 64 * 1024;
const PLUGIN_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
const PLUGIN_REQUEST_ALLOWED_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];
const PLUGIN_REQUEST_REDACTION: &str = "<redacted>";
const PLUGIN_WEBSOCKET_MAX_OPEN_REQUEST_BYTES: usize = 8 * 1024;
const PLUGIN_WEBSOCKET_MAX_TEXT_BYTES: usize = 32 * 1024;
const PLUGIN_WEBSOCKET_MAX_FRAME_BYTES: usize = 32 * 1024;
const PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES: usize = 64 * 1024;
const PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS: usize = 4;
const PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const PLUGIN_WEBSOCKET_MAX_TIMEOUT: Duration = Duration::from_secs(30);
const PLUGIN_WEBSOCKET_MAX_HANDLE_AGE: Duration = Duration::from_secs(15 * 60);
const PLUGIN_WEBSOCKET_MAX_CONTROL_FRAMES: usize = 16;
const PLUGIN_FS_MAX_REQUEST_BYTES: usize = 64 * 1024;
const PLUGIN_FS_MAX_PATH_BYTES: usize = 4096;
const PLUGIN_FS_MAX_READ_BYTES: usize = 64 * 1024;
const PLUGIN_FS_MAX_WRITE_BYTES: usize = 64 * 1024;
const PLUGIN_FS_MAX_LIST_ENTRIES: usize = 256;
fn wasm_component_store_limits() -> wasmtime::StoreLimits {
wasmtime::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()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PluginFsRuntimeOperation {
Read,
List,
Write,
}
impl PluginFsRuntimeOperation {
fn grant_operation(self) -> PluginFsOperation {
match self {
Self::Read => PluginFsOperation::Read,
Self::List => PluginFsOperation::List,
Self::Write => PluginFsOperation::Write,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::List => "list",
Self::Write => "write",
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginFsPathRequest {
path: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginFsWriteRequest {
path: String,
content: String,
}
#[derive(Clone, Debug, Serialize)]
struct PluginFsReadResponse {
path: String,
content: String,
truncated: bool,
}
#[derive(Clone, Debug, Serialize)]
struct PluginFsListResponse {
path: String,
entries: Vec<PluginFsDirEntry>,
truncated: bool,
}
#[derive(Clone, Debug, Serialize)]
struct PluginFsDirEntry {
name: String,
kind: String,
}
#[derive(Clone, Debug, Serialize)]
struct PluginFsWriteResponse {
path: String,
bytes_written: usize,
}
#[derive(Debug)]
struct PluginFsError {
message: String,
}
impl PluginFsError {
fn new(message: impl Into<String>) -> Self {
let message = message.into();
Self {
message: bounded_message(redact_secret_like(&message)),
}
}
}
impl std::fmt::Display for PluginFsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for PluginFsError {}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginRequestRequest {
method: String,
url: String,
#[serde(default)]
headers: Vec<PluginRequestHeader>,
#[serde(default)]
body: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginRequestHeader {
name: String,
value: String,
}
#[derive(Clone, Debug, Serialize)]
struct PluginRequestResponse {
status: u16,
headers: Vec<PluginRequestHeader>,
body: String,
truncated: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct PluginWebSocketOpenRequest {
url: String,
#[serde(default)]
protocols: Vec<String>,
#[serde(default)]
headers: Vec<PluginRequestHeader>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginServiceWebSocketSendCommandPayload {
url: String,
text: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginServiceDiagnosticStatusCommandPayload {
#[serde(default)]
message: Option<String>,
#[serde(default)]
status: Option<Value>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct PluginWebSocketOpenResponse {
handle: u32,
url: String,
}
#[derive(Clone, Debug, Serialize)]
struct PluginWebSocketSendResponse {
sent: bool,
bytes: usize,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum PluginWebSocketRecvResponse {
Text { text: String },
Closed,
}
#[derive(Clone, Debug, Serialize)]
struct PluginWebSocketCloseResponse {
closed: bool,
}
#[derive(Clone, Copy, Debug)]
struct PluginWebSocketLimits {
timeout: Duration,
max_message_bytes: usize,
}
impl Default for PluginWebSocketLimits {
fn default() -> Self {
Self {
timeout: PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT,
max_message_bytes: PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES,
}
}
}
#[derive(Debug)]
struct PluginWebSocketError(String);
impl PluginWebSocketError {
fn new(message: impl Into<String>) -> Self {
Self(redact_secret_like(&bounded_message(message.into())))
}
}
impl std::fmt::Display for PluginWebSocketError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for PluginWebSocketError {}
trait PluginWebSocketConnection: Send {
fn send_text(&mut self, text: &str) -> Result<(), PluginWebSocketError>;
fn recv_text(
&mut self,
timeout: Duration,
max_message_bytes: usize,
) -> Result<PluginWebSocketRecvResponse, PluginWebSocketError>;
fn close(&mut self) -> Result<(), PluginWebSocketError>;
}
trait PluginWebSocketClient: Send + Sync {
fn supports_bounded_open(&self) -> bool;
fn open(
&self,
request: &PluginWebSocketOpenRequest,
url: &reqwest::Url,
limits: PluginWebSocketLimits,
) -> Result<Box<dyn PluginWebSocketConnection>, PluginWebSocketError>;
}
struct TungstenitePluginWebSocketClient;
type AsyncSystemWebSocket =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
struct PluginWebSocketRuntime {
runtime: Option<TokioRuntime>,
}
impl PluginWebSocketRuntime {
fn new(runtime: TokioRuntime) -> Self {
Self {
runtime: Some(runtime),
}
}
fn get(&self) -> &TokioRuntime {
self.runtime
.as_ref()
.expect("plugin websocket runtime missing")
}
}
impl Drop for PluginWebSocketRuntime {
fn drop(&mut self) {
let Some(runtime) = self.runtime.take() else {
return;
};
if TokioHandle::try_current().is_ok() {
let _ = tokio::task::spawn_blocking(move || drop(runtime));
} else {
drop(runtime);
}
}
}
struct TungstenitePluginWebSocketConnection {
runtime: PluginWebSocketRuntime,
socket: AsyncSystemWebSocket,
}
impl PluginWebSocketClient for TungstenitePluginWebSocketClient {
fn supports_bounded_open(&self) -> bool {
true
}
fn open(
&self,
_request: &PluginWebSocketOpenRequest,
url: &reqwest::Url,
limits: PluginWebSocketLimits,
) -> Result<Box<dyn PluginWebSocketConnection>, PluginWebSocketError> {
let mut request = url.as_str().into_client_request().map_err(|error| {
PluginWebSocketError::new(format!("WebSocket request build failed: {error}"))
})?;
request.headers_mut().insert(
tokio_tungstenite::tungstenite::http::header::USER_AGENT,
tokio_tungstenite::tungstenite::http::HeaderValue::from_static("yoi-plugin-host/1"),
);
let config = WebSocketConfig::default()
.max_message_size(Some(limits.max_message_bytes))
.max_frame_size(Some(PLUGIN_WEBSOCKET_MAX_FRAME_BYTES))
.accept_unmasked_frames(false);
let runtime = new_websocket_runtime()?;
let open = async {
tokio::time::timeout(
limits.timeout,
tokio_tungstenite::connect_async_tls_with_config(
request,
Some(config),
false,
None,
),
)
.await
};
let (socket, _response) = block_on_websocket_future(runtime.get(), open)
.map_err(|error| {
PluginWebSocketError::new(format!(
"WebSocket open timed out after {} ms for {}: {error}",
limits.timeout.as_millis(),
safe_url(url)
))
})?
.map_err(|error| {
PluginWebSocketError::new(format!(
"WebSocket connection failed for {}: {error}",
safe_url(url)
))
})?;
Ok(Box::new(TungstenitePluginWebSocketConnection {
runtime,
socket,
}))
}
}
impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection {
fn send_text(&mut self, text: &str) -> Result<(), PluginWebSocketError> {
let send = tokio::time::timeout(
PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT,
self.socket.send(Message::Text(text.to_string().into())),
);
block_on_websocket_future(self.runtime.get(), send)
.map_err(|_| PluginWebSocketError::new("WebSocket send timed out"))?
.map_err(|error| PluginWebSocketError::new(format!("WebSocket send failed: {error}")))
}
fn recv_text(
&mut self,
timeout: Duration,
max_message_bytes: usize,
) -> Result<PluginWebSocketRecvResponse, PluginWebSocketError> {
for _ in 0..PLUGIN_WEBSOCKET_MAX_CONTROL_FRAMES {
let next = tokio::time::timeout(timeout, self.socket.next());
let message = block_on_websocket_future(self.runtime.get(), next)
.map_err(|_| PluginWebSocketError::new("WebSocket receive timed out"))?
.ok_or_else(|| PluginWebSocketError::new("WebSocket stream ended"))?
.map_err(|error| {
PluginWebSocketError::new(format!("WebSocket receive failed: {error}"))
})?;
match message {
Message::Text(text) => {
if text.len() > max_message_bytes {
return Err(PluginWebSocketError::new(format!(
"WebSocket text message exceeds {} bytes",
max_message_bytes
)));
}
return Ok(PluginWebSocketRecvResponse::Text {
text: text.to_string(),
});
}
Message::Binary(_) => {
return Err(PluginWebSocketError::new(
"binary WebSocket messages are not supported by host_api.websocket v1",
));
}
Message::Close(_) => return Ok(PluginWebSocketRecvResponse::Closed),
Message::Ping(payload) => {
let send = tokio::time::timeout(
PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT,
self.socket.send(Message::Pong(payload)),
);
block_on_websocket_future(self.runtime.get(), send)
.map_err(|_| PluginWebSocketError::new("WebSocket pong timed out"))?
.map_err(|error| {
PluginWebSocketError::new(format!("WebSocket pong failed: {error}"))
})?;
}
Message::Pong(_) | Message::Frame(_) => continue,
}
}
Err(PluginWebSocketError::new(
"WebSocket receive exceeded bounded control-frame budget",
))
}
fn close(&mut self) -> Result<(), PluginWebSocketError> {
let close = tokio::time::timeout(PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.close(None));
block_on_websocket_future(self.runtime.get(), close)
.map_err(|_| PluginWebSocketError::new("WebSocket close timed out"))?
.map_err(|error| PluginWebSocketError::new(format!("WebSocket close failed: {error}")))
}
}
fn new_websocket_runtime() -> Result<PluginWebSocketRuntime, PluginWebSocketError> {
let runtime = TokioRuntimeBuilder::new_current_thread()
.enable_all()
.build()
.map_err(|error| {
PluginWebSocketError::new(format!("WebSocket runtime build failed: {error}"))
})?;
Ok(PluginWebSocketRuntime::new(runtime))
}
fn block_on_websocket_future<F: std::future::Future>(
runtime: &TokioRuntime,
future: F,
) -> F::Output {
if TokioHandle::try_current().is_ok() {
tokio::task::block_in_place(|| runtime.block_on(future))
} else {
runtime.block_on(future)
}
}
#[derive(Clone, Default)]
struct PluginWebSocketHandles {
inner: Arc<Mutex<PluginWebSocketHandleTable>>,
}
impl PluginWebSocketHandles {
fn reserve_open(&self) -> Result<PluginWebSocketOpenReservation, PluginWebSocketError> {
self.inner
.lock()
.expect("plugin websocket handle table poisoned")
.reserve_open()?;
Ok(PluginWebSocketOpenReservation {
handles: self.clone(),
active: true,
})
}
fn with_connection<T>(
&self,
handle: u32,
f: impl FnOnce(&mut dyn PluginWebSocketConnection) -> Result<T, PluginWebSocketError>,
) -> Result<T, PluginWebSocketError> {
self.inner
.lock()
.expect("plugin websocket handle table poisoned")
.with_connection(handle, f)
}
fn close(&self, handle: u32) -> Result<bool, PluginWebSocketError> {
self.inner
.lock()
.expect("plugin websocket handle table poisoned")
.close(handle)
}
fn close_all(&self) {
self.inner
.lock()
.expect("plugin websocket handle table poisoned")
.close_all();
}
#[cfg(test)]
fn reservation_count(&self) -> usize {
self.inner
.lock()
.expect("plugin websocket handle table poisoned")
.reservations
}
}
struct PluginWebSocketOpenReservation {
handles: PluginWebSocketHandles,
active: bool,
}
impl PluginWebSocketOpenReservation {
fn commit(
mut self,
connection: Box<dyn PluginWebSocketConnection>,
) -> Result<u32, PluginWebSocketError> {
let result = self
.handles
.inner
.lock()
.expect("plugin websocket handle table poisoned")
.insert_reserved(connection);
self.active = false;
result
}
}
impl Drop for PluginWebSocketOpenReservation {
fn drop(&mut self) {
if self.active {
self.handles
.inner
.lock()
.expect("plugin websocket handle table poisoned")
.release_reservation();
self.active = false;
}
}
}
#[derive(Default)]
struct PluginWebSocketHandleTable {
next: u32,
reservations: usize,
connections: HashMap<u32, PluginWebSocketHandleEntry>,
}
struct PluginWebSocketHandleEntry {
opened_at: Instant,
connection: Box<dyn PluginWebSocketConnection>,
}
impl PluginWebSocketHandleTable {
fn reserve_open(&mut self) -> Result<(), PluginWebSocketError> {
self.expire_stale();
if self.connections.len() + self.reservations >= PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS {
return Err(PluginWebSocketError::new(format!(
"host_api.websocket open connection limit ({}) exceeded before dialing",
PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS
)));
}
self.reservations += 1;
Ok(())
}
fn release_reservation(&mut self) {
self.reservations = self.reservations.saturating_sub(1);
}
fn insert_reserved(
&mut self,
mut connection: Box<dyn PluginWebSocketConnection>,
) -> Result<u32, PluginWebSocketError> {
self.release_reservation();
self.expire_stale();
if self.connections.len() >= PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS {
let _ = connection.close();
return Err(PluginWebSocketError::new(format!(
"host_api.websocket open connection limit ({}) exceeded while committing reserved handle",
PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS
)));
}
let mut attempts = 0usize;
loop {
self.next = self.next.wrapping_add(1).max(1);
attempts += 1;
if !self.connections.contains_key(&self.next) {
let handle = self.next;
self.connections.insert(
handle,
PluginWebSocketHandleEntry {
opened_at: Instant::now(),
connection,
},
);
return Ok(handle);
}
if attempts > u32::MAX as usize {
return Err(PluginWebSocketError::new(
"WebSocket handle space exhausted",
));
}
}
}
fn with_connection<T>(
&mut self,
handle: u32,
f: impl FnOnce(&mut dyn PluginWebSocketConnection) -> Result<T, PluginWebSocketError>,
) -> Result<T, PluginWebSocketError> {
self.expire_stale();
let entry = self.connections.get_mut(&handle).ok_or_else(|| {
PluginWebSocketError::new(format!("unknown WebSocket handle {handle}"))
})?;
f(entry.connection.as_mut())
}
fn close(&mut self, handle: u32) -> Result<bool, PluginWebSocketError> {
if let Some(mut entry) = self.connections.remove(&handle) {
entry.connection.close()?;
Ok(true)
} else {
Ok(false)
}
}
fn close_all(&mut self) {
self.reservations = 0;
for (_, mut entry) in self.connections.drain() {
let _ = entry.connection.close();
}
}
fn expire_stale(&mut self) {
let now = Instant::now();
let stale: Vec<_> = self
.connections
.iter()
.filter_map(|(handle, entry)| {
(now.duration_since(entry.opened_at) > PLUGIN_WEBSOCKET_MAX_HANDLE_AGE)
.then_some(*handle)
})
.collect();
for handle in stale {
let _ = self.close(handle);
}
}
}
impl Drop for PluginWebSocketHandleTable {
fn drop(&mut self) {
self.close_all();
}
}
#[derive(Clone, Copy, Debug)]
struct PluginRequestLimits {
timeout: Duration,
max_response_bytes: usize,
}
impl Default for PluginRequestLimits {
fn default() -> Self {
Self {
timeout: PLUGIN_REQUEST_TIMEOUT,
max_response_bytes: PLUGIN_REQUEST_MAX_RESPONSE_BYTES,
}
}
}
trait PluginRequestClient: Send + Sync {
fn execute(
&self,
request: &PluginRequestRequest,
url: &reqwest::Url,
limits: PluginRequestLimits,
) -> Result<PluginRequestResponse, PluginRequestError>;
}
struct ReqwestPluginRequestClient;
struct SystemPluginRequestResolver;
#[derive(Clone, Debug)]
struct PinnedRequestResolution {
domains: Vec<String>,
addrs: Vec<SocketAddr>,
}
trait PluginRequestResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginRequestError>;
}
impl PluginRequestResolver for SystemPluginRequestResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginRequestError> {
let mut addrs = Vec::new();
for addr in (host, port).to_socket_addrs().map_err(|error| {
PluginRequestError::new(format!("DNS lookup failed for {:?}: {error}", host))
})? {
addrs.push(addr);
}
Ok(addrs)
}
}
#[derive(Debug)]
struct PluginRequestError(String);
impl PluginRequestError {
fn new(message: impl Into<String>) -> Self {
Self(redact_secret_like(&bounded_message(message.into())))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum PluginInstanceLifecycleState {
Ready,
Starting,
Running,
Stopping,
Stopped,
Failed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum PluginInstanceDiagnosticKind {
Lifecycle,
InvalidEvent,
QueueFull,
DispatchTimeout,
DispatchFailed,
ServiceUnavailable,
ServiceFailed,
ServiceStopped,
ServiceOutputCommandRecorded,
ServiceOutputCommandRejected,
ServiceOutputCommandUnsupported,
ServiceWebSocketConnected,
ServiceWebSocketClosed,
ServiceWebSocketError,
ServiceWebSocketSendFailed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginInstanceDiagnostic {
pub kind: PluginInstanceDiagnosticKind,
pub state: PluginInstanceLifecycleState,
pub message: String,
}
impl PluginInstanceDiagnostic {
pub fn new(state: PluginInstanceLifecycleState, message: impl Into<String>) -> Self {
Self::with_kind(PluginInstanceDiagnosticKind::Lifecycle, state, message)
}
pub fn with_kind(
kind: PluginInstanceDiagnosticKind,
state: PluginInstanceLifecycleState,
message: impl Into<String>,
) -> Self {
Self {
kind,
state,
message: bounded_message(redact_secret_like(&message.into())),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct PluginIngressDispatchCounters {
pub enqueued: u64,
pub dispatched: u64,
pub rejected: u64,
pub failed: u64,
pub timed_out: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum PluginServiceWebSocketConnectionState {
Connecting,
Connected,
Closed,
Failed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct PluginServiceWebSocketConnectionStatus {
pub url: String,
pub ingress_name: String,
pub state: PluginServiceWebSocketConnectionState,
pub last_frame_at: Option<String>,
pub last_error: Option<String>,
pub received_text_frames: u64,
pub sent_text_frames: u64,
pub queue_drops: u64,
pub send_failures: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PluginInstanceStatus {
pub plugin_ref: String,
pub lifecycle: PluginInstanceLifecycleState,
pub component_status: Option<Value>,
pub queue_depth: usize,
pub queue_capacity: usize,
pub last_error: Option<String>,
pub dispatch_counters: PluginIngressDispatchCounters,
/// Last bounded Service output command outcomes. These are produced only by
/// Service/Ingress dispatch and are intentionally separate from ToolOutput.
pub output_command_results: Vec<PluginServiceOutputCommandResult>,
pub websocket_connections: Vec<PluginServiceWebSocketConnectionStatus>,
pub diagnostics: Vec<PluginInstanceDiagnostic>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PluginIngressEvent {
pub kind: String,
pub source: String,
pub ingress_name: String,
pub payload: Value,
pub created_at: String,
pub attempt: u32,
pub correlation_id: String,
}
impl PluginIngressEvent {
pub fn new(
ingress_name: impl Into<String>,
kind: impl Into<String>,
source: impl Into<String>,
payload: Value,
) -> Self {
Self {
kind: kind.into(),
source: source.into(),
ingress_name: ingress_name.into(),
payload,
created_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
attempt: 1,
correlation_id: uuid::Uuid::now_v7().to_string(),
}
}
}
/// Host-validated output command envelope returned by Service/Ingress handlers.
///
/// Service output commands are intentionally distinct from ordinary plugin
/// `ToolOutput`: handlers can request bounded side effects, but the host parses,
/// validates, grant-checks, records diagnostics, and fail-closes before executing
/// any supported command.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginServiceOutputCommandEnvelope {
pub correlation_id: String,
pub source_event_id: String,
pub command_id: String,
pub kind: PluginServiceOutputCommandKind,
pub payload: Value,
pub requested_at: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginServiceOutputCommandKind {
DiagnosticStatusUpdate,
HostRequestDispatch,
#[serde(rename = "websocket_send")]
WebSocketSend,
}
impl PluginServiceOutputCommandKind {
fn as_str(self) -> &'static str {
match self {
Self::DiagnosticStatusUpdate => "diagnostic_status_update",
Self::HostRequestDispatch => "host_request_dispatch",
Self::WebSocketSend => "websocket_send",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum PluginServiceOutputCommandStatus {
Recorded,
Rejected,
Unsupported,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PluginServiceOutputCommandResult {
pub correlation_id: Option<String>,
pub source_event_id: Option<String>,
pub command_id: Option<String>,
pub kind: Option<PluginServiceOutputCommandKind>,
pub status: PluginServiceOutputCommandStatus,
pub message: String,
pub recorded_at: String,
}
impl PluginServiceOutputCommandResult {
fn rejected(message: impl Into<String>) -> Self {
Self::from_parts(
None,
None,
None,
None,
PluginServiceOutputCommandStatus::Rejected,
message,
)
}
fn rejected_for(
command: &PluginServiceOutputCommandEnvelope,
message: impl Into<String>,
) -> Self {
Self::from_command(command, PluginServiceOutputCommandStatus::Rejected, message)
}
fn unsupported(
command: &PluginServiceOutputCommandEnvelope,
message: impl Into<String>,
) -> Self {
Self::from_command(
command,
PluginServiceOutputCommandStatus::Unsupported,
message,
)
}
fn recorded(command: &PluginServiceOutputCommandEnvelope, message: impl Into<String>) -> Self {
Self::from_command(command, PluginServiceOutputCommandStatus::Recorded, message)
}
fn from_command(
command: &PluginServiceOutputCommandEnvelope,
status: PluginServiceOutputCommandStatus,
message: impl Into<String>,
) -> Self {
Self::from_parts(
Some(command.correlation_id.clone()),
Some(command.source_event_id.clone()),
Some(command.command_id.clone()),
Some(command.kind),
status,
message,
)
}
fn from_parts(
correlation_id: Option<String>,
source_event_id: Option<String>,
command_id: Option<String>,
kind: Option<PluginServiceOutputCommandKind>,
status: PluginServiceOutputCommandStatus,
message: impl Into<String>,
) -> Self {
Self {
correlation_id,
source_event_id,
command_id,
kind,
status,
message: bounded_message(redact_secret_like(&message.into())),
recorded_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PluginIngressDispatchReport {
pub plugin_ref: String,
pub ingress: String,
pub accepted: bool,
pub output: Value,
/// Results of host-side Service output command parsing/validation/grant-checking.
/// This path never feeds ordinary plugin ToolOutput handling.
pub output_command_results: Vec<PluginServiceOutputCommandResult>,
pub queue_depth: usize,
pub dispatch_counters: PluginIngressDispatchCounters,
pub diagnostics: Vec<PluginInstanceDiagnostic>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PluginIngressDispatchError {
InvalidEvent(String),
QueueFull { capacity: usize },
ServiceUnavailable { state: PluginInstanceLifecycleState },
ServiceFailed(String),
ServiceStopped { state: PluginInstanceLifecycleState },
DispatchTimeout { timeout: Duration },
DispatchFailed(String),
}
impl PluginIngressDispatchError {
fn bounded_message(&self) -> String {
match self {
Self::InvalidEvent(message) => bounded_message(format!(
"invalid plugin ingress event: {}",
redact_secret_like(message)
)),
Self::QueueFull { capacity } => bounded_message(format!(
"plugin ingress queue is full (capacity {capacity})"
)),
Self::ServiceUnavailable { state } => bounded_message(format!(
"plugin service is not running for ingress dispatch (state {state:?})"
)),
Self::ServiceFailed(message) => bounded_message(format!(
"plugin service is failed for ingress dispatch: {}",
redact_secret_like(message)
)),
Self::ServiceStopped { state } => bounded_message(format!(
"plugin service rejects ingress while stopping/stopped (state {state:?})"
)),
Self::DispatchTimeout { timeout } => bounded_message(format!(
"plugin ingress dispatch timed out after {timeout:?}"
)),
Self::DispatchFailed(message) => bounded_message(format!(
"plugin ingress dispatch failed closed: {}",
redact_secret_like(message)
)),
}
}
fn diagnostic_kind(&self) -> PluginInstanceDiagnosticKind {
match self {
Self::InvalidEvent(_) => PluginInstanceDiagnosticKind::InvalidEvent,
Self::QueueFull { .. } => PluginInstanceDiagnosticKind::QueueFull,
Self::ServiceUnavailable { .. } => PluginInstanceDiagnosticKind::ServiceUnavailable,
Self::ServiceFailed(_) => PluginInstanceDiagnosticKind::ServiceFailed,
Self::ServiceStopped { .. } => PluginInstanceDiagnosticKind::ServiceStopped,
Self::DispatchTimeout { .. } => PluginInstanceDiagnosticKind::DispatchTimeout,
Self::DispatchFailed(_) => PluginInstanceDiagnosticKind::DispatchFailed,
}
}
}
impl std::fmt::Display for PluginIngressDispatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.bounded_message())
}
}
impl std::error::Error for PluginIngressDispatchError {}
#[derive(Clone, Debug)]
struct QueuedPluginIngress {
ingress_name: String,
event: PluginIngressEvent,
enqueued_at: Instant,
}
#[derive(Clone, Default)]
pub struct PluginInstanceRegistry {
instances: Arc<Mutex<HashMap<String, PluginInstanceHandle>>>,
}
impl PluginInstanceRegistry {
pub fn register(
&self,
record: ResolvedPluginRecord,
) -> Result<PluginInstanceHandle, FeatureInstallError> {
let key = record.identity.to_string();
let mut instances = self
.instances
.lock()
.expect("plugin instance registry poisoned");
if let Some(existing) = instances.get(&key) {
return Ok(existing.clone());
}
let handle = PluginInstanceHandle::new(record).map_err(|error| {
FeatureInstallError::Install(format!(
"plugin instance startup failed closed: {}",
error.bounded_message()
))
})?;
instances.insert(key, handle.clone());
Ok(handle)
}
pub fn status(&self, plugin_ref: &str) -> Option<PluginInstanceStatus> {
self.instances
.lock()
.expect("plugin instance registry poisoned")
.get(plugin_ref)
.map(PluginInstanceHandle::status)
}
pub fn handle(&self, plugin_ref: &str) -> Option<PluginInstanceHandle> {
self.instances
.lock()
.expect("plugin instance registry poisoned")
.get(plugin_ref)
.cloned()
}
pub fn stop(&self, plugin_ref: &str) -> Result<Option<PluginInstanceStatus>, PluginWasmError> {
let handle = self
.instances
.lock()
.expect("plugin instance registry poisoned")
.get(plugin_ref)
.cloned();
handle.map(|handle| handle.stop()).transpose()
}
}
#[derive(Clone, Debug)]
struct PluginServiceWebSocketSubscription {
ingress_name: String,
source: String,
url: String,
}
#[derive(Clone, Default)]
struct PluginServiceWebSocketDriver {
inner: Arc<Mutex<HashMap<String, PluginServiceWebSocketConnection>>>,
}
struct PluginServiceWebSocketConnection {
status: PluginServiceWebSocketConnectionStatus,
connection: Option<Arc<Mutex<Box<dyn PluginWebSocketConnection>>>>,
stop: Arc<AtomicBool>,
}
impl PluginServiceWebSocketDriver {
fn start_connection(
&self,
handle: PluginInstanceHandle,
client: Arc<dyn PluginWebSocketClient>,
subscription: PluginServiceWebSocketSubscription,
) {
if self.connection_count() >= PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS {
let message = format!(
"host-owned WebSocket connection limit ({}) exceeded for {}",
PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS, subscription.url
);
self.insert_failed_status(&subscription, message.clone());
handle.record_service_websocket_diagnostic(
PluginInstanceDiagnosticKind::ServiceWebSocketError,
message,
true,
);
return;
}
let request = PluginWebSocketOpenRequest {
url: subscription.url.clone(),
protocols: Vec::new(),
headers: Vec::new(),
};
self.insert_status(
&subscription,
PluginServiceWebSocketConnectionState::Connecting,
None,
);
let (request, url) = match validate_plugin_service_websocket_open_request(&handle, &request)
{
Ok(value) => value,
Err(message) => {
self.update_status_error(
&subscription.url,
PluginServiceWebSocketConnectionState::Failed,
message.clone(),
);
handle.record_service_websocket_diagnostic(
PluginInstanceDiagnosticKind::ServiceWebSocketError,
message,
true,
);
return;
}
};
if !client.supports_bounded_open() {
let message = "host-owned WebSocket client cannot guarantee bounded/cancellable open; refusing to dial".to_string();
self.update_status_error(
&subscription.url,
PluginServiceWebSocketConnectionState::Failed,
message.clone(),
);
handle.record_service_websocket_diagnostic(
PluginInstanceDiagnosticKind::ServiceWebSocketError,
message,
true,
);
return;
}
let connection = match client.open(&request, &url, PluginWebSocketLimits::default()) {
Ok(connection) => Arc::new(Mutex::new(connection)),
Err(error) => {
let message = format!(
"host-owned WebSocket open failed for {}: {}",
safe_url(&url),
error.0
);
self.update_status_error(
&subscription.url,
PluginServiceWebSocketConnectionState::Failed,
message.clone(),
);
handle.record_service_websocket_diagnostic(
PluginInstanceDiagnosticKind::ServiceWebSocketError,
message,
true,
);
return;
}
};
let stop = Arc::new(AtomicBool::new(false));
self.attach_connection(&subscription.url, connection.clone(), stop.clone());
self.update_status_connected(&subscription.url);
handle.record_service_websocket_diagnostic(
PluginInstanceDiagnosticKind::ServiceWebSocketConnected,
format!("host-owned WebSocket connected: {}", safe_url(&url)),
false,
);
let driver = self.clone();
std::thread::spawn(move || {
driver.reader_loop(handle, subscription, connection, stop);
});
}
fn reader_loop(
&self,
handle: PluginInstanceHandle,
subscription: PluginServiceWebSocketSubscription,
connection: Arc<Mutex<Box<dyn PluginWebSocketConnection>>>,
stop: Arc<AtomicBool>,
) {
while !stop.load(Ordering::SeqCst) {
let recv = {
let mut connection = connection
.lock()
.expect("service websocket connection poisoned");
connection.recv_text(
PLUGIN_SERVICE_WEBSOCKET_RECV_TIMEOUT,
PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES,
)
};
match recv {
Ok(PluginWebSocketRecvResponse::Text { text }) => {
self.record_frame(&subscription.url);
let event = PluginIngressEvent::new(
subscription.ingress_name.clone(),
"websocket_text",
subscription.source.clone(),
serde_json::json!({
"url": subscription.url,
"text": text,
}),
);
if let Err(error) = handle.deliver_ingress(&subscription.ingress_name, event) {
let message = error.bounded_message();
self.record_queue_drop(&subscription.url, message.clone());
handle.record_service_websocket_diagnostic(
error.diagnostic_kind(),
format!("host-owned WebSocket ingress drop: {message}"),
true,
);
}
}
Ok(PluginWebSocketRecvResponse::Closed) => {
let message = "host-owned WebSocket closed by peer".to_string();
self.update_status_error(
&subscription.url,
PluginServiceWebSocketConnectionState::Closed,
message.clone(),
);
handle.record_service_websocket_diagnostic(
PluginInstanceDiagnosticKind::ServiceWebSocketClosed,
message.clone(),
false,
);
let event = PluginIngressEvent::new(
subscription.ingress_name.clone(),
"websocket_close",
subscription.source.clone(),
serde_json::json!({"url": subscription.url, "reason": message}),
);
let _ = handle.deliver_ingress(&subscription.ingress_name, event);
break;
}
Err(error) if error.0.contains("timed out") => continue,
Err(error) => {
let message = format!("host-owned WebSocket receive failed: {}", error.0);
self.update_status_error(
&subscription.url,
PluginServiceWebSocketConnectionState::Failed,
message.clone(),
);
handle.record_service_websocket_diagnostic(
PluginInstanceDiagnosticKind::ServiceWebSocketError,
message.clone(),
true,
);
let event = PluginIngressEvent::new(
subscription.ingress_name.clone(),
"websocket_error",
subscription.source.clone(),
serde_json::json!({"url": subscription.url, "error": message}),
);
let _ = handle.deliver_ingress(&subscription.ingress_name, event);
break;
}
}
}
}
fn send_text(&self, url: &str, text: &str) -> Result<String, String> {
if text.len() > PLUGIN_WEBSOCKET_MAX_TEXT_BYTES {
return Err(format!(
"websocket_send text exceeds {} bytes",
PLUGIN_WEBSOCKET_MAX_TEXT_BYTES
));
}
let parsed =
reqwest::Url::parse(url).map_err(|error| format!("invalid WebSocket URL: {error}"))?;
let key = parsed.as_str().to_string();
let (display_url, connection) = {
let guard = self
.inner
.lock()
.expect("service websocket driver poisoned");
let entry = guard.get(&key).ok_or_else(|| {
format!(
"no host-owned WebSocket connection is active for {}",
safe_url(&parsed)
)
})?;
let Some(connection) = &entry.connection else {
return Err(format!(
"host-owned WebSocket connection is not connected for {}",
safe_url(&parsed)
));
};
(entry.status.url.clone(), connection.clone())
};
let send = connection
.lock()
.expect("service websocket connection poisoned")
.send_text(text);
match send {
Ok(()) => {
self.record_send_success(&key);
Ok(format!(
"websocket_send sent {} bytes to {display_url}",
text.len()
))
}
Err(error) => {
let message = format!("websocket_send failed for {display_url}: {}", error.0);
self.record_send_failure(&key, message.clone());
Err(message)
}
}
}
fn statuses(&self) -> Vec<PluginServiceWebSocketConnectionStatus> {
let mut statuses: Vec<_> = self
.inner
.lock()
.expect("service websocket driver poisoned")
.values()
.map(|entry| entry.status.clone())
.collect();
statuses.sort_by(|left, right| left.url.cmp(&right.url));
statuses
}
fn stop_all(&self) {
let connections: Vec<_> = {
let mut guard = self
.inner
.lock()
.expect("service websocket driver poisoned");
guard
.values_mut()
.filter_map(|entry| {
entry.stop.store(true, Ordering::SeqCst);
entry.status.state = PluginServiceWebSocketConnectionState::Closed;
entry.connection.clone()
})
.collect()
};
for connection in connections {
if let Ok(mut connection) = connection.lock() {
let _ = connection.close();
}
}
}
fn connection_count(&self) -> usize {
self.inner
.lock()
.expect("service websocket driver poisoned")
.len()
}
fn insert_status(
&self,
subscription: &PluginServiceWebSocketSubscription,
state: PluginServiceWebSocketConnectionState,
error: Option<String>,
) {
let url = reqwest::Url::parse(&subscription.url)
.map(|url| safe_url(&url))
.unwrap_or_else(|_| safe_fs_path(&subscription.url));
self.inner
.lock()
.expect("service websocket driver poisoned")
.insert(
subscription.url.clone(),
PluginServiceWebSocketConnection {
status: PluginServiceWebSocketConnectionStatus {
url,
ingress_name: subscription.ingress_name.clone(),
state,
last_frame_at: None,
last_error: error,
received_text_frames: 0,
sent_text_frames: 0,
queue_drops: 0,
send_failures: 0,
},
connection: None,
stop: Arc::new(AtomicBool::new(false)),
},
);
}
fn insert_failed_status(
&self,
subscription: &PluginServiceWebSocketSubscription,
error: String,
) {
self.insert_status(
subscription,
PluginServiceWebSocketConnectionState::Failed,
Some(error),
);
}
fn attach_connection(
&self,
key: &str,
connection: Arc<Mutex<Box<dyn PluginWebSocketConnection>>>,
stop: Arc<AtomicBool>,
) {
if let Some(entry) = self
.inner
.lock()
.expect("service websocket driver poisoned")
.get_mut(key)
{
entry.connection = Some(connection);
entry.stop = stop;
}
}
fn update_status_connected(&self, key: &str) {
if let Some(entry) = self
.inner
.lock()
.expect("service websocket driver poisoned")
.get_mut(key)
{
entry.status.state = PluginServiceWebSocketConnectionState::Connected;
entry.status.last_error = None;
}
}
fn update_status_error(
&self,
key: &str,
state: PluginServiceWebSocketConnectionState,
error: String,
) {
if let Some(entry) = self
.inner
.lock()
.expect("service websocket driver poisoned")
.get_mut(key)
{
entry.status.state = state;
entry.status.last_error = Some(bounded_message(redact_secret_like(&error)));
}
}
fn record_frame(&self, key: &str) {
if let Some(entry) = self
.inner
.lock()
.expect("service websocket driver poisoned")
.get_mut(key)
{
entry.status.last_frame_at =
Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
entry.status.received_text_frames += 1;
}
}
fn record_queue_drop(&self, key: &str, error: String) {
if let Some(entry) = self
.inner
.lock()
.expect("service websocket driver poisoned")
.get_mut(key)
{
entry.status.queue_drops += 1;
entry.status.last_error = Some(bounded_message(redact_secret_like(&error)));
}
}
fn record_send_success(&self, key: &str) {
if let Some(entry) = self
.inner
.lock()
.expect("service websocket driver poisoned")
.get_mut(key)
{
entry.status.sent_text_frames += 1;
}
}
fn record_send_failure(&self, key: &str, error: String) {
if let Some(entry) = self
.inner
.lock()
.expect("service websocket driver poisoned")
.get_mut(key)
{
entry.status.send_failures += 1;
entry.status.last_error = Some(bounded_message(redact_secret_like(&error)));
}
}
}
fn validate_plugin_service_websocket_open_request(
handle: &PluginInstanceHandle,
request: &PluginWebSocketOpenRequest,
) -> Result<(PluginWebSocketOpenRequest, reqwest::Url), String> {
let record = {
let instance = handle.0.lock().expect("plugin instance poisoned");
instance.record.clone()
};
authorize_plugin_host_api(&record, PluginHostApi::WebSocket)
.map_err(|error| format!("host-owned websocket not granted: {}", error.0))?;
let bytes = serde_json::to_vec(request)
.map_err(|error| format!("failed to encode host-owned websocket open request: {error}"))?;
validate_plugin_websocket_open_request(&record, &bytes).map_err(|error| error.0)
}
fn parse_plugin_service_websocket_source(source: &str) -> Option<Result<String, String>> {
let trimmed = source.trim();
let raw = if let Some(rest) = trimmed.strip_prefix("websocket:") {
rest.trim()
} else if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") {
trimmed
} else {
return None;
};
if raw.is_empty() {
return Some(Err("websocket source URL is empty".to_string()));
}
let parsed = match reqwest::Url::parse(raw) {
Ok(parsed) => parsed,
Err(error) => return Some(Err(format!("invalid WebSocket URL: {error}"))),
};
match parsed.scheme() {
"ws" | "wss" => Some(Ok(parsed.as_str().to_string())),
other => Some(Err(format!("unsupported WebSocket URL scheme: {other}"))),
}
}
#[derive(Clone)]
pub struct PluginInstanceHandle(Arc<Mutex<PluginInstance>>);
impl PluginInstanceHandle {
fn new(record: ResolvedPluginRecord) -> Result<Self, PluginWasmError> {
Self::new_with_service_websocket_client(record, Arc::new(TungstenitePluginWebSocketClient))
}
#[cfg(test)]
fn new_with_test_websocket_client(
record: ResolvedPluginRecord,
service_websocket_client: Arc<dyn PluginWebSocketClient>,
) -> Result<Self, PluginWasmError> {
Self::new_with_service_websocket_client(record, service_websocket_client)
}
fn new_with_service_websocket_client(
record: ResolvedPluginRecord,
service_websocket_client: Arc<dyn PluginWebSocketClient>,
) -> Result<Self, PluginWasmError> {
let runtime = PluginInstanceRuntime::new(&record)?;
let mut instance = PluginInstance {
record,
runtime,
lifecycle: PluginInstanceLifecycleState::Ready,
component_status: None,
ingress_queue: VecDeque::new(),
ingress_queue_capacity: PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY,
dispatch_counters: PluginIngressDispatchCounters::default(),
last_error: None,
output_command_results: Vec::new(),
service_websockets: PluginServiceWebSocketDriver::default(),
service_websocket_client,
diagnostics: Vec::new(),
};
instance.start()?;
let handle = Self(Arc::new(Mutex::new(instance)));
handle.start_service_websockets();
Ok(handle)
}
fn handle_tool(&self, tool_name: &str, input: Vec<u8>) -> Result<ToolOutput, PluginWasmError> {
self.0
.lock()
.expect("plugin instance poisoned")
.handle_tool(tool_name, input)
}
pub fn deliver_ingress(
&self,
ingress_name: &str,
event: PluginIngressEvent,
) -> Result<PluginIngressDispatchReport, PluginIngressDispatchError> {
let mut instance = self.0.lock().expect("plugin instance poisoned");
instance.enqueue_ingress(ingress_name, event)?;
instance.dispatch_next_ingress()
}
pub fn status(&self) -> PluginInstanceStatus {
let mut instance = self.0.lock().expect("plugin instance poisoned");
instance.status()
}
pub fn stop(&self) -> Result<PluginInstanceStatus, PluginWasmError> {
let mut instance = self.0.lock().expect("plugin instance poisoned");
instance.stop()?;
Ok(instance.snapshot_status())
}
fn record_diagnostic(&self, diagnostic: PluginInstanceDiagnostic) {
if let Ok(mut instance) = self.0.lock() {
instance.lifecycle = diagnostic.state.clone();
instance.last_error = Some(diagnostic.message.clone());
instance.diagnostics.push(diagnostic);
}
}
fn record_service_websocket_diagnostic(
&self,
kind: PluginInstanceDiagnosticKind,
message: impl Into<String>,
mark_error: bool,
) {
if let Ok(mut instance) = self.0.lock() {
let message = bounded_message(redact_secret_like(&message.into()));
if mark_error {
instance.last_error = Some(message.clone());
}
let state = instance.lifecycle.clone();
instance
.diagnostics
.push(PluginInstanceDiagnostic::with_kind(kind, state, message));
}
}
fn start_service_websockets(&self) {
let (driver, client, subscriptions, diagnostics) = {
let instance = self.0.lock().expect("plugin instance poisoned");
let (subscriptions, diagnostics) = instance.service_websocket_subscriptions();
let driver = instance.service_websockets.clone();
let client = instance.service_websocket_client.clone();
(driver, client, subscriptions, diagnostics)
};
for diagnostic in diagnostics {
self.record_service_websocket_diagnostic(
PluginInstanceDiagnosticKind::ServiceWebSocketError,
diagnostic,
true,
);
}
for subscription in subscriptions {
driver.start_connection(self.clone(), client.clone(), subscription);
}
}
}
struct PluginInstance {
record: ResolvedPluginRecord,
runtime: PluginInstanceRuntime,
lifecycle: PluginInstanceLifecycleState,
component_status: Option<Value>,
ingress_queue: VecDeque<QueuedPluginIngress>,
ingress_queue_capacity: usize,
dispatch_counters: PluginIngressDispatchCounters,
last_error: Option<String>,
output_command_results: Vec<PluginServiceOutputCommandResult>,
service_websockets: PluginServiceWebSocketDriver,
service_websocket_client: Arc<dyn PluginWebSocketClient>,
diagnostics: Vec<PluginInstanceDiagnostic>,
}
impl PluginInstance {
fn start(&mut self) -> Result<(), PluginWasmError> {
self.lifecycle = PluginInstanceLifecycleState::Starting;
let start_result = match &mut self.runtime {
PluginInstanceRuntime::ComponentToolAdapter => {
self.lifecycle = PluginInstanceLifecycleState::Ready;
self.diagnostics.push(PluginInstanceDiagnostic::new(
PluginInstanceLifecycleState::Ready,
"component tool runtime registered behind PluginInstanceRegistry",
));
Ok(())
}
#[cfg(test)]
PluginInstanceRuntime::TestIngress { .. } => {
self.lifecycle = PluginInstanceLifecycleState::Running;
self.diagnostics.push(PluginInstanceDiagnostic::new(
PluginInstanceLifecycleState::Running,
"test ingress runtime initialized",
));
Ok(())
}
PluginInstanceRuntime::ComponentInstance(runtime) => {
match runtime.start(&self.record) {
Ok(status) => {
self.component_status = Some(status);
self.lifecycle = PluginInstanceLifecycleState::Running;
self.diagnostics.push(PluginInstanceDiagnostic::new(
PluginInstanceLifecycleState::Running,
"component instance start returned; host-managed ingress queue is running",
));
Ok(())
}
Err(error) => Err(error),
}
}
};
if let Err(error) = start_result {
self.lifecycle = PluginInstanceLifecycleState::Failed;
self.record_runtime_error(
PluginInstanceDiagnosticKind::ServiceFailed,
format!("plugin component start failed: {}", error.bounded_message()),
);
return Err(error);
}
Ok(())
}
fn service_websocket_subscriptions(
&self,
) -> (Vec<PluginServiceWebSocketSubscription>, Vec<String>) {
if !surface_enabled(&self.record, PluginSurface::Service)
|| !surface_enabled(&self.record, PluginSurface::Ingress)
{
return (Vec::new(), Vec::new());
}
let mut subscriptions = Vec::new();
let mut diagnostics = Vec::new();
for ingress in &self.record.manifest.ingresses {
for source in &ingress.sources {
match parse_plugin_service_websocket_source(source) {
None => {}
Some(Ok(url)) => subscriptions.push(PluginServiceWebSocketSubscription {
ingress_name: ingress.name.clone(),
source: source.clone(),
url,
}),
Some(Err(message)) => diagnostics.push(format!(
"invalid WebSocket ingress source for {}: {message}",
ingress.name
)),
}
}
}
(subscriptions, diagnostics)
}
fn handle_tool(
&mut self,
tool_name: &str,
input: Vec<u8>,
) -> Result<ToolOutput, PluginWasmError> {
if !surface_enabled(&self.record, PluginSurface::Tool) {
return Err(PluginWasmError::Module(
"plugin tool surface is not enabled".to_string(),
));
}
let tool = self
.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(&self.record, tool).map_err(|error| {
PluginWasmError::Module(format!(
"plugin permission denied: {}",
error.bounded_message()
))
})?;
match &mut self.runtime {
PluginInstanceRuntime::ComponentToolAdapter => {
run_plugin_component_tool(self.record.clone(), tool_name.to_string(), input)
}
#[cfg(test)]
PluginInstanceRuntime::TestIngress { tool_calls, .. } => {
*tool_calls += 1;
Ok(ToolOutput {
summary: format!("{tool_name}: {tool_calls}"),
content: Some(String::from_utf8_lossy(&input).to_string()),
})
}
PluginInstanceRuntime::ComponentInstance(runtime) => {
runtime.handle_tool(tool_name, input)
}
}
}
fn enqueue_ingress(
&mut self,
ingress_name: &str,
event: PluginIngressEvent,
) -> Result<(), PluginIngressDispatchError> {
self.validate_ingress_event(ingress_name, &event)
.map_err(|error| self.record_rejection(error))?;
if self.ingress_queue.len() >= self.ingress_queue_capacity {
let error = PluginIngressDispatchError::QueueFull {
capacity: self.ingress_queue_capacity,
};
return Err(self.record_rejection(error));
}
self.ingress_queue.push_back(QueuedPluginIngress {
ingress_name: ingress_name.to_string(),
event,
enqueued_at: Instant::now(),
});
self.dispatch_counters.enqueued += 1;
Ok(())
}
fn validate_ingress_event(
&self,
ingress_name: &str,
event: &PluginIngressEvent,
) -> Result<(), PluginIngressDispatchError> {
if !surface_enabled(&self.record, PluginSurface::Ingress) {
return Err(PluginIngressDispatchError::InvalidEvent(
"plugin ingress surface is not enabled".to_string(),
));
}
match self.lifecycle {
PluginInstanceLifecycleState::Running => {}
PluginInstanceLifecycleState::Failed => {
return Err(PluginIngressDispatchError::ServiceFailed(
self.last_error
.clone()
.unwrap_or_else(|| "service is failed".to_string()),
));
}
PluginInstanceLifecycleState::Stopping | PluginInstanceLifecycleState::Stopped => {
return Err(PluginIngressDispatchError::ServiceStopped {
state: self.lifecycle.clone(),
});
}
_ => {
return Err(PluginIngressDispatchError::ServiceUnavailable {
state: self.lifecycle.clone(),
});
}
}
if event.source.trim().is_empty() {
return Err(PluginIngressDispatchError::InvalidEvent(
"source must not be empty".to_string(),
));
}
if event.ingress_name != ingress_name {
return Err(PluginIngressDispatchError::InvalidEvent(format!(
"event ingress `{}` does not match dispatch ingress `{ingress_name}`",
event.ingress_name
)));
}
if event.kind.trim().is_empty() {
return Err(PluginIngressDispatchError::InvalidEvent(
"event kind must not be empty".to_string(),
));
}
if event.created_at.trim().is_empty() {
return Err(PluginIngressDispatchError::InvalidEvent(
"created_at must not be empty".to_string(),
));
}
if event.attempt == 0 {
return Err(PluginIngressDispatchError::InvalidEvent(
"attempt must be greater than zero".to_string(),
));
}
if event.correlation_id.trim().is_empty() {
return Err(PluginIngressDispatchError::InvalidEvent(
"correlation_id must not be empty".to_string(),
));
}
if serde_json::to_vec(event)
.map(|bytes| bytes.len())
.unwrap_or(usize::MAX)
> PLUGIN_WASM_MAX_INPUT_BYTES
{
return Err(PluginIngressDispatchError::InvalidEvent(format!(
"plugin ingress event exceeds {} bytes",
PLUGIN_WASM_MAX_INPUT_BYTES
)));
}
let ingress = self
.record
.manifest
.ingresses
.iter()
.find(|ingress| ingress.name == ingress_name)
.ok_or_else(|| {
PluginIngressDispatchError::InvalidEvent(
"requested ingress is not declared by plugin manifest".to_string(),
)
})?;
if !ingress.event_kinds.is_empty()
&& !ingress.event_kinds.iter().any(|kind| kind == &event.kind)
{
return Err(PluginIngressDispatchError::InvalidEvent(format!(
"event kind `{}` is not declared for ingress `{ingress_name}`",
event.kind
)));
}
authorize_plugin_ingress(&self.record, ingress_name).map_err(|error| {
PluginIngressDispatchError::InvalidEvent(format!(
"plugin ingress permission denied: {}",
error.bounded_message()
))
})?;
Ok(())
}
fn dispatch_next_ingress(
&mut self,
) -> Result<PluginIngressDispatchReport, PluginIngressDispatchError> {
let Some(queued) = self.ingress_queue.pop_front() else {
return Err(PluginIngressDispatchError::InvalidEvent(
"plugin ingress queue is empty".to_string(),
));
};
let started_at = Instant::now();
let result = self.dispatch_ingress_now(&queued.ingress_name, queued.event);
let elapsed = started_at.elapsed();
if elapsed > PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT {
let error = PluginIngressDispatchError::DispatchTimeout {
timeout: PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT,
};
self.dispatch_counters.timed_out += 1;
self.dispatch_counters.failed += 1;
self.lifecycle = PluginInstanceLifecycleState::Failed;
self.record_dispatch_error(&error);
return Err(error);
}
match result {
Ok(mut report) => {
let queue_latency_ms = queued.enqueued_at.elapsed().as_millis() as u64;
if report.output.get("queue_latency_ms").is_none() {
if let Some(map) = report.output.as_object_mut() {
map.insert(
"queue_latency_ms".to_string(),
Value::from(queue_latency_ms),
);
}
}
self.dispatch_counters.dispatched += 1;
report.queue_depth = self.ingress_queue.len();
report.dispatch_counters = self.dispatch_counters.clone();
report.diagnostics = self.diagnostics.clone();
Ok(report)
}
Err(error) => {
self.dispatch_counters.failed += 1;
self.lifecycle = PluginInstanceLifecycleState::Failed;
let dispatch_error =
PluginIngressDispatchError::DispatchFailed(error.bounded_message());
self.record_dispatch_error(&dispatch_error);
Err(dispatch_error)
}
}
}
fn dispatch_ingress_now(
&mut self,
ingress_name: &str,
event: PluginIngressEvent,
) -> Result<PluginIngressDispatchReport, PluginWasmError> {
let output = match &mut self.runtime {
PluginInstanceRuntime::ComponentToolAdapter => {
return Err(PluginWasmError::Module(
"component tool runtime does not expose ingress dispatch".to_string(),
));
}
#[cfg(test)]
PluginInstanceRuntime::TestIngress {
tool_calls,
ingress_calls,
} => {
if let Some(sleep_ms) = event.payload.get("sleep_ms").and_then(Value::as_u64) {
std::thread::sleep(Duration::from_millis(sleep_ms));
}
if event.payload.get("fail").and_then(Value::as_bool) == Some(true) {
return Err(PluginWasmError::Execution(
"test ingress requested failure".to_string(),
));
}
*ingress_calls += 1;
let mut output = serde_json::json!({
"ingress": ingress_name,
"kind": event.kind.clone(),
"source": event.source.clone(),
"ingress_name": event.ingress_name.clone(),
"attempt": event.attempt,
"correlation_id": event.correlation_id.clone(),
"calls": *tool_calls,
"ingress_calls": *ingress_calls,
"payload": event.payload.clone(),
});
if let (Some(map), Some(commands)) = (
output.as_object_mut(),
event.payload.get("output_commands").cloned(),
) {
map.insert("output_commands".to_string(), commands);
}
output
}
PluginInstanceRuntime::ComponentInstance(runtime) => {
runtime.handle_ingress(ingress_name, &event)?
}
};
let output_command_results = self.process_service_output_commands(&output, &event);
Ok(PluginIngressDispatchReport {
plugin_ref: self.record.identity.to_string(),
ingress: ingress_name.to_string(),
accepted: true,
output,
output_command_results,
queue_depth: self.ingress_queue.len(),
dispatch_counters: self.dispatch_counters.clone(),
diagnostics: self.diagnostics.clone(),
})
}
fn stop(&mut self) -> Result<(), PluginWasmError> {
if self.lifecycle == PluginInstanceLifecycleState::Stopped {
return Ok(());
}
self.lifecycle = PluginInstanceLifecycleState::Stopping;
self.diagnostics.push(PluginInstanceDiagnostic::new(
PluginInstanceLifecycleState::Stopping,
"plugin service stop requested; ingress queue is closed",
));
self.ingress_queue.clear();
self.service_websockets.stop_all();
let stop_result = match &mut self.runtime {
PluginInstanceRuntime::ComponentToolAdapter => Ok(()),
#[cfg(test)]
PluginInstanceRuntime::TestIngress { .. } => Ok(()),
PluginInstanceRuntime::ComponentInstance(runtime) => match runtime.stop() {
Ok(status) => {
self.component_status = Some(status);
Ok(())
}
Err(error) => Err(error),
},
};
if let Err(error) = stop_result {
self.lifecycle = PluginInstanceLifecycleState::Failed;
self.record_runtime_error(
PluginInstanceDiagnosticKind::ServiceFailed,
format!("plugin component stop failed: {}", error.bounded_message()),
);
return Err(error);
}
self.lifecycle = PluginInstanceLifecycleState::Stopped;
self.diagnostics.push(PluginInstanceDiagnostic::new(
PluginInstanceLifecycleState::Stopped,
"plugin service stopped",
));
Ok(())
}
fn snapshot_status(&self) -> PluginInstanceStatus {
PluginInstanceStatus {
plugin_ref: self.record.identity.to_string(),
lifecycle: self.lifecycle.clone(),
component_status: self.component_status.clone(),
queue_depth: self.ingress_queue.len(),
queue_capacity: self.ingress_queue_capacity,
last_error: self.last_error.clone(),
dispatch_counters: self.dispatch_counters.clone(),
output_command_results: self.output_command_results.clone(),
websocket_connections: self.service_websockets.statuses(),
diagnostics: self.diagnostics.clone(),
}
}
fn status(&mut self) -> PluginInstanceStatus {
if self.lifecycle == PluginInstanceLifecycleState::Running {
if let PluginInstanceRuntime::ComponentInstance(runtime) = &mut self.runtime {
match runtime.status() {
Ok(status) => self.component_status = Some(status),
Err(error) => {
self.lifecycle = PluginInstanceLifecycleState::Failed;
self.record_runtime_error(
PluginInstanceDiagnosticKind::ServiceFailed,
format!(
"plugin component status failed: {}",
error.bounded_message()
),
);
}
}
}
}
self.snapshot_status()
}
fn record_rejection(
&mut self,
error: PluginIngressDispatchError,
) -> PluginIngressDispatchError {
self.dispatch_counters.rejected += 1;
self.record_dispatch_error(&error);
error
}
fn record_dispatch_error(&mut self, error: &PluginIngressDispatchError) {
let state = match error {
PluginIngressDispatchError::DispatchFailed(_)
| PluginIngressDispatchError::DispatchTimeout { .. }
| PluginIngressDispatchError::ServiceFailed(_) => PluginInstanceLifecycleState::Failed,
PluginIngressDispatchError::ServiceStopped { .. } => self.lifecycle.clone(),
_ => self.lifecycle.clone(),
};
self.record_runtime_error(error.diagnostic_kind(), error.bounded_message());
if matches!(state, PluginInstanceLifecycleState::Failed) {
self.lifecycle = PluginInstanceLifecycleState::Failed;
}
}
fn process_service_output_commands(
&mut self,
output: &Value,
event: &PluginIngressEvent,
) -> Vec<PluginServiceOutputCommandResult> {
let results = match self.validate_service_output_commands(output, event) {
Ok(commands) => {
let mut results = Vec::with_capacity(commands.len());
for command in commands {
results.push(self.execute_service_output_command(command));
}
results
}
Err(results) => results,
};
self.record_service_output_command_results(&results);
results
}
fn validate_service_output_commands(
&self,
output: &Value,
event: &PluginIngressEvent,
) -> Result<Vec<PluginServiceOutputCommandEnvelope>, Vec<PluginServiceOutputCommandResult>>
{
let Some(values) = output.get("output_commands") else {
return Ok(Vec::new());
};
let Some(values) = values.as_array() else {
return Err(vec![PluginServiceOutputCommandResult::rejected(
"service output_commands must be an array",
)]);
};
if values.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT {
return Err(vec![PluginServiceOutputCommandResult::rejected(format!(
"service output_commands exceeds {} commands",
PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT
))]);
}
let mut commands = Vec::with_capacity(values.len());
let mut rejected = Vec::new();
for value in values {
let command: PluginServiceOutputCommandEnvelope =
match serde_json::from_value(value.clone()) {
Ok(command) => command,
Err(error) => {
rejected.push(PluginServiceOutputCommandResult::rejected(format!(
"malformed service output command envelope: {error}"
)));
continue;
}
};
if let Err(message) = self.validate_service_output_command_envelope(&command, event) {
rejected.push(PluginServiceOutputCommandResult::rejected_for(
&command, message,
));
continue;
}
if let Err(message) = self.grant_check_service_output_command(&command) {
rejected.push(PluginServiceOutputCommandResult::rejected_for(
&command, message,
));
continue;
}
commands.push(command);
}
if rejected.is_empty() {
Ok(commands)
} else {
Err(rejected)
}
}
fn validate_service_output_command_envelope(
&self,
command: &PluginServiceOutputCommandEnvelope,
event: &PluginIngressEvent,
) -> Result<(), String> {
validate_output_command_id("correlation_id", &command.correlation_id)?;
validate_output_command_id("source_event_id", &command.source_event_id)?;
validate_output_command_id("command_id", &command.command_id)?;
if command.source_event_id != event.correlation_id {
return Err("source_event_id must match the ingress event correlation_id".to_string());
}
chrono::DateTime::parse_from_rfc3339(&command.requested_at)
.map_err(|error| format!("requested_at must be RFC3339: {error}"))?;
let payload_bytes = serde_json::to_vec(&command.payload)
.map_err(|error| format!("payload is not serializable JSON: {error}"))?;
if payload_bytes.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES {
return Err(format!(
"payload exceeds {} bytes",
PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES
));
}
match command.kind {
PluginServiceOutputCommandKind::DiagnosticStatusUpdate => {
serde_json::from_value::<PluginServiceDiagnosticStatusCommandPayload>(
command.payload.clone(),
)
.map_err(|error| format!("invalid diagnostic_status_update payload: {error}"))?;
}
PluginServiceOutputCommandKind::HostRequestDispatch => {
let request: PluginRequestRequest = serde_json::from_value(command.payload.clone())
.map_err(|error| format!("invalid host_request_dispatch payload: {error}"))?;
validate_plugin_request_request(&self.record, &request)
.map_err(|error| format!("host_request_dispatch target denied: {}", error.0))?;
}
PluginServiceOutputCommandKind::WebSocketSend => {
let payload: PluginServiceWebSocketSendCommandPayload =
serde_json::from_value(command.payload.clone())
.map_err(|error| format!("invalid websocket_send payload: {error}"))?;
self.validate_service_websocket_send_payload(&payload)?;
}
}
Ok(())
}
fn grant_check_service_output_command(
&self,
command: &PluginServiceOutputCommandEnvelope,
) -> Result<(), String> {
match command.kind {
PluginServiceOutputCommandKind::DiagnosticStatusUpdate => Ok(()),
PluginServiceOutputCommandKind::HostRequestDispatch => {
authorize_plugin_host_api(&self.record, PluginHostApi::Request)
.map_err(|error| format!("host_request_dispatch not granted: {}", error.0))
}
PluginServiceOutputCommandKind::WebSocketSend => {
authorize_plugin_host_api(&self.record, PluginHostApi::WebSocket)
.map_err(|error| format!("websocket_send not granted: {}", error.0))
}
}
}
fn validate_service_websocket_send_payload(
&self,
payload: &PluginServiceWebSocketSendCommandPayload,
) -> Result<(), String> {
if payload.text.len() > PLUGIN_WEBSOCKET_MAX_TEXT_BYTES {
return Err(format!(
"websocket_send text exceeds {} bytes",
PLUGIN_WEBSOCKET_MAX_TEXT_BYTES
));
}
let url = reqwest::Url::parse(&payload.url)
.map_err(|error| format!("invalid WebSocket URL: {error}"))?;
match url.scheme() {
"ws" | "wss" => {}
"http" | "https" => {
return Err("HTTP URLs are not supported by websocket_send".to_string());
}
scheme => {
return Err(format!(
"unsupported WebSocket URL scheme {scheme:?}; only ws and wss are allowed"
));
}
}
if url.host_str().is_none() {
return Err("WebSocket URL must include a host".to_string());
}
if !url.username().is_empty() || url.password().is_some() {
return Err("WebSocket URLs with embedded credentials are not allowed".to_string());
}
validate_static_request_target(&url).map_err(|error| error.0)?;
authorize_websocket_allowlist(&self.record, &url).map_err(|error| error.0)?;
Ok(())
}
fn execute_service_output_command(
&mut self,
command: PluginServiceOutputCommandEnvelope,
) -> PluginServiceOutputCommandResult {
match command.kind {
PluginServiceOutputCommandKind::DiagnosticStatusUpdate => {
let payload: PluginServiceDiagnosticStatusCommandPayload =
match serde_json::from_value(command.payload.clone()) {
Ok(payload) => payload,
Err(error) => {
return PluginServiceOutputCommandResult::rejected_for(
&command,
format!("invalid diagnostic_status_update payload: {error}"),
);
}
};
if let Some(status) = payload.status {
self.component_status = Some(status);
}
let message = payload
.message
.as_deref()
.map(bounded_message)
.unwrap_or_else(|| "plugin service status update recorded".to_string());
PluginServiceOutputCommandResult::recorded(&command, message)
}
PluginServiceOutputCommandKind::HostRequestDispatch => {
PluginServiceOutputCommandResult::unsupported(
&command,
"host_request_dispatch output command is grant-checked but transport dispatch is unsupported in v0",
)
}
PluginServiceOutputCommandKind::WebSocketSend => {
let payload: PluginServiceWebSocketSendCommandPayload =
match serde_json::from_value(command.payload.clone()) {
Ok(payload) => payload,
Err(error) => {
return PluginServiceOutputCommandResult::rejected_for(
&command,
format!("invalid websocket_send payload: {error}"),
);
}
};
match self
.service_websockets
.send_text(&payload.url, &payload.text)
{
Ok(message) => PluginServiceOutputCommandResult::recorded(&command, message),
Err(message) => {
self.diagnostics.push(PluginInstanceDiagnostic::with_kind(
PluginInstanceDiagnosticKind::ServiceWebSocketSendFailed,
self.lifecycle.clone(),
bounded_message(redact_secret_like(&message)),
));
PluginServiceOutputCommandResult::rejected_for(&command, message)
}
}
}
}
}
fn record_service_output_command_results(
&mut self,
results: &[PluginServiceOutputCommandResult],
) {
if results.is_empty() {
return;
}
for result in results {
let kind = match result.status {
PluginServiceOutputCommandStatus::Recorded => {
PluginInstanceDiagnosticKind::ServiceOutputCommandRecorded
}
PluginServiceOutputCommandStatus::Rejected => {
PluginInstanceDiagnosticKind::ServiceOutputCommandRejected
}
PluginServiceOutputCommandStatus::Unsupported => {
PluginInstanceDiagnosticKind::ServiceOutputCommandUnsupported
}
};
let command_label = result.command_id.as_deref().unwrap_or("<malformed>");
let command_kind = result
.kind
.map(PluginServiceOutputCommandKind::as_str)
.unwrap_or("unknown");
let message = bounded_message(format!(
"service output command {command_label} ({command_kind}): {}",
result.message
));
if !matches!(result.status, PluginServiceOutputCommandStatus::Recorded) {
self.last_error = Some(message.clone());
}
self.diagnostics.push(PluginInstanceDiagnostic::with_kind(
kind,
self.lifecycle.clone(),
message,
));
}
self.output_command_results.extend_from_slice(results);
if self.output_command_results.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS {
let keep_from =
self.output_command_results.len() - PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS;
self.output_command_results.drain(0..keep_from);
}
}
fn record_runtime_error(
&mut self,
kind: PluginInstanceDiagnosticKind,
message: impl Into<String>,
) {
let message = bounded_message(redact_secret_like(&message.into()));
self.last_error = Some(message.clone());
self.diagnostics.push(PluginInstanceDiagnostic::with_kind(
kind,
self.lifecycle.clone(),
message,
));
}
}
enum PluginInstanceRuntime {
ComponentToolAdapter,
#[cfg(test)]
TestIngress {
tool_calls: u64,
ingress_calls: u64,
},
ComponentInstance(PluginComponentInstanceRuntime),
}
impl PluginInstanceRuntime {
fn new(record: &ResolvedPluginRecord) -> Result<Self, PluginWasmError> {
let Some(runtime) = record.manifest.runtime.as_ref() else {
return Err(PluginWasmError::Module(
"plugin runtime is not declared".to_string(),
));
};
match runtime.kind.as_str() {
#[cfg(test)]
"test-ingress" => Ok(Self::TestIngress {
tool_calls: 0,
ingress_calls: 0,
}),
PLUGIN_RUNTIME_COMPONENT_KIND
if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) =>
{
Ok(Self::ComponentInstance(
PluginComponentInstanceRuntime::instantiate(record)?,
))
}
PLUGIN_RUNTIME_COMPONENT_KIND
if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD) =>
{
Ok(Self::ComponentToolAdapter)
}
PLUGIN_RUNTIME_COMPONENT_KIND => Err(PluginWasmError::Module(
"unsupported or missing plugin component world".to_string(),
)),
LEGACY_PLUGIN_RUNTIME_WASM_KIND => Err(PluginWasmError::Module(
"legacy raw wasm plugin runtime is not supported; use wasm-component".to_string(),
)),
other => Err(PluginWasmError::Module(format!(
"unsupported plugin runtime kind `{other}`"
))),
}
}
}
struct PluginComponentInstanceRuntime {
store: wasmtime::Store<PluginComponentHostState>,
instance: wasmtime::component::Instance,
}
impl PluginComponentInstanceRuntime {
fn instantiate(record: &ResolvedPluginRecord) -> Result<Self, PluginWasmError> {
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(),
request_client: Arc::new(ReqwestPluginRequestClient),
websocket_client: Arc::new(TungstenitePluginWebSocketClient),
websocket_handles: PluginWebSocketHandles::default(),
store_limits: wasm_component_store_limits(),
},
);
store.limiter(|state| &mut state.store_limits);
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()))?;
Ok(Self { store, instance })
}
fn reset_fuel(&mut self) -> Result<(), PluginWasmError> {
self.store
.set_fuel(PLUGIN_WASM_FUEL)
.map_err(|error| PluginWasmError::Execution(error.to_string()))
}
fn start(&mut self, record: &ResolvedPluginRecord) -> Result<Value, PluginWasmError> {
self.reset_fuel()?;
let start = self
.instance
.get_typed_func::<(&str,), (String,)>(&mut self.store, "start")
.map_err(|error| {
PluginWasmError::Module(format!(
"component does not export expected `{}` start function: {error}",
PLUGIN_COMPONENT_INSTANCE_WORLD
))
})?;
let config_json = plugin_config_json(record);
let (status,) = start
.call(&mut self.store, (&config_json,))
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
decode_plugin_lifecycle_output("start", &status)
}
fn handle_tool(
&mut self,
tool_name: &str,
input: Vec<u8>,
) -> Result<ToolOutput, PluginWasmError> {
self.reset_fuel()?;
let call = self
.instance
.get_typed_func::<(&str, &str), (String,)>(&mut self.store, "handle-tool")
.map_err(|error| {
PluginWasmError::Module(format!(
"component does not export expected `{}` handle-tool function: {error}",
PLUGIN_COMPONENT_INSTANCE_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 self.store, (tool_name, input_json))
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
decode_plugin_wasm_output(output.as_bytes())
}
fn handle_ingress(
&mut self,
ingress_name: &str,
event: &PluginIngressEvent,
) -> Result<Value, PluginWasmError> {
self.reset_fuel()?;
let call = self
.instance
.get_typed_func::<(&str, &str), (String,)>(&mut self.store, "handle-ingress")
.map_err(|error| {
PluginWasmError::Module(format!(
"component does not export expected `{}` handle-ingress function: {error}",
PLUGIN_COMPONENT_INSTANCE_WORLD
))
})?;
let event_json = serde_json::to_string(event)
.map_err(|error| PluginWasmError::Output(error.to_string()))?;
let (output,) = call
.call(&mut self.store, (ingress_name, event_json.as_str()))
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
if output.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES {
return Err(PluginWasmError::Output(format!(
"plugin ingress output exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
)));
}
serde_json::from_str(&output).map_err(|error| {
PluginWasmError::Output(format!("plugin ingress output is not JSON: {error}"))
})
}
fn stop(&mut self) -> Result<Value, PluginWasmError> {
self.reset_fuel()?;
let stop = self
.instance
.get_typed_func::<(), (String,)>(&mut self.store, "stop")
.map_err(|error| {
PluginWasmError::Module(format!(
"component does not export expected `{}` stop function: {error}",
PLUGIN_COMPONENT_INSTANCE_WORLD
))
})?;
let (status,) = stop
.call(&mut self.store, ())
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
self.store.data().websocket_handles.close_all();
decode_plugin_lifecycle_output("stop", &status)
}
fn status(&mut self) -> Result<Value, PluginWasmError> {
self.reset_fuel()?;
let status = self
.instance
.get_typed_func::<(), (String,)>(&mut self.store, "status")
.map_err(|error| {
PluginWasmError::Module(format!(
"component does not export expected `{}` status function: {error}",
PLUGIN_COMPONENT_INSTANCE_WORLD
))
})?;
let (status,) = status
.call(&mut self.store, ())
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
decode_plugin_lifecycle_output("status", &status)
}
}
fn decode_plugin_lifecycle_output(phase: &str, output: &str) -> Result<Value, PluginWasmError> {
if output.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES {
return Err(PluginWasmError::Output(format!(
"plugin component {phase} output exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
)));
}
let value: Value = serde_json::from_str(output).map_err(|error| {
PluginWasmError::Output(format!(
"plugin component {phase} output is not JSON: {error}"
))
})?;
if let Some(error) = value.get("error") {
return Err(PluginWasmError::Execution(format!(
"plugin component {phase} returned error: {}",
bounded_message(error.to_string())
)));
}
if value.get("state").and_then(Value::as_str) == Some("failed") {
return Err(PluginWasmError::Execution(format!(
"plugin component {phase} returned failed status: {}",
bounded_message(value.to_string())
)));
}
Ok(value)
}
fn plugin_config_json(record: &ResolvedPluginRecord) -> String {
serde_json::to_string(&record.config).unwrap_or_else(|_| "{}".to_string())
}
fn plugin_instance_tool_definition(
instance: PluginInstanceHandle,
name: String,
description: String,
input_schema: Value,
) -> ToolDefinition {
let origin = {
let guard = instance.0.lock().expect("plugin instance poisoned");
plugin_tool_origin(&guard.record)
};
Arc::new(move || {
(
ToolMeta::new(name.clone())
.description(description.clone())
.input_schema(input_schema.clone())
.origin(origin.clone()),
Arc::new(PluginInstanceTool {
instance: instance.clone(),
name: name.clone(),
origin: origin.clone(),
}) as Arc<dyn Tool>,
)
})
}
struct PluginInstanceTool {
instance: PluginInstanceHandle,
name: String,
origin: ToolOrigin,
}
#[async_trait]
impl Tool for PluginInstanceTool {
async fn execute(
&self,
input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
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 instance = self.instance.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 || instance.handle_tool(&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 tool `{}` from `{}` (digest {}) failed closed: {}",
self.name,
plugin_ref,
digest,
error.bounded_message()
))),
Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!(
"plugin tool `{}` from `{}` (digest {}) cancelled/failed to join: {}",
self.name,
plugin_ref,
digest,
bounded_message(error.to_string())
))),
Err(_) => {
self.instance
.record_diagnostic(PluginInstanceDiagnostic::new(
PluginInstanceLifecycleState::Failed,
format!("plugin tool timed out after {:?}", PLUGIN_WASM_TIMEOUT),
));
Err(ToolError::ExecutionFailed(format!(
"plugin tool `{}` from `{}` (digest {}) timed out after {:?}",
self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT
)))
}
}
}
}
#[derive(Debug)]
pub 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(Clone)]
struct PluginComponentHostState {
record: ResolvedPluginRecord,
request_client: Arc<dyn PluginRequestClient>,
websocket_client: Arc<dyn PluginWebSocketClient>,
websocket_handles: PluginWebSocketHandles,
store_limits: wasmtime::StoreLimits,
}
fn run_plugin_component_tool(
record: ResolvedPluginRecord,
tool_name: String,
input: Vec<u8>,
) -> Result<ToolOutput, PluginWasmError> {
run_plugin_component_tool_with_request_client(
record,
tool_name,
input,
Arc::new(ReqwestPluginRequestClient),
)
}
fn run_plugin_component_tool_with_request_client(
record: ResolvedPluginRecord,
tool_name: String,
input: Vec<u8>,
request_client: Arc<dyn PluginRequestClient>,
) -> 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(),
request_client,
websocket_client: Arc::new(TungstenitePluginWebSocketClient),
websocket_handles: PluginWebSocketHandles::default(),
store_limits: wasm_component_store_limits(),
},
);
store.limiter(|state| &mut state.store_limits);
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}"))
})?;
// Wasmtime lifts the returned WIT `string` into a host `String` before the
// ordinary ToolOutput JSON cap can be applied. Keep the component store on
// the same memory/table/instance limits as the raw WASM runtime so an
// untrusted component can only force host string allocation from bounded
// component memory; oversized memories/tables/instances fail closed during
// instantiation/growth before this lift succeeds.
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/request@1.0.0" => {
authorize_plugin_host_api(record, PluginHostApi::Request).map_err(|error| {
PluginWasmError::Module(format!(
"plugin host API dispatch denied: {}",
error.bounded_message()
))
})?;
}
"yoi:host/websocket@1.0.0" => {
authorize_plugin_host_api(record, PluginHostApi::WebSocket).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/request@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::Request)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
let response = execute_plugin_request_request(
&store.data().record,
store.data().request_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 websocket = root
.instance("yoi:host/websocket@1.0.0")
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
websocket
.func_wrap(
"open",
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
(request,): (String,)|
-> wasmtime::Result<(String,)> {
authorize_plugin_host_api(&store.data().record, PluginHostApi::WebSocket)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
execute_plugin_websocket_open(
&store.data().record,
store.data().websocket_client.as_ref(),
&store.data().websocket_handles,
request.as_bytes(),
)
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
.map_err(|error| wasmtime::Error::msg(error.0))
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
websocket
.func_wrap(
"send-text",
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
(handle, text): (u32, String)|
-> wasmtime::Result<(String,)> {
authorize_plugin_host_api(&store.data().record, PluginHostApi::WebSocket)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
execute_plugin_websocket_send_text(
&store.data().websocket_handles,
handle,
text.as_bytes(),
)
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
.map_err(|error| wasmtime::Error::msg(error.0))
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
websocket
.func_wrap(
"recv",
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
(handle, timeout_ms): (u32, u32)|
-> wasmtime::Result<(String,)> {
authorize_plugin_host_api(&store.data().record, PluginHostApi::WebSocket)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
execute_plugin_websocket_recv(&store.data().websocket_handles, handle, timeout_ms)
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
.map_err(|error| wasmtime::Error::msg(error.0))
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
websocket
.func_wrap(
"close",
|store: wasmtime::StoreContextMut<'_, PluginComponentHostState>,
(handle,): (u32,)|
-> wasmtime::Result<(String,)> {
authorize_plugin_host_api(&store.data().record, PluginHostApi::WebSocket)
.map_err(|error| wasmtime::Error::msg(error.bounded_message()))?;
execute_plugin_websocket_close(&store.data().websocket_handles, handle)
.map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),))
.map_err(|error| wasmtime::Error::msg(error.0))
},
)
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
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 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_output_command_id(field: &str, value: &str) -> Result<(), String> {
if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
return Err(format!(
"{field} is empty, too long, or contains control characters"
));
}
Ok(())
}
fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> {
let mut seen = HashSet::new();
for tool in &record.manifest.tools {
if !seen.insert(tool.name.as_str()) {
return Err(FeatureInstallError::DuplicateToolName {
tool: tool.name.clone(),
first_feature: format!("{} (same plugin package)", record.identity),
duplicate_feature: record.identity.to_string(),
});
}
}
Ok(())
}
fn validate_tool_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("name must not be empty");
}
if name.len() > 128 {
return Err("name is longer than 128 bytes");
}
if name.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err("name must not contain whitespace or control characters");
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SupportedSchemaType {
Object,
Array,
String,
Number,
Integer,
Boolean,
Null,
}
impl SupportedSchemaType {
fn parse(value: &str) -> Option<Self> {
match value {
"object" => Some(Self::Object),
"array" => Some(Self::Array),
"string" => Some(Self::String),
"number" => Some(Self::Number),
"integer" => Some(Self::Integer),
"boolean" => Some(Self::Boolean),
"null" => Some(Self::Null),
_ => None,
}
}
}
fn validate_input_schema(schema: &Value) -> Result<(), String> {
let ty = validate_schema_node(schema, "$", true)?;
if ty != SupportedSchemaType::Object {
return Err("root schema type must be `object`".into());
}
Ok(())
}
fn validate_schema_node(
schema: &Value,
path: &str,
root: bool,
) -> Result<SupportedSchemaType, String> {
let Value::Object(map) = schema else {
return Err(format!("{path}: schema node must be a JSON object"));
};
for key in map.keys() {
if !is_supported_schema_keyword(key) {
return Err(format!("{path}: unsupported schema keyword `{key}`"));
}
}
let ty = match map.get("type") {
Some(Value::String(value)) => SupportedSchemaType::parse(value)
.ok_or_else(|| format!("{path}: unsupported schema type `{value}`"))?,
Some(_) => return Err(format!("{path}: type must be a string")),
None if root => return Err("root schema must declare type = `object`".into()),
None => return Err(format!("{path}: schema node must declare type")),
};
if let Some(title) = map.get("title") {
if !title.is_string() {
return Err(format!("{path}: title must be a string"));
}
}
if let Some(description) = map.get("description") {
if !description.is_string() {
return Err(format!("{path}: description must be a string"));
}
}
let properties = map.get("properties");
if let Some(properties) = properties {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: properties is only supported for object schemas"
));
}
let Some(properties) = properties.as_object() else {
return Err(format!("{path}: properties must be a JSON object"));
};
for (name, child_schema) in properties {
validate_schema_node(child_schema, &format!("{path}.properties.{name}"), false)?;
}
}
if let Some(required) = map.get("required") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: required is only supported for object schemas"
));
}
let Some(required) = required.as_array() else {
return Err(format!("{path}: required must be an array"));
};
let mut seen = HashSet::new();
for entry in required {
let Some(name) = entry.as_str() else {
return Err(format!("{path}: required entries must be strings"));
};
if !seen.insert(name) {
return Err(format!("{path}: required entries must be unique"));
}
if let Some(properties) = properties.and_then(Value::as_object) {
if !properties.contains_key(name) {
return Err(format!(
"{path}: required entry `{name}` is not declared in properties"
));
}
}
}
}
if let Some(additional) = map.get("additionalProperties") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: additionalProperties is only supported for object schemas"
));
}
match additional {
Value::Bool(_) => {}
Value::Object(_) => {
validate_schema_node(additional, &format!("{path}.additionalProperties"), false)?;
}
_ => {
return Err(format!(
"{path}: additionalProperties must be boolean or schema object"
));
}
}
}
if let Some(items) = map.get("items") {
if ty != SupportedSchemaType::Array {
return Err(format!("{path}: items is only supported for array schemas"));
}
validate_schema_node(items, &format!("{path}.items"), false)?;
}
if let Some(enum_values) = map.get("enum") {
let Some(enum_values) = enum_values.as_array() else {
return Err(format!("{path}: enum must be an array"));
};
if enum_values.is_empty() {
return Err(format!("{path}: enum must not be empty"));
}
for (index, value) in enum_values.iter().enumerate() {
if enum_values
.iter()
.skip(index + 1)
.any(|other| other == value)
{
return Err(format!("{path}: enum entries must be unique"));
}
}
}
Ok(ty)
}
fn is_supported_schema_keyword(key: &str) -> bool {
matches!(
key,
"type"
| "title"
| "description"
| "properties"
| "required"
| "additionalProperties"
| "items"
| "enum"
)
}
#[cfg(test)]
mod tests {
use super::*;
use manifest::plugin::{
PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig,
PluginPackageManifest, PluginRequestGrant, PluginRuntimeManifest, SourceQualifiedPluginId,
resolve_plugin_config_for_startup,
};
use serde_json::json;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
#[tokio::test(flavor = "multi_thread")]
async fn websocket_runtime_drop_is_safe_inside_async_context() {
let runtime = new_websocket_runtime().unwrap();
drop(runtime);
}
fn tool(name: &str) -> manifest::plugin::PluginToolManifest {
manifest::plugin::PluginToolManifest {
name: name.into(),
description: format!("{name} tool"),
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
external_write: false,
}
}
fn record(tools: Vec<manifest::plugin::PluginToolManifest>) -> ResolvedPluginRecord {
record_with_identity("project:example", tools)
}
fn record_with_identity(
identity: &str,
tools: Vec<manifest::plugin::PluginToolManifest>,
) -> ResolvedPluginRecord {
let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap();
let permissions = tool_permissions(&tools);
ResolvedPluginRecord {
identity: parsed_identity.clone(),
source: parsed_identity.source,
package_path: std::path::PathBuf::from("/tmp/example.zip"),
package_label: "example.zip".into(),
digest: "sha256:abc".into(),
version: "0.1.0".into(),
manifest: PluginPackageManifest {
schema_version: 1,
id: "example".into(),
name: "Example".into(),
version: "0.1.0".into(),
description: None,
surfaces: vec![PluginSurface::Tool],
runtime: Some(manifest::plugin::PluginRuntimeManifest {
kind: "test-ingress".to_string(),
entry: None,
abi: None,
component: None,
world: None,
}),
hooks: Vec::new(),
tools,
services: Vec::new(),
ingresses: Vec::new(),
permissions: permissions.clone(),
request: Vec::new(),
websocket: Vec::new(),
},
enabled_surfaces: vec![PluginSurface::Tool],
grants: PluginGrantConfig {
id: Some(parsed_identity.to_string()),
version: Some(PluginExactVersion("0.1.0".to_string())),
digest: Some("sha256:abc".to_string()),
permissions,
request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(),
},
config: None,
}
}
fn tool_permissions(tools: &[manifest::plugin::PluginToolManifest]) -> Vec<PluginPermission> {
let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
permissions.extend(
tools
.iter()
.map(|tool| PluginPermission::tool(tool.name.clone())),
);
permissions
}
fn install_feature(
feature: PluginToolFeature,
) -> (
super::super::FeatureRegistryInstallReport,
Vec<ToolDefinition>,
) {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(feature)
.install_into_pending(&mut pending, &mut hooks);
(report, pending)
}
#[test]
fn component_lifecycle_rejects_start_error_status() {
let component = component_instance_with_outputs(
br#"{"error":{"message":"boom"}}"#,
br#"{"state":"ready"}"#,
br#"{"state":"stopped"}"#,
br#"{"summary":"tool"}"#,
br#"{"accepted":true}"#,
);
let (_dir, mut record) = resolved_record_with_component(component);
record.manifest.runtime.as_mut().unwrap().world =
Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into());
let error = match PluginInstanceHandle::new(record) {
Ok(_) => panic!("component start error should fail instance creation"),
Err(error) => error,
};
assert!(error.bounded_message().contains("start returned error"));
}
#[test]
fn component_lifecycle_reports_status_and_stop_outputs() {
let component = component_instance_with_outputs(
br#"{"state":"ready","data":{"phase":"start"}}"#,
br#"{"state":"ready","data":{"phase":"status"}}"#,
br#"{"state":"stopped","data":{"phase":"stop"}}"#,
br#"{"summary":"tool"}"#,
br#"{"accepted":true}"#,
);
let (_dir, mut record) = resolved_record_with_component(component);
record.manifest.runtime.as_mut().unwrap().world =
Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into());
let handle = PluginInstanceHandle::new(record).unwrap();
let status = handle.status();
assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Running);
assert_eq!(status.component_status.unwrap()["data"]["phase"], "status");
let stopped = handle.stop().unwrap();
assert_eq!(stopped.lifecycle, PluginInstanceLifecycleState::Stopped);
assert_eq!(stopped.component_status.unwrap()["data"]["phase"], "stop");
}
fn add_service(record: &mut ResolvedPluginRecord, name: &str) {
record.manifest.surfaces.push(PluginSurface::Service);
record.enabled_surfaces.push(PluginSurface::Service);
record
.manifest
.services
.push(manifest::plugin::PluginServiceManifest {
name: name.into(),
description: "test service".into(),
lifecycle: "host-managed".into(),
status_schema: None,
side_effects: Vec::new(),
});
record
.manifest
.permissions
.push(PluginPermission::surface(PluginSurface::Service));
record
.manifest
.permissions
.push(PluginPermission::service(name));
record
.grants
.permissions
.push(PluginPermission::surface(PluginSurface::Service));
record
.grants
.permissions
.push(PluginPermission::service(name));
}
fn add_ingress(record: &mut ResolvedPluginRecord, name: &str) {
record.manifest.surfaces.push(PluginSurface::Ingress);
record.enabled_surfaces.push(PluginSurface::Ingress);
record
.manifest
.ingresses
.push(manifest::plugin::PluginIngressManifest {
name: name.into(),
description: "test ingress".into(),
event_kinds: vec!["test".into()],
input_schema: None,
sources: Vec::new(),
side_effects: Vec::new(),
});
record
.manifest
.permissions
.push(PluginPermission::surface(PluginSurface::Ingress));
record
.manifest
.permissions
.push(PluginPermission::ingress(name));
record
.grants
.permissions
.push(PluginPermission::surface(PluginSurface::Ingress));
record
.grants
.permissions
.push(PluginPermission::ingress(name));
}
fn test_ingress_event(ingress_name: &str, payload: Value) -> PluginIngressEvent {
PluginIngressEvent::new(ingress_name, "test", "unit", payload)
}
fn service_output_command(
event: &PluginIngressEvent,
command_id: &str,
kind: &str,
payload: Value,
) -> Value {
json!({
"correlation_id": event.correlation_id.clone(),
"source_event_id": event.correlation_id.clone(),
"command_id": command_id,
"kind": kind,
"payload": payload,
"requested_at": event.created_at.clone(),
})
}
fn add_request_output_grant(record: &mut ResolvedPluginRecord) {
let permission = PluginPermission::host_api(PluginHostApi::Request);
record.manifest.permissions.push(permission.clone());
record.grants.permissions.push(permission);
let target = PluginRequestGrant {
scheme: "https".to_string(),
host: "api.example.test".to_string(),
port: None,
methods: vec!["POST".to_string()],
path_prefixes: vec!["/v1".to_string()],
};
record.manifest.request.push(target.clone());
record.grants.request.push(target);
}
fn add_websocket_output_grant(record: &mut ResolvedPluginRecord) {
let permission = PluginPermission::host_api(PluginHostApi::WebSocket);
record.manifest.permissions.push(permission.clone());
record.grants.permissions.push(permission);
let target = PluginWebSocketGrant {
scheme: "wss".to_string(),
host: "ws.example.test".to_string(),
port: None,
path_prefixes: vec!["/events".to_string()],
};
record.manifest.websocket.push(target.clone());
record.grants.websocket.push(target);
}
fn add_websocket_ingress_source(record: &mut ResolvedPluginRecord, source: &str) {
let ingress = record
.manifest
.ingresses
.iter_mut()
.find(|ingress| ingress.name == "shared_ingress")
.expect("shared ingress");
ingress.event_kinds = vec![
"test".into(),
"websocket_text".into(),
"websocket_close".into(),
"websocket_error".into(),
];
ingress.sources.push(source.to_string());
}
fn service_websocket_record() -> ResolvedPluginRecord {
let mut record = test_service_ingress_record();
add_websocket_output_grant(&mut record);
add_websocket_ingress_source(&mut record, "websocket:wss://ws.example.test/events");
record
}
#[derive(Clone, Default)]
struct ServiceWebSocketClient {
events: Arc<Mutex<VecDeque<Result<PluginWebSocketRecvResponse, String>>>>,
sent: Arc<Mutex<Vec<String>>>,
opens: Arc<std::sync::atomic::AtomicUsize>,
send_error: Arc<Mutex<Option<String>>>,
}
impl ServiceWebSocketClient {
fn with_events(events: Vec<Result<PluginWebSocketRecvResponse, String>>) -> Self {
Self {
events: Arc::new(Mutex::new(events.into())),
..Self::default()
}
}
fn sent(&self) -> Vec<String> {
self.sent.lock().unwrap().clone()
}
fn fail_sends_with(&self, message: &str) {
*self.send_error.lock().unwrap() = Some(message.to_string());
}
}
impl PluginWebSocketClient for ServiceWebSocketClient {
fn supports_bounded_open(&self) -> bool {
true
}
fn open(
&self,
_request: &PluginWebSocketOpenRequest,
_url: &reqwest::Url,
_limits: PluginWebSocketLimits,
) -> Result<Box<dyn PluginWebSocketConnection>, PluginWebSocketError> {
self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(Box::new(ServiceWebSocketConnection {
events: self.events.clone(),
sent: self.sent.clone(),
send_error: self.send_error.clone(),
closed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}))
}
}
struct ServiceWebSocketConnection {
events: Arc<Mutex<VecDeque<Result<PluginWebSocketRecvResponse, String>>>>,
sent: Arc<Mutex<Vec<String>>>,
send_error: Arc<Mutex<Option<String>>>,
closed: Arc<std::sync::atomic::AtomicBool>,
}
impl PluginWebSocketConnection for ServiceWebSocketConnection {
fn send_text(&mut self, text: &str) -> Result<(), PluginWebSocketError> {
if let Some(error) = self.send_error.lock().unwrap().clone() {
return Err(PluginWebSocketError::new(error));
}
self.sent.lock().unwrap().push(text.to_string());
Ok(())
}
fn recv_text(
&mut self,
timeout: Duration,
_max_message_bytes: usize,
) -> Result<PluginWebSocketRecvResponse, PluginWebSocketError> {
if self.closed.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(PluginWebSocketRecvResponse::Closed);
}
if let Some(event) = self.events.lock().unwrap().pop_front() {
return event.map_err(PluginWebSocketError::new);
}
std::thread::sleep(timeout.min(Duration::from_millis(10)));
Err(PluginWebSocketError::new("receive timed out"))
}
fn close(&mut self) -> Result<(), PluginWebSocketError> {
self.closed.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
}
fn wait_until(mut condition: impl FnMut() -> bool) {
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while std::time::Instant::now() < deadline {
if condition() {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(condition(), "condition did not become true before timeout");
}
#[test]
fn service_selected_ignores_unselected_tool_without_grants() {
let mut record = record(vec![tool("hidden_tool")]);
add_service(&mut record, "svc");
record.enabled_surfaces = vec![PluginSurface::Service];
record.manifest.permissions = vec![
PluginPermission::surface(PluginSurface::Service),
PluginPermission::service("svc"),
];
record.grants.permissions = record.manifest.permissions.clone();
let feature = PluginToolFeature::new(record);
assert!(feature.descriptor().tools.is_empty());
assert_eq!(feature.descriptor().provides_services.len(), 1);
let (report, pending) = install_feature(feature.clone());
assert!(
report.reports.iter().all(|report| report.installed),
"{report:#?}"
);
assert!(pending.is_empty(), "unselected Tool must not register");
assert_eq!(report.reports[0].provided_services.len(), 1);
assert_eq!(
feature.instance_status().unwrap().lifecycle,
PluginInstanceLifecycleState::Running
);
}
#[test]
fn tool_selected_ignores_unselected_service_ingress_even_with_grants() {
let mut record = record(vec![tool("visible_tool")]);
add_service(&mut record, "hidden_service");
add_ingress(&mut record, "hidden_ingress");
record.enabled_surfaces = vec![PluginSurface::Tool];
let feature = PluginToolFeature::new(record);
assert!(feature.descriptor().provides_services.is_empty());
assert_eq!(feature.descriptor().tools.len(), 1);
let (report, pending) = install_feature(feature.clone());
assert!(
report.reports.iter().all(|report| report.installed),
"{report:#?}"
);
assert_eq!(pending.len(), 1);
assert!(report.reports[0].provided_services.is_empty());
let dispatch = feature.dispatch_ingress(
"hidden_ingress",
test_ingress_event("hidden_ingress", serde_json::json!({})),
);
assert!(
dispatch
.unwrap_err()
.bounded_message()
.contains("not enabled")
);
}
#[test]
fn service_only_install_retains_host_managed_instance() {
let mut record = record(Vec::new());
add_service(&mut record, "svc");
record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest {
kind: "test-ingress".into(),
entry: None,
abi: None,
component: None,
world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()),
});
let feature = PluginToolFeature::new(record);
let (report, _pending) = install_feature(feature.clone());
assert!(
report.reports.iter().all(|report| report.installed),
"{report:#?}"
);
let status = feature.instance_status().expect("service instance started");
assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Running);
assert_eq!(status.queue_depth, 0);
assert_eq!(status.queue_capacity, PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY);
assert!(status.last_error.is_none());
}
fn test_service_ingress_record() -> ResolvedPluginRecord {
let mut record = record(Vec::new());
add_service(&mut record, "svc");
add_ingress(&mut record, "shared_ingress");
record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest {
kind: "test-ingress".into(),
entry: None,
abi: None,
component: None,
world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()),
});
record
}
#[test]
fn ingress_queue_dispatches_serially_and_reports_status() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
assert_eq!(
handle.status().lifecycle,
PluginInstanceLifecycleState::Running
);
let first = handle
.deliver_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })),
)
.unwrap();
let second = handle
.deliver_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "seq": 2 })),
)
.unwrap();
assert_eq!(first.output["ingress_calls"], 1);
assert_eq!(second.output["ingress_calls"], 2);
assert_eq!(second.queue_depth, 0);
assert_eq!(second.dispatch_counters.enqueued, 2);
assert_eq!(second.dispatch_counters.dispatched, 2);
let status = handle.status();
assert_eq!(status.queue_depth, 0);
assert_eq!(status.dispatch_counters.enqueued, 2);
assert_eq!(status.dispatch_counters.dispatched, 2);
assert!(status.last_error.is_none());
}
#[test]
fn bounded_ingress_queue_rejects_full_queue() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
let mut instance = handle.0.lock().unwrap();
instance.ingress_queue_capacity = 1;
instance
.enqueue_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })),
)
.unwrap();
let error = instance
.enqueue_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "seq": 2 })),
)
.unwrap_err();
assert!(matches!(
error,
PluginIngressDispatchError::QueueFull { capacity: 1 }
));
assert_eq!(instance.ingress_queue.len(), 1);
assert_eq!(instance.dispatch_counters.rejected, 1);
assert_eq!(
instance.diagnostics.last().unwrap().kind,
PluginInstanceDiagnosticKind::QueueFull
);
}
#[test]
fn ingress_dispatch_failure_marks_service_failed_and_rejects_later_events() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
let error = handle
.deliver_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "fail": true })),
)
.unwrap_err();
assert!(matches!(
error,
PluginIngressDispatchError::DispatchFailed(_)
));
let status = handle.status();
assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Failed);
assert_eq!(status.dispatch_counters.failed, 1);
assert_eq!(
status.diagnostics.last().unwrap().kind,
PluginInstanceDiagnosticKind::DispatchFailed
);
let retry = handle
.deliver_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "seq": 3 })),
)
.unwrap_err();
assert!(matches!(
retry,
PluginIngressDispatchError::ServiceFailed(_)
));
}
#[test]
fn ingress_dispatch_timeout_records_typed_diagnostic() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
let error = handle
.deliver_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "sleep_ms": 50 })),
)
.unwrap_err();
assert!(matches!(
error,
PluginIngressDispatchError::DispatchTimeout { .. }
));
let status = handle.status();
assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Failed);
assert_eq!(status.dispatch_counters.timed_out, 1);
assert_eq!(
status.diagnostics.last().unwrap().kind,
PluginInstanceDiagnosticKind::DispatchTimeout
);
}
#[test]
fn stopped_service_rejects_ingress_events() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
handle.stop().unwrap();
let error = handle
.deliver_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })),
)
.unwrap_err();
assert!(matches!(
error,
PluginIngressDispatchError::ServiceStopped {
state: PluginInstanceLifecycleState::Stopped
}
));
}
#[test]
fn invalid_ingress_event_is_typed_rejection() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
let mut event = test_ingress_event("shared_ingress", serde_json::json!({}));
event.correlation_id.clear();
let error = handle.deliver_ingress("shared_ingress", event).unwrap_err();
assert!(matches!(error, PluginIngressDispatchError::InvalidEvent(_)));
let status = handle.status();
assert_eq!(status.dispatch_counters.rejected, 1);
assert_eq!(
status.diagnostics.last().unwrap().kind,
PluginInstanceDiagnosticKind::InvalidEvent
);
}
#[test]
fn service_output_command_records_diagnostic_status_separately_from_tool_output() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
let mut event = test_ingress_event("shared_ingress", json!({}));
let command = service_output_command(
&event,
"cmd-status",
"diagnostic_status_update",
json!({
"message": "service became ready",
"status": {"ready": true}
}),
);
event.payload = json!({"output_commands": [command]});
let report = handle.deliver_ingress("shared_ingress", event).unwrap();
assert_eq!(report.output_command_results.len(), 1);
assert_eq!(
report.output_command_results[0].kind,
Some(PluginServiceOutputCommandKind::DiagnosticStatusUpdate)
);
assert_eq!(
report.output_command_results[0].status,
PluginServiceOutputCommandStatus::Recorded
);
assert_eq!(
report.output["payload"]["output_commands"]
.as_array()
.unwrap()
.len(),
1
);
let status = handle.status();
assert_eq!(status.component_status, Some(json!({"ready": true})));
assert_eq!(status.output_command_results.len(), 1);
assert!(status.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRecorded
&& diagnostic.message.contains("cmd-status")
}));
}
#[test]
fn service_output_command_rejects_ungranted_request_without_executing_status_update() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
let mut event = test_ingress_event("shared_ingress", json!({}));
let status_command = service_output_command(
&event,
"cmd-status",
"diagnostic_status_update",
json!({"status": {"should_not_record": true}}),
);
let request_command = service_output_command(
&event,
"cmd-request",
"host_request_dispatch",
json!({
"method": "POST",
"url": "https://api.example.test/v1/events"
}),
);
event.payload = json!({"output_commands": [status_command, request_command]});
let report = handle.deliver_ingress("shared_ingress", event).unwrap();
assert_eq!(report.output_command_results.len(), 1);
assert_eq!(
report.output_command_results[0].status,
PluginServiceOutputCommandStatus::Rejected
);
assert_eq!(handle.status().component_status, None);
assert!(handle.status().diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRejected
&& diagnostic.message.contains("cmd-request")
}));
}
#[test]
fn service_output_commands_are_grant_checked_and_supported_transport_rejects_without_connection()
{
let mut record = test_service_ingress_record();
add_request_output_grant(&mut record);
add_websocket_output_grant(&mut record);
let handle = PluginInstanceHandle::new(record).unwrap();
let mut event = test_ingress_event("shared_ingress", json!({}));
let request_command = service_output_command(
&event,
"cmd-request",
"host_request_dispatch",
json!({
"method": "POST",
"url": "https://api.example.test/v1/events"
}),
);
let websocket_command = service_output_command(
&event,
"cmd-websocket",
"websocket_send",
json!({
"url": "wss://ws.example.test/events",
"text": "hello"
}),
);
event.payload = json!({"output_commands": [request_command, websocket_command]});
let report = handle.deliver_ingress("shared_ingress", event).unwrap();
assert_eq!(report.output_command_results.len(), 2);
let request_result = report
.output_command_results
.iter()
.find(|result| result.command_id.as_deref() == Some("cmd-request"))
.unwrap();
let websocket_result = report
.output_command_results
.iter()
.find(|result| result.command_id.as_deref() == Some("cmd-websocket"))
.unwrap();
assert_eq!(
request_result.status,
PluginServiceOutputCommandStatus::Unsupported
);
assert_eq!(
websocket_result.status,
PluginServiceOutputCommandStatus::Rejected
);
let status = handle.status();
assert_eq!(status.output_command_results.len(), 2);
assert!(status.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandUnsupported
&& diagnostic.message.contains("cmd-request")
}));
assert!(status.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRejected
&& diagnostic.message.contains("cmd-websocket")
}));
}
#[test]
fn service_websocket_driver_enqueues_incoming_text_and_reports_close() {
let client = ServiceWebSocketClient::with_events(vec![
Ok(PluginWebSocketRecvResponse::Text {
text: "hello service".into(),
}),
Ok(PluginWebSocketRecvResponse::Closed),
]);
let handle = PluginInstanceHandle::new_with_test_websocket_client(
service_websocket_record(),
Arc::new(client.clone()),
)
.unwrap();
wait_until(|| handle.status().dispatch_counters.dispatched >= 1);
let status = handle.status();
assert!(status.dispatch_counters.dispatched >= 1);
assert_eq!(status.websocket_connections.len(), 1);
let connection = &status.websocket_connections[0];
assert_eq!(connection.received_text_frames, 1);
assert!(connection.last_frame_at.is_some());
assert_eq!(connection.queue_drops, 0);
assert!(status.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketClosed
}));
handle.stop().unwrap();
}
#[test]
fn websocket_send_output_command_sends_on_host_owned_connection() {
let client = ServiceWebSocketClient::default();
let handle = PluginInstanceHandle::new_with_test_websocket_client(
service_websocket_record(),
Arc::new(client.clone()),
)
.unwrap();
wait_until(|| {
handle
.status()
.websocket_connections
.iter()
.any(|connection| {
connection.state == PluginServiceWebSocketConnectionState::Connected
})
});
let event = test_ingress_event("shared_ingress", json!({}));
let command_value = service_output_command(
&event,
"cmd-websocket",
"websocket_send",
json!({"url": "wss://ws.example.test/events", "text": "pong"}),
);
let command: PluginServiceOutputCommandEnvelope =
serde_json::from_value(command_value).unwrap();
let result = handle
.0
.lock()
.unwrap()
.execute_service_output_command(command);
assert_eq!(result.status, PluginServiceOutputCommandStatus::Recorded);
assert_eq!(client.sent(), vec!["pong".to_string()]);
let status = handle.status();
assert_eq!(status.websocket_connections[0].sent_text_frames, 1);
handle.stop().unwrap();
}
#[test]
fn websocket_send_output_command_records_send_failure_diagnostic() {
let client = ServiceWebSocketClient::default();
client.fail_sends_with("transport write failed");
let handle = PluginInstanceHandle::new_with_test_websocket_client(
service_websocket_record(),
Arc::new(client),
)
.unwrap();
wait_until(|| {
handle
.status()
.websocket_connections
.iter()
.any(|connection| {
connection.state == PluginServiceWebSocketConnectionState::Connected
})
});
let event = test_ingress_event("shared_ingress", json!({}));
let command: PluginServiceOutputCommandEnvelope =
serde_json::from_value(service_output_command(
&event,
"cmd-websocket-fail",
"websocket_send",
json!({"url": "wss://ws.example.test/events", "text": "pong"}),
))
.unwrap();
let result = handle
.0
.lock()
.unwrap()
.execute_service_output_command(command);
assert_eq!(result.status, PluginServiceOutputCommandStatus::Rejected);
let status = handle.status();
assert_eq!(status.websocket_connections[0].send_failures, 1);
assert!(status.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketSendFailed
&& diagnostic.message.contains("transport write failed")
}));
handle.stop().unwrap();
}
#[test]
fn websocket_send_rejects_unauthorized_target_before_execution() {
let mut record = service_websocket_record();
let handle = PluginInstanceHandle::new_with_test_websocket_client(
record.clone(),
Arc::new(ServiceWebSocketClient::default()),
)
.unwrap();
let event = test_ingress_event("shared_ingress", json!({}));
let command: PluginServiceOutputCommandEnvelope =
serde_json::from_value(service_output_command(
&event,
"cmd-websocket-denied",
"websocket_send",
json!({"url": "wss://ws.example.test/private", "text": "nope"}),
))
.unwrap();
let error = handle
.0
.lock()
.unwrap()
.validate_service_output_command_envelope(&command, &event)
.unwrap_err();
assert!(
error.contains("websocket_send target denied")
|| error.contains("not declared by the plugin manifest"),
"{error}"
);
assert_eq!(handle.status().websocket_connections[0].sent_text_frames, 0);
record.grants.permissions.retain(|permission| {
*permission != PluginPermission::host_api(PluginHostApi::WebSocket)
});
let denied_handle = PluginInstanceHandle::new_with_test_websocket_client(
record,
Arc::new(ServiceWebSocketClient::default()),
)
.unwrap();
wait_until(|| !denied_handle.status().diagnostics.is_empty());
assert!(denied_handle.status().diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketError
&& diagnostic.message.contains("not granted")
}));
handle.stop().unwrap();
denied_handle.stop().unwrap();
}
#[test]
fn service_websocket_driver_reports_receive_error_as_diagnostic() {
let client = ServiceWebSocketClient::with_events(vec![Err(
"binary frames are not supported by host-owned service websocket".into(),
)]);
let handle = PluginInstanceHandle::new_with_test_websocket_client(
service_websocket_record(),
Arc::new(client),
)
.unwrap();
wait_until(|| {
handle.status().websocket_connections[0].state
== PluginServiceWebSocketConnectionState::Failed
});
let status = handle.status();
assert!(status.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketError
&& diagnostic.message.contains("binary frames")
}));
assert!(
status.websocket_connections[0]
.last_error
.as_deref()
.unwrap()
.contains("binary frames")
);
handle.stop().unwrap();
}
#[test]
fn service_output_command_rejects_malformed_envelope() {
let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap();
let mut event = test_ingress_event("shared_ingress", json!({}));
event.payload = json!({"output_commands": [{"kind": "diagnostic_status_update"}]});
let report = handle.deliver_ingress("shared_ingress", event).unwrap();
assert_eq!(report.output_command_results.len(), 1);
assert_eq!(
report.output_command_results[0].status,
PluginServiceOutputCommandStatus::Rejected
);
assert!(
report.output_command_results[0]
.message
.contains("malformed service output command envelope")
);
}
#[test]
fn installed_ingress_dispatch_uses_retained_shared_instance() {
let mut record = record(vec![tool("shared_tool")]);
add_ingress(&mut record, "shared_ingress");
record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest {
kind: "test-ingress".into(),
entry: None,
abi: None,
component: None,
world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()),
});
let feature = PluginToolFeature::new(record);
let (report, pending) = install_feature(feature.clone());
assert!(
report.reports.iter().all(|report| report.installed),
"{report:#?}"
);
let (_meta, tool) = pending
.into_iter()
.map(|definition| definition())
.find(|(meta, _tool)| meta.name == "shared_tool")
.unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let output = runtime
.block_on(tool.execute(r#"{"first":true}"#, ToolExecutionContext::default()))
.unwrap();
assert!(output.summary.contains("shared_tool"));
let report = feature
.dispatch_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "hello": "world" })),
)
.unwrap();
assert!(report.accepted);
assert_eq!(report.output["calls"], 1);
}
#[test]
fn instance_ingress_dispatch_uses_shared_in_process_instance() {
let mut record = record(vec![tool("shared_tool")]);
record.manifest.surfaces.push(PluginSurface::Ingress);
record.enabled_surfaces.push(PluginSurface::Ingress);
record
.manifest
.ingresses
.push(manifest::plugin::PluginIngressManifest {
name: "shared_ingress".into(),
description: "test ingress".into(),
event_kinds: vec!["test".into()],
input_schema: None,
sources: Vec::new(),
side_effects: Vec::new(),
});
record
.manifest
.permissions
.push(PluginPermission::surface(PluginSurface::Ingress));
record
.manifest
.permissions
.push(PluginPermission::ingress("shared_ingress"));
record
.grants
.permissions
.push(PluginPermission::surface(PluginSurface::Ingress));
record
.grants
.permissions
.push(PluginPermission::ingress("shared_ingress"));
let handle = PluginInstanceHandle(Arc::new(Mutex::new(PluginInstance {
record,
runtime: PluginInstanceRuntime::TestIngress {
tool_calls: 0,
ingress_calls: 0,
},
lifecycle: PluginInstanceLifecycleState::Running,
component_status: None,
ingress_queue: VecDeque::new(),
ingress_queue_capacity: PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY,
dispatch_counters: PluginIngressDispatchCounters::default(),
last_error: None,
output_command_results: Vec::new(),
service_websockets: PluginServiceWebSocketDriver::default(),
service_websocket_client: Arc::new(TungstenitePluginWebSocketClient),
diagnostics: Vec::new(),
})));
let _tool = handle
.handle_tool("shared_tool", br#"{"first":true}"#.to_vec())
.unwrap();
let report = handle
.deliver_ingress(
"shared_ingress",
test_ingress_event("shared_ingress", serde_json::json!({ "hello": "world" })),
)
.unwrap();
assert!(report.accepted);
assert_eq!(report.output["calls"], 1);
assert_eq!(report.output["ingress"], "shared_ingress");
}
fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize {
report
.reports
.iter()
.map(|feature_report| feature_report.skipped.len())
.sum()
}
fn has_diagnostic(report: &super::super::FeatureRegistryInstallReport, needle: &str) -> bool {
report.reports.iter().any(|feature_report| {
feature_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(needle))
})
}
fn install_plugin_record(
record: ResolvedPluginRecord,
) -> (
super::super::FeatureRegistryInstallReport,
Vec<ToolDefinition>,
) {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record))
.install_into_pending(&mut pending, &mut hooks);
(report, pending)
}
fn record_with_request_grant() -> ResolvedPluginRecord {
let mut record = record(vec![tool("RequestTool")]);
let request_permission = PluginPermission::HostApi {
api: PluginHostApi::Request,
};
record.manifest.permissions.push(request_permission.clone());
record.manifest.request.push(PluginRequestGrant {
scheme: "https".to_string(),
host: "api.example.test".to_string(),
port: None,
methods: vec!["GET".to_string(), "POST".to_string()],
path_prefixes: vec!["/v1".to_string()],
});
record.grants.permissions.push(request_permission);
record.grants.request.push(PluginRequestGrant {
scheme: "https".to_string(),
host: "api.example.test".to_string(),
port: None,
methods: vec!["GET".to_string(), "POST".to_string()],
path_prefixes: vec!["/v1".to_string()],
});
record
}
struct MockRequestClient {
calls: Mutex<usize>,
response_body: String,
error: Mutex<Option<String>>,
}
impl Default for MockRequestClient {
fn default() -> Self {
Self {
calls: Mutex::new(0),
response_body: "ok".to_string(),
error: Mutex::new(None),
}
}
}
impl MockRequestClient {
fn call_count(&self) -> usize {
*self.calls.lock().expect("mock call lock")
}
}
impl PluginRequestClient for MockRequestClient {
fn execute(
&self,
_request: &PluginRequestRequest,
_url: &reqwest::Url,
_limits: PluginRequestLimits,
) -> Result<PluginRequestResponse, PluginRequestError> {
*self.calls.lock().expect("mock call lock") += 1;
if let Some(error) = self.error.lock().expect("mock error lock").take() {
return Err(PluginRequestError::new(error));
}
Ok(PluginRequestResponse {
status: 200,
headers: vec![PluginRequestHeader {
name: "content-type".to_string(),
value: "text/plain".to_string(),
}],
body: self.response_body.clone(),
truncated: false,
})
}
}
struct FakeRequestResolver {
calls: Mutex<Vec<(String, u16)>>,
addrs: Vec<SocketAddr>,
}
impl FakeRequestResolver {
fn new(addrs: Vec<SocketAddr>) -> Self {
Self {
calls: Mutex::new(Vec::new()),
addrs,
}
}
fn calls(&self) -> Vec<(String, u16)> {
self.calls.lock().expect("resolver calls lock").clone()
}
}
impl PluginRequestResolver for FakeRequestResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginRequestError> {
self.calls
.lock()
.expect("resolver calls lock")
.push((host.to_string(), port));
Ok(self.addrs.clone())
}
}
fn request_request_json(method: &str, url: &str) -> String {
json!({ "method": method, "url": url }).to_string()
}
fn fs_request_json(path: &str) -> String {
json!({ "path": path }).to_string()
}
fn fs_write_request_json(path: &str, content: &str) -> String {
json!({ "path": path, "content": content }).to_string()
}
fn record_with_fs_grant(
root: &Path,
operations: Vec<PluginFsOperation>,
) -> ResolvedPluginRecord {
let mut record = record(vec![tool("FsTool")]);
let fs_permission = PluginPermission::HostApi {
api: PluginHostApi::Fs,
};
record.manifest.permissions.push(fs_permission.clone());
record.grants.permissions.push(fs_permission);
record.grants.fs.push(PluginFsGrant {
root: root.to_string_lossy().into_owned(),
operations,
});
record
}
#[test]
fn rejects_invalid_root_schema() {
let schema = json!({"type":"string"});
assert!(
validate_input_schema(&schema)
.unwrap_err()
.contains("type must be `object`")
);
}
#[test]
fn rejects_unsupported_schema_keyword() {
let schema = json!({"type":"object","oneOf":[]});
assert!(
validate_input_schema(&schema)
.unwrap_err()
.contains("unsupported schema keyword")
);
}
#[test]
fn rejects_invalid_nested_property_schema_node() {
let schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let error = validate_input_schema(&schema).unwrap_err();
assert!(error.contains("$.properties.query"));
assert!(error.contains("schema node must be a JSON object"));
}
#[test]
fn rejects_invalid_recursive_schema_members() {
let duplicate_required = json!({
"type":"object",
"properties":{"query":{"type":"string"}},
"required":["query", "query"]
});
assert!(
validate_input_schema(&duplicate_required)
.unwrap_err()
.contains("required entries must be unique")
);
let invalid_items = json!({
"type":"object",
"properties":{"values":{"type":"array", "items":"not-a-schema"}}
});
assert!(
validate_input_schema(&invalid_items)
.unwrap_err()
.contains("$.properties.values.items")
);
let invalid_additional = json!({
"type":"object",
"additionalProperties":{"type":"unsupported"}
});
assert!(
validate_input_schema(&invalid_additional)
.unwrap_err()
.contains("unsupported schema type")
);
}
#[test]
fn accepts_object_tool_schema() {
validate_input_schema(&json!({
"type":"object",
"properties":{
"query":{"type":"string", "description":"Search text"},
"limit":{"type":"integer", "enum":[1, 5, 10]},
"tags":{"type":"array", "items":{"type":"string"}}
},
"required":["query"],
"additionalProperties":{"type":"string"}
}))
.unwrap();
}
#[test]
fn granted_fs_read_list_and_write_are_scoped() {
let root = TempDir::new().expect("temp root");
fs::write(root.path().join("read.txt"), "hello fs").expect("write fixture");
fs::create_dir(root.path().join("dir")).expect("create dir");
fs::write(root.path().join("dir").join("entry.txt"), "entry").expect("write entry");
let record = record_with_fs_grant(
root.path(),
vec![
PluginFsOperation::Read,
PluginFsOperation::List,
PluginFsOperation::Write,
],
);
let read = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("read.txt").as_bytes(),
)
.expect("read allowed");
let read: serde_json::Value = serde_json::from_slice(&read).expect("read response json");
assert_eq!(read["content"], "hello fs");
assert_eq!(read["truncated"], false);
let list = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::List,
fs_request_json("dir").as_bytes(),
)
.expect("list allowed");
let list: serde_json::Value = serde_json::from_slice(&list).expect("list response json");
assert_eq!(list["entries"][0]["name"], "entry.txt");
assert_eq!(list["entries"][0]["kind"], "file");
let write = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Write,
fs_write_request_json("written.txt", "new content").as_bytes(),
)
.expect("write allowed");
let write: serde_json::Value = serde_json::from_slice(&write).expect("write response json");
assert_eq!(write["bytes_written"], "new content".len());
assert_eq!(
fs::read_to_string(root.path().join("written.txt")).expect("read written"),
"new content"
);
}
#[test]
fn missing_fs_grant_denies_even_when_manifest_requests_api() {
let root = TempDir::new().expect("temp root");
fs::write(root.path().join("secret.txt"), "must not leak").expect("write fixture");
let mut record = record(vec![tool("FsTool")]);
record.manifest.permissions.push(PluginPermission::HostApi {
api: PluginHostApi::Fs,
});
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("secret.txt").as_bytes(),
)
.expect_err("grant denied");
assert!(error.message.contains("host_api.fs"));
assert!(!error.message.contains("must not leak"));
}
#[test]
fn workspace_scope_is_not_inherited_without_plugin_fs_grant() {
let root = TempDir::new().expect("temp root");
fs::write(root.path().join("workspace.txt"), "workspace authority").expect("write fixture");
let record = record(vec![tool("FsTool")]);
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("workspace.txt").as_bytes(),
)
.expect_err("plugin grant required");
assert!(error.message.contains("host_api.fs"));
assert!(!error.message.contains("workspace authority"));
}
#[test]
fn fs_traversal_and_absolute_paths_are_rejected() {
let root = TempDir::new().expect("temp root");
let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]);
for path in ["../outside.txt", "/etc/passwd"] {
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json(path).as_bytes(),
)
.expect_err("path denied");
assert!(
error.message.contains("relative") || error.message.contains("traversal"),
"unexpected error: {}",
error.message
);
}
}
#[cfg(unix)]
#[test]
fn fs_symlink_escape_is_rejected() {
let root = TempDir::new().expect("temp root");
let outside = TempDir::new().expect("outside root");
fs::write(outside.path().join("outside.txt"), "outside secret").expect("write outside");
std::os::unix::fs::symlink(outside.path(), root.path().join("link"))
.expect("create symlink");
let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]);
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("link/outside.txt").as_bytes(),
)
.expect_err("symlink denied");
assert!(error.message.contains("symlink"));
assert!(!error.message.contains("outside secret"));
}
#[test]
fn fs_read_write_and_list_bounds_are_enforced() {
let root = TempDir::new().expect("temp root");
fs::write(
root.path().join("big.txt"),
vec![b'a'; PLUGIN_FS_MAX_READ_BYTES + 1],
)
.expect("write big file");
let list_dir = root.path().join("many");
fs::create_dir(&list_dir).expect("create list dir");
for index in 0..=PLUGIN_FS_MAX_LIST_ENTRIES {
fs::write(list_dir.join(format!("entry-{index:03}.txt")), "x")
.expect("write list entry");
}
let record = record_with_fs_grant(
root.path(),
vec![
PluginFsOperation::Read,
PluginFsOperation::List,
PluginFsOperation::Write,
],
);
let read = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("big.txt").as_bytes(),
)
.expect("bounded read allowed");
let read: serde_json::Value = serde_json::from_slice(&read).expect("read response json");
assert_eq!(read["truncated"], true);
assert_eq!(
read["content"].as_str().expect("content").len(),
PLUGIN_FS_MAX_READ_BYTES
);
let list = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::List,
fs_request_json("many").as_bytes(),
)
.expect("bounded list allowed");
let list: serde_json::Value = serde_json::from_slice(&list).expect("list response json");
assert_eq!(list["truncated"], true);
assert_eq!(
list["entries"].as_array().expect("entries").len(),
PLUGIN_FS_MAX_LIST_ENTRIES
);
let too_large = "x".repeat(PLUGIN_FS_MAX_WRITE_BYTES + 1);
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Write,
fs_write_request_json("too-large.txt", &too_large).as_bytes(),
)
.expect_err("oversize write denied");
assert!(error.message.contains("exceeds"));
assert!(!root.path().join("too-large.txt").exists());
}
#[test]
fn fs_diagnostics_redact_secret_like_path_segments() {
let root = TempDir::new().expect("temp root");
let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]);
let error = execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Read,
fs_request_json("secret=my-token-value.txt").as_bytes(),
)
.expect_err("missing file");
assert!(error.message.contains("secret"));
assert!(error.message.contains("<redacted>"));
assert!(!error.message.contains("my-token-value"));
}
#[test]
fn fs_writes_serialize_to_normalized_target() {
let root = TempDir::new().expect("temp root");
let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Write]);
execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Write,
fs_write_request_json("target.txt", "first").as_bytes(),
)
.expect("first write");
execute_plugin_fs_request(
&record,
PluginFsRuntimeOperation::Write,
fs_write_request_json("./target.txt", "second").as_bytes(),
)
.expect("second write");
assert_eq!(
fs::read_to_string(root.path().join("target.txt")).expect("read target"),
"second"
);
}
#[test]
fn origin_retains_plugin_metadata() {
let feature = PluginToolFeature::new(record(Vec::new()));
let origin = feature.origin();
assert_eq!(origin.kind, "plugin");
assert_eq!(origin.plugin_id, "example");
assert_eq!(origin.plugin_ref, "project:example");
assert_eq!(origin.source, "project");
assert_eq!(origin.digest, "sha256:abc");
assert_eq!(origin.package_version, "0.1.0");
assert_eq!(origin.package_api_version, 1);
assert_eq!(origin.surface, "tool");
}
#[test]
fn disallowed_request_targets_deny_before_network() {
let record = record_with_request_grant();
let client = MockRequestClient::default();
for (method, url, needle) in [
("GET", "http://api.example.test/v1/data", "manifest"),
("TRACE", "https://api.example.test/v1/data", "method"),
("GET", "https://other.example.test/v1/data", "manifest"),
("GET", "http://localhost/v1/data", "manifest"),
("GET", "http://127.0.0.1/v1/data", "manifest"),
("GET", "file:///tmp/secret", "scheme"),
("GET", "ws://api.example.test/v1/data", "WebSocket"),
] {
let error = execute_plugin_request_request(
&record,
&client,
request_request_json(method, url).as_bytes(),
)
.expect_err("request denied");
assert!(
error.0.contains(needle),
"{method} {url} produced {:?}, expected {needle}",
error.0
);
}
assert_eq!(client.call_count(), 0);
}
#[test]
fn loopback_request_requires_explicit_manifest_and_grant() {
let mut record = record_with_request_grant();
record.manifest.request.push(PluginRequestGrant {
scheme: "http".to_string(),
host: "localhost".to_string(),
port: Some(8080),
methods: vec!["GET".to_string()],
path_prefixes: vec!["/health".to_string()],
});
let client = MockRequestClient::default();
let denied = execute_plugin_request_request(
&record,
&client,
request_request_json("GET", "http://localhost:8080/health").as_bytes(),
)
.expect_err("manifest-only local target denied");
assert!(denied.0.contains("grants"), "{}", denied.0);
record.grants.request.push(PluginRequestGrant {
scheme: "http".to_string(),
host: "localhost".to_string(),
port: Some(8080),
methods: vec!["GET".to_string()],
path_prefixes: vec!["/health".to_string()],
});
execute_plugin_request_request(
&record,
&client,
request_request_json("GET", "http://localhost:8080/health").as_bytes(),
)
.expect("explicit loopback target allowed");
assert_eq!(client.call_count(), 1);
}
#[test]
fn upgrade_and_sse_headers_are_rejected() {
let record = record_with_request_grant();
let client = MockRequestClient::default();
for headers in [
r#"[{"name":"Upgrade","value":"websocket"}]"#,
r#"[{"name":"Connection","value":"keep-alive, Upgrade"}]"#,
r#"[{"name":"Accept","value":"text/event-stream"}]"#,
] {
let body = format!(
r#"{{"method":"GET","url":"https://api.example.test/v1/data","headers":{headers}}}"#
);
let error = execute_plugin_request_request(&record, &client, body.as_bytes())
.expect_err("persistent request header denied");
assert!(
error.0.contains("not supported") || error.0.contains("upgrade"),
"{}",
error.0
);
}
assert_eq!(client.call_count(), 0);
}
#[test]
fn dns_resolution_is_pinned_to_validated_public_socket_addresses() {
let url = reqwest::Url::parse("https://api.example.test:8443/v1/data").unwrap();
let resolver = FakeRequestResolver::new(vec!["93.184.216.34:8443".parse().unwrap()]);
let pinned = resolve_request_target_for_client(&url, &resolver)
.expect("resolution")
.expect("hostname resolution is pinned");
assert_eq!(
resolver.calls(),
vec![("api.example.test".to_string(), 8443)]
);
assert_eq!(pinned.domains, vec!["api.example.test".to_string()]);
assert_eq!(pinned.addrs, vec!["93.184.216.34:8443".parse().unwrap()]);
let mut builder = reqwest::blocking::Client::builder().no_proxy();
for domain in &pinned.domains {
builder = builder.resolve_to_addrs(domain, &pinned.addrs);
}
builder.build().expect("client accepts pinned resolver");
}
#[test]
fn dns_resolution_can_pin_explicit_private_addresses() {
let url = reqwest::Url::parse("https://api.example.test/v1/data").unwrap();
let resolver = FakeRequestResolver::new(vec!["127.0.0.1:443".parse().unwrap()]);
let pinned = resolve_request_target_for_client(&url, &resolver)
.expect("explicit request grants, not ambient network policy, govern local targets")
.expect("hostname resolution is pinned");
assert_eq!(pinned.addrs, vec!["127.0.0.1:443".parse().unwrap()]);
}
#[test]
fn timeout_and_secret_diagnostics_are_bounded_and_redacted() {
let record = record_with_request_grant();
let client = MockRequestClient::default();
*client.error.lock().expect("mock error lock") =
Some("timeout while using Authorization: Bearer SUPER_SECRET_TOKEN".to_string());
let error = execute_plugin_request_request(
&record,
&client,
request_request_json("GET", "https://api.example.test/v1/data").as_bytes(),
)
.expect_err("timeout error");
assert!(error.0.contains("timeout"));
assert!(error.0.contains(PLUGIN_REQUEST_REDACTION));
assert!(!error.0.contains("SUPER_SECRET_TOKEN"));
assert!(error.0.len() <= 513);
assert_eq!(client.call_count(), 1);
}
#[test]
fn response_size_bound_truncates() {
let record = record_with_request_grant();
let client = MockRequestClient {
calls: Mutex::new(0),
response_body: "x".repeat(PLUGIN_REQUEST_MAX_RESPONSE_BYTES + 8),
error: Mutex::new(None),
};
let response = execute_plugin_request_request(
&record,
&client,
request_request_json("GET", "https://api.example.test/v1/data").as_bytes(),
)
.expect("response");
let value: Value = serde_json::from_slice(&response).expect("response json");
assert_eq!(value["truncated"], true);
assert_eq!(
value["body"].as_str().expect("body").len(),
PLUGIN_REQUEST_MAX_RESPONSE_BYTES
);
}
#[test]
fn enabled_plugin_tool_registers_model_visible_schema_and_origin() {
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
);
assert_eq!(report.installed_tool_names(), vec!["PluginSearch"]);
assert_eq!(pending.len(), 1);
let (meta, _) = pending[0]();
assert_eq!(meta.name, "PluginSearch");
assert_eq!(meta.input_schema["type"], "object");
let origin = meta.origin.expect("plugin origin metadata");
assert_eq!(origin.plugin_ref, "project:example");
assert_eq!(origin.digest, "sha256:abc");
assert_eq!(origin.source, "project");
assert_eq!(origin.surface, "tool");
}
#[test]
fn no_grant_denies_plugin_tool_registration_and_runtime_execution() {
let mut record = record(vec![tool("PluginSearch")]);
record.grants = PluginGrantConfig::default();
let (report, pending) = install_plugin_record(record.clone());
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "registration denied"));
assert!(has_diagnostic(
&report,
"granted surfaces.tool permission is missing"
));
let (_dir, mut runtime_record) = resolved_record_with_component(
component_tool_that_returns(br#"{"summary":"should not run"}"#),
);
runtime_record.grants = PluginGrantConfig::default();
let error =
run_plugin_component_tool(runtime_record, "PluginEcho".into(), br#"{}"#.to_vec())
.unwrap_err()
.bounded_message();
assert!(error.contains("plugin permission denied"), "{error}");
assert!(
error.contains("granted surfaces.tool permission is missing"),
"{error}"
);
assert!(error.len() < 700, "{error}");
}
#[test]
fn specific_tool_grant_registers_only_intended_plugin_tool() {
let mut record = record(vec![tool("PluginAllowed"), tool("PluginDenied")]);
record.grants.permissions = vec![
PluginPermission::surface(PluginSurface::Tool),
PluginPermission::tool("PluginAllowed"),
];
let (report, pending) = install_plugin_record(record);
assert_eq!(pending.len(), 1);
let (meta, _) = pending[0]();
assert_eq!(meta.name, "PluginAllowed");
assert_eq!(report.installed_tool_names(), vec!["PluginAllowed"]);
assert!(has_diagnostic(
&report,
"granted tool permission for `PluginDenied` is missing"
));
}
#[test]
fn grant_binding_mismatches_do_not_authorize_plugin_tool() {
let mut unrelated = record(vec![tool("PluginSearch")]);
unrelated.grants.id = Some("project:other".to_string());
let error = authorize_plugin_tool(&unrelated, &unrelated.manifest.tools[0])
.unwrap_err()
.bounded_message();
assert!(
error.contains("package id binding does not match"),
"{error}"
);
let mut bad_digest = record(vec![tool("PluginSearch")]);
bad_digest.grants.digest = Some("sha256:not-the-package".to_string());
let error = authorize_plugin_tool(&bad_digest, &bad_digest.manifest.tools[0])
.unwrap_err()
.bounded_message();
assert!(error.contains("digest binding does not match"), "{error}");
let mut bad_version = record(vec![tool("PluginSearch")]);
bad_version.grants.version = Some(PluginExactVersion("9.9.9".to_string()));
let error = authorize_plugin_tool(&bad_version, &bad_version.manifest.tools[0])
.unwrap_err()
.bounded_message();
assert!(error.contains("version binding does not match"), "{error}");
}
#[test]
fn requested_surface_tool_and_external_write_permissions_are_required() {
let mut missing_surface = record(vec![tool("PluginSearch")]);
missing_surface.manifest.permissions = vec![PluginPermission::tool("PluginSearch")];
let (report, pending) = install_plugin_record(missing_surface);
assert!(pending.is_empty());
assert!(has_diagnostic(
&report,
"requested surfaces.tool permission is missing"
));
let mut missing_tool = record(vec![tool("PluginSearch")]);
missing_tool.manifest.permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
let (report, pending) = install_plugin_record(missing_tool);
assert!(pending.is_empty());
assert!(has_diagnostic(
&report,
"requested tool permission for `PluginSearch` is missing"
));
let mut external_tool = tool("PluginWrite");
external_tool.external_write = true;
let mut missing_external_request = record(vec![external_tool]);
let (report, pending) = install_plugin_record(missing_external_request.clone());
assert!(pending.is_empty());
assert!(has_diagnostic(
&report,
"requested external_write permission is missing"
));
missing_external_request
.manifest
.permissions
.push(PluginPermission::ExternalWrite);
let (report, pending) = install_plugin_record(missing_external_request);
assert!(pending.is_empty());
assert!(has_diagnostic(
&report,
"granted external_write permission is missing"
));
}
#[test]
fn component_host_api_imports_are_permission_checked_by_manifest_and_grants() {
let (_dir, mut record) = resolved_record_with_component(component_tool_importing_request(
br#"{"summary":"should not run"}"#,
));
record.manifest.permissions.retain(|permission| {
!matches!(
permission,
PluginPermission::HostApi {
api: PluginHostApi::Request
}
)
});
record.manifest.request.clear();
record.grants.permissions.retain(|permission| {
!matches!(
permission,
PluginPermission::HostApi {
api: PluginHostApi::Request
}
)
});
record.grants.request.clear();
let error =
run_plugin_component_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec())
.unwrap_err()
.bounded_message();
assert!(
error.contains("requested host_api.request permission is missing"),
"{error}"
);
record
.manifest
.permissions
.push(PluginPermission::host_api(PluginHostApi::Request));
record
.grants
.permissions
.push(PluginPermission::host_api(PluginHostApi::Request));
let error =
run_plugin_component_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec())
.unwrap_err()
.bounded_message();
assert!(
error.contains("manifest host_api.request target declaration is missing"),
"{error}"
);
record.manifest.request.push(PluginRequestGrant {
scheme: "https".to_string(),
host: "api.example.test".to_string(),
port: None,
methods: vec!["GET".to_string()],
path_prefixes: vec!["/".to_string()],
});
let error = run_plugin_component_tool(record, "PluginEcho".into(), br#"{}"#.to_vec())
.unwrap_err()
.bounded_message();
assert!(
error.contains("enabled host_api.request grants are missing"),
"{error}"
);
}
#[test]
fn package_without_enabled_tool_surface_registers_no_schema() {
let mut config = PluginConfig::default();
let mut disabled = record(vec![tool("PluginSearch")]);
disabled.enabled_surfaces.clear();
config.resolved.push(disabled);
assert!(plugin_tool_features(&config).is_empty());
}
#[test]
fn disabled_profile_feature_registers_no_schema() {
let mut config = PluginConfig::default();
config.resolved.push(record(vec![tool("PluginSearch")]));
assert!(plugin_tool_features_if_enabled(false, &config).is_empty());
assert_eq!(plugin_tool_features_if_enabled(true, &config).len(), 1);
}
#[test]
fn duplicate_plugin_tool_names_are_rejected_with_diagnostic() {
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")])))
.with_module(PluginToolFeature::new(record_with_identity(
"project:other",
vec![tool("PluginSearch")],
)))
.install_into_pending(&mut pending, &mut hooks);
assert_eq!(pending.len(), 1);
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
#[test]
fn builtin_tool_name_collision_is_rejected_with_diagnostic() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let mut registered = std::collections::HashMap::new();
registered.insert("Read".to_string(), FeatureId::builtin("preexisting-tool"));
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("Read")])))
.install_into_pending_with_registered(&mut pending, &mut hooks, registered);
assert!(pending.is_empty());
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
#[test]
fn invalid_input_schema_is_rejected_with_diagnostic() {
let mut invalid = tool("BadSchema");
invalid.input_schema = json!({"type":"object","$ref":"#/defs/input"});
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![invalid])))
.install_into_pending(&mut pending, &mut hooks);
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "invalid input_schema"));
}
#[test]
fn nested_invalid_input_schema_does_not_register_plugin_tool() {
let mut invalid = tool("BadNestedSchema");
invalid.input_schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![invalid])))
.install_into_pending(&mut pending, &mut hooks);
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "invalid input_schema"));
assert!(has_diagnostic(&report, "$.properties.query"));
}
#[test]
fn pdk_tool_output_shape_is_accepted_by_wasm_decoder() {
let pdk_output =
yoi_plugin_pdk::ToolOutput::json("pdk ok", serde_json::json!({"answer": 42}))
.unwrap()
.to_json_string();
let output = decode_plugin_wasm_output(pdk_output.as_bytes()).unwrap();
assert_eq!(output.summary, "pdk ok");
assert_eq!(output.content.as_deref(), Some(r#"{"answer":42}"#));
}
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),
request: Vec::new(),
websocket: Vec::new(),
fs: Vec::new(),
};
(dir, record)
}
fn wat_bytes(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!(r#"\{:02x}"#, byte))
.collect()
}
fn component_instance_with_outputs(
start: &[u8],
status: &[u8],
stop: &[u8],
tool: &[u8],
ingress: &[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) "{}")
(data (i32.const 2048) "{}")
(data (i32.const 3072) "{}")
(data (i32.const 4096) "{}")
(data (i32.const 5120) "{}")
(func $write (param i32 i32)
(i32.store (i32.const 6144) (local.get 0))
(i32.store (i32.const 6148) (local.get 1)))
(func (export "start") (param i32 i32) (result i32)
(call $write (i32.const 1024) (i32.const {}))
(i32.const 6144))
(func (export "status") (result i32)
(call $write (i32.const 2048) (i32.const {}))
(i32.const 6144))
(func (export "stop") (result i32)
(call $write (i32.const 3072) (i32.const {}))
(i32.const 6144))
(func (export "tool") (param i32 i32 i32 i32) (result i32)
(call $write (i32.const 4096) (i32.const {}))
(i32.const 6144))
(func (export "ingress") (param i32 i32 i32 i32) (result i32)
(call $write (i32.const 5120) (i32.const {}))
(i32.const 6144))
)
(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 "start" (core func $start_core))
(alias core export $i "status" (core func $status_core))
(alias core export $i "stop" (core func $stop_core))
(alias core export $i "tool" (core func $tool_core))
(alias core export $i "ingress" (core func $ingress_core))
(type $start_ty (func (param "config-json" string) (result string)))
(type $noarg_ty (func (result string)))
(type $twoarg_ty (func (param "name" string) (param "json" string) (result string)))
(func $start (type $start_ty) (canon lift (core func $start_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(func $status (type $noarg_ty) (canon lift (core func $status_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(func $stop (type $noarg_ty) (canon lift (core func $stop_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(func $tool (type $twoarg_ty) (canon lift (core func $tool_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(func $ingress (type $twoarg_ty) (canon lift (core func $ingress_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(export "start" (func $start))
(export "status" (func $status))
(export "stop" (func $stop))
(export "handle-tool" (func $tool))
(export "handle-ingress" (func $ingress))
)"#,
wat_bytes(start),
wat_bytes(status),
wat_bytes(stop),
wat_bytes(tool),
wat_bytes(ingress),
start.len(),
status.len(),
stop.len(),
tool.len(),
ingress.len(),
))
.unwrap()
}
fn component_tool_that_returns(output: &[u8]) -> Vec<u8> {
component_tool_with_memory_pages(output, 1)
}
fn component_tool_with_memory_pages(output: &[u8], memory_pages: usize) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(core module $m
(memory (export "memory") {})
(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))
)"#,
memory_pages,
wat_bytes(output),
output.len()
))
.expect("valid component wat")
}
fn component_tool_with_table_elements(output: &[u8], table_elements: usize) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(core module $m
(memory (export "memory") 1)
(table {} funcref)
(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))
)"#,
table_elements,
wat_bytes(output),
output.len()
))
.expect("valid component wat")
}
fn component_tool_importing_request(output: &[u8]) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(import "yoi:host/request@1.0.0" (instance $request (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_memory_limit_fails_closed_before_string_lift() {
let oversized_memory_pages = (PLUGIN_WASM_MEMORY_BYTES / 65_536) + 1;
let (_dir, record) = resolved_record_with_component(component_tool_with_memory_pages(
br#"{"summary":"should not lift"}"#,
oversized_memory_pages,
));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component memory limit is enforced");
assert!(format!("{error:?}").contains("growing memory"), "{error:?}");
}
#[test]
fn component_table_limit_fails_closed() {
let (_dir, record) = resolved_record_with_component(component_tool_with_table_elements(
br#"{"summary":"should not run"}"#,
PLUGIN_WASM_TABLE_ELEMENTS + 1,
));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component table limit is enforced");
assert!(format!("{error:?}").contains("growing table"), "{error:?}");
}
#[test]
fn component_output_cap_still_fails_closed_after_bounded_lift() {
let output = format!(
r#"{{"summary":"too big","content":"{}"}}"#,
"x".repeat(PLUGIN_WASM_MAX_OUTPUT_BYTES)
);
let (_dir, record) =
resolved_record_with_component(component_tool_with_memory_pages(output.as_bytes(), 2));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component output cap is enforced");
assert!(format!("{error:?}").contains("output exceeds"), "{error:?}");
}
#[test]
fn component_tool_denies_host_import_without_matching_grant() {
let (_dir, record) = resolved_record_with_component(component_tool_importing_request(
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 static_inspection_reports_covering_request_grants_as_runtime_eligible() {
let mut exact_manifest_broad_grant = record_with_request_grant();
exact_manifest_broad_grant.grants.request = vec![PluginRequestGrant {
scheme: "*".to_string(),
host: "*".to_string(),
port: None,
methods: vec!["GET".to_string()],
path_prefixes: Vec::new(),
}];
let inspection = inspect_resolved_plugin_static(&exact_manifest_broad_grant);
let target = inspection
.host_apis
.iter()
.find(|api| {
api.permission
.starts_with("host_api.request target https://api.example.test")
})
.expect("manifest request target inspection");
assert!(target.requested);
assert!(target.granted);
assert!(target.eligible);
let diagnostic = target.diagnostic.as_deref().unwrap_or_default();
assert!(
diagnostic.contains("broad/arbitrary") || diagnostic.contains("partially covered"),
"{target:#?}"
);
let broad_grant = inspection
.host_apis
.iter()
.find(|api| api.permission.starts_with("host_api.request grant *://*"))
.expect("broad grant inspection");
assert!(broad_grant.requested);
assert!(broad_grant.granted);
assert!(broad_grant.eligible);
assert!(
!inspection
.host_apis
.iter()
.any(|api| api.permission.starts_with("host_api.request grant-only")),
"{:#?}",
inspection.host_apis
);
}
#[test]
fn static_inspection_reports_request_grant_intersections_as_runtime_eligible() {
let mut broad_manifest_exact_grant = record_with_request_grant();
broad_manifest_exact_grant.manifest.request = vec![PluginRequestGrant {
scheme: "*".to_string(),
host: "*".to_string(),
port: None,
methods: vec!["GET".to_string(), "POST".to_string()],
path_prefixes: Vec::new(),
}];
let inspection = inspect_resolved_plugin_static(&broad_manifest_exact_grant);
let target = inspection
.host_apis
.iter()
.find(|api| api.permission.starts_with("host_api.request target *://*"))
.expect("broad manifest target inspection");
assert!(target.requested);
assert!(target.granted);
assert!(target.eligible);
assert!(
target
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("partially covered"),
"{target:#?}"
);
let exact_grant = inspection
.host_apis
.iter()
.find(|api| {
api.permission
.starts_with("host_api.request grant https://api.example.test")
})
.expect("exact grant inspection");
assert!(exact_grant.requested);
assert!(exact_grant.granted);
assert!(exact_grant.eligible);
assert!(
!inspection
.host_apis
.iter()
.any(|api| api.permission.starts_with("host_api.request grant-only")),
"{:#?}",
inspection.host_apis
);
}
#[test]
fn legacy_raw_wasm_runtime_is_rejected_without_fallback_execution() {
let mut record = record(vec![tool("PluginEcho")]);
record.manifest.runtime = Some(PluginRuntimeManifest {
kind: LEGACY_PLUGIN_RUNTIME_WASM_KIND.to_string(),
entry: Some("plugin.wasm".to_string()),
abi: Some("yoi-plugin-wasm-1".to_string()),
component: None,
world: None,
});
let inspection = inspect_resolved_plugin_static(&record);
assert!(!inspection.runtime.eligible);
assert!(
inspection
.runtime
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("legacy raw wasm plugin runtime is not an active execution path")
);
let error = match PluginInstanceHandle::new(record) {
Ok(_) => panic!("legacy raw wasm runtime unexpectedly instantiated"),
Err(error) => error.bounded_message(),
};
assert!(
error.contains("legacy raw wasm plugin runtime is not supported"),
"{error}"
);
}
#[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());
}
#[test]
fn static_inspection_does_not_read_or_execute_package() {
let mut record = record(vec![tool("Echo")]);
record.package_path = std::path::PathBuf::from("/no/such/plugin.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.tools.len(), 1);
assert!(inspection.tools[0].eligible);
assert!(inspection.statically_eligible());
}
#[test]
fn static_inspection_reports_missing_tool_grant() {
let mut record = record(vec![tool("Echo")]);
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()),
});
record.grants.permissions = vec![PluginPermission::surface(PluginSurface::Tool)];
let inspection = inspect_resolved_plugin_static(&record);
assert!(!inspection.statically_eligible());
assert!(!inspection.tools[0].eligible);
assert!(
inspection.tools[0]
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("grant")
);
}
#[test]
fn static_inspection_reports_invalid_tool_definition() {
let mut bad_schema = tool("Echo");
bad_schema.input_schema = json!({"type":"string"});
let mut record = record(vec![bad_schema]);
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.statically_eligible());
assert!(!inspection.tools[0].eligible);
let diagnostic = inspection.tools[0]
.diagnostic
.as_deref()
.unwrap_or_default();
assert!(diagnostic.contains("invalid input_schema"));
assert!(diagnostic.contains("root schema type must be `object`"));
}
#[test]
fn static_inspection_reports_invalid_and_duplicate_tool_names() {
let mut invalid = tool("Bad Tool");
invalid.input_schema = json!({"type":"object"});
let mut first_duplicate = tool("Echo");
let mut second_duplicate = tool("Echo");
first_duplicate.input_schema = json!({"type":"object"});
second_duplicate.input_schema = json!({"type":"object"});
let mut record = record(vec![invalid, first_duplicate, second_duplicate]);
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.statically_eligible());
assert!(
inspection.tools[0]
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("invalid name")
);
assert!(
inspection.tools[1]
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("duplicate name")
);
assert!(
inspection.tools[2]
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("duplicate name")
);
}
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
}
#[derive(Clone, Default)]
struct MockWebSocketClient {
closed: Arc<std::sync::atomic::AtomicUsize>,
opens: Arc<std::sync::atomic::AtomicUsize>,
}
impl PluginWebSocketClient for MockWebSocketClient {
fn supports_bounded_open(&self) -> bool {
true
}
fn open(
&self,
_request: &PluginWebSocketOpenRequest,
_url: &reqwest::Url,
_limits: PluginWebSocketLimits,
) -> Result<Box<dyn PluginWebSocketConnection>, PluginWebSocketError> {
self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(Box::new(MockWebSocketConnection {
closed: self.closed.clone(),
next_recv: Some(PluginWebSocketRecvResponse::Text {
text: "hello".to_string(),
}),
}))
}
}
struct MockWebSocketConnection {
closed: Arc<std::sync::atomic::AtomicUsize>,
next_recv: Option<PluginWebSocketRecvResponse>,
}
impl PluginWebSocketConnection for MockWebSocketConnection {
fn send_text(&mut self, _text: &str) -> Result<(), PluginWebSocketError> {
Ok(())
}
fn recv_text(
&mut self,
_timeout: Duration,
_max_message_bytes: usize,
) -> Result<PluginWebSocketRecvResponse, PluginWebSocketError> {
Ok(self
.next_recv
.take()
.unwrap_or(PluginWebSocketRecvResponse::Closed))
}
fn close(&mut self) -> Result<(), PluginWebSocketError> {
self.closed
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
}
#[derive(Clone, Default)]
struct FailingWebSocketClient {
opens: Arc<std::sync::atomic::AtomicUsize>,
}
impl PluginWebSocketClient for FailingWebSocketClient {
fn supports_bounded_open(&self) -> bool {
true
}
fn open(
&self,
_request: &PluginWebSocketOpenRequest,
_url: &reqwest::Url,
_limits: PluginWebSocketLimits,
) -> Result<Box<dyn PluginWebSocketConnection>, PluginWebSocketError> {
self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(PluginWebSocketError::new("simulated bounded open failure"))
}
}
#[derive(Clone, Default)]
struct UnboundedWebSocketClient {
opens: Arc<std::sync::atomic::AtomicUsize>,
}
impl PluginWebSocketClient for UnboundedWebSocketClient {
fn supports_bounded_open(&self) -> bool {
false
}
fn open(
&self,
_request: &PluginWebSocketOpenRequest,
_url: &reqwest::Url,
_limits: PluginWebSocketLimits,
) -> Result<Box<dyn PluginWebSocketConnection>, PluginWebSocketError> {
self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(PluginWebSocketError::new("should not dial"))
}
}
fn websocket_grant(
scheme: &str,
host: &str,
port: Option<u16>,
paths: &[&str],
) -> PluginWebSocketGrant {
PluginWebSocketGrant {
scheme: scheme.to_string(),
host: host.to_string(),
port,
path_prefixes: paths.iter().map(|path| (*path).to_string()).collect(),
}
}
fn record_with_websocket(
manifest_targets: Vec<PluginWebSocketGrant>,
grants: Vec<PluginWebSocketGrant>,
) -> ResolvedPluginRecord {
let mut record = record(vec![]);
record.manifest.permissions = vec![PluginPermission::host_api(PluginHostApi::WebSocket)];
record.manifest.websocket = manifest_targets;
record.grants.websocket = grants;
record
}
#[test]
fn websocket_max_open_connections_rejects_before_network_open() {
let record = record_with_websocket(
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
);
let client = MockWebSocketClient::default();
let handles = PluginWebSocketHandles::default();
for _ in 0..PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS {
execute_plugin_websocket_open(
&record,
&client,
&handles,
br#"{"url":"wss://gateway.example.com/gateway"}"#,
)
.unwrap();
}
assert_eq!(
client.opens.load(std::sync::atomic::Ordering::SeqCst),
PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS
);
let error = execute_plugin_websocket_open(
&record,
&client,
&handles,
br#"{"url":"wss://gateway.example.com/gateway"}"#,
)
.unwrap_err();
assert!(error.0.contains("before dialing"));
assert_eq!(
client.opens.load(std::sync::atomic::Ordering::SeqCst),
PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS
);
}
#[test]
fn websocket_open_failure_releases_capacity_reservation() {
let record = record_with_websocket(
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
);
let failing = FailingWebSocketClient::default();
let handles = PluginWebSocketHandles::default();
let error = execute_plugin_websocket_open(
&record,
&failing,
&handles,
br#"{"url":"wss://gateway.example.com/gateway"}"#,
)
.unwrap_err();
assert!(error.0.contains("simulated bounded open failure"));
assert_eq!(handles.reservation_count(), 0);
let client = MockWebSocketClient::default();
let open = execute_plugin_websocket_open(
&record,
&client,
&handles,
br#"{"url":"wss://gateway.example.com/gateway"}"#,
)
.unwrap();
let open: PluginWebSocketOpenResponse = serde_json::from_slice(&open).unwrap();
assert_eq!(open.handle, 1);
}
#[test]
fn websocket_unbounded_open_client_fails_closed_before_dialing() {
let record = record_with_websocket(
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
);
let client = UnboundedWebSocketClient::default();
let handles = PluginWebSocketHandles::default();
let error = execute_plugin_websocket_open(
&record,
&client,
&handles,
br#"{"url":"wss://gateway.example.com/gateway"}"#,
)
.unwrap_err();
assert!(
error
.0
.contains("cannot guarantee bounded/cancellable open")
);
assert_eq!(client.opens.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!(handles.reservation_count(), 0);
}
#[test]
fn websocket_open_send_recv_close_is_bounded_and_explicit() {
let record = record_with_websocket(
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
);
let client = MockWebSocketClient::default();
let handles = PluginWebSocketHandles::default();
let open = execute_plugin_websocket_open(
&record,
&client,
&handles,
br#"{"url":"wss://gateway.example.com/gateway?v=10"}"#,
)
.unwrap();
let open: PluginWebSocketOpenResponse = serde_json::from_slice(&open).unwrap();
assert_eq!(open.handle, 1);
assert_eq!(open.url, "wss://gateway.example.com/gateway");
let send = execute_plugin_websocket_send_text(&handles, open.handle, b"ping").unwrap();
assert_eq!(send, br#"{"sent":true,"bytes":4}"#);
let recv = execute_plugin_websocket_recv(&handles, open.handle, 1).unwrap();
assert_eq!(recv, br#"{"type":"text","text":"hello"}"#);
let close = execute_plugin_websocket_close(&handles, open.handle).unwrap();
assert_eq!(close, br#"{"closed":true}"#);
assert_eq!(client.closed.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn websocket_open_requires_manifest_and_grant() {
let client = MockWebSocketClient::default();
let handles = PluginWebSocketHandles::default();
let missing_grant = record_with_websocket(
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
vec![],
);
let error = execute_plugin_websocket_open(
&missing_grant,
&client,
&handles,
br#"{"url":"wss://gateway.example.com/gateway"}"#,
)
.unwrap_err();
assert!(error.0.contains("enabled WebSocket grants"));
let missing_manifest = record_with_websocket(
vec![],
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
);
let error = execute_plugin_websocket_open(
&missing_manifest,
&client,
&handles,
br#"{"url":"wss://gateway.example.com/gateway"}"#,
)
.unwrap_err();
assert!(error.0.contains("not declared"));
assert_eq!(client.opens.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
fn websocket_loopback_requires_explicit_manifest_and_grant() {
let client = MockWebSocketClient::default();
let handles = PluginWebSocketHandles::default();
let denied = record_with_websocket(
vec![websocket_grant("ws", "127.0.0.1", Some(8080), &["/socket"])],
vec![],
);
assert!(
execute_plugin_websocket_open(
&denied,
&client,
&handles,
br#"{"url":"ws://127.0.0.1:8080/socket"}"#,
)
.is_err()
);
let allowed = record_with_websocket(
vec![websocket_grant("ws", "127.0.0.1", Some(8080), &["/socket"])],
vec![websocket_grant("ws", "127.0.0.1", Some(8080), &["/socket"])],
);
assert!(
execute_plugin_websocket_open(
&allowed,
&client,
&handles,
br#"{"url":"ws://127.0.0.1:8080/socket"}"#,
)
.is_ok()
);
}
#[test]
fn websocket_rejects_guest_headers_and_binary_send_surface() {
let record = record_with_websocket(
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
vec![websocket_grant(
"wss",
"gateway.example.com",
None,
&["/gateway"],
)],
);
let error = validate_plugin_websocket_open_request(
&record,
br#"{"url":"wss://gateway.example.com/gateway","headers":[{"name":"authorization","value":"secret"}]}"#,
)
.unwrap_err();
assert!(error.0.contains("handshake headers"));
let handles = PluginWebSocketHandles::default();
let invalid_utf8 = [0xff, 0xfe];
let error = execute_plugin_websocket_send_text(&handles, 1, &invalid_utf8).unwrap_err();
assert!(error.0.contains("UTF-8"));
}
#[test]
fn websocket_static_inspection_reports_grant_only_missing_and_broad() {
let grant_only = record_with_websocket(
vec![websocket_grant(
"wss",
"declared.example.com",
None,
&["/gateway"],
)],
vec![websocket_grant("*", "*", None, &[])],
);
let inspection = inspect_resolved_plugin_static(&grant_only);
let ws: Vec<_> = inspection
.host_apis
.iter()
.filter(|item| item.permission.contains("host_api.websocket"))
.collect();
assert!(ws.iter().any(|item| item.permission.contains("target")));
assert!(ws.iter().any(|item| {
item.permission.contains("grant")
&& item
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("broad")
}));
let missing = record_with_websocket(
vec![websocket_grant(
"wss",
"missing.example.com",
None,
&["/gateway"],
)],
vec![],
);
let inspection = inspect_resolved_plugin_static(&missing);
assert!(inspection.host_apis.iter().any(|item| {
item.permission.contains("host_api.websocket target")
&& item
.diagnostic
.as_deref()
.unwrap_or_default()
.contains("missing")
}));
}
#[test]
fn request_host_api_still_rejects_websocket_urls() {
let record = record_with_request_grant();
let client = MockRequestClient::default();
let error = execute_plugin_request_request(
&record,
&client,
br#"{"method":"GET","url":"wss://api.example.com/socket"}"#,
)
.unwrap_err();
assert!(error.0.contains("WebSocket"));
}
}