From 4f7dcfda615f034bb1982ac88944e3fe804be95a Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 30 Oct 2026 21:45:03 +0900 Subject: [PATCH] feat: add authorized Plugin Ticket data capabilities --- crates/manifest/src/lib.rs | 2 +- crates/manifest/src/plugin.rs | 126 +- crates/manifest/src/plugin/component.rs | 75 +- crates/manifest/src/plugin/schema.rs | 144 ++ crates/manifest/src/plugin_profile.rs | 14 + crates/plugin-pdk/src/lib.rs | 44 +- .../fixtures/ticket-tool-v2.component.wasm | Bin 0 -> 157260 bytes .../plugin-pdk/tests/ticket_bindgen_probe.rs | 47 + crates/plugin-plan/src/lib.rs | 39 +- crates/plugin-plan/src/tests.rs | 36 + crates/plugin-runtime/src/delivery.rs | 1 + crates/plugin-runtime/src/delivery/tests.rs | 10 + crates/plugin-runtime/src/executor.rs | 10 + crates/plugin-runtime/src/executor/ticket.rs | 455 ++++ crates/plugin-runtime/src/host.rs | 68 +- crates/plugin-runtime/src/lib.rs | 14 + crates/plugin-runtime/src/owner.rs | 278 ++- crates/plugin-runtime/src/owner/ticket.rs | 289 +++ crates/plugin-runtime/tests/executor.rs | 1 + .../tests/ticket-guest/Cargo.lock | 367 ++++ .../tests/ticket-guest/Cargo.toml | 13 + .../tests/ticket-guest/plugin.toml | 38 + .../tests/ticket-guest/src/lib.rs | 95 + .../plugin-runtime/tests/ticket_component.rs | 345 +++ crates/server-api/src/lib.rs | 22 + crates/server-api/src/plugin.rs | 74 +- crates/server-api/src/plugin_data.rs | 177 ++ crates/server-api/src/plugin_host.rs | 52 + crates/server-api/src/plugin_types.rs | 16 + crates/worker-runtime/src/plugin.rs | 23 + crates/worker/src/plugin/tests.rs | 1 + .../src/assignment_tracking_tests.rs | 11 +- crates/workspace-server/src/lib.rs | 1 + crates/workspace-server/src/plugin_catalog.rs | 86 + .../src/plugin_catalog/migration_tests.rs | 8 +- crates/workspace-server/src/plugin_data.rs | 600 ++++++ .../src/plugin_data/catalog_tests.rs | 159 ++ .../src/plugin_data/reference_migration.sql | 22 + .../src/plugin_data/schema.sql | 105 + .../workspace-server/src/plugin_data/tests.rs | 1102 ++++++++++ .../src/plugin_data_migration_tests.rs | 63 + crates/workspace-server/src/plugin_host.rs | 8 +- .../src/plugin_host_migration_tests.rs | 6 +- .../src/plugin_plan/migration_tests.rs | 4 + crates/workspace-server/src/plugin_receipt.rs | 16 +- .../src/plugin_receipt/tests.rs | 1 + .../workspace-server/src/plugin_settings.rs | 71 +- .../src/schema_convergence_tests.rs | 5 + crates/workspace-server/src/server.rs | 27 + crates/workspace-server/src/server_plugin.rs | 84 + .../src/server_plugin_data.rs | 331 +++ .../src/server_plugin_data_tests.rs | 1899 +++++++++++++++++ .../src/server_plugin_host.rs | 10 + .../src/server_plugin_host_tests.rs | 102 +- .../src/server_plugin_runtime_tests.rs | 6 + crates/workspace-server/src/store.rs | 61 +- .../src/subject_assignment_migration_tests.rs | 13 +- .../src/workspace_deletion.rs | 7 + openapi/server-api.json | 1028 ++++++++- resources/config-schema/plugins.dcdl | 3 + .../config-schema/profile-authoring.dcdl | 3 + resources/plugin/API.md | 6 +- .../deps/yoi-host-v2.1/yoi-host-v2.1.wit | 46 + .../deps/yoi-host-v2/yoi-host-v2.wit | 61 + .../deps/yoi-plugin-v2/yoi-plugin-v2.wit | 49 + .../plugin/wit-ticket/yoi-plugin-v2.1.wit | 10 + .../src/lib/generated/companion-api.ts | 2 +- web/workspace/src/lib/generated/plugin-api.ts | 12 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 72 files changed, 8847 insertions(+), 65 deletions(-) create mode 100644 crates/plugin-pdk/tests/fixtures/ticket-tool-v2.component.wasm create mode 100644 crates/plugin-pdk/tests/ticket_bindgen_probe.rs create mode 100644 crates/plugin-runtime/src/executor/ticket.rs create mode 100644 crates/plugin-runtime/src/owner/ticket.rs create mode 100644 crates/plugin-runtime/tests/ticket-guest/Cargo.lock create mode 100644 crates/plugin-runtime/tests/ticket-guest/Cargo.toml create mode 100644 crates/plugin-runtime/tests/ticket-guest/plugin.toml create mode 100644 crates/plugin-runtime/tests/ticket-guest/src/lib.rs create mode 100644 crates/plugin-runtime/tests/ticket_component.rs create mode 100644 crates/server-api/src/plugin_data.rs create mode 100644 crates/workspace-server/src/plugin_data.rs create mode 100644 crates/workspace-server/src/plugin_data/catalog_tests.rs create mode 100644 crates/workspace-server/src/plugin_data/reference_migration.sql create mode 100644 crates/workspace-server/src/plugin_data/schema.sql create mode 100644 crates/workspace-server/src/plugin_data/tests.rs create mode 100644 crates/workspace-server/src/plugin_data_migration_tests.rs create mode 100644 crates/workspace-server/src/server_plugin_data.rs create mode 100644 crates/workspace-server/src/server_plugin_data_tests.rs create mode 100644 resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit create mode 100644 resources/plugin/wit-ticket/deps/yoi-host-v2/yoi-host-v2.wit create mode 100644 resources/plugin/wit-ticket/deps/yoi-plugin-v2/yoi-plugin-v2.wit create mode 100644 resources/plugin/wit-ticket/yoi-plugin-v2.1.wit diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 765a28e51..e148d630e 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -25,7 +25,7 @@ pub use model::{ AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, }; pub use paths::user_profiles_path; -pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection}; +pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection, PluginTicketDataGrant}; pub use profile::{ ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 7511a97b4..6810e8bdb 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -136,10 +136,32 @@ impl fmt::Display for PluginSurface { #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum PluginRequestedGrant { Logging, - Https { slot: String }, + Https { + slot: String, + }, + TicketRead, + TicketDataRead { + collection: String, + schema_id: String, + schema_version: String, + }, + TicketDataWrite { + collection: String, + schema_id: String, + schema_version: String, + }, } /// A slot requests HTTPS protocol bounds, not credentials or concrete bindings. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginDataCollection { + pub collection: String, + pub schema_id: String, + pub schema_version: String, + pub schema: serde_json::Value, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginConnectionSlot { @@ -240,6 +262,8 @@ pub struct PluginPackageManifest { pub connections: Vec, #[serde(default)] pub requested_grants: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub data_collections: Vec, } impl PluginPackageManifest { @@ -248,6 +272,8 @@ impl PluginPackageManifest { } } +pub const PLUGIN_COMPONENT_TICKET_TOOL_WORLD: &str = "yoi:plugin/ticket-tool-plugin@2.1.0"; + pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component"; pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0"; pub const PLUGIN_COMPONENT_HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0"; @@ -1088,6 +1114,39 @@ fn validate_manifest( )); } } + let ticket_world = runtime.world == PLUGIN_COMPONENT_TICKET_TOOL_WORLD; + if !ticket_world + && (!manifest.data_collections.is_empty() + || manifest.requested_grants.iter().any(|g| { + matches!( + g, + PluginRequestedGrant::TicketRead + | PluginRequestedGrant::TicketDataRead { .. } + | PluginRequestedGrant::TicketDataWrite { .. } + ) + })) + { + return Err(reject( + PluginDiagnosticKind::Api, + "Ticket capabilities require the exact @2.1.0 Ticket world".into(), + )); + } + let mut collections = BTreeSet::new(); + for c in &manifest.data_collections { + if [&c.collection, &c.schema_id, &c.schema_version] + .iter() + .any(|s| s.is_empty() || s.len() > 128 || s.chars().any(char::is_control)) + || !collections.insert((&c.collection, &c.schema_id, &c.schema_version)) + { + return Err(reject( + PluginDiagnosticKind::Malformed, + "invalid or duplicate data collection".into(), + )); + } + schema::validate_data_schema(&c.schema) + .map_err(|e| reject(PluginDiagnosticKind::Malformed, e))?; + } + let mut ticket_grants = BTreeSet::new(); let mut requested_slots = BTreeSet::new(); let mut logging = false; for grant in &manifest.requested_grants { @@ -1095,6 +1154,29 @@ fn validate_manifest( PluginRequestedGrant::Logging if !logging => logging = true, PluginRequestedGrant::Https { slot } if slots.contains(slot) && requested_slots.insert(slot) => {} + PluginRequestedGrant::TicketRead + if ticket_world && ticket_grants.insert(("read", "", "", "")) => {} + PluginRequestedGrant::TicketDataRead { + collection, + schema_id, + schema_version, + } + | PluginRequestedGrant::TicketDataWrite { + collection, + schema_id, + schema_version, + } if ticket_world + && collections.contains(&(collection, schema_id, schema_version)) + && ticket_grants.insert(( + if matches!(grant, PluginRequestedGrant::TicketDataWrite { .. }) { + "data-write" + } else { + "data-read" + }, + collection.as_str(), + schema_id.as_str(), + schema_version.as_str(), + )) => {} _ => { return Err(reject( PluginDiagnosticKind::Malformed, @@ -1121,7 +1203,10 @@ fn validate_manifest( .required_imports .iter() .any(|v| v == "yoi:host/logging@2.0.0"); - if http != !slots.is_empty() || http != connections || logging != logs { + if (!ticket_world && (http != !slots.is_empty() || logging != logs)) + || (ticket_world && ((!slots.is_empty() && !http) || (logging && !logs))) + || http != connections + { return Err(reject( PluginDiagnosticKind::Api, "Host imports and requested grants/slots disagree".into(), @@ -1970,3 +2055,40 @@ output_schema = { type = "object", additionalProperties = false } } } } + +#[cfg(test)] +mod ticket_manifest_tests { + use super::*; + use serde_json::json; + + #[test] + fn old_world_cannot_request_ticket_capabilities_or_schema_data() { + let mut manifest: PluginPackageManifest = toml::from_str(include_str!( + "../../../resources/plugin/templates/rust-component-tool/plugin.toml" + )) + .unwrap(); + let files = BTreeMap::from([(manifest.runtime.component.clone(), Vec::new())]); + manifest + .requested_grants + .push(PluginRequestedGrant::TicketRead); + let archive = StoredArchive { files }; + let error = validate_manifest(&manifest, &archive, "fixture", PluginSourceKind::Explicit) + .unwrap_err(); + assert_eq!(error.kind, PluginDiagnosticKind::Api); + manifest + .requested_grants + .retain(|g| !matches!(g, PluginRequestedGrant::TicketRead)); + manifest.data_collections.push(PluginDataCollection { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "1".into(), + schema: json!({"type":"string"}), + }); + assert_eq!( + validate_manifest(&manifest, &archive, "fixture", PluginSourceKind::Explicit) + .unwrap_err() + .kind, + PluginDiagnosticKind::Api + ); + } +} diff --git a/crates/manifest/src/plugin/component.rs b/crates/manifest/src/plugin/component.rs index 56babc532..48595804a 100644 --- a/crates/manifest/src/plugin/component.rs +++ b/crates/manifest/src/plugin/component.rs @@ -36,6 +36,7 @@ pub fn validate_component( "yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin", "yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin", "yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin", + "yoi:plugin/ticket-tool-plugin@2.1.0" => "ticket-tool-plugin", _ => return Err(format!("unsupported Component world `{world}`")), }; if !wasmparser::Parser::is_component(bytes) { @@ -107,9 +108,27 @@ fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> { resolve .push_str("yoi-host-v2.wit", HOST_WIT) .map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?; - let package = resolve + let old_package = resolve .push_str("yoi-plugin-v2.wit", PLUGIN_WIT) .map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?; + let package = if world_name == "ticket-tool-plugin" { + resolve + .push_str( + "yoi-host-v2.1.wit", + include_str!( + "../../../../resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit" + ), + ) + .map_err(|e| format!("invalid Ticket Host WIT: {e:#}"))?; + resolve + .push_str( + "yoi-plugin-v2.1.wit", + include_str!("../../../../resources/plugin/wit-ticket/yoi-plugin-v2.1.wit"), + ) + .map_err(|e| format!("invalid Ticket Plugin WIT: {e:#}"))? + } else { + old_package + }; let world = resolve .select_world(&[package], Some(world_name)) .map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?; @@ -570,3 +589,57 @@ mod tests { assert!(validate_component(&component, PURE, &[]).is_err()); } } + +#[cfg(test)] +mod ticket_tests { + use super::*; + #[test] + fn additive_ticket_world_cannot_be_claimed_as_an_old_component_world() { + let (resolve, world) = contract("ticket-tool-plugin").unwrap(); + let mut core = + wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32); + wit_component::embed_component_metadata( + &mut core, + &resolve, + world, + wit_component::StringEncoding::UTF8, + ) + .unwrap(); + let bytes = wit_component::ComponentEncoder::default() + .module(&core) + .unwrap() + .validate(true) + .encode() + .unwrap(); + let imports: Vec<_> = surface(&resolve, world, true) + .unwrap() + .into_keys() + .collect(); + let checked = validate_component( + &bytes, + crate::plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD, + &imports, + ) + .unwrap(); + assert!(checked.imports.contains(&"yoi:host/objects@2.1.0".into())); + assert!( + checked + .imports + .contains(&"yoi:host/ticket-read@2.1.0".into()) + ); + assert!( + checked + .imports + .contains(&"yoi:host/extension-data@2.1.0".into()) + ); + assert_eq!(checked.exports, ["yoi:plugin/tool@2.0.0"]); + assert!( + validate_component( + &bytes, + crate::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD, + &imports + ) + .is_err() + ); + } +} diff --git a/crates/manifest/src/plugin/schema.rs b/crates/manifest/src/plugin/schema.rs index cb64c24bf..bc94f8495 100644 --- a/crates/manifest/src/plugin/schema.rs +++ b/crates/manifest/src/plugin/schema.rs @@ -508,3 +508,147 @@ mod tests { assert!(error.contains("depth limit"), "{error}"); } } + +/// Extension cells require a closed typed tree, not a permissive Tool schema. +/// Restricting composition/ref keywords here prevents an untyped branch or a +/// boolean schema from admitting undeclared fields. New schema shapes require +/// deliberate admission changes, never remote retrieval or guest migration. +pub fn validate_data_schema(schema: &Value) -> Result<(), String> { + validate_tool_schema(schema)?; + fn closed(node: &Value) -> Result<(), String> { + let object = node + .as_object() + .ok_or("data schema must be a typed object")?; + let kind = object + .get("type") + .and_then(Value::as_str) + .ok_or("data schema requires one explicit type")?; + let allowed: &[&str] = match kind { + "object" => &[ + "type", + "description", + "properties", + "required", + "additionalProperties", + ], + "array" => &[ + "type", + "description", + "items", + "minItems", + "maxItems", + "uniqueItems", + ], + "string" => &[ + "type", + "description", + "enum", + "minLength", + "maxLength", + "pattern", + ], + "number" | "integer" => &[ + "type", + "description", + "enum", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + ], + "boolean" | "null" => &["type", "description", "enum"], + _ => return Err("unsupported data schema type".into()), + }; + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err("unsupported data schema keyword".into()); + } + match kind { + "object" => { + if object.get("additionalProperties") != Some(&Value::Bool(false)) { + return Err("data objects must be closed".into()); + } + let properties = object + .get("properties") + .and_then(Value::as_object) + .ok_or("data object requires properties")?; + for value in properties.values() { + closed(value)?; + } + if let Some(required) = object.get("required").and_then(Value::as_array) { + if required + .iter() + .any(|name| name.as_str().is_none_or(|n| !properties.contains_key(n))) + { + return Err("required data field is undeclared".into()); + } + } + } + "array" => closed( + object + .get("items") + .ok_or("data arrays require typed items")?, + )?, + _ => {} + } + Ok(()) + } + closed(schema) +} + +/// Payload validation uses the same offline closed contract as package admission. +pub fn validate_data_value(schema: &Value, value: &Value) -> Result<(), String> { + validate_data_schema(schema)?; + if serde_json::to_vec(value) + .map_err(|_| "invalid data value")? + .len() + > 64 * 1024 + { + return Err("data value exceeds 64 KiB".into()); + } + let validator = jsonschema::options() + .with_draft(jsonschema::Draft::Draft202012) + .with_retriever(NoRetrieval) + .build(schema) + .map_err(|_| "invalid data schema")?; + if !validator.is_valid(value) { + return Err("data value does not match declared schema".into()); + } + Ok(()) +} + +#[cfg(test)] +mod data_tests { + use super::*; + use serde_json::json; + #[test] + fn closed_data_schema_rejects_open_or_untyped_branches() { + for schema in [ + json!(true), + json!({}), + json!({"type":"object"}), + json!({"type":"object","properties":{"nested":{}},"additionalProperties":false}), + json!({"type":"array"}), + json!({"type":"array","items":true}), + json!({"type":"object","properties":{},"additionalProperties":false,"anyOf":[{}]}), + json!({"type":"object","properties":{},"additionalProperties":false,"required":["undeclared"]}), + ] { + assert!(validate_data_schema(&schema).is_err(), "{schema}"); + } + } + #[test] + fn closed_data_value_enforces_nested_types_and_size() { + let schema = json!({"type":"object","additionalProperties":false,"required":["notes"],"properties":{"notes":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","maxLength":8}},"required":["text"],"additionalProperties":false}}}}); + validate_data_schema(&schema).unwrap(); + validate_data_value(&schema, &json!({"notes":[{"text":"ok"}]})).unwrap(); + for value in [ + json!({"notes":[{"text":"ok","extra":true}]}), + json!({"notes":[{"text":42}]}), + json!({"notes":[{"text":"too long text"}]}), + json!({"extra":true}), + ] { + assert!(validate_data_value(&schema, &value).is_err()); + } + assert!(validate_data_value(&json!({"type":"string"}), &json!("x".repeat(65536))).is_err()); + } +} diff --git a/crates/manifest/src/plugin_profile.rs b/crates/manifest/src/plugin_profile.rs index b17c99ba1..b86b1559f 100644 --- a/crates/manifest/src/plugin_profile.rs +++ b/crates/manifest/src/plugin_profile.rs @@ -40,10 +40,24 @@ pub struct PluginProfileSelection { pub logging: bool, #[serde(default)] pub https_slots: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ticket_read: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ticket_data_read: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ticket_data_write: Vec, #[serde(default)] pub config: BTreeMap, } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginTicketDataGrant { + pub collection: String, + pub schema_id: String, + pub schema_version: String, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/plugin-pdk/src/lib.rs b/crates/plugin-pdk/src/lib.rs index 364833a44..04f8bcf89 100644 --- a/crates/plugin-pdk/src/lib.rs +++ b/crates/plugin-pdk/src/lib.rs @@ -12,6 +12,24 @@ 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-v2.wit"); +/// Additive Ticket resource world. Existing world constants retain exact v2 ABI. +pub const TICKET_TOOL_WORLD: &str = "yoi:plugin/ticket-tool-plugin@2.1.0"; +pub const TICKET_TOOL_WIT: &str = + include_str!("../../../resources/plugin/wit-ticket/yoi-plugin-v2.1.wit"); +pub const TICKET_HOST_WIT: &str = + include_str!("../../../resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit"); +pub const MAX_EXTENSION_DATA_BYTES: usize = 64 * 1024; + +/// Encode a cell without implicit mutation retries or schema conversion. +/// The Host remains authoritative for closed-schema and namespace validation. +pub fn extension_data_json(value: &T) -> Result { + let json = serde_json::to_string(value).map_err(ToolError::serialization)?; + if json.len() > MAX_EXTENSION_DATA_BYTES { + return Err(ToolError::invalid_input("extension data exceeds 64 KiB")); + } + Ok(json) +} + pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024; pub const MAX_TOOL_INPUT_BYTES: usize = 64 * 1024; pub const MAX_CONFIG_BYTES: usize = 64 * 1024; @@ -213,13 +231,17 @@ pub fn run_json_tool( #[macro_export] macro_rules! export_component_tool { ($adapter:ident, $handler:path) => { + $crate::export_component_tool!($adapter, $handler, exports::yoi::plugin::tool); + }; + ($adapter:ident, $handler:path, $interface:path) => { + use $interface as __yoi_component_tool_interface; struct $adapter; - impl exports::yoi::plugin::tool::Guest for $adapter { + impl __yoi_component_tool_interface::Guest for $adapter { fn invoke( - input: exports::yoi::plugin::tool::Invocation, + input: __yoi_component_tool_interface::Invocation, ) -> ::std::result::Result< - exports::yoi::plugin::tool::Output, - exports::yoi::plugin::tool::ToolError, + __yoi_component_tool_interface::Output, + __yoi_component_tool_interface::ToolError, > { let context = $crate::ToolContext::new( input.invocation_id, @@ -230,13 +252,13 @@ macro_rules! export_component_tool { $crate::run_json_tool(context, &input.input_json, $handler) .map(|output| { let (summary, content, structured_json) = output.into_parts(); - exports::yoi::plugin::tool::Output { + __yoi_component_tool_interface::Output { summary, content, structured_json, } }) - .map_err(|error| exports::yoi::plugin::tool::ToolError { + .map_err(|error| __yoi_component_tool_interface::ToolError { code: error.code_str().to_owned(), message: error.message().to_owned(), }) @@ -246,6 +268,16 @@ macro_rules! export_component_tool { }; } +/// Export glue for the exact Ticket world, retaining the original Tool ABI. +/// Generate bindings from wit-ticket with generate_all; its versioned modules +/// expose yoi::host2_1_0::{objects,ticket_read,extension_data} without raw ABI code. +#[macro_export] +macro_rules! export_ticket_component_tool { + ($adapter:ident, $handler:path) => { + $crate::export_component_tool!($adapter, $handler, exports::yoi::plugin2_0_0::tool); + }; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/plugin-pdk/tests/fixtures/ticket-tool-v2.component.wasm b/crates/plugin-pdk/tests/fixtures/ticket-tool-v2.component.wasm new file mode 100644 index 0000000000000000000000000000000000000000..063c6f2967fcd227f1403335e9e54ff46d2e920f GIT binary patch literal 157260 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 z

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 VhuweCgWqK~_Wz$@{o^&k{{xoyEYJV| literal 0 HcmV?d00001 diff --git a/crates/plugin-pdk/tests/ticket_bindgen_probe.rs b/crates/plugin-pdk/tests/ticket_bindgen_probe.rs new file mode 100644 index 000000000..e77b92bda --- /dev/null +++ b/crates/plugin-pdk/tests/ticket_bindgen_probe.rs @@ -0,0 +1,47 @@ +//! Compile-pass: the additive world uses the original Tool export and typed +//! imported Ticket resources; no handwritten canonical ABI or ambient authority. +use yoi_plugin_pdk::wit_bindgen; +wit_bindgen::generate!({ + world: "ticket-tool-plugin", + path: "../../resources/plugin/wit-ticket", + generate_all, + runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", +}); +fn handle( + _: yoi_plugin_pdk::ToolContext, + input: String, +) -> yoi_plugin_pdk::Result { + yoi_plugin_pdk::ToolOutput::summary(input) +} +yoi_plugin_pdk::export_ticket_component_tool!(TicketProbe, handle); + +#[test] +fn additive_export_uses_original_tool_result_abi() { + use exports::yoi::plugin2_0_0::tool::{Guest, Invocation}; + let result = TicketProbe::invoke(Invocation { + invocation_id: "invocation".into(), + tool_name: "probe".into(), + input_json: "\"ok\"".into(), + config_json: "{}".into(), + remaining_ms: 1000, + }) + .unwrap(); + assert_eq!(result.summary, "ok"); + let _access = yoi::host2_1_0::objects::Access::ExtensionWrite; +} + +#[test] +fn old_wit_dependencies_are_exact_copies_not_augmented_versions() { + assert_eq!( + include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"), + include_str!("../../../resources/plugin/wit-ticket/deps/yoi-plugin-v2/yoi-plugin-v2.wit") + ); + assert_eq!( + include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"), + include_str!("../../../resources/plugin/wit-ticket/deps/yoi-host-v2/yoi-host-v2.wit") + ); + assert_ne!( + yoi_plugin_pdk::TICKET_TOOL_WORLD, + yoi_plugin_pdk::TOOL_WORLD + ); +} diff --git a/crates/plugin-plan/src/lib.rs b/crates/plugin-plan/src/lib.rs index 2ee98754e..81693d200 100644 --- a/crates/plugin-plan/src/lib.rs +++ b/crates/plugin-plan/src/lib.rs @@ -91,12 +91,25 @@ pub enum PluginSurface { Tool, } -/// Only capabilities implemented by the initial Tool Host are representable. +/// Exact grant identities; new operations are never implied by old pins. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum PluginGrant { Logging, - Https { slot: String }, + Https { + slot: String, + }, + TicketRead, + TicketDataRead { + collection: String, + schema_id: String, + schema_version: String, + }, + TicketDataWrite { + collection: String, + schema_id: String, + schema_version: String, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -164,6 +177,7 @@ impl WorkerPluginPin { | plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD | plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD | plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD + | plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD ) || !valid_digest(&artifact.config_schema.digest) || !valid_identity(&artifact.package_id) @@ -201,6 +215,27 @@ impl WorkerPluginPin { .any(|slot| *slot == binding.slot) }) || projection.grants.iter().any(|grant| match grant { + PluginGrant::TicketRead + | PluginGrant::TicketDataRead { .. } + | PluginGrant::TicketDataWrite { .. } => { + artifact.world != plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD + || !artifact.requested_grants.contains(grant) + || match grant { + PluginGrant::TicketDataRead { + collection, + schema_id, + schema_version, + } + | PluginGrant::TicketDataWrite { + collection, + schema_id, + schema_version, + } => [collection, schema_id, schema_version] + .iter() + .any(|v| !valid_identity(v) || v.len() > 128), + _ => false, + } + } PluginGrant::Logging => !artifact.requested_grants.contains(&PluginGrant::Logging), PluginGrant::Https { slot } => { !valid_identity(slot) diff --git a/crates/plugin-plan/src/tests.rs b/crates/plugin-plan/src/tests.rs index a093157db..1099d27ff 100644 --- a/crates/plugin-plan/src/tests.rs +++ b/crates/plugin-plan/src/tests.rs @@ -202,3 +202,39 @@ fn deserialized_unknown_fields_versions_and_noncanonical_order_are_rejected() { unsorted.input.plugins.push(second); assert_eq!(unsorted.validate_canonical(), Err(PlanError::Invalid)); } + +#[test] +fn ticket_grants_require_exact_world_request_and_schema_namespace() { + let mut a = artifact(); + let write = PluginGrant::TicketDataWrite { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "1".into(), + }; + let read = PluginGrant::TicketDataRead { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "1".into(), + }; + a.requested_grants + .extend([PluginGrant::TicketRead, write.clone()]); + let mut selected = projection(); + selected.grants.push(write.clone()); + // Even a fabricated metadata request cannot add Ticket operations to v2.0. + assert_eq!(pin(&a, selected.clone()), Err(PlanError::Invalid)); + a.world = plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD.into(); + let exact = pin(&a, selected.clone()).unwrap(); + assert!(exact.projection().grants.contains(&write)); + assert!(!exact.projection().grants.contains(&read)); // write is not read + selected.grants.push(read); + assert_eq!(pin(&a, selected), Err(PlanError::Invalid)); + let mut selected = projection(); + selected.grants.push(PluginGrant::TicketDataWrite { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "2".into(), + }); + assert_eq!(pin(&a, selected), Err(PlanError::Invalid)); + a.requested_grants.retain(|g| g != &write); + assert_eq!(exact.matches_artifact(&a), Err(PlanError::Invalid)); +} diff --git a/crates/plugin-runtime/src/delivery.rs b/crates/plugin-runtime/src/delivery.rs index 5bfad2163..e4ec407d3 100644 --- a/crates/plugin-runtime/src/delivery.rs +++ b/crates/plugin-runtime/src/delivery.rs @@ -360,6 +360,7 @@ impl ArtifactCache { || !same_json(&d.tools, &m.tools)? || !same_json(&d.connections, &m.connections)? || !same_json(&d.requested_grants, &m.requested_grants)? + || !same_json(&d.data_collections, &m.data_collections)? || d.config_schema.dcdl != m.config_schema || d.config_schema.digest != schema.digest || d.config_schema.identity != schema.identity diff --git a/crates/plugin-runtime/src/delivery/tests.rs b/crates/plugin-runtime/src/delivery/tests.rs index e6dc60aed..ab4c0fda7 100644 --- a/crates/plugin-runtime/src/delivery/tests.rs +++ b/crates/plugin-runtime/src/delivery/tests.rs @@ -46,6 +46,7 @@ fn fixture() -> (CanonicalPluginArtifact, PluginArtifact) { tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(), connections: vec![], requested_grants: vec![], + data_collections: vec![], config_schema: server_api::PluginSchemaContribution { identity: format!("{}/config", m.id), version: m.version.clone(), @@ -391,6 +392,7 @@ async fn canonical_package_must_match_complete_descriptor_not_only_digests() { "tools", "connections", "grants", + "data_collections", "schema_identity", "schema_version", "schema_digest", @@ -415,6 +417,14 @@ async fn canonical_package_must_match_complete_descriptor_not_only_digests() { "grants" => d .requested_grants .push(server_api::PluginGrantMetadata::Logging), + "data_collections" => d + .data_collections + .push(server_api::PluginDataSchemaMetadata { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "1".into(), + schema: serde_json::json!({"type":"string"}), + }), "schema_identity" => d.config_schema.identity.push('x'), "schema_version" => d.config_schema.version.push('x'), "schema_digest" => d.config_schema.digest.push('x'), diff --git a/crates/plugin-runtime/src/executor.rs b/crates/plugin-runtime/src/executor.rs index 623afefbc..c47ef48d9 100644 --- a/crates/plugin-runtime/src/executor.rs +++ b/crates/plugin-runtime/src/executor.rs @@ -1,3 +1,4 @@ +mod ticket; use crate::sandbox::SandboxLimits; use crate::{ bindings, @@ -297,6 +298,15 @@ impl Executor { |s| s, ) .map_err(trap)?; + if compiled.manifest.runtime.world == plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD { + use crate::host::{extension_data, objects, ticket_read}; + objects::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s) + .map_err(trap)?; + ticket_read::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s) + .map_err(trap)?; + extension_data::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s) + .map_err(trap)?; + } let instance = bindings::HttpsLoggedToolPlugin::instantiate_async( &mut store, &compiled.component, diff --git a/crates/plugin-runtime/src/executor/ticket.rs b/crates/plugin-runtime/src/executor/ticket.rs new file mode 100644 index 000000000..fa4486f29 --- /dev/null +++ b/crates/plugin-runtime/src/executor/ticket.rs @@ -0,0 +1,455 @@ +use super::*; +use crate::host::{ + Ticket, TicketDataOperation as Op, TicketDataResult as Reply, extension_data as data, objects, + ticket_read, +}; +use server_api::{ + PluginDataAddress as Address, PluginDataCell as Cell, PluginDataCollection as Collection, + PluginDataExpectation as Expectation, PluginDataMutation as Mutation, +}; + +fn denied() -> errors::HostError { + host::error(errors::Code::Denied, "Ticket capability not granted") +} +fn invalid() -> errors::HostError { + host::error(errors::Code::InvalidInput, "invalid Ticket data request") +} +fn protocol() -> errors::HostError { + host::error(errors::Code::Unavailable, "invalid Ticket owner response") +} +fn collection(c: data::Collection) -> Collection { + Collection { + collection: c.collection, + schema_id: c.schema_id, + schema_version: c.schema_version, + } +} +fn address(a: data::Address) -> Address { + Address { + collection: collection(a.namespace), + key: a.key, + } +} +fn cell(c: Cell) -> data::Cell { + match c { + Cell::NeverWritten => data::Cell::NeverWritten, + Cell::Deleted { last_delete } => data::Cell::Deleted(last_delete), + Cell::Present { + value_json, + last_write, + } => data::Cell::Present(data::PresentCell { + value_json, + last_write, + }), + } +} +fn receipt(r: server_api::PluginDataReceipt) -> data::Receipt { + data::Receipt { + mutation_id: r.mutation_id, + result_digest: r.result_digest, + } +} +impl State { + fn ticket_world(&self) -> Result<(), errors::HostError> { + if self.manifest.runtime.world != plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD { + return Err(denied()); + } + Ok(()) + } + fn data_schema(&self, c: &Collection) -> Result<&serde_json::Value, errors::HostError> { + self.manifest + .data_collections + .iter() + .find(|d| { + d.collection == c.collection + && d.schema_id == c.schema_id + && d.schema_version == c.schema_version + }) + .map(|d| &d.schema) + .ok_or_else(denied) + } + fn data_grant(&self, ctx: &InvocationContext, c: &Collection, write: bool) -> bool { + let id = ( + c.collection.clone(), + c.schema_id.clone(), + c.schema_version.clone(), + ); + let requested = if write { + plugin::PluginRequestedGrant::TicketDataWrite { + collection: c.collection.clone(), + schema_id: c.schema_id.clone(), + schema_version: c.schema_version.clone(), + } + } else { + plugin::PluginRequestedGrant::TicketDataRead { + collection: c.collection.clone(), + schema_id: c.schema_id.clone(), + schema_version: c.schema_version.clone(), + } + }; + self.manifest.requested_grants.contains(&requested) + && (if write { + &ctx.grants.ticket_data_write + } else { + &ctx.grants.ticket_data_read + }) + .contains(&id) + } + fn check_cell(&self, namespace: &Collection, c: &Cell) -> Result<(), errors::HostError> { + if let Cell::Present { value_json, .. } = c { + if value_json.len() > server_api::PLUGIN_DATA_VALUE_LIMIT { + return Err(protocol()); + } + let value = serde_json::from_str(value_json).map_err(|_| protocol())?; + plugin::schema::validate_data_value(self.data_schema(namespace)?, &value) + .map_err(|_| protocol())?; + } + Ok(()) + } + async fn data_call( + &mut self, + target: Resource, + operation: Op, + ) -> wasmtime::Result> { + let context = self.current()?; + let checked = (|| { + self.operation()?; + self.ticket_world()?; + crate::owner::validate_operation(&operation).map_err(|_| invalid())?; + let (namespace, write, lookup) = match &operation { + Op::Get(a) => (&a.collection, false, false), + Op::Mutate { address, .. } => (&address.collection, true, false), + Op::List { collection, .. } => (collection, false, false), + Op::LookupMutation { address, .. } => (&address.collection, false, true), + }; + let schema = self.data_schema(namespace)?; + if !self.data_grant(&context, namespace, write || lookup) { + return Err(denied()); + } + if let Op::Mutate { + mutation: Mutation::Put { value_json, .. }, + .. + } = &operation + { + let value = serde_json::from_str(value_json).map_err(|_| invalid())?; + plugin::schema::validate_data_value(schema, &value).map_err(|_| invalid())?; + } + Ok(()) + })(); + if let Err(e) = checked { + return Ok(Err(e)); + } + let binding = self.table.get(&target)?.binding.clone(); + let namespace = match &operation { + Op::Get(a) | Op::Mutate { address: a, .. } | Op::LookupMutation { address: a, .. } => { + a.collection.clone() + } + Op::List { collection, .. } => collection.clone(), + }; + let list_limit = if let Op::List { limit, .. } = &operation { + Some(*limit as usize) + } else { + None + }; + let mutating = matches!(&operation, Op::Mutate { .. }); + let expected_reply = match &operation { + Op::Get(_) => 0, + Op::Mutate { .. } => 1, + Op::List { .. } => 2, + Op::LookupMutation { .. } => 3, + }; + let reply = match self + .provider + .ticket_data(&context, binding.as_ref(), operation) + .await + { + Ok(reply) => reply, + Err(e) => return Ok(Err(e)), + }; + check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket data result"))?; + let actual_reply = match &reply { + Reply::Cell(_) => 0, + Reply::Mutated(_) => 1, + Reply::Listed(_) => 2, + Reply::MutationLookup(_) => 3, + }; + if actual_reply != expected_reply { + let mut error = protocol(); + if mutating { + error.code = errors::Code::Indeterminate; + error.effect = errors::EffectObservation::Indeterminate; + } + return Ok(Err(error)); + } + let valid = match &reply { + Reply::Cell(c) => self.check_cell(&namespace, c), + Reply::Listed(page) => { + if page.entries.len() > list_limit.unwrap_or(0) + || page.entries.windows(2).any(|p| p[0].key >= p[1].key) + || page.entries.iter().any(|e| { + e.key.is_empty() + || e.key.len() > 256 + || matches!(e.cell, Cell::NeverWritten) + }) + || page + .next_cursor + .as_ref() + .is_some_and(|s| s.is_empty() || s.len() > 4096) + { + Err(protocol()) + } else { + page.entries + .iter() + .try_for_each(|e| self.check_cell(&namespace, &e.cell)) + } + } + _ => Ok(()), + }; + if let Err(e) = valid { + return Ok(Err(e)); + } + Ok(Ok(reply)) + } +} +impl objects::HostTicket for State { + async fn drop(&mut self, target: Resource) -> wasmtime::Result<()> { + self.table.delete(target)?; + self.resources -= 1; + Ok(()) + } +} +impl objects::Host for State { + async fn resolve_ticket( + &mut self, + ticket_ref: String, + requested: objects::Access, + ) -> wasmtime::Result, errors::HostError>> { + let context = self.current()?; + if let Err(e) = self.operation().and_then(|_| self.ticket_world()) { + return Ok(Err(e)); + } + if ticket_ref.is_empty() || ticket_ref.len() > 256 { + return Ok(Err(invalid())); + } + let writable = matches!(requested, objects::Access::ExtensionWrite); + let allowed = self.manifest.requested_grants.iter().any(|g| match g { + plugin::PluginRequestedGrant::TicketRead => !writable && context.grants.ticket_read, + plugin::PluginRequestedGrant::TicketDataRead { + collection, + schema_id, + schema_version, + } => { + !writable + && context.grants.ticket_data_read.contains(&( + collection.clone(), + schema_id.clone(), + schema_version.clone(), + )) + } + plugin::PluginRequestedGrant::TicketDataWrite { + collection, + schema_id, + schema_version, + } => { + writable + && context.grants.ticket_data_write.contains(&( + collection.clone(), + schema_id.clone(), + schema_version.clone(), + )) + } + _ => false, + }); + if !allowed { + return Ok(Err(denied())); + } + if self.resources >= self.limits.resources { + return Ok(Err(host::error( + errors::Code::QuotaExceeded, + "resource limit", + ))); + } + let binding = match self + .provider + .resolve_ticket(&context, &ticket_ref, requested) + .await + { + Ok(binding) => binding, + Err(e) => return Ok(Err(e)), + }; + check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket resource"))?; + let target = self.table.push(Ticket { binding })?; + self.resources += 1; + Ok(Ok(target)) + } +} +impl ticket_read::Host for State { + async fn read( + &mut self, + target: Resource, + ) -> wasmtime::Result> { + let context = self.current()?; + if let Err(e) = self.operation().and_then(|_| self.ticket_world()) { + return Ok(Err(e)); + } + if !context.grants.ticket_read + || !self + .manifest + .requested_grants + .contains(&plugin::PluginRequestedGrant::TicketRead) + { + return Ok(Err(denied())); + } + let binding = self.table.get(&target)?.binding.clone(); + let result = self.provider.read_ticket(&context, binding.as_ref()).await; + check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket snapshot"))?; + Ok(result.and_then(|s| { + if s.body.len() <= server_api::PLUGIN_TICKET_BODY_LIMIT { + Ok(s) + } else { + Err(protocol()) + } + })) + } +} +impl data::Host for State { + async fn get( + &mut self, + target: Resource, + cell_address: data::Address, + ) -> wasmtime::Result> { + Ok( + match self + .data_call(target, Op::Get(address(cell_address))) + .await? + { + Ok(Reply::Cell(c)) => Ok(cell(c)), + Err(e) => Err(e), + _ => Err(protocol()), + }, + ) + } + async fn put( + &mut self, + target: Resource, + cell_address: data::Address, + expected: data::Expectation, + operation_key: String, + value_json: String, + ) -> wasmtime::Result> { + let expected = match expected { + data::Expectation::NeverWritten => Expectation::NeverWritten, + data::Expectation::LastWrite(id) => Expectation::LastWrite(id), + }; + Ok( + match self + .data_call( + target, + Op::Mutate { + address: address(cell_address), + operation_key, + mutation: Mutation::Put { + expected, + value_json, + }, + }, + ) + .await? + { + Ok(Reply::Mutated(r)) => Ok(receipt(r)), + Err(e) => Err(e), + _ => Err(protocol()), + }, + ) + } + async fn delete( + &mut self, + target: Resource, + cell_address: data::Address, + expected_last_write: String, + operation_key: String, + ) -> wasmtime::Result> { + Ok( + match self + .data_call( + target, + Op::Mutate { + address: address(cell_address), + operation_key, + mutation: Mutation::Delete { + expected_last_write, + }, + }, + ) + .await? + { + Ok(Reply::Mutated(r)) => Ok(receipt(r)), + Err(e) => Err(e), + _ => Err(protocol()), + }, + ) + } + async fn list( + &mut self, + target: Resource, + namespace: data::Collection, + cursor: Option, + limit: u32, + ) -> wasmtime::Result> { + Ok( + match self + .data_call( + target, + Op::List { + collection: collection(namespace), + cursor, + limit, + }, + ) + .await? + { + Ok(Reply::Listed(p)) => Ok(data::Page { + entries: p + .entries + .into_iter() + .map(|e| data::Entry { + key: e.key, + value: cell(e.cell), + }) + .collect(), + next_cursor: p.next_cursor, + }), + Err(e) => Err(e), + _ => Err(protocol()), + }, + ) + } + async fn lookup_mutation( + &mut self, + target: Resource, + cell_address: data::Address, + operation_key: String, + ) -> wasmtime::Result> { + use server_api::PluginDataMutationLookup as Lookup; + Ok( + match self + .data_call( + target, + Op::LookupMutation { + address: address(cell_address), + operation_key, + }, + ) + .await? + { + Ok(Reply::MutationLookup(outcome)) => Ok(match outcome { + Lookup::NotRecorded => data::MutationLookup::NotRecorded, + Lookup::Expired => data::MutationLookup::Expired, + Lookup::Committed(r) => data::MutationLookup::Committed(receipt(r)), + }), + Err(e) => Err(e), + _ => Err(protocol()), + }, + ) + } +} diff --git a/crates/plugin-runtime/src/host.rs b/crates/plugin-runtime/src/host.rs index e92d71d59..8cb28b736 100644 --- a/crates/plugin-runtime/src/host.rs +++ b/crates/plugin-runtime/src/host.rs @@ -5,6 +5,7 @@ //! cancellation-safe, bound their own allocations and never auto-retry effects. pub use crate::bindings::yoi::host::{errors, http, logging}; +pub use crate::ticket_bindings::yoi::host2_1_0::{extension_data, objects, ticket_read}; use async_trait::async_trait; use std::{any::Any, collections::BTreeSet, sync::Arc}; use tokio::time::Instant; @@ -30,6 +31,9 @@ pub struct InvocationContext { pub struct Grants { pub logging: bool, pub connection_slots: BTreeSet, + pub ticket_read: bool, + pub ticket_data_read: BTreeSet<(String, String, String)>, + pub ticket_data_write: BTreeSet<(String, String, String)>, } /// Opaque Host-owned capability, stored only within one invocation's Store. @@ -39,6 +43,11 @@ pub struct Connection { pub(crate) binding: Arc, } +/// Opaque Ticket capability stored only in one invocation's Store. +pub struct Ticket { + pub(crate) binding: Arc, +} + /// Each operation requires a fresh owner check; admission covers pure Tools too. pub enum Operation<'a> { Admit, @@ -54,7 +63,8 @@ pub enum Operation<'a> { Log, } -/// No default implementation is supplied. Test providers belong to test targets. +/// Core operations require implementations. Additive APIs default to Unsupported +/// for old providers, never to permission or success. Test providers belong to tests. /// /// `authorize` must check the current installation/artifact/plan/subject and /// operation against live owner policy. `open`/`send` must also enforce concrete @@ -63,6 +73,35 @@ pub enum Operation<'a> { /// dropped on cancellation/deadline; providers must not leave detached work. #[async_trait] pub trait Host: Send + Sync { + /// Old providers fail closed, never infer permission from an old grant. + async fn resolve_ticket( + &self, + _context: &InvocationContext, + _ticket_ref: &str, + _access: objects::Access, + ) -> Result, errors::HostError> { + Err(error(errors::Code::Unsupported, "Ticket API unavailable")) + } + async fn read_ticket( + &self, + _context: &InvocationContext, + _binding: &(dyn Any + Send + Sync), + ) -> Result { + Err(error(errors::Code::Unsupported, "Ticket API unavailable")) + } + /// Providers live-authorize the concrete operation, Ticket and namespace. + async fn ticket_data( + &self, + _context: &InvocationContext, + _binding: &(dyn Any + Send + Sync), + _operation: TicketDataOperation, + ) -> Result { + Err(error( + errors::Code::Unsupported, + "Ticket data API unavailable", + )) + } + async fn authorize( &self, context: &InvocationContext, @@ -94,3 +133,30 @@ pub(crate) fn error(code: errors::Code, message: &str) -> errors::HostError { effect: errors::EffectObservation::NotDispatched, } } + +/// Portable provider boundary shared by Remote and Embedded owners. +#[derive(Debug, Clone)] +pub enum TicketDataOperation { + Get(server_api::PluginDataAddress), + Mutate { + address: server_api::PluginDataAddress, + operation_key: String, + mutation: server_api::PluginDataMutation, + }, + List { + collection: server_api::PluginDataCollection, + cursor: Option, + limit: u32, + }, + LookupMutation { + address: server_api::PluginDataAddress, + operation_key: String, + }, +} +#[derive(Debug)] +pub enum TicketDataResult { + Cell(server_api::PluginDataCell), + Mutated(server_api::PluginDataReceipt), + Listed(server_api::PluginDataPage), + MutationLookup(server_api::PluginDataMutationLookup), +} diff --git a/crates/plugin-runtime/src/lib.rs b/crates/plugin-runtime/src/lib.rs index 54a506cce..450226753 100644 --- a/crates/plugin-runtime/src/lib.rs +++ b/crates/plugin-runtime/src/lib.rs @@ -16,3 +16,17 @@ pub mod host; pub mod owner; mod sandbox; pub use executor::*; + +/// Additive Ticket imports share the original v2 error types. +pub mod ticket_bindings { + wasmtime::component::bindgen!({ + path: "../../resources/plugin/wit-ticket", + world: "ticket-tool-plugin", + imports: { default: async | trappable }, + exports: { default: async }, + with: { + "yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors, + "yoi:host/objects@2.1.0/ticket": crate::host::Ticket, + }, + }); +} diff --git a/crates/plugin-runtime/src/owner.rs b/crates/plugin-runtime/src/owner.rs index 2bfbb449b..30eb20329 100644 --- a/crates/plugin-runtime/src/owner.rs +++ b/crates/plugin-runtime/src/owner.rs @@ -1,6 +1,10 @@ //! Shared owner transport and WIT Host adapter. In-process owners implement the //! same trait directly; a failed remote call never selects a local fallback. -use crate::host::{Host, InvocationContext, Operation, errors, http, logging}; +use crate::host::{ + Host, InvocationContext, Operation, TicketDataOperation, TicketDataResult, errors, http, + logging, objects, ticket_read, +}; +mod ticket; use async_trait::async_trait; use server_api::{ PluginConsumer, PluginEffectObservation, PluginHostError, PluginHostErrorCode, @@ -12,6 +16,7 @@ use std::{ sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; +pub(crate) use ticket::validate_operation; use tokio::time::Instant; /// Bound to one authenticated caller and Workspace by the integration. The owner @@ -44,11 +49,12 @@ pub trait PluginHostOwner: Send + Sync { } } -/// Finite generated-client response budget for the supported one-MiB binary -/// body, its worst-case JSON numeric-array expansion, headers and envelope. +/// Finite generated-client response budget: a 100-cell page of 64-KiB JSON +/// strings, their wire escaping, plus keys/receipt metadata; also covers the +/// original one-MiB binary HTTP body and numeric-array expansion. /// Set `response_body_limit` on the injected client before constructing a Remote /// owner; a smaller operator-selected limit may reject otherwise valid replies. -pub const PLUGIN_HOST_RESPONSE_BODY_LIMIT: usize = 4 * 1024 * 1024 + 512 * 1024; +pub const PLUGIN_HOST_RESPONSE_BODY_LIMIT: usize = 16 * 1024 * 1024; /// Uses an injected, already configured generated client (including its existing /// Runtime request authorizer). This adapter never creates or owns credentials. @@ -72,7 +78,10 @@ impl PluginHostOwner &self, request: PluginHostRequest, ) -> Result { - let may_dispatch = matches!(request, PluginHostRequest::Send { .. }); + let may_dispatch = matches!( + request, + PluginHostRequest::Send { .. } | PluginHostRequest::DataMutate { .. } + ); self.client .plugin_host(self.workspace_id.clone(), request) .await @@ -210,6 +219,9 @@ impl OwnerHost { || context.invocation_id != bound.invocation_id || context.artifact != bound.artifact || context.tool_name != bound.tool_name + || context.grants.ticket_read != bound.grants.ticket_read + || context.grants.ticket_data_read != bound.grants.ticket_data_read + || context.grants.ticket_data_write != bound.grants.ticket_data_write || context.grants.logging != bound.grants.logging || context.grants.connection_slots != bound.grants.connection_slots // The executor may shorten the deadline and derive a child token. @@ -253,6 +265,36 @@ impl OwnerHost { #[async_trait] impl Host for OwnerHost { + async fn resolve_ticket( + &self, + context: &InvocationContext, + ticket_ref: &str, + access: objects::Access, + ) -> Result, errors::HostError> { + self.resolve_ticket_binding(context, ticket_ref, access) + .await + .map_err(wit_error) + } + async fn read_ticket( + &self, + context: &InvocationContext, + binding: &(dyn Any + Send + Sync), + ) -> Result { + self.read_ticket_binding(context, binding) + .await + .map_err(wit_error) + } + async fn ticket_data( + &self, + context: &InvocationContext, + binding: &(dyn Any + Send + Sync), + operation: TicketDataOperation, + ) -> Result { + self.ticket_data_binding(context, binding, operation) + .await + .map_err(wit_error) + } + async fn authorize( &self, context: &InvocationContext, @@ -447,7 +489,10 @@ async fn call_owner( request: PluginHostRequest, ) -> Result { check_lifetime(context)?; - let effect = if matches!(request, PluginHostRequest::Send { .. }) { + let effect = if matches!( + request, + PluginHostRequest::Send { .. } | PluginHostRequest::DataMutate { .. } + ) { PluginEffectObservation::Indeterminate } else { PluginEffectObservation::NotDispatched @@ -588,6 +633,40 @@ mod tests { }, }, PluginHostRequest::Log { .. } => PluginHostResponse::Logged, + PluginHostRequest::ResolveTicket { .. } => PluginHostResponse::TicketResolved { + ticket_id: "canonical-ticket".into(), + }, + PluginHostRequest::ReadTicket { .. } => PluginHostResponse::TicketRead { + snapshot: server_api::PluginTicketSnapshot { + title: "title".into(), + case_state: server_api::PluginTicketCaseState::Open, + content_digest: "digest".into(), + body: "bounded".into(), + truncated: false, + }, + }, + PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell { + cell: server_api::PluginDataCell::NeverWritten, + }, + PluginHostRequest::DataMutate { operation_key, .. } => { + PluginHostResponse::DataMutated { + receipt: server_api::PluginDataReceipt { + mutation_id: operation_key, + result_digest: "digest".into(), + }, + } + } + PluginHostRequest::DataList { .. } => PluginHostResponse::DataListed { + page: server_api::PluginDataPage { + entries: vec![], + next_cursor: None, + }, + }, + PluginHostRequest::DataLookupMutation { .. } => { + PluginHostResponse::DataMutationLookup { + outcome: server_api::PluginDataMutationLookup::NotRecorded, + } + } }) } } @@ -607,6 +686,7 @@ mod tests { grants: Grants { logging: true, connection_slots: ["api".into(), "other".into()].into(), + ..Default::default() }, deadline: Instant::now() + Duration::from_secs(60), cancellation: CancellationToken::new(), @@ -860,6 +940,192 @@ mod tests { assert_eq!(owner.requests.lock().unwrap().len(), count + 1); } + fn ticket_context() -> InvocationContext { + let mut c = context(); + c.grants.ticket_read = true; + c.grants + .ticket_data_read + .insert(("notes".into(), "note".into(), "1".into())); + c.grants.ticket_data_write = c.grants.ticket_data_read.clone(); + c + } + fn cell_address() -> server_api::PluginDataAddress { + server_api::PluginDataAddress { + collection: server_api::PluginDataCollection { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "1".into(), + }, + key: "key".into(), + } + } + #[tokio::test] + async fn remote_and_direct_ticket_resources_reauthorize_and_reject_foreign_handles() { + let mut transcripts = Vec::new(); + for use_remote in [false, true] { + let owner = Arc::new(TestOwner::default()); + let (transport, _endpoint): (Arc, _) = if use_remote { + let (t, e) = remote(owner.clone()).await; + (t, Some(e)) + } else { + (owner.clone(), None) + }; + let host = OwnerHost::begin(transport.clone(), ticket_context(), consumer()) + .await + .unwrap(); + let foreign = OwnerHost::begin(transport, ticket_context(), consumer()) + .await + .unwrap(); + let resource = host + .resolve_ticket(host.context(), "T-795", objects::Access::ExtensionWrite) + .await + .unwrap(); + let other = foreign + .resolve_ticket(foreign.context(), "T-795", objects::Access::ExtensionWrite) + .await + .unwrap(); + // Test owner deliberately reuses invocation IDs. Private Arc identity, + // not the string or canonical Ticket ID, still rejects foreign use. + let count = owner.requests.lock().unwrap().len(); + assert_eq!( + host.read_ticket(host.context(), other.as_ref()) + .await + .unwrap_err() + .code, + errors::Code::Denied + ); + assert_eq!( + host.ticket_data( + host.context(), + other.as_ref(), + TicketDataOperation::Get(cell_address()) + ) + .await + .unwrap_err() + .code, + errors::Code::Denied + ); + let mut widened = host.context().clone(); + widened + .grants + .ticket_data_read + .insert(("foreign".into(), "note".into(), "1".into())); + assert_eq!( + host.read_ticket(&widened, resource.as_ref()) + .await + .unwrap_err() + .code, + errors::Code::Denied + ); + assert_eq!(owner.requests.lock().unwrap().len(), count); + assert_eq!( + host.read_ticket(host.context(), resource.as_ref()) + .await + .unwrap() + .body, + "bounded" + ); + let read_only = host + .resolve_ticket(host.context(), "T-795", objects::Access::Read) + .await + .unwrap(); + let mutation = TicketDataOperation::Mutate { + address: cell_address(), + operation_key: "write".into(), + mutation: server_api::PluginDataMutation::Put { + expected: server_api::PluginDataExpectation::NeverWritten, + value_json: "{}".into(), + }, + }; + assert_eq!( + host.ticket_data(host.context(), read_only.as_ref(), mutation.clone()) + .await + .unwrap_err() + .code, + errors::Code::Denied + ); + assert!(matches!( + host.ticket_data(host.context(), resource.as_ref(), mutation) + .await + .unwrap(), + TicketDataResult::Mutated(_) + )); + assert!(matches!( + host.ticket_data( + host.context(), + resource.as_ref(), + TicketDataOperation::Get(cell_address()) + ) + .await + .unwrap(), + TicketDataResult::Cell(_) + )); + host.ticket_data( + host.context(), + resource.as_ref(), + TicketDataOperation::List { + collection: cell_address().collection, + cursor: None, + limit: 10, + }, + ) + .await + .unwrap(); + host.ticket_data( + host.context(), + resource.as_ref(), + TicketDataOperation::LookupMutation { + address: cell_address(), + operation_key: "write".into(), + }, + ) + .await + .unwrap(); + let mut wrong_schema = cell_address(); + wrong_schema.collection.schema_version = "2".into(); + assert_eq!( + host.ticket_data( + host.context(), + resource.as_ref(), + TicketDataOperation::Get(wrong_schema) + ) + .await + .unwrap_err() + .code, + errors::Code::Denied + ); + owner.revoked.store(true, Ordering::SeqCst); + assert_eq!( + host.read_ticket(host.context(), resource.as_ref()) + .await + .unwrap_err() + .code, + errors::Code::Denied + ); + assert_eq!( + host.ticket_data( + host.context(), + resource.as_ref(), + TicketDataOperation::Get(cell_address()) + ) + .await + .unwrap_err() + .code, + errors::Code::Denied + ); + host.context().cancellation.cancel(); + assert_eq!( + host.read_ticket(host.context(), resource.as_ref()) + .await + .unwrap_err() + .code, + errors::Code::Cancelled + ); + transcripts.push(owner.requests.lock().unwrap().clone()); + } + assert_eq!(transcripts[0], transcripts[1]); + } + struct PendingOwner { inner: TestOwner, begin: bool, diff --git a/crates/plugin-runtime/src/owner/ticket.rs b/crates/plugin-runtime/src/owner/ticket.rs new file mode 100644 index 000000000..8c2216255 --- /dev/null +++ b/crates/plugin-runtime/src/owner/ticket.rs @@ -0,0 +1,289 @@ +//! Invocation-local resource identity is independent of owner Ticket selectors. +use super::*; +use server_api::*; + +struct OwnerTicket { + invocation_id: String, + ticket_id: String, + writable: bool, + identity: Arc<()>, +} +fn denied() -> PluginHostError { + local_error( + PluginHostErrorCode::Denied, + PluginEffectObservation::NotDispatched, + ) +} +fn invalid() -> PluginHostError { + local_error( + PluginHostErrorCode::InvalidInput, + PluginEffectObservation::NotDispatched, + ) +} +impl OwnerHost { + fn ticket<'a>( + &self, + binding: &'a (dyn Any + Send + Sync), + ) -> Result<&'a OwnerTicket, PluginHostError> { + binding + .downcast_ref::() + .filter(|t| { + t.invocation_id == self.context.invocation_id + && Arc::ptr_eq(&t.identity, &self.binding_identity) + }) + .ok_or_else(denied) + } + pub(super) async fn resolve_ticket_binding( + &self, + context: &InvocationContext, + ticket_ref: &str, + access: objects::Access, + ) -> Result, PluginHostError> { + self.check_context(context)?; + if ticket_ref.is_empty() || ticket_ref.len() > 256 { + return Err(invalid()); + } + let writable = matches!(access, objects::Access::ExtensionWrite); + if if writable { + context.grants.ticket_data_write.is_empty() + } else { + !context.grants.ticket_read && context.grants.ticket_data_read.is_empty() + } { + return Err(denied()); + } + match call_owner( + self.owner.as_ref(), + context, + PluginHostRequest::ResolveTicket { + invocation_id: context.invocation_id.clone(), + ticket_ref: ticket_ref.into(), + access: if writable { + PluginTicketAccess::ExtensionWrite + } else { + PluginTicketAccess::Read + }, + }, + ) + .await? + { + PluginHostResponse::TicketResolved { ticket_id } + if !ticket_id.is_empty() && ticket_id.len() <= 256 => + { + Ok(Arc::new(OwnerTicket { + invocation_id: context.invocation_id.clone(), + ticket_id, + writable, + identity: self.binding_identity.clone(), + })) + } + _ => Err(protocol_error(false)), + } + } + pub(super) async fn read_ticket_binding( + &self, + context: &InvocationContext, + binding: &(dyn Any + Send + Sync), + ) -> Result { + self.check_context(context)?; + let ticket = self.ticket(binding)?; + if !context.grants.ticket_read { + return Err(denied()); + } + match call_owner( + self.owner.as_ref(), + context, + PluginHostRequest::ReadTicket { + invocation_id: context.invocation_id.clone(), + ticket_id: ticket.ticket_id.clone(), + }, + ) + .await? + { + PluginHostResponse::TicketRead { snapshot } + if snapshot.body.len() <= PLUGIN_TICKET_BODY_LIMIT => + { + Ok(ticket_read::Snapshot { + title: snapshot.title, + state: match snapshot.case_state { + PluginTicketCaseState::Open => ticket_read::CaseState::Open, + PluginTicketCaseState::Closed => ticket_read::CaseState::Closed, + }, + content_digest: snapshot.content_digest, + body: snapshot.body, + truncated: snapshot.truncated, + }) + } + _ => Err(protocol_error(false)), + } + } + pub(super) async fn ticket_data_binding( + &self, + context: &InvocationContext, + binding: &(dyn Any + Send + Sync), + operation: TicketDataOperation, + ) -> Result { + self.check_context(context)?; + let ticket = self.ticket(binding)?; + let (collection, write) = match &operation { + TicketDataOperation::Get(a) + | TicketDataOperation::LookupMutation { address: a, .. } => (&a.collection, false), + TicketDataOperation::Mutate { address: a, .. } => (&a.collection, true), + TicketDataOperation::List { collection, .. } => (collection, false), + }; + let identity = ( + collection.collection.clone(), + collection.schema_id.clone(), + collection.schema_version.clone(), + ); + let lookup = matches!(&operation, TicketDataOperation::LookupMutation { .. }); + let grants = if write || lookup { + &context.grants.ticket_data_write + } else { + &context.grants.ticket_data_read + }; + if !grants.contains(&identity) || ((write || lookup) && !ticket.writable) { + return Err(denied()); + } + validate_operation(&operation)?; + let invocation_id = context.invocation_id.clone(); + let ticket_id = ticket.ticket_id.clone(); + let mutation = matches!(&operation, TicketDataOperation::Mutate { .. }); + let request = match operation { + TicketDataOperation::Get(address) => PluginHostRequest::DataGet { + invocation_id, + ticket_id, + address, + }, + TicketDataOperation::Mutate { + address, + operation_key, + mutation, + } => PluginHostRequest::DataMutate { + invocation_id, + ticket_id, + address, + operation_key, + mutation, + }, + TicketDataOperation::List { + collection, + cursor, + limit, + } => PluginHostRequest::DataList { + invocation_id, + ticket_id, + collection, + cursor, + limit, + }, + TicketDataOperation::LookupMutation { + address, + operation_key, + } => PluginHostRequest::DataLookupMutation { + invocation_id, + ticket_id, + address, + operation_key, + }, + }; + match call_owner(self.owner.as_ref(), context, request.clone()).await? { + PluginHostResponse::DataCell { cell } + if matches!(request, PluginHostRequest::DataGet { .. }) => + { + Ok(TicketDataResult::Cell(cell)) + } + PluginHostResponse::DataMutated { receipt } if mutation => { + Ok(TicketDataResult::Mutated(receipt)) + } + PluginHostResponse::DataListed { page } + if matches!(request, PluginHostRequest::DataList { .. }) => + { + Ok(TicketDataResult::Listed(page)) + } + PluginHostResponse::DataMutationLookup { outcome } + if matches!(request, PluginHostRequest::DataLookupMutation { .. }) => + { + Ok(TicketDataResult::MutationLookup(outcome)) + } + _ => Err(protocol_error(mutation)), + } + } +} +fn text(s: &str, limit: usize) -> bool { + !s.is_empty() && s.len() <= limit && !s.chars().any(char::is_control) +} +pub(crate) fn validate_operation(operation: &TicketDataOperation) -> Result<(), PluginHostError> { + let collection = match operation { + TicketDataOperation::Get(a) + | TicketDataOperation::Mutate { address: a, .. } + | TicketDataOperation::LookupMutation { address: a, .. } => { + if !text(&a.key, 256) { + return Err(invalid()); + } + &a.collection + } + TicketDataOperation::List { + collection, + cursor, + limit, + } => { + if *limit == 0 + || *limit > PLUGIN_DATA_LIST_LIMIT + || cursor.as_ref().is_some_and(|c| !text(c, 4096)) + { + return Err(invalid()); + } + collection + } + }; + if [ + &collection.collection, + &collection.schema_id, + &collection.schema_version, + ] + .iter() + .any(|s| !text(s, 128)) + { + return Err(invalid()); + } + match operation { + TicketDataOperation::Mutate { + operation_key, + mutation, + .. + } => { + if !text(operation_key, 256) { + return Err(invalid()); + } + match mutation { + PluginDataMutation::Put { + expected, + value_json, + } => { + if value_json.len() > PLUGIN_DATA_VALUE_LIMIT + || serde_json::from_str::(value_json).is_err() + { + return Err(invalid()); + } + if let PluginDataExpectation::LastWrite(id) = expected { + if !text(id, 256) { + return Err(invalid()); + } + } + } + PluginDataMutation::Delete { + expected_last_write, + } => { + if !text(expected_last_write, 256) { + return Err(invalid()); + } + } + } + } + TicketDataOperation::LookupMutation { operation_key, .. } if !text(operation_key, 256) => { + return Err(invalid()); + } + _ => {} + } + Ok(()) +} diff --git a/crates/plugin-runtime/tests/executor.rs b/crates/plugin-runtime/tests/executor.rs index 33d2d59b3..cd1ef82fa 100644 --- a/crates/plugin-runtime/tests/executor.rs +++ b/crates/plugin-runtime/tests/executor.rs @@ -111,6 +111,7 @@ fn context(component: &CompiledTool, id: &str) -> InvocationContext { grants: Grants { logging: true, connection_slots: BTreeSet::from(["api".into()]), + ..Default::default() }, deadline: Instant::now() + Duration::from_secs(10), cancellation: CancellationToken::new(), diff --git a/crates/plugin-runtime/tests/ticket-guest/Cargo.lock b/crates/plugin-runtime/tests/ticket-guest/Cargo.lock new file mode 100644 index 000000000..268a1e50f --- /dev/null +++ b/crates/plugin-runtime/tests/ticket-guest/Cargo.lock @@ -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 = "ticket-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" diff --git a/crates/plugin-runtime/tests/ticket-guest/Cargo.toml b/crates/plugin-runtime/tests/ticket-guest/Cargo.toml new file mode 100644 index 000000000..ad22e36f5 --- /dev/null +++ b/crates/plugin-runtime/tests/ticket-guest/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ticket-test-guest" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +yoi-plugin-pdk = { path = "../../../plugin-pdk" } diff --git a/crates/plugin-runtime/tests/ticket-guest/plugin.toml b/crates/plugin-runtime/tests/ticket-guest/plugin.toml new file mode 100644 index 000000000..21a3488e3 --- /dev/null +++ b/crates/plugin-runtime/tests/ticket-guest/plugin.toml @@ -0,0 +1,38 @@ +schema_version = 2 +id = "ticket-test" +name = "Ticket test" +version = "1.0.0" +surfaces = ["tool"] +config_schema = "{}" + +[runtime] +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/ticket-tool-plugin@2.1.0" +required_imports = ["yoi:host/errors@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"] + +[[tools]] +name = "ticket_test" +description = "Exercise the typed Ticket extension boundary" +input_schema = { type = "string" } +output_schema = { type = "null" } +error_codes = ["failed"] + +[[data_collections]] +collection = "notes" +schema_id = "note" +schema_version = "1" +schema = { type = "string", maxLength = 64 } + +[[requested_grants]] +kind = "ticket_read" +[[requested_grants]] +kind = "ticket_data_read" +collection = "notes" +schema_id = "note" +schema_version = "1" +[[requested_grants]] +kind = "ticket_data_write" +collection = "notes" +schema_id = "note" +schema_version = "1" diff --git a/crates/plugin-runtime/tests/ticket-guest/src/lib.rs b/crates/plugin-runtime/tests/ticket-guest/src/lib.rs new file mode 100644 index 000000000..62152d037 --- /dev/null +++ b/crates/plugin-runtime/tests/ticket-guest/src/lib.rs @@ -0,0 +1,95 @@ +use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput, wit_bindgen}; +wit_bindgen::generate!({ + path: "../../../../resources/plugin/wit-ticket", + world: "ticket-tool-plugin", + generate_all, + runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", +}); +use yoi::host2_1_0::{extension_data as data, objects, ticket_read}; + +fn namespace() -> data::Collection { + data::Collection { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "1".into(), + } +} +fn address() -> data::Address { + data::Address { + namespace: namespace(), + key: "main".into(), + } +} +fn failure(error: yoi::host2_0_0::errors::HostError) -> ToolError { + ToolError::failed(format!("{:?}", error.code)) +} +fn handle(_: ToolContext, mode: String) -> yoi_plugin_pdk::Result { + let ticket = if mode == "foreign" { + unsafe { objects::Ticket::from_handle(0) } + } else { + objects::resolve_ticket("T-795", objects::Access::ExtensionWrite).map_err(failure)? + }; + let ticket = if mode == "stale" { + let handle = ticket.handle(); + drop(ticket); + unsafe { objects::Ticket::from_handle(handle) } + } else { + ticket + }; + let snapshot = ticket_read::read(&ticket).map_err(failure)?; + if mode == "other-schema" { + let mut a = address(); + a.namespace.schema_version = "2".into(); + data::get(&ticket, &a).map_err(failure)?; + } + if mode == "read" { + return ToolOutput::json(snapshot.title, ()); + } + let json = if mode == "bad-schema" { + "{\"undeclared\":true}".into() + } else { + yoi_plugin_pdk::extension_data_json(&"note")? + }; + let written = data::put( + &ticket, + &address(), + &data::Expectation::NeverWritten, + "put", + &json, + ) + .map_err(failure)?; + assert!(matches!( + data::get(&ticket, &address()).map_err(failure)?, + data::Cell::Present(_) + )); + let page = data::list(&ticket, &namespace(), None, 10).map_err(failure)?; + assert_eq!(page.entries.len(), 1); + let lookup = data::lookup_mutation(&ticket, &address(), "put").map_err(failure)?; + assert!(matches!(lookup, data::MutationLookup::Committed(_))); + let deleted = + data::delete(&ticket, &address(), &written.mutation_id, "delete").map_err(failure)?; + assert!(matches!( + data::get(&ticket, &address()).map_err(failure)?, + data::Cell::Deleted(_) + )); + data::put( + &ticket, + &address(), + &data::Expectation::LastWrite(deleted.mutation_id), + "recreate", + &json, + ) + .map_err(failure)?; + // Replay the original exact request even though the cell has since changed. + let replay = data::put( + &ticket, + &address(), + &data::Expectation::NeverWritten, + "put", + &json, + ) + .map_err(failure)?; + assert_eq!(replay.mutation_id, written.mutation_id); + ToolOutput::json(snapshot.title, ()) +} +yoi_plugin_pdk::export_ticket_component_tool!(TicketPlugin, handle); diff --git a/crates/plugin-runtime/tests/ticket_component.rs b/crates/plugin-runtime/tests/ticket_component.rs new file mode 100644 index 000000000..9f68dbd56 --- /dev/null +++ b/crates/plugin-runtime/tests/ticket_component.rs @@ -0,0 +1,345 @@ +//! Real Rust PDK -> Component -> Wasmtime -> OwnerHost adapter regression. +//! The deterministic owner here tests the adapter/ABI, not DB durability or +//! production authorization; real owner/storage races belong to workspace-server. +use async_trait::async_trait; +use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind}; +use server_api::*; +use std::{ + collections::BTreeMap, + fs, + path::Path, + process::Command, + sync::{Arc, Mutex, OnceLock}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::time::Instant; +use yoi_plugin_runtime::{ + host::{CancellationToken, Grants, InvocationContext}, + owner::{OwnerHost, PluginHostOwner}, + *, +}; + +fn component() -> &'static [u8] { + static COMPONENT: OnceLock> = OnceLock::new(); + COMPONENT.get_or_init(|| { + // Fixture builds write only this explicitly isolated output directory. + let target = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts/guest"); + let output = Command::new(env!("CARGO")) + .args([ + "build", + "--locked", + "--offline", + "--release", + "--target", + "wasm32-unknown-unknown", + "--manifest-path", + ]) + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/ticket-guest/Cargo.toml")) + .arg("--target-dir") + .arg(&target) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let core = + fs::read(target.join("wasm32-unknown-unknown/release/ticket_test_guest.wasm")).unwrap(); + let bytes = wit_component::ComponentEncoder::default() + .module(&core) + .unwrap() + .validate(true) + .encode() + .unwrap(); + fs::write( + target.parent().unwrap().join("ticket-tool.component.wasm"), + &bytes, + ) + .unwrap(); + bytes + }) +} +fn artifact(manifest: &str) -> plugin::CanonicalPluginArtifact { + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts"); + fs::create_dir_all(&target).unwrap(); + let dir = tempfile::tempdir_in(target).unwrap(); + fs::write(dir.path().join("plugin.toml"), manifest).unwrap(); + fs::write(dir.path().join("plugin.component.wasm"), component()).unwrap(); + let package = plugin::read_plugin_directory( + dir.path(), + PluginSourceKind::Explicit, + &PluginPackageLimits::default(), + ) + .unwrap(); + plugin::pack_plugin_package(&package, &PluginPackageLimits::default()).unwrap() +} +struct State { + cell: PluginDataCell, + receipts: BTreeMap, + calls: Vec, +} +struct Owner { + state: Mutex, + revoke_after_resolve: bool, +} +impl Owner { + fn new(revoke_after_resolve: bool) -> Self { + Self { + state: Mutex::new(State { + cell: PluginDataCell::NeverWritten, + receipts: BTreeMap::new(), + calls: vec![], + }), + revoke_after_resolve, + } + } +} +fn rejected(code: PluginHostErrorCode) -> PluginHostError { + PluginHostError { + code, + effect: PluginEffectObservation::NotDispatched, + diagnostic_id: "isolated-test".into(), + } +} +#[async_trait] +impl PluginHostOwner for Owner { + async fn call( + &self, + request: PluginHostRequest, + ) -> Result { + let mut s = self.state.lock().unwrap(); + s.calls.push(request.clone()); + Ok(match request.clone() { + PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation { + invocation_id: "logical-invocation".into(), + expires_at_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64 + + 60_000, + }, + PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized, + PluginHostRequest::ResolveTicket { ticket_ref, .. } => { + assert_eq!(ticket_ref, "T-795"); + PluginHostResponse::TicketResolved { + ticket_id: "canonical-ticket".into(), + } + } + PluginHostRequest::ReadTicket { ticket_id, .. } => { + assert_eq!(ticket_id, "canonical-ticket"); + if self.revoke_after_resolve { + return Err(rejected(PluginHostErrorCode::Denied)); + } + PluginHostResponse::TicketRead { + snapshot: PluginTicketSnapshot { + title: "unchanged title".into(), + case_state: PluginTicketCaseState::Open, + content_digest: "unchanged digest".into(), + body: "bounded body".into(), + truncated: false, + }, + } + } + PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell { + cell: s.cell.clone(), + }, + PluginHostRequest::DataList { .. } => PluginHostResponse::DataListed { + page: PluginDataPage { + entries: vec![PluginDataEntry { + key: "main".into(), + cell: s.cell.clone(), + }], + next_cursor: None, + }, + }, + PluginHostRequest::DataLookupMutation { operation_key, .. } => { + PluginHostResponse::DataMutationLookup { + outcome: s + .receipts + .get(&operation_key) + .map(|(_, r)| PluginDataMutationLookup::Committed(r.clone())) + .unwrap_or(PluginDataMutationLookup::NotRecorded), + } + } + PluginHostRequest::DataMutate { + operation_key, + mutation, + .. + } => { + if let Some((original, receipt)) = s.receipts.get(&operation_key) { + if *original != request { + return Err(rejected(PluginHostErrorCode::Conflict)); + } + return Ok(PluginHostResponse::DataMutated { + receipt: receipt.clone(), + }); + } + let observed = match &s.cell { + PluginDataCell::NeverWritten => PluginDataExpectation::NeverWritten, + PluginDataCell::Present { last_write, .. } => { + PluginDataExpectation::LastWrite(last_write.clone()) + } + PluginDataCell::Deleted { last_delete } => { + PluginDataExpectation::LastWrite(last_delete.clone()) + } + }; + let id = format!("mutation-{operation_key}"); + s.cell = match mutation { + PluginDataMutation::Put { + expected, + value_json, + } => { + if expected != observed { + return Err(rejected(PluginHostErrorCode::Conflict)); + } + PluginDataCell::Present { + value_json, + last_write: id.clone(), + } + } + PluginDataMutation::Delete { + expected_last_write, + } => { + if PluginDataExpectation::LastWrite(expected_last_write) != observed { + return Err(rejected(PluginHostErrorCode::Conflict)); + } + PluginDataCell::Deleted { + last_delete: id.clone(), + } + } + }; + let receipt = PluginDataReceipt { + mutation_id: id, + result_digest: "result-digest".into(), + }; + s.receipts.insert(operation_key, (request, receipt.clone())); + PluginHostResponse::DataMutated { receipt } + } + _ => return Err(rejected(PluginHostErrorCode::Unsupported)), + }) + } +} +fn context(compiled: &CompiledTool) -> InvocationContext { + let namespace: (String, String, String) = ("notes".into(), "note".into(), "1".into()); + InvocationContext { + workspace: "ws".into(), + installation: "installation".into(), + plan: "plan".into(), + subject: "subject".into(), + invocation_id: "not-authority".into(), + artifact: compiled.identity().clone(), + tool_name: "ticket_test".into(), + grants: Grants { + ticket_read: true, + ticket_data_read: [namespace.clone()].into(), + ticket_data_write: [namespace].into(), + ..Default::default() + }, + deadline: Instant::now() + Duration::from_secs(60), + cancellation: CancellationToken::new(), + } +} +async fn run( + ex: &Executor, + compiled: &CompiledTool, + owner: Arc, + mode: &str, + granted: bool, +) -> Result { + let mut ctx = context(compiled); + if !granted { + ctx.grants.ticket_data_write.clear(); + } + let host = Arc::new( + OwnerHost::begin( + owner, + ctx, + PluginConsumer::ToolCall { + tool_call_id: "call".into(), + tool_name: "ticket_test".into(), + }, + ) + .await + .unwrap(), + ); + ex.invoke( + compiled, + host.context().clone(), + host, + "ticket_test", + &format!("\"{mode}\""), + "{}", + ) + .await +} +#[tokio::test] +async fn actual_component_resolves_reads_mutates_lists_and_reconciles_receipts() { + let artifact = artifact(include_str!("ticket-guest/plugin.toml")); + let ex = Executor::new(Limits::default()).unwrap(); + let compiled = ex + .compile( + &artifact.bytes, + &artifact.package_digest, + &artifact.artifact_digest, + artifact.artifact_size, + ) + .unwrap(); + let owner = Arc::new(Owner::new(false)); + let result = run(&ex, &compiled, owner.clone(), "all", true) + .await + .unwrap(); + assert_eq!(result.output.summary, "unchanged title"); + let s = owner.state.lock().unwrap(); + assert_eq!(s.receipts.len(), 3); // put/delete/recreate, not a fourth replay + assert!( + matches!(&s.cell, PluginDataCell::Present { last_write, .. } if last_write == "mutation-recreate") + ); + assert_eq!( + s.calls + .iter() + .filter(|r| matches!(r, PluginHostRequest::DataMutate { .. })) + .count(), + 4 + ); +} +#[tokio::test] +async fn actual_component_denies_old_grants_foreign_resources_revocation_and_bad_schema() { + let artifact = artifact(include_str!("ticket-guest/plugin.toml")); + let ex = Executor::new(Limits::default()).unwrap(); + let compiled = ex + .compile( + &artifact.bytes, + &artifact.package_digest, + &artifact.artifact_digest, + artifact.artifact_size, + ) + .unwrap(); + for (mode, revoked, granted) in [ + ("read", true, true), + ("foreign", false, true), + ("stale", false, true), + ("other-schema", false, true), + ("read", false, false), + ("bad-schema", false, true), + ] { + let owner = Arc::new(Owner::new(revoked)); + assert!( + run(&ex, &compiled, owner.clone(), mode, granted) + .await + .is_err(), + "{mode}" + ); + assert!( + !owner + .state + .lock() + .unwrap() + .calls + .iter() + .any(|r| matches!(r, PluginHostRequest::DataMutate { .. })), + "rejection precedes write" + ); + } +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index c00319423..43c4fd1bf 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -14,7 +14,9 @@ pub use chat_transport::*; mod drive; mod notification; mod plugin; +pub mod plugin_data; pub mod plugin_host; +pub use plugin_data::*; mod plugin_types; pub use notification::*; pub use plugin::*; @@ -235,6 +237,9 @@ impl_openapi_schema!( PluginAddArtifactRequest, PluginRevokeRequest, PluginUninstallRequest, + PluginDataPurgeState, + PluginDataPurgeRequest, + PluginDataPurgeResponse, PluginListRequest, PluginUploadResponse, PluginInstallation, @@ -3072,6 +3077,23 @@ pub trait ServerApi { #[body] request: PluginUninstallRequest, ) -> Result; + #[get("/api/w/{workspace_id}/plugins/{installation_id}/data-state", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true)] + async fn plugin_data_observe( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] installation_id: String, + ) -> Result; + + #[post("/api/w/{workspace_id}/plugins/{installation_id}/data-purge", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn plugin_data_purge( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] installation_id: String, + #[body] request: PluginDataPurgeRequest, + ) -> Result; + #[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)] async fn plugin_operation( &self, diff --git a/crates/server-api/src/plugin.rs b/crates/server-api/src/plugin.rs index b2afa63c9..cc6705d8f 100644 --- a/crates/server-api/src/plugin.rs +++ b/crates/server-api/src/plugin.rs @@ -97,7 +97,31 @@ pub struct PluginConnectionMetadata { #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum PluginGrantMetadata { Logging, - Https { slot: String }, + Https { + slot: String, + }, + TicketRead, + TicketDataRead { + collection: String, + schema_id: String, + schema_version: String, + }, + TicketDataWrite { + collection: String, + schema_id: String, + schema_version: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataSchemaMetadata { + pub collection: String, + pub schema_id: String, + pub schema_version: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub schema: serde_json::Value, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -117,6 +141,8 @@ pub struct PluginArtifact { pub tools: Vec, pub connections: Vec, pub requested_grants: Vec, + #[serde(default)] + pub data_collections: Vec, pub config_schema: PluginSchemaContribution, pub revoked: bool, pub revocation_reason: Option, @@ -147,6 +173,25 @@ impl plugin_plan::ArtifactDescriptor for PluginArtifact { PluginGrantMetadata::Https { slot } => { plugin_plan::PluginGrant::Https { slot: slot.clone() } } + PluginGrantMetadata::TicketRead => plugin_plan::PluginGrant::TicketRead, + PluginGrantMetadata::TicketDataRead { + collection, + schema_id, + schema_version, + } => plugin_plan::PluginGrant::TicketDataRead { + collection: collection.clone(), + schema_id: schema_id.clone(), + schema_version: schema_version.clone(), + }, + PluginGrantMetadata::TicketDataWrite { + collection, + schema_id, + schema_version, + } => plugin_plan::PluginGrant::TicketDataWrite { + collection: collection.clone(), + schema_id: schema_id.clone(), + schema_version: schema_version.clone(), + }, }) .collect(), config_schema: plugin_plan::PluginSchemaIdentity { @@ -160,6 +205,33 @@ impl plugin_plan::ArtifactDescriptor for PluginArtifact { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataPurgeState { + pub installation_last_operation: String, + pub cells_digest: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub cell_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataPurgeRequest { + pub operation_key: String, + pub expected: PluginDataPurgeState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataPurgeResponse { + pub purge_id: String, + pub observed: PluginDataPurgeState, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] diff --git a/crates/server-api/src/plugin_data.rs b/crates/server-api/src/plugin_data.rs new file mode 100644 index 000000000..379455ac8 --- /dev/null +++ b/crates/server-api/src/plugin_data.rs @@ -0,0 +1,177 @@ +//! Ticket extension-data wire boundary. Workspace, installation and execution +//! identity come exclusively from the authenticated, owner-issued invocation. +//! Guest selectors are never authorization. Receipts share the HTTPS invocation +//! scope and expiry, and are checked after live authorization but before cell CAS. +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const PLUGIN_TICKET_BODY_LIMIT: usize = 64 * 1024; +pub const PLUGIN_DATA_VALUE_LIMIT: usize = 64 * 1024; +pub const PLUGIN_DATA_LIST_LIMIT: u32 = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum PluginTicketAccess { + Read, + ExtensionWrite, +} + +/// Canonical Ticket identity is returned only after resolve authorizes it. It is +/// still a selector: every subsequent operation must reauthorize with the owner. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginTicketSnapshot { + pub title: String, + pub case_state: PluginTicketCaseState, + pub content_digest: String, + pub body: String, + pub truncated: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum PluginTicketCaseState { + Open, + Closed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataCollection { + pub collection: String, + pub schema_id: String, + pub schema_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataAddress { + pub collection: PluginDataCollection, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde( + tag = "kind", + content = "data", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum PluginDataExpectation { + NeverWritten, + LastWrite(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde( + tag = "kind", + content = "data", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum PluginDataCell { + NeverWritten, + Present { + value_json: String, + last_write: String, + }, + Deleted { + last_delete: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginDataMutation { + Put { + expected: PluginDataExpectation, + value_json: String, + }, + Delete { + expected_last_write: String, + }, +} + +/// A saved mutation result, not an observation of the current cell. The digest +/// covers the canonical resulting cell (including its final operation identity). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataReceipt { + pub mutation_id: String, + pub result_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde( + tag = "kind", + content = "data", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum PluginDataMutationLookup { + NotRecorded, + Committed(PluginDataReceipt), + Expired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataEntry { + pub key: String, + pub cell: PluginDataCell, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PluginDataPage { + pub entries: Vec, + pub next_cursor: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn cell_states_and_receipt_absence_are_not_conflated() { + let never = serde_json::to_value(PluginDataCell::NeverWritten).unwrap(); + let deleted = serde_json::to_value(PluginDataCell::Deleted { + last_delete: "delete-operation".into(), + }) + .unwrap(); + assert_ne!(never, deleted); + assert_eq!( + serde_json::from_value::(never).unwrap(), + PluginDataCell::NeverWritten + ); + assert_ne!( + serde_json::to_value(PluginDataMutationLookup::NotRecorded).unwrap(), + serde_json::to_value(PluginDataMutationLookup::Expired).unwrap() + ); + } + #[test] + fn address_rejects_guest_workspace_installation_or_schema_aliases() { + let address = json!({"collection":{"collection":"notes","schema_id":"note","schema_version":"1"},"key":"main"}); + assert!(serde_json::from_value::(address.clone()).is_ok()); + for field in ["workspace_id", "installation_id", "ticket_id"] { + let mut forged = address.clone(); + forged[field] = json!("foreign"); + assert!(serde_json::from_value::(forged).is_err()); + } + let mut numeric_version = address; + numeric_version["collection"]["schema_version"] = json!(1); + assert!(serde_json::from_value::(numeric_version).is_err()); + } +} diff --git a/crates/server-api/src/plugin_host.rs b/crates/server-api/src/plugin_host.rs index 8739880d8..93f982b9d 100644 --- a/crates/server-api/src/plugin_host.rs +++ b/crates/server-api/src/plugin_host.rs @@ -39,6 +39,40 @@ pub enum PluginHostRequest { invocation_id: String, entry: PluginLogEntry, }, + ResolveTicket { + invocation_id: String, + ticket_ref: String, + access: crate::PluginTicketAccess, + }, + ReadTicket { + invocation_id: String, + ticket_id: String, + }, + DataGet { + invocation_id: String, + ticket_id: String, + address: crate::PluginDataAddress, + }, + DataMutate { + invocation_id: String, + ticket_id: String, + address: crate::PluginDataAddress, + operation_key: String, + mutation: crate::PluginDataMutation, + }, + DataList { + invocation_id: String, + ticket_id: String, + collection: crate::PluginDataCollection, + cursor: Option, + limit: u32, + }, + DataLookupMutation { + invocation_id: String, + ticket_id: String, + address: crate::PluginDataAddress, + operation_key: String, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -116,6 +150,24 @@ pub enum PluginHostResponse { response: PluginHttpResponse, }, Logged, + TicketResolved { + ticket_id: String, + }, + TicketRead { + snapshot: crate::PluginTicketSnapshot, + }, + DataCell { + cell: crate::PluginDataCell, + }, + DataMutated { + receipt: crate::PluginDataReceipt, + }, + DataListed { + page: crate::PluginDataPage, + }, + DataMutationLookup { + outcome: crate::PluginDataMutationLookup, + }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/server-api/src/plugin_types.rs b/crates/server-api/src/plugin_types.rs index 61a165f9f..03027155a 100644 --- a/crates/server-api/src/plugin_types.rs +++ b/crates/server-api/src/plugin_types.rs @@ -7,6 +7,18 @@ pub fn plugin_host_typescript() -> String { let declarations = [ PluginHttpMethod::decl(&config), PluginConsumer::decl(&config), + PluginTicketAccess::decl(&config), + PluginTicketCaseState::decl(&config), + PluginTicketSnapshot::decl(&config), + PluginDataCollection::decl(&config), + PluginDataAddress::decl(&config), + PluginDataExpectation::decl(&config), + PluginDataCell::decl(&config), + PluginDataMutation::decl(&config), + PluginDataReceipt::decl(&config), + PluginDataMutationLookup::decl(&config), + PluginDataEntry::decl(&config), + PluginDataPage::decl(&config), PluginHostRequest::decl(&config), PluginHostOperation::decl(&config), PluginHttpRequest::decl(&config), @@ -39,6 +51,10 @@ pub fn plugin_api_typescript() -> String { PluginHttpMethod::decl(&config), PluginConnectionMetadata::decl(&config), PluginGrantMetadata::decl(&config), + PluginDataSchemaMetadata::decl(&config), + PluginDataPurgeState::decl(&config), + PluginDataPurgeRequest::decl(&config), + PluginDataPurgeResponse::decl(&config), PluginArtifact::decl(&config), PluginUploadResponse::decl(&config), PluginInstallation::decl(&config), diff --git a/crates/worker-runtime/src/plugin.rs b/crates/worker-runtime/src/plugin.rs index 677c1768c..7ebe1941b 100644 --- a/crates/worker-runtime/src/plugin.rs +++ b/crates/worker-runtime/src/plugin.rs @@ -67,6 +67,29 @@ pub async fn materialize( plugin_plan::PluginGrant::Https { slot } => { grants.connection_slots.insert(slot.clone()); } + plugin_plan::PluginGrant::TicketRead => grants.ticket_read = true, + plugin_plan::PluginGrant::TicketDataRead { + collection, + schema_id, + schema_version, + } => { + grants.ticket_data_read.insert(( + collection.clone(), + schema_id.clone(), + schema_version.clone(), + )); + } + plugin_plan::PluginGrant::TicketDataWrite { + collection, + schema_id, + schema_version, + } => { + grants.ticket_data_write.insert(( + collection.clone(), + schema_id.clone(), + schema_version.clone(), + )); + } } } pins.push(worker::plugin::PluginToolPin { diff --git a/crates/worker/src/plugin/tests.rs b/crates/worker/src/plugin/tests.rs index b97fea88e..e6ddb1a43 100644 --- a/crates/worker/src/plugin/tests.rs +++ b/crates/worker/src/plugin/tests.rs @@ -102,6 +102,7 @@ impl Fixture { tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(), connections: vec![], requested_grants: vec![], + data_collections: vec![], config_schema: PluginSchemaContribution { identity: format!("{}/config", m.id), version: m.version.clone(), diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index b48a591e2..8405889cc 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -517,8 +517,15 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip "Subject Ticket responsibility and current-body notification routing" .into() ), - (109, "Durable Plugin Host invocations and HTTPS effect receipts".into()), - (110, "Worker Plugin launch selection associations".into()) + ( + 109, + "Durable Plugin Host invocations and HTTPS effect receipts".into() + ), + (110, "Worker Plugin launch selection associations".into()), + ( + 111, + "Typed Plugin Ticket extension data and atomic mutation receipts".into() + ) ] ); for (table, rows) in retained.iter().zip(original.iter()) { diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 9fe3f4f43..22c9982d1 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -14,6 +14,7 @@ pub mod memory_staging; pub mod observation; pub mod plugin_binding; pub mod plugin_catalog; +pub mod plugin_data; pub mod plugin_delivery; pub mod plugin_host; pub mod plugin_https; diff --git a/crates/workspace-server/src/plugin_catalog.rs b/crates/workspace-server/src/plugin_catalog.rs index 13646c015..596ad5235 100644 --- a/crates/workspace-server/src/plugin_catalog.rs +++ b/crates/workspace-server/src/plugin_catalog.rs @@ -88,8 +88,19 @@ pub(crate) fn migrate(conn: &Connection) -> Result<()> { Ok(()) } pub(crate) fn verify(conn: &Connection) -> Result<()> { + verify_catalog_schema(conn, false) +} +/// Latest-schema verification after the separately registered typed-data +/// migration. Historical migration preconditions continue to use `verify`. +pub(crate) fn verify_with_data(conn: &Connection) -> Result<()> { + verify_catalog_schema(conn, true) +} +fn verify_catalog_schema(conn: &Connection, with_data: bool) -> Result<()> { let expected = Connection::open_in_memory()?; expected.execute_batch(include_str!("plugin_catalog_schema.sql"))?; + if with_data { + expected.execute_batch(crate::plugin_data::REFERENCE_MIGRATION_SQL)?; + } let objects = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name")? .query_map([], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))? .collect::>>()?; @@ -249,6 +260,7 @@ fn artifact_metadata( .collect(), connections: decode(&encode(&m.connections)?)?, requested_grants: decode(&encode(&m.requested_grants)?)?, + data_collections: decode(&encode(&m.data_collections)?)?, config_schema: PluginSchemaContribution { identity: schema.identity, version: schema.version, @@ -369,12 +381,79 @@ fn save_operation( tx.execute("INSERT INTO plugin_operations(workspace_id,operation_id,actor,kind,operation_key,fingerprint,result_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,operation,actor,kind_name(kind),key,fingerprint,encode(&result)?])?; Ok(result) } +// Collection/schema-id/version is a durable namespace within one installation. +// Revocation does not erase that contract: a later artifact must use a new schema +// version instead of reinterpreting retained cells under a changed schema. +fn canonical_schema(value: &serde_json::Value) -> Result { + fn sorted(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(map) => { + let ordered: std::collections::BTreeMap<_, _> = map + .iter() + .map(|(key, value)| (key.clone(), sorted(value))) + .collect(); + serde_json::Value::Object(ordered.into_iter().collect()) + } + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.iter().map(sorted).collect()) + } + value => value.clone(), + } + } + encode(&sorted(value)) +} +fn compare_data_schemas( + tx: &Transaction<'_>, + workspace: &str, + installation: &str, + artifact: &PluginArtifact, +) -> Result<()> { + let mut incoming = std::collections::BTreeMap::new(); + for schema in &artifact.data_collections { + let address = ( + schema.collection.clone(), + schema.schema_id.clone(), + schema.schema_version.clone(), + ); + let canonical = canonical_schema(&schema.schema)?; + if let Some(previous) = incoming.insert(address, canonical.clone()) { + if previous != canonical { + return fail(PluginCatalogError::Conflict); + } + } + } + if incoming.is_empty() { + return Ok(()); + } + // No revoked filter: the schema identity outlives execution authority. + let mut statement = tx.prepare( + "SELECT metadata_json FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2", + )?; + let mut rows = statement.query(params![workspace, installation])?; + while let Some(row) = rows.next()? { + let registered: PluginArtifact = decode(&row.get::<_, String>(0)?)?; + for schema in registered.data_collections { + let address = ( + schema.collection.clone(), + schema.schema_id.clone(), + schema.schema_version.clone(), + ); + if let Some(candidate) = incoming.get(&address) { + if *candidate != canonical_schema(&schema.schema)? { + return fail(PluginCatalogError::Conflict); + } + } + } + } + Ok(()) +} fn add_artifact( tx: &Transaction<'_>, workspace: &str, installation: &str, artifact: &PluginArtifact, ) -> Result<()> { + compare_data_schemas(tx, workspace, installation, artifact)?; let existing=tx.query_row("SELECT artifact_digest,package_digest,revoked FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND version=?3",params![workspace,installation,artifact.version],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,bool>(2)?))).optional()?; if let Some((old, package, revoked)) = existing { if old != artifact.artifact_digest || package != artifact.package_digest || revoked { @@ -660,6 +739,8 @@ pub enum PluginReferenceKind { Pending, Active, Restorable, + /// Retained typed extension data, including delete tombstones. + Data, } impl PluginReferenceKind { fn name(self) -> &'static str { @@ -668,6 +749,7 @@ impl PluginReferenceKind { Self::Pending => "pending", Self::Active => "active", Self::Restorable => "restorable", + Self::Data => "data", } } } @@ -729,5 +811,9 @@ pub fn plugin_reference_remove( Ok(()) } +#[cfg(test)] +#[path = "plugin_data/catalog_tests.rs"] +mod data_schema_tests; + #[cfg(test)] mod tests; diff --git a/crates/workspace-server/src/plugin_catalog/migration_tests.rs b/crates/workspace-server/src/plugin_catalog/migration_tests.rs index 58b06f735..0c17e6604 100644 --- a/crates/workspace-server/src/plugin_catalog/migration_tests.rs +++ b/crates/workspace-server/src/plugin_catalog/migration_tests.rs @@ -30,7 +30,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() // Plugin catalog remains migration 106 even when startup applies later steps. assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::>(), vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]); - assert_eq!(after.last().unwrap(), &(110, "Worker Plugin launch selection associations".into())); + assert_eq!(after.last().unwrap(), &(111, "Typed Plugin Ticket extension data and atomic mutation receipts".into())); assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION); assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins"); assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0); @@ -77,8 +77,12 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang "Durable Plugin Host invocations and HTTPS effect receipts".into(), )); expected.push((110, "Worker Plugin launch selection associations".into())); + expected.push(( + 111, + "Typed Plugin Ticket extension data and atomic mutation receipts".into(), + )); assert_eq!(workspace_schema_migration_history(c)?, expected); - crate::plugin_catalog::verify(c)?; + crate::plugin_catalog::verify_with_data(c)?; Ok(()) }) .unwrap(); diff --git a/crates/workspace-server/src/plugin_data.rs b/crates/workspace-server/src/plugin_data.rs new file mode 100644 index 000000000..b8415a316 --- /dev/null +++ b/crates/workspace-server/src/plugin_data.rs @@ -0,0 +1,600 @@ +//! Transactional Ticket extension cells, not an authorization or schema authority. +//! +//! The owner must reauthorize the exact object/collection on every call (including +//! receipt replay), validate values against the pinned closed schema, and supply +//! canonical Ticket and catalog identities. Use the Ticket owner's SQLite DB and +//! an IMMEDIATE transaction; commit before reporting success. No guest identity, +//! SQL, migration or cross-object transaction is exposed by this module. +//! +//! Cells and delete tombstones survive Ticket deletion and revocation. A parent +//! existence check under write ownership plus SQL triggers fences deletion; the +//! Workspace `deleting` state is also fenced. Explicit admin purge first requires +//! all artifacts revoked, then compares the ordered exact cell/last-write set. +use crate::{ + Error, Result, + plugin_catalog::{self, PluginReference, PluginReferenceKind}, + plugin_receipt::{self, InvocationRecord}, +}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use serde::{Deserialize, Serialize}; +use server_api::{ + PluginDataAddress, PluginDataCell, PluginDataCollection, PluginDataEntry, + PluginDataExpectation, PluginDataMutation, PluginDataMutationLookup, PluginDataPage, + PluginDataReceipt, PluginEffectObservation, PluginHostError, PluginHostErrorCode, +}; +use sha2::{Digest, Sha256}; + +pub const SCHEMA_SQL: &str = include_str!("plugin_data/schema.sql"); +/// Apply exactly once in a new ordered migration, before `SCHEMA_SQL`. +pub const REFERENCE_MIGRATION_SQL: &str = include_str!("plugin_data/reference_migration.sql"); +const CURSOR_LIFETIME_MS: i64 = 10 * 60 * 1000; +const MAX_CURSORS_PER_WORKSPACE: i64 = 4096; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Namespace { + pub workspace_id: String, + pub installation_id: String, + pub ticket_id: String, + pub collection: PluginDataCollection, +} +fn fail(code: PluginHostErrorCode) -> Result { + // Key/identity conflicts and expired/cancelled invocations can have an older + // committed mutation. Match the shared receipt owner's conservative signal. + let effect = match code { + PluginHostErrorCode::Conflict + | PluginHostErrorCode::ReceiptExpired + | PluginHostErrorCode::Cancelled => PluginEffectObservation::Indeterminate, + _ => PluginEffectObservation::NotDispatched, + }; + Err(PluginHostError { + code, + effect, + diagnostic_id: uuid::Uuid::now_v7().to_string(), + } + .into()) +} +fn valid(value: &str, max: usize) -> bool { + !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control) +} +fn encode(value: &impl Serialize) -> Result { + serde_json::to_string(value).map_err(|_| Error::Store("invalid Plugin data encoding".into())) +} +fn decode(value: &str) -> Result { + serde_json::from_str(value).map_err(|_| Error::Store("invalid persisted Plugin data".into())) +} +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} +fn hash(bytes: &[u8]) -> String { + format!("sha256:{}", hex(&Sha256::digest(bytes))) +} +// Recursion is bounded by serde_json's parser; objects are explicitly sorted even +// if another dependency enables serde_json/preserve_order through feature union. +fn canonical(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(map) => { + let sorted: std::collections::BTreeMap<_, _> = map + .into_iter() + .map(|(key, value)| (key, canonical(value))) + .collect(); + serde_json::Value::Object(sorted.into_iter().collect()) + } + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.into_iter().map(canonical).collect()) + } + other => other, + } +} +fn canonical_json(value: &str) -> Result { + if value.len() > server_api::PLUGIN_DATA_VALUE_LIMIT { + return fail(PluginHostErrorCode::QuotaExceeded); + } + let value = serde_json::from_str(value).map_err(|_| { + Error::from(PluginHostError { + code: PluginHostErrorCode::InvalidInput, + effect: PluginEffectObservation::NotDispatched, + diagnostic_id: uuid::Uuid::now_v7().to_string(), + }) + })?; + let encoded = encode(&canonical(value))?; + if encoded.len() > server_api::PLUGIN_DATA_VALUE_LIMIT { + return fail(PluginHostErrorCode::QuotaExceeded); + } + Ok(encoded) +} +fn digest(value: &impl Serialize) -> Result { + let value = + serde_json::to_value(value).map_err(|_| Error::Store("invalid Plugin data".into()))?; + Ok(hash(encode(&canonical(value))?.as_bytes())) +} +fn validate_namespace(ns: &Namespace) -> Result<()> { + if !valid(&ns.workspace_id, 512) + || !valid(&ns.installation_id, 512) + || !valid(&ns.ticket_id, 512) + || !valid(&ns.collection.collection, 128) + || !valid(&ns.collection.schema_id, 128) + || !valid(&ns.collection.schema_version, 128) + { + return fail(PluginHostErrorCode::InvalidInput); + } + Ok(()) +} +fn parent(conn: &Connection, ns: &Namespace) -> Result<()> { + validate_namespace(ns)?; + if !conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id) JOIN plugin_installations i ON i.workspace_id=t.workspace_id WHERE t.workspace_id=?1 AND t.ticket_id=?2 AND w.state='active' AND i.installation_id=?3 AND i.uninstalled=0)",params![ns.workspace_id,ns.ticket_id,ns.installation_id], |row| row.get::<_,bool>(0))? { + return fail(PluginHostErrorCode::NotFound); + } + Ok(()) +} +fn invocation( + conn: &Connection, + record: &InvocationRecord, + ns: &Namespace, + now_ms: i64, +) -> Result { + parent(conn, ns)?; + let record = plugin_receipt::verify_record(conn, record)?; + if record.workspace_id != ns.workspace_id || record.installation_id != ns.installation_id { + return fail(PluginHostErrorCode::Conflict); + } + if now_ms < 0 { + return fail(PluginHostErrorCode::InvalidInput); + } + Ok(record) +} +fn expired(record: &InvocationRecord, now_ms: i64) -> bool { + record.expired || now_ms >= record.expires_at_ms +} +fn active(record: &InvocationRecord, now_ms: i64) -> Result<()> { + if expired(record, now_ms) { + return fail(PluginHostErrorCode::ReceiptExpired); + } + if record.cancelled { + return fail(PluginHostErrorCode::Cancelled); + } + Ok(()) +} +fn validate_key(key: &str) -> Result<()> { + if !valid(key, 256) { + return fail(PluginHostErrorCode::InvalidInput); + } + Ok(()) +} +fn cell(conn: &Connection, ns: &Namespace, key: &str) -> Result { + let saved = conn.query_row("SELECT last_write,value_json FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2 AND ticket_id=?3 AND collection=?4 AND schema_id=?5 AND schema_version=?6 AND cell_key=?7", + params![ns.workspace_id,ns.installation_id,ns.ticket_id,ns.collection.collection,ns.collection.schema_id,ns.collection.schema_version,key], + |row| Ok((row.get::<_,String>(0)?,row.get::<_,Option>(1)?))).optional()?; + Ok(match saved { + None => PluginDataCell::NeverWritten, + Some((last_write, Some(value_json))) => PluginDataCell::Present { + value_json, + last_write, + }, + Some((last_delete, None)) => PluginDataCell::Deleted { last_delete }, + }) +} +/// Current read, never a receipt replay. Parent must check the exact read grant. +pub fn get(conn: &Connection, ns: &Namespace, key: &str) -> Result { + parent(conn, ns)?; + validate_key(key)?; + cell(conn, ns, key) +} + +// Persist only a bounded selector, never authorization or a snapshot of cells. +// The 256-bit CSPRNG token cannot be forged by editing namespace/offset/deadline. +fn issue_cursor( + conn: &Connection, + ns: &Namespace, + scope: &str, + after: &str, + deadline: i64, +) -> Result { + use ring::rand::{SecureRandom, SystemRandom}; + if let Some(token) = conn.query_row("SELECT cursor_token FROM plugin_data_cursors WHERE workspace_id=?1 AND namespace_digest=?2 AND after_key=?3 AND expires_at_ms=?4",params![ns.workspace_id,scope,after,deadline],|row|row.get(0)).optional()? { + return Ok(token); + } + let mut bytes = [0u8; 32]; + SystemRandom::new() + .fill(&mut bytes) + .map_err(|_| Error::Store("Plugin data cursor entropy unavailable".into()))?; + let token = format!("pdc-{}", hex(&bytes)); + // The bounded INSERT predicate is evaluated under SQLite write ownership, + // even when tests call list on a Connection rather than the owner's tx. + let changed = conn.execute("INSERT INTO plugin_data_cursors(workspace_id,installation_id,cursor_token,namespace_digest,after_key,expires_at_ms) SELECT ?1,?2,?3,?4,?5,?6 WHERE (SELECT count(*) FROM plugin_data_cursors WHERE workspace_id=?1)Ok(token),None=>fail(PluginHostErrorCode::QuotaExceeded), + } +} +/// Stable bytewise key order including tombstones, not a cross-page snapshot. +/// Opaque persisted cursors bind namespace/offset/expiry, not access authority. +/// Current authorization is required; the owner commits cursor issuance in its +/// IMMEDIATE transaction before returning a page. Expired selectors are pruned. +pub fn list( + conn: &Connection, + ns: &Namespace, + cursor: Option<&str>, + limit: u32, + now_ms: i64, +) -> Result { + parent(conn, ns)?; + if !(1..=server_api::PLUGIN_DATA_LIST_LIMIT).contains(&limit) || now_ms < 0 { + return fail(PluginHostErrorCode::InvalidInput); + } + let scope = digest(ns)?; + conn.execute( + "DELETE FROM plugin_data_cursors WHERE workspace_id=?1 AND expires_at_ms<=?2", + params![ns.workspace_id, now_ms], + )?; + let (after, deadline) = match cursor { + Some(raw) => { + if raw.len() != 68 + || !raw.starts_with("pdc-") + || !raw[4..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return fail(PluginHostErrorCode::InvalidInput); + } + let saved = conn.query_row("SELECT after_key,expires_at_ms FROM plugin_data_cursors WHERE workspace_id=?1 AND installation_id=?2 AND namespace_digest=?3 AND cursor_token=?4 AND expires_at_ms>?5",params![ns.workspace_id,ns.installation_id,scope,raw,now_ms],|row|Ok((row.get::<_,String>(0)?,row.get::<_,i64>(1)?))).optional()?; + match saved { + Some(value) => value, + None => return fail(PluginHostErrorCode::InvalidInput), + } + } + None => ( + String::new(), + now_ms + .checked_add(CURSOR_LIFETIME_MS) + .ok_or_else(|| Error::InvalidInput("Plugin cursor time".into()))?, + ), + }; + let mut stmt = conn.prepare("SELECT cell_key,last_write,value_json FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2 AND ticket_id=?3 AND collection=?4 AND schema_id=?5 AND schema_version=?6 AND cell_key>?7 COLLATE BINARY ORDER BY cell_key COLLATE BINARY LIMIT ?8")?; + let mut entries = stmt + .query_map( + params![ + ns.workspace_id, + ns.installation_id, + ns.ticket_id, + ns.collection.collection, + ns.collection.schema_id, + ns.collection.schema_version, + after, + i64::from(limit) + 1 + ], + |row| { + let key = row.get(0)?; + let last = row.get::<_, String>(1)?; + let value = row.get::<_, Option>(2)?; + Ok(PluginDataEntry { + key, + cell: match value { + Some(value_json) => PluginDataCell::Present { + value_json, + last_write: last, + }, + None => PluginDataCell::Deleted { last_delete: last }, + }, + }) + }, + )? + .collect::>>()?; + let next_cursor = if entries.len() > limit as usize { + entries.pop(); + Some(issue_cursor( + conn, + ns, + &scope, + &entries.last().expect("nonzero limit").key, + deadline, + )?) + } else { + None + }; + Ok(PluginDataPage { + entries, + next_cursor, + }) +} + +struct Saved { + namespace: String, + fingerprint: String, + receipt: PluginDataReceipt, +} +fn saved( + conn: &Connection, + record: &InvocationRecord, + operation_key: &str, +) -> Result> { + Ok(conn.query_row("SELECT namespace_json,fingerprint,mutation_id,result_digest FROM plugin_data_receipts WHERE workspace_id=?1 AND invocation_id=?2 AND operation_key=?3", + params![record.workspace_id,record.invocation_id,operation_key], |row| Ok(Saved{namespace:row.get(0)?,fingerprint:row.get(1)?,receipt:PluginDataReceipt{mutation_id:row.get(2)?,result_digest:row.get(3)?}})).optional()?) +} +fn http_key(conn: &Connection, record: &InvocationRecord, key: &str) -> Result { + Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM plugin_effect_receipts WHERE workspace_id=?1 AND invocation_id=?2 AND effect_key=?3)",params![record.workspace_id,record.invocation_id,key],|r|r.get(0))?) +} +fn address_json(ns: &Namespace, key: &str) -> Result { + encode(&(ns, key)) +} +/// Lookup is scoped to one exact address; transport/DB errors are not NotRecorded. +/// Expiry takes precedence over unused keys, as for the existing HTTPS receipts. +pub fn lookup_mutation( + conn: &Connection, + record: &InvocationRecord, + ns: &Namespace, + key: &str, + operation_key: &str, + now_ms: i64, +) -> Result { + validate_key(key)?; + validate_key(operation_key)?; + let record = invocation(conn, record, ns, now_ms)?; + if expired(&record, now_ms) { + return Ok(PluginDataMutationLookup::Expired); + } + if record.cancelled { + return fail(PluginHostErrorCode::Cancelled); + } + if let Some(saved) = saved(conn, &record, operation_key)? { + if saved.namespace != address_json(ns, key)? { + return fail(PluginHostErrorCode::Conflict); + } + return Ok(PluginDataMutationLookup::Committed(saved.receipt)); + } + if http_key(conn, &record, operation_key)? { + return fail(PluginHostErrorCode::Conflict); + } + Ok(PluginDataMutationLookup::NotRecorded) +} +fn atomic(tx: &Transaction<'_>, work: impl FnOnce() -> Result) -> Result { + tx.execute_batch("SAVEPOINT plugin_data_atomic")?; + match work() { + Ok(value) => { + tx.execute_batch("RELEASE plugin_data_atomic")?; + Ok(value) + } + Err(error) => { + tx.execute_batch("ROLLBACK TO plugin_data_atomic; RELEASE plugin_data_atomic")?; + Err(error) + } + } +} +/// Put/delete and receipt are atomic even if the caller catches an error and +/// commits unrelated work. `reference` is Host-admitted, never guest input. +/// Replay is checked before catalog/CAS and returns the original receipt only. +pub fn mutate( + tx: &Transaction<'_>, + record: &InvocationRecord, + ns: &Namespace, + key: &str, + operation_key: &str, + mutation: &PluginDataMutation, + reference: &PluginReference, + now_ms: i64, +) -> Result { + validate_key(key)?; + validate_key(operation_key)?; + let record = invocation(tx, record, ns, now_ms)?; + active(&record, now_ms)?; + let mutation = match mutation { + PluginDataMutation::Put { + expected, + value_json, + } => PluginDataMutation::Put { + expected: expected.clone(), + value_json: canonical_json(value_json)?, + }, + PluginDataMutation::Delete { + expected_last_write, + } => PluginDataMutation::Delete { + expected_last_write: expected_last_write.clone(), + }, + }; + let address = address_json(ns, key)?; + let fingerprint = digest(&("ticket-data-mutation", ns, key, &mutation))?; + if let Some(saved) = saved(tx, &record, operation_key)? { + if saved.namespace != address || saved.fingerprint != fingerprint { + return fail(PluginHostErrorCode::Conflict); + } + return Ok(saved.receipt); + } + if http_key(tx, &record, operation_key)? { + return fail(PluginHostErrorCode::Conflict); + } + let expected = match &mutation { + PluginDataMutation::Put { expected, .. } => expected.clone(), + PluginDataMutation::Delete { + expected_last_write, + } => PluginDataExpectation::LastWrite(expected_last_write.clone()), + }; + if let PluginDataExpectation::LastWrite(last) = &expected { + if !valid(last, 512) { + return fail(PluginHostErrorCode::InvalidInput); + } + } + atomic(tx, || { + let observed = cell(tx, ns, key)?; + let matches = match (&expected, &observed) { + (PluginDataExpectation::NeverWritten, PluginDataCell::NeverWritten) => true, + ( + PluginDataExpectation::LastWrite(want), + PluginDataCell::Present { last_write, .. }, + ) => want == last_write, + (PluginDataExpectation::LastWrite(want), PluginDataCell::Deleted { last_delete }) => { + want == last_delete + } + _ => false, + }; + if !matches { + return fail(PluginHostErrorCode::Conflict); + } + if reference.installation_id != ns.installation_id { + return fail(PluginHostErrorCode::Conflict); + } + let owner = digest(ns)?; + let reference = PluginReference { + kind: PluginReferenceKind::Data, + owner: owner.clone(), + ..reference.clone() + }; + plugin_catalog::plugin_reference_put(tx, &ns.workspace_id, &reference)?; + tx.execute("INSERT INTO plugin_data_holders(workspace_id,installation_id,owner,artifact_digest) VALUES(?1,?2,?3,?4) ON CONFLICT DO NOTHING",params![ns.workspace_id,ns.installation_id,owner,reference.artifact_digest])?; + let mutation_id = uuid::Uuid::now_v7().to_string(); + let value = match &mutation { + PluginDataMutation::Put { value_json, .. } => Some(value_json.as_str()), + PluginDataMutation::Delete { .. } => None, + }; + tx.execute("INSERT INTO plugin_data_cells(workspace_id,installation_id,ticket_id,collection,schema_id,schema_version,cell_key,last_write,value_json) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9) ON CONFLICT(workspace_id,installation_id,ticket_id,collection,schema_id,schema_version,cell_key) DO UPDATE SET last_write=excluded.last_write,value_json=excluded.value_json", + params![ns.workspace_id,ns.installation_id,ns.ticket_id,ns.collection.collection,ns.collection.schema_id,ns.collection.schema_version,key,mutation_id,value])?; + let result = match value { + Some(value_json) => PluginDataCell::Present { + value_json: value_json.into(), + last_write: mutation_id.clone(), + }, + None => PluginDataCell::Deleted { + last_delete: mutation_id.clone(), + }, + }; + let receipt = PluginDataReceipt { + mutation_id, + result_digest: digest(&result)?, + }; + tx.execute("INSERT INTO plugin_data_receipts(workspace_id,invocation_id,operation_key,namespace_json,fingerprint,mutation_id,result_digest,result_json) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + params![ns.workspace_id,record.invocation_id,operation_key,address,fingerprint,receipt.mutation_id,receipt.result_digest,encode(&result)?])?; + Ok(receipt) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PurgeSnapshot { + pub installation_last_operation: String, + pub cells_digest: String, + pub cell_count: u64, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PurgeReceipt { + pub purge_id: String, + pub observed: PurgeSnapshot, +} +fn purge_fenced(conn: &Connection, workspace: &str, installation: &str) -> Result { + if !valid(workspace, 512) || !valid(installation, 512) { + return fail(PluginHostErrorCode::InvalidInput); + } + let operation = conn.query_row("SELECT i.last_operation FROM plugin_installations i JOIN workspaces w USING(workspace_id) WHERE i.workspace_id=?1 AND i.installation_id=?2 AND i.uninstalled=0 AND w.state='active' AND NOT EXISTS(SELECT 1 FROM plugin_artifacts a WHERE a.workspace_id=i.workspace_id AND a.installation_id=i.installation_id AND a.revoked=0)",params![workspace,installation],|row|row.get(0)).optional()?; + match operation { + Some(value) => Ok(value), + None => fail(PluginHostErrorCode::Conflict), + } +} +/// Observe only after execution is fenced by catalog revocation. Hash a streamed, +/// ordered set of exact namespace/key/last-write/deleted tuples, not a generation. +/// Caller keeps the same IMMEDIATE transaction through this observation. +pub fn purge_snapshot( + tx: &Transaction<'_>, + workspace: &str, + installation: &str, +) -> Result { + let installation_last_operation = purge_fenced(tx, workspace, installation)?; + let mut stmt = tx.prepare("SELECT ticket_id,collection,schema_id,schema_version,cell_key,last_write,value_json IS NULL FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2 ORDER BY ticket_id COLLATE BINARY,collection COLLATE BINARY,schema_id COLLATE BINARY,schema_version COLLATE BINARY,cell_key COLLATE BINARY")?; + let mut rows = stmt.query(params![workspace, installation])?; + let mut hash = Sha256::new(); + hash.update(b"ticket-data-purge-exact-set-v1\0"); + hash.update(encode(&(workspace, installation))?.as_bytes()); + let mut count = 0u64; + while let Some(row) = rows.next()? { + let tuple = ( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, bool>(6)?, + ); + let bytes = encode(&tuple)?; + hash.update((bytes.len() as u64).to_be_bytes()); + hash.update(bytes.as_bytes()); + count += 1; + } + Ok(PurgeSnapshot { + installation_last_operation, + cells_digest: format!("sha256:{}", hex(&hash.finalize())), + cell_count: count, + }) +} +/// Current Workspace-admin auth is external and required even for replay. Purge +/// receipts survive data removal and uninstall. No implicit uninstall or revoke. +pub fn purge( + tx: &Transaction<'_>, + workspace: &str, + installation: &str, + actor: &str, + operation_key: &str, + expected: &PurgeSnapshot, +) -> Result { + if !valid(workspace, 512) || !valid(installation, 512) || !valid(actor, 512) { + return fail(PluginHostErrorCode::InvalidInput); + } + validate_key(operation_key)?; + let fingerprint = digest(&("ticket-data-purge", installation, expected))?; + if let Some((saved_fingerprint,result)) = tx.query_row("SELECT fingerprint,result_json FROM plugin_data_purges WHERE workspace_id=?1 AND actor=?2 AND operation_key=?3",params![workspace,actor,operation_key],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?))).optional()? { + if saved_fingerprint!=fingerprint { return fail(PluginHostErrorCode::Conflict); } + return decode(&result); + } + atomic(tx, || { + let observed = purge_snapshot(tx, workspace, installation)?; + if observed != *expected { + return fail(PluginHostErrorCode::Conflict); + } + let holders = tx.prepare("SELECT owner,artifact_digest FROM plugin_data_holders WHERE workspace_id=?1 AND installation_id=?2")? + .query_map(params![workspace,installation],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::>>()?; + let receipt = PurgeReceipt { + purge_id: uuid::Uuid::now_v7().to_string(), + observed, + }; + tx.execute("INSERT INTO plugin_data_purges(workspace_id,actor,operation_key,installation_id,fingerprint,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace,actor,operation_key,installation,fingerprint,encode(&receipt)?])?; + tx.execute( + "DELETE FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2", + params![workspace, installation], + )?; + tx.execute( + "DELETE FROM plugin_data_cursors WHERE workspace_id=?1 AND installation_id=?2", + params![workspace, installation], + )?; + for (owner, artifact) in holders { + plugin_catalog::plugin_reference_remove( + tx, + workspace, + PluginReferenceKind::Data, + &owner, + installation, + &artifact, + )?; + } + tx.execute( + "DELETE FROM plugin_data_holders WHERE workspace_id=?1 AND installation_id=?2", + params![workspace, installation], + )?; + Ok(receipt) + }) +} + +/// Convenience constructor for the owner after resolving a wire address. +pub fn namespace( + record: &InvocationRecord, + ticket_id: &str, + address: &PluginDataAddress, +) -> Namespace { + Namespace { + workspace_id: record.workspace_id.clone(), + installation_id: record.installation_id.clone(), + ticket_id: ticket_id.into(), + collection: address.collection.clone(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/workspace-server/src/plugin_data/catalog_tests.rs b/crates/workspace-server/src/plugin_data/catalog_tests.rs new file mode 100644 index 000000000..70db9483c --- /dev/null +++ b/crates/workspace-server/src/plugin_data/catalog_tests.rs @@ -0,0 +1,159 @@ +//! Real SQLite tests of catalog artifact registration and immutable typed schema +//! namespaces. Manifest/Component validation and caller authorization are outside +//! this owner test; immutable metadata DTOs are the input boundary. No guest runs. +use super::*; +use std::{ + sync::{Arc, Barrier}, + time::Duration as StdDuration, +}; +use tempfile::tempdir; + +fn artifact(version: &str, schema_version: &str, schema: serde_json::Value) -> PluginArtifact { + serde_json::from_value(serde_json::json!({ + "package_id":"package","name":"package","version":version,"description":null, + "package_digest":format!("sha256:{}",hash(format!("package-{version}").as_bytes())), + "artifact_digest":format!("sha256:{}",hash(format!("artifact-{version}").as_bytes())),"artifact_size":1, + "schema_version":2,"world":manifest::plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD, + "required_imports":[],"tools":[],"connections":[],"requested_grants":[], + "data_collections":[{"collection":"collection","schema_id":"schema","schema_version":schema_version,"schema":schema}], + "config_schema":{"identity":"config","version":"1","namespace":"package","digest":format!("sha256:{}",hash(b"config")),"dcdl":""}, + "revoked":false,"revocation_reason":null + })).unwrap() +} +fn schema(value_type: &str) -> serde_json::Value { + serde_json::json!({"type":"object","properties":{"value":{"type":value_type}},"required":["value"],"additionalProperties":false}) +} +fn open(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL;") + .unwrap(); + conn.busy_timeout(StdDuration::from_secs(10)).unwrap(); + conn +} +fn initialize(conn: &Connection) { + conn.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES('w'),('other-workspace');").unwrap(); + conn.execute_batch(include_str!("../plugin_catalog_schema.sql")) + .unwrap(); + for workspace in ["w", "other-workspace"] { + for installation in ["i", "other-installation"] { + conn.execute( + "INSERT INTO plugin_installations VALUES(?1,?2,?2,'package','op',0)", + params![workspace, installation], + ) + .unwrap(); + } + } +} +fn register( + conn: &mut Connection, + workspace: &str, + installation: &str, + candidate: &PluginArtifact, +) -> Result<()> { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + add_artifact(&tx, workspace, installation, candidate)?; + tx.commit()?; + Ok(()) +} +fn conflict(result: Result<()>) { + assert!( + matches!( + result, + Err(Error::PluginCatalog(PluginCatalogError::Conflict)) + ), + "expected schema conflict, got {result:?}" + ); +} + +#[test] +fn registered_schema_content_is_immutable_per_namespace_including_revoked_artifacts() { + let dir = tempdir().unwrap(); + let path = dir.path().join("catalog.db"); + let mut conn = open(&path); + initialize(&conn); + let first = artifact("1.0.0", "1", schema("string")); + register(&mut conn, "w", "i", &first).unwrap(); + // Different object member order is the same canonical schema, not a change. + let reordered=serde_json::from_str(r#"{"additionalProperties":false,"required":["value"],"properties":{"value":{"type":"string"}},"type":"object"}"#).unwrap(); + register(&mut conn, "w", "i", &artifact("1.0.1", "1", reordered)).unwrap(); + conn.execute("UPDATE plugin_artifacts SET revoked=1,revocation_reason='admin' WHERE workspace_id='w' AND installation_id='i'",[]).unwrap(); + drop(conn); + let mut conn = open(&path); + let changed = artifact("2.0.0", "1", schema("integer")); + conflict(register(&mut conn, "w", "i", &changed)); + assert_eq!( + conn.query_row( + "SELECT count(*) FROM plugin_artifacts WHERE workspace_id='w' AND installation_id='i'", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 2 + ); + // Explicit new schema version registers a separate namespace; rollback keeps + // the original schema metadata instead of reading it as the new definition. + let next = artifact("2.0.0", "2", schema("integer")); + register(&mut conn, "w", "i", &next).unwrap(); + let saved = load_installation(&conn, "w", "i").unwrap(); + let old = saved + .artifacts + .iter() + .find(|a| a.version == "1.0.0") + .unwrap(); + let new = saved + .artifacts + .iter() + .find(|a| a.version == "2.0.0") + .unwrap(); + assert!(old.revoked); + assert_eq!(old.data_collections[0].schema_version, "1"); + assert_eq!(old.data_collections[0].schema, schema("string")); + assert_eq!(new.data_collections[0].schema_version, "2"); + assert_eq!(new.data_collections[0].schema, schema("integer")); + // Installation and Workspace ownership are independent schema namespaces. + register(&mut conn, "w", "other-installation", &changed).unwrap(); + register(&mut conn, "other-workspace", "i", &changed).unwrap(); +} + +#[test] +fn concurrent_artifact_registration_cannot_redefine_a_schema_namespace() { + let dir = tempdir().unwrap(); + let path = dir.path().join("catalog.db"); + initialize(&open(&path)); + let gate = Arc::new(Barrier::new(3)); + let handles: Vec<_> = [("1.0.0", "string"), ("2.0.0", "integer")] + .into_iter() + .map(|(version, kind)| { + let path = path.clone(); + let gate = gate.clone(); + std::thread::spawn(move || { + let mut conn = open(&path); + gate.wait(); + let result = register(&mut conn, "w", "i", &artifact(version, "1", schema(kind))); + match result { + Ok(()) => true, + Err(error) => { + assert!(matches!( + error, + Error::PluginCatalog(PluginCatalogError::Conflict) + )); + false + } + } + }) + }) + .collect(); + gate.wait(); + let winners = handles + .into_iter() + .map(|handle| usize::from(handle.join().unwrap())) + .sum::(); + assert_eq!(winners, 1); + assert_eq!( + load_installation(&open(&path), "w", "i") + .unwrap() + .artifacts + .len(), + 1 + ); +} diff --git a/crates/workspace-server/src/plugin_data/reference_migration.sql b/crates/workspace-server/src/plugin_data/reference_migration.sql new file mode 100644 index 000000000..6fa01e349 --- /dev/null +++ b/crates/workspace-server/src/plugin_data/reference_migration.sql @@ -0,0 +1,22 @@ +-- New ordered migration only. Never modify the published catalog baseline. +DROP TRIGGER plugin_reference_live; +DROP TRIGGER plugin_uninstall_referenced; +CREATE TABLE plugin_references_with_data ( + workspace_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('config','pending','active','restorable','data')), + owner TEXT NOT NULL CHECK(length(owner) BETWEEN 1 AND 512), + installation_id TEXT NOT NULL, + artifact_digest TEXT NOT NULL, + PRIMARY KEY(workspace_id, kind, owner, installation_id, artifact_digest), + FOREIGN KEY(workspace_id, installation_id, artifact_digest) REFERENCES plugin_artifacts(workspace_id, installation_id, artifact_digest) ON DELETE RESTRICT +); +INSERT INTO plugin_references_with_data SELECT * FROM plugin_references; +DROP TABLE plugin_references; +ALTER TABLE plugin_references_with_data RENAME TO plugin_references; +CREATE TRIGGER plugin_reference_live BEFORE INSERT ON plugin_references +WHEN NOT EXISTS(SELECT 1 FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id, installation_id) + WHERE a.workspace_id=NEW.workspace_id AND a.installation_id=NEW.installation_id AND a.artifact_digest=NEW.artifact_digest AND a.revoked=0 AND i.uninstalled=0) +BEGIN SELECT RAISE(ABORT,'plugin reference target unavailable'); END; +CREATE TRIGGER plugin_uninstall_referenced BEFORE UPDATE OF uninstalled ON plugin_installations +WHEN NEW.uninstalled=1 AND EXISTS(SELECT 1 FROM plugin_references r WHERE r.workspace_id=NEW.workspace_id AND r.installation_id=NEW.installation_id) +BEGIN SELECT RAISE(ABORT,'plugin installation referenced'); END; diff --git a/crates/workspace-server/src/plugin_data/schema.sql b/crates/workspace-server/src/plugin_data/schema.sql new file mode 100644 index 000000000..36d0aae74 --- /dev/null +++ b/crates/workspace-server/src/plugin_data/schema.sql @@ -0,0 +1,105 @@ +-- Ticket children survive parent deletion for explicit Host retention/purge. +-- No guest SQL access; caller and triggers share the Ticket owner's DB fence. +CREATE TABLE plugin_data_cells ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + installation_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + collection TEXT NOT NULL, + schema_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + cell_key TEXT NOT NULL CHECK(length(CAST(cell_key AS BLOB)) BETWEEN 1 AND 512), + last_write TEXT NOT NULL UNIQUE, + value_json TEXT CHECK(value_json IS NULL OR (json_valid(value_json) AND length(CAST(value_json AS BLOB))<=65536)), + PRIMARY KEY(workspace_id,installation_id,ticket_id,collection,schema_id,schema_version,cell_key), + FOREIGN KEY(workspace_id,installation_id) REFERENCES plugin_installations(workspace_id,installation_id) +); +CREATE TRIGGER plugin_data_parent_insert BEFORE INSERT ON plugin_data_cells +WHEN NOT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id) + WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id AND w.state='active') +BEGIN SELECT RAISE(ABORT,'Plugin data parent unavailable'); END; +CREATE TRIGGER plugin_data_parent_update BEFORE UPDATE ON plugin_data_cells +WHEN NOT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id) + WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id AND w.state='active') +BEGIN SELECT RAISE(ABORT,'Plugin data parent unavailable'); END; + +-- Random opaque pagination selectors only, never authorization. Expired rows +-- are discarded; API issuance is bounded per Workspace and cannot extend a +-- traversal deadline. Explicit purge invalidates installation selectors. +CREATE TABLE plugin_data_cursors ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + installation_id TEXT NOT NULL, + cursor_token TEXT NOT NULL CHECK(length(cursor_token)=68 AND substr(cursor_token,1,4)='pdc-' AND substr(cursor_token,5) NOT GLOB '*[^0-9a-f]*'), + namespace_digest TEXT NOT NULL CHECK(length(namespace_digest)=71), + after_key TEXT NOT NULL CHECK(length(CAST(after_key AS BLOB)) BETWEEN 1 AND 256), + expires_at_ms INTEGER NOT NULL CHECK(expires_at_ms>0), + PRIMARY KEY(workspace_id,cursor_token), + UNIQUE(workspace_id,namespace_digest,after_key,expires_at_ms), + FOREIGN KEY(workspace_id,installation_id) REFERENCES plugin_installations(workspace_id,installation_id) +); +CREATE INDEX plugin_data_cursor_expiry ON plugin_data_cursors(workspace_id,expires_at_ms); + +-- Conservatively retain each writing artifact until explicit installation purge, +-- even when a later artifact overwrites its cell. owner is exact namespace hash. +CREATE TABLE plugin_data_holders ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + installation_id TEXT NOT NULL, + owner TEXT NOT NULL, + artifact_digest TEXT NOT NULL, + PRIMARY KEY(workspace_id,installation_id,owner,artifact_digest), + FOREIGN KEY(workspace_id,installation_id,artifact_digest) REFERENCES plugin_artifacts(workspace_id,installation_id,artifact_digest) +); +CREATE TABLE plugin_data_receipts ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + invocation_id TEXT NOT NULL, + operation_key TEXT NOT NULL CHECK(length(CAST(operation_key AS BLOB)) BETWEEN 1 AND 256), + namespace_json TEXT NOT NULL CHECK(json_valid(namespace_json)), + fingerprint TEXT NOT NULL CHECK(length(fingerprint)=71), + mutation_id TEXT NOT NULL UNIQUE, + result_digest TEXT NOT NULL CHECK(length(result_digest)=71), + result_json TEXT CHECK(result_json IS NULL OR (json_valid(result_json) AND length(CAST(result_json AS BLOB))<=262144)), + PRIMARY KEY(workspace_id,invocation_id,operation_key), + FOREIGN KEY(workspace_id,invocation_id) REFERENCES plugin_invocations(workspace_id,invocation_id) +); +CREATE TRIGGER plugin_data_receipt_immutable BEFORE UPDATE ON plugin_data_receipts +WHEN NOT(OLD.result_json IS NOT NULL AND NEW.result_json IS NULL + AND NEW.workspace_id=OLD.workspace_id AND NEW.invocation_id=OLD.invocation_id + AND NEW.operation_key=OLD.operation_key AND NEW.namespace_json=OLD.namespace_json + AND NEW.fingerprint=OLD.fingerprint AND NEW.mutation_id=OLD.mutation_id AND NEW.result_digest=OLD.result_digest + AND EXISTS(SELECT 1 FROM plugin_invocations i WHERE i.workspace_id=OLD.workspace_id AND i.invocation_id=OLD.invocation_id AND i.expired=1)) +BEGIN SELECT RAISE(ABORT,'Plugin data receipt immutable'); END; +CREATE TRIGGER plugin_data_receipt_no_replace BEFORE INSERT ON plugin_data_receipts +WHEN EXISTS(SELECT 1 FROM plugin_data_receipts WHERE (workspace_id=NEW.workspace_id AND invocation_id=NEW.invocation_id AND operation_key=NEW.operation_key) OR mutation_id=NEW.mutation_id) +BEGIN SELECT RAISE(ABORT,'Plugin data operation key retained'); END; +CREATE TRIGGER plugin_data_receipt_retained BEFORE DELETE ON plugin_data_receipts +WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id) +BEGIN SELECT RAISE(ABORT,'Plugin data receipt retained'); END; +CREATE TRIGGER plugin_data_receipt_expiry AFTER UPDATE OF expired ON plugin_invocations +WHEN NEW.expired=1 AND OLD.expired=0 +BEGIN UPDATE plugin_data_receipts SET result_json=NULL WHERE workspace_id=NEW.workspace_id AND invocation_id=NEW.invocation_id AND result_json IS NOT NULL; END; + +CREATE TRIGGER plugin_effect_data_collision BEFORE INSERT ON plugin_effect_receipts +WHEN EXISTS(SELECT 1 FROM plugin_data_receipts WHERE workspace_id=NEW.workspace_id AND invocation_id=NEW.invocation_id AND operation_key=NEW.effect_key) +BEGIN SELECT RAISE(ABORT,'Plugin operation key belongs to data'); END; +CREATE TRIGGER plugin_data_effect_collision BEFORE INSERT ON plugin_data_receipts +WHEN EXISTS(SELECT 1 FROM plugin_effect_receipts WHERE workspace_id=NEW.workspace_id AND invocation_id=NEW.invocation_id AND effect_key=NEW.operation_key) +BEGIN SELECT RAISE(ABORT,'Plugin operation key belongs to HTTP'); END; + +-- Admin operations are not guest invocations; this is only an atomic purge +-- receipt, never an execution identity or saved authorization decision. +CREATE TABLE plugin_data_purges ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + actor TEXT NOT NULL, + operation_key TEXT NOT NULL CHECK(length(CAST(operation_key AS BLOB)) BETWEEN 1 AND 256), + installation_id TEXT NOT NULL, + fingerprint TEXT NOT NULL, + result_json TEXT NOT NULL CHECK(json_valid(result_json)), + PRIMARY KEY(workspace_id,actor,operation_key) +); +CREATE TRIGGER plugin_data_purge_immutable BEFORE UPDATE ON plugin_data_purges +BEGIN SELECT RAISE(ABORT,'Plugin purge receipt immutable'); END; +CREATE TRIGGER plugin_data_purge_no_replace BEFORE INSERT ON plugin_data_purges +WHEN EXISTS(SELECT 1 FROM plugin_data_purges WHERE workspace_id=NEW.workspace_id AND actor=NEW.actor AND operation_key=NEW.operation_key) +BEGIN SELECT RAISE(ABORT,'Plugin purge key retained'); END; +CREATE TRIGGER plugin_data_purge_retained BEFORE DELETE ON plugin_data_purges +WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id) +BEGIN SELECT RAISE(ABORT,'Plugin purge receipt retained'); END; diff --git a/crates/workspace-server/src/plugin_data/tests.rs b/crates/workspace-server/src/plugin_data/tests.rs new file mode 100644 index 000000000..69924038f --- /dev/null +++ b/crates/workspace-server/src/plugin_data/tests.rs @@ -0,0 +1,1102 @@ +//! Real file-backed SQLite owner tests: production catalog/plan/invocation/data +//! DDL and functions, independent connections, fixed deadlines and bounded thread +//! synchronization. Only Workspace/Ticket parent rows and live authorization are +//! fixtures; this suite does not claim guest authorization or adapter coverage. +use super::*; +use crate::plugin_plan::{ + ConfigProvenance, HostExecutionIdentity, PlanPinState, PluginPinProjection, PluginSurface, + ProfileProvenance, StoredPluginPlan, WorkerPluginExecutionPlan, WorkerPluginPin, + WorkerPluginPlanInput, +}; +use rusqlite::TransactionBehavior; +use server_api::{PluginArtifact, PluginConsumer}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Barrier, mpsc}, + time::Duration, +}; +use tempfile::{TempDir, tempdir}; + +const NOW: i64 = 1000; +const RETENTION: i64 = 500; +fn open(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;") + .unwrap(); + conn.busy_timeout(Duration::from_secs(10)).unwrap(); + conn +} +fn artifact() -> PluginArtifact { + serde_json::from_value(serde_json::json!({ + "package_id":"package","name":"package","version":"1.0.0","description":null, + "package_digest":hash(b"package"),"artifact_digest":hash(b"artifact"),"artifact_size":1, + "schema_version":2,"world":manifest::plugin::PLUGIN_COMPONENT_TOOL_WORLD, + "required_imports":[],"tools":[{"name":"echo","description":"echo","input_schema":{},"output_schema":{},"error_codes":[],"external_write":false}], + "connections":[],"requested_grants":[],"data_collections":[], + "config_schema":{"identity":"schema","version":"1","namespace":"package","digest":hash(b"schema"),"dcdl":""}, + "revoked":false,"revocation_reason":null + })).unwrap() +} +fn plan(workspace: &str, installation: &str, id: &str) -> StoredPluginPlan { + let pin = WorkerPluginPin::from_artifact( + installation.into(), + "catalog-op".into(), + &artifact(), + PluginPinProjection { + config: serde_json::json!({}), + surfaces: vec![PluginSurface::Tool], + tools: vec!["echo".into()], + grants: vec![], + bindings: vec![], + }, + ) + .unwrap(); + let plan = WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput { + workspace_id: workspace.into(), + config: ConfigProvenance { + content_digest: hash(b"config"), + toolchain_fingerprint: "toolchain".into(), + }, + profile: ProfileProvenance { + identity: "profile".into(), + projection_digest: hash(b"profile"), + }, + execution: HostExecutionIdentity { + runtime_id: "runtime".into(), + worker_id: "worker".into(), + principal_id: "principal".into(), + delegation_id: Some("delegation".into()), + }, + plugins: vec![pin], + }) + .unwrap(); + StoredPluginPlan { + plan_id: id.into(), + fingerprint: plan.fingerprint().unwrap(), + pin_state: PlanPinState::Active, + plan, + } +} +struct Fixture { + dir: TempDir, + record: InvocationRecord, + reference: PluginReference, + ns: Namespace, +} +impl Fixture { + fn new() -> Self { + let dir = tempdir().unwrap(); + let path = dir.path().join("data.db"); + let mut conn = open(&path); + conn.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY,state TEXT NOT NULL); INSERT INTO workspaces VALUES('a','active'),('b','active'); CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id)); INSERT INTO typed_tickets VALUES('a','ticket'),('a','other-ticket'),('b','ticket');").unwrap(); + conn.execute_batch(include_str!("../plugin_catalog_schema.sql")) + .unwrap(); + conn.execute_batch(crate::plugin_plan::SCHEMA_SQL).unwrap(); + conn.execute_batch(plugin_receipt::SCHEMA_SQL).unwrap(); + // The production published baseline is upgraded by NEW migration SQL. + conn.execute_batch(REFERENCE_MIGRATION_SQL).unwrap(); + conn.execute_batch(SCHEMA_SQL).unwrap(); + let artifact = artifact(); + for workspace in ["a", "b"] { + for installation in ["installation", "other-installation"] { + conn.execute( + "INSERT INTO plugin_installations VALUES(?1,?2,?2,'package','catalog-op',0)", + params![workspace, installation], + ) + .unwrap(); + conn.execute("INSERT INTO plugin_artifacts(workspace_id,installation_id,artifact_digest,package_digest,version,artifact_size,metadata_json) VALUES(?1,?2,?3,?4,'1.0.0',1,?5)",params![workspace,installation,artifact.artifact_digest,artifact.package_digest,encode(&artifact).unwrap()]).unwrap(); + } + } + let stored = plan("a", "installation", "plan"); + conn.execute("INSERT INTO plugin_execution_plans(workspace_id,plan_id,actor,operation_key,fingerprint,plan_json,pin_state) VALUES('a','plan','fixture','plan',?1,?2,'active')",params![stored.fingerprint,stored.plan.canonical_json().unwrap()]).unwrap(); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + let record = plugin_receipt::begin( + &tx, + &stored, + "installation", + &PluginConsumer::ToolCall { + tool_call_id: "call".into(), + tool_name: "echo".into(), + }, + NOW, + RETENTION, + ) + .unwrap(); + tx.commit().unwrap(); + Self { + dir, + record, + reference: PluginReference { + kind: PluginReferenceKind::Data, + owner: "ignored".into(), + installation_id: "installation".into(), + expected_last_operation: "catalog-op".into(), + package_digest: artifact.package_digest, + artifact_digest: artifact.artifact_digest, + }, + ns: Namespace { + workspace_id: "a".into(), + installation_id: "installation".into(), + ticket_id: "ticket".into(), + collection: PluginDataCollection { + collection: "collection".into(), + schema_id: "schema".into(), + schema_version: "1".into(), + }, + }, + } + } + fn path(&self) -> PathBuf { + self.dir.path().join("data.db") + } + fn conn(&self) -> Connection { + open(&self.path()) + } + fn write(&self, work: impl FnOnce(&Transaction<'_>) -> Result) -> Result { + let mut conn = self.conn(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let result = work(&tx)?; + tx.commit()?; + Ok(result) + } + fn put( + &self, + key: &str, + operation: &str, + expected: PluginDataExpectation, + value: &str, + ) -> Result { + self.write(|tx| { + mutate( + tx, + &self.record, + &self.ns, + key, + operation, + &PluginDataMutation::Put { + expected, + value_json: value.into(), + }, + &self.reference, + NOW, + ) + }) + } + fn revoke(&self) { + self.write(|tx| { + tx.execute("UPDATE plugin_artifacts SET revoked=1,revocation_reason='admin' WHERE workspace_id='a' AND installation_id='installation'",[])?; + tx.execute("UPDATE plugin_installations SET last_operation='revoke-op' WHERE workspace_id='a' AND installation_id='installation'",[])?; + Ok(()) + }).unwrap(); + } + fn count(&self, table: &str) -> i64 { + self.conn() + .query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0)) + .unwrap() + } +} +fn expect_error(result: Result, code: PluginHostErrorCode) { + assert!( + matches!(&result,Err(Error::PluginHost(error)) if error.code==code), + "expected {code:?}: {result:?}" + ); +} +fn put(expected: PluginDataExpectation, value: &str) -> PluginDataMutation { + PluginDataMutation::Put { + expected, + value_json: value.into(), + } +} + +#[test] +fn lost_response_reopen_replays_before_cas_and_rejects_changed_input() { + let f = Fixture::new(); + let first = f + .put( + "key", + "write", + PluginDataExpectation::NeverWritten, + "{\"b\":2,\"a\":1}", + ) + .unwrap(); + let later = f + .put( + "key", + "later", + PluginDataExpectation::LastWrite(first.mutation_id.clone()), + "{}", + ) + .unwrap(); + // Fresh connection, lost first response: canonical equivalent input replays. + let replay = f + .put( + "key", + "write", + PluginDataExpectation::NeverWritten, + "{\"a\":1,\"b\":2}", + ) + .unwrap(); + assert_eq!(first, replay); + assert_ne!(first, later); + assert!( + matches!(get(&f.conn(),&f.ns,"key").unwrap(),PluginDataCell::Present{last_write,..} if last_write==later.mutation_id) + ); + assert_eq!( + lookup_mutation(&f.conn(), &f.record, &f.ns, "key", "write", NOW).unwrap(), + PluginDataMutationLookup::Committed(first) + ); + expect_error( + f.put("key", "write", PluginDataExpectation::NeverWritten, "null"), + PluginHostErrorCode::Conflict, + ); + expect_error( + f.put( + "different", + "write", + PluginDataExpectation::NeverWritten, + "{}", + ), + PluginHostErrorCode::Conflict, + ); + expect_error( + lookup_mutation(&f.conn(), &f.record, &f.ns, "different", "write", NOW), + PluginHostErrorCode::Conflict, + ); + assert_eq!( + lookup_mutation(&f.conn(), &f.record, &f.ns, "key", "unused", NOW).unwrap(), + PluginDataMutationLookup::NotRecorded + ); +} + +#[test] +fn delete_tombstone_prevents_never_written_and_stale_operation_aba() { + let f = Fixture::new(); + assert_eq!( + get(&f.conn(), &f.ns, "key").unwrap(), + PluginDataCell::NeverWritten + ); + let first = f + .put("key", "put", PluginDataExpectation::NeverWritten, "null") + .unwrap(); + let deleted = f + .write(|tx| { + mutate( + tx, + &f.record, + &f.ns, + "key", + "delete", + &PluginDataMutation::Delete { + expected_last_write: first.mutation_id.clone(), + }, + &f.reference, + NOW, + ) + }) + .unwrap(); + assert_eq!( + get(&f.conn(), &f.ns, "key").unwrap(), + PluginDataCell::Deleted { + last_delete: deleted.mutation_id.clone() + } + ); + expect_error( + f.put( + "key", + "stale-never", + PluginDataExpectation::NeverWritten, + "null", + ), + PluginHostErrorCode::Conflict, + ); + expect_error( + f.put( + "key", + "stale-put", + PluginDataExpectation::LastWrite(first.mutation_id), + "null", + ), + PluginHostErrorCode::Conflict, + ); + let recreated = f + .put( + "key", + "recreate", + PluginDataExpectation::LastWrite(deleted.mutation_id), + "null", + ) + .unwrap(); + assert!( + matches!(get(&f.conn(),&f.ns,"key").unwrap(),PluginDataCell::Present{last_write,value_json} if value_json=="null" && last_write==recreated.mutation_id) + ); + assert_eq!(f.count("plugin_references"), 1); +} + +#[test] +fn namespace_schema_rollback_and_cursor_rebinding_cannot_cross_cells() { + let f = Fixture::new(); + f.put("a", "a", PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + f.put("b", "b", PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + let page = list(&f.conn(), &f.ns, None, 1, NOW).unwrap(); + assert_eq!(page.entries[0].key, "a"); + let cursor = page.next_cursor.unwrap(); + let mut scopes = vec![]; + let mut ns = f.ns.clone(); + ns.workspace_id = "b".into(); + scopes.push(ns); + let mut ns = f.ns.clone(); + ns.installation_id = "other-installation".into(); + scopes.push(ns); + let mut ns = f.ns.clone(); + ns.ticket_id = "other-ticket".into(); + scopes.push(ns); + let mut ns = f.ns.clone(); + ns.collection.collection = "other".into(); + scopes.push(ns); + let mut ns = f.ns.clone(); + ns.collection.schema_id = "other".into(); + scopes.push(ns); + let mut ns = f.ns.clone(); + ns.collection.schema_version = "2".into(); + scopes.push(ns.clone()); + for scope in &scopes { + assert_eq!( + get(&f.conn(), scope, "a").unwrap(), + PluginDataCell::NeverWritten + ); + expect_error( + list(&f.conn(), scope, Some(&cursor), 1, NOW), + PluginHostErrorCode::InvalidInput, + ); + } + f.write(|tx| { + mutate( + tx, + &f.record, + &ns, + "a", + "schema-two", + &put(PluginDataExpectation::NeverWritten, "{\"v\":2}"), + &f.reference, + NOW, + ) + }) + .unwrap(); + assert!( + matches!(get(&f.conn(),&f.ns,"a").unwrap(),PluginDataCell::Present{value_json,..} if value_json=="{}") + ); + assert!( + matches!(get(&f.conn(),&ns,"a").unwrap(),PluginDataCell::Present{value_json,..} if value_json=="{\"v\":2}") + ); + let next = list(&f.conn(), &f.ns, Some(&cursor), 1, NOW).unwrap(); + assert_eq!(next.entries[0].key, "b"); + assert!(next.next_cursor.is_none()); + expect_error( + list(&f.conn(), &f.ns, Some(&cursor), 1, NOW + CURSOR_LIFETIME_MS), + PluginHostErrorCode::InvalidInput, + ); + expect_error( + list(&f.conn(), &f.ns, Some("malformed"), 1, NOW), + PluginHostErrorCode::InvalidInput, + ); + for limit in [0, 101] { + expect_error( + list(&f.conn(), &f.ns, None, limit, NOW), + PluginHostErrorCode::InvalidInput, + ); + } +} + +#[test] +fn expiry_and_prune_preserve_keys_and_cannot_resurrect_on_backwards_clock() { + let f = Fixture::new(); + f.put( + "key", + "write", + PluginDataExpectation::NeverWritten, + "{\"secret\":true}", + ) + .unwrap(); + assert_eq!( + lookup_mutation( + &f.conn(), + &f.record, + &f.ns, + "key", + "unused", + NOW + RETENTION + ) + .unwrap(), + PluginDataMutationLookup::Expired + ); + f.write(|tx| { + plugin_receipt::prune(tx, "a", NOW + RETENTION)?; + Ok(()) + }) + .unwrap(); + assert_eq!( + lookup_mutation(&f.conn(), &f.record, &f.ns, "key", "write", NOW).unwrap(), + PluginDataMutationLookup::Expired + ); + expect_error( + f.put("new", "new", PluginDataExpectation::NeverWritten, "{}"), + PluginHostErrorCode::ReceiptExpired, + ); + assert_eq!(f.count("plugin_data_receipts"), 1); + let payload: Option = f + .conn() + .query_row("SELECT result_json FROM plugin_data_receipts", [], |r| { + r.get(0) + }) + .unwrap(); + assert!(payload.is_none()); + assert!( + f.conn() + .execute("DELETE FROM plugin_data_receipts", []) + .is_err() + ); + assert!(matches!( + get(&f.conn(), &f.ns, "key").unwrap(), + PluginDataCell::Present { .. } + )); +} + +#[test] +fn invocation_identity_and_cancellation_are_rechecked_from_owner() { + let f = Fixture::new(); + let mut forged = f.record.clone(); + forged.consumer = PluginConsumer::ToolCall { + tool_call_id: "forged".into(), + tool_name: "echo".into(), + }; + expect_error( + f.write(|tx| { + mutate( + tx, + &forged, + &f.ns, + "key", + "op", + &put(PluginDataExpectation::NeverWritten, "{}"), + &f.reference, + NOW, + ) + }), + PluginHostErrorCode::Conflict, + ); + let mut foreign = f.ns.clone(); + foreign.workspace_id = "b".into(); + expect_error( + f.write(|tx| { + mutate( + tx, + &f.record, + &foreign, + "key", + "op", + &put(PluginDataExpectation::NeverWritten, "{}"), + &f.reference, + NOW, + ) + }), + PluginHostErrorCode::Conflict, + ); + f.write(|tx| plugin_receipt::cancel(tx, "a", &f.record.invocation_id)) + .unwrap(); + expect_error( + f.put("key", "op", PluginDataExpectation::NeverWritten, "{}"), + PluginHostErrorCode::Cancelled, + ); + expect_error( + lookup_mutation(&f.conn(), &f.record, &f.ns, "key", "unused", NOW), + PluginHostErrorCode::Cancelled, + ); + assert_eq!(f.count("plugin_data_cells"), 0); +} + +#[test] +fn receipt_failure_rolls_back_cell_reference_and_holder_even_if_owner_commits() { + let f = Fixture::new(); + f.conn().execute_batch("CREATE TRIGGER inject_receipt_failure BEFORE INSERT ON plugin_data_receipts BEGIN SELECT RAISE(ABORT,'injected storage failure'); END;").unwrap(); + f.write(|tx| { + assert!( + mutate( + tx, + &f.record, + &f.ns, + "key", + "write", + &put(PluginDataExpectation::NeverWritten, "{}"), + &f.reference, + NOW + ) + .is_err() + ); + tx.execute( + "UPDATE workspaces SET state='active' WHERE workspace_id='b'", + [], + )?; + Ok(()) + }) + .unwrap(); + for table in [ + "plugin_data_cells", + "plugin_data_holders", + "plugin_data_receipts", + "plugin_references", + ] { + assert_eq!(f.count(table), 0, "{table}"); + } +} + +#[test] +fn http_and_data_share_operation_key_namespace_without_a_second_invocation() { + let f = Fixture::new(); + f.put("key", "data", PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + expect_error( + f.write(|tx| plugin_receipt::effect_admit(tx, &f.record, "data", &hash(b"http"), NOW)), + PluginHostErrorCode::Conflict, + ); + f.write(|tx| plugin_receipt::effect_admit(tx, &f.record, "http", &hash(b"http"), NOW)) + .unwrap(); + expect_error( + f.put("other", "http", PluginDataExpectation::NeverWritten, "{}"), + PluginHostErrorCode::Conflict, + ); + expect_error( + lookup_mutation(&f.conn(), &f.record, &f.ns, "key", "http", NOW), + PluginHostErrorCode::Conflict, + ); + assert_eq!(f.count("plugin_invocations"), 1); +} + +#[test] +fn concurrent_final_write_cas_has_one_committed_winner() { + let f = Fixture::new(); + let gate = Arc::new(Barrier::new(3)); + let handles:Vec<_>=(0..2).map(|index| { + let gate=gate.clone(); let path=f.path(); let record=f.record.clone(); let ns=f.ns.clone(); let reference=f.reference.clone(); + std::thread::spawn(move || { + let mut conn=open(&path); gate.wait(); + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate).unwrap(); + let outcome=mutate(&tx,&record,&ns,"key",&format!("writer-{index}"),&put(PluginDataExpectation::NeverWritten,"{}"),&reference,NOW); + match outcome { Ok(receipt)=>{tx.commit().unwrap();Some(receipt)},Err(error)=>{assert!(matches!(error,Error::PluginHost(ref e) if e.code==PluginHostErrorCode::Conflict));None} } + }) + }).collect(); + gate.wait(); + let winners: Vec<_> = handles + .into_iter() + .filter_map(|h| h.join().unwrap()) + .collect(); + assert_eq!(winners.len(), 1); + assert_eq!(f.count("plugin_data_receipts"), 1); + assert!( + matches!(get(&f.conn(),&f.ns,"key").unwrap(),PluginDataCell::Present{last_write,..} if last_write==winners[0].mutation_id) + ); +} + +#[test] +fn physical_ticket_deletion_and_workspace_deleting_fence_stale_writers() { + let f = Fixture::new(); + let mut deleting = f.conn(); + let tx = deleting + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + tx.execute( + "DELETE FROM typed_tickets WHERE workspace_id='a' AND ticket_id='ticket'", + [], + ) + .unwrap(); + let (started_tx, started_rx) = mpsc::channel(); + let path = f.path(); + let record = f.record.clone(); + let ns = f.ns.clone(); + let reference = f.reference.clone(); + let writer = std::thread::spawn(move || { + let mut conn = open(&path); + started_tx.send(()).unwrap(); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + mutate( + &tx, + &record, + &ns, + "key", + "late", + &put(PluginDataExpectation::NeverWritten, "{}"), + &reference, + NOW, + ) + }); + started_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + tx.commit().unwrap(); + expect_error(writer.join().unwrap(), PluginHostErrorCode::NotFound); + assert_eq!(f.count("plugin_data_cells"), 0); + // The opposite ordering retains data but no read/write may resolve deleted parent. + let f = Fixture::new(); + f.put("key", "first", PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + f.write(|tx| { + tx.execute( + "DELETE FROM typed_tickets WHERE workspace_id='a' AND ticket_id='ticket'", + [], + )?; + Ok(()) + }) + .unwrap(); + expect_error(get(&f.conn(), &f.ns, "key"), PluginHostErrorCode::NotFound); + assert_eq!(f.count("plugin_data_cells"), 1); + let f = Fixture::new(); + f.write(|tx| { + tx.execute( + "UPDATE workspaces SET state='deleting' WHERE workspace_id='a'", + [], + )?; + Ok(()) + }) + .unwrap(); + expect_error( + f.put("key", "late", PluginDataExpectation::NeverWritten, "{}"), + PluginHostErrorCode::NotFound, + ); + // Physical SQL fences are not merely advisory owner checks. + assert!(f.conn().execute("INSERT INTO plugin_data_cells VALUES('a','installation','ticket','collection','schema','1','key','illegal','{}')",[]).is_err()); +} + +#[test] +fn uninstall_racing_first_data_write_cannot_drop_retained_installation() { + let f = Fixture::new(); + let gate = Arc::new(Barrier::new(3)); + let path = f.path(); + let gate_writer = gate.clone(); + let record = f.record.clone(); + let ns = f.ns.clone(); + let reference = f.reference.clone(); + let writer = std::thread::spawn(move || { + let mut conn = open(&path); + gate_writer.wait(); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + let success = mutate( + &tx, + &record, + &ns, + "key", + "write", + &put(PluginDataExpectation::NeverWritten, "{}"), + &reference, + NOW, + ) + .is_ok(); + tx.commit().unwrap(); + success + }); + let path = f.path(); + let gate_uninstall = gate.clone(); + let uninstall = std::thread::spawn(move || { + let mut conn = open(&path); + gate_uninstall.wait(); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + let success=tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='a' AND installation_id='installation'",[]).is_ok(); + tx.commit().unwrap(); + success + }); + gate.wait(); + let wrote = writer.join().unwrap(); + let uninstalled = uninstall.join().unwrap(); + assert_ne!(wrote, uninstalled); + assert_eq!(f.count("plugin_data_cells"), if wrote { 1 } else { 0 }); +} + +#[test] +fn purge_requires_post_revocation_exact_set_and_retains_replay_after_uninstall() { + let f = Fixture::new(); + let first = f + .put("key", "first", PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + expect_error( + f.write(|tx| purge_snapshot(tx, "a", "installation")), + PluginHostErrorCode::Conflict, + ); + let deleted = f + .write(|tx| { + mutate( + tx, + &f.record, + &f.ns, + "key", + "delete", + &PluginDataMutation::Delete { + expected_last_write: first.mutation_id, + }, + &f.reference, + NOW, + ) + }) + .unwrap(); + f.revoke(); + assert_eq!(f.count("plugin_data_cells"), 1); + let snapshot = f + .write(|tx| purge_snapshot(tx, "a", "installation")) + .unwrap(); + assert_eq!(snapshot.cell_count, 1); + // A missing tombstone is a different exact set, not an opaque version counter. + let mut wrong = snapshot.clone(); + wrong.cells_digest = hash(deleted.mutation_id.as_bytes()); + expect_error( + f.write(|tx| purge(tx, "a", "installation", "admin", "purge", &wrong)), + PluginHostErrorCode::Conflict, + ); + assert_eq!(f.count("plugin_references"), 1); + let receipt = f + .write(|tx| purge(tx, "a", "installation", "admin", "purge", &snapshot)) + .unwrap(); + assert_eq!(f.count("plugin_data_cells"), 0); + assert_eq!(f.count("plugin_data_holders"), 0); + assert_eq!(f.count("plugin_references"), 0); + assert_eq!(f.count("plugin_data_receipts"), 2); + assert_eq!(f.count("plugin_data_purges"), 1); + f.write(|tx| {tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='a' AND installation_id='installation'",[])?;Ok(())}).unwrap(); + assert_eq!( + f.write(|tx| purge(tx, "a", "installation", "admin", "purge", &snapshot)) + .unwrap(), + receipt + ); + expect_error( + f.write(|tx| purge(tx, "a", "installation", "admin", "purge", &wrong)), + PluginHostErrorCode::Conflict, + ); + assert!( + f.conn() + .execute("DELETE FROM plugin_data_purges", []) + .is_err() + ); +} + +#[test] +fn purge_rollback_keeps_cells_and_references_and_no_success_receipt() { + let f = Fixture::new(); + f.put("key", "write", PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + f.revoke(); + let snapshot = f + .write(|tx| purge_snapshot(tx, "a", "installation")) + .unwrap(); + f.conn().execute_batch("CREATE TRIGGER inject_reference_failure BEFORE DELETE ON plugin_references BEGIN SELECT RAISE(ABORT,'injected purge failure'); END;").unwrap(); + f.write(|tx| { + assert!(purge(tx, "a", "installation", "admin", "purge", &snapshot).is_err()); + Ok(()) + }) + .unwrap(); + assert_eq!(f.count("plugin_data_cells"), 1); + assert_eq!(f.count("plugin_data_holders"), 1); + assert_eq!(f.count("plugin_references"), 1); + assert_eq!(f.count("plugin_data_purges"), 0); +} + +#[test] +fn purge_serializes_with_uninstall_and_parent_deletion_without_receipt_loss() { + let f = Fixture::new(); + f.put("key", "write", PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + f.revoke(); + // Uninstall cannot win before purge removes retained Data reference. + assert!(f.conn().execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='a' AND installation_id='installation'",[]).is_err()); + let mut owner = f.conn(); + let tx = owner + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + let snapshot = purge_snapshot(&tx, "a", "installation").unwrap(); + let receipt = purge(&tx, "a", "installation", "admin", "purge", &snapshot).unwrap(); + let path = f.path(); + let (started_tx, started_rx) = mpsc::channel(); + let deleting = std::thread::spawn(move || { + let mut conn = open(&path); + started_tx.send(()).unwrap(); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + tx.execute( + "DELETE FROM typed_tickets WHERE workspace_id='a' AND ticket_id='ticket'", + [], + ) + .unwrap(); + tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='a' AND installation_id='installation'",[]).unwrap(); + tx.commit().unwrap(); + }); + started_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + tx.commit().unwrap(); + deleting.join().unwrap(); + assert_eq!( + f.write(|tx| purge(tx, "a", "installation", "admin", "purge", &snapshot)) + .unwrap(), + receipt + ); + assert_eq!(f.count("plugin_data_cells"), 0); + assert_eq!(f.count("plugin_data_purges"), 1); +} + +#[test] +fn new_reference_migration_preserves_published_rows_and_failed_upgrade_rolls_back() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES('w');").unwrap(); + conn.execute_batch(include_str!("../plugin_catalog_schema.sql")) + .unwrap(); + conn.execute( + "INSERT INTO plugin_installations VALUES('w','i','alias','package','op',0)", + [], + ) + .unwrap(); + let a = hash(b"artifact"); + let p = hash(b"package"); + conn.execute("INSERT INTO plugin_artifacts(workspace_id,installation_id,artifact_digest,package_digest,version,artifact_size,metadata_json) VALUES('w','i',?1,?2,'1',1,'{}')",params![a,p]).unwrap(); + conn.execute( + "INSERT INTO plugin_references VALUES('w','active','owner','i',?1)", + [&a], + ) + .unwrap(); + { + let tx = conn.unchecked_transaction().unwrap(); + tx.execute_batch(REFERENCE_MIGRATION_SQL).unwrap(); + tx.execute_batch("CREATE TABLE plugin_data_cells(collision INTEGER);") + .unwrap(); + assert!(tx.execute_batch(SCHEMA_SQL).is_err()); + tx.rollback().unwrap(); + } + assert!( + conn.execute( + "INSERT INTO plugin_references VALUES('w','data','owner','i',?1)", + [&a] + ) + .is_err() + ); + plugin_catalog::verify(&conn).unwrap(); + conn.execute_batch(REFERENCE_MIGRATION_SQL).unwrap(); + plugin_catalog::verify_with_data(&conn).unwrap(); + let row: (String, String) = conn + .query_row("SELECT kind,owner FROM plugin_references", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!(row, ("active".into(), "owner".into())); + conn.execute( + "INSERT INTO plugin_references VALUES('w','data','data-owner','i',?1)", + [&a], + ) + .unwrap(); + assert!( + conn.execute( + "UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='w'", + [] + ) + .is_err() + ); + assert_eq!( + conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + )) + .unwrap(), + 0 + ); +} + +#[test] +fn purge_catalog_fence_change_conflicts_even_when_exact_cell_set_is_unchanged() { + let f = Fixture::new(); + f.put("key", "write", PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + f.revoke(); + let snapshot = f + .write(|tx| purge_snapshot(tx, "a", "installation")) + .unwrap(); + // A catalog operation (e.g. adding then revoking an artifact) changes the + // execution fence even though no typed cells were written in between. + f.write(|tx| { + tx.execute("UPDATE plugin_installations SET last_operation='another-catalog-op' WHERE workspace_id='a' AND installation_id='installation'", [])?; + Ok(()) + }).unwrap(); + expect_error( + f.write(|tx| purge(tx, "a", "installation", "admin", "purge", &snapshot)), + PluginHostErrorCode::Conflict, + ); + assert_eq!(f.count("plugin_data_cells"), 1); + assert_eq!(f.count("plugin_data_purges"), 0); + let current = f + .write(|tx| purge_snapshot(tx, "a", "installation")) + .unwrap(); + assert_eq!(snapshot.cells_digest, current.cells_digest); + assert_ne!( + snapshot.installation_last_operation, + current.installation_last_operation + ); +} + +#[test] +fn maximum_value_and_key_bounds_are_enforced_without_partial_records() { + let f = Fixture::new(); + let maximum_key = "k".repeat(256); + // Backslash-heavy JSON expands when encoded in a receipt's value_json field. + let value = format!( + "\"{}\"", + "\\\\".repeat((server_api::PLUGIN_DATA_VALUE_LIMIT - 2) / 2) + ); + assert_eq!(value.len(), server_api::PLUGIN_DATA_VALUE_LIMIT); + f.put( + &maximum_key, + "write", + PluginDataExpectation::NeverWritten, + &value, + ) + .unwrap(); + assert!( + matches!(get(&f.conn(), &f.ns, &maximum_key).unwrap(), PluginDataCell::Present { value_json, .. } if value_json == value) + ); + for key in [String::new(), "k".repeat(257), "bad\nkey".into()] { + expect_error( + f.put(&key, "invalid", PluginDataExpectation::NeverWritten, "{}"), + PluginHostErrorCode::InvalidInput, + ); + } + expect_error( + f.put( + "oversized", + "oversized", + PluginDataExpectation::NeverWritten, + &format!("\"{}\"", "x".repeat(65536)), + ), + PluginHostErrorCode::QuotaExceeded, + ); + expect_error( + f.put( + "invalid", + "invalid", + PluginDataExpectation::NeverWritten, + "not-json", + ), + PluginHostErrorCode::InvalidInput, + ); + assert_eq!(f.count("plugin_data_cells"), 1); + assert_eq!(f.count("plugin_data_receipts"), 1); +} + +#[test] +fn opaque_cursor_reopen_tampering_and_deadline_extension_are_rejected() { + let f = Fixture::new(); + for key in ["a", "b", "c"] { + f.put(key, key, PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + } + let first = list(&f.conn(), &f.ns, None, 1, NOW).unwrap(); + let token = first.next_cursor.unwrap(); + assert_eq!(token.len(), 68); + assert!(token.starts_with("pdc-")); + assert!(serde_json::from_str::(&token).is_err()); + let mut tampered = token.clone(); + let replacement = if token.ends_with('0') { "1" } else { "0" }; + tampered.replace_range(67..68, replacement); + expect_error( + list(&f.conn(), &f.ns, Some(&tampered), 1, NOW), + PluginHostErrorCode::InvalidInput, + ); + let forged_json = encode(&serde_json::json!({"namespace":digest(&f.ns).unwrap(),"after":"a","expires_at_ms":NOW+CURSOR_LIFETIME_MS})).unwrap(); + expect_error( + list(&f.conn(), &f.ns, Some(&forged_json), 1, NOW), + PluginHostErrorCode::InvalidInput, + ); + // Independent reopened connection recovers the selector, not client data. + let second = list(&f.conn(), &f.ns, Some(&token), 1, NOW + 50).unwrap(); + assert_eq!(second.entries[0].key, "b"); + let next = second.next_cursor.unwrap(); + let deadline: i64 = f + .conn() + .query_row( + "SELECT expires_at_ms FROM plugin_data_cursors WHERE cursor_token=?1", + [&next], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(deadline, NOW + CURSOR_LIFETIME_MS); + expect_error( + list(&f.conn(), &f.ns, Some(&next), 1, deadline), + PluginHostErrorCode::InvalidInput, + ); + // Autocommit expiry pruning forgets the opaque token permanently. + expect_error( + list(&f.conn(), &f.ns, Some(&next), 1, NOW), + PluginHostErrorCode::InvalidInput, + ); +} + +#[test] +fn cursor_selector_map_is_bounded_reusable_and_expired_rows_are_reclaimed() { + let f = Fixture::new(); + for key in ["a", "b", "c"] { + f.put(key, key, PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + } + let token = list(&f.conn(), &f.ns, None, 1, NOW) + .unwrap() + .next_cursor + .unwrap(); + f.write(|tx| { + let mut insert=tx.prepare("INSERT INTO plugin_data_cursors(workspace_id,installation_id,cursor_token,namespace_digest,after_key,expires_at_ms) VALUES('a','installation',?1,?2,'offset',?3)")?; + for index in 1..MAX_CURSORS_PER_WORKSPACE { + insert.execute(params![format!("pdc-{index:064x}"),hash(format!("fixture-scope-{index}").as_bytes()),NOW+CURSOR_LIFETIME_MS])?; + } + Ok(()) + }).unwrap(); + assert_eq!(f.count("plugin_data_cursors"), MAX_CURSORS_PER_WORKSPACE); + // Reissuing the exact existing selector needs no additional capacity. + assert_eq!( + list(&f.conn(), &f.ns, None, 1, NOW) + .unwrap() + .next_cursor + .unwrap(), + token + ); + expect_error( + list(&f.conn(), &f.ns, Some(&token), 1, NOW), + PluginHostErrorCode::QuotaExceeded, + ); + let fresh = list(&f.conn(), &f.ns, None, 1, NOW + CURSOR_LIFETIME_MS) + .unwrap() + .next_cursor + .unwrap(); + assert_ne!(fresh, token); + assert_eq!(f.count("plugin_data_cursors"), 1); +} + +#[test] +fn explicit_purge_invalidates_persisted_pagination_selectors() { + let f = Fixture::new(); + for key in ["a", "b"] { + f.put(key, key, PluginDataExpectation::NeverWritten, "{}") + .unwrap(); + } + let token = list(&f.conn(), &f.ns, None, 1, NOW) + .unwrap() + .next_cursor + .unwrap(); + assert_eq!(f.count("plugin_data_cursors"), 1); + f.revoke(); + let snapshot = f + .write(|tx| purge_snapshot(tx, "a", "installation")) + .unwrap(); + f.write(|tx| purge(tx, "a", "installation", "admin", "purge", &snapshot)) + .unwrap(); + assert_eq!(f.count("plugin_data_cursors"), 0); + expect_error( + list(&f.conn(), &f.ns, Some(&token), 1, NOW), + PluginHostErrorCode::InvalidInput, + ); +} diff --git a/crates/workspace-server/src/plugin_data_migration_tests.rs b/crates/workspace-server/src/plugin_data_migration_tests.rs new file mode 100644 index 000000000..4a6ddee08 --- /dev/null +++ b/crates/workspace-server/src/plugin_data_migration_tests.rs @@ -0,0 +1,63 @@ +// The real retained predecessor exercises both the catalog CHECK rebuild and +// new tables in one owner transaction; no schema-number relabeling is used. +#[test] +fn plugin_data_migration_preserves_references_and_matches_fresh_schema() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + prepare_retained_schema(&path, 110); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + seed_migration_workspace(&conn); + let digest = format!("sha256:{}", "a".repeat(64)); + conn.execute("INSERT INTO plugin_installations(workspace_id,installation_id,alias,package_id,last_operation) VALUES('space','installation','alias','package','install')", []).unwrap(); + conn.execute("INSERT INTO plugin_artifacts(workspace_id,installation_id,artifact_digest,package_digest,version,artifact_size,metadata_json) VALUES('space','installation',?1,?1,'1',1,'{}')", [&digest]).unwrap(); + conn.execute( + "INSERT INTO plugin_references VALUES('space','config','config-owner','installation',?1)", + [&digest], + ) + .unwrap(); + let history = workspace_schema_migration_history(&conn).unwrap(); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT count(*) FROM plugin_references WHERE kind='config' AND owner='config-owner'", [], |r| r.get::<_, i64>(0))?, 1); + conn.execute("INSERT INTO plugin_references VALUES('space','data','data-owner','installation',?1)", [&digest])?; + assert!(conn.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='space'", []).is_err()); + assert!(conn.execute("INSERT INTO plugin_references VALUES('space','unknown','bad-owner','installation',?1)", [&digest]).is_err()); + assert_eq!(&workspace_schema_migration_history(conn)?[..history.len()], history.as_slice()); + let fresh = SqliteWorkspaceStore::in_memory_for_test()?; + let objects = |connection: &Connection| -> Result> { + Ok(connection.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND (name LIKE 'plugin_data_%' OR name='plugin_references') ORDER BY name")?.query_map([], |r| Ok((r.get(0)?,r.get(1)?)))?.collect::>>()?) + }; + let expected = fresh.with_conn(objects)?; + assert_eq!(objects(conn)?, expected); + assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r.get::<_,i64>(0))?, 0); + Ok(()) + }).unwrap(); + drop(store); + SqliteWorkspaceStore::open_for_test(&path).unwrap(); +} + +#[test] +fn plugin_data_migration_late_failure_rolls_back_catalog_rebuild_and_history() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.db"); + prepare_retained_schema(&path, 110); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch("CREATE TABLE plugin_data_receipts(unrelated INTEGER)") + .unwrap(); + let history = workspace_schema_migration_history(&conn).unwrap(); + let schema = |conn: &Connection| { + conn.query_row("SELECT group_concat(sql,char(10)) FROM (SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name)", [], |r| r.get::<_,String>(0)).unwrap() + }; + let before = schema(&conn); + assert!(migrate_plugin_data_v110_to_v111(&conn).is_err()); + assert_eq!(schema(&conn), before); + assert_eq!(workspace_schema_migration_history(&conn).unwrap(), history); + conn.execute_batch("DROP TABLE plugin_data_receipts") + .unwrap(); + migrate_plugin_data_v110_to_v111(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 111); + assert!(migrate_plugin_data_v110_to_v111(&conn).is_err()); +} diff --git a/crates/workspace-server/src/plugin_host.rs b/crates/workspace-server/src/plugin_host.rs index 0b352720c..d1cac0d39 100644 --- a/crates/workspace-server/src/plugin_host.rs +++ b/crates/workspace-server/src/plugin_host.rs @@ -23,6 +23,7 @@ pub struct AdmittedPluginOperation { pub plan: StoredPluginPlan, pub pin: WorkerPluginPin, pub binding: Option, + pub data_collections: Vec, } /// `execution` is derived by the authenticated Host boundary from current Worker @@ -131,7 +132,12 @@ pub fn authorize( } else { None }; - Ok(AdmittedPluginOperation { plan, pin, binding }) + Ok(AdmittedPluginOperation { + plan, + pin, + binding, + data_collections: artifact.data_collections, + }) } pub(crate) fn live_binding( diff --git a/crates/workspace-server/src/plugin_host_migration_tests.rs b/crates/workspace-server/src/plugin_host_migration_tests.rs index 9c527e33c..85f4c77d6 100644 --- a/crates/workspace-server/src/plugin_host_migration_tests.rs +++ b/crates/workspace-server/src/plugin_host_migration_tests.rs @@ -12,17 +12,17 @@ fn plugin_receipt_tail_migrates_true_predecessor_reopens_and_matches_fresh_schem assert!(!table_exists(&conn, "plugin_invocations").unwrap()); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, 108); - assert_eq!(plan.migrations.iter().map(|step| step.version).collect::>(), [109, 110]); + assert_eq!(plan.migrations.iter().map(|step| step.version).collect::>(), [109, 110, 111]); assert_eq!(current_schema_version(&conn).unwrap(), 108); drop(conn); let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store.with_conn(|conn| { crate::plugin_host::verify_receipt_schema(conn)?; - assert_eq!(current_schema_version(conn)?, 110); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); let upgraded = workspace_schema_migration_history(conn)?; assert_eq!(&upgraded[..history.len()], history.as_slice()); assert_eq!(upgraded.iter().find(|(version, _)| *version == 109).unwrap().1, "Durable Plugin Host invocations and HTTPS effect receipts"); - assert_eq!(upgraded.last().unwrap().1, "Worker Plugin launch selection associations"); + assert_eq!(upgraded.iter().find(|(version, _)| *version == 110).unwrap().1, "Worker Plugin launch selection associations"); assert_eq!(conn.query_row("SELECT count(*) FROM workspaces WHERE workspace_id='space'", [], |row| row.get::<_, i64>(0))?, 1); assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, i64>(0))?, 0); Ok(()) diff --git a/crates/workspace-server/src/plugin_plan/migration_tests.rs b/crates/workspace-server/src/plugin_plan/migration_tests.rs index b95443e82..ac0ea1d76 100644 --- a/crates/workspace-server/src/plugin_plan/migration_tests.rs +++ b/crates/workspace-server/src/plugin_plan/migration_tests.rs @@ -35,6 +35,10 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match "Durable Plugin Host invocations and HTTPS effect receipts".into(), )); expected.push((110, "Worker Plugin launch selection associations".into())); + expected.push(( + 111, + "Typed Plugin Ticket extension data and atomic mutation receipts".into(), + )); assert_eq!(workspace_schema_migration_history(conn)?, expected); subject_assignment_migration::verify(conn)?; crate::plugin_plan::verify_plan_schema(conn)?; diff --git a/crates/workspace-server/src/plugin_receipt.rs b/crates/workspace-server/src/plugin_receipt.rs index 5ae1b0631..a5d4384c3 100644 --- a/crates/workspace-server/src/plugin_receipt.rs +++ b/crates/workspace-server/src/plugin_receipt.rs @@ -227,7 +227,10 @@ pub fn lookup(conn: &Connection, workspace: &str, invocation_id: &str) -> Result // Never trust a passed record's mutable flags/deadline or accept a fabricated // plan/execution/consumer. Records may be stale only in monotonic terminal flags. -fn verify_record(conn: &Connection, supplied: &InvocationRecord) -> Result { +pub(crate) fn verify_record( + conn: &Connection, + supplied: &InvocationRecord, +) -> Result { let saved = lookup(conn, &supplied.workspace_id, &supplied.invocation_id)?; if supplied.plan_id != saved.plan_id || supplied.installation_id != saved.installation_id @@ -334,7 +337,11 @@ pub fn effect_admit( tx.execute( "INSERT INTO plugin_effect_receipts(workspace_id,invocation_id,effect_key,fingerprint,admitted_at_ms,state,error_json) VALUES(?1,?2,?3,?4,?5,'pending',?6)", params![record.workspace_id, record.invocation_id, effect_key, fingerprint, now_ms, encode(&unknown)?], - )?; + ).map_err(|error| match &error { + rusqlite::Error::SqliteFailure(_, Some(message)) if message == "Plugin operation key belongs to data" => + Error::from(host_error(PluginHostErrorCode::Conflict, PluginEffectObservation::Indeterminate)), + _ => Error::from(error), + })?; Ok(EffectAdmission::Dispatch) } @@ -441,8 +448,9 @@ pub fn cancel(tx: &Transaction<'_>, workspace: &str, invocation_id: &str) -> Res } /// Expire matching invocations and erase response/error payloads, never identity, -/// fingerprints or keys. Returns the number of receipts newly tombstoned. Even a -/// subsequent backwards clock cannot resurrect an explicitly expired invocation. +/// fingerprints or keys. Returns the number of HTTPS receipts newly tombstoned; +/// extension-data payload expiry follows the same invocation flag atomically. +/// A backwards clock cannot resurrect an explicitly expired invocation. /// Tombstones survive until the parent's Workspace deletion cascade. pub fn prune(tx: &Transaction<'_>, workspace: &str, now_ms: i64) -> Result { if !valid_identity(workspace, 512) || now_ms < 0 { diff --git a/crates/workspace-server/src/plugin_receipt/tests.rs b/crates/workspace-server/src/plugin_receipt/tests.rs index ec1fdca50..3dda2eeae 100644 --- a/crates/workspace-server/src/plugin_receipt/tests.rs +++ b/crates/workspace-server/src/plugin_receipt/tests.rs @@ -140,6 +140,7 @@ fn make_plan( }], connections: vec![], requested_grants: vec![], + data_collections: vec![], config_schema: PluginSchemaContribution { identity: "schema".into(), version: "1".into(), diff --git a/crates/workspace-server/src/plugin_settings.rs b/crates/workspace-server/src/plugin_settings.rs index 13458b2df..88871d64b 100644 --- a/crates/workspace-server/src/plugin_settings.rs +++ b/crates/workspace-server/src/plugin_settings.rs @@ -46,6 +46,12 @@ pub struct PluginEnablement { pub artifact_digest: String, pub tools: Vec, pub logging: bool, + #[serde(default)] + pub ticket_read: bool, + #[serde(default)] + pub ticket_data_read: Vec, + #[serde(default)] + pub ticket_data_write: Vec, pub connections: BTreeMap, pub config: serde_json::Value, } @@ -164,7 +170,7 @@ pub(crate) fn evaluate_catalog_config( // Each alias has its own closed schema. Reusing an artifact with distinct // bindings is intentional and does not manufacture another schema authority. let enabled_fields = artifacts.iter().map(|(alias, artifact)| { - format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl) + format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; ticket_read = Bool default false; ticket_data_read = [...{{ collection = String; schema_id = String; schema_version = String; }}] default []; ticket_data_write = [...{{ collection = String; schema_id = String; schema_version = String; }}] default []; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl) }).collect::>().join("\n"); let enabled_schema = if enabled_fields.is_empty() { "{} default {}".to_string() @@ -238,6 +244,7 @@ fn validate_enablement( | manifest::plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD | manifest::plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD | manifest::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD + | "yoi:plugin/ticket-tool-plugin@2.1.0" ) || artifact.required_imports.iter().any(|i| { !matches!( @@ -246,6 +253,9 @@ fn validate_enablement( | "yoi:host/connections@2.0.0" | "yoi:host/http@2.0.0" | "yoi:host/logging@2.0.0" + | "yoi:host/objects@2.1.0" + | "yoi:host/ticket-read@2.1.0" + | "yoi:host/extension-data@2.1.0" ) }) { @@ -266,6 +276,39 @@ fn validate_enablement( { return Err(config_error("logging exceeds package request")); } + if enabled.ticket_read + && !artifact + .requested_grants + .contains(&PluginGrantMetadata::TicketRead) + { + return Err(config_error("Ticket read exceeds package request")); + } + for (write, grants) in [ + (false, &enabled.ticket_data_read), + (true, &enabled.ticket_data_write), + ] { + if grants.iter().collect::>().len() != grants.len() { + return Err(config_error("duplicate Ticket data grant")); + } + for grant in grants { + let metadata = if write { + PluginGrantMetadata::TicketDataWrite { + collection: grant.collection.clone(), + schema_id: grant.schema_id.clone(), + schema_version: grant.schema_version.clone(), + } + } else { + PluginGrantMetadata::TicketDataRead { + collection: grant.collection.clone(), + schema_id: grant.schema_id.clone(), + schema_version: grant.schema_version.clone(), + } + }; + if !artifact.requested_grants.contains(&metadata) { + return Err(config_error("Ticket data grant exceeds package request")); + } + } + } for (slot, binding) in &enabled.connections { let requested = artifact .connections @@ -390,6 +433,15 @@ fn project_profile_plugins( .iter() .any(|tool| !enabled.tools.contains(tool)) || (selection.logging && !enabled.logging) + || (selection.ticket_read && !enabled.ticket_read) + || selection + .ticket_data_read + .iter() + .any(|grant| !enabled.ticket_data_read.contains(grant)) + || selection + .ticket_data_write + .iter() + .any(|grant| !enabled.ticket_data_write.contains(grant)) || selection .https_slots .iter() @@ -412,6 +464,23 @@ fn project_profile_plugins( if selection.logging { grants.push(PluginGrant::Logging); } + if selection.ticket_read { + grants.push(PluginGrant::TicketRead); + } + for grant in &selection.ticket_data_read { + grants.push(PluginGrant::TicketDataRead { + collection: grant.collection.clone(), + schema_id: grant.schema_id.clone(), + schema_version: grant.schema_version.clone(), + }); + } + for grant in &selection.ticket_data_write { + grants.push(PluginGrant::TicketDataWrite { + collection: grant.collection.clone(), + schema_id: grant.schema_id.clone(), + schema_version: grant.schema_version.clone(), + }); + } let mut bindings = Vec::new(); for slot in &selection.https_slots { let binding_id = &enabled.connections[slot]; diff --git a/crates/workspace-server/src/schema_convergence_tests.rs b/crates/workspace-server/src/schema_convergence_tests.rs index 37a9827dc..dff59a3ab 100644 --- a/crates/workspace-server/src/schema_convergence_tests.rs +++ b/crates/workspace-server/src/schema_convergence_tests.rs @@ -120,6 +120,10 @@ mod schema_convergence { version: 110, name: "Worker Plugin launch selection associations".into(), }, + WorkspaceSchemaMigrationStep { + version: 111, + name: "Typed Plugin Ticket extension data and atomic mutation receipts".into(), + }, ] ); assert_eq!( @@ -141,6 +145,7 @@ mod schema_convergence { expected.push((108, "Subject Ticket responsibility and current-body notification routing".into())); expected.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into())); expected.push((110, "Worker Plugin launch selection associations".into())); + expected.push((111, "Typed Plugin Ticket extension data and atomic mutation receipts".into())); assert_eq!(workspace_schema_migration_history(conn)?, expected); for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] { assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}"); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 0b8faaa2f..6f7ab46a3 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -13806,6 +13806,33 @@ impl server_api::ServerApi for ServerApiContractService { plugin::uninstall(api, actor, workspace_id, installation_id, request).await } + async fn plugin_data_observe( + &self, + actor: RequestActor, + workspace_id: String, + installation_id: String, + ) -> std::result::Result { + plugin::data_observe(self.workspace_api()?, actor, workspace_id, installation_id).await + } + + async fn plugin_data_purge( + &self, + actor: RequestActor, + workspace_id: String, + installation_id: String, + request: server_api::PluginDataPurgeRequest, + ) -> std::result::Result + { + plugin::data_purge( + self.workspace_api()?, + actor, + workspace_id, + installation_id, + request, + ) + .await + } + async fn plugin_operation( &self, actor: RequestActor, diff --git a/crates/workspace-server/src/server_plugin.rs b/crates/workspace-server/src/server_plugin.rs index 594aa8bcf..ba7834db8 100644 --- a/crates/workspace-server/src/server_plugin.rs +++ b/crates/workspace-server/src/server_plugin.rs @@ -21,6 +21,8 @@ pub(super) fn router(service: Arc) -> Router { .merge(server_api_axum::plugin_artifact_add(service.clone())) .merge(server_api_axum::plugin_revoke(service.clone())) .merge(server_api_axum::plugin_uninstall(service.clone())) + .merge(server_api_axum::plugin_data_observe(service.clone())) + .merge(server_api_axum::plugin_data_purge(service.clone())) .merge(server_api_axum::plugin_operation(service)) .layer(middleware::from_fn(normalize_rejections)) } @@ -225,3 +227,85 @@ pub(super) async fn operation( .await .map_err(failure) } + +fn data_failure(error: Error) -> RepositoryApiError { + if let Error::PluginHost(error) = error { + let (status, code) = match error.code { + PluginHostErrorCode::NotFound => (404, "not_found"), + PluginHostErrorCode::Denied => (403, "access_denied"), + PluginHostErrorCode::Conflict => (409, "plugin_data_conflict"), + PluginHostErrorCode::InvalidInput => (400, "plugin_invalid"), + _ => (503, "plugin_data_outcome_unknown"), + }; + RepositoryApiError::new(status, code, "Plugin data operation", vec![]) + } else { + failure(error) + } +} +fn purge_state(state: crate::plugin_data::PurgeSnapshot) -> PluginDataPurgeState { + PluginDataPurgeState { + installation_last_operation: state.installation_last_operation, + cells_digest: state.cells_digest, + cell_count: state.cell_count, + } +} + +pub(super) async fn data_observe( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + installation: String, +) -> CatalogResult { + // Observing a purge confirmation is an administrator operation. It is only + // available after all execution artifacts have been irrevocably revoked. + authorize(api, &workspace, &actor, true).await?; + let store = api.config_store.clone(); + store + .db_executor() + .run(move || { + store.with_immediate_transaction(|tx| { + crate::plugin_data::purge_snapshot(tx, &workspace, &installation) + }) + }) + .await + .map(purge_state) + .map_err(data_failure) +} + +pub(super) async fn data_purge( + api: &WorkspaceApi, + actor: RequestActor, + workspace: String, + installation: String, + request: PluginDataPurgeRequest, +) -> CatalogResult { + // Authenticate again even when this will be a receipt replay. Management + // credentials are never borrowed by guest invocations or used to run them. + authorize(api, &workspace, &actor, true).await?; + let actor = actor_identity(&actor); + let store = api.config_store.clone(); + store + .db_executor() + .run(move || { + store.with_immediate_transaction(|tx| { + crate::plugin_data::purge( + tx, + &workspace, + &installation, + &actor, + &request.operation_key, + &crate::plugin_data::PurgeSnapshot { + installation_last_operation: request.expected.installation_last_operation, + cells_digest: request.expected.cells_digest, + cell_count: request.expected.cell_count, + }, + ) + }) + }) + .await + .map(|receipt| PluginDataPurgeResponse { + purge_id: receipt.purge_id, + observed: purge_state(receipt.observed), + }) + .map_err(data_failure) +} diff --git a/crates/workspace-server/src/server_plugin_data.rs b/crates/workspace-server/src/server_plugin_data.rs new file mode 100644 index 000000000..500813e2f --- /dev/null +++ b/crates/workspace-server/src/server_plugin_data.rs @@ -0,0 +1,331 @@ +//! Narrow Ticket owner adapter. Resource selectors never replace current +//! Subject/delegation authorization; values are projected explicitly and never +//! serialized from the general Ticket detail (which contains thread/admin data). +use super::*; +use crate::plugin_catalog::{PluginReference, PluginReferenceKind}; +use crate::plugin_data as store; +use server_api::authorization::{OperationAuthorizationRequest, PermissionResource}; + +fn not_found() -> PluginHostError { + policy::map_error(policy::failure(PluginHostErrorCode::NotFound)) +} +fn invalid() -> PluginHostError { + policy::map_error(policy::failure(PluginHostErrorCode::InvalidInput)) +} + +fn collection_granted( + admitted: &policy::AdmittedPluginOperation, + collection: &PluginDataCollection, + write: bool, +) -> bool { + admitted + .pin + .projection() + .grants + .iter() + .any(|grant| match grant { + crate::plugin_plan::PluginGrant::TicketDataRead { + collection: c, + schema_id, + schema_version, + } if !write => { + c == &collection.collection + && schema_id == &collection.schema_id + && schema_version == &collection.schema_version + } + crate::plugin_plan::PluginGrant::TicketDataWrite { + collection: c, + schema_id, + schema_version, + } if write => { + c == &collection.collection + && schema_id == &collection.schema_id + && schema_version == &collection.schema_version + } + _ => false, + }) +} + +fn access_granted(admitted: &policy::AdmittedPluginOperation, access: PluginTicketAccess) -> bool { + admitted + .pin + .projection() + .grants + .iter() + .any(|grant| match (access, grant) { + ( + PluginTicketAccess::Read, + crate::plugin_plan::PluginGrant::TicketRead + | crate::plugin_plan::PluginGrant::TicketDataRead { .. }, + ) => true, + ( + PluginTicketAccess::ExtensionWrite, + crate::plugin_plan::PluginGrant::TicketDataWrite { .. }, + ) => true, + _ => false, + }) +} + +fn authorize_ticket( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + reference: &str, + access: PluginTicketAccess, +) -> crate::Result { + let hidden = || policy::failure(PluginHostErrorCode::NotFound); + if reference.is_empty() || reference.len() > 512 || reference.chars().any(char::is_control) { + return Err(hidden()); + } + let worker = subject_authorization::source_from_context(context).map_err(|e| e.error)?; + let ticket_id = api + .store + .resolve_resource_reference(workspace, WorkspaceResourceKind::Ticket, reference)? + .ok_or_else(hidden)?; + subject_authorization::authorize( + api, + &worker, + &OperationAuthorizationRequest { + operation: match access { + PluginTicketAccess::Read => "ticket.read", + PluginTicketAccess::ExtensionWrite => "ticket.extension.write", + } + .into(), + resource: PermissionResource::Ticket { + workspace_id: workspace.into(), + ticket_id: ticket_id.clone(), + }, + }, + ) + .map_err(|e| match e.error { + Error::WorkspacePermissionDenied(_) => hidden(), + other => other, + })?; + Ok(ticket_id) +} + +fn read_ticket( + tx: &rusqlite::Transaction<'_>, + workspace: &str, + ticket: &str, +) -> crate::Result { + let (title, status, body): (String, String, String) = tx.query_row( + "SELECT t.title,t.status,e.body FROM typed_tickets t JOIN typed_ticket_events e ON e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id AND e.event_index=-1 WHERE t.workspace_id=?1 AND t.ticket_id=?2 AND EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=t.workspace_id AND state='active')", + rusqlite::params![workspace,ticket], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)), + ).optional()?.ok_or_else(|| policy::failure(PluginHostErrorCode::NotFound))?; + let content_digest = ticket::sqlite_ticket_content_digest(tx, workspace, ticket)? + .ok_or_else(|| policy::failure(PluginHostErrorCode::NotFound))?; + let truncated = body.len() > PLUGIN_TICKET_BODY_LIMIT; + let mut end = body.len().min(PLUGIN_TICKET_BODY_LIMIT); + while !body.is_char_boundary(end) { + end -= 1; + } + let case_state = match status.as_str() { + "open" => PluginTicketCaseState::Open, + "closed" => PluginTicketCaseState::Closed, + _ => return Err(policy::failure(PluginHostErrorCode::Unavailable)), + }; + Ok(PluginTicketSnapshot { + title, + case_state, + content_digest, + body: body[..end].into(), + truncated, + }) +} + +fn reference( + tx: &rusqlite::Transaction<'_>, + workspace: &str, + admitted: &policy::AdmittedPluginOperation, +) -> crate::Result { + let expected_last_operation = tx.query_row( + "SELECT last_operation FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2 AND uninstalled=0", + rusqlite::params![workspace,admitted.pin.installation_id()], |row| row.get(0), + ).optional()?.ok_or_else(|| policy::failure(PluginHostErrorCode::Denied))?; + Ok(PluginReference { + kind: PluginReferenceKind::Data, + owner: String::new(), + installation_id: admitted.pin.installation_id().into(), + expected_last_operation, + package_digest: admitted.pin.package_digest().into(), + artifact_digest: admitted.pin.artifact_digest().into(), + }) +} + +fn schema<'a>( + admitted: &'a policy::AdmittedPluginOperation, + collection: &PluginDataCollection, +) -> crate::Result<&'a PluginDataSchemaMetadata> { + admitted + .data_collections + .iter() + .find(|declaration| { + declaration.collection == collection.collection + && declaration.schema_id == collection.schema_id + && declaration.schema_version == collection.schema_version + }) + .ok_or_else(|| policy::failure(PluginHostErrorCode::Denied)) +} +fn validate_value( + declaration: &PluginDataSchemaMetadata, + mutation: &PluginDataMutation, +) -> crate::Result<()> { + if let PluginDataMutation::Put { value_json, .. } = mutation { + if value_json.len() > PLUGIN_DATA_VALUE_LIMIT { + return Err(policy::failure(PluginHostErrorCode::QuotaExceeded)); + } + let value: serde_json::Value = serde_json::from_str(value_json) + .map_err(|_| policy::failure(PluginHostErrorCode::InvalidInput))?; + manifest::plugin::schema::validate_data_value(&declaration.schema, &value) + .map_err(|_| policy::failure(PluginHostErrorCode::InvalidInput))?; + } + Ok(()) +} + +enum Action { + Resolve(PluginTicketAccess), + Read, + Get(PluginDataAddress), + Mutate(PluginDataAddress, String, PluginDataMutation), + List(PluginDataCollection, Option, u32), + Lookup(PluginDataAddress, String), +} +impl Action { + fn access(&self) -> PluginTicketAccess { + match self { + Self::Resolve(access) => *access, + Self::Mutate(..) | Self::Lookup(..) => PluginTicketAccess::ExtensionWrite, + _ => PluginTicketAccess::Read, + } + } + fn collection(&self) -> Option<&PluginDataCollection> { + match self { + Self::Get(a) | Self::Mutate(a, ..) | Self::Lookup(a, ..) => Some(&a.collection), + Self::List(c, ..) => Some(c), + _ => None, + } + } +} + +pub(super) async fn execute( + api: Arc, + context: ServerRequestContext, + workspace: String, + request: PluginHostRequest, +) -> std::result::Result { + let (invocation_id, selector, action) = match request { + PluginHostRequest::ResolveTicket { + invocation_id, + ticket_ref, + access, + } => (invocation_id, ticket_ref, Action::Resolve(access)), + PluginHostRequest::ReadTicket { + invocation_id, + ticket_id, + } => (invocation_id, ticket_id, Action::Read), + PluginHostRequest::DataGet { + invocation_id, + ticket_id, + address, + } => (invocation_id, ticket_id, Action::Get(address)), + PluginHostRequest::DataMutate { + invocation_id, + ticket_id, + address, + operation_key, + mutation, + } => ( + invocation_id, + ticket_id, + Action::Mutate(address, operation_key, mutation), + ), + PluginHostRequest::DataList { + invocation_id, + ticket_id, + collection, + cursor, + limit, + } => ( + invocation_id, + ticket_id, + Action::List(collection, cursor, limit), + ), + PluginHostRequest::DataLookupMutation { + invocation_id, + ticket_id, + address, + operation_key, + } => ( + invocation_id, + ticket_id, + Action::Lookup(address, operation_key), + ), + _ => return Err(invalid()), + }; + // Lookup after expiry must remain distinguishable from NotRecorded. It still + // goes through the exact same live owner checks and current Ticket authority. + let config = api.config_store.clone(); + let w = workspace.clone(); + let id = invocation_id.clone(); + let record = config + .db_executor() + .run(move || config.with_conn(|conn| receipt::lookup(conn, &w, &id))) + .await + .map_err(policy::map_error)?; + let admitted = check( + api.clone(), + context.clone(), + workspace.clone(), + record.plan_id.clone(), + record.installation_id.clone(), + record.consumer.clone(), + PluginHostOperation::Admit, + ) + .await?; + if record.execution != *admitted.plan.plan.execution() { + return Err(denied()); + } + if !access_granted(&admitted, action.access()) { + return Err(not_found()); + } + let config = api.config_store.clone(); + let executor = config.db_executor(); + executor.run(move || { + // Resolve aliases and the live Subject/delegation ceiling before any + // Ticket content or receipt is read. The DB transaction then fences + // catalog revoke, cancellation, parent deletion and competing writes. + let ticket_id = authorize_ticket(&api, &context, &workspace, &selector, action.access())?; + config.with_immediate_transaction(|tx| { + let record = receipt::verify_record(tx, &record)?; + let tools = match &record.consumer { PluginConsumer::ToolCall { tool_name, .. } => vec![tool_name.clone()], _ => return Err(policy::failure(PluginHostErrorCode::Unsupported)) }; + let admitted = policy::authorize(tx, &workspace, &record.execution, &record.plan_id, &record.installation_id, &tools, &PluginHostOperation::Admit)?; + if record.cancelled { return Err(policy::failure(PluginHostErrorCode::Cancelled)); } + if !matches!(action, Action::Lookup(..)) && (record.expired || record.expires_at_ms <= now_ms()) { return Err(policy::failure(PluginHostErrorCode::ReceiptExpired)); } + if !tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id) WHERE t.workspace_id=?1 AND t.ticket_id=?2 AND w.state='active')",rusqlite::params![workspace,ticket_id],|row|row.get::<_,bool>(0))? { return Err(policy::failure(PluginHostErrorCode::NotFound)); } + if let Some(collection) = action.collection() { + let write = matches!(action, Action::Mutate(..) | Action::Lookup(..)); + if !collection_granted(&admitted, collection, write) { return Err(policy::failure(PluginHostErrorCode::Denied)); } + schema(&admitted, collection)?; + } + match action { + Action::Resolve(_) => Ok(PluginHostResponse::TicketResolved { ticket_id }), + Action::Read => { + if !admitted.pin.projection().grants.contains(&crate::plugin_plan::PluginGrant::TicketRead) { return Err(policy::failure(PluginHostErrorCode::Denied)); } + Ok(PluginHostResponse::TicketRead { snapshot: read_ticket(tx, &workspace, &ticket_id)? }) + } + Action::Get(address) => Ok(PluginHostResponse::DataCell { cell: store::get(tx, &store::namespace(&record, &ticket_id, &address), &address.key)? }), + Action::Mutate(address, operation_key, mutation) => { + validate_value(schema(&admitted, &address.collection)?, &mutation)?; + let receipt = store::mutate(tx, &record, &store::namespace(&record, &ticket_id, &address), &address.key, &operation_key, &mutation, &reference(tx, &workspace, &admitted)?, now_ms())?; + Ok(PluginHostResponse::DataMutated { receipt }) + } + Action::List(collection, cursor, limit) => { + let namespace = store::Namespace { workspace_id: workspace, installation_id: record.installation_id, ticket_id, collection }; + Ok(PluginHostResponse::DataListed { page: store::list(tx, &namespace, cursor.as_deref(), limit, now_ms())? }) + } + Action::Lookup(address, operation_key) => Ok(PluginHostResponse::DataMutationLookup { outcome: store::lookup_mutation(tx, &record, &store::namespace(&record, &ticket_id, &address), &address.key, &operation_key, now_ms())? }), + } + }) + }).await.map_err(policy::map_error) +} diff --git a/crates/workspace-server/src/server_plugin_data_tests.rs b/crates/workspace-server/src/server_plugin_data_tests.rs new file mode 100644 index 000000000..44c39e698 --- /dev/null +++ b/crates/workspace-server/src/server_plugin_data_tests.rs @@ -0,0 +1,1899 @@ +//! Real Component/OwnerHost -> authenticated Remote or Embedded -> Ticket owner, +//! catalog, current Subject permissions and SQLite integration. The enclosing +//! suite supplies isolated DBs, signed Runtime proofs and RAII loopback servers; +//! only unrelated Worker execution is substituted. No live external service. +//! Run in the ordinary `yoi-workspace-server --lib plugin_host_tests` suite. +use super::*; +use server_api::{ + PluginDataAddress, PluginDataCell, PluginDataCollection, PluginDataExpectation, + PluginDataMutation, PluginDataMutationLookup, PluginDataReceipt, PluginTicketAccess, + PluginTicketCaseState, +}; +use yoi_plugin_runtime::host::{Host, TicketDataOperation, errors, objects}; + +fn collection(version: &str) -> PluginDataCollection { + PluginDataCollection { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: version.into(), + } +} + +fn address(key: &str) -> PluginDataAddress { + PluginDataAddress { + collection: collection("1"), + key: key.into(), + } +} + +fn configured_grants(read: bool, write: bool) -> Value { + json!({ + "ticket_read": read, + "ticket_data_read": if read { vec![collection("1"), collection("2")] } else { vec![] }, + "ticket_data_write": if write { vec![collection("1"), collection("2")] } else { vec![] }, + }) +} + +fn data_manifest() -> String { + use manifest::plugin::{PluginPackageManifest, PluginRequestedGrant}; + let mut manifest: PluginPackageManifest = toml::from_str(include_str!( + "../../plugin-runtime/tests/ticket-guest/plugin.toml" + )) + .unwrap(); + // Both namespaces are explicit declarations, not a migration/implicit grant. + let mut newer = manifest + .data_collections + .iter() + .find(|c| c.collection == "notes" && c.schema_id == "note" && c.schema_version == "1") + .unwrap() + .clone(); + newer.schema_version = "2".into(); + manifest.data_collections.push(newer); + manifest.requested_grants.extend([ + PluginRequestedGrant::TicketDataRead { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "2".into(), + }, + PluginRequestedGrant::TicketDataWrite { + collection: "notes".into(), + schema_id: "note".into(), + schema_version: "2".into(), + }, + ]); + toml::to_string(&manifest).unwrap() +} + +fn guest_component() -> &'static [u8] { + // The parent generates this tracked Component from the actual shared + // plugin-runtime/tests/ticket-guest Rust PDK source, using the encoding + // recipe in plugin-runtime/tests/ticket_component.rs. No second guest. + include_bytes!("../../plugin-pdk/tests/fixtures/ticket-tool-v2.component.wasm") +} + +async fn data_fixture(read: bool, write: bool) -> Fixture { + Fixture::with_package( + None, + data_manifest(), + guest_component(), + "ticket_test", + Some(configured_grants(read, write)), + ) + .await +} + +fn create_ticket(f: &Fixture, body: &str) -> ticket::Ticket { + create_ticket_with_slug(f, body, None) +} + +fn create_ticket_with_slug(f: &Fixture, body: &str, slug: Option<&str>) -> ticket::Ticket { + let backend = browser_ticket_backend(&f.api).unwrap(); + let mut input = ticket::NewTicket::new("Ordinary Ticket remains authoritative"); + input.slug = slug.map(str::to_owned); + input.body = ticket::MarkdownText::new(body); + let ticket = backend.create(input).unwrap(); + backend + .add_event( + ticket.id.clone().into(), + ticket::NewTicketEvent::new( + ticket::TicketEventKind::Comment, + "private thread must not enter snapshot", + ), + ) + .unwrap(); + backend.show(ticket.id.into()).unwrap() +} + +// Keep the real installation policy and change only concrete Ticket permissions. +// A cached resolve or receipt must not bypass these subsequent live changes. +fn ticket_policy(f: &Fixture, tickets: &[(&str, bool, bool)]) { + let store = open_subjektiv_store(&f.api).unwrap(); + let previous = store.subject_permissions(&f.subject).unwrap().unwrap(); + let mut execution: Vec<_> = previous + .execution + .into_iter() + .filter(|p| !matches!(p.resource, PermissionResource::Ticket { .. })) + .collect(); + for &(ticket, read, write) in tickets { + for (operation, granted) in [("ticket.read", read), ("ticket.extension.write", write)] { + if granted { + execution.push(ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Ticket { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: ticket.into(), + }, + }); + } + } + } + store + .set_subject_permissions( + &f.subject, + &SubjectPermissionsUpdate { + operation_id: Uuid::now_v7().to_string(), + expected_change_id: Some(previous.change_id), + execution, + delegation: vec![], + }, + ) + .unwrap(); +} + +async fn data_host(f: &Fixture, owner: Arc, call: &str) -> Arc { + host_for_plan(f, owner, &f.plan, &f.component, call).await +} + +async fn host_for_plan( + f: &Fixture, + owner: Arc, + plan: &PluginPlanCreateReceipt, + component: &CompiledTool, + call: &str, +) -> Arc { + use crate::plugin_plan::PluginGrant; + let pin = &plan.plan.plugins()[0]; + let mut context = f.context(); + context.plan = plan.plan_id.clone(); + context.installation = pin.installation_id().into(); + context.artifact = component.identity().clone(); + context.subject = plan.plan.execution().principal_id.clone(); + context.grants = Grants::default(); + for grant in &pin.projection().grants { + match grant { + PluginGrant::Logging => context.grants.logging = true, + PluginGrant::Https { slot } => { + context.grants.connection_slots.insert(slot.clone()); + } + PluginGrant::TicketRead => context.grants.ticket_read = true, + PluginGrant::TicketDataRead { + collection, + schema_id, + schema_version, + } => { + context.grants.ticket_data_read.insert(( + collection.clone(), + schema_id.clone(), + schema_version.clone(), + )); + } + PluginGrant::TicketDataWrite { + collection, + schema_id, + schema_version, + } => { + context.grants.ticket_data_write.insert(( + collection.clone(), + schema_id.clone(), + schema_version.clone(), + )); + } + } + } + Arc::new( + OwnerHost::begin(owner, context, f.consumer(call)) + .await + .unwrap(), + ) +} + +async fn resolve( + owner: &Arc, + invocation: &str, + ticket: &str, + access: PluginTicketAccess, +) -> String { + match owner + .call(PluginHostRequest::ResolveTicket { + invocation_id: invocation.into(), + ticket_ref: ticket.into(), + access, + }) + .await + .unwrap() + { + PluginHostResponse::TicketResolved { ticket_id } => ticket_id, + other => panic!("expected resolved Ticket, got {other:?}"), + } +} + +fn get(invocation: &str, ticket: &str, address: PluginDataAddress) -> PluginHostRequest { + PluginHostRequest::DataGet { + invocation_id: invocation.into(), + ticket_id: ticket.into(), + address, + } +} + +fn put( + invocation: &str, + ticket: &str, + address: PluginDataAddress, + key: &str, + expected: PluginDataExpectation, + text: &str, +) -> PluginHostRequest { + PluginHostRequest::DataMutate { + invocation_id: invocation.into(), + ticket_id: ticket.into(), + address, + operation_key: key.into(), + mutation: PluginDataMutation::Put { + expected, + value_json: json!(text).to_string(), + }, + } +} + +fn lookup( + invocation: &str, + ticket: &str, + address: PluginDataAddress, + key: &str, +) -> PluginHostRequest { + PluginHostRequest::DataLookupMutation { + invocation_id: invocation.into(), + ticket_id: ticket.into(), + address, + operation_key: key.into(), + } +} + +fn receipt(response: PluginHostResponse) -> PluginDataReceipt { + match response { + PluginHostResponse::DataMutated { receipt } => receipt, + other => panic!("expected mutation receipt, got {other:?}"), + } +} + +fn assert_ticket_unchanged(f: &Fixture, before: &ticket::Ticket) { + let after = browser_ticket_backend(&f.api) + .unwrap() + .show(before.meta.id.clone().into()) + .unwrap(); + assert_eq!(after.meta.title, before.meta.title); + assert_eq!(after.document.body, before.document.body); + assert_eq!(after.meta.workflow_state, before.meta.workflow_state); + assert_eq!( + ticket::ticket_content_digest(&after), + ticket::ticket_content_digest(before) + ); + assert_eq!( + after.events, before.events, + "extension data must not add ordinary thread events" + ); + assert_eq!(after.case_history, before.case_history); + assert_eq!(after.current_closure, before.current_closure); + assert_eq!(after.closure_operation_key, before.closure_operation_key); +} + +#[tokio::test] +async fn component_ticket_data_remote_and_embedded_preserve_ordinary_ticket() { + for use_remote in [false, true] { + let f = data_fixture(true, true).await; + // The shared real guest deliberately resolves this fixed selector. + // A real Ticket slug supplies it; the ordinary owner resolver still + // verifies current Subject permissions and returns its canonical ID. + let before = create_ticket_with_slug( + &f, + "Ordinary **body** is not extension data.", + Some("T-795"), + ); + let before = if use_remote { + browser_ticket_backend(&f.api) + .unwrap() + .update_case( + &before.meta.id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&before), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: before.closure_operation_key.clone(), + operation_key: "fixture-close".into(), + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: + "Retained ordinary case judgment must not change with extension data" + .into(), + references: vec![], + author: Some("fixture-owner".into()), + }, + ) + .unwrap() + } else { + before + }; + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let owner = if use_remote { + remote + } else { + f.owner(None).await + }; + let host = data_host(&f, owner.clone(), "component-data").await; + let invocation = host.context().invocation_id.clone(); + let result = f + .executor + .invoke( + &f.component, + host.context().clone(), + host, + f.tool, + r#""exercise""#, + "{}", + ) + .await + .unwrap(); + assert_eq!(result.output.summary, before.meta.title); + assert_eq!(result.output.structured_json.as_deref(), Some("null")); + // The shared guest puts/reads/lists/looks up/deletes/recreates and then + // replays its original put. Check persisted effects with another real + // adapter, not only guest assertions or an invented output envelope. + let embedded = f.owner(None).await; + let written = match embedded + .call(lookup(&invocation, &before.meta.id, address("main"), "put")) + .await + .unwrap() + { + PluginHostResponse::DataMutationLookup { + outcome: PluginDataMutationLookup::Committed(receipt), + } => receipt, + other => panic!("guest put receipt expected: {other:?}"), + }; + let recreated = match embedded + .call(lookup( + &invocation, + &before.meta.id, + address("main"), + "recreate", + )) + .await + .unwrap() + { + PluginHostResponse::DataMutationLookup { + outcome: PluginDataMutationLookup::Committed(receipt), + } => receipt, + other => panic!("guest recreate receipt expected: {other:?}"), + }; + assert_ne!(written.mutation_id, recreated.mutation_id); + assert_eq!( + embedded + .call(get(&invocation, &before.meta.id, address("main"))) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::Present { + value_json: r#""note""#.into(), + last_write: recreated.mutation_id, + } + } + ); + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; + } +} + +#[tokio::test] +async fn adapter_data_replay_precedes_cas_and_delete_tombstone_prevents_aba() { + let f = data_fixture(true, true).await; + let before = create_ticket(&f, "CAS does not edit Ticket text"); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let embedded = f.owner(None).await; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let host = data_host(&f, remote.clone(), "cas-replay").await; + let id = &host.context().invocation_id; + let ticket = resolve( + &remote, + id, + &before.meta.id, + PluginTicketAccess::ExtensionWrite, + ) + .await; + assert_eq!( + remote + .call(get(id, &ticket, address("cell"))) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::NeverWritten + } + ); + let first_request = put( + id, + &ticket, + address("cell"), + "create", + PluginDataExpectation::NeverWritten, + "first", + ); + let first = receipt(remote.call(first_request.clone()).await.unwrap()); + let second = receipt( + embedded + .call(put( + id, + &ticket, + address("cell"), + "replace", + PluginDataExpectation::LastWrite(first.mutation_id.clone()), + "second", + )) + .await + .unwrap(), + ); + assert_eq!( + receipt(embedded.call(first_request).await.unwrap()), + first, + "replay is not a fresh CAS" + ); + assert_eq!( + remote + .call(put( + id, + &ticket, + address("cell"), + "stale", + PluginDataExpectation::LastWrite(first.mutation_id.clone()), + "third" + )) + .await + .unwrap_err() + .code, + PluginHostErrorCode::Conflict + ); + assert_eq!( + remote + .call(put( + id, + &ticket, + address("cell"), + "create", + PluginDataExpectation::NeverWritten, + "changed input" + )) + .await + .unwrap_err() + .code, + PluginHostErrorCode::Conflict + ); + let deleted = receipt( + remote + .call(PluginHostRequest::DataMutate { + invocation_id: id.clone(), + ticket_id: ticket.clone(), + address: address("cell"), + operation_key: "delete".into(), + mutation: PluginDataMutation::Delete { + expected_last_write: second.mutation_id, + }, + }) + .await + .unwrap(), + ); + assert_eq!( + embedded + .call(get(id, &ticket, address("cell"))) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::Deleted { + last_delete: deleted.mutation_id.clone() + } + } + ); + assert_eq!( + remote + .call(put( + id, + &ticket, + address("cell"), + "never-again", + PluginDataExpectation::NeverWritten, + "recreated" + )) + .await + .unwrap_err() + .code, + PluginHostErrorCode::Conflict + ); + let recreated = receipt( + embedded + .call(put( + id, + &ticket, + address("cell"), + "recreate", + PluginDataExpectation::LastWrite(deleted.mutation_id.clone()), + "recreated", + )) + .await + .unwrap(), + ); + assert_ne!(recreated.mutation_id, deleted.mutation_id); + assert_eq!( + remote + .call(lookup(id, &ticket, address("cell"), "create")) + .await + .unwrap(), + PluginHostResponse::DataMutationLookup { + outcome: PluginDataMutationLookup::Committed(first) + } + ); + assert!( + matches!(remote.call(get(id, &ticket, address("cell"))).await.unwrap(), + PluginHostResponse::DataCell { cell: PluginDataCell::Present { last_write, .. } } if last_write == recreated.mutation_id) + ); + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn adapter_data_object_schema_namespaces_and_list_cursors_cannot_transfer() { + let f = data_fixture(true, true).await; + let a = create_ticket(&f, "Object A"); + let b = create_ticket(&f, "Object B"); + ticket_policy(&f, &[(&a.meta.id, true, true), (&b.meta.id, true, true)]); + let embedded = f.owner(None).await; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let host = data_host(&f, embedded.clone(), "namespace-cursors").await; + let id = &host.context().invocation_id; + for key in ["a", "b"] { + remote + .call(put( + id, + &a.meta.id, + address(key), + key, + PluginDataExpectation::NeverWritten, + key, + )) + .await + .unwrap(); + } + assert_eq!( + remote + .call(get(id, &b.meta.id, address("a"))) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::NeverWritten + } + ); + let mut newer = address("a"); + newer.collection.schema_version = "2".into(); + assert_eq!( + embedded + .call(get(id, &a.meta.id, newer.clone())) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::NeverWritten + } + ); + remote + .call(put( + id, + &a.meta.id, + newer, + "new-schema", + PluginDataExpectation::NeverWritten, + "version two", + )) + .await + .unwrap(); + assert!( + matches!(embedded.call(get(id, &a.meta.id, address("a"))).await.unwrap(), + PluginHostResponse::DataCell { cell: PluginDataCell::Present { value_json, .. } } if value_json == r#""a""#) + ); + let request = PluginHostRequest::DataList { + invocation_id: id.clone(), + ticket_id: a.meta.id.clone(), + collection: collection("1"), + cursor: None, + limit: 1, + }; + let PluginHostResponse::DataListed { page } = remote.call(request.clone()).await.unwrap() + else { + panic!("list expected") + }; + assert_eq!(page.entries.len(), 1); + assert_eq!(page.entries[0].key, "a"); + let cursor = page.next_cursor.unwrap(); + let mut continuation = request; + if let PluginHostRequest::DataList { cursor: value, .. } = &mut continuation { + *value = Some(cursor); + } + let PluginHostResponse::DataListed { page } = + embedded.call(continuation.clone()).await.unwrap() + else { + panic!("list expected") + }; + assert_eq!(page.entries[0].key, "b"); + for change_schema in [false, true] { + let mut foreign = continuation.clone(); + if let PluginHostRequest::DataList { + ticket_id, + collection, + .. + } = &mut foreign + { + if change_schema { + collection.schema_version = "2".into(); + } else { + *ticket_id = b.meta.id.clone(); + } + } + assert_eq!( + remote.call(foreign).await.unwrap_err().code, + PluginHostErrorCode::InvalidInput + ); + } + let mut undeclared = address("a"); + undeclared.collection.schema_id = "foreign.schema".into(); + assert_denied(embedded.call(get(id, &a.meta.id, undeclared)).await); + assert_eq!( + remote + .call(lookup(id, &b.meta.id, address("a"), "a")) + .await + .unwrap_err() + .code, + PluginHostErrorCode::Conflict, + "a receipt is not transferable to another object" + ); + assert_ticket_unchanged(&f, &a); + assert_ticket_unchanged(&f, &b); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn resolved_ticket_and_saved_data_receipt_recheck_live_permissions_and_revocation() { + let f = data_fixture(true, true).await; + let before = create_ticket(&f, "Live permission subject"); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let embedded = f.owner(None).await; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let host = data_host(&f, embedded.clone(), "live-data-policy").await; + let id = &host.context().invocation_id; + let binding = host + .resolve_ticket( + host.context(), + &before.meta.id, + objects::Access::ExtensionWrite, + ) + .await + .unwrap(); + let request = put( + id, + &before.meta.id, + address("retained"), + "write", + PluginDataExpectation::NeverWritten, + "retained", + ); + remote.call(request.clone()).await.unwrap(); + ticket_policy(&f, &[]); + for owner in [&embedded, &remote] { + for request in [ + PluginHostRequest::ReadTicket { + invocation_id: id.clone(), + ticket_id: before.meta.id.clone(), + }, + get(id, &before.meta.id, address("retained")), + request.clone(), + lookup(id, &before.meta.id, address("retained"), "write"), + ] { + assert_eq!( + owner.call(request).await.unwrap_err().code, + PluginHostErrorCode::NotFound + ); + } + } + assert_eq!( + host.read_ticket(host.context(), binding.as_ref()) + .await + .unwrap_err() + .code, + errors::Code::NotFound + ); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + assert!(matches!( + remote + .call(get(id, &before.meta.id, address("retained"))) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::Present { .. } + } + )); + f.revoke(); + for owner in [&embedded, &remote] { + assert_denied( + owner + .call(get(id, &before.meta.id, address("retained"))) + .await, + ); + assert_denied( + owner + .call(lookup(id, &before.meta.id, address("retained"), "write")) + .await, + ); + } + let retained: i64 = f.api.config_store.with_conn(|conn| Ok(conn.query_row( + "SELECT count(*) FROM plugin_data_cells WHERE workspace_id=?1 AND installation_id=?2", + rusqlite::params![TEST_WORKSPACE_ID, f.installed.installation.installation_id], |row| row.get(0), + )?)).unwrap(); + assert_eq!( + retained, 1, + "revoke fences execution without deleting extension data" + ); + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn ticket_handles_cannot_cross_owner_host_even_for_the_same_durable_invocation() { + let f = data_fixture(true, true).await; + let ticket = create_ticket(&f, "Opaque handle subject"); + ticket_policy(&f, &[(&ticket.meta.id, true, true)]); + let embedded = f.owner(None).await; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let first = data_host(&f, embedded, "same-call").await; + let second = data_host(&f, remote, "same-call").await; + assert_eq!( + first.context().invocation_id, + second.context().invocation_id + ); + let binding = first + .resolve_ticket(first.context(), &ticket.meta.id, objects::Access::Read) + .await + .unwrap(); + assert_eq!( + second + .read_ticket(second.context(), binding.as_ref()) + .await + .unwrap_err() + .code, + errors::Code::Denied + ); + let data = second + .ticket_data( + second.context(), + binding.as_ref(), + TicketDataOperation::Get(address("cell")), + ) + .await; + assert!(matches!(data, Err(error) if error.code == errors::Code::Denied)); + let read_only_write = first + .ticket_data( + first.context(), + binding.as_ref(), + TicketDataOperation::Mutate { + address: address("read-only-handle"), + operation_key: "write".into(), + mutation: PluginDataMutation::Put { + expected: PluginDataExpectation::NeverWritten, + value_json: r#"{"text":"not authorized by read handle"}"#.into(), + }, + }, + ) + .await; + assert!(matches!(read_only_write, Err(error) if error.code == errors::Code::Denied)); + assert!( + first + .read_ticket(first.context(), binding.as_ref()) + .await + .is_ok() + ); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn mutation_response_loss_recovers_from_reopened_store_and_expiry_is_not_absence() { + let f = data_fixture(true, true).await; + let before = create_ticket(&f, "Receipt recovery does not mutate the Ticket"); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let embedded = f.owner(None).await; + let host = data_host(&f, embedded.clone(), "lost-mutation-reply").await; + let id = &host.context().invocation_id; + let request = put( + id, + &before.meta.id, + address("durable"), + "lost-reply", + PluginDataExpectation::NeverWritten, + "committed", + ); + assert_eq!( + embedded + .call(lookup( + id, + &before.meta.id, + address("durable"), + "lost-reply" + )) + .await + .unwrap(), + PluginHostResponse::DataMutationLookup { + outcome: PluginDataMutationLookup::NotRecorded + } + ); + // The real router commits normally, but the generated client cannot accept + // its response body. No fake owner or alternate authentication is involved. + let (lossy, server) = + remote_owner_for_with_limit(&f, &f.source.worker_id, TEST_WORKSPACE_ID, 1).await; + let error = lossy.call(request.clone()).await.unwrap_err(); + assert_eq!(error.code, PluginHostErrorCode::Indeterminate); + assert_eq!( + error.effect, + server_api::PluginEffectObservation::Indeterminate + ); + assert_eq!( + lossy + .call(lookup( + id, + &before.meta.id, + address("durable"), + "lost-reply" + )) + .await + .unwrap_err() + .code, + PluginHostErrorCode::Unavailable, + "lookup transport failure must not become NotRecorded" + ); + let mut recovered_api = f.api.clone(); + recovered_api.config_store = + Arc::new(SqliteWorkspaceStore::open_for_test(&f.api.config.database_path).unwrap()); + let recovered: Arc = + Arc::new(plugin_host::EmbeddedPluginHostOwner::authenticated( + Arc::new(recovered_api.clone()), + TEST_WORKSPACE_ID.into(), + Arc::new(SignedAuthorizer { + signer: worker_runtime::auth::RuntimeRequestSourceSigner::from_identity( + &f.identity, + ), + worker_id: f.source.worker_id.clone(), + }), + )); + let restored = data_host(&f, recovered.clone(), "lost-mutation-reply").await; + assert_eq!(restored.context().invocation_id, *id); + let PluginHostResponse::DataMutationLookup { + outcome: PluginDataMutationLookup::Committed(saved), + } = recovered + .call(lookup( + id, + &before.meta.id, + address("durable"), + "lost-reply", + )) + .await + .unwrap() + else { + panic!("the lost response must still have a durable receipt") + }; + assert_eq!( + receipt(recovered.call(request.clone()).await.unwrap()), + saved + ); + assert!( + matches!(recovered.call(get(id, &before.meta.id, address("durable"))).await.unwrap(), + PluginHostResponse::DataCell { cell: PluginDataCell::Present { last_write, .. } } if last_write == saved.mutation_id) + ); + recovered_api + .config_store + .with_immediate_transaction(|tx| { + let record = crate::plugin_receipt::lookup(tx, TEST_WORKSPACE_ID, id)?; + crate::plugin_receipt::prune(tx, TEST_WORKSPACE_ID, record.expires_at_ms) + }) + .unwrap(); + for key in ["lost-reply", "never-recorded"] { + assert_eq!( + recovered + .call(lookup(id, &before.meta.id, address("durable"), key)) + .await + .unwrap(), + PluginHostResponse::DataMutationLookup { + outcome: PluginDataMutationLookup::Expired + } + ); + } + assert_eq!( + recovered.call(request).await.unwrap_err().code, + PluginHostErrorCode::ReceiptExpired + ); + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn data_read_and_write_require_independent_plan_and_live_ticket_permissions() { + for (plan_read, plan_write) in [(true, false), (false, true)] { + let f = data_fixture(plan_read, plan_write).await; + let before = create_ticket(&f, "Independent read and write ceilings"); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let embedded = f.owner(None).await; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let host = data_host(&f, embedded.clone(), "plan-ceiling").await; + let id = &host.context().invocation_id; + for owner in [&embedded, &remote] { + let read = owner + .call(get(id, &before.meta.id, address("permissions"))) + .await; + if plan_read { + assert!(read.is_ok()); + } else { + assert_eq!(read.unwrap_err().code, PluginHostErrorCode::NotFound); + } + let write = owner + .call(put( + id, + &before.meta.id, + address("permissions"), + "write", + PluginDataExpectation::NeverWritten, + "value", + )) + .await; + if plan_write { + assert!(write.is_ok()); + } else { + assert_eq!(write.unwrap_err().code, PluginHostErrorCode::NotFound); + } + } + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; + } + let f = data_fixture(true, true).await; + let before = create_ticket(&f, "Current Subject permissions are a second ceiling"); + let embedded = f.owner(None).await; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let host = data_host(&f, remote.clone(), "subject-ceiling").await; + let id = &host.context().invocation_id; + ticket_policy(&f, &[(&before.meta.id, true, false)]); + for owner in [&embedded, &remote] { + assert!( + owner + .call(get(id, &before.meta.id, address("permissions"))) + .await + .is_ok() + ); + assert_eq!( + owner + .call(put( + id, + &before.meta.id, + address("permissions"), + "write", + PluginDataExpectation::NeverWritten, + "value" + )) + .await + .unwrap_err() + .code, + PluginHostErrorCode::NotFound + ); + } + ticket_policy(&f, &[(&before.meta.id, false, true)]); + for owner in [&embedded, &remote] { + assert_eq!( + owner + .call(get(id, &before.meta.id, address("permissions"))) + .await + .unwrap_err() + .code, + PluginHostErrorCode::NotFound + ); + owner + .call(put( + id, + &before.meta.id, + address("permissions"), + "write", + PluginDataExpectation::NeverWritten, + "value", + )) + .await + .unwrap(); + } + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn object_value_rejected_by_declared_string_schema_does_not_save_cell_or_receipt() { + let f = data_fixture(true, true).await; + let before = create_ticket(&f, "Closed value schema"); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let host = data_host(&f, remote.clone(), "invalid-data-value").await; + let id = &host.context().invocation_id; + let mut invalid = put( + id, + &before.meta.id, + address("invalid"), + "invalid", + PluginDataExpectation::NeverWritten, + "value", + ); + if let PluginHostRequest::DataMutate { + mutation: PluginDataMutation::Put { value_json, .. }, + .. + } = &mut invalid + { + *value_json = json!({"text":"value", "undeclared":true}).to_string(); + } + assert_eq!( + remote.call(invalid).await.unwrap_err().code, + PluginHostErrorCode::InvalidInput + ); + assert_eq!( + remote + .call(get(id, &before.meta.id, address("invalid"))) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::NeverWritten + } + ); + assert_eq!( + remote + .call(lookup(id, &before.meta.id, address("invalid"), "invalid")) + .await + .unwrap(), + PluginHostResponse::DataMutationLookup { + outcome: PluginDataMutationLookup::NotRecorded + } + ); + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn same_artifact_in_second_installation_cannot_read_cells_or_reuse_list_cursor() { + use manifest::plugin::*; + let f = data_fixture(true, true).await; + let before = create_ticket(&f, "Installation scoped data"); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let embedded = f.owner(None).await; + let first = data_host(&f, embedded.clone(), "first-installation").await; + let id = &first.context().invocation_id; + for key in ["a", "b"] { + embedded + .call(put( + id, + &before.meta.id, + address(key), + key, + PluginDataExpectation::NeverWritten, + key, + )) + .await + .unwrap(); + } + let PluginHostResponse::DataListed { page } = embedded + .call(PluginHostRequest::DataList { + invocation_id: id.clone(), + ticket_id: before.meta.id.clone(), + collection: collection("1"), + cursor: None, + limit: 1, + }) + .await + .unwrap() + else { + panic!("list expected") + }; + let cursor = page.next_cursor.unwrap(); + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("plugin.toml"), data_manifest()).unwrap(); + fs::write(dir.path().join("plugin.component.wasm"), guest_component()).unwrap(); + let limits = PluginPackageLimits::default(); + let package = pack_plugin_package( + &read_plugin_directory(dir.path(), PluginSourceKind::Explicit, &limits).unwrap(), + &limits, + ) + .unwrap(); + let upload = f + .api + .config_store + .plugin_upload( + &ServerConfig::server_data_root_for_data_dir(&f.api.config.data_root) + .join("plugin-artifacts"), + TEST_WORKSPACE_ID, + "fixture-owner", + &package.bytes, + ) + .unwrap(); + let installed = f + .api + .config_store + .plugin_install( + TEST_WORKSPACE_ID, + "fixture-owner", + &server_api::PluginInstallRequest { + upload_id: upload.upload_id, + alias: "second-installation".into(), + operation_key: "second-install".into(), + }, + ) + .unwrap(); + assert_eq!( + installed.installation.artifacts[0].artifact_digest, + f.installed.installation.artifacts[0].artifact_digest + ); + commit_config_with_ticket_grants( + &f.api, + &installed, + f.tool, + None, + Some(&configured_grants(true, true)), + ); + let store = open_subjektiv_store(&f.api).unwrap(); + let mut permissions = store.subject_permissions(&f.subject).unwrap().unwrap(); + let more: Vec<_> = permissions + .execution + .iter() + .filter_map(|p| match &p.resource { + PermissionResource::PluginInstallation { workspace_id, .. } => Some(ScopedPermission { + operation: p.operation.clone(), + resource: PermissionResource::PluginInstallation { + workspace_id: workspace_id.clone(), + installation_id: installed.installation.installation_id.clone(), + }, + }), + _ => None, + }) + .collect(); + permissions.execution.extend(more); + store + .set_subject_permissions( + &f.subject, + &SubjectPermissionsUpdate { + operation_id: Uuid::now_v7().to_string(), + expected_change_id: Some(permissions.change_id), + execution: permissions.execution, + delegation: vec![], + }, + ) + .unwrap(); + let execution = f.api.plugin_execution_identity(&f.source).await.unwrap(); + let state = f + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let plan = f + .api + .config_store + .with_immediate_transaction(|tx| { + let plan = crate::plugin_settings::resolve_worker_plugin_plan( + tx, + TEST_WORKSPACE_ID, + &state.snapshot.digest, + PROFILE, + execution, + )?; + crate::plugin_plan::plugin_plan_create(tx, "fixture-host", "second-launch", &plan) + }) + .unwrap(); + f.api + .config_store + .plugin_plan_transition( + TEST_WORKSPACE_ID, + &plan.plan_id, + PlanPinState::Pending, + PlanPinState::Active, + ) + .unwrap(); + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let PluginHostResponse::Invocation { + invocation_id: second, + .. + } = remote + .call(PluginHostRequest::Begin { + plan_id: plan.plan_id, + installation_id: installed.installation.installation_id, + consumer: f.consumer("second-installation"), + }) + .await + .unwrap() + else { + panic!("invocation expected") + }; + for owner in [&embedded, &remote] { + assert_eq!( + owner + .call(get(&second, &before.meta.id, address("a"))) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::NeverWritten + } + ); + assert_eq!( + owner + .call(PluginHostRequest::DataList { + invocation_id: second.clone(), + ticket_id: before.meta.id.clone(), + collection: collection("1"), + cursor: Some(cursor.clone()), + limit: 1, + }) + .await + .unwrap_err() + .code, + PluginHostErrorCode::InvalidInput + ); + } + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; +} + +fn schema_artifact_manifest(version: &str) -> String { + use manifest::plugin::{PluginPackageManifest, PluginRequestedGrant}; + let mut manifest: PluginPackageManifest = toml::from_str(include_str!( + "../../plugin-runtime/tests/ticket-guest/plugin.toml" + )) + .unwrap(); + manifest.version = format!("{version}.0.0"); + for declaration in &mut manifest.data_collections { + declaration.schema_version = version.into(); + if version == "2" { + declaration.schema = json!({ + "type":"object", "properties":{"count":{"type":"integer"}}, + "required":["count"], "additionalProperties":false, + }); + } + } + for grant in &mut manifest.requested_grants { + if let PluginRequestedGrant::TicketDataRead { schema_version, .. } + | PluginRequestedGrant::TicketDataWrite { schema_version, .. } = grant + { + *schema_version = version.into(); + } + } + toml::to_string(&manifest).unwrap() +} + +fn schema_grants(version: &str) -> Value { + json!({"ticket_read":true, "ticket_data_read":[collection(version)], "ticket_data_write":[collection(version)]}) +} + +fn package_manifest(manifest: &str) -> manifest::plugin::CanonicalPluginArtifact { + use manifest::plugin::*; + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("plugin.toml"), manifest).unwrap(); + fs::write(dir.path().join("plugin.component.wasm"), guest_component()).unwrap(); + let limits = PluginPackageLimits::default(); + pack_plugin_package( + &read_plugin_directory(dir.path(), PluginSourceKind::Explicit, &limits).unwrap(), + &limits, + ) + .unwrap() +} + +async fn active_config_plan(f: &Fixture, operation: &str) -> PluginPlanCreateReceipt { + let execution = f.api.plugin_execution_identity(&f.source).await.unwrap(); + let state = f + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let plan = f + .api + .config_store + .with_immediate_transaction(|tx| { + let plan = crate::plugin_settings::resolve_worker_plugin_plan( + tx, + TEST_WORKSPACE_ID, + &state.snapshot.digest, + PROFILE, + execution, + )?; + crate::plugin_plan::plugin_plan_create(tx, "fixture-host", operation, &plan) + }) + .unwrap(); + f.api + .config_store + .plugin_plan_transition( + TEST_WORKSPACE_ID, + &plan.plan_id, + PlanPinState::Pending, + PlanPinState::Active, + ) + .unwrap(); + plan +} + +#[tokio::test] +async fn same_installation_schema_upgrade_and_original_pin_restore_keep_cells_separate() { + let f = Fixture::with_package( + None, + schema_artifact_manifest("1"), + guest_component(), + "ticket_test", + Some(schema_grants("1")), + ) + .await; + let before = create_ticket_with_slug( + &f, + "Schema rollback must retain original namespace", + Some("T-795"), + ); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let embedded = f.owner(None).await; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let old_host = data_host(&f, embedded.clone(), "original-pin-call").await; + let old_invocation = old_host.context().invocation_id.clone(); + let old_request = put( + &old_invocation, + &before.meta.id, + address("cycle"), + "original-write", + PluginDataExpectation::NeverWritten, + "original string", + ); + let original = receipt(embedded.call(old_request.clone()).await.unwrap()); + f.api + .config_store + .plugin_plan_transition( + TEST_WORKSPACE_ID, + &f.plan.plan_id, + PlanPinState::Active, + PlanPinState::Restorable, + ) + .unwrap(); + + // A second immutable archive and catalog artifact under the SAME installation, + // with a genuinely incompatible declared schema and an exact new plan pin. + // It uses the same actual Tool Component; no replacement/fake guest source. + let package = package_manifest(&schema_artifact_manifest("2")); + assert_ne!( + package.package_digest, + f.component.identity().package_digest + ); + let compiled = f + .executor + .compile( + &package.bytes, + &package.package_digest, + &package.artifact_digest, + package.artifact_size, + ) + .unwrap(); + let upload = f + .api + .config_store + .plugin_upload( + &ServerConfig::server_data_root_for_data_dir(&f.api.config.data_root) + .join("plugin-artifacts"), + TEST_WORKSPACE_ID, + "fixture-owner", + &package.bytes, + ) + .unwrap(); + let added = f + .api + .config_store + .plugin_add_artifact( + TEST_WORKSPACE_ID, + "fixture-owner", + &f.installed.installation.installation_id, + &server_api::PluginAddArtifactRequest { + upload_id: upload.upload_id, + expected_last_operation: f.installed.operation_id.clone(), + operation_key: "add-schema-two".into(), + }, + ) + .unwrap(); + assert_eq!( + added.installation.installation_id, + f.installed.installation.installation_id + ); + let artifact = added + .installation + .artifacts + .iter() + .find(|a| a.artifact_digest == package.artifact_digest) + .unwrap(); + commit_config_for_artifact( + &f.api, + &added, + artifact, + f.tool, + None, + Some(&schema_grants("2")), + ); + let upgraded = active_config_plan(&f, "schema-two-plan").await; + assert_ne!(upgraded.plan_id, f.plan.plan_id); + assert_eq!( + upgraded.plan.plugins()[0].artifact_digest(), + package.artifact_digest + ); + let new_host = host_for_plan(&f, remote.clone(), &upgraded, &compiled, "schema-two-call").await; + let new_invocation = new_host.context().invocation_id.clone(); + let mut new_address = address("cycle"); + new_address.collection = collection("2"); + assert_eq!( + remote + .call(get(&new_invocation, &before.meta.id, new_address.clone())) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::NeverWritten + } + ); + assert_denied( + remote + .call(get(&new_invocation, &before.meta.id, address("cycle"))) + .await, + ); + let newer = receipt( + remote + .call(PluginHostRequest::DataMutate { + invocation_id: new_invocation.clone(), + ticket_id: before.meta.id.clone(), + address: new_address.clone(), + operation_key: "new-write".into(), + mutation: PluginDataMutation::Put { + expected: PluginDataExpectation::NeverWritten, + value_json: json!({"count":2}).to_string(), + }, + }) + .await + .unwrap(), + ); + assert_eq!( + f.executor + .invoke( + &compiled, + new_host.context().clone(), + new_host, + f.tool, + r#""read""#, + "{}" + ) + .await + .unwrap() + .output + .summary, + before.meta.title + ); + + // Rollback chooses the original artifact in actual Workspace/Profile config, + // and restores the retained ORIGINAL plan rather than synthesizing a pin + // from the latest artifact or widening its schema grant set. + let original_artifact = &f.installed.installation.artifacts[0]; + commit_config_for_artifact( + &f.api, + &added, + original_artifact, + f.tool, + None, + Some(&schema_grants("1")), + ); + f.api + .config_store + .plugin_plan_transition( + TEST_WORKSPACE_ID, + &upgraded.plan_id, + PlanPinState::Active, + PlanPinState::Restorable, + ) + .unwrap(); + let restored_plan = f + .api + .config_store + .plugin_plan_transition( + TEST_WORKSPACE_ID, + &f.plan.plan_id, + PlanPinState::Restorable, + PlanPinState::Active, + ) + .unwrap(); + assert_eq!(restored_plan.plan, f.plan.plan); + assert_eq!(restored_plan.fingerprint, f.plan.fingerprint); + remote + .prepare( + f.plan.plan_id.clone(), + f.installed.installation.installation_id.clone(), + vec![f.tool.into()], + ) + .await + .unwrap(); + let restored = data_host(&f, remote.clone(), "original-pin-call").await; + assert_eq!(restored.context().invocation_id, old_invocation); + assert_eq!(restored.context().artifact, *f.component.identity()); + assert_eq!( + f.executor + .invoke( + &f.component, + restored.context().clone(), + restored, + f.tool, + r#""read""#, + "{}" + ) + .await + .unwrap() + .output + .summary, + before.meta.title + ); + assert_eq!(receipt(remote.call(old_request).await.unwrap()), original); + for owner in [&embedded, &remote] { + assert_eq!( + owner + .call(get(&old_invocation, &before.meta.id, address("cycle"))) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::Present { + value_json: r#""original string""#.into(), + last_write: original.mutation_id.clone() + } + } + ); + assert_denied( + owner + .call(get(&old_invocation, &before.meta.id, new_address.clone())) + .await, + ); + assert_eq!( + owner + .call(get(&new_invocation, &before.meta.id, new_address.clone())) + .await + .unwrap(), + PluginHostResponse::DataCell { + cell: PluginDataCell::Present { + value_json: json!({"count":2}).to_string(), + last_write: newer.mutation_id.clone() + } + } + ); + } + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn foreign_workspace_or_invisible_ticket_resolve_is_not_found() { + let f = data_fixture(true, true).await; + let local = create_ticket(&f, "Invisible local Ticket"); + let mut workspace = f + .api + .store + .get_workspace(TEST_WORKSPACE_ID) + .await + .unwrap() + .unwrap(); + workspace.workspace_id = "foreign-data-workspace".into(); + f.api.store.upsert_workspace(&workspace).await.unwrap(); + let foreign = ticket::SqliteTicketBackend::open_verified( + &f.api.config.database_path, + &workspace.workspace_id, + ) + .unwrap() + .create(ticket::NewTicket::new("Foreign Ticket")) + .unwrap(); + let embedded = f.owner(None).await; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let host = data_host(&f, embedded.clone(), "hidden-tickets").await; + let id = &host.context().invocation_id; + for owner in [&embedded, &remote] { + for reference in [ + local.meta.id.as_str(), + foreign.id.as_str(), + "missing-ticket", + ] { + assert_eq!( + owner + .call(PluginHostRequest::ResolveTicket { + invocation_id: id.clone(), + ticket_ref: reference.into(), + access: PluginTicketAccess::Read, + }) + .await + .unwrap_err() + .code, + PluginHostErrorCode::NotFound + ); + } + } + let (foreign_owner, foreign_server) = + remote_owner_for(&f, &f.source.worker_id, &workspace.workspace_id).await; + assert!( + foreign_owner + .call(get(id, &local.meta.id, address("a"))) + .await + .is_err() + ); + assert_ticket_unchanged(&f, &local); + drop(foreign_server); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn ticket_snapshot_is_utf8_bounded_and_omits_thread_fields() { + let f = data_fixture(true, false).await; + let body = format!( + "{}終端", + "あ".repeat(server_api::PLUGIN_TICKET_BODY_LIMIT / 3 + 1) + ); + let before = create_ticket(&f, &body); + ticket_policy(&f, &[(&before.meta.id, true, false)]); + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let host = data_host(&f, remote.clone(), "bounded-read").await; + let response = remote + .call(PluginHostRequest::ReadTicket { + invocation_id: host.context().invocation_id.clone(), + ticket_id: before.meta.id.clone(), + }) + .await + .unwrap(); + let PluginHostResponse::TicketRead { snapshot } = &response else { + panic!("snapshot expected") + }; + assert!(snapshot.truncated); + assert!(snapshot.body.len() <= server_api::PLUGIN_TICKET_BODY_LIMIT); + assert!(body.starts_with(&snapshot.body)); + assert_eq!(snapshot.case_state, PluginTicketCaseState::Open); + assert_eq!( + snapshot.content_digest, + ticket::ticket_content_digest(&before) + ); + let json = serde_json::to_value(response).unwrap(); + let fields = json["data"]["snapshot"].as_object().unwrap(); + assert_eq!(fields.len(), 5); + assert!(!json.to_string().contains("private thread")); + assert_ticket_unchanged(&f, &before); + drop(server); + f.shutdown().await; +} + +#[tokio::test] +async fn administrator_purge_routes_require_post_revoke_snapshot_exact_cas_and_live_authority() { + let f = data_fixture(true, true).await; + let before = create_ticket(&f, "Purge must not change this Ticket."); + ticket_policy(&f, &[(&before.meta.id, true, true)]); + let owner = f.owner(None).await; + let host = data_host(&f, owner.clone(), "admin-purge-cell").await; + owner + .call(put( + &host.context().invocation_id, + &before.meta.id, + address("admin"), + "admin-write", + PluginDataExpectation::NeverWritten, + "retained", + )) + .await + .unwrap(); + let manager = seed_test_workspace_owner_token(f.api.store.as_ref(), "data-manager").await; + let reader = seed_test_api_token(f.api.store.as_ref(), "data-reader"); + let app = build_router(f.api.clone()); + let prefix = format!( + "/api/w/{TEST_WORKSPACE_ID}/plugins/{}", + f.installed.installation.installation_id + ); + let observe = format!("{prefix}/data-state"); + let purge = format!("{prefix}/data-purge"); + use crate::server::tests::plugin_tests::request; + request( + app.clone(), + Some(&reader), + "GET", + &observe, + vec![], + false, + StatusCode::FORBIDDEN, + ) + .await; + request( + app.clone(), + Some(&manager), + "GET", + &observe, + vec![], + false, + StatusCode::CONFLICT, + ) + .await; + let revoke = serde_json::to_vec(&server_api::PluginRevokeRequest { + artifact_digest: f.installed.installation.artifacts[0] + .artifact_digest + .clone(), + expected_last_operation: f.installed.installation.last_operation.clone(), + reason: "Explicit data purge confirmation".into(), + operation_key: "revoke-before-observe".into(), + }) + .unwrap(); + request( + app.clone(), + Some(&manager), + "POST", + &format!("{prefix}/revoke"), + revoke, + false, + StatusCode::OK, + ) + .await; + let state = request( + app.clone(), + Some(&manager), + "GET", + &observe, + vec![], + false, + StatusCode::OK, + ) + .await; + assert_eq!(state["cell_count"], 1); + let mut wrong = state.clone(); + wrong["cells_digest"] = json!(format!("sha256:{}", "0".repeat(64))); + request( + app.clone(), + Some(&manager), + "POST", + &purge, + serde_json::to_vec(&json!({"operation_key":"purge", "expected":wrong})).unwrap(), + false, + StatusCode::CONFLICT, + ) + .await; + assert_eq!(f.count("plugin_data_cells"), 1); + let body = serde_json::to_vec(&json!({"operation_key":"purge", "expected":state})).unwrap(); + let receipt = request( + app.clone(), + Some(&manager), + "POST", + &purge, + body.clone(), + false, + StatusCode::OK, + ) + .await; + assert_eq!(receipt["observed"], state); + assert_eq!(f.count("plugin_data_cells"), 0); + assert_eq!(f.count("plugin_data_receipts"), 1); + assert_eq!(f.count("plugin_data_purges"), 1); + assert_eq!( + request( + app.clone(), + Some(&manager), + "POST", + &purge, + body.clone(), + false, + StatusCode::OK + ) + .await, + receipt + ); + request( + app, + Some(&reader), + "POST", + &purge, + body, + false, + StatusCode::FORBIDDEN, + ) + .await; + assert_ticket_unchanged(&f, &before); + f.shutdown().await; +} + +#[tokio::test] +async fn remote_maximum_escaped_value_page_fits_exported_response_budget_and_small_client_fails() { + let mut manifest: manifest::plugin::PluginPackageManifest = + toml::from_str(&data_manifest()).unwrap(); + for declaration in &mut manifest.data_collections { + declaration.schema = json!({"type":"string", "maxLength":65536}); + } + let f = Fixture::with_package( + None, + toml::to_string(&manifest).unwrap(), + guest_component(), + "ticket_test", + Some(configured_grants(true, true)), + ) + .await; + let ticket = create_ticket(&f, "Bounded page"); + ticket_policy(&f, &[(&ticket.meta.id, true, true)]); + let embedded = f.owner(None).await; + let host = data_host(&f, embedded.clone(), "escaped-page").await; + let invocation = &host.context().invocation_id; + // Each encoded value is exactly 64 KiB, with nearly every byte escaped + // again by the Remote envelope. The full 100-entry page remains bounded. + let text = "\n".repeat((server_api::PLUGIN_DATA_VALUE_LIMIT - 2) / 2); + assert_eq!( + json!(text).to_string().len(), + server_api::PLUGIN_DATA_VALUE_LIMIT + ); + for index in 0..server_api::PLUGIN_DATA_LIST_LIMIT { + let key = format!("{index:03}-{}", "k".repeat(252)); + embedded + .call(put( + invocation, + &ticket.meta.id, + address(&key), + &format!("page-{index}"), + PluginDataExpectation::NeverWritten, + &text, + )) + .await + .unwrap(); + } + let request = PluginHostRequest::DataList { + invocation_id: invocation.clone(), + ticket_id: ticket.meta.id.clone(), + collection: collection("1"), + cursor: None, + limit: server_api::PLUGIN_DATA_LIST_LIMIT, + }; + let (remote, server) = remote_owner_for(&f, &f.source.worker_id, TEST_WORKSPACE_ID).await; + let response = remote.call(request.clone()).await.unwrap(); + assert!( + serde_json::to_vec(&response).unwrap().len() + < yoi_plugin_runtime::owner::PLUGIN_HOST_RESPONSE_BODY_LIMIT + ); + let PluginHostResponse::DataListed { page } = response else { + panic!("page expected") + }; + assert_eq!( + page.entries.len(), + server_api::PLUGIN_DATA_LIST_LIMIT as usize + ); + assert!(page.next_cursor.is_none()); + for entry in page.entries { + assert!( + matches!(entry.cell, PluginDataCell::Present { value_json, .. } if value_json.len() == server_api::PLUGIN_DATA_VALUE_LIMIT) + ); + } + // A caller-selected smaller transport budget rejects the reply; no first + // page or NotRecorded result is synthesized on that transport failure. + let (small, small_server) = + remote_owner_for_with_limit(&f, &f.source.worker_id, TEST_WORKSPACE_ID, 1024).await; + assert_eq!( + small.call(request).await.unwrap_err().code, + PluginHostErrorCode::Unavailable + ); + drop(small_server); + drop(server); + assert_ticket_unchanged(&f, &ticket); + f.shutdown().await; +} diff --git a/crates/workspace-server/src/server_plugin_host.rs b/crates/workspace-server/src/server_plugin_host.rs index 759d05952..39f8b4d8b 100644 --- a/crates/workspace-server/src/server_plugin_host.rs +++ b/crates/workspace-server/src/server_plugin_host.rs @@ -5,6 +5,8 @@ use super::*; use crate::{plugin_host as policy, plugin_https as https, plugin_receipt as receipt}; use rusqlite::OptionalExtension; use server_api::*; +#[path = "server_plugin_data.rs"] +mod data; impl WorkspaceApi { /// Host plan creation after the canonical reservation and grant/Job binding, @@ -392,6 +394,14 @@ async fn execute_with_transport( // Human management and a Runtime-only (no Worker) source both fail closed. subject_authorization::source_from_context(&context).map_err(|_| denied())?; match request { + request @ (PluginHostRequest::ResolveTicket { .. } + | PluginHostRequest::ReadTicket { .. } + | PluginHostRequest::DataGet { .. } + | PluginHostRequest::DataMutate { .. } + | PluginHostRequest::DataList { .. } + | PluginHostRequest::DataLookupMutation { .. }) => { + data::execute(api, context, workspace, request).await + } PluginHostRequest::Prepare { plan_id, installation_id, diff --git a/crates/workspace-server/src/server_plugin_host_tests.rs b/crates/workspace-server/src/server_plugin_host_tests.rs index a9bd12736..8f797e616 100644 --- a/crates/workspace-server/src/server_plugin_host_tests.rs +++ b/crates/workspace-server/src/server_plugin_host_tests.rs @@ -4,6 +4,9 @@ // proof verification, current Subject permissions and all Host checks are real. // Run with `cargo test -p yoi-workspace-server --lib plugin_host_tests`. // The shared TLS fixture requires openssl; tests never mutate process environment. +#[path = "server_plugin_data_tests.rs"] +mod data; + use super::*; use crate::plugin_binding::PluginConnectionBinding; use crate::plugin_https::{ @@ -48,17 +51,6 @@ struct Fixture { impl Fixture { async fn new(binding: Option) -> Self { - use manifest::plugin::*; - let directory = tempfile::tempdir().unwrap(); - let mut api = test_api(directory.path()).await; - register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; - let runtime = WorkdirlessFixtureRuntime::default(); - api.runtime.register_or_replace(runtime.clone()); - let subject = open_subjektiv_store(&api) - .unwrap() - .create_subject(crate::subjektiv::SubjectRole::new("plugin-host-integration").unwrap()) - .unwrap() - .id; let https = binding.is_some(); let (manifest, component, tool) = if https { (include_str!("../../../resources/plugin/templates/rust-component-https/plugin.toml") @@ -73,6 +65,27 @@ impl Fixture { "example_echo", ) }; + Self::with_package(binding, manifest, component, tool, None).await + } + + async fn with_package( + binding: Option, + manifest: String, + component: &[u8], + tool: &'static str, + ticket_grants: Option, + ) -> Self { + use manifest::plugin::*; + let directory = tempfile::tempdir().unwrap(); + let mut api = test_api(directory.path()).await; + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let subject = open_subjektiv_store(&api) + .unwrap() + .create_subject(crate::subjektiv::SubjectRole::new("plugin-host-integration").unwrap()) + .unwrap() + .id; let package_dir = tempfile::tempdir().unwrap(); fs::write(package_dir.path().join("plugin.toml"), manifest).unwrap(); fs::write(package_dir.path().join("plugin.component.wasm"), component).unwrap(); @@ -105,7 +118,13 @@ impl Fixture { }, ) .unwrap(); - commit_config(&api, &installed, tool, binding.as_ref()); + commit_config_with_ticket_grants( + &api, + &installed, + tool, + binding.as_ref(), + ticket_grants.as_ref(), + ); let Json(created) = create_workspace_worker( State(api.clone()), HeaderMap::new(), @@ -207,6 +226,7 @@ impl Fixture { } else { Default::default() }, + ..Grants::default() }, deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(20), cancellation: CancellationToken::new(), @@ -312,7 +332,7 @@ impl Fixture { } fn count(&self, table: &str) -> i64 { - assert!(["plugin_invocations", "plugin_effect_receipts"].contains(&table)); + assert!(["plugin_invocations", "plugin_effect_receipts", "plugin_data_cells", "plugin_data_receipts", "plugin_data_purges"].contains(&table)); self.api .config_store .with_conn(|conn| { @@ -391,16 +411,49 @@ fn commit_config( tool: &str, binding: Option<&PluginConnectionBinding>, ) { - let artifact = &installed.installation.artifacts[0]; - let plugins = json!({"enabled":{"integration":{ + commit_config_with_ticket_grants(api, installed, tool, binding, None); +} + +fn commit_config_with_ticket_grants( + api: &WorkspaceApi, + installed: &server_api::PluginOperationResponse, + tool: &str, + binding: Option<&PluginConnectionBinding>, + ticket_grants: Option<&Value>, +) { + commit_config_for_artifact( + api, + installed, + &installed.installation.artifacts[0], + tool, + binding, + ticket_grants, + ); +} + +fn commit_config_for_artifact( + api: &WorkspaceApi, + installed: &server_api::PluginOperationResponse, + artifact: &server_api::PluginArtifact, + tool: &str, + binding: Option<&PluginConnectionBinding>, + ticket_grants: Option<&Value>, +) { + let mut plugins = json!({"enabled":{"integration":{ "installation_id":installed.installation.installation_id, "expected_last_operation":installed.operation_id, "package_digest":artifact.package_digest,"artifact_digest":artifact.artifact_digest, "tools":[tool],"logging":false,"config":{}, "connections":if binding.is_some() { json!({"api":"remote"}) } else { json!({}) } }},"bindings":if let Some(binding) = binding { json!({"remote":binding}) } else { json!({}) }}); - let selection = json!({"selections":[{"installation_id":installed.installation.installation_id, + let mut selection = json!({"selections":[{"installation_id":installed.installation.installation_id, "tools":[tool],"logging":false,"https_slots":if binding.is_some() { vec!["api"] } else { vec![] }}]}); + if let Some(grants) = ticket_grants { + for (key, value) in grants.as_object().unwrap() { + plugins["enabled"]["integration"][key] = value.clone(); + selection["selections"][0][key] = value.clone(); + } + } let content = format!( r#"{{ plugins = {}; @@ -1250,6 +1303,21 @@ async fn remote_owner_for( f: &Fixture, worker_id: &str, workspace: &str, +) -> (Arc, ServerTask) { + remote_owner_for_with_limit( + f, + worker_id, + workspace, + yoi_plugin_runtime::owner::PLUGIN_HOST_RESPONSE_BODY_LIMIT, + ) + .await +} + +async fn remote_owner_for_with_limit( + f: &Fixture, + worker_id: &str, + workspace: &str, + response_limit: usize, ) -> (Arc, ServerTask) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -1259,7 +1327,7 @@ async fn remote_owner_for( })); let client = server_api::ServerApiClient::builder(format!("http://{address}")) .unwrap() - .response_body_limit(yoi_plugin_runtime::owner::PLUGIN_HOST_RESPONSE_BODY_LIMIT) + .response_body_limit(response_limit) .client( reqwest::Client::builder() .no_proxy() diff --git a/crates/workspace-server/src/server_plugin_runtime_tests.rs b/crates/workspace-server/src/server_plugin_runtime_tests.rs index 127df8be4..ba2b21c30 100644 --- a/crates/workspace-server/src/server_plugin_runtime_tests.rs +++ b/crates/workspace-server/src/server_plugin_runtime_tests.rs @@ -311,6 +311,9 @@ pub(super) mod plugin_runtime_tests { .plugins .selections .push(manifest::PluginProfileSelection { + ticket_read: false, + ticket_data_read: vec![], + ticket_data_write: vec![], installation_id: f.installed.installation.installation_id.clone(), tools: vec![f.tool.into()], logging: false, @@ -834,6 +837,9 @@ pub(super) mod plugin_runtime_tests { .plugins .selections .push(manifest::PluginProfileSelection { + ticket_read: false, + ticket_data_read: vec![], + ticket_data_write: vec![], installation_id: f.installed.installation.installation_id.clone(), tools: vec![f.tool.into()], logging: false, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 63b15d791..3c10ef7dd 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -129,7 +129,7 @@ pub use ticket::{ }; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 110; +const LATEST_SCHEMA_VERSION: i64 = 111; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -487,6 +487,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Worker Plugin launch selection associations", apply: migrate_plugin_launches_v109_to_v110, }, + Migration { + version: 111, + name: "Typed Plugin Ticket extension data and atomic mutation receipts", + apply: migrate_plugin_data_v110_to_v111, + }, ]; // Both schema-103 identities shipped. Keep the alternate implementation and accept @@ -15897,6 +15902,49 @@ fn migrate_subject_assignments_v107_to_v108(conn: &Connection) -> Result<()> { Ok(()) } +fn verify_plugin_data_schema(conn: &Connection) -> Result<()> { + let expected = Connection::open_in_memory()?; + expected.execute_batch(crate::plugin_receipt::SCHEMA_SQL)?; + expected.execute_batch(crate::plugin_data::SCHEMA_SQL)?; + let mut query = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND (name LIKE 'plugin_data_%' OR name='plugin_effect_data_collision') ORDER BY name")?; + for item in query.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? { + let (name, sql) = item?; + let actual: Option = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [&name], + |row| row.get(0), + ) + .optional()?; + if actual.as_deref() != Some(&sql) { + return Err(Error::Store(format!( + "Plugin extension-data schema has drifted: {name}" + ))); + } + } + Ok(()) +} + +fn migrate_plugin_data_v110_to_v111(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 110 { + return Err(Error::Store( + "Plugin extension-data migration requires schema 110".into(), + )); + } + verify_schema_history(conn, 110)?; + let tx = conn.unchecked_transaction()?; + tx.execute_batch(crate::plugin_data::REFERENCE_MIGRATION_SQL)?; + tx.execute_batch(crate::plugin_data::SCHEMA_SQL)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(111,?1)", + ["Typed Plugin Ticket extension data and atomic mutation receipts"], + )?; + tx.commit()?; + Ok(()) +} + fn migrate_plugin_launches_v109_to_v110(conn: &Connection) -> Result<()> { if current_schema_version(conn)? != 109 { return Err(Error::Store( @@ -15975,6 +16023,8 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?; conn.execute_batch(crate::plugin_receipt::SCHEMA_SQL)?; conn.execute_batch(crate::plugin_plan::LAUNCH_SCHEMA_SQL)?; + conn.execute_batch(crate::plugin_data::REFERENCE_MIGRATION_SQL)?; + conn.execute_batch(crate::plugin_data::SCHEMA_SQL)?; Ok(()) } @@ -17015,7 +17065,8 @@ fn apply_migrations_with_secret_source( } verify_schema_history(conn, LATEST_SCHEMA_VERSION)?; - crate::plugin_catalog::verify(conn)?; + crate::plugin_catalog::verify_with_data(conn)?; + verify_plugin_data_schema(conn)?; crate::plugin_plan::verify_plan_schema(conn)?; crate::plugin_host::verify_receipt_schema(conn)?; crate::plugin_plan::verify_launch_schema(conn)?; @@ -19304,6 +19355,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx include!("ticket_post_upgrade_tests.rs"); include!("migration_entrypoint_tests.rs"); include!("plugin_host_migration_tests.rs"); + include!("plugin_data_migration_tests.rs"); include!("legacy_evidence_column_tests.rs"); #[test] @@ -19721,6 +19773,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx WorkspaceSchemaMigrationStep {version:108, name:"Subject Ticket responsibility and current-body notification routing".into()}, WorkspaceSchemaMigrationStep {version:109, name:"Durable Plugin Host invocations and HTTPS effect receipts".into()}, WorkspaceSchemaMigrationStep {version:110, name:"Worker Plugin launch selection associations".into()}, + WorkspaceSchemaMigrationStep {version:111, name:"Typed Plugin Ticket extension data and atomic mutation receipts".into()}, ] ); @@ -19841,6 +19894,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (108, "Subject Ticket responsibility and current-body notification routing".to_string()), (109, "Durable Plugin Host invocations and HTTPS effect receipts".to_string()), (110, "Worker Plugin launch selection associations".to_string()), +(111, "Typed Plugin Ticket extension data and atomic mutation receipts".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -23132,7 +23186,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (107, "Immutable Worker Plugin plans and retained pins".into()), (108, "Subject Ticket responsibility and current-body notification routing".into()), (109, "Durable Plugin Host invocations and HTTPS effect receipts".into()), - (110, "Worker Plugin launch selection associations".into())]); + (110, "Worker Plugin launch selection associations".into()), + (111, "Typed Plugin Ticket extension data and atomic mutation receipts".into())]); for (table, rows) in unchanged.iter().zip(before.iter()) { assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); } diff --git a/crates/workspace-server/src/subject_assignment_migration_tests.rs b/crates/workspace-server/src/subject_assignment_migration_tests.rs index ac933ebd4..6b9ad9209 100644 --- a/crates/workspace-server/src/subject_assignment_migration_tests.rs +++ b/crates/workspace-server/src/subject_assignment_migration_tests.rs @@ -1071,7 +1071,7 @@ mod subject_assignment_migration_tests { ); assert_eq!(history.len(), host.len() + expected.len()); assert_component14_append(conn, component); - crate::plugin_catalog::verify(conn).unwrap(); + crate::plugin_catalog::verify_with_data(conn).unwrap(); plugin_plan::verify_plan_schema(conn).unwrap(); verify(conn).unwrap(); assert_health(conn); @@ -1130,6 +1130,10 @@ mod subject_assignment_migration_tests { }; tail.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into())); tail.push((110, "Worker Plugin launch selection associations".into())); + tail.push(( + 111, + "Typed Plugin Ticket extension data and atomic mutation receipts".into(), + )); tail } @@ -1179,7 +1183,7 @@ mod subject_assignment_migration_tests { fn plugin_schema(conn: &Connection) -> Rows { fixture_query_rows( conn, - "SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema WHERE tbl_name LIKE 'plugin_%' AND tbl_name NOT IN ('plugin_invocations','plugin_effect_receipts') ORDER BY type,name", + "SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema WHERE tbl_name LIKE 'plugin_%' AND tbl_name NOT LIKE 'plugin_data_%' AND tbl_name NOT IN ('plugin_invocations','plugin_effect_receipts','plugin_references') ORDER BY type,name", ) } @@ -1189,6 +1193,9 @@ mod subject_assignment_migration_tests { let before = snapshot(&conn); let rows = data(&conn, true); let plans = plugin_schema(&conn); + // Migration111 intentionally rebuilds the reference guard to admit + // data owners; its complete row set must still survive unchanged. + let references = stored_fixture_rows(&conn, "plugin_references"); let contracts = principal_table_contracts(&conn); let host = host_history(&conn); let component = component_history(&conn); @@ -1205,6 +1212,8 @@ mod subject_assignment_migration_tests { assert_data(&conn, &rows); assert_final(&conn, 107, &host, &component); assert_eq!(plugin_schema(&conn), plans); + assert_eq!(stored_fixture_rows(&conn, "plugin_references"), references); + verify_plugin_data_schema(&conn).unwrap(); assert_eq!(principal_table_contracts(&conn), contracts); let final_snapshot = snapshot(&conn); drop(conn); diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 928e45fb4..713148673 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -16,6 +16,11 @@ use crate::{Error, Result}; /// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally /// excluded so retries and audit remain available after the Workspace row is gone. const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ + "plugin_data_receipts", + "plugin_data_purges", + "plugin_data_holders", + "plugin_data_cells", + "plugin_data_cursors", "plugin_effect_receipts", "plugin_invocations", "worker_plugin_launches", @@ -512,6 +517,8 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { // commit; update guards remain installed throughout. let mut ticket_delete_guards = Vec::new(); for (table, trigger) in [ + ("plugin_data_receipts", "plugin_data_receipt_retained"), + ("plugin_data_purges", "plugin_data_purge_retained"), ("plugin_effect_receipts", "plugin_effect_retained"), ("plugin_invocations", "plugin_invocation_retained"), ("worker_plugin_launches", "worker_plugin_launch_retained"), diff --git a/openapi/server-api.json b/openapi/server-api.json index bf731d084..2ba8d005b 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -7859,6 +7859,13 @@ }, "type": "array" }, + "data_collections": { + "default": [], + "items": { + "$ref": "#/components/schemas/PluginDataSchemaMetadata" + }, + "type": "array" + }, "description": { "type": [ "string", @@ -8008,6 +8015,369 @@ } ] }, + "PluginDataAddress": { + "additionalProperties": false, + "properties": { + "collection": { + "$ref": "#/components/schemas/PluginDataCollection" + }, + "key": { + "type": "string" + } + }, + "required": [ + "collection", + "key" + ], + "type": "object" + }, + "PluginDataCell": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "never_written", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "last_write": { + "type": "string" + }, + "value_json": { + "type": "string" + } + }, + "required": [ + "value_json", + "last_write" + ], + "type": "object" + }, + "kind": { + "const": "present", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "last_delete": { + "type": "string" + } + }, + "required": [ + "last_delete" + ], + "type": "object" + }, + "kind": { + "const": "deleted", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + } + ] + }, + "PluginDataCollection": { + "additionalProperties": false, + "properties": { + "collection": { + "type": "string" + }, + "schema_id": { + "type": "string" + }, + "schema_version": { + "type": "string" + } + }, + "required": [ + "collection", + "schema_id", + "schema_version" + ], + "type": "object" + }, + "PluginDataEntry": { + "additionalProperties": false, + "properties": { + "cell": { + "$ref": "#/components/schemas/PluginDataCell" + }, + "key": { + "type": "string" + } + }, + "required": [ + "key", + "cell" + ], + "type": "object" + }, + "PluginDataExpectation": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "never_written", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "type": "string" + }, + "kind": { + "const": "last_write", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + } + ] + }, + "PluginDataMutation": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "expected": { + "$ref": "#/components/schemas/PluginDataExpectation" + }, + "kind": { + "const": "put", + "type": "string" + }, + "value_json": { + "type": "string" + } + }, + "required": [ + "kind", + "expected", + "value_json" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expected_last_write": { + "type": "string" + }, + "kind": { + "const": "delete", + "type": "string" + } + }, + "required": [ + "kind", + "expected_last_write" + ], + "type": "object" + } + ] + }, + "PluginDataMutationLookup": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "not_recorded", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/PluginDataReceipt" + }, + "kind": { + "const": "committed", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "expired", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "PluginDataPage": { + "additionalProperties": false, + "properties": { + "entries": { + "items": { + "$ref": "#/components/schemas/PluginDataEntry" + }, + "type": "array" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "PluginDataPurgeRequest": { + "additionalProperties": false, + "properties": { + "expected": { + "$ref": "#/components/schemas/PluginDataPurgeState" + }, + "operation_key": { + "type": "string" + } + }, + "required": [ + "operation_key", + "expected" + ], + "type": "object" + }, + "PluginDataPurgeResponse": { + "additionalProperties": false, + "properties": { + "observed": { + "$ref": "#/components/schemas/PluginDataPurgeState" + }, + "purge_id": { + "type": "string" + } + }, + "required": [ + "purge_id", + "observed" + ], + "type": "object" + }, + "PluginDataPurgeState": { + "additionalProperties": false, + "properties": { + "cell_count": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "cells_digest": { + "type": "string" + }, + "installation_last_operation": { + "type": "string" + } + }, + "required": [ + "installation_last_operation", + "cells_digest", + "cell_count" + ], + "type": "object" + }, + "PluginDataReceipt": { + "additionalProperties": false, + "description": "A saved mutation result, not an observation of the current cell. The digest\ncovers the canonical resulting cell (including its final operation identity).", + "properties": { + "mutation_id": { + "type": "string" + }, + "result_digest": { + "type": "string" + } + }, + "required": [ + "mutation_id", + "result_digest" + ], + "type": "object" + }, + "PluginDataSchemaMetadata": { + "additionalProperties": false, + "properties": { + "collection": { + "type": "string" + }, + "schema": true, + "schema_id": { + "type": "string" + }, + "schema_version": { + "type": "string" + } + }, + "required": [ + "collection", + "schema_id", + "schema_version", + "schema" + ], + "type": "object" + }, "PluginDiagnosticAdmission": { "description": "A successful observation is not retained admission authority.", "oneOf": [ @@ -8172,6 +8542,69 @@ "slot" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "ticket_read", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "collection": { + "type": "string" + }, + "kind": { + "const": "ticket_data_read", + "type": "string" + }, + "schema_id": { + "type": "string" + }, + "schema_version": { + "type": "string" + } + }, + "required": [ + "kind", + "collection", + "schema_id", + "schema_version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "collection": { + "type": "string" + }, + "kind": { + "const": "ticket_data_write", + "type": "string" + }, + "schema_id": { + "type": "string" + }, + "schema_version": { + "type": "string" + } + }, + "required": [ + "kind", + "collection", + "schema_id", + "schema_version" + ], + "type": "object" } ] }, @@ -8468,6 +8901,176 @@ "entry" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/PluginTicketAccess" + }, + "invocation_id": { + "type": "string" + }, + "kind": { + "const": "resolve_ticket", + "type": "string" + }, + "ticket_ref": { + "type": "string" + } + }, + "required": [ + "kind", + "invocation_id", + "ticket_ref", + "access" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "invocation_id": { + "type": "string" + }, + "kind": { + "const": "read_ticket", + "type": "string" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "kind", + "invocation_id", + "ticket_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "address": { + "$ref": "#/components/schemas/PluginDataAddress" + }, + "invocation_id": { + "type": "string" + }, + "kind": { + "const": "data_get", + "type": "string" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "kind", + "invocation_id", + "ticket_id", + "address" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "address": { + "$ref": "#/components/schemas/PluginDataAddress" + }, + "invocation_id": { + "type": "string" + }, + "kind": { + "const": "data_mutate", + "type": "string" + }, + "mutation": { + "$ref": "#/components/schemas/PluginDataMutation" + }, + "operation_key": { + "type": "string" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "kind", + "invocation_id", + "ticket_id", + "address", + "operation_key", + "mutation" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "collection": { + "$ref": "#/components/schemas/PluginDataCollection" + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "invocation_id": { + "type": "string" + }, + "kind": { + "const": "data_list", + "type": "string" + }, + "limit": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "kind", + "invocation_id", + "ticket_id", + "collection", + "limit" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "address": { + "$ref": "#/components/schemas/PluginDataAddress" + }, + "invocation_id": { + "type": "string" + }, + "kind": { + "const": "data_lookup_mutation", + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "kind", + "invocation_id", + "ticket_id", + "address", + "operation_key" + ], + "type": "object" } ] }, @@ -8570,6 +9173,162 @@ "kind" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "ticket_id": { + "type": "string" + } + }, + "required": [ + "ticket_id" + ], + "type": "object" + }, + "kind": { + "const": "ticket_resolved", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "snapshot": { + "$ref": "#/components/schemas/PluginTicketSnapshot" + } + }, + "required": [ + "snapshot" + ], + "type": "object" + }, + "kind": { + "const": "ticket_read", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "cell": { + "$ref": "#/components/schemas/PluginDataCell" + } + }, + "required": [ + "cell" + ], + "type": "object" + }, + "kind": { + "const": "data_cell", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "receipt": { + "$ref": "#/components/schemas/PluginDataReceipt" + } + }, + "required": [ + "receipt" + ], + "type": "object" + }, + "kind": { + "const": "data_mutated", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "page": { + "$ref": "#/components/schemas/PluginDataPage" + } + }, + "required": [ + "page" + ], + "type": "object" + }, + "kind": { + "const": "data_listed", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "outcome": { + "$ref": "#/components/schemas/PluginDataMutationLookup" + } + }, + "required": [ + "outcome" + ], + "type": "object" + }, + "kind": { + "const": "data_mutation_lookup", + "type": "string" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" } ] }, @@ -9145,6 +9904,49 @@ ], "type": "object" }, + "PluginTicketAccess": { + "enum": [ + "read", + "extension_write" + ], + "type": "string" + }, + "PluginTicketCaseState": { + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "PluginTicketSnapshot": { + "additionalProperties": false, + "description": "Canonical Ticket identity is returned only after resolve authorizes it. It is\nstill a selector: every subsequent operation must reauthorize with the owner.", + "properties": { + "body": { + "type": "string" + }, + "case_state": { + "$ref": "#/components/schemas/PluginTicketCaseState" + }, + "content_digest": { + "type": "string" + }, + "title": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "title", + "case_state", + "content_digest", + "body", + "truncated" + ], + "type": "object" + }, "PluginToolMetadata": { "additionalProperties": false, "properties": { @@ -20664,7 +21466,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4" + "x-yoi-source-digest": "sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350" }, "openapi": "3.1.0", "paths": { @@ -31494,6 +32296,230 @@ ] } }, + "/api/w/{workspace_id}/plugins/{installation_id}/data-purge": { + "post": { + "operationId": "plugin_data_purge", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "installation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginDataPurgeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginDataPurgeResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/plugins/{installation_id}/data-state": { + "get": { + "operationId": "plugin_data_observe", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "installation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginDataPurgeState" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/plugins/{installation_id}/revoke": { "post": { "operationId": "plugin_revoke", diff --git a/resources/config-schema/plugins.dcdl b/resources/config-schema/plugins.dcdl index 5d1be44db..a4857ac63 100644 --- a/resources/config-schema/plugins.dcdl +++ b/resources/config-schema/plugins.dcdl @@ -6,6 +6,9 @@ let artifact_digest = String; tools = [...String]; logging = Bool default false; + ticket_read = Bool default false; + ticket_data_read = [...{ collection = String; schema_id = String; schema_version = String; }] default []; + ticket_data_write = [...{ collection = String; schema_id = String; schema_version = String; }] default []; connections = { ...String } default {}; config = { ...Unknown } default {}; }; diff --git a/resources/config-schema/profile-authoring.dcdl b/resources/config-schema/profile-authoring.dcdl index f6b3415b0..0c2de1e3c 100644 --- a/resources/config-schema/profile-authoring.dcdl +++ b/resources/config-schema/profile-authoring.dcdl @@ -79,6 +79,9 @@ let plugins = { selections = [...{ installation_id = String; tools = [...String]; logging = Bool; https_slots = [...String]; + ticket_read = Bool default false; + ticket_data_read = [...{ collection = String; schema_id = String; schema_version = String; }] default []; + ticket_data_write = [...{ collection = String; schema_id = String; schema_version = String; }] default []; # 非秘密のoverrideは補完用のshapeではなく、導入済みpackageの # closed typed schemaで検証する。 config = { ...Unknown }; diff --git a/resources/plugin/API.md b/resources/plugin/API.md index 6c1734386..52b851320 100644 --- a/resources/plugin/API.md +++ b/resources/plugin/API.md @@ -257,6 +257,8 @@ loggingはlevel、event code、安全なmessageとscalar fieldsに限定する ## 9. 付随データの契約 +Ticket拡張の機械的契約は[追加world](wit-ticket/yoi-plugin-v2.1.wit)と[resource imports](wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit)、`server-api::plugin_data`に置く。旧`@2.0.0` worldへのimport追加や保存pinへのgrant補完はしない。package保存formatのschema versionと、Component world/APIのexact versionは別の境界である。 + Server拡張はdomain objectに関連付けるPlugin所有のtyped dataとし、既存tableへの任意column追加では実現しない。物理保存schemaはHostが管理する。`feature-storage`のnative migration/SQLite handleは信頼済みHost用でありguestへ公開しない。 addressはHost contextのWorkspace/installationと、認可済みobject resource、宣言collection、schema id/version、keyから決まる。schema id/versionも物理・論理namespaceに含め、新旧artifactが同じcellを別schemaで読み書きしない。guestにWorkspace selectorやraw table名を与えない。最初のobject kindはTicketとし、Hostが存在・Workspace・callerのread/write権限を検証したTicket resourceだけを使う。一般objectへの拡張はadapterと権限を追加して行う。 @@ -265,7 +267,7 @@ addressはHost contextのWorkspace/installationと、認可済みobject resource `ticket-read.read(borrow)`はread権限を再検査し、title、case state、content digest、bounded bodyとtruncated flagだけを返す専用read APIとする。thread、添付、認証・管理情報、将来増えるfieldを汎用serializeで公開しない。上限は64 KiB、完全本文が必要なら本APIではunsupportedとする。eventの削除通知自体は安全なmetadataとして配送できるが、削除済みTicket resourceをresolve/readできる意味ではない。 -- packageはcollectionごとにschema id/versionとclosed typed schemaを宣言する。data valueのJSONはそのschemaで検証する。 +- packageはcollectionごとにschema id/versionとclosed typed schemaを宣言する。data valueのJSONはそのschemaで検証する。同installationの同collection/schema id/versionはrevoked artifactを含め同一schemaへ束縛し、artifact登録transactionで内容差を拒否する。exact artifactだけでは共有cellの意味を固定できないためである。value上限64 KiBはJSONのUTF-8表現(escapeを含む)のbyte数とし、listは最大100件、Remote envelopeの再escapeを含む有限response budgetを適用する。 - APIは`get`、`put(expected_last_write | never_written, operation_key, value)`、`delete(expected_last_write, operation_key)`、bounded `list(cursor, limit)`。任意query、SQL、cross-object transactionは公開しない。 - 同じcellへのput/deleteを確定した最終operation identityを、書込みtransaction内で観測値と比較する。CAS成功とoperation receiptを同じtransactionで保存し、自動読直しによるlast-write-winsへ変えない。同じ内容の再書込みや削除・再作成を時刻/内容hashだけで区別できないため、このoperationを比較する。 - deleteは最終delete operationのtombstoneを保持する。再作成はそのoperationを期待値にし、`never_written`を通さない。これによりABAを防ぐ。`get`は対象objectの認可成功後に限り、present(value, last-write)、never-written、deleted(last-delete)を区別する。不可視objectと不存在は同じnot-foundにする。 @@ -276,7 +278,7 @@ addressはHost contextのWorkspace/installationと、認可済みobject resource - 親object削除とPlugin data書込みはowner側の削除fenceで競合を解決する。削除後に新しい子dataを作らず、retention後にHostが回収する。 - schemaの破壊的変更は新しいschema version/namespaceとする。既存dataをguest起動時にmigrationしない。移行は新旧schemaを明示した認可付き操作として後続で設計し、初期APIでは自動変換しない。 - rollbackで旧artifactを使う場合も元schemaだけを読む。最新schemaへ読み替えない。 -- revoke/disableではdataを消さない。Workspace管理者の明示purgeをoperation keyと期待data-generationで行い、先に実行をrevoke/fenceする。purge receiptを残し、uninstallとの一操作への暗黙統合をしない。 +- revoke/disableではdataを消さない。Workspace管理者の明示purgeは先に全artifactをrevoke/fenceし、その後のtransactionで観測したinstallation最終catalog operationと、全cell(tombstone含む)のnamespace/key/最終write operation/deleted状態の順序付き集合digest・件数を期待値として比較する。新規writeだけでなく同一値の再書込みやdelete/recreateも区別し、汎用更新番号は導入しない。operation keyによるpurge receiptを残し、uninstallとの一操作への暗黙統合をしない。 ## 10. commit済みeventとService diff --git a/resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit b/resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit new file mode 100644 index 000000000..4099da1bd --- /dev/null +++ b/resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit @@ -0,0 +1,46 @@ +package yoi:host@2.1.0; + +/// Handles are invocation-local selectors, not durable authorization. +interface objects { + use yoi:host/errors@2.0.0.{host-error}; + enum access { read, extension-write } + resource ticket; + resolve-ticket: func(ticket-ref: string, requested: access) -> result; +} + +interface ticket-read { + use yoi:host/errors@2.0.0.{host-error}; + use objects.{ticket}; + enum case-state { open, closed } + record snapshot { + title: string, + state: case-state, + content-digest: string, + body: string, + truncated: bool, + } + read: func(target: borrow) -> result; +} + +interface extension-data { + use yoi:host/errors@2.0.0.{host-error}; + use objects.{ticket}; + record collection { + collection: string, + schema-id: string, + schema-version: string, + } + record address { namespace: collection, key: string } + variant expectation { never-written, last-write(string) } + record present-cell { value-json: string, last-write: string } + variant cell { never-written, present(present-cell), deleted(string) } + record receipt { mutation-id: string, result-digest: string } + variant mutation-lookup { not-recorded, committed(receipt), expired } + record entry { key: string, value: cell } + record page { entries: list, next-cursor: option } + get: func(target: borrow, cell-address: address) -> result; + put: func(target: borrow, cell-address: address, expected: expectation, operation-key: string, value-json: string) -> result; + delete: func(target: borrow, cell-address: address, expected-last-write: string, operation-key: string) -> result; + %list: func(target: borrow, namespace: collection, cursor: option, limit: u32) -> result; + lookup-mutation: func(target: borrow, cell-address: address, operation-key: string) -> result; +} diff --git a/resources/plugin/wit-ticket/deps/yoi-host-v2/yoi-host-v2.wit b/resources/plugin/wit-ticket/deps/yoi-host-v2/yoi-host-v2.wit new file mode 100644 index 000000000..c6f40d2c7 --- /dev/null +++ b/resources/plugin/wit-ticket/deps/yoi-host-v2/yoi-host-v2.wit @@ -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; +} + +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, + headers: list, + body: list, + timeout-ms: u32, + effect-key: string, + } + record response { + status: u16, + headers: list, + body: list, + } + /// Host authorizes each operation and does not automatically retry or follow redirects. + send: func(target: borrow, input: request) -> result; +} + +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, + } + /// Bounded diagnostic data, not model context. Never include secrets or raw payloads. + log: func(input: entry) -> result<_, host-error>; +} diff --git a/resources/plugin/wit-ticket/deps/yoi-plugin-v2/yoi-plugin-v2.wit b/resources/plugin/wit-ticket/deps/yoi-plugin-v2/yoi-plugin-v2.wit new file mode 100644 index 000000000..9c7f3b02d --- /dev/null +++ b/resources/plugin/wit-ticket/deps/yoi-plugin-v2/yoi-plugin-v2.wit @@ -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, + structured-json: option, + } + record tool-error { + code: string, + message: string, + } + invoke: func(input: invocation) -> result; +} + +/// 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; +} diff --git a/resources/plugin/wit-ticket/yoi-plugin-v2.1.wit b/resources/plugin/wit-ticket/yoi-plugin-v2.1.wit new file mode 100644 index 000000000..f3166b48a --- /dev/null +++ b/resources/plugin/wit-ticket/yoi-plugin-v2.1.wit @@ -0,0 +1,10 @@ +package yoi:plugin@2.1.0; + +/// Additive exact world: never augment an old @2.0.0 pin's imports or grants. +world ticket-tool-plugin { + include yoi:plugin/tool-plugin@2.0.0; + import yoi:host/errors@2.0.0; + import yoi:host/objects@2.1.0; + import yoi:host/ticket-read@2.1.0; + import yoi:host/extension-data@2.1.0; +} diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index dddbeaa92..09cf6a262 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4) +// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/plugin-api.ts b/web/workspace/src/lib/generated/plugin-api.ts index 0844960f8..e95ac5bb7 100644 --- a/web/workspace/src/lib/generated/plugin-api.ts +++ b/web/workspace/src/lib/generated/plugin-api.ts @@ -19,9 +19,17 @@ export type PluginHttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELE export type PluginConnectionMetadata = { name: string, methods: Array, path_prefixes: Array, }; -export type PluginGrantMetadata = { "kind": "logging" } | { "kind": "https", slot: string, }; +export type PluginGrantMetadata = { "kind": "logging" } | { "kind": "https", slot: string, } | { "kind": "ticket_read" } | { "kind": "ticket_data_read", collection: string, schema_id: string, schema_version: string, } | { "kind": "ticket_data_write", collection: string, schema_id: string, schema_version: string, }; -export type PluginArtifact = { package_id: string, name: string, version: string, description: string | null, package_digest: string, artifact_digest: string, artifact_size: number, schema_version: number, world: string, required_imports: Array, tools: Array, connections: Array, requested_grants: Array, config_schema: PluginSchemaContribution, revoked: boolean, revocation_reason: string | null, }; +export type PluginDataSchemaMetadata = { collection: string, schema_id: string, schema_version: string, schema: unknown, }; + +export type PluginDataPurgeState = { installation_last_operation: string, cells_digest: string, cell_count: number, }; + +export type PluginDataPurgeRequest = { operation_key: string, expected: PluginDataPurgeState, }; + +export type PluginDataPurgeResponse = { purge_id: string, observed: PluginDataPurgeState, }; + +export type PluginArtifact = { package_id: string, name: string, version: string, description: string | null, package_digest: string, artifact_digest: string, artifact_size: number, schema_version: number, world: string, required_imports: Array, tools: Array, connections: Array, requested_grants: Array, data_collections: Array, config_schema: PluginSchemaContribution, revoked: boolean, revocation_reason: string | null, }; export type PluginUploadResponse = { upload_id: string, expires_at: string, artifact: PluginArtifact, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index ff8ec037d..cbfda2061 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4) +// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index ad7a11ec9..22a7165fa 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4) +// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index fc7b39ffd..f664141f2 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4) +// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index ffea8a34c..13ad14fa1 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1053b09bb77ff0fc09fec2d3f96af04b76974c5ea52870f805381302e1b29fe4) +// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check