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:
2026-10-28 13:31:47 +09:00
parent 06883c51b7
commit 2d195f4058
40 changed files with 3056 additions and 1662 deletions
@@ -1,5 +1,5 @@
[package]
name = "yoi-rust-component-service-template"
name = "yoi-rust-component-https-template"
version = "0.1.0"
edition = "2024"
license = "MIT"
@@ -14,7 +14,6 @@ crate-type = ["cdylib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
# Out-of-tree Plugin packages should replace the local path with a pinned
@@ -0,0 +1,5 @@
# Connection-bound HTTPS Tool雛形
接続先とsecretをguestが選べないよう、宣言slotからcall-local resourceを取得する。副作用の観測を失敗時にも保持し、自動再送やrollback成功を装わない。HTTP error statusはresponseとして扱う。
このソースをComponentへbuildできることは、HTTPS Hostの実装・認可・receipt保存を提供した証拠にはならない。packageの要求grantはWorkspace bindingや実行権限を自己発行しない。
@@ -0,0 +1,28 @@
schema_version = 2
id = "example.rust_component_https"
name = "Rust HTTPS Component Tool Template"
version = "0.1.0"
surfaces = ["tool"]
config_schema = "{}"
requested_grants = [{ kind = "https", slot = "api" }]
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/https-tool-plugin@2.0.0"
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/connections@2.0.0", "yoi:host/http@2.0.0"]
[[tools]]
name = "example_status"
description = "Observe the status response through an explicitly bound HTTPS slot."
input_schema = { type = "object", properties = {}, additionalProperties = false }
output_schema = { type = "object", properties = { status = { type = "integer" }, body_bytes = { type = "integer" } }, required = ["status", "body_bytes"], additionalProperties = false }
# Even GET may have side effects. Host receipt/admission policy still applies.
error_codes = ["invalid_input", "invalid_output", "serialization", "unsupported_tool", "failed"]
external_write = true
[[connections]]
name = "api"
# Package requests HTTPS bounds; Workspace binding supplies origin and secrets.
methods = ["GET"]
path_prefixes = ["/status"]
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({
world: "https-tool-plugin",
path: "wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {}
#[derive(Serialize)]
struct Output {
status: u16,
body_bytes: usize,
}
fn handle_status(ctx: ToolContext, _input: Input) -> Result<ToolOutput, ToolError> {
use yoi::host::{connections, http};
if ctx.tool_name() != "example_status" {
return Err(ToolError::unsupported_tool(ctx.tool_name()));
}
// This resource is call-local; the slot has no URL or credential value.
let target = connections::open("api").map_err(host_failure)?;
let response = http::send(
&target,
&http::Request {
method: http::Method::Get,
path: "/status".into(),
query: Vec::new(),
headers: Vec::new(),
body: Vec::new(),
timeout_ms: ctx.remaining_ms().min(15_000),
// Stable within this logical invocation. Never automatically retry a failure.
effect_key: "status-v1".into(),
},
)
.map_err(host_failure)?;
// HTTP 4xx/5xx remain responses, not transport failures. Do not log raw bodies.
ToolOutput::json(
"HTTP response observed",
Output {
status: response.status,
body_bytes: response.body.len(),
},
)
}
fn host_failure(error: yoi::host::errors::HostError) -> ToolError {
// Preserve the effect observation for the caller without claiming rollback or retry safety.
ToolError::failed(format!(
"Host {:?}; effect {:?}; diagnostic {}",
error.code, error.effect, error.diagnostic_id
))
}
yoi_plugin_pdk::export_component_tool!(Plugin, handle_status);
@@ -0,0 +1,61 @@
package yoi:host@2.0.0;
interface errors {
enum code {
invalid-input, denied, not-found, conflict, unsupported,
quota-exceeded, deadline-exceeded, cancelled, unavailable,
indeterminate, receipt-expired,
}
enum effect-observation { not-dispatched, response-observed, indeterminate }
record host-error {
code: code,
message: string,
diagnostic-id: string,
effect: effect-observation,
}
}
interface connections {
use errors.{host-error};
/// Call-local resource. No serialization, persistence or cross-Worker transfer.
resource connection;
open: func(slot: string) -> result<connection, host-error>;
}
interface http {
use connections.{connection};
use errors.{host-error};
enum method { get, head, post, put, patch, delete, options }
record pair { name: string, value: string }
record request {
method: method,
path: string,
query: list<pair>,
headers: list<pair>,
body: list<u8>,
timeout-ms: u32,
effect-key: string,
}
record response {
status: u16,
headers: list<pair>,
body: list<u8>,
}
/// Host authorizes each operation and does not automatically retry or follow redirects.
send: func(target: borrow<connection>, input: request) -> result<response, host-error>;
}
interface logging {
use errors.{host-error};
enum level { trace, debug, info, warn, error }
variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) }
record field { name: string, value: scalar }
record entry {
level: level,
event-code: string,
message: string,
fields: list<field>,
}
/// Bounded diagnostic data, not model context. Never include secrets or raw payloads.
log: func(input: entry) -> result<_, host-error>;
}
@@ -0,0 +1,49 @@
package yoi:plugin@2.0.0;
/// Only plugin-specific payloads use schema-validated JSON. Context is not authority.
interface tool {
record invocation {
invocation-id: string,
tool-name: string,
input-json: string,
config-json: string,
remaining-ms: u32,
}
record output {
summary: string,
content: option<string>,
structured-json: option<string>,
}
record tool-error {
code: string,
message: string,
}
invoke: func(input: invocation) -> result<output, tool-error>;
}
/// Pure computation: no ambient WASI or unused Host capabilities.
world tool-plugin {
export tool;
}
world https-tool-plugin {
import yoi:host/errors@2.0.0;
import yoi:host/connections@2.0.0;
import yoi:host/http@2.0.0;
export tool;
}
/// Logging is an explicit capability, never an ambient console.
world logged-tool-plugin {
import yoi:host/errors@2.0.0;
import yoi:host/logging@2.0.0;
export tool;
}
world https-logged-tool-plugin {
import yoi:host/errors@2.0.0;
import yoi:host/connections@2.0.0;
import yoi:host/http@2.0.0;
import yoi:host/logging@2.0.0;
export tool;
}
@@ -1,5 +0,0 @@
# Rust Component Instance Plugin雛形
提案段階の`yoi:plugin/instance@1.0.0`を対象にしたオフラインpackageの雛形である。YoiはPluginのインストールやWorker内での実行を提供していない。
Tool・Serviceの要求と応答は将来の実行境界を検討するための例である。host APIやgrantのmetadataは現在の製品では権限を付与せず、オフライン検査の成功も実行の許可を意味しない。
@@ -1,3 +0,0 @@
# Build with:
# cargo component build --release
# cp target/wasm32-wasip1/release/example_yoi_instance_plugin.wasm plugin.component.wasm
@@ -1,42 +0,0 @@
schema_version = 1
id = "example.rust_service_plugin"
name = "Rust Service Plugin Template"
version = "0.1.0"
description = "Example Component Model Plugin with Tool and Service ingress handlers."
surfaces = ["tool", "service", "ingress"]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_echo" },
{ kind = "surface", surface = "service" },
{ kind = "service", name = "example_service" },
{ kind = "surface", surface = "ingress" },
{ kind = "ingress", name = "example_ws" },
{ kind = "host_api", api = "websocket" },
]
[runtime]
kind = "wasm-component"
world = "yoi:plugin/instance@1.0.0"
component = "plugin.component.wasm"
[[tools]]
name = "example_echo"
description = "Echo input text through the shared Plugin instance."
input_schema = { type = "object" }
[[services]]
name = "example_service"
description = "Host-managed service instance for bounded ingress events."
lifecycle = "host-managed"
[[ingresses]]
name = "example_ws"
description = "Handles host-owned WebSocket text events and returns websocket_send output commands."
event_kinds = ["websocket_text"]
sources = ["websocket:wss://example.com/socket"]
input_schema = { type = "object" }
[[websocket]]
scheme = "wss"
host = "example.com"
path_prefixes = ["/socket"]
@@ -1,85 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{
export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ServiceOutput, ToolError,
ToolOutput,
};
wit_bindgen::generate!({
world: "instance",
path: "../../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[derive(Default)]
struct ExamplePlugin {
count: u64,
}
#[derive(Deserialize)]
struct EchoInput {
text: String,
}
#[derive(Serialize)]
struct EchoOutput {
text: String,
count: u64,
}
impl Plugin for ExamplePlugin {
fn start(config: Value) -> Result<Self, ToolError> {
Ok(Self {
count: config.get("start_count").and_then(Value::as_u64).unwrap_or(0),
})
}
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput, ToolError> {
if name != "example_echo" {
return Err(ToolError::invalid_input(format!("unknown tool: {name}")));
}
let input: EchoInput =
serde_json::from_value(input).map_err(|err| ToolError::invalid_input(err.to_string()))?;
self.count += 1;
ToolOutput::json(
format!("echoed {} bytes", input.text.len()),
EchoOutput {
text: input.text,
count: self.count,
},
)
}
fn handle_ingress(
&mut self,
name: &str,
event: PluginIngressEvent,
) -> Result<ServiceOutput, ToolError> {
if name != "example_ws" {
return Ok(ServiceOutput::accepted(json!({ "ignored": name }))?);
}
let Some(text) = event.websocket_text() else {
return Ok(ServiceOutput::accepted(json!({
"accepted": true,
"kind": event.kind,
}))?);
};
self.count += 1;
ServiceOutput::websocket_send(
&event,
format!("example-reply-{}", self.count),
event.source.strip_prefix("websocket:").unwrap_or(&event.source),
format!("echo({}): {text}", self.count),
)
}
fn status(&self) -> Result<PluginStatus, ToolError> {
Ok(PluginStatus::ready(json!({ "count": self.count })))
}
}
export_plugin_instance!(ExamplePluginComponent, ExamplePlugin);
@@ -1,5 +1,5 @@
# Rust Component Plugin雛形
# Rust Tool Component雛形
提案段階の`yoi:plugin/tool@1.0.0`を対象にしたオフラインpackageの雛形である。YoiはPluginのインストールやWorker内での実行を提供していない。
guest境界は生成されたWIT bindingsに任せ、手書きpointer ABIとWASI依存を持たせない。呼出し間の永続状態やServiceを成功stubとして公開しない。
guest側の型付き入出力とエラー表現はRust PDKが担当し、ファイル・ネットワーク・環境への権限は付与しない。`plugin.component.wasm`はpackage検査用のテキストplaceholderであり、実行可能なcomponentの証拠にはならない。
`plugin.component.wasm`はソースからbuildして配置する成果物であり、placeholderをpackageへ混入させない。PDKからのbuild成功と、Hostの認可・実行経路の完成は別の判断である。
@@ -1 +0,0 @@
placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling.
@@ -1,20 +1,21 @@
schema_version = 1
schema_version = 2
id = "example.rust_component_tool"
name = "Rust Component Tool Template"
version = "0.1.0"
surfaces = ["tool"]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_echo" },
]
config_schema = "{}"
requested_grants = []
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0"
world = "yoi:plugin/tool-plugin@2.0.0"
required_imports = []
[[tools]]
name = "example_echo"
description = "Echo input text using the Rust PDK."
input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false }
output_schema = { type = "object", properties = { tool = { type = "string" }, text = { type = "string" } }, required = ["tool", "text"], additionalProperties = false }
error_codes = ["invalid_input", "invalid_output", "serialization", "unsupported_tool", "failed"]
external_write = false
@@ -3,13 +3,14 @@ use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({
world: "tool",
path: "../../../../resources/plugin/wit",
world: "tool-plugin",
path: "wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct EchoInput {
text: String,
}
@@ -21,6 +22,9 @@ struct EchoOutput<'a> {
}
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
if ctx.tool_name() != "example_echo" {
return Err(ToolError::unsupported_tool(ctx.tool_name()));
}
if input.text.trim().is_empty() {
return Err(ToolError::invalid_input("`text` must not be empty"));
}
@@ -0,0 +1,61 @@
package yoi:host@2.0.0;
interface errors {
enum code {
invalid-input, denied, not-found, conflict, unsupported,
quota-exceeded, deadline-exceeded, cancelled, unavailable,
indeterminate, receipt-expired,
}
enum effect-observation { not-dispatched, response-observed, indeterminate }
record host-error {
code: code,
message: string,
diagnostic-id: string,
effect: effect-observation,
}
}
interface connections {
use errors.{host-error};
/// Call-local resource. No serialization, persistence or cross-Worker transfer.
resource connection;
open: func(slot: string) -> result<connection, host-error>;
}
interface http {
use connections.{connection};
use errors.{host-error};
enum method { get, head, post, put, patch, delete, options }
record pair { name: string, value: string }
record request {
method: method,
path: string,
query: list<pair>,
headers: list<pair>,
body: list<u8>,
timeout-ms: u32,
effect-key: string,
}
record response {
status: u16,
headers: list<pair>,
body: list<u8>,
}
/// Host authorizes each operation and does not automatically retry or follow redirects.
send: func(target: borrow<connection>, input: request) -> result<response, host-error>;
}
interface logging {
use errors.{host-error};
enum level { trace, debug, info, warn, error }
variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) }
record field { name: string, value: scalar }
record entry {
level: level,
event-code: string,
message: string,
fields: list<field>,
}
/// Bounded diagnostic data, not model context. Never include secrets or raw payloads.
log: func(input: entry) -> result<_, host-error>;
}
@@ -0,0 +1,49 @@
package yoi:plugin@2.0.0;
/// Only plugin-specific payloads use schema-validated JSON. Context is not authority.
interface tool {
record invocation {
invocation-id: string,
tool-name: string,
input-json: string,
config-json: string,
remaining-ms: u32,
}
record output {
summary: string,
content: option<string>,
structured-json: option<string>,
}
record tool-error {
code: string,
message: string,
}
invoke: func(input: invocation) -> result<output, tool-error>;
}
/// Pure computation: no ambient WASI or unused Host capabilities.
world tool-plugin {
export tool;
}
world https-tool-plugin {
import yoi:host/errors@2.0.0;
import yoi:host/connections@2.0.0;
import yoi:host/http@2.0.0;
export tool;
}
/// Logging is an explicit capability, never an ambient console.
world logged-tool-plugin {
import yoi:host/errors@2.0.0;
import yoi:host/logging@2.0.0;
export tool;
}
world https-logged-tool-plugin {
import yoi:host/errors@2.0.0;
import yoi:host/connections@2.0.0;
import yoi:host/http@2.0.0;
import yoi:host/logging@2.0.0;
export tool;
}