feat: provide v2 Plugin Component contracts and offline validation
Validate exact Component interfaces and static Tool/DCDL schemas without guest execution. Share canonical archive packing and independent package/artifact verification; build real pure and HTTPS PDK fixtures and retire v1/Service authoring stubs.
This commit is contained in:
@@ -15,3 +15,6 @@ wit-bindgen = "0.51.0"
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
wasmparser = "0.244.0"
|
||||
wit-component = "0.244.0"
|
||||
wit-parser = "0.244.0"
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# plugin-pdk
|
||||
|
||||
Guest-side JSON/WIT helpers for offline Component Model packages. Without depending on host/runtime crates, it converts Tool and Service inputs/outputs and bounds Tool output size. Using the PDK or validating a package does not authorize installation or execution in a Worker.
|
||||
Guest-side Rust helpers for the versioned Component Model Tool contract. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm.
|
||||
|
||||
## Main entry points
|
||||
|
||||
- [`run_json_tool`] and [`export_component_tool!`] — typed Tool input/output boundary
|
||||
- [`ToolOutput`] and [`ToolError`] — bounded output and errors
|
||||
- [`ServiceOutput`] and [`export_plugin_instance!`] — instance output contracts
|
||||
Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. The PDK does not import WASI, implement a Host, or provide Service/event/Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter.
|
||||
|
||||
+184
-644
@@ -2,146 +2,126 @@
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
pub use wit_bindgen;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ToolError>;
|
||||
|
||||
/// Current Yoi Component Model Tool world targeted by this PDK.
|
||||
pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
|
||||
|
||||
/// Repository WIT for the current Tool world, exposed for authoring tools and
|
||||
/// tests. Runtime components should still generate bindings at compile time.
|
||||
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-tool-v1.wit");
|
||||
|
||||
/// Repository WIT for the grant-bound host APIs importable by Tool components.
|
||||
pub const TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0";
|
||||
pub const HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0";
|
||||
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit");
|
||||
pub const HOST_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit");
|
||||
|
||||
/// Serialized JSON limit defined by the offline Tool world's output contract.
|
||||
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
|
||||
pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
|
||||
/// Byte limit for summaries in the offline Tool world.
|
||||
pub const MAX_SUMMARY_BYTES: usize = 1024;
|
||||
/// Conservative content cap that leaves room for JSON framing and escaping.
|
||||
pub const MAX_CONTENT_BYTES: usize = MAX_TOOL_OUTPUT_BYTES - MAX_SUMMARY_BYTES - 4096;
|
||||
/// Maximum structured error message bytes retained by the PDK.
|
||||
pub const MAX_TOOL_INPUT_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096;
|
||||
|
||||
/// Per-call context passed to a typed JSON handler.
|
||||
/// Non-secret call snapshot. Invocation identity tracks effects; it grants no authority.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ToolContext {
|
||||
invocation_id: String,
|
||||
tool_name: String,
|
||||
config_json: String,
|
||||
remaining_ms: u32,
|
||||
}
|
||||
|
||||
impl ToolContext {
|
||||
/// Create context for the manifest-declared Tool name selected by the host.
|
||||
pub fn new(tool_name: impl Into<String>) -> Self {
|
||||
pub fn new(
|
||||
invocation_id: String,
|
||||
tool_name: String,
|
||||
config_json: String,
|
||||
remaining_ms: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_name: bounded_text(tool_name.into(), MAX_SUMMARY_BYTES),
|
||||
invocation_id,
|
||||
tool_name,
|
||||
config_json,
|
||||
remaining_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Manifest-declared Tool name supplied by the host runtime.
|
||||
pub fn invocation_id(&self) -> &str {
|
||||
&self.invocation_id
|
||||
}
|
||||
pub fn tool_name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
pub fn remaining_ms(&self) -> u32 {
|
||||
self.remaining_ms
|
||||
}
|
||||
|
||||
/// Decode the plan's non-secret config. Host validation remains authoritative.
|
||||
pub fn config<T: DeserializeOwned>(&self) -> Result<T> {
|
||||
parse_bounded_json(&self.config_json, MAX_CONFIG_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded Tool output formatted on the guest side.
|
||||
///
|
||||
/// Shorten text at character boundaries and recheck the serialized JSON limit, including escapes.
|
||||
/// Typed JSON through [`Self::json`] errors on overflow rather than truncating and breaking
|
||||
/// its structure. Providing this output format does not provide Plugin execution by Workers.
|
||||
/// Output crosses WIT as a record, never as a success envelope hiding an error.
|
||||
/// Oversized output is rejected, not silently truncated into invalid JSON.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ToolOutput {
|
||||
summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
structured_json: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolOutput {
|
||||
/// Create an ordinary Tool output.
|
||||
pub fn new(summary: impl Into<String>, content: Option<String>) -> Self {
|
||||
let mut output = Self {
|
||||
summary: normalize_summary(summary.into()),
|
||||
content: content.map(|value| bounded_text(value, MAX_CONTENT_BYTES)),
|
||||
};
|
||||
output.bound_serialized_json();
|
||||
output
|
||||
pub fn new(summary: impl Into<String>, content: Option<String>) -> Result<Self> {
|
||||
Self::checked(summary.into(), content, None)
|
||||
}
|
||||
|
||||
/// Create a Tool output whose content is typed JSON.
|
||||
pub fn json(
|
||||
summary: impl Into<String>,
|
||||
value: impl Serialize,
|
||||
) -> std::result::Result<Self, ToolError> {
|
||||
let content = serde_json::to_string(&value).map_err(ToolError::serialization)?;
|
||||
pub fn summary(summary: impl Into<String>) -> Result<Self> {
|
||||
Self::new(summary, None)
|
||||
}
|
||||
|
||||
/// Structured JSON is separate from presentation text and needs a manifest output schema.
|
||||
pub fn json(summary: impl Into<String>, value: impl Serialize) -> Result<Self> {
|
||||
let json = serde_json::to_string(&value).map_err(ToolError::serialization)?;
|
||||
Self::checked(summary.into(), None, Some(json))
|
||||
}
|
||||
|
||||
fn checked(
|
||||
summary: String,
|
||||
content: Option<String>,
|
||||
structured_json: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let output = Self {
|
||||
summary: normalize_summary(summary.into()),
|
||||
content: Some(content),
|
||||
summary,
|
||||
content,
|
||||
structured_json,
|
||||
};
|
||||
let serialized = serde_json::to_string(&output).map_err(ToolError::serialization)?;
|
||||
if serialized.len() > MAX_TOOL_OUTPUT_BYTES {
|
||||
return Err(ToolError::invalid_output(format!(
|
||||
"serialized ToolOutput JSON exceeds {MAX_TOOL_OUTPUT_BYTES} bytes"
|
||||
)));
|
||||
let bytes = serde_json::to_vec(&output).map_err(ToolError::serialization)?;
|
||||
if bytes.len() > MAX_TOOL_OUTPUT_BYTES {
|
||||
return Err(ToolError::invalid_output(
|
||||
"serialized output exceeds 64 KiB",
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Create a summary-only Tool output.
|
||||
pub fn summary(summary: impl Into<String>) -> Self {
|
||||
Self::new(summary, None)
|
||||
}
|
||||
|
||||
/// Return the bounded summary.
|
||||
pub fn summary_text(&self) -> &str {
|
||||
&self.summary
|
||||
}
|
||||
|
||||
/// Return optional detailed content.
|
||||
pub fn content(&self) -> Option<&str> {
|
||||
self.content.as_deref()
|
||||
}
|
||||
|
||||
/// Serialize to the ToolOutput JSON string returned by the WIT `call` export.
|
||||
pub fn to_json_string(&self) -> String {
|
||||
serde_json::to_string(self).unwrap_or_else(|_| {
|
||||
r#"{"summary":"tool error: serialization","content":"{\"error\":{\"code\":\"serialization\",\"message\":\"failed to serialize ToolOutput\"}}"}"#.to_string()
|
||||
})
|
||||
pub fn structured_json(&self) -> Option<&str> {
|
||||
self.structured_json.as_deref()
|
||||
}
|
||||
|
||||
fn bound_serialized_json(&mut self) {
|
||||
loop {
|
||||
let Ok(serialized) = serde_json::to_string(self) else {
|
||||
return;
|
||||
};
|
||||
if serialized.len() <= MAX_TOOL_OUTPUT_BYTES {
|
||||
return;
|
||||
}
|
||||
let Some(content) = self.content.take() else {
|
||||
return;
|
||||
};
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let overflow = serialized.len() - MAX_TOOL_OUTPUT_BYTES;
|
||||
let target = content.len().saturating_sub(overflow + "…".len());
|
||||
self.content = Some(bounded_text(content, target));
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn into_parts(self) -> (String, Option<String>, Option<String>) {
|
||||
(self.summary, self.content, self.structured_json)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable, low-cardinality error codes for PDK-produced Tool errors.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
/// Guest business errors are not Host authorization decisions or effect receipts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ToolErrorCode {
|
||||
InvalidInput,
|
||||
InvalidOutput,
|
||||
Serialization,
|
||||
Denied,
|
||||
UnsupportedTool,
|
||||
Failed,
|
||||
}
|
||||
@@ -152,640 +132,200 @@ impl ToolErrorCode {
|
||||
Self::InvalidInput => "invalid_input",
|
||||
Self::InvalidOutput => "invalid_output",
|
||||
Self::Serialization => "serialization",
|
||||
Self::Denied => "denied",
|
||||
Self::UnsupportedTool => "unsupported_tool",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured, bounded error that is rendered as ordinary ToolOutput JSON.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, thiserror::Error)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{code:?}: {message}")]
|
||||
pub struct ToolError {
|
||||
code: ToolErrorCode,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<Value>,
|
||||
}
|
||||
|
||||
impl ToolError {
|
||||
pub fn new(code: ToolErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES),
|
||||
details: None,
|
||||
let mut message = message.into();
|
||||
if message.len() > MAX_ERROR_MESSAGE_BYTES {
|
||||
let mut end = MAX_ERROR_MESSAGE_BYTES;
|
||||
while !message.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
message.truncate(end);
|
||||
}
|
||||
Self { code, message }
|
||||
}
|
||||
|
||||
pub fn invalid_input(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::InvalidInput, message)
|
||||
}
|
||||
|
||||
pub fn invalid_output(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::InvalidOutput, message)
|
||||
}
|
||||
|
||||
pub fn serialization(error: impl std::fmt::Display) -> Self {
|
||||
Self::new(ToolErrorCode::Serialization, error.to_string())
|
||||
/// Serialization diagnostics must not include the original payload.
|
||||
pub fn serialization(_error: impl std::fmt::Display) -> Self {
|
||||
Self::new(ToolErrorCode::Serialization, "output serialization failed")
|
||||
}
|
||||
|
||||
pub fn denied(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::Denied, message)
|
||||
pub fn unsupported_tool(_name: impl AsRef<str>) -> Self {
|
||||
Self::new(ToolErrorCode::UnsupportedTool, "unsupported Tool name")
|
||||
}
|
||||
|
||||
pub fn unsupported_tool(tool_name: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
ToolErrorCode::UnsupportedTool,
|
||||
format!("unsupported tool `{}`", tool_name.into()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::Failed, message)
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: impl Serialize) -> Self {
|
||||
self.details = serde_json::to_value(details).ok();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn code(&self) -> ToolErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn code_str(&self) -> &'static str {
|
||||
self.code.as_str()
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
/// Render this error as ordinary ToolOutput JSON content.
|
||||
pub fn into_tool_output(self) -> ToolOutput {
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody<'a> {
|
||||
error: ErrorPayload<'a>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorPayload<'a> {
|
||||
code: &'static str,
|
||||
message: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<&'a Value>,
|
||||
}
|
||||
|
||||
let summary = format!("tool error: {}", self.code.as_str());
|
||||
let payload = ErrorBody {
|
||||
error: ErrorPayload {
|
||||
code: self.code.as_str(),
|
||||
message: &self.message,
|
||||
details: self.details.as_ref(),
|
||||
},
|
||||
};
|
||||
let mut content = serde_json::to_string(&payload).unwrap_or_else(|_| {
|
||||
format!(
|
||||
r#"{{"error":{{"code":"{}","message":"failed to serialize error details"}}}}"#,
|
||||
ToolErrorCode::Serialization.as_str()
|
||||
)
|
||||
});
|
||||
if content.len() > MAX_CONTENT_BYTES {
|
||||
let payload = ErrorBody {
|
||||
error: ErrorPayload {
|
||||
code: self.code.as_str(),
|
||||
message: &self.message,
|
||||
details: None,
|
||||
},
|
||||
};
|
||||
content = serde_json::to_string(&payload).unwrap_or_else(|_| {
|
||||
r#"{"error":{"code":"serialization","message":"failed to serialize error"}}"#
|
||||
.to_string()
|
||||
});
|
||||
}
|
||||
ToolOutput::new(summary, Some(content))
|
||||
fn parse_bounded_json<T: DeserializeOwned>(json: &str, limit: usize) -> Result<T> {
|
||||
if json.len() > limit {
|
||||
return Err(ToolError::invalid_input("JSON exceeds 64 KiB"));
|
||||
}
|
||||
serde_json::from_str(json)
|
||||
.map_err(|_| ToolError::invalid_input("JSON does not match the expected schema"))
|
||||
}
|
||||
|
||||
/// Parse the WIT `input-json` string into a typed input value.
|
||||
pub fn parse_json_input<T>(input_json: &str) -> std::result::Result<T, ToolError>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::invalid_input(format!(
|
||||
"tool input is not valid JSON for this schema: {error}"
|
||||
))
|
||||
})
|
||||
pub fn parse_json_input<T: DeserializeOwned>(json: &str) -> Result<T> {
|
||||
parse_bounded_json(json, MAX_TOOL_INPUT_BYTES)
|
||||
}
|
||||
|
||||
/// Guest-side boundary that runs a typed JSON handler and converts the result to ToolOutput JSON.
|
||||
///
|
||||
/// Convert parse, handler, and serialization failures to the normal output form. These errors
|
||||
/// are not host authorization decisions, and catching handler panics is not guaranteed.
|
||||
/// The PDK does not depend on host/runtime crates or grant permissions through input conversion.
|
||||
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
|
||||
where
|
||||
I: DeserializeOwned,
|
||||
F: FnOnce(ToolContext, I) -> std::result::Result<ToolOutput, ToolError>,
|
||||
{
|
||||
let result = parse_json_input::<I>(input_json).and_then(|input| {
|
||||
let context = ToolContext::new(tool_name);
|
||||
handler(context, input)
|
||||
});
|
||||
match result {
|
||||
Ok(output) => output.to_json_string(),
|
||||
Err(error) => error.into_tool_output().to_json_string(),
|
||||
}
|
||||
/// Host performs schema and admission checks; this boundary only decodes guest data.
|
||||
/// Failures remain the WIT result's error arm. No retry or success stub is introduced.
|
||||
pub fn run_json_tool<I: DeserializeOwned>(
|
||||
context: ToolContext,
|
||||
input_json: &str,
|
||||
handler: impl FnOnce(ToolContext, I) -> Result<ToolOutput>,
|
||||
) -> Result<ToolOutput> {
|
||||
let _: serde_json::Value = context.config()?;
|
||||
let input = parse_json_input(input_json)?;
|
||||
handler(context, input)
|
||||
}
|
||||
|
||||
/// Connect a typed JSON handler to a WIT `Guest` implementation and component export.
|
||||
///
|
||||
/// Use guest-generated bindings and centralize handler conversion in [`run_json_tool`].
|
||||
/// This macro grants no host API permissions and is not a mechanism for registering or
|
||||
/// executing dynamic Plugins in the current Worker.
|
||||
/// Adapt bindings generated from any supported v2 Tool world. The generated glue
|
||||
/// owns the canonical ABI; callers never implement pointer/length exports.
|
||||
#[macro_export]
|
||||
macro_rules! export_component_tool {
|
||||
($adapter:ident, $handler:path) => {
|
||||
struct $adapter;
|
||||
|
||||
impl Guest for $adapter {
|
||||
fn call(
|
||||
tool_name: ::std::string::String,
|
||||
input_json: ::std::string::String,
|
||||
) -> ::std::string::String {
|
||||
$crate::run_json_tool(&tool_name, &input_json, $handler)
|
||||
impl exports::yoi::plugin::tool::Guest for $adapter {
|
||||
fn invoke(
|
||||
input: exports::yoi::plugin::tool::Invocation,
|
||||
) -> ::std::result::Result<
|
||||
exports::yoi::plugin::tool::Output,
|
||||
exports::yoi::plugin::tool::ToolError,
|
||||
> {
|
||||
let context = $crate::ToolContext::new(
|
||||
input.invocation_id,
|
||||
input.tool_name,
|
||||
input.config_json,
|
||||
input.remaining_ms,
|
||||
);
|
||||
$crate::run_json_tool(context, &input.input_json, $handler)
|
||||
.map(|output| {
|
||||
let (summary, content, structured_json) = output.into_parts();
|
||||
exports::yoi::plugin::tool::Output {
|
||||
summary,
|
||||
content,
|
||||
structured_json,
|
||||
}
|
||||
})
|
||||
.map_err(|error| exports::yoi::plugin::tool::ToolError {
|
||||
code: error.code_str().to_owned(),
|
||||
message: error.message().to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export!($adapter);
|
||||
};
|
||||
}
|
||||
|
||||
fn normalize_summary(summary: String) -> String {
|
||||
let summary = bounded_text(summary, MAX_SUMMARY_BYTES);
|
||||
if summary.trim().is_empty() {
|
||||
"tool completed".to_string()
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_text(mut value: String, max_bytes: usize) -> String {
|
||||
if max_bytes == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
value = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_control() && ch != '\n' && ch != '\t' {
|
||||
' '
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if value.len() <= max_bytes {
|
||||
return value;
|
||||
}
|
||||
|
||||
let suffix = "…";
|
||||
let budget = max_bytes.saturating_sub(suffix.len());
|
||||
let mut cut = budget.min(value.len());
|
||||
while cut > 0 && !value.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
value.truncate(cut);
|
||||
value.push_str(suffix);
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EchoInput {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Input {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EchoOutput<'a> {
|
||||
tool: &'a str,
|
||||
text: String,
|
||||
fn context(config: &str) -> ToolContext {
|
||||
ToolContext::new("call-1".into(), "echo".into(), config.into(), 500)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_tool_parses_input_and_serializes_output() {
|
||||
let output = run_json_tool(
|
||||
"example_echo",
|
||||
r#"{"text":"hello"}"#,
|
||||
|ctx, input: EchoInput| {
|
||||
ToolOutput::json(
|
||||
format!("{} ok", ctx.tool_name()),
|
||||
EchoOutput {
|
||||
tool: ctx.tool_name(),
|
||||
text: input.text,
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
let value: Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(value["summary"], "example_echo ok");
|
||||
let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(content, json!({"tool":"example_echo","text":"hello"}));
|
||||
assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_tool_renders_error_output() {
|
||||
let output =
|
||||
run_json_tool::<EchoInput, _>("example_echo", r#"{"text":1}"#, |_ctx, _input| {
|
||||
Ok(ToolOutput::summary("unreachable"))
|
||||
});
|
||||
|
||||
let value: Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(value["summary"], "tool error: invalid_input");
|
||||
let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(content["error"]["code"], "invalid_input");
|
||||
assert!(content["error"]["message"].as_str().unwrap().len() <= MAX_ERROR_MESSAGE_BYTES);
|
||||
assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_output_rejects_oversized_serialized_tool_output() {
|
||||
let too_large = vec!["quoted \" text"; MAX_TOOL_OUTPUT_BYTES / 4];
|
||||
let error = ToolOutput::json("too large", too_large).unwrap_err();
|
||||
|
||||
assert_eq!(error.code(), ToolErrorCode::InvalidOutput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_error_bounds_message_and_control_characters() {
|
||||
let message = format!("bad\u{0007}{}", "x".repeat(MAX_ERROR_MESSAGE_BYTES * 2));
|
||||
let error = ToolError::failed(message);
|
||||
|
||||
assert_eq!(error.code_str(), "failed");
|
||||
assert!(!error.message().contains('\u{0007}'));
|
||||
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_output_helper_builds_runtime_command_envelope() {
|
||||
let event = PluginIngressEvent {
|
||||
kind: "websocket_text".to_string(),
|
||||
source: "websocket:wss://example.test/socket".to_string(),
|
||||
ingress_name: "example_ws".to_string(),
|
||||
payload: json!({"text":"ping"}),
|
||||
created_at: "2026-06-25T00:00:00Z".to_string(),
|
||||
attempt: 1,
|
||||
correlation_id: "event-1".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(event.websocket_text(), Some("ping"));
|
||||
let output =
|
||||
ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong")
|
||||
.unwrap();
|
||||
let value = serde_json::to_value(output).unwrap();
|
||||
|
||||
assert_eq!(value["accepted"], true);
|
||||
assert_eq!(value["output_commands"][0]["source_event_id"], "event-1");
|
||||
assert_eq!(value["output_commands"][0]["command_id"], "reply-1");
|
||||
assert_eq!(value["output_commands"][0]["kind"], "websocket_send");
|
||||
assert_eq!(value["output_commands"][0]["payload"]["text"], "pong");
|
||||
fn typed_handler_keeps_structured_output_separate_from_text() {
|
||||
let output = run_json_tool(context("{}"), r#"{"text":"hello"}"#, |ctx, input: Input| {
|
||||
assert_eq!(ctx.invocation_id(), "call-1");
|
||||
assert_eq!(ctx.remaining_ms(), 500);
|
||||
ToolOutput::json(
|
||||
"echoed",
|
||||
json!({"text": input.text, "tool": ctx.tool_name()}),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(output.content(), None);
|
||||
assert_eq!(
|
||||
value["output_commands"][0]["requested_at"],
|
||||
"2026-06-25T00:00:00Z"
|
||||
serde_json::from_str::<serde_json::Value>(output.structured_json().unwrap()).unwrap(),
|
||||
json!({"text":"hello", "tool":"echo"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_constants_match_current_world() {
|
||||
assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0"));
|
||||
assert!(TOOL_WIT.contains("world tool"));
|
||||
assert!(TOOL_WIT.contains("export call"));
|
||||
assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0");
|
||||
assert!(HOST_WIT.contains("interface request"));
|
||||
assert!(HOST_WIT.contains("interface websocket"));
|
||||
assert!(HOST_WIT.contains("interface fs"));
|
||||
assert!(HOST_WIT.contains("%list: func"));
|
||||
assert!(INSTANCE_WIT.contains("world instance"));
|
||||
assert!(INSTANCE_WIT.contains("export handle-ingress"));
|
||||
assert!(INSTANCE_WIT.contains("websocket_send"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Versioned Component Model instance world handled by the host-managed
|
||||
/// PluginInstanceRegistry.
|
||||
pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
|
||||
|
||||
/// Repository WIT for the current instance world.
|
||||
pub const INSTANCE_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PluginIngressEvent {
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub ingress_name: String,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default = "default_attempt")]
|
||||
pub attempt: u32,
|
||||
#[serde(default)]
|
||||
pub correlation_id: String,
|
||||
}
|
||||
|
||||
impl PluginIngressEvent {
|
||||
/// Return the text payload carried by a host-owned WebSocket ingress event.
|
||||
pub fn websocket_text(&self) -> Option<&str> {
|
||||
self.payload.get("text").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// Build a `websocket_send` output command that replies through the
|
||||
/// host-owned Service WebSocket driver. The host still validates the target
|
||||
/// URL and matching grants before sending.
|
||||
pub fn websocket_send(
|
||||
&self,
|
||||
command_id: impl Into<String>,
|
||||
url: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
) -> Result<ServiceOutputCommand> {
|
||||
ServiceOutputCommand::new(
|
||||
self,
|
||||
command_id,
|
||||
ServiceOutputCommandKind::WebSocketSend,
|
||||
serde_json::json!({ "url": url.into(), "text": text.into() }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_attempt() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PluginStatus {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl PluginStatus {
|
||||
pub fn ready(data: Value) -> Self {
|
||||
Self {
|
||||
state: "ready".to_string(),
|
||||
data,
|
||||
fn invalid_input_or_config_never_runs_handler_or_returns_success() {
|
||||
for (config, input) in [
|
||||
("{}", r#"{"text":1}"#),
|
||||
("broken", r#"{"text":"ok"}"#),
|
||||
("{}", r#"{"text":"ok","extra":true}"#),
|
||||
] {
|
||||
let error = run_json_tool::<Input>(context(config), input, |_, _| {
|
||||
panic!("handler must not run")
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code(), ToolErrorCode::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopped() -> Self {
|
||||
Self {
|
||||
state: "stopped".to_string(),
|
||||
data: Value::Null,
|
||||
#[test]
|
||||
fn payload_limits_reject_overflow_including_json_escaping() {
|
||||
for text in [
|
||||
"x".repeat(MAX_TOOL_OUTPUT_BYTES),
|
||||
"\"".repeat(MAX_TOOL_OUTPUT_BYTES / 2),
|
||||
"語".repeat(MAX_TOOL_OUTPUT_BYTES / 3),
|
||||
] {
|
||||
assert_eq!(
|
||||
ToolOutput::json("large", &text).unwrap_err().code(),
|
||||
ToolErrorCode::InvalidOutput
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
parse_json_input::<serde_json::Value>(&format!(
|
||||
"\"{}\"",
|
||||
"x".repeat(MAX_TOOL_INPUT_BYTES)
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
let large = format!("\"{}\"", "x".repeat(MAX_CONFIG_BYTES));
|
||||
assert!(context(&large).config::<serde_json::Value>().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceOutputCommandKind {
|
||||
DiagnosticStatusUpdate,
|
||||
HostRequestDispatch,
|
||||
#[serde(rename = "websocket_send")]
|
||||
WebSocketSend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ServiceOutputCommand {
|
||||
pub correlation_id: String,
|
||||
pub source_event_id: String,
|
||||
pub command_id: String,
|
||||
pub kind: ServiceOutputCommandKind,
|
||||
pub payload: Value,
|
||||
pub requested_at: String,
|
||||
}
|
||||
|
||||
impl ServiceOutputCommand {
|
||||
pub fn new(
|
||||
event: &PluginIngressEvent,
|
||||
command_id: impl Into<String>,
|
||||
kind: ServiceOutputCommandKind,
|
||||
payload: impl Serialize,
|
||||
) -> Result<Self> {
|
||||
let command_id = sanitize_command_id(command_id.into());
|
||||
if command_id.is_empty() {
|
||||
return Err(ToolError::invalid_output(
|
||||
"service output command_id must not be empty",
|
||||
));
|
||||
}
|
||||
let source_event_id = event.correlation_id.clone();
|
||||
if source_event_id.is_empty() {
|
||||
return Err(ToolError::invalid_output(
|
||||
"service output command requires ingress event correlation_id",
|
||||
));
|
||||
}
|
||||
let requested_at = if event.created_at.is_empty() {
|
||||
"1970-01-01T00:00:00Z".to_string()
|
||||
} else {
|
||||
event.created_at.clone()
|
||||
};
|
||||
Ok(Self {
|
||||
correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")),
|
||||
source_event_id,
|
||||
command_id,
|
||||
kind,
|
||||
payload: serde_json::to_value(payload).map_err(ToolError::serialization)?,
|
||||
requested_at,
|
||||
#[test]
|
||||
fn business_failure_is_preserved_and_message_is_utf8_bounded() {
|
||||
let error = run_json_tool(context("{}"), "null", |_, _: ()| {
|
||||
Err(ToolError::failed("語".repeat(MAX_ERROR_MESSAGE_BYTES)))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code_str(), "failed");
|
||||
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES);
|
||||
assert!(error.message().ends_with('語'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance processing results and output commands requested from the host.
|
||||
///
|
||||
/// The guest does not send anything here; it returns commands as data tied to the original
|
||||
/// ingress event. Command creation/serialization and host authorization/execution are separate
|
||||
/// boundaries. This type provides neither network permissions nor the current Worker execution mechanism.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ServiceOutput {
|
||||
#[serde(default)]
|
||||
pub accepted: bool,
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
pub data: Value,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output_commands: Vec<ServiceOutputCommand>,
|
||||
}
|
||||
|
||||
impl ServiceOutput {
|
||||
pub fn accepted(data: impl Serialize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
accepted: true,
|
||||
data: serde_json::to_value(data).map_err(ToolError::serialization)?,
|
||||
output_commands: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
accepted: true,
|
||||
data: Value::Null,
|
||||
output_commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_command(mut self, command: ServiceOutputCommand) -> Self {
|
||||
self.output_commands.push(command);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn websocket_send(
|
||||
event: &PluginIngressEvent,
|
||||
command_id: impl Into<String>,
|
||||
url: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
Ok(Self::empty().with_command(event.websocket_send(command_id, url, text)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_command_id(value: String) -> String {
|
||||
bounded_text(
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| if ch.is_control() { '-' } else { ch })
|
||||
.collect(),
|
||||
128,
|
||||
)
|
||||
}
|
||||
|
||||
/// Rust-facing instance Plugin contract. Hosts call `start` once, then route
|
||||
/// Tool/Ingress surfaces through the same mutable instance.
|
||||
pub trait Plugin: Sized + 'static {
|
||||
fn start(config: Value) -> Result<Self>;
|
||||
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput>;
|
||||
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<ServiceOutput>;
|
||||
fn status(&self) -> Result<PluginStatus> {
|
||||
Ok(PluginStatus::ready(Value::Null))
|
||||
}
|
||||
fn stop(&mut self) -> Result<PluginStatus> {
|
||||
Ok(PluginStatus::stopped())
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn plugin_instance_error(message: impl Into<String>) -> String {
|
||||
serde_json::json!({ "error": { "message": message.into() } }).to_string()
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn plugin_instance_status(status: &PluginStatus) -> String {
|
||||
serde_json::to_string(status).unwrap_or_else(|error| plugin_instance_error(error.to_string()))
|
||||
}
|
||||
|
||||
/// Implement the generated Component Model `Guest` trait for an instance Plugin
|
||||
/// and export it with the `wit-bindgen` generated `export!` macro.
|
||||
///
|
||||
/// The caller must invoke `wit_bindgen::generate!` for the `instance` world
|
||||
/// first, with `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines
|
||||
/// the `Guest` trait and `export!` macro in the current module.
|
||||
#[macro_export]
|
||||
macro_rules! export_plugin_instance {
|
||||
($adapter:ident, $plugin:ty) => {
|
||||
struct $adapter;
|
||||
|
||||
thread_local! {
|
||||
static YOI_PLUGIN_INSTANCE: ::std::cell::RefCell<::std::option::Option<$plugin>> = const { ::std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
impl Guest for $adapter {
|
||||
fn start(config_json: ::std::string::String) -> ::std::string::String {
|
||||
let config = serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null);
|
||||
match <$plugin as $crate::Plugin>::start(config) {
|
||||
Ok(plugin) => {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin));
|
||||
$crate::plugin_instance_status(&$crate::PluginStatus::ready(serde_json::Value::Null))
|
||||
}
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tool(
|
||||
name: ::std::string::String,
|
||||
input_json: ::std::string::String,
|
||||
) -> ::std::string::String {
|
||||
let input = serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null);
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.handle_tool(&name, input) {
|
||||
Ok(output) => output.to_json_string(),
|
||||
Err(error) => error.into_tool_output().to_json_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_ingress(
|
||||
name: ::std::string::String,
|
||||
event_json: ::std::string::String,
|
||||
) -> ::std::string::String {
|
||||
let event = match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) {
|
||||
Ok(event) => event,
|
||||
Err(error) => return $crate::plugin_instance_error(error.to_string()),
|
||||
};
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.handle_ingress(&name, event) {
|
||||
Ok(output) => serde_json::to_string(&output)
|
||||
.unwrap_or_else(|error| $crate::plugin_instance_error(error.to_string())),
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn status() -> ::std::string::String {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let slot = slot.borrow();
|
||||
let Some(plugin) = slot.as_ref() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.status() {
|
||||
Ok(status) => $crate::plugin_instance_status(&status),
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn stop() -> ::std::string::String {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.stop() {
|
||||
Ok(status) => {
|
||||
let output = $crate::plugin_instance_status(&status);
|
||||
*slot = None;
|
||||
output
|
||||
}
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export!($adapter);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
//! Minimal Component Model Tool plugin authoring sketch using `yoi-plugin-pdk`.
|
||||
//!
|
||||
//! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling
|
||||
//! and package the adapted component as `plugin.component.wasm`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "tool",
|
||||
path: "../../../resources/plugin/wit",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EchoInput {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EchoOutput<'a> {
|
||||
tool: &'a str,
|
||||
text: String,
|
||||
}
|
||||
|
||||
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
|
||||
ToolOutput::json(
|
||||
format!("{} ok", ctx.tool_name()),
|
||||
EchoOutput {
|
||||
tool: ctx.tool_name(),
|
||||
text: input.text,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);
|
||||
Binary file not shown.
Binary file not shown.
+157
-198
@@ -1,247 +1,206 @@
|
||||
//! Check the shipped templates against the real PDK and WIT for the guest target.
|
||||
//! Each check owns its source and lockfile; Cargo alone shares and validates artifacts.
|
||||
|
||||
//! Real Rust -> core Wasm -> Component coverage for the shipped starters.
|
||||
//! Only filesystem paths are relocated; WIT and guest source are unchanged.
|
||||
//! Requires the wasm32-unknown-unknown Rust std target and cached Cargo dependencies.
|
||||
//! No guest is instantiated and no HTTPS Host is faked by this suite.
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use toml::Value;
|
||||
use wit_parser::WorldItem;
|
||||
|
||||
const TEMPLATE_CARGO: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml");
|
||||
const TEMPLATE_LIB: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs");
|
||||
const TEMPLATE_PLUGIN: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
|
||||
const SERVICE_TEMPLATE_CARGO: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml");
|
||||
const SERVICE_TEMPLATE_LIB: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs");
|
||||
const SERVICE_TEMPLATE_PLUGIN: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml");
|
||||
// Preserve the removed document's source as a byte-identical fixture to keep the test input unchanged.
|
||||
const SAMPLE_LIB: &str = include_str!("fixtures/component-tool-source.txt");
|
||||
const PDK_CARGO: &str = include_str!("../Cargo.toml");
|
||||
const TEMPLATES: [&str; 2] = ["rust-component-tool", "rust-component-https"];
|
||||
|
||||
#[test]
|
||||
fn rust_component_tool_template_has_expected_files() {
|
||||
let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses");
|
||||
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
|
||||
assert!(cargo["workspace"].is_table());
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
assert_eq!(
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
|
||||
|
||||
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert_eq!(
|
||||
plugin["runtime"]["world"].as_str(),
|
||||
Some("yoi:plugin/tool@1.0.0")
|
||||
);
|
||||
assert!(plugin["tools"].as_array().expect("tools array").len() == 1);
|
||||
|
||||
assert!(TEMPLATE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
|
||||
assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!"));
|
||||
assert!(TEMPLATE_LIB.contains("generate_all"));
|
||||
assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
|
||||
assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
|
||||
assert!(TEMPLATE_LIB.contains("ToolOutput::json"));
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_component_service_template_has_event_output_pattern() {
|
||||
let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses");
|
||||
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
assert_eq!(
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
|
||||
|
||||
let plugin: Value =
|
||||
toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert_eq!(
|
||||
plugin["runtime"]["world"].as_str(),
|
||||
Some("yoi:plugin/instance@1.0.0")
|
||||
);
|
||||
assert!(
|
||||
plugin["permissions"]
|
||||
.as_array()
|
||||
.expect("permissions array")
|
||||
.iter()
|
||||
.any(|permission| permission["kind"].as_str() == Some("host_api")
|
||||
&& permission["api"].as_str() == Some("websocket"))
|
||||
);
|
||||
assert_eq!(
|
||||
plugin["services"].as_array().expect("services array").len(),
|
||||
1
|
||||
);
|
||||
let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0];
|
||||
assert!(
|
||||
ingress["event_kinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("websocket_text"))
|
||||
);
|
||||
assert!(
|
||||
ingress["sources"]
|
||||
.as_array()
|
||||
.expect("sources")
|
||||
.iter()
|
||||
.any(|source| source.as_str() == Some("websocket:wss://example.com/socket"))
|
||||
);
|
||||
let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0];
|
||||
assert_eq!(websocket["scheme"].as_str(), Some("wss"));
|
||||
assert_eq!(websocket["host"].as_str(), Some("example.com"));
|
||||
assert!(
|
||||
websocket["path_prefixes"]
|
||||
.as_array()
|
||||
.expect("websocket path prefixes")
|
||||
.iter()
|
||||
.any(|prefix| prefix.as_str() == Some("/socket"))
|
||||
);
|
||||
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\""));
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent"));
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send"));
|
||||
assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout"));
|
||||
assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\""));
|
||||
fn template_wit_copies_match_the_versioned_contract() {
|
||||
let root = root();
|
||||
for template in TEMPLATES {
|
||||
for file in ["yoi-plugin-v2.wit", "deps/yoi-host/yoi-host-v2.wit"] {
|
||||
assert_eq!(
|
||||
fs::read(root.join("resources/plugin/wit").join(file)).unwrap(),
|
||||
fs::read(
|
||||
root.join("resources/plugin/templates")
|
||||
.join(template)
|
||||
.join("wit")
|
||||
.join(file)
|
||||
)
|
||||
.unwrap(),
|
||||
"{template}/{file} must be regenerated from the canonical WIT"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_tool_fixture_uses_pdk_bindings() {
|
||||
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
|
||||
assert!(SAMPLE_LIB.contains("wit_bindgen::generate!"));
|
||||
assert!(SAMPLE_LIB.contains("generate_all"));
|
||||
assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
|
||||
assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
|
||||
assert!(!SAMPLE_LIB.contains("export_name"));
|
||||
fn template_manifest_worlds_use_exact_versions_and_schemas() {
|
||||
for template in TEMPLATES {
|
||||
let path = root().join("resources/plugin/templates").join(template);
|
||||
let cargo: Value =
|
||||
toml::from_str(&fs::read_to_string(path.join("Cargo.toml")).unwrap()).unwrap();
|
||||
assert!(cargo["workspace"].is_table());
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
let manifest: Value =
|
||||
toml::from_str(&fs::read_to_string(path.join("plugin.toml")).unwrap()).unwrap();
|
||||
assert_eq!(manifest["schema_version"].as_integer(), Some(2));
|
||||
assert_eq!(manifest["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert!(
|
||||
manifest["runtime"]["world"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("@2.0.0")
|
||||
);
|
||||
for tool in manifest["tools"].as_array().unwrap() {
|
||||
for schema in ["input_schema", "output_schema"] {
|
||||
assert_eq!(tool[schema]["type"].as_str(), Some("object"));
|
||||
assert_eq!(tool[schema]["additionalProperties"].as_bool(), Some(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-tool");
|
||||
fn pure_starter_builds_real_component_without_imports() {
|
||||
build_component(
|
||||
"rust-component-tool",
|
||||
"yoi_rust_component_tool_template",
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_service_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-instance");
|
||||
fn https_starter_builds_real_component_with_only_explicit_host_imports() {
|
||||
build_component(
|
||||
"rust-component-https",
|
||||
"yoi_rust_component_https_template",
|
||||
&[
|
||||
"yoi:host/connections@2.0.0",
|
||||
"yoi:host/errors@2.0.0",
|
||||
"yoi:host/http@2.0.0",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn cargo_check_template(template: &str) {
|
||||
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let templates_dir = crate_dir.join("../../resources/plugin/templates");
|
||||
let template_dir = templates_dir.join(template);
|
||||
|
||||
// A sibling fixture preserves the shipped relative PDK and WIT paths verbatim.
|
||||
// Never create or remove a lockfile in the shipped template: another invocation
|
||||
// may be checking it at the same time, or a developer may own that lockfile.
|
||||
fn build_component(template: &str, library: &str, expected_imports: &[&str]) {
|
||||
let root = root();
|
||||
let sdk = root.join("target/sdk");
|
||||
fs::create_dir_all(&sdk).unwrap();
|
||||
let source = root.join("resources/plugin/templates").join(template);
|
||||
let fixture = tempfile::Builder::new()
|
||||
.prefix(".cargo-check-")
|
||||
.tempdir_in(&templates_dir)
|
||||
.expect("temporary template fixture");
|
||||
copy_template_tree(&template_dir, fixture.path(), true);
|
||||
|
||||
// Both templates share the PDK dependencies. A separate persistent target
|
||||
// reuses those artifacts without sharing source or the outer Cargo build lock.
|
||||
// Cargo fingerprints and its target lock handle changes and concurrent checks.
|
||||
let executable = std::env::current_exe().expect("current test executable");
|
||||
let target_dir = executable
|
||||
.parent()
|
||||
.and_then(|deps| deps.parent())
|
||||
.expect("test executable has a profile directory")
|
||||
.join("compile-tests/plugin-pdk-templates");
|
||||
.prefix("build-")
|
||||
.tempdir_in(&sdk)
|
||||
.unwrap();
|
||||
copy_tree(&source, fixture.path());
|
||||
let cargo_path = fixture.path().join("Cargo.toml");
|
||||
let cargo = fs::read_to_string(&cargo_path).unwrap().replace(
|
||||
"../../../../crates/plugin-pdk",
|
||||
root.join("crates/plugin-pdk").to_str().unwrap(),
|
||||
);
|
||||
fs::write(&cargo_path, cargo).unwrap();
|
||||
let target = sdk.join("guest");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.arg("check")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-unknown-unknown",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.arg("--manifest-path")
|
||||
.arg(fixture.path().join("Cargo.toml"))
|
||||
.arg("--target")
|
||||
.arg("wasm32-unknown-unknown")
|
||||
.arg("--offline")
|
||||
.arg("--target-dir")
|
||||
.arg(target_dir)
|
||||
.arg(&cargo_path)
|
||||
.current_dir(fixture.path())
|
||||
.env("CARGO_TERM_COLOR", "never")
|
||||
.output()
|
||||
.expect("spawn cargo check for embedded template");
|
||||
|
||||
.expect("run guest Cargo build");
|
||||
let logs = sdk.join("logs");
|
||||
fs::create_dir_all(&logs).unwrap();
|
||||
fs::write(logs.join(format!("{template}.stdout.log")), &output.stdout).unwrap();
|
||||
fs::write(logs.join(format!("{template}.stderr.log")), &output.stderr).unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"template {template} cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
|
||||
output.status,
|
||||
"{template}: cargo build failed\n{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let core =
|
||||
fs::read(target.join(format!("wasm32-unknown-unknown/release/{library}.wasm"))).unwrap();
|
||||
let component = wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap();
|
||||
wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all())
|
||||
.validate_all(&component)
|
||||
.unwrap();
|
||||
let wit_parser::decoding::DecodedWasm::Component(resolve, world) =
|
||||
wit_component::decode(&component).unwrap()
|
||||
else {
|
||||
panic!("expected Component, not WIT package")
|
||||
};
|
||||
let world = &resolve.worlds[world];
|
||||
let mut imports: Vec<_> = world
|
||||
.imports
|
||||
.keys()
|
||||
.map(|key| resolve.name_world_key(key))
|
||||
.collect();
|
||||
imports.sort();
|
||||
assert_eq!(imports, expected_imports);
|
||||
let exports: Vec<_> = world
|
||||
.exports
|
||||
.keys()
|
||||
.map(|key| resolve.name_world_key(key))
|
||||
.collect();
|
||||
assert_eq!(exports, ["yoi:plugin/tool@2.0.0"]);
|
||||
let WorldItem::Interface { id, .. } = world.exports.values().next().unwrap() else {
|
||||
panic!("Tool interface export required")
|
||||
};
|
||||
assert_eq!(
|
||||
resolve.interfaces[*id].functions.keys().collect::<Vec<_>>(),
|
||||
[&"invoke".to_string()]
|
||||
);
|
||||
|
||||
// Retain actual package inputs for the shared validator/pack owner, not a mock .wasm.
|
||||
let package = sdk.join("fixtures").join(template);
|
||||
fs::create_dir_all(&package).unwrap();
|
||||
fs::copy(source.join("plugin.toml"), package.join("plugin.toml")).unwrap();
|
||||
fs::write(package.join("plugin.component.wasm"), component).unwrap();
|
||||
}
|
||||
|
||||
fn copy_template_tree(source: &Path, destination: &Path, is_root: bool) {
|
||||
let metadata = fs::symlink_metadata(source).expect("read template directory metadata");
|
||||
assert!(
|
||||
metadata.is_dir() && !metadata.file_type().is_symlink(),
|
||||
"template input must be a real directory: {}",
|
||||
source.display()
|
||||
);
|
||||
for entry in fs::read_dir(source).expect("read template directory") {
|
||||
let entry = entry.expect("read template entry");
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type().expect("read template entry type");
|
||||
assert!(
|
||||
!kind.is_symlink() && (kind.is_dir() || kind.is_file()),
|
||||
"unsupported template input (including symlinks): {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
fn copy_tree(source: &Path, destination: &Path) {
|
||||
fs::create_dir_all(destination).unwrap();
|
||||
for entry in fs::read_dir(source).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let kind = entry.file_type().unwrap();
|
||||
assert!(!kind.is_symlink(), "template may not contain symlinks");
|
||||
let name = entry.file_name();
|
||||
// Preserve all compiler inputs, including build scripts and nested assets.
|
||||
// Only omit the root build output and lockfile: each isolated consumer must
|
||||
// resolve its own lockfile, as the original unlocked template check did.
|
||||
if is_root
|
||||
&& ((kind.is_dir() && name == "target") || (kind.is_file() && name == "Cargo.lock"))
|
||||
{
|
||||
if name == "target" || name == "Cargo.lock" {
|
||||
continue;
|
||||
}
|
||||
let copied = destination.join(name);
|
||||
if kind.is_dir() {
|
||||
fs::create_dir(&copied).expect("create template fixture directory");
|
||||
copy_template_tree(&path, &copied, false);
|
||||
copy_tree(&entry.path(), &copied);
|
||||
} else {
|
||||
fs::copy(&path, &copied).expect("copy unmodified template input");
|
||||
fs::copy(entry.path(), copied).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdk_runtime_dependencies_are_guest_side_only() {
|
||||
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses");
|
||||
let dependencies = cargo["dependencies"]
|
||||
.as_table()
|
||||
.expect("dependencies table");
|
||||
let forbidden = [
|
||||
"worker",
|
||||
"yoi-worker",
|
||||
"agen",
|
||||
"tui",
|
||||
"yoi-tui",
|
||||
"client",
|
||||
"yoi-client",
|
||||
"manifest",
|
||||
"yoi-manifest",
|
||||
"ticket",
|
||||
"yoi-ticket",
|
||||
];
|
||||
|
||||
for name in forbidden {
|
||||
let cargo: Value = toml::from_str(include_str!("../Cargo.toml")).unwrap();
|
||||
for dependency in cargo["dependencies"].as_table().unwrap().keys() {
|
||||
assert!(
|
||||
!dependencies.contains_key(name),
|
||||
"PDK must not depend on host/runtime crate `{name}`"
|
||||
["serde", "serde_json", "thiserror", "wit-bindgen"].contains(&dependency.as_str()),
|
||||
"unexpected Host dependency {dependency}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
//! Compile-pass coverage for generated v2 bindings and the public adapter macro.
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "tool",
|
||||
world: "tool-plugin",
|
||||
path: "../../resources/plugin/wit",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
struct Probe;
|
||||
fn handle(
|
||||
ctx: yoi_plugin_pdk::ToolContext,
|
||||
input: serde_json::Value,
|
||||
) -> yoi_plugin_pdk::Result<yoi_plugin_pdk::ToolOutput> {
|
||||
if ctx.tool_name() != "probe" {
|
||||
return Err(yoi_plugin_pdk::ToolError::unsupported_tool(ctx.tool_name()));
|
||||
}
|
||||
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
|
||||
}
|
||||
|
||||
impl Guest for Probe {
|
||||
fn call(tool_name: String, input_json: String) -> String {
|
||||
yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| {
|
||||
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
|
||||
})
|
||||
yoi_plugin_pdk::export_component_tool!(Probe, handle);
|
||||
|
||||
fn invocation(input_json: &str) -> exports::yoi::plugin::tool::Invocation {
|
||||
exports::yoi::plugin::tool::Invocation {
|
||||
invocation_id: "call-1".into(),
|
||||
tool_name: "probe".into(),
|
||||
input_json: input_json.into(),
|
||||
config_json: "{}".into(),
|
||||
remaining_ms: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_bindgen_generates_current_tool_world() {
|
||||
let output = <Probe as Guest>::call("probe".to_string(), r#"{"ok":true}"#.to_string());
|
||||
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(value["summary"], "probe ok");
|
||||
fn generated_adapter_returns_record_and_keeps_business_error_in_error_arm() {
|
||||
use exports::yoi::plugin::tool::Guest;
|
||||
let output = Probe::invoke(invocation(r#"{"ok":true}"#)).unwrap();
|
||||
assert_eq!(output.summary, "probe ok");
|
||||
assert_eq!(output.content, None);
|
||||
assert_eq!(output.structured_json.as_deref(), Some(r#"{"ok":true}"#));
|
||||
assert_eq!(
|
||||
Probe::invoke(invocation("broken")).unwrap_err().code,
|
||||
"invalid_input"
|
||||
);
|
||||
let mut input = invocation("{}");
|
||||
input.tool_name = "unknown".into();
|
||||
assert_eq!(Probe::invoke(input).unwrap_err().code, "unsupported_tool");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user