feat: integrate isolated Plugin Tool executor
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "yoi-plugin-runtime"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
manifest.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
jsonschema = { version = "0.33.0", default-features = false }
|
||||
wasmparser = "0.244.0"
|
||||
wasmtime = { version = "=38.0.4", default-features = false, features = ["async", "component-model", "cranelift", "runtime", "std", "threads"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
wit-component = { version = "0.244.0", features = ["dummy-module"] }
|
||||
wit-parser = "0.244.0"
|
||||
wasmprinter = "0.244.0"
|
||||
wat = "1"
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] }
|
||||
@@ -0,0 +1,13 @@
|
||||
# yoi-plugin-runtime
|
||||
|
||||
Runtime-owned, standalone execution of exact v2 Tool Components. [`Executor`] compiles only artifacts reverified through `manifest::plugin`, then creates a fresh Store and instance for each invocation. It does not resolve catalogs, config, Worker plans, delivery caches or repository paths, and does not register Tools with a Worker.
|
||||
|
||||
[`host`] is an explicit asynchronous injection boundary, not an implementation of Server authority or HTTPS. Every invocation (including pure computation) requires live admission and result-publication authorization. Providers must bind their authenticated installation/plan/subject and narrowed grants to [`host::InvocationContext`], authorize every protected operation, and make pending work cancellation-safe. There is no default provider or product allow-all fallback. Dropping a provider future must release temporary resources; an already dispatched external effect is not rolled back. Durable effect receipts, secret resolution, URL/DNS/TLS policy and real network transport belong to the Host provider, not this sandbox.
|
||||
|
||||
Component instantiation is not a Tool export: all Host imports, including logging, are rejected until the export context is installed. Service start/stop semantics are outside this crate. Only exact WIT imports are linked, without WASI, filesystem, environment, process, clock or network ambient authority. Call-local connection resources hold opaque provider objects; guest resource representations never identify a Server resource or issue a grant.
|
||||
|
||||
A single deadline covers admission, instantiation, guest execution, asynchronous Host waits, post-return and publication. Fuel bounds computation and cooperatively yields; epoch interruption cooperatively yields as well. Store memory/table/instance limits, call-local resource and operation counts, serialized payload and diagnostic budgets complement those bounds. Text/JSON overflows fail rather than returning truncated success; excess diagnostic entries are dropped and counted. Guest diagnostics are untrusted, must not contain secrets, and are separate from model output. Host adapters must not log raw request/response or credentials.
|
||||
|
||||
The Tool sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity.
|
||||
|
||||
Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. This standalone core is not connected to normal Worker launch/restore; product integration must supply the live Server provider.
|
||||
@@ -0,0 +1,713 @@
|
||||
use crate::sandbox::SandboxLimits;
|
||||
use crate::{
|
||||
bindings,
|
||||
host::{self, Connection, Host, InvocationContext, Operation, errors, http, logging},
|
||||
};
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginPackageManifest, PluginToolManifest};
|
||||
use serde::Serialize;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::time::Instant;
|
||||
use wasmtime::component::{Component, Linker, Resource, ResourceTable};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Limits {
|
||||
pub payload_bytes: usize,
|
||||
pub config_bytes: usize,
|
||||
pub memory_bytes: usize,
|
||||
pub table_elements: usize,
|
||||
pub instances: usize,
|
||||
pub memories: usize,
|
||||
pub tables: usize,
|
||||
pub resources: usize,
|
||||
pub fuel: u64,
|
||||
pub wall_time: Duration,
|
||||
pub host_operations: usize,
|
||||
pub http_body_bytes: usize,
|
||||
pub http_header_bytes: usize,
|
||||
pub http_header_pairs: usize,
|
||||
pub log_bytes: usize,
|
||||
}
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
payload_bytes: 64 * 1024,
|
||||
config_bytes: 64 * 1024,
|
||||
memory_bytes: 256 * 1024 * 1024,
|
||||
table_elements: 10_000,
|
||||
instances: 32,
|
||||
memories: 4,
|
||||
tables: 8,
|
||||
resources: 32,
|
||||
fuel: 10_000_000,
|
||||
wall_time: Duration::from_secs(30),
|
||||
host_operations: 32,
|
||||
http_body_bytes: 1024 * 1024,
|
||||
http_header_bytes: 16 * 1024,
|
||||
http_header_pairs: 64,
|
||||
log_bytes: 16 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ExecutionError {
|
||||
#[error("invalid artifact: {0}")]
|
||||
Artifact(String),
|
||||
#[error("unsupported Component execution policy: {0}")]
|
||||
UnsupportedComponent(String),
|
||||
#[error("invalid invocation: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("invalid output: {0}")]
|
||||
InvalidOutput(String),
|
||||
#[error("invocation cancelled")]
|
||||
Cancelled,
|
||||
#[error("invocation deadline exceeded")]
|
||||
Deadline,
|
||||
#[error("Host denied or failed operation: {0:?}")]
|
||||
Host(errors::HostError),
|
||||
#[error("guest error {code}: {message}")]
|
||||
Guest { code: String, message: String },
|
||||
#[error("Component trap or resource limit: {0}")]
|
||||
Trap(String),
|
||||
}
|
||||
|
||||
/// Content identity only; both digests are independently verified by the package owner.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactIdentity {
|
||||
pub package_digest: String,
|
||||
pub artifact_digest: String,
|
||||
pub artifact_size: u64,
|
||||
}
|
||||
|
||||
/// Immutable compiled bytes and declarations, not execution authority.
|
||||
/// Private fields prevent mutable package metadata from bypassing verification.
|
||||
pub struct CompiledTool {
|
||||
component: Component,
|
||||
manifest: PluginPackageManifest,
|
||||
identity: ArtifactIdentity,
|
||||
}
|
||||
impl CompiledTool {
|
||||
pub fn identity(&self) -> &ArtifactIdentity {
|
||||
&self.identity
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InvocationResult {
|
||||
pub output: bindings::exports::yoi::plugin::tool::Output,
|
||||
pub dropped_logs: u64,
|
||||
}
|
||||
|
||||
pub struct Executor {
|
||||
engine: Engine,
|
||||
limits: Limits,
|
||||
}
|
||||
impl Executor {
|
||||
/// Limits are fixed by the Host, never selected by guest input.
|
||||
pub fn new(limits: Limits) -> wasmtime::Result<Self> {
|
||||
let ceiling = Limits::default();
|
||||
macro_rules! check_ceiling {
|
||||
($($field:ident),+ $(,)?) => { $(
|
||||
if limits.$field > ceiling.$field {
|
||||
return Err(wasmtime::Error::msg(concat!("limit exceeds supported ceiling: ", stringify!($field))));
|
||||
}
|
||||
)+ };
|
||||
}
|
||||
check_ceiling!(
|
||||
payload_bytes,
|
||||
config_bytes,
|
||||
memory_bytes,
|
||||
table_elements,
|
||||
instances,
|
||||
memories,
|
||||
tables,
|
||||
resources,
|
||||
fuel,
|
||||
wall_time,
|
||||
host_operations,
|
||||
http_body_bytes,
|
||||
http_header_bytes,
|
||||
http_header_pairs,
|
||||
log_bytes
|
||||
);
|
||||
let mut config = Config::new();
|
||||
config
|
||||
.async_support(true)
|
||||
.wasm_component_model(true)
|
||||
.consume_fuel(true)
|
||||
.epoch_interruption(true)
|
||||
.wasm_threads(false)
|
||||
.wasm_shared_everything_threads(false);
|
||||
Ok(Self {
|
||||
engine: Engine::new(&config)?,
|
||||
limits,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> &Limits {
|
||||
&self.limits
|
||||
}
|
||||
|
||||
/// Reuses the canonical package/digest/ABI validator; no path discovery or
|
||||
/// duplicate pack/hash implementation is involved. Compilation executes no guest.
|
||||
pub fn compile(
|
||||
&self,
|
||||
artifact: &[u8],
|
||||
package_digest: &str,
|
||||
artifact_digest: &str,
|
||||
artifact_size: u64,
|
||||
) -> Result<CompiledTool, ExecutionError> {
|
||||
let verified = plugin::verify_plugin_artifact(
|
||||
artifact,
|
||||
package_digest,
|
||||
artifact_digest,
|
||||
artifact_size,
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.map_err(|e| ExecutionError::Artifact(e.message))?;
|
||||
let manifest = verified.package.manifest;
|
||||
let component_bytes = &verified.files[&manifest.runtime.component];
|
||||
crate::sandbox::check_component_resources(component_bytes)
|
||||
.map_err(|e| ExecutionError::UnsupportedComponent(e.to_string()))?;
|
||||
let component = Component::new(&self.engine, component_bytes)
|
||||
.map_err(|e| ExecutionError::Artifact(e.to_string()))?;
|
||||
Ok(CompiledTool {
|
||||
component,
|
||||
manifest,
|
||||
identity: ArtifactIdentity {
|
||||
package_digest: package_digest.into(),
|
||||
artifact_digest: artifact_digest.into(),
|
||||
artifact_size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// All computation and Host waits share the earlier of the caller deadline
|
||||
/// and local ceiling. Dropping this future also drops its Store and resources.
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
compiled: &CompiledTool,
|
||||
mut context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
tool_name: &str,
|
||||
input_json: &str,
|
||||
config_json: &str,
|
||||
) -> Result<InvocationResult, ExecutionError> {
|
||||
context.deadline = context.deadline.min(Instant::now() + self.limits.wall_time);
|
||||
context.cancellation = context.cancellation.child_token();
|
||||
let _lifetime = InvocationLifetime(context.cancellation.clone());
|
||||
let deadline = context.deadline;
|
||||
let cancellation = context.cancellation.clone();
|
||||
let execution = self.run(
|
||||
compiled,
|
||||
context,
|
||||
provider,
|
||||
tool_name,
|
||||
input_json,
|
||||
config_json,
|
||||
);
|
||||
tokio::pin!(execution);
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(10));
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => return Err(ExecutionError::Cancelled),
|
||||
_ = tokio::time::sleep_until(deadline) => return Err(ExecutionError::Deadline),
|
||||
result = &mut execution => {
|
||||
if cancellation.is_cancelled() { return Err(ExecutionError::Cancelled); }
|
||||
if Instant::now() >= deadline { return Err(ExecutionError::Deadline); }
|
||||
return result;
|
||||
},
|
||||
_ = tick.tick() => self.engine.increment_epoch(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
compiled: &CompiledTool,
|
||||
context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
tool_name: &str,
|
||||
input_json: &str,
|
||||
config_json: &str,
|
||||
) -> Result<InvocationResult, ExecutionError> {
|
||||
if !Engine::same(&self.engine, compiled.component.engine()) {
|
||||
return Err(ExecutionError::Artifact(
|
||||
"Component belongs to another executor".into(),
|
||||
));
|
||||
}
|
||||
if context.artifact != compiled.identity || context.tool_name != tool_name {
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"artifact/Tool does not match Host-bound context".into(),
|
||||
));
|
||||
}
|
||||
let tool = compiled
|
||||
.manifest
|
||||
.tools
|
||||
.iter()
|
||||
.find(|t| t.name == tool_name)
|
||||
.ok_or_else(|| ExecutionError::InvalidInput("undeclared Tool".into()))?;
|
||||
if input_json.len() > self.limits.payload_bytes
|
||||
|| config_json.len() > self.limits.config_bytes
|
||||
|| context.invocation_id.len() > 1024
|
||||
{
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"input/config/identity exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
validate_json(&tool.input_schema, input_json).map_err(ExecutionError::InvalidInput)?;
|
||||
serde_json::from_str::<serde_json::Value>(config_json)
|
||||
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?;
|
||||
provider
|
||||
.authorize(&context, Operation::Admit)
|
||||
.await
|
||||
.map_err(ExecutionError::Host)?;
|
||||
check_current(&context).map_err(ExecutionError::Host)?;
|
||||
let limits = &self.limits;
|
||||
let state = State {
|
||||
context: None,
|
||||
provider: provider.clone(),
|
||||
limits: limits.clone(),
|
||||
manifest: compiled.manifest.clone(),
|
||||
table: ResourceTable::new(),
|
||||
operations: 0,
|
||||
resources: 0,
|
||||
log_bytes: 0,
|
||||
dropped_logs: 0,
|
||||
store_limits: SandboxLimits::new(limits),
|
||||
};
|
||||
let mut store = Store::new(&self.engine, state);
|
||||
store.limiter(|state| &mut state.store_limits);
|
||||
store.set_fuel(limits.fuel).map_err(trap)?;
|
||||
store
|
||||
.fuel_async_yield_interval(Some(10_000))
|
||||
.map_err(trap)?;
|
||||
store.set_epoch_deadline(1);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
let mut linker = Linker::new(&self.engine);
|
||||
bindings::HttpsLoggedToolPlugin::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
|
||||
&mut linker,
|
||||
|s| s,
|
||||
)
|
||||
.map_err(trap)?;
|
||||
let instance = bindings::HttpsLoggedToolPlugin::instantiate_async(
|
||||
&mut store,
|
||||
&compiled.component,
|
||||
&linker,
|
||||
)
|
||||
.await
|
||||
.map_err(trap)?;
|
||||
// No import during instantiate/start may act, including logging. Service
|
||||
// on-start is a different world and has no adapter here.
|
||||
check_current(&context).map_err(ExecutionError::Host)?;
|
||||
store.data_mut().context = Some(context.clone());
|
||||
let invocation = bindings::exports::yoi::plugin::tool::Invocation {
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
tool_name: tool_name.into(),
|
||||
input_json: input_json.into(),
|
||||
config_json: config_json.into(),
|
||||
remaining_ms: context
|
||||
.deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(u32::MAX as u128) as u32,
|
||||
};
|
||||
let result = instance
|
||||
.yoi_plugin_tool()
|
||||
.call_invoke(&mut store, &invocation)
|
||||
.await
|
||||
.map_err(trap)?;
|
||||
check_current(&context).map_err(ExecutionError::Host)?;
|
||||
provider
|
||||
.authorize(&context, Operation::Publish)
|
||||
.await
|
||||
.map_err(ExecutionError::Host)?;
|
||||
check_current(&context).map_err(ExecutionError::Host)?;
|
||||
let output = match result {
|
||||
Ok(output) => {
|
||||
validate_output(tool, &output, limits.payload_bytes)?;
|
||||
output
|
||||
}
|
||||
Err(error) => {
|
||||
if error.message.len().saturating_add(error.code.len()) > limits.payload_bytes {
|
||||
return Err(ExecutionError::InvalidOutput(
|
||||
"guest error exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
let code = if tool.error_codes.contains(&error.code) {
|
||||
error.code
|
||||
} else {
|
||||
"plugin-failed".into()
|
||||
};
|
||||
#[derive(Serialize)]
|
||||
struct Failure<'a> {
|
||||
code: &'a str,
|
||||
message: &'a str,
|
||||
}
|
||||
if serde_json::to_vec(&Failure {
|
||||
code: &code,
|
||||
message: &error.message,
|
||||
})
|
||||
.unwrap()
|
||||
.len()
|
||||
> limits.payload_bytes
|
||||
{
|
||||
return Err(ExecutionError::InvalidOutput(
|
||||
"serialized guest error exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
return Err(ExecutionError::Guest {
|
||||
code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(InvocationResult {
|
||||
output,
|
||||
dropped_logs: store.data().dropped_logs,
|
||||
})
|
||||
}
|
||||
}
|
||||
fn trap(e: wasmtime::Error) -> ExecutionError {
|
||||
ExecutionError::Trap(format!("{e:#}"))
|
||||
}
|
||||
fn validate_json(schema: &serde_json::Value, json: &str) -> Result<(), String> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
|
||||
// Schema admission already forbids external refs/dialects in manifest::plugin.
|
||||
let validator = jsonschema::options()
|
||||
.with_draft(jsonschema::Draft::Draft202012)
|
||||
.should_validate_formats(true)
|
||||
.build(schema)
|
||||
.map_err(|e| e.to_string())?;
|
||||
validator.validate(&value).map_err(|e| e.to_string())
|
||||
}
|
||||
fn validate_output(
|
||||
tool: &PluginToolManifest,
|
||||
output: &bindings::exports::yoi::plugin::tool::Output,
|
||||
max: usize,
|
||||
) -> Result<(), ExecutionError> {
|
||||
let raw_bytes = output
|
||||
.summary
|
||||
.len()
|
||||
.saturating_add(output.content.as_ref().map_or(0, String::len))
|
||||
.saturating_add(output.structured_json.as_ref().map_or(0, String::len));
|
||||
if raw_bytes > max {
|
||||
return Err(ExecutionError::InvalidOutput("output exceeds limit".into()));
|
||||
}
|
||||
// Use the complete serialized envelope budget, not separate per-field limits.
|
||||
#[derive(Serialize)]
|
||||
struct Envelope<'a> {
|
||||
summary: &'a str,
|
||||
content: &'a Option<String>,
|
||||
structured_json: &'a Option<String>,
|
||||
}
|
||||
let bytes = serde_json::to_vec(&Envelope {
|
||||
summary: &output.summary,
|
||||
content: &output.content,
|
||||
structured_json: &output.structured_json,
|
||||
})
|
||||
.unwrap();
|
||||
if bytes.len() > max {
|
||||
return Err(ExecutionError::InvalidOutput("output exceeds limit".into()));
|
||||
}
|
||||
let json = output
|
||||
.structured_json
|
||||
.as_deref()
|
||||
.ok_or_else(|| ExecutionError::InvalidOutput("missing structured output".into()))?;
|
||||
validate_json(&tool.output_schema, json).map_err(ExecutionError::InvalidOutput)
|
||||
}
|
||||
|
||||
struct InvocationLifetime(host::CancellationToken);
|
||||
impl Drop for InvocationLifetime {
|
||||
fn drop(&mut self) {
|
||||
self.0.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
context: Option<InvocationContext>,
|
||||
provider: Arc<dyn Host>,
|
||||
limits: Limits,
|
||||
manifest: PluginPackageManifest,
|
||||
table: ResourceTable,
|
||||
operations: usize,
|
||||
resources: usize,
|
||||
log_bytes: usize,
|
||||
dropped_logs: u64,
|
||||
store_limits: SandboxLimits,
|
||||
}
|
||||
fn check_current(context: &InvocationContext) -> Result<(), errors::HostError> {
|
||||
if context.cancellation.is_cancelled() {
|
||||
return Err(host::error(errors::Code::Cancelled, "invocation cancelled"));
|
||||
}
|
||||
if Instant::now() >= context.deadline {
|
||||
return Err(host::error(
|
||||
errors::Code::DeadlineExceeded,
|
||||
"invocation deadline exceeded",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
impl State {
|
||||
fn current(&self) -> wasmtime::Result<InvocationContext> {
|
||||
let context = self
|
||||
.context
|
||||
.as_ref()
|
||||
.ok_or_else(|| wasmtime::Error::msg("Host import outside Tool export context"))?;
|
||||
check_current(context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
Ok(context.clone())
|
||||
}
|
||||
fn operation(&mut self) -> Result<(), errors::HostError> {
|
||||
if self.operations >= self.limits.host_operations {
|
||||
return Err(host::error(
|
||||
errors::Code::QuotaExceeded,
|
||||
"Host operation limit",
|
||||
));
|
||||
}
|
||||
self.operations += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl bindings::yoi::host::errors::Host for State {}
|
||||
impl bindings::yoi::host::connections::HostConnection for State {
|
||||
async fn drop(&mut self, resource: Resource<Connection>) -> wasmtime::Result<()> {
|
||||
self.table.delete(resource)?;
|
||||
self.resources -= 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl bindings::yoi::host::connections::Host for State {
|
||||
async fn open(
|
||||
&mut self,
|
||||
slot: String,
|
||||
) -> wasmtime::Result<Result<Resource<Connection>, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
if let Err(e) = self.operation() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let declared = self.manifest.connections.iter().any(|s| s.name == slot);
|
||||
if !declared || !context.grants.connection_slots.contains(&slot) {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::Denied,
|
||||
"connection slot not granted",
|
||||
)));
|
||||
}
|
||||
if self.resources >= self.limits.resources {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::QuotaExceeded,
|
||||
"resource limit",
|
||||
)));
|
||||
}
|
||||
if let Err(e) = self
|
||||
.provider
|
||||
.authorize(&context, Operation::Open { slot: &slot })
|
||||
.await
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
let binding = match self.provider.open(&context, &slot).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late connection result"))?;
|
||||
let resource = self.table.push(Connection { slot, binding })?;
|
||||
self.resources += 1;
|
||||
Ok(Ok(resource))
|
||||
}
|
||||
}
|
||||
impl bindings::yoi::host::http::Host for State {
|
||||
async fn send(
|
||||
&mut self,
|
||||
target: Resource<Connection>,
|
||||
mut input: http::Request,
|
||||
) -> wasmtime::Result<Result<http::Response, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
if let Err(e) = self.operation() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let connection = self.table.get(&target)?;
|
||||
let slot = connection.slot.clone();
|
||||
let binding = connection.binding.clone();
|
||||
if !context.grants.connection_slots.contains(&slot) {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::Denied,
|
||||
"connection slot not granted",
|
||||
)));
|
||||
}
|
||||
let declaration = self
|
||||
.manifest
|
||||
.connections
|
||||
.iter()
|
||||
.find(|s| s.name == slot)
|
||||
.unwrap();
|
||||
let method = match input.method {
|
||||
http::Method::Get => plugin::PluginHttpMethod::Get,
|
||||
http::Method::Head => plugin::PluginHttpMethod::Head,
|
||||
http::Method::Post => plugin::PluginHttpMethod::Post,
|
||||
http::Method::Put => plugin::PluginHttpMethod::Put,
|
||||
http::Method::Patch => plugin::PluginHttpMethod::Patch,
|
||||
http::Method::Delete => plugin::PluginHttpMethod::Delete,
|
||||
http::Method::Options => plugin::PluginHttpMethod::Options,
|
||||
};
|
||||
// Canonical URL/DNS/TLS/secret and narrower binding checks belong to the
|
||||
// provider. These static ceilings are not a network protection claim.
|
||||
if !declaration.methods.contains(&method)
|
||||
|| !declaration.path_prefixes.iter().any(|p| {
|
||||
input.path == *p
|
||||
|| input
|
||||
.path
|
||||
.strip_prefix(p)
|
||||
.is_some_and(|rest| p.ends_with('/') || rest.starts_with('/'))
|
||||
})
|
||||
{
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::Denied,
|
||||
"request exceeds package ceiling",
|
||||
)));
|
||||
}
|
||||
if !request_bounded(&input, &self.limits) {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::QuotaExceeded,
|
||||
"request exceeds limit",
|
||||
)));
|
||||
}
|
||||
input.timeout_ms = input.timeout_ms.min(15_000).min(
|
||||
context
|
||||
.deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(u32::MAX as u128) as u32,
|
||||
);
|
||||
let send_deadline = context
|
||||
.deadline
|
||||
.min(Instant::now() + Duration::from_millis(input.timeout_ms as u64));
|
||||
match tokio::time::timeout_at(
|
||||
send_deadline,
|
||||
self.provider.authorize(
|
||||
&context,
|
||||
Operation::Send {
|
||||
slot: &slot,
|
||||
binding: binding.as_ref(),
|
||||
request: &input,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => (),
|
||||
Ok(Err(error)) => return Ok(Err(error)),
|
||||
Err(_) => {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::DeadlineExceeded,
|
||||
"HTTP authorization deadline exceeded",
|
||||
)));
|
||||
}
|
||||
}
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
if Instant::now() >= send_deadline {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::DeadlineExceeded,
|
||||
"HTTP deadline exceeded before dispatch",
|
||||
)));
|
||||
}
|
||||
let response = match tokio::time::timeout_at(
|
||||
send_deadline,
|
||||
self.provider.send(&context, binding.as_ref(), input),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => Err(errors::HostError {
|
||||
code: errors::Code::DeadlineExceeded,
|
||||
message: "HTTP wait deadline exceeded".into(),
|
||||
diagnostic_id: String::new(),
|
||||
effect: errors::EffectObservation::Indeterminate,
|
||||
}),
|
||||
};
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late HTTP result"))?;
|
||||
Ok(response.and_then(|response| {
|
||||
if response.body.len() > self.limits.http_body_bytes
|
||||
|| !pairs_bounded(&response.headers, &self.limits)
|
||||
{
|
||||
Err(errors::HostError {
|
||||
code: errors::Code::QuotaExceeded,
|
||||
message: "response exceeds limit".into(),
|
||||
diagnostic_id: String::new(),
|
||||
effect: errors::EffectObservation::ResponseObserved,
|
||||
})
|
||||
} else {
|
||||
Ok(response)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn pairs_bounded(pairs: &[http::Pair], limits: &Limits) -> bool {
|
||||
pairs.len() <= limits.http_header_pairs
|
||||
&& pairs
|
||||
.iter()
|
||||
.try_fold(0usize, |n, p| {
|
||||
n.checked_add(p.name.len())?.checked_add(p.value.len())
|
||||
})
|
||||
.is_some_and(|n| n <= limits.http_header_bytes)
|
||||
}
|
||||
fn request_bounded(request: &http::Request, limits: &Limits) -> bool {
|
||||
request.body.len() <= limits.http_body_bytes
|
||||
&& request.path.len() <= limits.payload_bytes
|
||||
&& request.effect_key.len() <= 128
|
||||
&& !request.effect_key.is_empty()
|
||||
&& pairs_bounded(&request.headers, limits)
|
||||
&& pairs_bounded(&request.query, limits)
|
||||
}
|
||||
impl bindings::yoi::host::logging::Host for State {
|
||||
async fn log(
|
||||
&mut self,
|
||||
input: logging::Entry,
|
||||
) -> wasmtime::Result<Result<(), errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
if !context.grants.logging
|
||||
|| !self
|
||||
.manifest
|
||||
.requested_grants
|
||||
.contains(&plugin::PluginRequestedGrant::Logging)
|
||||
{
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::Denied,
|
||||
"logging not granted",
|
||||
)));
|
||||
}
|
||||
let size = input
|
||||
.event_code
|
||||
.len()
|
||||
.saturating_add(input.message.len())
|
||||
.saturating_add(
|
||||
input
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
f.name.len().saturating_add(match &f.value {
|
||||
logging::Scalar::Text(s) => s.len(),
|
||||
_ => 8,
|
||||
})
|
||||
})
|
||||
.sum::<usize>(),
|
||||
);
|
||||
if size > self.limits.log_bytes.saturating_sub(self.log_bytes) {
|
||||
self.dropped_logs = self.dropped_logs.saturating_add(1);
|
||||
return Ok(Ok(()));
|
||||
}
|
||||
if let Err(e) = self.operation() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.provider.authorize(&context, Operation::Log).await {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
self.log_bytes += size;
|
||||
let result = self.provider.log(&context, input).await;
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late log result"))?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Host-bound identity, grants and asynchronous provider contracts.
|
||||
//!
|
||||
//! The provider is the authority adapter: opaque bindings are not credentials
|
||||
//! supplied by guests. Implementations must live-check authorization, remain
|
||||
//! cancellation-safe, bound their own allocations and never auto-retry effects.
|
||||
|
||||
pub use crate::bindings::yoi::host::{errors, http, logging};
|
||||
use async_trait::async_trait;
|
||||
use std::{any::Any, collections::BTreeSet, sync::Arc};
|
||||
use tokio::time::Instant;
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Constructed by the authenticated Runtime integration, never from guest JSON.
|
||||
/// All fields are a snapshot upper bound, not a cached live authorization.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InvocationContext {
|
||||
pub workspace: String,
|
||||
pub installation: String,
|
||||
pub plan: String,
|
||||
pub subject: String,
|
||||
pub invocation_id: String,
|
||||
pub artifact: crate::ArtifactIdentity,
|
||||
pub tool_name: String,
|
||||
pub grants: Grants,
|
||||
pub deadline: Instant,
|
||||
pub cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Grants {
|
||||
pub logging: bool,
|
||||
pub connection_slots: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Opaque Host-owned capability, stored only within one invocation's Store.
|
||||
/// The provider must not interpret a guest resource representation as identity.
|
||||
pub struct Connection {
|
||||
pub(crate) slot: String,
|
||||
pub(crate) binding: Arc<dyn Any + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Each operation requires a fresh owner check; admission covers pure Tools too.
|
||||
pub enum Operation<'a> {
|
||||
Admit,
|
||||
Publish,
|
||||
Open {
|
||||
slot: &'a str,
|
||||
},
|
||||
Send {
|
||||
slot: &'a str,
|
||||
binding: &'a (dyn Any + Send + Sync),
|
||||
request: &'a http::Request,
|
||||
},
|
||||
Log,
|
||||
}
|
||||
|
||||
/// No default implementation is supplied. Test providers belong to test targets.
|
||||
///
|
||||
/// `authorize` must check the current installation/artifact/plan/subject and
|
||||
/// operation against live owner policy. `open`/`send` must also enforce concrete
|
||||
/// binding policy, secret handling and effect receipts at dispatch time. The
|
||||
/// executor only enforces static package and invocation ceilings. All waits are
|
||||
/// dropped on cancellation/deadline; providers must not leave detached work.
|
||||
#[async_trait]
|
||||
pub trait Host: Send + Sync {
|
||||
async fn authorize(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
operation: Operation<'_>,
|
||||
) -> Result<(), errors::HostError>;
|
||||
async fn open(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
slot: &str,
|
||||
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError>;
|
||||
async fn send(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
binding: &(dyn Any + Send + Sync),
|
||||
request: http::Request,
|
||||
) -> Result<http::Response, errors::HostError>;
|
||||
async fn log(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
entry: logging::Entry,
|
||||
) -> Result<(), errors::HostError>;
|
||||
}
|
||||
|
||||
pub(crate) fn error(code: errors::Code, message: &str) -> errors::HostError {
|
||||
errors::HostError {
|
||||
code,
|
||||
message: message.into(),
|
||||
diagnostic_id: String::new(),
|
||||
effect: errors::EffectObservation::NotDispatched,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../../resources/plugin/wit",
|
||||
world: "https-logged-tool-plugin",
|
||||
imports: { default: async | trappable },
|
||||
exports: { default: async },
|
||||
with: { "yoi:host/connections/connection": crate::host::Connection },
|
||||
});
|
||||
}
|
||||
|
||||
mod executor;
|
||||
pub mod host;
|
||||
mod sandbox;
|
||||
pub use executor::*;
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Aggregate Store budgets: Wasmtime's built-in memory/table sizes are per
|
||||
//! object, so count accepted growth across all objects as well. Nothing in a
|
||||
//! Tool Store outlives that invocation; no cross-Store accounting is retained.
|
||||
use crate::Limits;
|
||||
use wasmtime::{ResourceLimiter, StoreLimits, StoreLimitsBuilder};
|
||||
|
||||
/// Additional execution-policy check, not a replacement ABI/package validator.
|
||||
/// Wasmtime's Store limiter does not meter Component-internal resource tables.
|
||||
/// The Tool ABI needs only imported Host resources, whose creation is metered
|
||||
/// by our adapter; guest-defined resources and allocators therefore fail closed.
|
||||
/// `parse_all` visits concrete nested Components as well as the outer one.
|
||||
pub(crate) fn check_component_resources(bytes: &[u8]) -> wasmtime::Result<()> {
|
||||
use wasmparser::{CanonicalFunction, ComponentType, Parser, Payload};
|
||||
for payload in Parser::new(0).parse_all(bytes) {
|
||||
match payload? {
|
||||
Payload::ComponentTypeSection(types) => {
|
||||
for ty in types {
|
||||
if matches!(ty?, ComponentType::Resource { .. }) {
|
||||
return Err(wasmtime::Error::msg(
|
||||
"guest-defined Component resources are unsupported by the Tool sandbox",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Payload::ComponentCanonicalSection(functions) => {
|
||||
for function in functions {
|
||||
if matches!(function?, CanonicalFunction::ResourceNew { .. }) {
|
||||
return Err(wasmtime::Error::msg(
|
||||
"guest Component resource.new is unsupported by the Tool sandbox",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) struct SandboxLimits {
|
||||
inner: StoreLimits,
|
||||
memory: Budget,
|
||||
table: Budget,
|
||||
}
|
||||
struct Budget {
|
||||
used: usize,
|
||||
last_growth: usize,
|
||||
ceiling: usize,
|
||||
}
|
||||
impl Budget {
|
||||
fn new(ceiling: usize) -> Self {
|
||||
Self {
|
||||
used: 0,
|
||||
last_growth: 0,
|
||||
ceiling,
|
||||
}
|
||||
}
|
||||
fn reserve(&mut self, current: usize, desired: usize, kind: &str) -> wasmtime::Result<()> {
|
||||
let growth = desired.saturating_sub(current);
|
||||
if growth > self.ceiling.saturating_sub(self.used) {
|
||||
return Err(wasmtime::Error::msg(format!(
|
||||
"aggregate {kind} budget exceeded"
|
||||
)));
|
||||
}
|
||||
self.used += growth;
|
||||
self.last_growth = growth;
|
||||
Ok(())
|
||||
}
|
||||
fn failed(&mut self) {
|
||||
self.used -= self.last_growth;
|
||||
self.last_growth = 0;
|
||||
}
|
||||
}
|
||||
impl SandboxLimits {
|
||||
pub(crate) fn new(limits: &Limits) -> Self {
|
||||
Self {
|
||||
inner: StoreLimitsBuilder::new()
|
||||
.memory_size(limits.memory_bytes)
|
||||
.table_elements(limits.table_elements)
|
||||
.instances(limits.instances)
|
||||
.memories(limits.memories)
|
||||
.tables(limits.tables)
|
||||
.trap_on_grow_failure(true)
|
||||
.build(),
|
||||
memory: Budget::new(limits.memory_bytes),
|
||||
table: Budget::new(limits.table_elements),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ResourceLimiter for SandboxLimits {
|
||||
fn memory_growing(
|
||||
&mut self,
|
||||
current: usize,
|
||||
desired: usize,
|
||||
maximum: Option<usize>,
|
||||
) -> wasmtime::Result<bool> {
|
||||
if !self.inner.memory_growing(current, desired, maximum)? {
|
||||
return Ok(false);
|
||||
}
|
||||
self.memory.reserve(current, desired, "memory")?;
|
||||
Ok(true)
|
||||
}
|
||||
fn table_growing(
|
||||
&mut self,
|
||||
current: usize,
|
||||
desired: usize,
|
||||
maximum: Option<usize>,
|
||||
) -> wasmtime::Result<bool> {
|
||||
if !self.inner.table_growing(current, desired, maximum)? {
|
||||
return Ok(false);
|
||||
}
|
||||
self.table.reserve(current, desired, "table")?;
|
||||
Ok(true)
|
||||
}
|
||||
fn memory_grow_failed(&mut self, error: wasmtime::Error) -> wasmtime::Result<()> {
|
||||
self.memory.failed();
|
||||
self.inner.memory_grow_failed(error)
|
||||
}
|
||||
fn table_grow_failed(&mut self, error: wasmtime::Error) -> wasmtime::Result<()> {
|
||||
self.table.failed();
|
||||
self.inner.table_grow_failed(error)
|
||||
}
|
||||
fn instances(&self) -> usize {
|
||||
self.inner.instances()
|
||||
}
|
||||
fn memories(&self) -> usize {
|
||||
self.inner.memories()
|
||||
}
|
||||
fn tables(&self) -> usize {
|
||||
self.inner.tables()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+367
@@ -0,0 +1,367 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "executor-test-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn 2.0.119",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoi-plugin-pdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "executor-test-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
|
||||
@@ -0,0 +1,138 @@
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
wit_bindgen::generate!({
|
||||
path: "../../../../resources/plugin/wit",
|
||||
world: "https-logged-tool-plugin",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
use exports::yoi::plugin::tool::{Guest, Invocation, Output, ToolError};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use yoi::host::{connections, http, logging};
|
||||
|
||||
static CALLS: AtomicU32 = AtomicU32::new(0);
|
||||
struct Plugin;
|
||||
impl Guest for Plugin {
|
||||
fn invoke(input: Invocation) -> Result<Output, ToolError> {
|
||||
let count = CALLS.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let mode = input.input_json.trim_matches('"');
|
||||
match mode {
|
||||
"trap" => panic!("guest trap"),
|
||||
"loop" => loop {
|
||||
std::hint::black_box(CALLS.load(Ordering::SeqCst));
|
||||
},
|
||||
"wall-loop" => {
|
||||
logging::log(&logging::Entry {
|
||||
level: logging::Level::Info,
|
||||
event_code: "count".into(),
|
||||
message: "loop entered".into(),
|
||||
fields: vec![],
|
||||
})
|
||||
.map_err(failure)?;
|
||||
loop {
|
||||
std::hint::black_box(CALLS.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
"memory" => {
|
||||
core::arch::wasm32::memory_grow::<0>(8192);
|
||||
}
|
||||
"error-output" => {
|
||||
return Err(ToolError {
|
||||
code: "typed".into(),
|
||||
message: "\"".repeat(40_000),
|
||||
});
|
||||
}
|
||||
"typed" | "unknown" => {
|
||||
return Err(ToolError {
|
||||
code: mode.into(),
|
||||
message: "bounded business failure".into(),
|
||||
});
|
||||
}
|
||||
"logging" => {
|
||||
for _ in 0..3 {
|
||||
logging::log(&logging::Entry {
|
||||
level: logging::Level::Info,
|
||||
event_code: "count".into(),
|
||||
message: "safe diagnostic".into(),
|
||||
fields: vec![logging::Field {
|
||||
name: "calls".into(),
|
||||
value: logging::Scalar::Unsigned(count as u64),
|
||||
}],
|
||||
})
|
||||
.map_err(failure)?;
|
||||
}
|
||||
}
|
||||
"http" | "stale" | "foreign" | "resources" => {
|
||||
if mode == "resources" {
|
||||
let _resources: Vec<_> =
|
||||
(0..64).map(|_| connections::open("api").unwrap()).collect();
|
||||
}
|
||||
let target = if mode == "foreign" {
|
||||
// An attacker can fabricate a representation, not a capability.
|
||||
unsafe { connections::Connection::from_handle(0) }
|
||||
} else {
|
||||
connections::open("api").map_err(failure)?
|
||||
};
|
||||
let target = if mode == "stale" {
|
||||
let handle = target.handle();
|
||||
drop(target);
|
||||
unsafe { connections::Connection::from_handle(handle) }
|
||||
} else {
|
||||
target
|
||||
};
|
||||
let response = http::send(
|
||||
&target,
|
||||
&http::Request {
|
||||
method: http::Method::Get,
|
||||
path: "/status".into(),
|
||||
query: vec![],
|
||||
headers: vec![],
|
||||
body: vec![],
|
||||
timeout_ms: 15_000,
|
||||
effect_key: "one".into(),
|
||||
},
|
||||
)
|
||||
.map_err(failure)?;
|
||||
return Ok(Output {
|
||||
summary: String::from_utf8(response.body).unwrap(),
|
||||
content: None,
|
||||
structured_json: Some(format!("{{\"calls\":{count}}}")),
|
||||
});
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
Ok(Output {
|
||||
summary: if mode == "output" {
|
||||
"x".repeat(70_000)
|
||||
} else {
|
||||
input.invocation_id
|
||||
},
|
||||
content: None,
|
||||
structured_json: Some(if mode == "bad-json" {
|
||||
"not-json".into()
|
||||
} else if mode == "bad-schema" {
|
||||
"{}".into()
|
||||
} else {
|
||||
format!("{{\"calls\":{count}}}")
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn failure(error: yoi::host::errors::HostError) -> ToolError {
|
||||
ToolError {
|
||||
code: "host".into(),
|
||||
message: format!(
|
||||
"{}:{}",
|
||||
match error.code {
|
||||
yoi::host::errors::Code::DeadlineExceeded => "deadline-exceeded",
|
||||
yoi::host::errors::Code::QuotaExceeded => "quota-exceeded",
|
||||
_ => "host-rejected",
|
||||
},
|
||||
match error.effect {
|
||||
yoi::host::errors::EffectObservation::NotDispatched => "not-dispatched",
|
||||
yoi::host::errors::EffectObservation::ResponseObserved => "response-observed",
|
||||
yoi::host::errors::EffectObservation::Indeterminate => "indeterminate",
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
export!(Plugin);
|
||||
Reference in New Issue
Block a user