plugin: bound component model runtime resources

This commit is contained in:
Keisuke Hirata 2026-06-20 02:16:16 +09:00
parent 57bbf14e1a
commit a705bb3bf2
No known key found for this signature in database
2 changed files with 109 additions and 1 deletions

View File

@ -1511,6 +1511,17 @@ const PLUGIN_FS_MAX_READ_BYTES: usize = 64 * 1024;
const PLUGIN_FS_MAX_WRITE_BYTES: usize = 64 * 1024; const PLUGIN_FS_MAX_WRITE_BYTES: usize = 64 * 1024;
const PLUGIN_FS_MAX_LIST_ENTRIES: usize = 256; const PLUGIN_FS_MAX_LIST_ENTRIES: usize = 256;
fn wasm_component_store_limits() -> wasmtime::StoreLimits {
wasmtime::StoreLimitsBuilder::new()
.memory_size(PLUGIN_WASM_MEMORY_BYTES)
.table_elements(PLUGIN_WASM_TABLE_ELEMENTS)
.instances(1)
.tables(1)
.memories(1)
.trap_on_grow_failure(true)
.build()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PluginFsRuntimeOperation { enum PluginFsRuntimeOperation {
Read, Read,
@ -1916,6 +1927,7 @@ fn run_plugin_wasm_tool_with_https_client(
struct PluginComponentHostState { struct PluginComponentHostState {
record: ResolvedPluginRecord, record: ResolvedPluginRecord,
https_client: Arc<dyn PluginHttpsClient>, https_client: Arc<dyn PluginHttpsClient>,
store_limits: wasmtime::StoreLimits,
} }
fn run_plugin_component_tool( fn run_plugin_component_tool(
@ -1981,8 +1993,10 @@ fn run_plugin_component_tool_with_https_client(
PluginComponentHostState { PluginComponentHostState {
record: record.clone(), record: record.clone(),
https_client, https_client,
store_limits: wasm_component_store_limits(),
}, },
); );
store.limiter(|state| &mut state.store_limits);
store store
.set_fuel(PLUGIN_WASM_FUEL) .set_fuel(PLUGIN_WASM_FUEL)
.map_err(|error| PluginWasmError::Execution(error.to_string()))?; .map_err(|error| PluginWasmError::Execution(error.to_string()))?;
@ -2000,6 +2014,12 @@ fn run_plugin_component_tool_with_https_client(
let input_json = std::str::from_utf8(&input).map_err(|error| { let input_json = std::str::from_utf8(&input).map_err(|error| {
PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}")) PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}"))
})?; })?;
// Wasmtime lifts the returned WIT `string` into a host `String` before the
// ordinary ToolOutput JSON cap can be applied. Keep the component store on
// the same memory/table/instance limits as the raw WASM runtime so an
// untrusted component can only force host string allocation from bounded
// component memory; oversized memories/tables/instances fail closed during
// instantiation/growth before this lift succeeds.
let (output,) = call let (output,) = call
.call(&mut store, (&tool_name, input_json)) .call(&mut store, (&tool_name, input_json))
.map_err(|error| PluginWasmError::Execution(error.to_string()))?; .map_err(|error| PluginWasmError::Execution(error.to_string()))?;
@ -4070,10 +4090,14 @@ input_schema = {{ type = "object", additionalProperties = true }}
} }
fn component_tool_that_returns(output: &[u8]) -> Vec<u8> { fn component_tool_that_returns(output: &[u8]) -> Vec<u8> {
component_tool_with_memory_pages(output, 1)
}
fn component_tool_with_memory_pages(output: &[u8], memory_pages: usize) -> Vec<u8> {
wat::parse_str(format!( wat::parse_str(format!(
r#"(component r#"(component
(core module $m (core module $m
(memory (export "memory") 1) (memory (export "memory") {})
(func (export "realloc") (param i32 i32 i32 i32) (result i32) (func (export "realloc") (param i32 i32 i32 i32) (result i32)
(if (result i32) (i32.eqz (local.get 0)) (if (result i32) (i32.eqz (local.get 0))
(then (i32.const 8192)) (then (i32.const 8192))
@ -4092,6 +4116,38 @@ input_schema = {{ type = "object", additionalProperties = true }}
(func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(export "call" (func $call)) (export "call" (func $call))
)"#, )"#,
memory_pages,
wat_bytes(output),
output.len()
))
.expect("valid component wat")
}
fn component_tool_with_table_elements(output: &[u8], table_elements: usize) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(core module $m
(memory (export "memory") 1)
(table {} funcref)
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
(if (result i32) (i32.eqz (local.get 0))
(then (i32.const 8192))
(else (local.get 0))))
(data (i32.const 1024) "{}")
(func (export "call") (param i32 i32 i32 i32) (result i32)
(i32.store (i32.const 2048) (i32.const 1024))
(i32.store (i32.const 2052) (i32.const {}))
(i32.const 2048))
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(alias core export $i "call" (core func $call_core))
(type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string)))
(func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(export "call" (func $call))
)"#,
table_elements,
wat_bytes(output), wat_bytes(output),
output.len() output.len()
)) ))
@ -4150,6 +4206,48 @@ input_schema = {{ type = "object", additionalProperties = true }}
assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); assert_eq!(output.content.as_deref(), Some("ordinary tool result path"));
} }
#[test]
fn component_memory_limit_fails_closed_before_string_lift() {
let oversized_memory_pages = (PLUGIN_WASM_MEMORY_BYTES / 65_536) + 1;
let (_dir, record) = resolved_record_with_component(component_tool_with_memory_pages(
br#"{"summary":"should not lift"}"#,
oversized_memory_pages,
));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component memory limit is enforced");
assert!(format!("{error:?}").contains("growing memory"), "{error:?}");
}
#[test]
fn component_table_limit_fails_closed() {
let (_dir, record) = resolved_record_with_component(component_tool_with_table_elements(
br#"{"summary":"should not run"}"#,
PLUGIN_WASM_TABLE_ELEMENTS + 1,
));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component table limit is enforced");
assert!(format!("{error:?}").contains("growing table"), "{error:?}");
}
#[test]
fn component_output_cap_still_fails_closed_after_bounded_lift() {
let output = format!(
r#"{{"summary":"too big","content":"{}"}}"#,
"x".repeat(PLUGIN_WASM_MAX_OUTPUT_BYTES)
);
let (_dir, record) =
resolved_record_with_component(component_tool_with_memory_pages(output.as_bytes(), 2));
let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec())
.expect_err("component output cap is enforced");
assert!(format!("{error:?}").contains("output exceeds"), "{error:?}");
}
#[test] #[test]
fn component_tool_denies_host_import_without_matching_grant() { fn component_tool_denies_host_import_without_matching_grant() {
let (_dir, record) = resolved_record_with_component(component_tool_importing_https( let (_dir, record) = resolved_record_with_component(component_tool_importing_https(

View File

@ -165,3 +165,13 @@ See `docs/examples/plugin-component-tool/lib.rs` for a minimal
`wit-bindgen`/SDK-style authoring sketch. Package authors should generate `wit-bindgen`/SDK-style authoring sketch. Package authors should generate
bindings from `resources/plugin/wit`, build a component artifact, and set the bindings from `resources/plugin/wit`, build a component artifact, and set the
component runtime metadata above. component runtime metadata above.
### v1 request/response shape
The v1 component world intentionally keeps Tool input, Tool output, and host API
payloads as JSON strings. This is a migration bridge that preserves the existing
ToolOutput schema, Tool history behavior, grant checks, and raw-Wasm host API
semantics while moving package authors onto WIT/canonical ABI bindings.
Structured WIT records for Tool requests/responses/errors and host HTTPS/FS
payloads are deferred to a follow-up API-design step rather than accidentally
omitted.