diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index a2d891b24..08bfe1dfc 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -4093,3 +4093,52 @@ mod tests { assert_eq!(diagnostics[0].kind, "cycle"); } } + +#[cfg(test)] +mod plugin_preflight_tests { + use super::*; + + #[test] + fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../resources/config-schema/fixtures/plugin-preflight.json" + )) + .unwrap(); + let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "plugin-preflight", + "plugins", + "1", + fixture["schema"].as_str().unwrap(), + ) + .unwrap()]) + .unwrap(); + let path = VirtualPath::parse("main.dcdl").unwrap(); + let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle); + for case in fixture["cases"].as_array().unwrap() { + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + path.clone(), + ConfigContentType::Decodal, + case["source"].as_str().unwrap(), + ) + .unwrap()]) + .unwrap(); + let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract); + match case.get("diagnostic") { + Some(kind) => { + let errors = result.unwrap_err(); + assert_eq!(errors[0].kind, kind.as_str().unwrap()); + assert_eq!(errors[0].path.as_str(), "main.dcdl"); + } + None => { + let evaluated = result.unwrap(); + assert_eq!( + evaluated.projections[0] + .data_json + .pointer("/plugins/enabled/echo/config/mode"), + Some(&case["mode"]) + ); + } + } + } + } +} diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 891f835e8..9a9f79624 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -58,6 +58,10 @@ pub struct WorkerManifestConfig { /// never starts a local MCP subprocess. #[serde(default)] pub mcp: McpConfig, + /// Omission retains the lower layer; an explicit section replaces the entire + /// selection list, including an empty list. Never unions Plugin grants. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, #[serde(default)] pub compaction: Option, /// First-class web tool opt-in. See [`WebConfig`]. @@ -795,11 +799,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er "unknown field in manifest: memory (removed; configure feature.memory)", )); } - if value.get("plugins").is_some() { - return Err(toml::de::Error::custom( - "unknown field in manifest: plugins (dynamic Plugins are not supported)", - )); - } if value .get("feature") .and_then(toml::Value::as_table) @@ -930,6 +929,7 @@ impl WorkerManifestConfig { ), feature: self.feature.merge(upper.feature), mcp: merge_mcp_config(self.mcp, upper.mcp), + plugins: upper.plugins.or(self.plugins), compaction: merge_option( self.compaction, upper.compaction, @@ -1445,6 +1445,7 @@ impl TryFrom for WorkerManifest { permissions, feature: FeatureConfig::from(cfg.feature), mcp: cfg.mcp, + plugins: cfg.plugins.unwrap_or_default(), compaction, web: cfg.web, skills: cfg.skills, @@ -1492,6 +1493,7 @@ mod tests { permissions: None, feature: FeatureConfigPartial::default(), mcp: McpConfig::default(), + plugins: None, session: None, compaction: None, web: None, diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 88c48473f..fc25a30df 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -7,6 +7,7 @@ mod model; pub mod model_catalog; pub mod paths; pub mod plugin; +mod plugin_profile; mod profile; mod scope; @@ -24,6 +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 profile::{ ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, @@ -80,6 +82,11 @@ pub struct WorkerManifest { /// consumer boundary. #[serde(default)] pub mcp: McpConfig, + /// Narrowing-only Workspace Plugin selections, not enablement or execution pins. + /// Empty selections are omitted to preserve existing no-Plugin snapshots and + /// their serialized fingerprint inputs. + #[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")] + pub plugins: PluginProfileConfig, #[serde(default)] pub compaction: Option, /// First-class web tools configuration. Network access remains fail-closed @@ -1116,6 +1123,7 @@ impl Default for CompactionConfig { impl WorkerManifest { pub fn requires_persisted_execution_snapshot(&self) -> bool { self.profile.is_some() + || !self.plugins.is_empty() || self.feature.memory.workspace_settings.is_some() || self.feature.subjektiv.workspace_settings.is_some() } @@ -1595,9 +1603,7 @@ model_id = "claude-sonnet-4-20250514" ); let error = WorkerManifest::from_toml(&toml).unwrap_err(); assert!( - error - .to_string() - .contains("dynamic Plugins are not supported"), + error.to_string().contains("unknown field `enabled`"), "unexpected error: {error}" ); } @@ -1617,7 +1623,7 @@ model_id = "claude-sonnet-4-20250514" }), ); let error = serde_json::from_value::(top_level).unwrap_err(); - assert!(error.to_string().contains("unknown field `plugins`")); + assert!(error.to_string().contains("unknown field `resolved`")); let mut nested = base; nested diff --git a/crates/manifest/src/plugin_profile.rs b/crates/manifest/src/plugin_profile.rs new file mode 100644 index 000000000..b17c99ba1 --- /dev/null +++ b/crates/manifest/src/plugin_profile.rs @@ -0,0 +1,269 @@ +//! Narrowing requests for Workspace-managed Plugin installations. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Profile input only, not Workspace enablement or a validated execution plan. +/// +/// The Workspace owner must resolve each installation against its enabled exact +/// artifact and reject selections outside its Tool/HTTPS/logging ceiling. Neither +/// these names nor config values grant authority, select package paths, or cause +/// discovery, dynamic registration, or guest execution. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginProfileConfig { + #[serde(default)] + pub selections: Vec, +} + +impl PluginProfileConfig { + pub fn is_empty(&self) -> bool { + self.selections.is_empty() + } +} + +/// One installation's explicit subset; omitted surfaces and capabilities are off. +/// +/// `tools` and `https_slots` are package-declared names, not public Tool names, +/// URLs, host paths, or connection bindings. `config` contains non-secret override +/// candidates only: the Workspace schema validator must reject them unless the +/// package's closed typed schema permits those overrides. Deserialization alone +/// cannot certify that arbitrary package-specific values are non-secret or valid. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginProfileSelection { + pub installation_id: String, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub logging: bool, + #[serde(default)] + pub https_slots: Vec, + #[serde(default)] + pub config: BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ProfileSource, WorkerManifest, WorkerManifestConfig, + read_persisted_worker_manifest_snapshot, resolve_profile_artifact_value, + write_persisted_worker_manifest_snapshot, + }; + use serde_json::{Value, json}; + use sha2::{Digest, Sha256}; + + fn partial(plugins: Value) -> WorkerManifestConfig { + serde_json::from_value(json!({ + "worker": { "name": "plugin-worker" }, + "plugins": plugins, + })) + .unwrap() + } + + fn resolve_profile(plugins: Value) -> crate::ResolvedProfile { + let dir = tempfile::tempdir().unwrap(); + resolve_profile_artifact_value( + json!({ "plugins": plugins }), + ProfileSource::Archive { + archive_id: "profile-input".into(), + source: "test".into(), + }, + dir.path(), + "plugin-worker", + ) + .unwrap() + } + + #[test] + fn omitted_plugin_selections_and_grants_default_off() { + let omitted = WorkerManifestConfig::from_toml("[worker]\nname = 'plain'\n").unwrap(); + assert!( + serde_json::to_value(&omitted) + .unwrap() + .get("plugins") + .is_none() + ); + let manifest = WorkerManifest::try_from(omitted).unwrap(); + assert!(manifest.plugins.selections.is_empty()); + assert!(!manifest.requires_persisted_execution_snapshot()); + assert!( + serde_json::to_value(manifest) + .unwrap() + .get("plugins") + .is_none() + ); + + let manifest = WorkerManifest::try_from(partial(json!({ + "selections": [{ "installation_id": "installation-a" }] + }))) + .unwrap(); + let selection = &manifest.plugins.selections[0]; + assert!(selection.tools.is_empty()); + assert!(!selection.logging); + assert!(selection.https_slots.is_empty()); + assert!(selection.config.is_empty()); + assert!(manifest.requires_persisted_execution_snapshot()); + } + + #[test] + fn plugin_partial_merge_replaces_instead_of_unioning_and_can_clear() { + let lower = partial(json!({ "selections": [{ + "installation_id": "installation-a", "tools": ["read", "write"], + "logging": true, "https_slots": ["remote"], "config": {"limit": 10} + }] })); + let omitted = lower.clone().merge(WorkerManifestConfig::default()); + assert_eq!(omitted.plugins, lower.plugins); + + let upper = partial(json!({ "selections": [{ + "installation_id": "installation-a", "tools": ["read"] + }] })); + let merged = lower.clone().merge(upper.clone()); + assert_eq!(merged.plugins, upper.plugins); + let round_trip: WorkerManifestConfig = + serde_json::from_value(serde_json::to_value(&merged).unwrap()).unwrap(); + assert_eq!(round_trip.plugins, merged.plugins); + + for empty in [json!({}), json!({ "selections": [] })] { + let cleared = lower.clone().merge(partial(empty)); + assert_eq!(cleared.plugins, Some(PluginProfileConfig::default())); + let manifest = WorkerManifest::try_from(cleared).unwrap(); + assert!(manifest.plugins.selections.is_empty()); + } + } + + #[test] + fn plugin_profile_toml_and_resolved_snapshots_preserve_typed_overrides() { + let dir = tempfile::tempdir().unwrap(); + let source = r#" +[[plugins.selections]] +installation_id = "installation-a" +tools = ["lookup"] +logging = true +https_slots = ["api"] +[plugins.selections.config] +limit = 5 +labels = ["one", "two"] +[plugins.selections.config.nested] +strict = true +"#; + let parsed = WorkerManifestConfig::from_toml(source).unwrap(); + let authored: toml::Value = toml::from_str(source).unwrap(); + let resolved = resolve_profile_artifact_value( + serde_json::to_value(authored).unwrap(), + ProfileSource::Archive { + archive_id: "typed-profile".into(), + source: "test".into(), + }, + dir.path(), + "plugin-worker", + ) + .unwrap(); + assert_eq!(Some(&resolved.manifest.plugins), parsed.plugins.as_ref()); + assert_eq!( + resolved.manifest.plugins.selections[0].config["nested"], + json!({"strict": true}) + ); + let snapshot = write_persisted_worker_manifest_snapshot(&resolved.manifest).unwrap(); + let restored = read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap(); + assert_eq!(restored.plugins, resolved.manifest.plugins); + assert_eq!( + write_persisted_worker_manifest_snapshot(&restored).unwrap(), + snapshot + ); + assert_eq!( + serde_json::to_value(restored).unwrap(), + resolved.manifest_snapshot + ); + let toml_round_trip = toml::to_string(&resolved.manifest).unwrap(); + assert_eq!( + WorkerManifest::from_toml(&toml_round_trip).unwrap().plugins, + resolved.manifest.plugins + ); + } + + #[test] + fn plugin_fields_change_serialized_profile_fingerprint_input() { + let baseline = json!({ "selections": [{ + "installation_id": "installation-a", "tools": ["read"], + "logging": false, "https_slots": [], "config": {"limit": 5} + }] }); + let fingerprint = |plugins| { + let resolved = resolve_profile(plugins); + Sha256::digest(serde_json::to_vec(&resolved.manifest_snapshot).unwrap()) + }; + let original = fingerprint(baseline.clone()); + assert_eq!(fingerprint(baseline.clone()), original); + for (field, value) in [ + ("installation_id", json!("installation-b")), + ("tools", json!(["write"])), + ("logging", json!(true)), + ("https_slots", json!(["api"])), + ("config", json!({"limit": 6})), + ] { + let mut changed = baseline.clone(); + changed["selections"][0][field] = value; + assert_ne!(fingerprint(changed), original, "{field} was lost"); + } + assert_eq!( + fingerprint(json!({})), + fingerprint(json!({"selections": []})) + ); + } + + #[test] + fn plugin_config_rejects_legacy_package_and_unknown_authority_fields() { + let dir = tempfile::tempdir().unwrap(); + let mut invalid = vec![ + json!({ "enabled": [{ "id": "project:legacy" }] }), + json!({ "packages": ["/tmp/legacy.yoi-plugin"] }), + json!({ "resolved": [{ "package_path": "/tmp/legacy.yoi-plugin" }] }), + json!({ "enable": true }), + ]; + for (field, value) in [ + ("package_path", json!("/tmp/legacy.yoi-plugin")), + ("path", json!("./plugin")), + ("url", json!("https://example.invalid/plugin")), + ("artifact_digest", json!("sha256:uninstalled")), + ("secret", json!("not-a-secret-reference")), + ("data", json!(true)), + ("events", json!(["changed"])), + ("websocket", json!(true)), + ("workdir", json!(true)), + ("unknown", json!(true)), + ] { + let mut selection = json!({ "installation_id": "installation-a" }); + selection[field] = value; + invalid.push(json!({ "selections": [selection] })); + } + invalid.extend([ + json!({ "selections": [{ "tools": ["read"] }] }), + json!({ "selections": [{ "installation_id": "a", "config": "raw" }] }), + ]); + for plugins in invalid { + let partial_input = json!({ "plugins": plugins.clone() }); + assert!( + serde_json::from_value::(partial_input.clone()).is_err(), + "{plugins}" + ); + let toml = toml::to_string(&partial_input).unwrap(); + assert!(WorkerManifestConfig::from_toml(&toml).is_err(), "{toml}"); + let error = resolve_profile_artifact_value( + partial_input, + ProfileSource::Archive { + archive_id: "invalid".into(), + source: "test".into(), + }, + dir.path(), + "plugin-worker", + ) + .unwrap_err(); + assert!( + error.to_string().contains("failed to decode Profile"), + "{error}" + ); + } + } +} diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 4afe730b8..e0ffcac08 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -652,6 +652,7 @@ fn resolve_profile_value( permissions: profile.permissions, feature: profile.feature, mcp: profile.mcp, + plugins: profile.plugins, compaction, web: profile.web, skills: profile.skills, @@ -700,6 +701,8 @@ struct ProfileConfig { #[serde(default)] mcp: McpConfig, #[serde(default)] + plugins: Option, + #[serde(default)] compaction: Option, #[serde(default)] web: Option, diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 5c95047bb..bfeb84abf 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1213,6 +1213,20 @@ pub trait ServerApi { #[path] workspace_id: String, ) -> Result; + #[post( + "/api/w/{workspace_id}/config/source-tree/preview", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_config_tree_preview( + &self, + #[path] workspace_id: String, + #[body] request: ConfigCommitRequest, + ) -> Result; + #[post( "/api/w/{workspace_id}/config/source-tree/commit", status = 201, @@ -12824,6 +12838,11 @@ mod tests { HttpMethod::Get, "/api/w/{workspace_id}/config/projections/prompts", ), + ( + "workspace_config_tree_preview", + HttpMethod::Post, + "/api/w/{workspace_id}/config/source-tree/preview", + ), ( "workspace_config_tree_commit", HttpMethod::Post, diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 8fde928ae..4041bb50c 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -1006,6 +1006,9 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi }), feature: manifest.feature.clone().into(), mcp: manifest.mcp.clone(), + // Reusable selection is not a plan or delegation authority. Backend launch + // must resolve it again within the child's own Workspace/delegation bound. + plugins: Some(manifest.plugins.clone()), compaction: manifest .compaction .as_ref() diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index 11187d859..cf4301180 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -510,6 +510,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip (106, crate::plugin_catalog::MIGRATION_NAME.into()), ( 107, + "Immutable Worker Plugin plans and retained pins".into() + ), + ( + 108, "Subject Ticket responsibility and current-body notification routing" .into() ) diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 4b82e93a9..b3de09449 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -206,7 +206,18 @@ impl SqliteWorkspaceStore { let state = match load_state(&tx, workspace_id)? { Some(state) => state, None => { - let state = initial_state_with_schema(schema_bundle.clone())?; + let mut state = initial_state_with_schema(schema_bundle.clone())?; + if has_plugin_provider(&schema_bundle) { + let (contract, evaluation, _) = + crate::plugin_settings::evaluate_catalog_config( + &tx, + workspace_id, + &state.snapshot, + schema_bundle.clone(), + )?; + state.contract = contract; + state.projection_digest = evaluation.projection_digest; + } insert_materialized_state(&tx, workspace_id, &state, materialized_at)?; state } @@ -215,6 +226,10 @@ impl SqliteWorkspaceStore { tx.commit()?; Ok((state, requires_toolchain_refresh, base_identity)) })?; + let desired_schema = crate::plugin_settings::materialization_schema( + desired_schema, + &state.contract.schema_bundle, + )?; let main_needs_normalization = state .snapshot .get(&main_config_path()) @@ -223,7 +238,17 @@ impl SqliteWorkspaceStore { || state.contract.schema_bundle.fingerprint != desired_schema.fingerprint || main_needs_normalization { - let candidate = evaluate_candidate(state, base_identity, &[], desired_schema)?; + let candidate = self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + evaluate_candidate_in_transaction( + &tx, + workspace_id, + state, + base_identity, + &[], + desired_schema, + ) + })?; return self.commit_evaluated_workspace_config(workspace_id, &candidate); } Ok(state) @@ -277,18 +302,23 @@ impl SqliteWorkspaceStore { request: &ConfigCommitRequest, schema_bundle: WorkspaceConfigSchemaBundle, ) -> Result { - let (current, base_identity) = self.with_conn(|conn| { + self.with_conn(|conn| { let tx = conn.unchecked_transaction()?; let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?; - let identity = load_evaluation_identity(&tx, workspace_id)?; - tx.commit()?; - Ok((current, identity)) - })?; - validate_entrypoint_request(&request.entrypoints)?; - if current.snapshot.digest != request.base_digest { - return Err(config_conflict("base content digest mismatch")); - } - evaluate_candidate(current, base_identity, &request.changes, schema_bundle) + let base_identity = load_evaluation_identity(&tx, workspace_id)?; + validate_entrypoint_request(&request.entrypoints)?; + if current.snapshot.digest != request.base_digest { + return Err(config_conflict("base content digest mismatch")); + } + evaluate_candidate_in_transaction( + &tx, + workspace_id, + current, + base_identity, + &request.changes, + schema_bundle, + ) + }) } pub fn evaluate_workspace_config_candidate( @@ -328,8 +358,28 @@ impl SqliteWorkspaceStore { { return Err(config_conflict("base evaluation identity mismatch")); } + if has_plugin_provider(¤t.contract.schema_bundle) + && !has_plugin_provider(&candidate.contract.schema_bundle) + { + return Err(config_conflict("Plugin schema authority cannot be removed")); + } let snapshot = candidate.snapshot.clone(); snapshot.validate().map_err(config_error)?; + if has_plugin_provider(&candidate.contract.schema_bundle) { + let (contract, evaluation, resolved) = + crate::plugin_settings::evaluate_catalog_config( + &tx, + workspace_id, + &snapshot, + candidate.contract.schema_bundle.clone(), + )?; + if contract != candidate.contract || evaluation != candidate.evaluation { + return Err(config_conflict( + "Plugin catalog/schema evaluation changed since Preview", + )); + } + crate::plugin_settings::sync_config_references(&tx, workspace_id, &resolved)?; + } let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); tx.execute( "DELETE FROM workspace_config_entries WHERE workspace_id = ?1", @@ -452,6 +502,40 @@ fn load_evaluation_identity( .map_err(Error::from) } +fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool { + bundle + .contributions + .iter() + .any(|provider| provider.provider_id == "builtin:plugins") +} + +fn evaluate_candidate_in_transaction( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + current: WorkspaceConfigState, + base_identity: (String, String), + changes: &[ConfigTreeChange], + schema_bundle: WorkspaceConfigSchemaBundle, +) -> Result { + if !has_plugin_provider(&schema_bundle) { + return evaluate_candidate(current, base_identity, changes, schema_bundle); + } + let mut providers = schema_bundle.contributions.clone(); + providers.retain(|provider| provider.provider_id != "builtin:plugins"); + providers.push(crate::plugin_settings::PluginConfigSchemaProvider.contribution()?); + let bootstrap = WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?; + let mut candidate = evaluate_candidate(current, base_identity, changes, bootstrap)?; + let (contract, evaluation, _) = crate::plugin_settings::evaluate_catalog_config( + tx, + workspace_id, + &candidate.snapshot, + schema_bundle, + )?; + candidate.contract = contract; + candidate.evaluation = evaluation; + Ok(candidate) +} + fn evaluate_candidate( current: WorkspaceConfigState, base_identity: (String, String), diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 71b93642d..56e87a595 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -12,7 +12,10 @@ pub mod hosts; pub mod memory_backend; pub mod memory_staging; pub mod observation; +pub mod plugin_binding; pub mod plugin_catalog; +pub mod plugin_plan; +pub mod plugin_settings; pub mod profile_settings; pub mod prompt_settings; pub mod records; diff --git a/crates/workspace-server/src/plugin_binding.rs b/crates/workspace-server/src/plugin_binding.rs new file mode 100644 index 000000000..cfd855e6e --- /dev/null +++ b/crates/workspace-server/src/plugin_binding.rs @@ -0,0 +1,371 @@ +//! Static Workspace HTTPS policy, not network admission. Runtime must recheck live +//! binding state, resolve/check every DNS address, pin the connection, verify TLS, +//! inject credentials, and enforce these bounds (including decompressed bytes). +//! Plans retain the binding identity and digest, never this destination policy. + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{Ipv4Addr, Ipv6Addr}; + +use serde::{Deserialize, Serialize}; +use server_api::PluginHttpMethod; +use sha2::{Digest, Sha256}; +use url::{Host, Url}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginNetworkPolicy { + /// DNS names are provisional until all resolved addresses pass live checks. + /// Private-network exceptions are deliberately not supported by this policy. + #[default] + PublicInternet, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginConnectionLimits { + pub timeout_ms: u32, + pub max_request_bytes: u32, + pub max_response_bytes: u32, + pub max_header_bytes: u32, +} + +/// Trusted server operational ceiling, supplied separately from Workspace input. +/// Deserializing a binding cannot set or expand this ceiling. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginConnectionPolicyCeiling { + pub limits: PluginConnectionLimits, +} + +impl Default for PluginConnectionPolicyCeiling { + fn default() -> Self { + Self { + limits: PluginConnectionLimits { + timeout_ms: 15_000, + max_request_bytes: 1024 * 1024, + max_response_bytes: 1024 * 1024, + max_header_bytes: 16 * 1024, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginConnectionBinding { + /// Canonical origin only: no trailing slash, path, query, credentials or fragment. + pub origin: String, + #[serde(default)] + pub network: PluginNetworkPolicy, + pub methods: Vec, + pub path_prefixes: Vec, + #[serde(default)] + pub request_headers: Vec, + #[serde(default)] + pub response_headers: Vec, + /// Empty means no credential. Otherwise an opaque Workspace-local secret ID; + /// never a value, token, provider URL, or filesystem reference. + #[serde(default)] + pub secret_ref: String, + pub limits: PluginConnectionLimits, +} + +/// Safe diagnostic categories contain no caller-supplied URLs, headers or secrets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PluginBindingError { + #[error("invalid plugin binding identity")] + InvalidIdentity, + #[error("plugin connection requires an exact canonical HTTPS origin")] + InvalidOrigin, + #[error("plugin connection destination is not public internet")] + NonPublicDestination, + #[error("plugin connection methods must be nonempty and unique")] + InvalidMethods, + #[error("plugin connection paths must be nonempty, unique canonical segment prefixes")] + InvalidPaths, + #[error("plugin connection header allowlist is invalid or contains a reserved header")] + InvalidHeaders, + #[error("plugin connection secret must be an opaque reference")] + InvalidSecretReference, + #[error("plugin connection limits must be positive and within the server ceiling")] + InvalidLimits, + #[error("too many plugin bindings")] + TooManyBindings, +} + +impl PluginConnectionBinding { + pub fn validate( + &self, + ceiling: &PluginConnectionPolicyCeiling, + ) -> Result<(), PluginBindingError> { + validate_origin(&self.origin)?; + if self.methods.is_empty() || self.methods.len() > 7 { + return Err(PluginBindingError::InvalidMethods); + } + let mut methods = BTreeSet::new(); + if self + .methods + .iter() + .any(|method| !methods.insert(method_name(method))) + { + return Err(PluginBindingError::InvalidMethods); + } + if self.path_prefixes.is_empty() + || self.path_prefixes.len() > 64 + || !unique_strings(&self.path_prefixes) + || self.path_prefixes.iter().any(|path| !canonical_path(path)) + { + return Err(PluginBindingError::InvalidPaths); + } + for headers in [&self.request_headers, &self.response_headers] { + if headers.len() > 64 + || !unique_strings(headers) + || headers.iter().any(|header| !allowed_header(header)) + { + return Err(PluginBindingError::InvalidHeaders); + } + } + if !self.secret_ref.is_empty() && !opaque_reference(&self.secret_ref) { + return Err(PluginBindingError::InvalidSecretReference); + } + for (value, maximum) in [ + (self.limits.timeout_ms, ceiling.limits.timeout_ms), + ( + self.limits.max_request_bytes, + ceiling.limits.max_request_bytes, + ), + ( + self.limits.max_response_bytes, + ceiling.limits.max_response_bytes, + ), + ( + self.limits.max_header_bytes, + ceiling.limits.max_header_bytes, + ), + ] { + if value == 0 || maximum == 0 || value > maximum { + return Err(PluginBindingError::InvalidLimits); + } + } + Ok(()) + } + + /// Versioned SHA-256 of policy content. Allowlists have set semantics, so their + /// order does not affect identity. Call `validate` before accepting a digest. + /// Binding map identity is separate and must also be pinned by the caller. + pub fn digest(&self) -> String { + let mut canonical = self.clone(); + canonical.methods.sort_by_key(method_name); + canonical.path_prefixes.sort(); + canonical.request_headers.sort(); + canonical.response_headers.sort(); + let bytes = serde_json::to_vec(&canonical).expect("binding contains only JSON primitives"); + let mut hash = Sha256::new(); + hash.update(b"yoi-plugin-connection-policy-v1\0"); + hash.update(bytes); + let hex: String = hash + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("sha256:{hex}") + } + + /// Static segment-boundary matching only; this is not runtime authorization. + /// Conservative paths exclude percent encodings to avoid decoder disagreement. + pub fn allows_path(&self, path: &str) -> bool { + self.path_prefixes + .iter() + .any(|prefix| path_within(prefix, path)) + } +} + +/// Validates the Workspace-owned map without copying any destination into a plan. +/// The map key is the binding identity; pin that key and `binding.digest()` only. +pub fn validate_bindings( + bindings: &BTreeMap, + ceiling: &PluginConnectionPolicyCeiling, +) -> Result<(), PluginBindingError> { + if bindings.len() > 256 { + return Err(PluginBindingError::TooManyBindings); + } + for (identity, binding) in bindings { + if !opaque_reference(identity) { + return Err(PluginBindingError::InvalidIdentity); + } + binding.validate(ceiling)?; + } + Ok(()) +} + +/// Conservative canonical path containment on segment boundaries, not a raw +/// string prefix. Runtime must use the same canonical path for checking/sending. +pub fn path_within(prefix: &str, path: &str) -> bool { + canonical_path(prefix) + && canonical_path(path) + && (prefix == "/" + || path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/'))) +} + +fn method_name(method: &PluginHttpMethod) -> &'static str { + match method { + PluginHttpMethod::Get => "GET", + PluginHttpMethod::Head => "HEAD", + PluginHttpMethod::Post => "POST", + PluginHttpMethod::Put => "PUT", + PluginHttpMethod::Patch => "PATCH", + PluginHttpMethod::Delete => "DELETE", + PluginHttpMethod::Options => "OPTIONS", + } +} + +fn opaque_reference(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) +} + +fn unique_strings(values: &[String]) -> bool { + values.iter().collect::>().len() == values.len() +} + +fn canonical_path(path: &str) -> bool { + if path == "/" { + return true; + } + path.len() <= 2048 + && path.starts_with('/') + && path.bytes().all(|b| { + b.is_ascii_alphanumeric() + || matches!( + b, + b'/' | b'-' + | b'_' + | b'.' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) + }) + && path[1..] + .split('/') + .all(|part| !matches!(part, "" | "." | "..")) +} + +fn allowed_header(header: &str) -> bool { + !header.is_empty() + && header.len() <= 128 + && header + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !header.starts_with("proxy-") + && !header.starts_with("sec-") + && !matches!( + header, + "authorization" + | "www-authenticate" + | "cookie" + | "set-cookie" + | "host" + | "connection" + | "keep-alive" + | "transfer-encoding" + | "te" + | "trailer" + | "upgrade" + | "content-length" + | "expect" + | "forwarded" + | "via" + | "x-forwarded-for" + | "x-forwarded-host" + | "x-forwarded-proto" + | "x-api-key" + | "api-key" + ) +} + +fn validate_origin(origin: &str) -> Result<(), PluginBindingError> { + if origin.len() > 512 || !origin.is_ascii() { + return Err(PluginBindingError::InvalidOrigin); + } + let url = Url::parse(origin).map_err(|_| PluginBindingError::InvalidOrigin)?; + if url.scheme() != "https" || url.origin().ascii_serialization() != origin { + return Err(PluginBindingError::InvalidOrigin); + } + let public = match url.host().ok_or(PluginBindingError::InvalidOrigin)? { + Host::Domain(host) => { + let labels: Vec<_> = host.split('.').collect(); + labels.len() >= 2 + && host.len() <= 253 + && labels.iter().all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }) + && ![ + "localhost", + "local", + "internal", + "home", + "lan", + "test", + "invalid", + "onion", + "arpa", + ] + .iter() + .any(|suffix| host == *suffix || host.ends_with(&format!(".{suffix}"))) + } + Host::Ipv4(ip) => public_ipv4(ip), + Host::Ipv6(ip) => public_ipv6(ip), + }; + if !public { + return Err(PluginBindingError::NonPublicDestination); + } + Ok(()) +} + +fn public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + // Conservative special-purpose exclusion; no Workspace-configurable bypass. + !matches!(a, 0 | 10 | 127 | 224..=255) + && !(a == 100 && (64..=127).contains(&b)) + && !(a == 169 && b == 254) + && !(a == 172 && (16..=31).contains(&b)) + && !(a == 192 && (b == 0 || b == 168 || (b == 88 && c == 99))) + && !(a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100))) + && !(a == 203 && b == 0 && c == 113) +} + +fn public_ipv6(ip: Ipv6Addr) -> bool { + let [a, b, ..] = ip.segments(); + // Only current global-unicast space; reject transition/special-purpose and + // documentation ranges as well as mapped IPv4, local, multicast and ULA. + (0x2000..=0x3fff).contains(&a) + && !(a == 0x2001 && (b <= 0x01ff || b == 0x0db8)) + && a != 0x2002 + && !(a == 0x3fff && b <= 0x0fff) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workspace-server/src/plugin_binding/tests.rs b/crates/workspace-server/src/plugin_binding/tests.rs new file mode 100644 index 000000000..adcaa0444 --- /dev/null +++ b/crates/workspace-server/src/plugin_binding/tests.rs @@ -0,0 +1,529 @@ +use super::*; +use serde_json::{Value, json}; + +fn input() -> Value { + json!({ + "origin": "https://api.example.com", + "methods": ["GET", "POST"], + "path_prefixes": ["/api", "/v2"], + "request_headers": ["accept", "content-type"], + "response_headers": ["content-type", "etag"], + "secret_ref": "secret-123", + "limits": { + "timeout_ms": 1000, + "max_request_bytes": 1024, + "max_response_bytes": 4096, + "max_header_bytes": 512 + } + }) +} + +fn binding(value: Value) -> PluginConnectionBinding { + serde_json::from_value(value).unwrap() +} + +#[test] +fn origin_input_table_requires_canonical_exact_https_and_public_destination() { + for (origin, expected) in [ + ("https://api.example.com", Ok(())), + ("https://api.example.com:8443", Ok(())), + ("https://8.8.8.8", Ok(())), + ("https://[2606:4700:4700::1111]", Ok(())), + ( + "http://api.example.com", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://api.example.com/", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://api.example.com/api", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://api.example.com?token=value", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://api.example.com#fragment", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://user:password@api.example.com", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://@api.example.com", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://api.example.com:443", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://API.example.com", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://api.example.com\\evil", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://api.example.com\n", + Err(PluginBindingError::InvalidOrigin), + ), + ( + "https://*.example.com", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://localhost", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://metadata.google.internal", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://service.local", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://example.com.", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://127.0.0.1", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://10.0.0.1", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://172.16.0.1", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://192.168.1.1", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://169.254.169.254", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://100.64.0.1", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://192.0.2.1", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://198.51.100.1", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://203.0.113.1", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://224.1.2.3", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://0.0.0.0", + Err(PluginBindingError::NonPublicDestination), + ), + ("https://2130706433", Err(PluginBindingError::InvalidOrigin)), + ("https://0177.0.0.1", Err(PluginBindingError::InvalidOrigin)), + ( + "https://[::1]", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://[::ffff:7f00:1]", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://[fd00::1]", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://[fe80::1]", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://[2001:db8::1]", + Err(PluginBindingError::NonPublicDestination), + ), + ( + "https://[2002:7f00:1::]", + Err(PluginBindingError::NonPublicDestination), + ), + ] { + let mut value = input(); + value["origin"] = json!(origin); + assert_eq!( + binding(value).validate(&PluginConnectionPolicyCeiling::default()), + expected, + "{origin}" + ); + } +} + +#[test] +fn path_input_table_rejects_traversal_and_preserves_segment_boundaries() { + for (path, accepted) in [ + ("/", true), + ("/api", true), + ("/api/v1", true), + ("api", false), + ("//evil.example", false), + ("/api/", false), + ("/api//v1", false), + ("/api/../admin", false), + ("/./api", false), + ("/api\\admin", false), + ("/api?x=1", false), + ("/api#x", false), + ("/api/%2e%2e/admin", false), + ("/api%2fadmin", false), + ("/api/%252e%252e", false), + ("/api/%5Cadmin", false), + ("https://evil.example/api", false), + ("/api/\n", false), + ] { + let mut value = input(); + value["path_prefixes"] = json!([path]); + assert_eq!( + binding(value) + .validate(&PluginConnectionPolicyCeiling::default()) + .is_ok(), + accepted, + "{path}" + ); + } + let value = binding(input()); + for (path, allowed) in [ + ("/api", true), + ("/api/v1", true), + ("/apix", false), + ("/admin", false), + ("/api/../admin", false), + ] { + assert_eq!(value.allows_path(path), allowed, "{path}"); + } + for paths in [json!([]), json!(["/api", "/api"])] { + let mut value = input(); + value["path_prefixes"] = paths; + assert_eq!( + binding(value).validate(&PluginConnectionPolicyCeiling::default()), + Err(PluginBindingError::InvalidPaths) + ); + } +} + +#[test] +fn header_input_table_rejects_guest_credentials_and_transport_controls() { + for field in ["request_headers", "response_headers"] { + for (name, accepted) in [ + ("accept", true), + ("content-type", true), + ("x-request-id", true), + ("authorization", false), + ("cookie", false), + ("set-cookie", false), + ("host", false), + ("proxy-authorization", false), + ("connection", false), + ("content-length", false), + ("transfer-encoding", false), + ("upgrade", false), + ("te", false), + ("trailer", false), + ("keep-alive", false), + ("sec-websocket-key", false), + ("x-api-key", false), + ("Content-Type", false), + ("x-a\r\n", false), + ("x-a: value", false), + ("", false), + ] { + let mut value = input(); + value[field] = json!([name]); + assert_eq!( + binding(value) + .validate(&PluginConnectionPolicyCeiling::default()) + .is_ok(), + accepted, + "{field}:{name}" + ); + } + let mut value = input(); + value[field] = json!(["accept", "accept"]); + assert_eq!( + binding(value).validate(&PluginConnectionPolicyCeiling::default()), + Err(PluginBindingError::InvalidHeaders) + ); + } +} + +#[test] +fn limits_input_table_requires_positive_finite_values_within_each_server_bound() { + let ceiling = PluginConnectionPolicyCeiling::default(); + for (field, max) in [ + ("timeout_ms", ceiling.limits.timeout_ms), + ("max_request_bytes", ceiling.limits.max_request_bytes), + ("max_response_bytes", ceiling.limits.max_response_bytes), + ("max_header_bytes", ceiling.limits.max_header_bytes), + ] { + for (amount, accepted) in [(0, false), (1, true), (max, true), (max + 1, false)] { + let mut value = input(); + value["limits"][field] = json!(amount); + assert_eq!( + binding(value).validate(&ceiling).is_ok(), + accepted, + "{field}:{amount}" + ); + } + for invalid in [ + json!(-1), + json!(1.5), + json!(4294967296u64), + json!("unlimited"), + Value::Null, + ] { + let mut value = input(); + value["limits"][field] = invalid; + assert!( + serde_json::from_value::(value).is_err(), + "{field}" + ); + } + } + let mut zero_ceiling = ceiling; + zero_ceiling.limits.timeout_ms = 0; + assert_eq!( + binding(input()).validate(&zero_ceiling), + Err(PluginBindingError::InvalidLimits) + ); +} + +#[test] +fn secret_and_identity_inputs_accept_only_opaque_workspace_references() { + for (reference, accepted) in [ + ("", true), + ("secret_123-a", true), + ("/etc/secret", false), + ("file:///etc/secret", false), + ("https://secret.example/token", false), + ("Bearer token", false), + ("env:API_KEY", false), + ("../secret", false), + ] { + let mut value = input(); + value["secret_ref"] = json!(reference); + assert_eq!( + binding(value) + .validate(&PluginConnectionPolicyCeiling::default()) + .is_ok(), + accepted, + "{reference}" + ); + } + for (identity, accepted) in [ + ("api-binding", true), + ("", false), + ("../binding", false), + ("https://example.com", false), + ] { + let bindings = BTreeMap::from([(identity.to_string(), binding(input()))]); + assert_eq!( + validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).is_ok(), + accepted + ); + } +} + +#[test] +fn json_boundary_is_closed_and_methods_network_are_not_ambient_authority() { + for (field, value) in [ + ("secret_value", json!("password")), + ("token", json!("token")), + ("proxy", json!("https://proxy.example")), + ("host_path", json!("/tmp")), + ("tls_verify", json!(false)), + ("allow_private", json!(true)), + ("network", json!("private")), + ("methods", json!(["CONNECT"])), + ] { + let mut source = input(); + source[field] = value; + assert!( + serde_json::from_value::(source).is_err(), + "{field}" + ); + } + for methods in [json!([]), json!(["GET", "GET"])] { + let mut source = input(); + source["methods"] = methods; + assert_eq!( + binding(source).validate(&PluginConnectionPolicyCeiling::default()), + Err(PluginBindingError::InvalidMethods) + ); + } + let mut source = input(); + source["limits"]["unlimited"] = json!(true); + assert!(serde_json::from_value::(source).is_err()); +} + +#[test] +fn policy_digest_is_order_independent_and_covers_every_policy_field() { + let original = binding(input()); + let mut reordered = original.clone(); + reordered.methods.reverse(); + reordered.path_prefixes.reverse(); + reordered.request_headers.reverse(); + reordered.response_headers.reverse(); + assert_eq!(original.digest(), reordered.digest()); + assert_eq!( + original.digest(), + "sha256:df6bd126eb6e825349a3c2e40f48f9d18732969a0775fe6f1b7f11b1667869c9" + ); + assert_eq!(original.digest().len(), 71); + assert!(original.digest().starts_with("sha256:")); + for (field, value) in [ + ("origin", json!("https://other.example.com")), + ("methods", json!(["GET"])), + ("path_prefixes", json!(["/v2"])), + ("request_headers", json!([])), + ("response_headers", json!([])), + ("secret_ref", json!("other-secret")), + ] { + let mut source = input(); + source[field] = value; + assert_ne!(original.digest(), binding(source).digest(), "{field}"); + } + for field in [ + "timeout_ms", + "max_request_bytes", + "max_response_bytes", + "max_header_bytes", + ] { + let mut source = input(); + source["limits"][field] = json!(2); + assert_ne!(original.digest(), binding(source).digest(), "{field}"); + } +} + +#[test] +fn policy_collections_and_references_have_finite_size_bounds() { + let ceiling = PluginConnectionPolicyCeiling::default(); + for (count, accepted) in [(64, true), (65, false)] { + for field in ["path_prefixes", "request_headers", "response_headers"] { + let mut source = input(); + source[field] = json!( + (0..count) + .map(|n| { + if field == "path_prefixes" { + format!("/path-{n}") + } else { + format!("x-header-{n}") + } + }) + .collect::>() + ); + assert_eq!( + binding(source).validate(&ceiling).is_ok(), + accepted, + "{field}:{count}" + ); + } + } + for (count, accepted) in [(256, true), (257, false)] { + let bindings = (0..count) + .map(|n| (format!("binding-{n}"), binding(input()))) + .collect(); + assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted); + } + for (size, accepted) in [(128, true), (129, false)] { + let mut source = input(); + source["secret_ref"] = json!("a".repeat(size)); + assert_eq!(binding(source).validate(&ceiling).is_ok(), accepted); + let bindings = BTreeMap::from([("a".repeat(size), binding(input()))]); + assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted); + } +} + +fn evaluate_schema(source: &str) -> Result { + use config_source::{ + ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeSnapshot, + SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle, + }; + let path = VirtualPath::parse("main.dcdl").unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + path.clone(), + ConfigContentType::Decodal, + source, + ) + .unwrap()]) + .unwrap(); + let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:plugins", + "plugins", + "1", + include_str!("../../../../resources/config-schema/plugins.dcdl"), + ) + .unwrap()]) + .unwrap(); + let result = SnapshotEnvironment::new(snapshot).evaluate_contract( + &ToolchainContract::with_schema_bundle(1, vec![path], 1, bundle), + ); + result + .map(|evaluated| evaluated.projections[0].data_json.clone()) + .map_err(|error| format!("{error:?}")) +} + +#[test] +fn bootstrap_schema_defaults_maps_and_projects_typed_binding_to_rust() { + assert_eq!( + evaluate_schema("{}").unwrap(), + json!({"plugins": {"enabled": {}, "bindings": {}}}) + ); + let projected = evaluate_schema(r#"{ + plugins = { + enabled = { demo = { + installation_id = "plugin-1"; expected_last_operation = "op-1"; + package_digest = "package"; artifact_digest = "artifact"; tools = ["query"]; + }; }; + bindings = { api = { + origin = "https://api.example.com"; methods = ["GET"]; path_prefixes = ["/api"]; + limits = { timeout_ms = 1000; max_request_bytes = 1024; max_response_bytes = 4096; max_header_bytes = 512; }; + }; }; + }; + }"#).unwrap(); + assert_eq!(projected["plugins"]["enabled"]["demo"]["logging"], false); + assert_eq!( + projected["plugins"]["enabled"]["demo"]["connections"], + json!({}) + ); + assert_eq!(projected["plugins"]["enabled"]["demo"]["config"], json!({})); + let bindings: BTreeMap = + serde_json::from_value(projected["plugins"]["bindings"].clone()).unwrap(); + validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).unwrap(); +} + +#[test] +fn bootstrap_schema_rejects_unknown_fields_and_wrong_binding_shapes() { + for source in [ + "{ plugins = { token = \"value\"; }; }", + "{ plugins = { bindings = { api = { origin = 123; }; }; }; }", + "{ plugins = { enabled = { demo = { arbitrary = true; }; }; }; }", + "{ plugins = { bindings = { api = \"https://api.example.com\"; }; }; }", + ] { + assert!(evaluate_schema(source).is_err(), "{source}"); + } +} diff --git a/crates/workspace-server/src/plugin_catalog/migration_tests.rs b/crates/workspace-server/src/plugin_catalog/migration_tests.rs index e5346e207..ae048dee2 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(), &(107, "Subject Ticket responsibility and current-body notification routing".into())); + assert_eq!(after.last().unwrap(), &(108, "Subject Ticket responsibility and current-body notification routing".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); @@ -66,6 +66,10 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into())); expected.push(( 107, + "Immutable Worker Plugin plans and retained pins".into(), + )); + expected.push(( + 108, "Subject Ticket responsibility and current-body notification routing".into(), )); assert_eq!(workspace_schema_migration_history(c)?, expected); diff --git a/crates/workspace-server/src/plugin_plan.rs b/crates/workspace-server/src/plugin_plan.rs new file mode 100644 index 000000000..de0dfa234 --- /dev/null +++ b/crates/workspace-server/src/plugin_plan.rs @@ -0,0 +1,419 @@ +//! Immutable, Server-owned Plugin execution snapshots and same-database pins. +//! +//! The config owner supplies an already validated, non-secret projection; this +//! module never reads config, selects a Profile, fetches packages or resolves +//! credentials. Host-bound execution identities are trusted inputs, not caller +//! authentication. Service entrypoints must authenticate/authorize every call, +//! including replay and reads. A saved plan is an upper bound, not live authority. +//! +//! Owner transaction functions compose with Worker creation in the catalog DB. +//! Their local savepoints also roll back partial pin acquisition if an owner +//! catches an error and commits other work. There is no cross-store compensation. +use crate::{Error, Result, plugin_catalog::PluginCatalogError}; +use manifest::plugin; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use server_api::{PluginArtifact, PluginGrantMetadata}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +mod store; +pub use store::*; + +/// Included by the config owner's ordered database migration, never at runtime. +pub const SCHEMA_SQL: &str = include_str!("plugin_plan/schema.sql"); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigProvenance { + pub content_digest: String, + pub toolchain_fingerprint: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileProvenance { + pub identity: String, + pub projection_digest: String, +} + +/// Supplied by the authenticated Host launch/delegation boundary, never by guest +/// or model input. These are identities only, not credentials or provider handles. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostExecutionIdentity { + pub runtime_id: String, + pub worker_id: String, + pub principal_id: String, + pub delegation_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BindingPolicyReference { + pub slot: String, + pub binding_id: String, + pub policy_digest: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginSurface { + Tool, +} + +/// Only capabilities implemented by the initial Tool Host are representable. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginGrant { + Logging, + Https { slot: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginSchemaIdentity { + pub identity: String, + pub version: String, + pub digest: String, + pub namespace: String, +} + +/// The upstream config/schema authority must validate config values as non-secret +/// and intersect grants/surfaces with Workspace, Profile and delegation bounds. +/// Binding policies remain with their owner: only identities and digests travel. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginPinProjection { + pub config: Value, + pub surfaces: Vec, + pub tools: Vec, + pub grants: Vec, + pub bindings: Vec, +} + +/// All fields are private: construct from admitted artifact metadata, not a +/// hand-built wire snapshot. Deserialization alone does not establish admission. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkerPluginPin { + installation_id: String, + expected_last_operation: String, + package_id: String, + package_version: String, + package_digest: String, + artifact_digest: String, + artifact_size: u32, + package_schema_version: u32, + component_world: String, + required_imports: Vec, + config_schema: PluginSchemaIdentity, + config_digest: String, + projection: PluginPinProjection, +} + +impl WorkerPluginPin { + pub fn from_artifact( + installation_id: String, + expected_last_operation: String, + artifact: &PluginArtifact, + mut projection: PluginPinProjection, + ) -> Result { + if artifact.revoked { + return conflict(); + } + if !valid_identity(&installation_id) + || !valid_identity(&expected_last_operation) + || !valid_digest(&artifact.package_digest) + || !valid_digest(&artifact.artifact_digest) + || artifact.artifact_size == 0 + || artifact.schema_version != plugin::SUPPORTED_PLUGIN_API_VERSION + || !matches!( + artifact.world.as_str(), + plugin::PLUGIN_COMPONENT_TOOL_WORLD + | plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD + | plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD + | plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD + ) + || !valid_digest(&artifact.config_schema.digest) + || !valid_identity(&artifact.package_id) + || !valid_identity(&artifact.version) + || !valid_identity(&artifact.config_schema.identity) + || !valid_identity(&artifact.config_schema.version) + || !valid_identity(&artifact.config_schema.namespace) + || !projection.config.is_object() + { + return invalid(); + } + projection.surfaces.sort(); + projection.surfaces.dedup(); + projection.tools.sort(); + projection.tools.dedup(); + projection.grants.sort(); + projection.grants.dedup(); + projection.bindings.sort(); + projection.config = canonical_value(&projection.config); + if (!projection.tools.is_empty() && !projection.surfaces.contains(&PluginSurface::Tool)) + || projection.tools.iter().any(|name| { + !valid_identity(name) || !artifact.tools.iter().any(|tool| tool.name == *name) + }) + || projection + .bindings + .windows(2) + .any(|b| b[0].slot == b[1].slot) + || projection.bindings.iter().any(|binding| { + !valid_identity(&binding.slot) + || !valid_identity(&binding.binding_id) + || !valid_digest(&binding.policy_digest) + || !artifact + .connections + .iter() + .any(|slot| slot.name == binding.slot) + }) + || projection.grants.iter().any(|grant| match grant { + PluginGrant::Logging => !artifact + .requested_grants + .contains(&PluginGrantMetadata::Logging), + PluginGrant::Https { slot } => { + !valid_identity(slot) + || !artifact + .requested_grants + .contains(&PluginGrantMetadata::Https { slot: slot.clone() }) + || !projection + .bindings + .iter() + .any(|binding| binding.slot == *slot) + } + }) + || projection.bindings.iter().any(|binding| { + !projection.grants.contains(&PluginGrant::Https { + slot: binding.slot.clone(), + }) + }) + { + return invalid(); + } + let mut required_imports = artifact.required_imports.clone(); + required_imports.sort(); + required_imports.dedup(); + Ok(Self { + installation_id, + expected_last_operation, + package_id: artifact.package_id.clone(), + package_version: artifact.version.clone(), + package_digest: artifact.package_digest.clone(), + artifact_digest: artifact.artifact_digest.clone(), + artifact_size: artifact.artifact_size, + package_schema_version: artifact.schema_version, + component_world: artifact.world.clone(), + required_imports, + config_schema: PluginSchemaIdentity { + identity: artifact.config_schema.identity.clone(), + version: artifact.config_schema.version.clone(), + digest: artifact.config_schema.digest.clone(), + namespace: artifact.config_schema.namespace.clone(), + }, + config_digest: digest_json(&projection.config)?, + projection, + }) + } + + pub fn installation_id(&self) -> &str { + &self.installation_id + } + pub fn expected_last_operation(&self) -> &str { + &self.expected_last_operation + } + pub fn package_id(&self) -> &str { + &self.package_id + } + pub fn package_version(&self) -> &str { + &self.package_version + } + pub fn package_schema_version(&self) -> u32 { + self.package_schema_version + } + pub fn component_world(&self) -> &str { + &self.component_world + } + pub fn required_imports(&self) -> &[String] { + &self.required_imports + } + pub fn package_digest(&self) -> &str { + &self.package_digest + } + pub fn artifact_digest(&self) -> &str { + &self.artifact_digest + } + pub fn artifact_size(&self) -> u32 { + self.artifact_size + } + pub fn config_schema(&self) -> &PluginSchemaIdentity { + &self.config_schema + } + pub fn config_digest(&self) -> &str { + &self.config_digest + } + pub fn projection(&self) -> &PluginPinProjection { + &self.projection + } + + pub(super) fn matches_artifact(&self, artifact: &PluginArtifact) -> Result { + Ok(Self::from_artifact( + self.installation_id.clone(), + self.expected_last_operation.clone(), + artifact, + self.projection.clone(), + )? == *self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkerPluginPlanInput { + pub workspace_id: String, + pub config: ConfigProvenance, + pub profile: ProfileProvenance, + pub execution: HostExecutionIdentity, + pub plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkerPluginExecutionPlan { + format_version: u32, + input: WorkerPluginPlanInput, +} + +impl WorkerPluginExecutionPlan { + pub fn canonicalize(mut input: WorkerPluginPlanInput) -> Result { + if !valid_identity(&input.workspace_id) + || !valid_digest(&input.config.content_digest) + || !valid_identity(&input.config.toolchain_fingerprint) + || !valid_identity(&input.profile.identity) + || !valid_digest(&input.profile.projection_digest) + || !valid_identity(&input.execution.runtime_id) + || !valid_identity(&input.execution.worker_id) + || !valid_identity(&input.execution.principal_id) + || input + .execution + .delegation_id + .as_ref() + .is_some_and(|id| !valid_identity(id)) + || input.plugins.len() > 128 + { + return invalid(); + } + input + .plugins + .sort_by(|a, b| a.installation_id.cmp(&b.installation_id)); + // One selection per installation; two aliases of one package instead use + // distinct installation identities, as required by the catalog contract. + if input + .plugins + .windows(2) + .any(|pins| pins[0].installation_id == pins[1].installation_id) + { + return invalid(); + } + // Public Tool names use immutable installation identities, never aliases. + let mut names = BTreeSet::new(); + for pin in &input.plugins { + for tool in &pin.projection.tools { + let name = format!("{}__{}", pin.installation_id, tool); + if name.len() > 128 || !names.insert(name) { + return invalid(); + } + } + } + Ok(Self { + format_version: 1, + input, + }) + } + + pub fn workspace_id(&self) -> &str { + &self.input.workspace_id + } + pub fn config(&self) -> &ConfigProvenance { + &self.input.config + } + pub fn profile(&self) -> &ProfileProvenance { + &self.input.profile + } + pub fn execution(&self) -> &HostExecutionIdentity { + &self.input.execution + } + pub fn plugins(&self) -> &[WorkerPluginPin] { + &self.input.plugins + } + pub fn fingerprint(&self) -> Result { + digest_json(self) + } + pub fn canonical_json(&self) -> Result { + encode_canonical(self) + } + + pub(super) fn validate_canonical(&self) -> Result<()> { + if self.format_version != 1 || Self::canonicalize(self.input.clone())? != *self { + return invalid(); + } + Ok(()) + } +} + +fn invalid() -> Result { + Err(Error::PluginCatalog(PluginCatalogError::Invalid)) +} +fn conflict() -> Result { + Err(Error::PluginCatalog(PluginCatalogError::Conflict)) +} +fn not_found() -> Result { + Err(Error::PluginCatalog(PluginCatalogError::NotFound)) +} +fn valid_identity(value: &str) -> bool { + !value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control) +} +fn valid_digest(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) +} +fn canonical_value(value: &Value) -> Value { + match value { + Value::Object(fields) => { + let mut keys: Vec<_> = fields.keys().collect(); + keys.sort(); + Value::Object( + keys.into_iter() + .map(|key| (key.clone(), canonical_value(&fields[key]))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()), + _ => value.clone(), + } +} +fn encode_canonical(value: &impl Serialize) -> Result { + let value = serde_json::to_value(value) + .map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))?; + serde_json::to_string(&canonical_value(&value)) + .map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid)) +} +fn digest_json(value: &impl Serialize) -> Result { + let bytes = encode_canonical(value)?; + Ok(hash_bytes(bytes.as_bytes())) +} +fn hash_bytes(bytes: &[u8]) -> String { + let hex: String = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("sha256:{hex}") +} + +#[cfg(test)] +mod tests; diff --git a/crates/workspace-server/src/plugin_plan/migration_tests.rs b/crates/workspace-server/src/plugin_plan/migration_tests.rs new file mode 100644 index 000000000..87b737c2f --- /dev/null +++ b/crates/workspace-server/src/plugin_plan/migration_tests.rs @@ -0,0 +1,51 @@ +//! Ordered production migration 106 -> 107. A marker failure rolls back new DDL +//! without changing the already applied catalog migration or its saved rows. +use super::*; + +#[test] +fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_matches_fresh() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retained.db"); + tests::prepare_retained_schema(&path, 106); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + let before = workspace_schema_migration_history(&conn).unwrap(); + conn.execute_batch("CREATE TRIGGER interrupt_plan_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=107 BEGIN SELECT RAISE(ABORT,'plan marker failure'); END;").unwrap(); + assert!(SqliteWorkspaceStore::open_for_test(&path).is_err()); + assert_eq!(workspace_schema_migration_history(&conn).unwrap(), before); + assert!(!table_exists(&conn, "plugin_execution_plans").unwrap()); + crate::plugin_catalog::verify(&conn).unwrap(); + conn.execute_batch("DROP TRIGGER interrupt_plan_migration") + .unwrap(); + drop(conn); + let updated = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + updated + .with_conn(|conn| { + let mut expected = before.clone(); + expected.push(( + 107, + "Immutable Worker Plugin plans and retained pins".into(), + )); + expected.push(( + 108, + "Subject Ticket responsibility and current-body notification routing".into(), + )); + assert_eq!(workspace_schema_migration_history(conn)?, expected); + subject_assignment_migration::verify(conn)?; + crate::plugin_plan::verify_plan_schema(conn)?; + assert_eq!( + conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + ))?, + 0 + ); + conn.execute_batch("DROP TRIGGER plugin_plan_snapshot_immutable")?; + Ok(()) + }) + .unwrap(); + drop(updated); + assert!( + matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin plan storage schema has drifted")) + ); +} diff --git a/crates/workspace-server/src/plugin_plan/schema.sql b/crates/workspace-server/src/plugin_plan/schema.sql new file mode 100644 index 000000000..2b7162eb1 --- /dev/null +++ b/crates/workspace-server/src/plugin_plan/schema.sql @@ -0,0 +1,27 @@ +CREATE TABLE plugin_execution_plans ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + plan_id TEXT NOT NULL, + actor TEXT NOT NULL CHECK(length(actor) BETWEEN 1 AND 512), + operation_key TEXT NOT NULL CHECK(length(operation_key) BETWEEN 1 AND 128), + fingerprint TEXT NOT NULL CHECK(length(fingerprint)=71 AND substr(fingerprint,1,7)='sha256:' AND substr(fingerprint,8) NOT GLOB '*[^0-9a-f]*'), + plan_json TEXT NOT NULL CHECK(json_valid(plan_json)), + pin_state TEXT NOT NULL CHECK(pin_state IN ('pending','active','restorable','released')), + PRIMARY KEY(workspace_id, plan_id), + UNIQUE(workspace_id, actor, operation_key) +); +CREATE TRIGGER plugin_plan_snapshot_immutable +BEFORE UPDATE OF workspace_id,plan_id,actor,operation_key,fingerprint,plan_json ON plugin_execution_plans +BEGIN SELECT RAISE(ABORT,'Plugin execution plan is immutable'); END; +CREATE TRIGGER plugin_plan_retention_terminal BEFORE UPDATE OF pin_state ON plugin_execution_plans +WHEN NOT ( + OLD.pin_state=NEW.pin_state OR + (OLD.pin_state='pending' AND NEW.pin_state IN ('active','released')) OR + (OLD.pin_state='active' AND NEW.pin_state IN ('restorable','released')) OR + (OLD.pin_state='restorable' AND NEW.pin_state IN ('active','released')) +) +BEGIN SELECT RAISE(ABORT,'invalid Plugin execution plan pin transition'); END; +-- Preserve the immutable create replay after retention release. Workspace deletion +-- may cascade; deleting an individual receipt must not make its key reusable. +CREATE TRIGGER plugin_plan_replay_retained BEFORE DELETE ON plugin_execution_plans +WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id) +BEGIN SELECT RAISE(ABORT,'Plugin execution plan replay must be retained'); END; diff --git a/crates/workspace-server/src/plugin_plan/store.rs b/crates/workspace-server/src/plugin_plan/store.rs new file mode 100644 index 000000000..40c8f6973 --- /dev/null +++ b/crates/workspace-server/src/plugin_plan/store.rs @@ -0,0 +1,331 @@ +use super::*; +use crate::{ + plugin_catalog::{self, PluginReference, PluginReferenceKind}, + store::SqliteWorkspaceStore, +}; +use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanPinState { + Pending, + Active, + Restorable, + Released, +} +impl PlanPinState { + fn name(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Active => "active", + Self::Restorable => "restorable", + Self::Released => "released", + } + } + fn reference_kind(self) -> Option { + match self { + Self::Pending => Some(PluginReferenceKind::Pending), + Self::Active => Some(PluginReferenceKind::Active), + Self::Restorable => Some(PluginReferenceKind::Restorable), + Self::Released => None, + } + } + fn can_transition(self, target: Self) -> bool { + self == target + || matches!( + (self, target), + (Self::Pending, Self::Active | Self::Released) + | (Self::Active, Self::Restorable | Self::Released) + | (Self::Restorable, Self::Active | Self::Released) + ) + } +} + +/// Immutable creation result. `initial_pin_state` describes creation, never the +/// current retention state. Replay after release must not resurrect any pins. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginPlanCreateReceipt { + pub plan_id: String, + pub fingerprint: String, + pub initial_pin_state: PlanPinState, + pub plan: WorkerPluginExecutionPlan, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StoredPluginPlan { + pub plan_id: String, + pub fingerprint: String, + pub pin_state: PlanPinState, + pub plan: WorkerPluginExecutionPlan, +} + +// A local savepoint keeps owner functions atomic even if a caller handles their +// error without aborting its surrounding Worker-create transaction. +fn atomic(tx: &Transaction<'_>, work: impl FnOnce() -> Result) -> Result { + tx.execute_batch("SAVEPOINT plugin_plan_owner")?; + match work() { + Ok(value) => { + tx.execute_batch("RELEASE plugin_plan_owner")?; + Ok(value) + } + Err(error) => { + tx.execute_batch("ROLLBACK TO plugin_plan_owner; RELEASE plugin_plan_owner")?; + Err(error) + } + } +} + +/// The caller owns the authenticated create key and an immediate transaction in +/// the *same database* as catalog and Worker creation. Include the returned plan +/// fingerprint in the Worker launch/create fingerprint. New creation rechecks +/// current config/Profile/binding provenance and admitted metadata in this same +/// transaction. Owner-supplied narrowed snapshots cannot exceed that authority. +/// +/// Replay precedes catalog admission: an immutable historical result is returned +/// even after revocation, uninstall or release. It is not fresh execution authority. +pub fn plugin_plan_create( + tx: &Transaction<'_>, + actor: &str, + operation_key: &str, + plan: &WorkerPluginExecutionPlan, +) -> Result { + if !valid_identity(actor) || !valid_identity(operation_key) || operation_key.len() > 128 { + return invalid(); + } + plan.validate_canonical()?; + let fingerprint = plan.fingerprint()?; + atomic(tx, || { + let saved = tx.query_row( + "SELECT plan_id,fingerprint FROM plugin_execution_plans WHERE workspace_id=?1 AND actor=?2 AND operation_key=?3", + params![plan.workspace_id(), actor, operation_key], + |row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?)), + ).optional()?; + if let Some((plan_id, saved_fingerprint)) = saved { + if saved_fingerprint != fingerprint { + return conflict(); + } + return receipt(plugin_plan_load(tx, plan.workspace_id(), &plan_id)?); + } + for pin in plan.plugins() { + plugin_catalog::plugin_artifact_admit( + tx, + plan.workspace_id(), + &pin.installation_id, + &pin.expected_last_operation, + &pin.package_digest, + &pin.artifact_digest, + )?; + } + crate::plugin_settings::validate_plan_source(tx, plan)?; + let plan_id = format!("pep-{}", uuid::Uuid::now_v7()); + tx.execute( + "INSERT INTO plugin_execution_plans(workspace_id,plan_id,actor,operation_key,fingerprint,plan_json,pin_state) VALUES(?1,?2,?3,?4,?5,?6,'pending')", + params![plan.workspace_id(), plan_id, actor, operation_key, fingerprint, plan.canonical_json()?], + )?; + for pin in plan.plugins() { + let artifact = plugin_catalog::plugin_artifact_admit( + tx, + plan.workspace_id(), + &pin.installation_id, + &pin.expected_last_operation, + &pin.package_digest, + &pin.artifact_digest, + )?; + if !pin.matches_artifact(&artifact)? { + return conflict(); + } + plugin_catalog::plugin_reference_put( + tx, + plan.workspace_id(), + &PluginReference { + kind: PluginReferenceKind::Pending, + owner: plan_id.clone(), + installation_id: pin.installation_id.clone(), + expected_last_operation: pin.expected_last_operation.clone(), + package_digest: pin.package_digest.clone(), + artifact_digest: pin.artifact_digest.clone(), + }, + )?; + } + receipt(plugin_plan_load(tx, plan.workspace_id(), &plan_id)?) + }) +} +fn receipt(saved: StoredPluginPlan) -> Result { + Ok(PluginPlanCreateReceipt { + plan_id: saved.plan_id, + fingerprint: saved.fingerprint, + initial_pin_state: PlanPinState::Pending, + plan: saved.plan, + }) +} + +/// Reads only persisted immutable input, never current config or a latest artifact. +/// `Released` snapshots remain readable for audit/replay but cannot be restored. +pub fn plugin_plan_load( + conn: &Connection, + workspace: &str, + plan_id: &str, +) -> Result { + let row = conn.query_row( + "SELECT fingerprint,plan_json,pin_state FROM plugin_execution_plans WHERE workspace_id=?1 AND plan_id=?2", + params![workspace, plan_id], + |row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?, row.get::<_,String>(2)?)), + ).optional()?; + let Some((fingerprint, json, state)) = row else { + return not_found(); + }; + let plan: WorkerPluginExecutionPlan = serde_json::from_str(&json) + .map_err(|_| Error::Store("invalid persisted Plugin execution plan".into()))?; + plan.validate_canonical()?; + if plan.workspace_id() != workspace || plan.fingerprint()? != fingerprint { + return Err(Error::Store( + "Plugin execution plan integrity mismatch".into(), + )); + } + let pin_state = match state.as_str() { + "pending" => PlanPinState::Pending, + "active" => PlanPinState::Active, + "restorable" => PlanPinState::Restorable, + "released" => PlanPinState::Released, + _ => { + return Err(Error::Store( + "invalid Plugin execution plan pin state".into(), + )); + } + }; + Ok(StoredPluginPlan { + plan_id: plan_id.into(), + fingerprint, + pin_state, + plan, + }) +} + +/// Moves pins atomically with the caller's Worker lifecycle update. State is CAS +/// compared; a stale transition is not silently replayed. Releasing is always +/// possible after revoke and never re-admits an artifact. Releasing twice with +/// expected=Released is a harmless no-op. No transition out of Released is allowed. +/// +/// Acquiring a different retained kind rechecks live catalog admission. It uses +/// the installation's current operation (read inside the write transaction), not +/// the creation-time observation: unrelated artifact additions do not invalidate +/// immutable snapshots. Revoked targets cannot acquire new pins; an unsuccessful +/// transition retains all old refs. A revoked Worker may release its pins, but +/// cannot become restorable/active. T-793 must still enforce live operation checks. +pub fn plugin_plan_transition( + tx: &Transaction<'_>, + workspace: &str, + plan_id: &str, + expected: PlanPinState, + target: PlanPinState, +) -> Result { + atomic(tx, || { + let mut saved = plugin_plan_load(tx, workspace, plan_id)?; + if saved.pin_state != expected || !expected.can_transition(target) { + return conflict(); + } + if expected == target { + return Ok(saved); + } + if let Some(kind) = target.reference_kind() { + for pin in saved.plan.plugins() { + let operation: String = tx.query_row( + "SELECT last_operation FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2", + params![workspace, pin.installation_id], |row| row.get(0), + )?; + plugin_catalog::plugin_reference_put( + tx, + workspace, + &PluginReference { + kind, + owner: plan_id.into(), + installation_id: pin.installation_id.clone(), + expected_last_operation: operation, + package_digest: pin.package_digest.clone(), + artifact_digest: pin.artifact_digest.clone(), + }, + )?; + } + } + if let Some(kind) = expected.reference_kind() { + for pin in saved.plan.plugins() { + plugin_catalog::plugin_reference_remove( + tx, + workspace, + kind, + plan_id, + &pin.installation_id, + &pin.artifact_digest, + )?; + } + } + let changed = tx.execute( + "UPDATE plugin_execution_plans SET pin_state=?1 WHERE workspace_id=?2 AND plan_id=?3 AND pin_state=?4", + params![target.name(),workspace,plan_id,expected.name()], + )?; + if changed != 1 { + return conflict(); + } + saved.pin_state = target; + Ok(saved) + }) +} + +impl SqliteWorkspaceStore { + pub fn plugin_plan_create( + &self, + actor: &str, + operation_key: &str, + plan: &WorkerPluginExecutionPlan, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let receipt = plugin_plan_create(&tx, actor, operation_key, plan)?; + tx.commit()?; + Ok(receipt) + }) + } + pub fn plugin_plan_load(&self, workspace: &str, plan_id: &str) -> Result { + self.with_conn(|conn| plugin_plan_load(conn, workspace, plan_id)) + } + pub fn plugin_plan_transition( + &self, + workspace: &str, + plan_id: &str, + expected: PlanPinState, + target: PlanPinState, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let saved = plugin_plan_transition(&tx, workspace, plan_id, expected, target)?; + tx.commit()?; + Ok(saved) + }) + } +} + +pub(crate) fn verify_plan_schema(conn: &Connection) -> Result<()> { + let expected = Connection::open_in_memory()?; + expected.execute_batch(SCHEMA_SQL)?; + let mut statement = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name='plugin_execution_plans' ORDER BY name")?; + for item in statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? { + let (name, sql) = item?; + let actual = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [&name], + |row| row.get::<_, String>(0), + ) + .optional()?; + if actual.as_deref() != Some(&sql) { + return Err(Error::Store( + "Plugin plan storage schema has drifted".into(), + )); + } + } + Ok(()) +} diff --git a/crates/workspace-server/src/plugin_plan/tests.rs b/crates/workspace-server/src/plugin_plan/tests.rs new file mode 100644 index 000000000..c0a7e3db1 --- /dev/null +++ b/crates/workspace-server/src/plugin_plan/tests.rs @@ -0,0 +1,779 @@ +//! Real file-backed SQLite, production catalog operations and a checked-in PDK +//! Component. Execution/Host side effects are intentionally outside this boundary. +use super::*; +use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; +use rusqlite::{Connection, TransactionBehavior, params}; +use server_api::*; +use std::fs; +use tempfile::{TempDir, tempdir}; + +struct Fixture { + dir: TempDir, + store: SqliteWorkspaceStore, +} +impl Fixture { + async fn new() -> Self { + let dir = tempdir().unwrap(); + let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap(); + for workspace in ["a", "b"] { + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: workspace.into(), + owner_account_id: "owner-account".into(), + display_name: workspace.into(), + state: "active".into(), + created_at: "2026-10-28T00:00:00Z".into(), + updated_at: "2026-10-28T00:00:00Z".into(), + }) + .await + .unwrap(); + } + Self { dir, store } + } + fn install(&self, alias: &str) -> PluginOperationResponse { + let upload = self + .store + .plugin_upload( + &self.dir.path().join("blobs"), + "a", + "actor", + &package("0.1.0"), + ) + .unwrap(); + self.store + .plugin_install( + "a", + "actor", + &PluginInstallRequest { + upload_id: upload.upload_id, + alias: alias.into(), + operation_key: alias.into(), + }, + ) + .unwrap() + } + fn count(&self, table: &str) -> i64 { + self.store + .with_conn(|conn| { + Ok(conn.query_row( + &format!( + "SELECT count(*) FROM {table}{}", + if table == "plugin_references" { + " WHERE kind != 'config'" + } else { + "" + } + ), + [], + |r| r.get(0), + )?) + }) + .unwrap() + } + fn kind(&self) -> String { + self.store + .with_conn(|conn| { + Ok(conn.query_row( + "SELECT kind FROM plugin_references WHERE kind != 'config' LIMIT 1", + [], + |r| r.get(0), + )?) + }) + .unwrap() + } + fn transition(&self, id: &str, from: PlanPinState, to: PlanPinState) -> StoredPluginPlan { + self.store + .plugin_plan_transition("a", id, from, to) + .unwrap() + } +} +fn package(version: &str) -> Vec { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("plugin.toml"), + include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml") + .replace("version = \"0.1.0\"", &format!("version = \"{version}\"")), + ) + .unwrap(); + fs::write( + dir.path().join("plugin.component.wasm"), + include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"), + ) + .unwrap(); + let package = plugin::read_plugin_directory( + dir.path(), + plugin::PluginSourceKind::Explicit, + &plugin::PluginPackageLimits::default(), + ) + .unwrap(); + plugin::pack_plugin_package(&package, &plugin::PluginPackageLimits::default()) + .unwrap() + .bytes +} +fn digest(seed: &str) -> String { + hash_bytes(seed.as_bytes()) +} +fn projection() -> PluginPinProjection { + PluginPinProjection { + config: serde_json::json!({}), + surfaces: vec![PluginSurface::Tool], + tools: vec!["example_echo".into()], + grants: vec![], + bindings: vec![], + } +} +fn pin(installed: &PluginOperationResponse) -> WorkerPluginPin { + WorkerPluginPin::from_artifact( + installed.installation.installation_id.clone(), + installed.operation_id.clone(), + &installed.installation.artifacts[0], + projection(), + ) + .unwrap() +} +fn input(plugins: Vec) -> WorkerPluginPlanInput { + WorkerPluginPlanInput { + workspace_id: "a".into(), + config: ConfigProvenance { + content_digest: digest("config"), + toolchain_fingerprint: digest("toolchain"), + }, + profile: ProfileProvenance { + identity: "profile:coder".into(), + projection_digest: digest("profile"), + }, + execution: HostExecutionIdentity { + runtime_id: "runtime".into(), + worker_id: "worker".into(), + principal_id: "worker-principal".into(), + delegation_id: Some("delegation".into()), + }, + plugins, + } +} +fn plan(plugins: Vec) -> WorkerPluginExecutionPlan { + WorkerPluginExecutionPlan::canonicalize(input(plugins)).unwrap() +} +fn error(result: Result, expected: PluginCatalogError) { + assert!( + matches!(result,Err(Error::PluginCatalog(actual)) if actual == expected), + "expected {expected:?}, got {result:?}" + ); +} +fn uninstall( + f: &Fixture, + installation: &PluginOperationResponse, + key: &str, +) -> Result { + f.store.plugin_uninstall( + "a", + "actor", + &installation.installation.installation_id, + &PluginUninstallRequest { + expected_last_operation: installation.operation_id.clone(), + operation_key: key.into(), + }, + ) +} + +#[tokio::test] +async fn canonical_order_and_fingerprint_bind_all_provenance_and_both_digests() { + let f = Fixture::new().await; + let a = f.install("one"); + let b = f.install("two"); + let p = plan(vec![pin(&a), pin(&b)]); + assert_eq!(p, plan(vec![pin(&b), pin(&a)])); + assert_eq!( + p.fingerprint().unwrap(), + plan(vec![pin(&b), pin(&a)]).fingerprint().unwrap() + ); + let mut changes = Vec::new(); + let base = input(vec![pin(&a), pin(&b)]); + let mut changed = base.clone(); + changed.config.content_digest = digest("new config"); + changes.push(changed); + let mut changed = base.clone(); + changed.config.toolchain_fingerprint = digest("new toolchain"); + changes.push(changed); + let mut changed = base.clone(); + changed.profile.projection_digest = digest("new profile"); + changes.push(changed); + let mut changed = base.clone(); + changed.execution.delegation_id = Some("different".into()); + changes.push(changed); + let mut changed = base.clone(); + changed.plugins[0].package_digest = digest("new package"); + changes.push(changed); + let mut changed = base.clone(); + changed.plugins[0].artifact_digest = digest("new artifact"); + changes.push(changed); + let mut changed = base; + changed.plugins[0].expected_last_operation = "new observation".into(); + changes.push(changed); + for changed in changes { + assert_ne!( + p.fingerprint().unwrap(), + WorkerPluginExecutionPlan::canonicalize(changed) + .unwrap() + .fingerprint() + .unwrap() + ); + } + error( + WorkerPluginExecutionPlan::canonicalize(input(vec![pin(&a), pin(&a)])), + PluginCatalogError::Invalid, + ); +} + +#[tokio::test] +async fn canonical_json_sorts_nested_config_and_selected_sets_without_copying_catalog_payloads() { + let f = Fixture::new().await; + let installed = f.install("one"); + let mut artifact = installed.installation.artifacts[0].clone(); + artifact.tools.push(PluginToolMetadata { + name: "other".into(), + ..artifact.tools[0].clone() + }); + artifact.requested_grants = vec![ + PluginGrantMetadata::Logging, + PluginGrantMetadata::Https { slot: "api".into() }, + ]; + artifact.connections = vec![PluginConnectionMetadata { + name: "api".into(), + methods: vec![PluginHttpMethod::Get], + path_prefixes: vec!["/".into()], + }]; + let mut first = projection(); + first.config = serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":[{"z":0,"a":false}]}"#).unwrap(); + first.tools = vec!["other".into(), "example_echo".into()]; + first.grants = vec![ + PluginGrant::Https { slot: "api".into() }, + PluginGrant::Logging, + ]; + first.bindings = vec![BindingPolicyReference { + slot: "api".into(), + binding_id: "binding".into(), + policy_digest: digest("policy"), + }]; + let mut second = first.clone(); + second.config = serde_json::from_str(r#"{"a":[{"a":false,"z":0}],"z":{"a":1,"b":2}}"#).unwrap(); + second.tools.reverse(); + second.grants.reverse(); + let construct = |projection| { + plan(vec![ + WorkerPluginPin::from_artifact( + installed.installation.installation_id.clone(), + installed.operation_id.clone(), + &artifact, + projection, + ) + .unwrap(), + ]) + }; + let canonical = construct(first); + assert_eq!(canonical, construct(second)); + let json = canonical.canonical_json().unwrap(); + for excluded in [ + "revocation_reason", + "description", + "input_schema", + "output_schema", + "dcdl", + "artifact_url", + "host_path", + "secret_value", + "provider_handle", + "token", + "package_bytes", + ] { + assert!( + !json.contains(&format!("\"{excluded}\"")), + "unexpected {excluded}" + ); + } + let mut unknown = serde_json::to_value(&canonical).unwrap(); + unknown["input"]["execution"]["token"] = Value::String("credential".into()); + assert!(serde_json::from_value::(unknown).is_err()); +} + +#[tokio::test] +async fn create_replay_is_immutable_across_restart_and_rejects_changed_input() { + let f = Fixture::new().await; + let installed = f.install("one"); + let plan = f.configure(&[&installed]); + let receipt = f + .store + .plugin_plan_create("actor", "launch", &plan) + .unwrap(); + assert_eq!(receipt.initial_pin_state, PlanPinState::Pending); + assert_eq!(f.kind(), "pending"); + f.transition( + &receipt.plan_id, + PlanPinState::Pending, + PlanPinState::Active, + ); + let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap(); + assert_eq!( + reopened + .plugin_plan_create("actor", "launch", &plan) + .unwrap(), + receipt + ); + assert_eq!( + reopened + .plugin_plan_load("a", &receipt.plan_id) + .unwrap() + .pin_state, + PlanPinState::Active + ); + let mut changed = plan.input.clone(); + changed.profile.projection_digest = digest("new profile"); + error( + reopened.plugin_plan_create( + "actor", + "launch", + &WorkerPluginExecutionPlan::canonicalize(changed).unwrap(), + ), + PluginCatalogError::Conflict, + ); + assert_eq!(f.count("plugin_execution_plans"), 1); + assert_eq!(f.count("plugin_references"), 1); + assert_eq!( + reopened + .plugin_plan_load("a", &receipt.plan_id) + .unwrap() + .plan, + plan + ); + error( + reopened.plugin_plan_load("b", &receipt.plan_id), + PluginCatalogError::NotFound, + ); +} + +#[tokio::test] +async fn retained_pin_lifecycle_blocks_uninstall_and_released_replay_never_reacquires() { + let f = Fixture::new().await; + let installed = f.install("one"); + let plan = f.configure(&[&installed]); + let receipt = f + .store + .plugin_plan_create("actor", "launch", &plan) + .unwrap(); + error( + uninstall(&f, &installed, "uninstall-pending"), + PluginCatalogError::Referenced, + ); + f.transition( + &receipt.plan_id, + PlanPinState::Pending, + PlanPinState::Active, + ); + assert_eq!(f.kind(), "active"); + error( + uninstall(&f, &installed, "uninstall-active"), + PluginCatalogError::Referenced, + ); + f.transition( + &receipt.plan_id, + PlanPinState::Active, + PlanPinState::Restorable, + ); + assert_eq!(f.kind(), "restorable"); + error( + uninstall(&f, &installed, "uninstall-restorable"), + PluginCatalogError::Referenced, + ); + f.transition( + &receipt.plan_id, + PlanPinState::Restorable, + PlanPinState::Active, + ); + f.transition( + &receipt.plan_id, + PlanPinState::Active, + PlanPinState::Released, + ); + f.transition( + &receipt.plan_id, + PlanPinState::Released, + PlanPinState::Released, + ); + assert_eq!(f.count("plugin_references"), 0); + f.disable(); + uninstall(&f, &installed, "uninstall-released").unwrap(); + assert_eq!( + f.store + .plugin_plan_create("actor", "launch", &plan) + .unwrap(), + receipt + ); + assert_eq!(f.count("plugin_references"), 0); + error( + f.store.plugin_plan_transition( + "a", + &receipt.plan_id, + PlanPinState::Released, + PlanPinState::Active, + ), + PluginCatalogError::Conflict, + ); + error( + f.store + .plugin_plan_create("actor", "different-launch", &plan), + PluginCatalogError::NotFound, + ); + assert_eq!(f.count("plugin_execution_plans"), 1); + f.store + .with_conn(|conn| { + assert!( + conn.execute("UPDATE plugin_execution_plans SET plan_json=plan_json", []) + .is_err() + ); + assert!( + conn.execute("UPDATE plugin_execution_plans SET pin_state='pending'", []) + .is_err() + ); + assert!( + conn.execute("DELETE FROM plugin_execution_plans", []) + .is_err() + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn revoke_blocks_create_and_new_pin_kinds_but_not_release_or_historical_replay() { + let f = Fixture::new().await; + let installed = f.install("one"); + let plan = f.configure(&[&installed]); + let receipt = f + .store + .plugin_plan_create("actor", "launch", &plan) + .unwrap(); + f.store + .plugin_revoke( + "a", + "actor", + &installed.installation.installation_id, + &PluginRevokeRequest { + artifact_digest: installed.installation.artifacts[0].artifact_digest.clone(), + expected_last_operation: installed.operation_id.clone(), + reason: "revoked".into(), + operation_key: "revoke".into(), + }, + ) + .unwrap(); + assert_eq!( + f.store + .plugin_plan_create("actor", "launch", &plan) + .unwrap(), + receipt + ); + error( + f.store.plugin_plan_transition( + "a", + &receipt.plan_id, + PlanPinState::Pending, + PlanPinState::Active, + ), + PluginCatalogError::Conflict, + ); + assert_eq!(f.kind(), "pending"); + error( + f.store.plugin_plan_create("actor", "new-launch", &plan), + PluginCatalogError::Conflict, + ); + assert_eq!(f.count("plugin_execution_plans"), 1); + f.transition( + &receipt.plan_id, + PlanPinState::Pending, + PlanPinState::Released, + ); + assert_eq!(f.count("plugin_references"), 0); +} + +#[tokio::test] +async fn artifact_addition_does_not_reselect_or_invalidate_existing_plan_lifecycle() { + let f = Fixture::new().await; + let installed = f.install("one"); + let plan = f.configure(&[&installed]); + let receipt = f + .store + .plugin_plan_create("actor", "launch", &plan) + .unwrap(); + let upload = f + .store + .plugin_upload(&f.dir.path().join("blobs"), "a", "actor", &package("0.2.0")) + .unwrap(); + f.store + .plugin_add_artifact( + "a", + "actor", + &installed.installation.installation_id, + &PluginAddArtifactRequest { + upload_id: upload.upload_id, + expected_last_operation: installed.operation_id.clone(), + operation_key: "update".into(), + }, + ) + .unwrap(); + let active = f.transition( + &receipt.plan_id, + PlanPinState::Pending, + PlanPinState::Active, + ); + assert_eq!(active.plan, plan); + assert_eq!( + active.plan.plugins()[0].artifact_digest(), + installed.installation.artifacts[0].artifact_digest + ); + error( + f.store + .plugin_plan_create("actor", "new-with-stale-observation", &plan), + PluginCatalogError::Conflict, + ); +} + +#[tokio::test] +async fn owner_savepoint_and_outer_transaction_rollback_leave_no_partial_plan_or_pins() { + let f = Fixture::new().await; + let first = f.install("one"); + let second = f.install("two"); + let mut bad_input = f.configure(&[&first, &second]).input; + let mut pins = bad_input.plugins.clone(); + pins.sort_by(|a, b| a.installation_id.cmp(&b.installation_id)); + pins[1].artifact_size += 1; + bad_input.plugins = pins; + let bad_plan = WorkerPluginExecutionPlan::canonicalize(bad_input).unwrap(); + f.store + .with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + error( + plugin_plan_create(&tx, "actor", "bad", &bad_plan), + PluginCatalogError::Conflict, + ); + // Even if the caller commits after a handled error, first pin is gone. + tx.commit()?; + Ok(()) + }) + .unwrap(); + assert_eq!(f.count("plugin_execution_plans"), 0); + assert_eq!(f.count("plugin_references"), 0); + let valid = f.configure(&[&first]); + f.store + .with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + plugin_plan_create(&tx, "actor", "outer-rollback", &valid)?; + tx.rollback()?; + Ok(()) + }) + .unwrap(); + assert_eq!(f.count("plugin_execution_plans"), 0); + assert_eq!(f.count("plugin_references"), 0); + // The same key is usable after rollback because neither domain fact committed. + f.store + .plugin_plan_create("actor", "outer-rollback", &valid) + .unwrap(); +} + +#[tokio::test] +async fn cross_workspace_and_exact_digest_mismatches_fail_without_retaining_references() { + let f = Fixture::new().await; + let installed = f.install("one"); + let base = f.configure(&[&installed]); + let mut other_workspace = base.input.clone(); + other_workspace.workspace_id = "b".into(); + error( + f.store.plugin_plan_create( + "actor", + "cross", + &WorkerPluginExecutionPlan::canonicalize(other_workspace).unwrap(), + ), + PluginCatalogError::NotFound, + ); + for change_package in [true, false] { + let mut changed = base.input.clone(); + if change_package { + changed.plugins[0].package_digest = digest("wrong-package"); + } else { + changed.plugins[0].artifact_digest = digest("wrong-artifact"); + } + let result = f.store.plugin_plan_create( + "actor", + "digests", + &WorkerPluginExecutionPlan::canonicalize(changed).unwrap(), + ); + error( + result, + if change_package { + PluginCatalogError::Conflict + } else { + PluginCatalogError::NotFound + }, + ); + } + assert_eq!(f.count("plugin_execution_plans"), 0); + assert_eq!(f.count("plugin_references"), 0); +} + +#[tokio::test] +async fn sqlite_write_fence_serializes_uninstall_and_pin_creation_across_connections() { + let f = Fixture::new().await; + let installed = f.install("one"); + let plan = f.configure(&[&installed]); + let mut other = Connection::open(f.dir.path().join("server.db")).unwrap(); + other.busy_timeout(std::time::Duration::ZERO).unwrap(); + f.store + .with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + plugin_plan_create(&tx, "actor", "launch", &plan)?; + // A second writer cannot pass the catalog deletion fence before commit. + assert!( + other + .transaction_with_behavior(TransactionBehavior::Immediate) + .is_err() + ); + tx.commit()?; + Ok(()) + }) + .unwrap(); + let tx = other + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + assert!(tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='a' AND installation_id=?1", params![installed.installation.installation_id]).is_err()); + tx.rollback().unwrap(); + error( + uninstall(&f, &installed, "after-committed-pin"), + PluginCatalogError::Referenced, + ); + // Opposite ordering: uninstall wins before a new plan's owner acquires pins. + let unpinned = f.install("unpinned"); + let unpinned_plan = f.configure(&[&unpinned]); + f.disable(); + uninstall(&f, &unpinned, "uninstall-first").unwrap(); + error( + f.store + .plugin_plan_create("actor", "after-uninstall", &unpinned_plan), + PluginCatalogError::NotFound, + ); +} + +impl Fixture { + fn configure(&self, installed: &[&PluginOperationResponse]) -> WorkerPluginExecutionPlan { + use crate::config_source::{ConfigCommitRequest, WorkspaceConfigSchemaProvider}; + use config_source::{ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle}; + let bundle = WorkspaceConfigSchemaBundle::compose([ + crate::plugin_settings::PluginConfigSchemaProvider + .contribution() + .unwrap(), + crate::profile_settings::ProfileConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap(); + let state = self + .store + .ensure_workspace_config_materialized_with_schema("a", "1", bundle.clone()) + .unwrap(); + let mut enabled = serde_json::Map::new(); + let mut selections = Vec::new(); + for (index, item) in installed.iter().enumerate() { + let a = &item.installation.artifacts[0]; + enabled.insert( + format!("plugin_{index}"), + serde_json::json!({ + "installation_id": item.installation.installation_id, + "expected_last_operation": item.operation_id, + "package_digest": a.package_digest, "artifact_digest": a.artifact_digest, + "tools": ["example_echo"], "config": {}, + }), + ); + selections.push(serde_json::json!({"installation_id":item.installation.installation_id,"tools":["example_echo"]})); + } + let value = serde_json::json!({"plugins":{"enabled": enabled}, "profile":{"entries":[{"selector":"project:tool","profile":{"plugins":{"selections":selections}}}]}}); + let path = VirtualPath::parse("main.dcdl").unwrap(); + let candidate = self + .store + .evaluate_workspace_config_candidate_with_schema( + "a", + &ConfigCommitRequest { + base_digest: state.snapshot.digest.clone(), + entrypoints: vec![path.clone()], + changes: vec![ConfigTreeChange::Update { + path: path.clone(), + expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(), + content: format!( + "{} as WorkspaceConfigSchema", + crate::plugin_settings::render_value(&value).unwrap() + ), + }], + }, + bundle, + ) + .unwrap(); + let state = self + .store + .commit_evaluated_workspace_config("a", &candidate) + .unwrap(); + self.store + .with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + crate::plugin_settings::resolve_worker_plugin_plan( + &tx, + "a", + &state.snapshot.digest, + "project:tool", + input(vec![]).execution, + ) + }) + .unwrap() + } + fn disable(&self) { + self.configure(&[]); + } +} + +impl SqliteWorkspaceStore { + // Data-only donor for cross-owner migration preservation tests. The caller + // imports explicit rows into real retained DDL, never this donor's schema or + // migration ledgers. Production catalog/config/plan APIs generate the bytes. + pub(crate) async fn plugin_plan_migration_donor() -> ( + TempDir, + Self, + Vec<(String, PluginPlanCreateReceipt, PlanPinState)>, + ) { + let f = Fixture::new().await; + let one = f.install("migration-one"); + let two = f.install("migration-two"); + let plan = f.configure(&[&one, &two]); + assert_ne!( + plan.plugins()[0].package_digest, + plan.plugins()[0].artifact_digest + ); + let mut receipts = Vec::new(); + for (key, state) in [ + ("pending", PlanPinState::Pending), + ("active", PlanPinState::Active), + ("restorable", PlanPinState::Restorable), + ("released", PlanPinState::Released), + ] { + let receipt = f.store.plugin_plan_create("actor", key, &plan).unwrap(); + if matches!(state, PlanPinState::Active | PlanPinState::Restorable) { + f.transition( + &receipt.plan_id, + PlanPinState::Pending, + PlanPinState::Active, + ); + } + if state == PlanPinState::Restorable { + f.transition(&receipt.plan_id, PlanPinState::Active, state); + } else if state == PlanPinState::Released { + f.transition(&receipt.plan_id, PlanPinState::Pending, state); + } + receipts.push((key.into(), receipt, state)); + } + (f.dir, f.store, receipts) + } +} diff --git a/crates/workspace-server/src/plugin_settings.rs b/crates/workspace-server/src/plugin_settings.rs new file mode 100644 index 000000000..36899e56b --- /dev/null +++ b/crates/workspace-server/src/plugin_settings.rs @@ -0,0 +1,709 @@ +//! Catalog-backed Plugin configuration. The virtual config tree remains the only +//! configuration authority; static metadata resolution never instantiates a guest. + +use std::collections::{BTreeMap, BTreeSet}; + +use config_source::{ + ConfigSchemaContribution, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment, + ToolchainContract, WorkspaceConfigSchemaBundle, +}; +use rusqlite::Transaction; +use serde::{Deserialize, Serialize}; +use server_api::{PluginArtifact, PluginGrantMetadata}; + +use crate::config_source::WorkspaceConfigSchemaProvider; +use crate::plugin_binding::PluginConnectionBinding; +use crate::plugin_catalog::{ + PluginReference, PluginReferenceKind, plugin_artifact_admit, plugin_reference_put, + plugin_reference_remove, +}; +use crate::{Error, Result}; + +const PROVIDER: &str = "builtin:plugins"; +const BOOTSTRAP: &str = include_str!("../../../resources/config-schema/plugins.dcdl"); +const CONFIG_OWNER: &str = "workspace-config"; + +#[derive(Default)] +pub struct PluginConfigSchemaProvider; +impl WorkspaceConfigSchemaProvider for PluginConfigSchemaProvider { + fn contribution(&self) -> Result { + ConfigSchemaContribution::new( + PROVIDER, + "plugins", + config_source::digest_bytes(BOOTSTRAP.as_bytes()), + BOOTSTRAP, + ) + .map_err(config_error) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginEnablement { + pub installation_id: String, + pub expected_last_operation: String, + pub package_digest: String, + pub artifact_digest: String, + pub tools: Vec, + pub logging: bool, + pub connections: BTreeMap, + pub config: serde_json::Value, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginSettings { + pub enabled: BTreeMap, + pub bindings: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct ResolvedPluginSettings { + pub settings: PluginSettings, + pub artifacts: BTreeMap, +} + +fn config_error(error: impl std::fmt::Display) -> Error { + Error::InvalidInput(format!("Plugin config: {error}")) +} + +fn contract(bundle: WorkspaceConfigSchemaBundle) -> ToolchainContract { + ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![ + config_source::VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT) + .expect("main path"), + ], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + bundle, + ) +} + +fn evaluate( + snapshot: &ConfigTreeSnapshot, + contract: &ToolchainContract, +) -> Result { + SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(contract) + .map_err(|diagnostics| { + config_error( + serde_json::to_string(&diagnostics) + .unwrap_or_else(|_| "invalid static config".into()), + ) + }) +} + +pub fn settings_from_evaluation(evaluation: &EvaluationResult) -> Result { + let value = evaluation + .projections + .first() + .ok_or_else(|| config_error("missing projection"))?; + let root = &value.data_json; + match root.get("plugins") { + Some(value) => serde_json::from_value(value.clone()).map_err(config_error), + None => Ok(PluginSettings::default()), + } +} + +/// Resolve core references before composing the closed artifact-specific schema. +/// Both reads use the owner's transaction; the resulting bundle fixes the exact +/// catalog operation as well as both byte identities, not a generic counter. +pub(crate) fn evaluate_catalog_config( + tx: &Transaction<'_>, + workspace: &str, + snapshot: &ConfigTreeSnapshot, + bundle: WorkspaceConfigSchemaBundle, +) -> Result<(ToolchainContract, EvaluationResult, ResolvedPluginSettings)> { + let mut providers = bundle + .contributions + .into_iter() + .filter(|p| p.provider_id != PROVIDER) + .collect::>(); + providers.push(PluginConfigSchemaProvider.contribution()?); + let bootstrap = + contract(WorkspaceConfigSchemaBundle::compose(providers.clone()).map_err(config_error)?); + let core = evaluate(snapshot, &bootstrap)?; + let settings = settings_from_evaluation(&core)?; + crate::plugin_binding::validate_bindings( + &settings.bindings, + &crate::plugin_binding::PluginConnectionPolicyCeiling::default(), + ) + .map_err(config_error)?; + if settings.enabled.len() > 128 { + return Err(config_error("too many enablements")); + } + let mut artifacts = BTreeMap::new(); + let mut schema_identities = BTreeMap::new(); + let mut installations = BTreeSet::new(); + for (alias, enabled) in &settings.enabled { + if !installations.insert(&enabled.installation_id) { + return Err(config_error("duplicate enabled installation")); + } + if !safe_name(alias) { + return Err(config_error("invalid enablement alias")); + } + let artifact = plugin_artifact_admit( + tx, + workspace, + &enabled.installation_id, + &enabled.expected_last_operation, + &enabled.package_digest, + &enabled.artifact_digest, + )?; + validate_enablement(enabled, &artifact, &settings)?; + let schema = &artifact.config_schema; + let key = (schema.identity.clone(), schema.version.clone()); + let value = (schema.namespace.clone(), schema.digest.clone()); + if let Some(previous) = schema_identities.insert(key, value.clone()) { + if previous != value { + return Err(config_error("schema identity collision")); + } + } + artifacts.insert(alias.clone(), artifact); + } + // 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) + }).collect::>().join("\n"); + let enabled_schema = if enabled_fields.is_empty() { + "{} default {}".to_string() + } else { + format!("{{{enabled_fields}}}") + }; + let (prefix, remaining) = BOOTSTRAP + .split_once("enabled = ") + .ok_or_else(|| config_error("missing bootstrap enablement"))?; + let (_, suffix) = remaining + .split_once(";\n bindings = ") + .ok_or_else(|| config_error("missing bootstrap bindings"))?; + let source = format!("{prefix}enabled = {enabled_schema};\n bindings = {suffix}"); + let identities = settings + .enabled + .iter() + .map(|(alias, e)| { + ( + alias, + &e.installation_id, + &e.expected_last_operation, + &e.package_digest, + &e.artifact_digest, + &artifacts[alias].config_schema, + ) + }) + .collect::>(); + let version = format!( + "{}:{}", + config_source::digest_bytes(BOOTSTRAP.as_bytes()), + config_source::digest_bytes(&serde_json::to_vec(&identities).map_err(config_error)?) + ); + providers.retain(|p| p.provider_id != PROVIDER); + providers.push( + ConfigSchemaContribution::new(PROVIDER, "plugins", version, source) + .map_err(config_error)?, + ); + let full_contract = + contract(WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?); + let full = evaluate(snapshot, &full_contract)?; + let final_settings = settings_from_evaluation(&full)?; + let resolved = ResolvedPluginSettings { + settings: final_settings, + artifacts, + }; + validate_profile_selections(&full, &resolved)?; + Ok((full_contract, full, resolved)) +} + +fn safe_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .as_bytes() + .first() + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +fn validate_enablement( + enabled: &PluginEnablement, + artifact: &PluginArtifact, + settings: &PluginSettings, +) -> Result<()> { + if artifact.schema_version != 2 + || !matches!( + artifact.world.as_str(), + manifest::plugin::PLUGIN_COMPONENT_TOOL_WORLD + | manifest::plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD + | manifest::plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD + | manifest::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD + ) + || artifact.required_imports.iter().any(|i| { + !matches!( + i.as_str(), + "yoi:host/errors@2.0.0" + | "yoi:host/connections@2.0.0" + | "yoi:host/http@2.0.0" + | "yoi:host/logging@2.0.0" + ) + }) + { + return Err(config_error("unsupported Component API")); + } + let tools = enabled.tools.iter().collect::>(); + if tools.len() != enabled.tools.len() + || tools + .iter() + .any(|name| !artifact.tools.iter().any(|tool| &tool.name == *name)) + { + return Err(config_error("unknown or duplicate Tool selection")); + } + if enabled.logging + && !artifact + .requested_grants + .contains(&PluginGrantMetadata::Logging) + { + return Err(config_error("logging exceeds package request")); + } + for (slot, binding) in &enabled.connections { + let requested = artifact + .connections + .iter() + .find(|s| &s.name == slot) + .ok_or_else(|| config_error("undeclared connection slot"))?; + let binding = settings + .bindings + .get(binding) + .ok_or_else(|| config_error("missing connection binding"))?; + // A binding can be narrower than the package request, never wider. + if binding + .methods + .iter() + .any(|m| !requested.methods.contains(m)) + || binding.path_prefixes.iter().any(|path| { + !requested.path_prefixes.iter().any(|prefix| { + prefix == "/" + || path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) + }) + { + return Err(config_error("binding exceeds package request")); + } + } + if serde_json::to_vec(&enabled.config) + .map_err(config_error)? + .len() + > 64 * 1024 + { + return Err(config_error("non-secret config exceeds 64 KiB")); + } + Ok(()) +} + +pub(crate) fn sync_config_references( + tx: &Transaction<'_>, + workspace: &str, + resolved: &ResolvedPluginSettings, +) -> Result<()> { + let existing = { + let mut statement = tx.prepare("SELECT installation_id, artifact_digest FROM plugin_references WHERE workspace_id=?1 AND kind='config' AND owner=?2")?; + statement + .query_map(rusqlite::params![workspace, CONFIG_OWNER], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })? + .collect::, _>>()? + }; + for (installation, digest) in existing { + plugin_reference_remove( + tx, + workspace, + PluginReferenceKind::Config, + CONFIG_OWNER, + &installation, + &digest, + )?; + } + for enabled in resolved.settings.enabled.values() { + plugin_reference_put( + tx, + workspace, + &PluginReference { + kind: PluginReferenceKind::Config, + owner: CONFIG_OWNER.into(), + installation_id: enabled.installation_id.clone(), + expected_last_operation: enabled.expected_last_operation.clone(), + package_digest: enabled.package_digest.clone(), + artifact_digest: enabled.artifact_digest.clone(), + }, + )?; + } + Ok(()) +} + +fn validate_profile_selections( + evaluation: &EvaluationResult, + resolved: &ResolvedPluginSettings, +) -> Result<()> { + let root = &evaluation.projections[0].data_json; + if let Some(entries) = root + .pointer("/profile/entries") + .and_then(serde_json::Value::as_array) + { + for entry in entries { + if let Some(plugins) = entry.pointer("/profile/plugins") { + let plugins: manifest::PluginProfileConfig = + serde_json::from_value(plugins.clone()).map_err(config_error)?; + project_profile_plugins(resolved, &plugins)?; + } + } + } + Ok(()) +} + +fn project_profile_plugins( + resolved: &ResolvedPluginSettings, + profile: &manifest::PluginProfileConfig, +) -> Result> { + use crate::plugin_plan::{ + BindingPolicyReference, PluginGrant, PluginPinProjection, PluginSurface, WorkerPluginPin, + }; + let mut selected = BTreeSet::new(); + let mut pins = Vec::new(); + for selection in &profile.selections { + if !selected.insert(&selection.installation_id) { + return Err(config_error("duplicate Profile installation")); + } + let (alias, enabled) = resolved + .settings + .enabled + .iter() + .find(|(_, e)| e.installation_id == selection.installation_id) + .ok_or_else(|| { + config_error("Profile selects an installation not enabled by Workspace") + })?; + if selection + .tools + .iter() + .any(|tool| !enabled.tools.contains(tool)) + || (selection.logging && !enabled.logging) + || selection + .https_slots + .iter() + .any(|slot| !enabled.connections.contains_key(slot)) + { + return Err(config_error( + "Profile exceeds Workspace surface/grant ceiling", + )); + } + let mut config = enabled.config.clone(); + let object = config + .as_object_mut() + .ok_or_else(|| config_error("non-secret config must be an object"))?; + for (key, value) in &selection.config { + object.insert(key.clone(), value.clone()); + } + let config = + evaluate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?; + let mut grants = Vec::new(); + if selection.logging { + grants.push(PluginGrant::Logging); + } + let mut bindings = Vec::new(); + for slot in &selection.https_slots { + let binding_id = &enabled.connections[slot]; + let binding = &resolved.settings.bindings[binding_id]; + grants.push(PluginGrant::Https { slot: slot.clone() }); + bindings.push(BindingPolicyReference { + slot: slot.clone(), + binding_id: binding_id.clone(), + policy_digest: binding.digest(), + }); + } + pins.push(WorkerPluginPin::from_artifact( + enabled.installation_id.clone(), + enabled.expected_last_operation.clone(), + &resolved.artifacts[alias], + PluginPinProjection { + config, + surfaces: if selection.tools.is_empty() { + vec![] + } else { + vec![PluginSurface::Tool] + }, + tools: selection.tools.clone(), + grants, + bindings, + }, + )?); + } + Ok(pins) +} + +fn evaluate_effective_config(value: &serde_json::Value, schema: &str) -> Result { + use config_source::{ConfigContentType, ConfigEntry, VirtualPath}; + if serde_json::to_vec(value).map_err(config_error)?.len() > 64 * 1024 { + return Err(config_error("non-secret config exceeds 64 KiB")); + } + let path = VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT).expect("main path"); + let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new( + path, + ConfigContentType::Decodal, + format!( + "{{ config = {}; }} as WorkspaceConfigSchema", + render_value(value)? + ), + ) + .map_err(config_error)?]) + .map_err(config_error)?; + let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "plugin-effective-config", + "config", + "1", + format!("{{ config = ({schema}); }}"), + ) + .map_err(config_error)?]) + .map_err(config_error)?; + // Static singleton fields can concretize omitted nested override values. + // Pin the complete projection, not just the candidate that passed validation. + let evaluated = evaluate(&snapshot, &contract(bundle))?; + let config = evaluated + .projections + .first() + .and_then(|projection| projection.data_json.get("config")) + .filter(|config| config.is_object()) + .ok_or_else(|| config_error("effective Plugin config projection must be an object"))?; + if serde_json::to_vec(config).map_err(config_error)?.len() > 64 * 1024 { + return Err(config_error("non-secret config exceeds 64 KiB")); + } + Ok(config.clone()) +} + +pub(crate) fn render_value(value: &serde_json::Value) -> Result { + Ok(match value { + serde_json::Value::Object(fields) => format!( + "{{ {} }}", + fields + .iter() + .map(|(key, value)| Ok(format!( + "{} = {};", + if safe_name(key) { + key.clone() + } else { + return Err(config_error("config key is not a static identifier")); + }, + render_value(value)? + ))) + .collect::>>()? + .join(" ") + ), + serde_json::Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(render_value) + .collect::>>()? + .join(", ") + ), + serde_json::Value::Null => return Err(config_error("null is not a Plugin config value")), + value => serde_json::to_string(value).map_err(config_error)?, + }) +} + +/// Build a maximum immutable snapshot from the current Workspace config and an +/// actual registered Profile. The Host supplies execution/delegation identities; +/// a caller-provided projection, binding digest or package payload is never used. +/// The owning Worker-create transaction may further narrow this snapshot, then +/// persist it with `plugin_plan_create` and its own launch fingerprint atomically. +/// This does not admit execution, resolve secrets or start/restore a Worker. +pub fn resolve_worker_plugin_plan( + tx: &Transaction<'_>, + workspace: &str, + expected_content_digest: &str, + profile_selector: &str, + execution: crate::plugin_plan::HostExecutionIdentity, +) -> Result { + use crate::plugin_plan::{ + ConfigProvenance, ProfileProvenance, WorkerPluginExecutionPlan, WorkerPluginPlanInput, + }; + let state = crate::config_source::load_state(tx, workspace)? + .ok_or_else(|| config_error("Workspace config is not materialized"))?; + if state.snapshot.digest != expected_content_digest { + return Err(Error::WorkspaceConfigConflict( + "plan source content changed".into(), + )); + } + let (contract, evaluation, resolved) = evaluate_catalog_config( + tx, + workspace, + &state.snapshot, + state.contract.schema_bundle.clone(), + )?; + if state.contract != contract || state.projection_digest != evaluation.projection_digest { + return Err(Error::WorkspaceConfigConflict( + "plan source catalog/schema changed".into(), + )); + } + let profile = resolve_profile(&evaluation, &state.snapshot.digest, profile_selector)?; + let projection_digest = config_source::digest_bytes( + &serde_json::to_vec(&profile.manifest_snapshot).map_err(config_error)?, + ); + WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput { + workspace_id: workspace.into(), + config: ConfigProvenance { + content_digest: state.snapshot.digest, + toolchain_fingerprint: contract.fingerprint, + }, + profile: ProfileProvenance { + identity: profile_selector.into(), + projection_digest, + }, + execution, + plugins: project_profile_plugins(&resolved, &profile.manifest.plugins)?, + }) +} + +fn resolve_profile( + evaluation: &EvaluationResult, + content_digest: &str, + selector: &str, +) -> Result { + let root = &evaluation.projections[0].data_json; + if selector.starts_with("project:") { + let value = root + .pointer("/profile/entries") + .and_then(serde_json::Value::as_array) + .and_then(|entries| { + entries.iter().find(|entry| { + entry.get("selector").and_then(serde_json::Value::as_str) == Some(selector) + }) + }) + .and_then(|entry| entry.get("profile")) + .ok_or_else(|| config_error("unknown registered Profile"))?; + manifest::resolve_profile_artifact_value( + value.clone(), + manifest::ProfileSource::Archive { + archive_id: format!("workspace-config:{content_digest}"), + source: selector.into(), + }, + std::path::Path::new("/"), + "plugin-plan-projection", + ) + .map_err(config_error) + } else if selector.starts_with("builtin:") { + let registry = manifest::ProfileDiscovery::with_sources(None, None) + .discover() + .map_err(config_error)?; + manifest::ProfileResolver::new() + .with_workspace_base("/") + .resolve_from_registry( + &manifest::ProfileSelector::source_named( + manifest::ProfileRegistrySource::Builtin, + selector.trim_start_matches("builtin:"), + ), + ®istry, + manifest::ProfileResolveOptions::with_worker_name("plugin-plan-projection"), + ) + .map_err(config_error) + } else { + Err(config_error("unknown registered Profile selector")) + } +} + +/// Check the owner-supplied launch snapshot against config authority on first +/// create. Exact replay deliberately uses the saved snapshot instead of rereading +/// changed config. Delegation can only remove pins/tools/grants from this maximum. +pub(crate) fn validate_plan_source( + tx: &Transaction<'_>, + plan: &crate::plugin_plan::WorkerPluginExecutionPlan, +) -> Result<()> { + let maximum = resolve_worker_plugin_plan( + tx, + plan.workspace_id(), + &plan.config().content_digest, + &plan.profile().identity, + plan.execution().clone(), + )?; + if maximum.config() != plan.config() || maximum.profile() != plan.profile() { + return Err(config_error("plan source provenance mismatch")); + } + for pin in plan.plugins() { + let upper = maximum + .plugins() + .iter() + .find(|p| p.installation_id() == pin.installation_id()) + .ok_or_else(|| config_error("plan exceeds selected Profile"))?; + let lower = pin.projection(); + let max = upper.projection(); + if pin.package_digest() != upper.package_digest() + || pin.artifact_digest() != upper.artifact_digest() + || pin.expected_last_operation() != upper.expected_last_operation() + || pin.config_digest() != upper.config_digest() + || pin.config_schema() != upper.config_schema() + || lower.config != max.config + || lower.tools.iter().any(|tool| !max.tools.contains(tool)) + || lower.grants.iter().any(|grant| !max.grants.contains(grant)) + || lower + .bindings + .iter() + .any(|binding| !max.bindings.contains(binding)) + || lower + .surfaces + .iter() + .any(|surface| !max.surfaces.contains(surface)) + { + return Err(config_error("plan exceeds config/Profile upper bound")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; + +pub(crate) fn base_schema_bundle( + bundle: &WorkspaceConfigSchemaBundle, +) -> Result { + let mut contributions = bundle + .contributions + .iter() + .filter(|p| p.provider_id != PROVIDER) + .cloned() + .collect::>(); + if bundle + .contributions + .iter() + .any(|p| p.provider_id == PROVIDER) + { + contributions.push(PluginConfigSchemaProvider.contribution()?); + } + WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error) +} + +// Saved config retains the schema that validated it, even when live catalog +// admission changes. Management must remain available to disable/rebind it after +// restart. New preview/create still rechecks the live catalog inside its tx. +pub(crate) fn materialization_schema( + desired: WorkspaceConfigSchemaBundle, + saved: &WorkspaceConfigSchemaBundle, +) -> Result { + let prefix = format!("{}:", config_source::digest_bytes(BOOTSTRAP.as_bytes())); + let active = saved + .contributions + .iter() + .find(|p| p.provider_id == PROVIDER && p.version.starts_with(&prefix)); + let Some(active) = active else { + return Ok(desired); + }; + let contributions = desired.contributions.into_iter().map(|p| { + if p.provider_id == PROVIDER { + active.clone() + } else { + p + } + }); + WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error) +} diff --git a/crates/workspace-server/src/plugin_settings/tests.rs b/crates/workspace-server/src/plugin_settings/tests.rs new file mode 100644 index 000000000..ca7279044 --- /dev/null +++ b/crates/workspace-server/src/plugin_settings/tests.rs @@ -0,0 +1,584 @@ +//! Real catalog + virtual config + SQLite transactions. HTTP authentication and +//! guest execution are outside this suite; the canonical Server config path is used. +use super::*; +use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState}; +use crate::store::{ControlPlaneStore, WorkspaceRecord}; +use config_source::{ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle}; +use serde_json::json; +use server_api::*; +use tempfile::{TempDir, tempdir}; + +struct Fixture { + dir: TempDir, + store: crate::SqliteWorkspaceStore, + installed: PluginOperationResponse, +} +impl Fixture { + async fn new() -> Self { + Self::with_schema("{ mode = String; }").await + } + async fn with_schema(schema: &str) -> Self { + let dir = tempdir().unwrap(); + let store = crate::SqliteWorkspaceStore::open_for_test(dir.path().join("db")).unwrap(); + for id in ["a", "b"] { + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: id.into(), + owner_account_id: "owner-account".into(), + display_name: id.into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .await + .unwrap(); + } + let upload = store + .plugin_upload( + &dir.path().join("artifacts"), + "a", + "owner", + &package_with_schema("0.1.0", schema), + ) + .unwrap(); + let installed = store + .plugin_install( + "a", + "owner", + &PluginInstallRequest { + upload_id: upload.upload_id, + alias: "echo".into(), + operation_key: "install".into(), + }, + ) + .unwrap(); + for id in ["a", "b"] { + store + .ensure_workspace_config_materialized_with_schema(id, "1", bundle()) + .unwrap(); + } + Self { + dir, + store, + installed, + } + } + fn source(&self) -> serde_json::Value { + let a = &self.installed.installation.artifacts[0]; + json!({"plugins":{"enabled":{"echo":{ + "installation_id":self.installed.installation.installation_id, + "expected_last_operation":self.installed.operation_id, + "package_digest":a.package_digest,"artifact_digest":a.artifact_digest, + "tools":["example_echo"],"logging":false,"connections":{},"config":{"mode":"hello"} + }},"bindings":{}},"profile":{"default_profile":"project:tool","entries":[{"selector":"project:tool","profile":{"plugins":{"selections":[{"installation_id":self.installed.installation.installation_id,"tools":["example_echo"],"logging":false}]}},"label":"","description":""}]}}) + } + fn candidate( + &self, + workspace: &str, + value: serde_json::Value, + ) -> Result { + let state = self.store.load_workspace_config(workspace)?.unwrap(); + let path = VirtualPath::parse("main.dcdl").unwrap(); + let request = ConfigCommitRequest { + base_digest: state.snapshot.digest.clone(), + entrypoints: vec![path.clone()], + changes: vec![ConfigTreeChange::Update { + path: path.clone(), + expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(), + content: format!("{} as WorkspaceConfigSchema", render_value(&value)?), + }], + }; + self.store + .evaluate_workspace_config_candidate_with_schema(workspace, &request, bundle()) + } + fn commit(&self, value: serde_json::Value) -> WorkspaceConfigState { + let c = self.candidate("a", value).unwrap(); + self.store + .commit_evaluated_workspace_config("a", &c) + .unwrap() + } + fn refs(&self) -> i64 { + self.store + .with_conn(|c| { + Ok(c.query_row( + "SELECT count(*) FROM plugin_references WHERE kind='config'", + [], + |r| r.get(0), + )?) + }) + .unwrap() + } + fn plan(&self, state: &WorkspaceConfigState) -> crate::plugin_plan::WorkerPluginExecutionPlan { + self.store + .with_conn(|c| { + let tx = c.unchecked_transaction()?; + resolve_worker_plugin_plan( + &tx, + "a", + &state.snapshot.digest, + "project:tool", + crate::plugin_plan::HostExecutionIdentity { + runtime_id: "runtime".into(), + worker_id: "worker".into(), + principal_id: "principal".into(), + delegation_id: None, + }, + ) + }) + .unwrap() + } +} +fn bundle() -> WorkspaceConfigSchemaBundle { + WorkspaceConfigSchemaBundle::compose([ + PluginConfigSchemaProvider.contribution().unwrap(), + crate::profile_settings::ProfileConfigSchemaProvider + .contribution() + .unwrap(), + ]) + .unwrap() +} +fn package(version: &str) -> Vec { + package_with_schema(version, "{ mode = String; }") +} +fn package_with_schema(version: &str, schema: &str) -> Vec { + use manifest::plugin::*; + let d = tempdir().unwrap(); + let source = + include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml") + .replace("version = \"0.1.0\"", &format!("version = \"{version}\"")) + .replace( + "config_schema = \"{}\"", + &format!("config_schema = {}", serde_json::to_string(schema).unwrap()), + ); + std::fs::write(d.path().join("plugin.toml"), source).unwrap(); + std::fs::write( + d.path().join("plugin.component.wasm"), + include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"), + ) + .unwrap(); + let parsed = read_plugin_directory( + d.path(), + PluginSourceKind::Explicit, + &PluginPackageLimits::default(), + ) + .unwrap(); + pack_plugin_package(&parsed, &PluginPackageLimits::default()) + .unwrap() + .bytes +} + +#[tokio::test] +async fn config_two_phase_closed_schema_and_profile_narrowing_reject_unknown_abstract_and_expansion() + { + let f = Fixture::new().await; + let source = f.source(); + let candidate = f.candidate("a", source.clone()).unwrap(); + assert!( + candidate + .contract + .schema_bundle + .source + .contains("mode = String") + ); + let native = SnapshotEnvironment::new(candidate.snapshot.clone()) + .evaluate_contract(&candidate.contract) + .unwrap(); + assert_eq!(native, candidate.evaluation); + for pointer in [ + "/plugins/enabled/echo/config/extra", + "/plugins/enabled/echo/logging", + "/profile/entries/0/profile/plugins/selections/0/logging", + ] { + let mut bad = source.clone(); + if pointer.ends_with("extra") { + bad["plugins"]["enabled"]["echo"]["config"]["extra"] = json!(true); + } else { + *bad.pointer_mut(pointer) + .unwrap_or_else(|| panic!("missing {pointer}")) = json!(true); + } + assert!(f.candidate("a", bad).is_err(), "accepted {pointer}"); + } + let mut invalid = source.clone(); + invalid["plugins"]["enabled"]["echo"]["config"]["mode"] = json!(3); + assert!(f.candidate("a", invalid).is_err()); + assert!(f.candidate("b", source).is_err()); + assert_eq!(f.refs(), 0); +} + +#[tokio::test] +async fn config_refs_commit_atomically_block_uninstall_and_release_on_disable() { + let f = Fixture::new().await; + f.commit(f.source()); + assert_eq!(f.refs(), 1); + assert!( + f.store + .plugin_uninstall( + "a", + "owner", + &f.installed.installation.installation_id, + &PluginUninstallRequest { + expected_last_operation: f.installed.operation_id.clone(), + operation_key: "remove".into() + } + ) + .is_err() + ); + f.commit(json!({"plugins":{},"profile":{"entries":[]}})); + assert_eq!(f.refs(), 0); + f.store + .plugin_uninstall( + "a", + "owner", + &f.installed.installation.installation_id, + &PluginUninstallRequest { + expected_last_operation: f.installed.operation_id.clone(), + operation_key: "remove-after-disable".into(), + }, + ) + .unwrap(); +} + +#[tokio::test] +async fn catalog_add_after_preview_rejects_stale_candidate_and_preserves_config_refs() { + let f = Fixture::new().await; + let candidate = f.candidate("a", f.source()).unwrap(); + let upload = f + .store + .plugin_upload( + &f.dir.path().join("artifacts"), + "a", + "owner", + &package("0.2.0"), + ) + .unwrap(); + f.store + .plugin_add_artifact( + "a", + "owner", + &f.installed.installation.installation_id, + &PluginAddArtifactRequest { + upload_id: upload.upload_id, + expected_last_operation: f.installed.operation_id.clone(), + operation_key: "update".into(), + }, + ) + .unwrap(); + assert!( + f.store + .commit_evaluated_workspace_config("a", &candidate) + .is_err() + ); + assert_eq!(f.refs(), 0); +} + +#[tokio::test] +async fn immutable_plan_create_replay_and_config_changes_do_not_hot_reload() { + use crate::plugin_plan::*; + let f = Fixture::new().await; + let state = f.commit(f.source()); + let plan = f.plan(&state); + assert_eq!(plan, f.plan(&state)); + let receipt = f.store.plugin_plan_create("host", "create", &plan).unwrap(); + let json = plan.canonical_json().unwrap(); + assert!(!json.contains("https://")); + assert!(!json.contains(f.dir.path().to_str().unwrap())); + let mut source = f.source(); + source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["tools"] = json!([]); + f.commit(source); + assert_eq!( + f.store.plugin_plan_create("host", "create", &plan).unwrap(), + receipt + ); + let saved = f.store.plugin_plan_load("a", &receipt.plan_id).unwrap(); + assert_eq!(saved.plan, plan); + let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap(); + assert_eq!( + reopened.plugin_plan_load("a", &receipt.plan_id).unwrap(), + saved + ); + reopened + .plugin_plan_transition( + "a", + &receipt.plan_id, + PlanPinState::Pending, + PlanPinState::Released, + ) + .unwrap(); + assert_eq!( + reopened + .plugin_plan_create("host", "create", &plan) + .unwrap(), + receipt + ); +} + +#[tokio::test] +async fn profile_override_is_closed_schema_validated_and_plan_only_uses_effective_config() { + let f = Fixture::new().await; + let mut source = f.source(); + source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] = + json!({"mode":"override"}); + let state = f.commit(source.clone()); + let plan = f.plan(&state); + assert_eq!( + plan.plugins()[0].projection().config, + json!({"mode":"override"}) + ); + source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] = + json!({"unknown":"not permitted"}); + assert!(f.candidate("a", source).is_err()); +} + +#[tokio::test] +async fn public_create_rejects_stale_or_forged_config_profile_and_projection_without_pins() { + use crate::plugin_plan::WorkerPluginExecutionPlan; + let f = Fixture::new().await; + let state = f.commit(f.source()); + let plan = f.plan(&state); + for pointer in [ + "/input/config/content_digest", + "/input/profile/projection_digest", + "/input/plugins/0/config_digest", + ] { + let mut forged = serde_json::to_value(&plan).unwrap(); + *forged.pointer_mut(pointer).unwrap() = json!(config_source::digest_bytes(b"forged")); + let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap(); + assert!( + f.store + .plugin_plan_create("host", pointer, &forged) + .is_err() + ); + } + let mut forged = serde_json::to_value(&plan).unwrap(); + forged["input"]["plugins"][0]["projection"]["config"] = json!({"mode":"caller-injected"}); + let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap(); + assert!( + f.store + .plugin_plan_create("host", "value-injection", &forged) + .is_err() + ); + let mut next = f.source(); + next["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["tools"] = json!([]); + f.commit(next); + assert!( + f.store + .plugin_plan_create("host", "stale-plan", &plan) + .is_err() + ); + f.store + .with_conn(|c| { + assert_eq!( + c.query_row("SELECT count(*) FROM plugin_execution_plans", [], |r| r + .get::<_, i64>(0))?, + 0 + ); + assert_eq!( + c.query_row( + "SELECT count(*) FROM plugin_references WHERE kind != 'config'", + [], + |r| r.get::<_, i64>(0) + )?, + 0 + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn config_persistence_failure_rolls_back_reference_changes_and_restart_keeps_management_after_revoke() + { + let f = Fixture::new().await; + let before = f.store.load_workspace_config("a").unwrap().unwrap(); + let candidate = f.candidate("a", f.source()).unwrap(); + f.store.with_conn(|c| { c.execute_batch("CREATE TRIGGER stop_config BEFORE INSERT ON workspace_config_entries BEGIN SELECT RAISE(ABORT,'config failure'); END;")?; Ok(()) }).unwrap(); + assert!( + f.store + .commit_evaluated_workspace_config("a", &candidate) + .is_err() + ); + assert_eq!(f.refs(), 0); + assert_eq!(f.store.load_workspace_config("a").unwrap().unwrap(), before); + f.store + .with_conn(|c| { + c.execute_batch("DROP TRIGGER stop_config")?; + Ok(()) + }) + .unwrap(); + let committed = f + .store + .commit_evaluated_workspace_config("a", &candidate) + .unwrap(); + let a = &f.installed.installation.artifacts[0]; + f.store + .plugin_revoke( + "a", + "owner", + &f.installed.installation.installation_id, + &PluginRevokeRequest { + artifact_digest: a.artifact_digest.clone(), + expected_last_operation: f.installed.operation_id.clone(), + reason: "withdraw".into(), + operation_key: "withdraw".into(), + }, + ) + .unwrap(); + let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap(); + assert_eq!( + reopened + .ensure_workspace_config_materialized_with_schema("a", "2", bundle()) + .unwrap(), + committed + ); + assert!(f.candidate("a", f.source()).is_err()); + f.commit(json!({"plugins":{},"profile":{"entries":[]}})); + assert_eq!(f.refs(), 0); +} + +#[tokio::test] +async fn https_config_binding_is_validated_pinned_by_digest_and_never_copied_to_plan() { + use crate::plugin_plan::WorkerPluginExecutionPlan; + use manifest::plugin::*; + let f = Fixture::new().await; + let d = tempdir().unwrap(); + std::fs::write( + d.path().join("plugin.toml"), + include_str!("../../../../resources/plugin/templates/rust-component-https/plugin.toml"), + ) + .unwrap(); + std::fs::write( + d.path().join("plugin.component.wasm"), + include_bytes!("../../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"), + ) + .unwrap(); + let limits = PluginPackageLimits::default(); + let bytes = pack_plugin_package( + &read_plugin_directory(d.path(), PluginSourceKind::Explicit, &limits).unwrap(), + &limits, + ) + .unwrap() + .bytes; + let upload = f + .store + .plugin_upload(&f.dir.path().join("artifacts"), "a", "owner", &bytes) + .unwrap(); + let installed = f + .store + .plugin_install( + "a", + "owner", + &PluginInstallRequest { + upload_id: upload.upload_id, + alias: "https".into(), + operation_key: "https-install".into(), + }, + ) + .unwrap(); + let artifact = &installed.installation.artifacts[0]; + let mut source = f.source(); + source["plugins"]["enabled"]["echo"] = json!({"installation_id":installed.installation.installation_id,"expected_last_operation":installed.operation_id,"package_digest":artifact.package_digest,"artifact_digest":artifact.artifact_digest,"tools":["example_status"],"connections":{"api":"remote"},"config":{}}); + source["plugins"]["bindings"] = json!({"remote":{"origin":"https://api.example.com","methods":["GET"],"path_prefixes":["/status"],"request_headers":["accept"],"response_headers":["content-type"],"secret_ref":"credential_ref","limits":{"timeout_ms":1000,"max_request_bytes":1024,"max_response_bytes":1024,"max_header_bytes":512}}}); + source["profile"]["entries"][0]["profile"]["plugins"]["selections"] = json!([{"installation_id":installed.installation.installation_id,"tools":["example_status"],"https_slots":["api"]}]); + let state = f.commit(source.clone()); + let plan = f.plan(&state); + let encoded = plan.canonical_json().unwrap(); + for excluded in [ + "https://api.example.com", + "credential_ref", + "origin", + "request_headers", + "secret_ref", + ] { + assert!(!encoded.contains(excluded), "leaked {excluded}"); + } + assert_eq!( + plan.plugins()[0].projection().bindings[0].binding_id, + "remote" + ); + let receipt = f + .store + .plugin_plan_create("host", "https-plan", &plan) + .unwrap(); + let mut forged = serde_json::to_value(&plan).unwrap(); + forged["input"]["plugins"][0]["projection"]["bindings"][0]["policy_digest"] = + json!(config_source::digest_bytes(b"forged-policy")); + let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap(); + assert!( + f.store + .plugin_plan_create("host", "forged-policy", &forged) + .is_err() + ); + for path in ["/statusish", "/%2e%2e/private"] { + let mut bad = source.clone(); + bad["plugins"]["bindings"]["remote"]["path_prefixes"] = json!([path]); + assert!(f.candidate("a", bad).is_err()); + } + source["plugins"]["bindings"]["remote"]["origin"] = json!("https://other.example.com"); + f.commit(source); + assert!( + f.store + .plugin_plan_create("host", "stale-binding", &plan) + .is_err() + ); + assert_eq!( + f.store + .plugin_plan_create("host", "https-plan", &plan) + .unwrap(), + receipt + ); +} + +#[tokio::test] +async fn nested_profile_override_pins_evaluated_singletons_through_create_restart_and_replay() { + use crate::plugin_plan::{WorkerPluginExecutionPlan, WorkerPluginPlanInput}; + let f = Fixture::with_schema("{ nested = { mode = String; count = 1; }; }").await; + let mut source = f.source(); + source["plugins"]["enabled"]["echo"]["config"] = json!({"nested":{"mode":"base","count":1}}); + source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] = + json!({"nested":{"mode":"override"}}); + let state = f.commit(source); + let plan = f.plan(&state); + let expected = json!({"nested":{"count":1,"mode":"override"}}); + assert_eq!(plan.plugins()[0].projection().config, expected); + assert_eq!( + serde_json::to_value(&plan).unwrap()["input"]["plugins"][0]["config_digest"], + config_source::digest_bytes(&serde_json::to_vec(&expected).unwrap()) + ); + let receipt = f + .store + .plugin_plan_create("host", "singletons", &plan) + .unwrap(); + assert_eq!(receipt.plan.plugins()[0].projection().config, expected); + let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap(); + let stored = reopened.plugin_plan_load("a", &receipt.plan_id).unwrap(); + assert_eq!(stored.plan, plan); + assert_eq!( + reopened + .plugin_plan_create("host", "singletons", &plan) + .unwrap(), + receipt + ); + // Even a canonically rehashed candidate cannot substitute the raw override + // for the complete projection reconstructed at the public create fence. + let mut forged = serde_json::to_value(&plan).unwrap()["input"].clone(); + forged["plugins"][0]["projection"]["config"] = json!({"nested":{"mode":"override"}}); + let input: WorkerPluginPlanInput = serde_json::from_value(forged).unwrap(); + let incomplete = WorkerPluginExecutionPlan::canonicalize(input).unwrap(); + assert!( + reopened + .plugin_plan_create("host", "raw-override", &incomplete) + .is_err() + ); +} + +#[test] +fn effective_config_size_limit_applies_after_schema_concretization() { + let schema = format!("{{ value = {}; }}", json!("a".repeat(64 * 1024))); + let error = evaluate_effective_config(&json!({}), &schema).unwrap_err(); + assert!( + error + .to_string() + .contains("non-secret config exceeds 64 KiB"), + "{error}" + ); +} diff --git a/crates/workspace-server/src/schema_convergence_tests.rs b/crates/workspace-server/src/schema_convergence_tests.rs index 5f267a4e1..67be277a0 100644 --- a/crates/workspace-server/src/schema_convergence_tests.rs +++ b/crates/workspace-server/src/schema_convergence_tests.rs @@ -106,6 +106,10 @@ mod schema_convergence { }, WorkspaceSchemaMigrationStep { version: 107, + name: "Immutable Worker Plugin plans and retained pins".into(), + }, + WorkspaceSchemaMigrationStep { + version: 108, name: "Subject Ticket responsibility and current-body notification routing".into(), }, ] @@ -125,7 +129,8 @@ mod schema_convergence { expected.push((104, "Converge Passkey results and Job input retention".into())); expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into())); expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into())); - expected.push((107, "Subject Ticket responsibility and current-body notification routing".into())); + expected.push((107, "Immutable Worker Plugin plans and retained pins".into())); + expected.push((108, "Subject Ticket responsibility and current-body notification routing".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/schema_equivalence_tests.rs b/crates/workspace-server/src/schema_equivalence_tests.rs index ccec3ae93..f485f411e 100644 --- a/crates/workspace-server/src/schema_equivalence_tests.rs +++ b/crates/workspace-server/src/schema_equivalence_tests.rs @@ -1,8 +1,11 @@ // Included in store::tests so both paths use the real retained migration chain. fn state_contract_schema_signature(conn: &Connection, table: &str) -> Vec> { + // Retained host generations alone add this exact Workspace-parent FK to + // the three principal tables. All other FKs (including composite owner FKs) + // remain in the signature; do not discard foreign-key contracts wholesale. let queries = [ "SELECT name, type, CAST(\"notnull\" AS TEXT), COALESCE(dflt_value, ''), CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name", - "SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) ORDER BY \"table\", \"from\", \"to\", seq", + "SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) WHERE NOT (?1 IN ('ticket_worker_assignments','ticket_current_worker_assignments','ticket_assignment_operations') AND \"table\"='workspaces' AND \"from\"='workspace_id' AND \"to\"='workspace_id' AND on_update='NO ACTION' AND on_delete='CASCADE' AND match='NONE' AND seq=0) ORDER BY \"table\", \"from\", \"to\", seq", "SELECT CAST(i.\"unique\" AS TEXT), CAST(i.partial AS TEXT), (SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1, 2, 3", "SELECT name, sql FROM sqlite_schema WHERE type='trigger' AND tbl_name=?1 ORDER BY name", ]; @@ -52,8 +55,16 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() { "typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_current_worker_assignments", + "ticket_worker_assignments", + "ticket_assignment_operations", "ticket_assignment_work_releases", "artifacts", + "plugin_installations", + "plugin_artifacts", + "plugin_uploads", + "plugin_operations", + "plugin_references", + "plugin_execution_plans", "workspace_config_trees", "workspace_config_tree_history", "workspace_memory_settings", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index c5aa27ccc..eb9dbfa86 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4425,7 +4425,8 @@ impl WorkspaceApi { .with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) .with_provider(Arc::new(RuntimeConfigSchemaProvider)) .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) - .with_provider(Arc::new(skills::SkillConfigSchemaProvider)); + .with_provider(Arc::new(skills::SkillConfigSchemaProvider)) + .with_provider(Arc::new(crate::plugin_settings::PluginConfigSchemaProvider)); let materialization_store = config_store.clone(); let workspace_id = config.workspace_id.clone(); let schema = config_schema_registry.compose()?; @@ -7510,6 +7511,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::workspace_prompt_projection( service.clone(), )) + .merge(server_api::server_api_axum::workspace_config_tree_preview( + service.clone(), + )) .merge(server_api::server_api_axum::workspace_config_tree_commit( service.clone(), )) @@ -9358,6 +9362,34 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn workspace_config_tree_preview( + &self, + workspace_id: String, + request: server_api::ConfigCommitRequest, + ) -> std::result::Result + { + let api = self.workspace_api()?.clone(); + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let candidate = prepare_workspace_config_tree( + &api, + &workspace_id, + &config_commit_request_from_api(request)?, + )?; + Ok(workspace_config_state_to_api( + crate::config_source::WorkspaceConfigState { + snapshot: candidate.snapshot, + contract: candidate.contract, + projection_digest: candidate.evaluation.projection_digest, + }, + )) + }) + .await + .map_err(|error: ApiError| error.into_repository_api_error()) + } + async fn workspace_config_tree_commit( &self, workspace_id: String, diff --git a/crates/workspace-server/src/server_plugin_tests.rs b/crates/workspace-server/src/server_plugin_tests.rs index aeec3c00d..529096761 100644 --- a/crates/workspace-server/src/server_plugin_tests.rs +++ b/crates/workspace-server/src/server_plugin_tests.rs @@ -412,3 +412,32 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog assert_eq!(bytes, package_bytes("0.1.0")); shutdown_workspace_feature_storage(&reopened).await.unwrap(); } + +#[tokio::test] +async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() { + let dir=tempfile::tempdir().unwrap(); + let api=test_api(dir.path()).await; + let owner=seed_test_workspace_owner_token(api.store.as_ref(),"plugin-config-owner").await; + let reader=seed_test_api_token(api.store.as_ref(),"plugin-config-reader"); + let app=build_router(api.clone()); + let prefix=format!("/api/w/{TEST_WORKSPACE_ID}"); + let upload=request(app.clone(),Some(&owner),"POST",&format!("{prefix}/plugin-uploads"),package_bytes("0.1.0"),true,StatusCode::OK).await; + let installed=json(app.clone(),&owner,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"configured","operation_key":"config-install"}),StatusCode::OK).await; + let state=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await; + let a=&installed["installation"]["artifacts"][0]; + let source=format!("{{ plugins.enabled.echo = {{ installation_id = {}; expected_last_operation = {}; package_digest = {}; artifact_digest = {}; tools = [\"example_echo\"]; config = {{}}; }}; }} as WorkspaceConfigSchema",installed["installation"]["installation_id"],installed["operation_id"],a["package_digest"],a["artifact_digest"]); + let change=serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]}); + json(app.clone(),&reader,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::FORBIDDEN).await; + json(app.clone(),&reader,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::FORBIDDEN).await; + let preview=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::OK).await; + let unchanged=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await; + assert_eq!(unchanged["snapshot"]["digest"],state["snapshot"]["digest"]); + let saved=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::CREATED).await; + assert_eq!(saved,preview); + assert_ne!(saved["snapshot"]["digest"],state["snapshot"]["digest"]); + json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change,StatusCode::CONFLICT).await; + json(app.clone(),&owner,&format!("{prefix}/plugins/{}/uninstall",installed["installation"]["installation_id"].as_str().unwrap()),serde_json::json!({"expected_last_operation":installed["operation_id"],"operation_key":"config-blocks-uninstall"}),StatusCode::CONFLICT).await; + let current=api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap(); + let candidates=current.contract.schema_bundle.contributions.iter().filter(|c|c.provider_id=="builtin:plugins").count(); + assert_eq!(candidates,1); +} diff --git a/crates/workspace-server/src/server_residual_execution_tests.rs b/crates/workspace-server/src/server_residual_execution_tests.rs index 8cee30e58..759dbf040 100644 --- a/crates/workspace-server/src/server_residual_execution_tests.rs +++ b/crates/workspace-server/src/server_residual_execution_tests.rs @@ -908,6 +908,18 @@ async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_contr released, }; observed(waiting).await.unwrap(); // actual SQL transaction owns the Feature connection + // Force close to be queued before the sole DB worker can start it. Pending + // admission alone must not be mistaken for an available second queue slot. + let (started, running) = oneshot::channel(); + let (start_close, held) = mpsc::channel(); + fixture + .executor + .dispatch(move || { + let _ = started.send(()); + let _ = held.recv_timeout(WATCHDOG); + }) + .unwrap(); + observed(running).await.unwrap(); let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let mut close = Box::pin(crate::db_executor::with_operation_lifetime( Arc::new(CountJobLifetime(leases.clone())), @@ -917,6 +929,30 @@ async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_contr futures::poll!(&mut close).is_pending(), "Feature connection close must not wait on Tokio" ); + assert!(matches!( + fixture.executor.dispatch(|| {}), + Err(Error::DbCapacityExceeded) + )); + start_close.send(()).unwrap(); + let fenced = observed(async { + loop { + match fixture + .api + .feature_storage + .open(&fixture.api.subjektiv_registration) + { + Err(crate::FeatureStorageError::ShuttingDown) => break, + Ok(_) => tokio::task::yield_now().await, + Err(error) => panic!("unexpected Feature admission failure: {error}"), + } + } + }); + tokio::select! { + _ = fenced => {}, + result = &mut close => panic!("close completed while its connection is held: {result:?}"), + } + // Observing the real shutdown fence proves close was dequeued and started; + // only now can the one waiting slot be filled deterministically. runtime_progress_while_locked(&lock).await; assert_eq!( observed(fixture.host_executor.run(|| Ok::<_, Error>("control"))) diff --git a/crates/workspace-server/src/server_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs index 66cb7be04..1247e2961 100644 --- a/crates/workspace-server/src/server_workspace_config.rs +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -595,7 +595,10 @@ pub(super) async fn commit( if candidate.base_digest != state.snapshot.digest || candidate.base_toolchain_fingerprint != state.contract.fingerprint || candidate.base_projection_digest != state.projection_digest - || candidate.contract.schema_bundle.fingerprint != schema_fingerprint + || crate::plugin_settings::base_schema_bundle(&candidate.contract.schema_bundle) + .map_err(pre_error)? + .fingerprint + != schema_fingerprint { return Err(fail(409, "stale_validator")); } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 255ee9b20..094b24447 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 = 107; +const LATEST_SCHEMA_VERSION: i64 = 108; 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"; @@ -469,8 +469,13 @@ const MIGRATIONS: &[Migration] = &[ }, Migration { version: 107, + name: "Immutable Worker Plugin plans and retained pins", + apply: migrate_plugin_plans_v106_to_v107, + }, + Migration { + version: 108, name: "Subject Ticket responsibility and current-body notification routing", - apply: migrate_subject_assignments_v106_to_v107, + apply: migrate_subject_assignments_v107_to_v108, }, ]; @@ -15823,17 +15828,23 @@ fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> { Ok(()) } -fn migrate_subject_assignments_v106_to_v107(conn: &Connection) -> Result<()> { - if current_schema_version(conn)? != 106 { +fn migrate_subject_assignments_v107_to_v108(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 107 { return Err(Error::Store( - "Subject assignment migration requires schema 106".into(), + "Subject assignment migration requires schema 107".into(), )); } + // Refuse preexisting drift before the new tail can commit any changes. + // Migration 107 and its historical identity remain frozen. + verify_schema_history(conn, 107)?; + crate::plugin_catalog::verify(conn)?; + crate::plugin_plan::verify_plan_schema(conn)?; let tx = conn.unchecked_transaction()?; subject_assignment_migration::install(&tx)?; notification_routing::install_subject_recipients(&tx)?; + subject_assignment_migration::verify(&tx)?; tx.execute( - "INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,?1)", + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(108,?1)", ["Subject Ticket responsibility and current-body notification routing"], )?; tx.commit()?; @@ -15858,12 +15869,26 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { }) } +fn migrate_plugin_plans_v106_to_v107(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 106 { + return Err(Error::Store( + "Plugin plan migration requires schema 106".into(), + )); + } + let tx = conn.unchecked_transaction()?; + tx.execute_batch(crate::plugin_plan::SCHEMA_SQL)?; + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,'Immutable Worker Plugin plans and retained pins')", [])?; + tx.commit()?; + Ok(()) +} + // Fresh composition uses the posts contract. Retained fixtures pass frozen host // and component DDL to the shared cross-domain constraint composition below. fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?; conn.execute_batch(include_str!("activation_schema.sql"))?; conn.execute_batch(include_str!("passkey_results_schema.sql"))?; + conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?; Ok(()) } @@ -16905,6 +16930,7 @@ fn apply_migrations_with_secret_source( verify_schema_history(conn, LATEST_SCHEMA_VERSION)?; crate::plugin_catalog::verify(conn)?; + crate::plugin_plan::verify_plan_schema(conn)?; subject_assignment_migration::verify(conn)?; verify_workspace_runtime_binding_schema(conn)?; verify_workspace_runtime_verification_schema(conn)?; @@ -19601,7 +19627,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()}, WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()}, WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()}, - WorkspaceSchemaMigrationStep {version:107, name:"Subject Ticket responsibility and current-body notification routing".into()}, + WorkspaceSchemaMigrationStep {version:107, name:"Immutable Worker Plugin plans and retained pins".into()}, + WorkspaceSchemaMigrationStep {version:108, name:"Subject Ticket responsibility and current-body notification routing".into()}, ] ); @@ -19718,7 +19745,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (104, "Converge Passkey results and Job input retention".to_string()), (105, "Durable Subject deletion snapshots and Job admission fences".to_string()), (106, crate::plugin_catalog::MIGRATION_NAME.to_string()), -(107, "Subject Ticket responsibility and current-body notification routing".to_string()), +(107, "Immutable Worker Plugin plans and retained pins".to_string()), +(108, "Subject Ticket responsibility and current-body notification routing".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -23007,7 +23035,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (104, "Converge Passkey results and Job input retention".into()), (105, "Durable Subject deletion snapshots and Job admission fences".into()), (106, crate::plugin_catalog::MIGRATION_NAME.into()), - (107, "Subject Ticket responsibility and current-body notification routing".into())]); + (107, "Immutable Worker Plugin plans and retained pins".into()), + (108, "Subject Ticket responsibility and current-body notification routing".into())]); for (table, rows) in unchanged.iter().zip(before.iter()) { assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); } @@ -28551,3 +28580,7 @@ mod frozen_runtime_binding_tests; #[cfg(test)] #[path = "plugin_catalog/migration_tests.rs"] mod plugin_catalog_migration_tests; + +#[cfg(test)] +#[path = "plugin_plan/migration_tests.rs"] +mod plugin_plan_migration_tests; diff --git a/crates/workspace-server/src/subject_assignment_migration_tests.rs b/crates/workspace-server/src/subject_assignment_migration_tests.rs index bf9bb0386..ac49380f7 100644 --- a/crates/workspace-server/src/subject_assignment_migration_tests.rs +++ b/crates/workspace-server/src/subject_assignment_migration_tests.rs @@ -27,9 +27,14 @@ mod subject_assignment_migration_tests { conn.execute_batch("DELETE FROM __yoi_schema_migrations; INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,'workspace schema baseline');").unwrap(); } - seed_workspace(&conn); + seed_legacy_evidence(&conn); + (directory, path, conn) + } + + fn seed_legacy_evidence(conn: &Connection) { + seed_workspace(conn); for status in ["waiting", "inprogress", "done", "unknown", "released"] { - seed_ticket(&conn, status); + seed_ticket(conn, status); conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w',?1,?1,'orchestrator','workspace_agent','workspace-orchestrator','original actor','original time')",[status]).unwrap(); if status == "unknown" { @@ -72,14 +77,16 @@ mod subject_assignment_migration_tests { VALUES('w','waiting','implementation','worker','worker','r','worker','original assigner','original time'); INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,runtime_id,worker_id,updated_at) VALUES('w','waiting','worker','implementation','worker','r','worker','original time'); - INSERT INTO ticket_implementation_delegations VALUES('w','implementation','waiting'); + INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap(); + if table_exists(conn, "ticket_implementation_results").unwrap() { + conn.execute_batch(" INSERT INTO ticket_implementation_delegations VALUES('w','implementation','waiting'); INSERT INTO ticket_implementation_results VALUES('w','waiting','result-op','saved-result','r','worker','{ \"body\" : \"original result\", \"kind\" : \"result\" }','result time'); INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) SELECT sequence,'w','saved-notification','deliver','original reason','attention' FROM notification_sources WHERE input_event_id='implementation-result:saved-result'; INSERT INTO notification_requests VALUES('w','saved-notification','original common request','{ \"target\" : \"old body\" }'); INSERT INTO notification_acknowledgements VALUES('w','saved-notification','{ \"accepted\" : \"old body\" }'); - INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time'); - INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap(); + INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time');").unwrap(); + } // A live body candidate and the old singleton exist simultaneously. A // migration must not infer stable-agent responsibility from either one. conn.execute_batch("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) @@ -88,7 +95,6 @@ mod subject_assignment_migration_tests { VALUES('w','r','body','Body','normal','1','2'); INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w','subjektiv:tempting-subject','r','body',1,'1','2');").unwrap(); - (directory, path, conn) } fn seed_workspace(conn: &Connection) { @@ -407,9 +413,7 @@ mod subject_assignment_migration_tests { }; assert_eq!(workspace_parents(new)?, 0, "fresh standalone-first preimage: {table}"); assert_eq!(workspace_parents(&conn)?, if table=="ticket_current_worker_assignments" {0} else {1}, "retained Workspace preimage: {table}"); - let contract = |db: &Connection| state_contract_schema_signature(db,table).into_iter() - .filter(|row| !row.first().is_some_and(|name| name=="workspaces")) - .collect::>(); + let contract = |db: &Connection| state_contract_schema_signature(db,table); assert_eq!(contract(&conn),contract(new),"{table}"); for db in [&conn,new] { let ddl:String=db.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",[table],|row|row.get(0))?; @@ -839,4 +843,545 @@ mod subject_assignment_migration_tests { assert_eq!(conn.query_row("SELECT request_fingerprint FROM ticket_assignment_operations WHERE operation_id='cached-subject'", [], |row| row.get::<_, String>(0)).unwrap(), "cached fingerprint"); assert_subject_contract(&conn); } + + mod subject_assignment_final_tail { + use super::*; + use crate::plugin_plan::{ + self, PlanPinState, PluginPlanCreateReceipt, WorkerPluginExecutionPlan, + WorkerPluginPlanInput, + }; + + const PLAN_NAME: &str = "Immutable Worker Plugin plans and retained pins"; + const SUBJECT_NAME: &str = + "Subject Ticket responsibility and current-body notification routing"; + type Rows = Vec>; + + fn raw(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn + } + + fn retained(version: i64) -> (tempfile::TempDir, std::path::PathBuf, Connection) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tail.db"); + prepare_retained_schema(&path, version); + let conn = raw(&path); + seed_legacy_evidence(&conn); + assert_component13(&conn); + assert!(!table_exists(&conn, "plugin_execution_plans").unwrap()); + assert_eq!( + table_exists(&conn, "plugin_installations").unwrap(), + version >= 106 + ); + (dir, path, conn) + } + + // Explicit data dependencies only. Real 107 DDL is installed by the frozen + // migration, not copied from a latest store or relabelled after downgrade. + async fn populated107() -> ( + tempfile::TempDir, + std::path::PathBuf, + Connection, + Vec<(String, PluginPlanCreateReceipt, PlanPinState)>, + ) { + let (dir, path, conn) = retained(106); + migrate_plugin_plans_v106_to_v107(&conn).unwrap(); + let (_donor_dir, donor, receipts) = + SqliteWorkspaceStore::plugin_plan_migration_donor().await; + for table in [ + "accounts", + "workspaces", + "workspace_config_trees", + "workspace_config_entries", + "workspace_config_tree_history", + "plugin_installations", + "plugin_artifacts", + "plugin_uploads", + "plugin_operations", + "plugin_execution_plans", + "plugin_references", + ] { + let columns = conn + .prepare(&format!("PRAGMA table_info({table})")) + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::>>() + .unwrap(); + let names = columns.join(","); + let rows = donor + .with_conn(|source| { + Ok(fixture_query_rows( + source, + &format!("SELECT {names} FROM {table} ORDER BY rowid"), + )) + }) + .unwrap(); + for row in rows { + conn.execute( + &format!( + "INSERT INTO {table} ({names}) VALUES ({})", + vec!["?"; columns.len()].join(",") + ), + rusqlite::params_from_iter(row), + ) + .unwrap(); + } + } + assert_eq!( + workspace_schema_migration_history(&conn).unwrap().last(), + Some(&(107, PLAN_NAME.into())) + ); + assert_pre_subject(&conn, 107); + crate::plugin_catalog::verify(&conn).unwrap(); + plugin_plan::verify_plan_schema(&conn).unwrap(); + for (_, receipt, state) in &receipts { + let loaded = plugin_plan::plugin_plan_load(&conn, "a", &receipt.plan_id).unwrap(); + assert_eq!(loaded.plan, receipt.plan); + assert_eq!(loaded.pin_state, *state); + } + assert_health(&conn); + drop(conn); + let conn = raw(&path); + (dir, path, conn, receipts) + } + + fn assert_pre_subject(conn: &Connection, version: i64) { + assert_eq!(current_schema_version(conn).unwrap(), version); + assert_component13(conn); + assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_schema WHERE name LIKE 'ticket_subject_%' OR name LIKE 'ticket_legacy_orchestrator_%'", [], |r|r.get::<_,i64>(0)).unwrap(), 0); + let kinds: String = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + kinds.contains("CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))") + ); + assert!(!kinds.contains("'subject'")); + let capture: String = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name='notification_assignment_change'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(!capture.contains("'subject'")); + } + + fn assert_health(conn: &Connection) { + assert_eq!( + fixture_query_rows(conn, "PRAGMA integrity_check"), + vec![vec![rusqlite::types::Value::Text("ok".into())]] + ); + assert!(fixture_query_rows(conn, "PRAGMA foreign_key_check").is_empty()); + assert_closed_schema_writes(conn); + } + + fn host_history(conn: &Connection) -> Rows { + fixture_query_rows( + conn, + "SELECT rowid,* FROM __yoi_schema_migrations ORDER BY version", + ) + } + + // Save the exact source columns, including rowids and original JSON bytes. + // Only the owning diagnostic enrichment and two append-only ledgers are + // excluded from the success oracle; failures compare ALL data and schema. + fn data(conn: &Connection, preserved: bool) -> Vec<(String, Rows)> { + let tables = conn + .prepare("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap(); + tables + .into_iter() + .filter(|table| !preserved || !table.contains("schema_migrations")) + .map(|table| { + let columns = conn + .prepare(&format!("PRAGMA table_info(\"{table}\")")) + .unwrap() + .query_map([], |r| r.get::<_, String>(1)) + .unwrap() + .collect::>>() + .unwrap() + .into_iter() + .filter(|column| { + !(preserved + && table == "ticket_worker_assignments" + && column == "tracking_migration_diagnostic") + }) + .map(|column| format!("\"{column}\"")) + .collect::>() + .join(","); + // Later historical migrations may introduce a new sequence. + // Existing append identities, particularly notification sources, + // must not move; allow only newly introduced sequence names. + let sequence_filter = if preserved && table == "sqlite_sequence" { + let names = conn + .prepare("SELECT quote(name) FROM sqlite_sequence ORDER BY name") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap(); + if names.is_empty() { + " WHERE 0".into() + } else { + format!(" WHERE name IN ({})", names.join(",")) + } + } else { + String::new() + }; + let query = format!( + "SELECT rowid,{columns} FROM \"{table}\"{sequence_filter} ORDER BY rowid" + ); + let rows = fixture_query_rows(conn, &query); + (query, rows) + }) + .collect() + } + + fn assert_data(conn: &Connection, before: &[(String, Rows)]) { + for (query, rows) in before { + assert_eq!(&fixture_query_rows(conn, query), rows, "{query}"); + } + } + + fn snapshot(conn: &Connection) -> (i64, Rows, Vec<(String, Rows)>) { + (cookie(conn), schema(conn), data(conn, false)) + } + + fn assert_final(conn: &Connection, version: i64, host: &Rows, component: &Rows) { + let history = host_history(conn); + assert_eq!(&history[..host.len()], host); + let expected = expected_tail(version); + assert_eq!( + workspace_schema_migration_history(conn) + .unwrap() + .into_iter() + .filter(|(v, _)| *v > version) + .collect::>(), + expected + ); + assert_eq!(history.len(), host.len() + expected.len()); + assert_component14_append(conn, component); + crate::plugin_catalog::verify(conn).unwrap(); + plugin_plan::verify_plan_schema(conn).unwrap(); + verify(conn).unwrap(); + assert_health(conn); + // Reuse the owner's real constraint probe, rolled back so it is not + // confused with migration writes or preserved notification evidence. + let tx = conn.unchecked_transaction().unwrap(); + assert_subject_contract(&tx); + tx.rollback().unwrap(); + for status in ["waiting", "inprogress", "done", "unknown", "released"] { + let actual: String = conn.query_row("SELECT tracking_migration_diagnostic FROM ticket_worker_assignments WHERE assignment_id=?1", [status], |r|r.get(0)).unwrap(); + assert_eq!( + actual, + if status == "unknown" { + format!("Existing ambiguity\n{LEGACY_DIAGNOSTIC}") + } else { + LEGACY_DIAGNOSTIC.into() + } + ); + } + } + + fn expected_tail(version: i64) -> Vec<(i64, String)> { + match version { + 96 => vec![ + ( + 97, + "MR notifications and authenticated implementation results".into(), + ), + (98, "Assignment-independent implementation reports".into()), + ( + 99, + "Explicit hosting administrators and self-registration policy".into(), + ), + ( + 100, + "Provisional users and scoped passkey activation".into(), + ), + (101, "Durable explicit Subject body release".into()), + (102, "Durable failed Subject creation recovery".into()), + (103, "Passkey metadata and atomic addition results".into()), + ( + 104, + "Converge Passkey results and Job input retention".into(), + ), + ( + 105, + "Durable Subject deletion snapshots and Job admission fences".into(), + ), + (106, crate::plugin_catalog::MIGRATION_NAME.into()), + (107, PLAN_NAME.into()), + (108, SUBJECT_NAME.into()), + ], + 106 => vec![(107, PLAN_NAME.into()), (108, SUBJECT_NAME.into())], + 107 => vec![(108, SUBJECT_NAME.into())], + _ => panic!("unsupported fixture origin"), + } + } + + fn plan_steps(version: i64) -> Vec { + expected_tail(version) + .into_iter() + .map(|(version, name)| WorkspaceSchemaMigrationStep { version, name }) + .collect() + } + + #[test] + fn retained96_and106_preflight_apply_and_reopen_preserve_original_evidence() { + for version in [96, 106] { + let (_dir, path, conn) = retained(version); + assert_pre_subject(&conn, version); + let before = snapshot(&conn); + let rows = data(&conn, true); + let host = host_history(&conn); + let component = component_history(&conn); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, version); + assert_eq!(plan.migrations, plan_steps(version)); + assert_eq!( + snapshot(&conn), + before, + "read-only preflight changed source" + ); + drop(conn); + assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan); + let conn = raw(&path); + assert_data(&conn, &rows); + assert_final(&conn, version, &host, &component); + let final_snapshot = snapshot(&conn); + drop(conn); + drop(SqliteWorkspaceStore::open_for_test(&path).unwrap()); + let conn = raw(&path); + assert_eq!(snapshot(&conn), final_snapshot); + assert!( + SqliteWorkspaceStore::migration_plan(&path) + .unwrap() + .migrations + .is_empty() + ); + } + } + + 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_%' ORDER BY type,name", + ) + } + + #[tokio::test] + async fn applied107_only108_preserves_canonical_plans_pins_and_released_replay() { + let (_dir, path, conn, receipts) = populated107().await; + let before = snapshot(&conn); + let rows = data(&conn, true); + let plans = plugin_schema(&conn); + let contracts = principal_table_contracts(&conn); + let host = host_history(&conn); + let component = component_history(&conn); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, 107); + assert_eq!( + plan.migrations, + vec![WorkspaceSchemaMigrationStep { + version: 108, + name: SUBJECT_NAME.into() + }] + ); + assert_eq!(snapshot(&conn), before); + drop(conn); + SqliteWorkspaceStore::migrate_database(&path).unwrap(); + let conn = raw(&path); + assert_data(&conn, &rows); + assert_final(&conn, 107, &host, &component); + assert_eq!(plugin_schema(&conn), plans); + assert_eq!(principal_table_contracts(&conn), contracts); + let final_snapshot = snapshot(&conn); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let conn = raw(&path); + assert_eq!(snapshot(&conn), final_snapshot); + for (key, receipt, state) in receipts { + let saved = store.plugin_plan_load("a", &receipt.plan_id).unwrap(); + assert_eq!(saved.plan, receipt.plan); + assert_eq!(saved.fingerprint, receipt.fingerprint); + assert_eq!(saved.pin_state, state); + assert_eq!( + store + .plugin_plan_create("actor", &key, &receipt.plan) + .unwrap(), + receipt + ); + if state == PlanPinState::Released { + assert_eq!( + conn.query_row( + "SELECT count(*) FROM plugin_references WHERE owner=?1", + [&receipt.plan_id], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); + } + let changed = WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput { + workspace_id: "a".into(), + config: receipt.plan.config().clone(), + profile: receipt.plan.profile().clone(), + execution: crate::plugin_plan::HostExecutionIdentity { + worker_id: "changed-worker".into(), + ..receipt.plan.execution().clone() + }, + plugins: receipt.plan.plugins().to_vec(), + }) + .unwrap(); + assert!(matches!( + store.plugin_plan_create("actor", &key, &changed), + Err(Error::PluginCatalog( + crate::plugin_catalog::PluginCatalogError::Conflict + )) + )); + } + assert_data(&conn, &rows); + } + + fn interrupt108(conn: &Connection) { + conn.execute_batch("CREATE TRIGGER interrupt_subject_tail BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=108 BEGIN SELECT RAISE(ABORT,'subject tail marker failure'); END;").unwrap(); + } + + #[tokio::test] + async fn marker108_failure_rolls_back_all_subject_changes_retains107_and_raw_retries() { + let (_dir, path, conn, _receipts) = populated107().await; + interrupt108(&conn); + let before = snapshot(&conn); + let host = host_history(&conn); + let component = component_history(&conn); + assert!(SqliteWorkspaceStore::migration_plan(&path).is_err()); + assert_eq!(snapshot(&conn), before); + let error = apply_migrations(&conn).unwrap_err(); + assert!( + error.to_string().contains("subject tail marker failure"), + "{error}" + ); + assert_eq!(snapshot(&conn), before); + assert_pre_subject(&conn, 107); + assert_health(&conn); + drop(conn); + let conn = raw(&path); + assert_eq!(snapshot(&conn), before); + conn.execute_batch("DROP TRIGGER interrupt_subject_tail") + .unwrap(); + assert_eq!( + SqliteWorkspaceStore::migration_plan(&path) + .unwrap() + .migrations, + plan_steps(107) + ); + apply_migrations(&conn).unwrap(); + assert_final(&conn, 107, &host, &component); + } + + #[test] + fn real106_commits107_before108_failure_but_dryrun_never_changes_source() { + let (_dir, path, conn) = retained(106); + interrupt108(&conn); + let before = snapshot(&conn); + let host = host_history(&conn); + let component = component_history(&conn); + let capture = fixture_query_rows( + &conn, + "SELECT name,sql FROM sqlite_schema WHERE name LIKE 'notification_%' AND type='trigger' ORDER BY name", + ); + assert!(SqliteWorkspaceStore::migration_plan(&path).is_err()); + assert_eq!(snapshot(&conn), before); + assert!(SqliteWorkspaceStore::migrate_database(&path).is_err()); + assert_eq!(snapshot(&conn), before); + assert!( + apply_migrations(&conn) + .unwrap_err() + .to_string() + .contains("subject tail marker failure") + ); + assert_pre_subject(&conn, 107); + assert!(table_exists(&conn, "plugin_execution_plans").unwrap()); + plugin_plan::verify_plan_schema(&conn).unwrap(); + let history = host_history(&conn); + assert_eq!(&history[..host.len()], &host); + assert_eq!(history.len(), host.len() + 1); + assert_eq!( + workspace_schema_migration_history(&conn).unwrap().last(), + Some(&(107, PLAN_NAME.into())) + ); + assert_eq!(component_history(&conn), component); + assert_eq!( + fixture_query_rows( + &conn, + "SELECT name,sql FROM sqlite_schema WHERE name LIKE 'notification_%' AND type='trigger' ORDER BY name" + ), + capture + ); + assert_health(&conn); + let failed = snapshot(&conn); + drop(conn); + let conn = raw(&path); + assert_eq!(snapshot(&conn), failed); + conn.execute_batch("DROP TRIGGER interrupt_subject_tail") + .unwrap(); + assert_eq!( + SqliteWorkspaceStore::migration_plan(&path) + .unwrap() + .migrations, + plan_steps(107) + ); + apply_migrations(&conn).unwrap(); + assert_final(&conn, 106, &host, &component); + } + + #[tokio::test] + async fn preexisting_plan_or_catalog_drift_refuses108_without_repair() { + for trigger in ["plugin_plan_snapshot_immutable", "plugin_reference_live"] { + let (_dir, path, conn, _receipts) = populated107().await; + conn.execute_batch(&format!("DROP TRIGGER {trigger}")) + .unwrap(); + let before = snapshot(&conn); + assert!(SqliteWorkspaceStore::migration_plan(&path).is_err()); + assert_eq!(snapshot(&conn), before); + assert!(apply_migrations(&conn).is_err()); + assert_eq!(snapshot(&conn), before); + assert_pre_subject(&conn, 107); + drop(conn); + assert!(SqliteWorkspaceStore::open_for_test(&path).is_err()); + assert_eq!(snapshot(&raw(&path)), before); + } + } + + #[tokio::test] + async fn current_missing_host_guard_or_plan_drift_reopen_refuses_without_repair() { + for trigger in [ + "ticket_legacy_orchestrator_delivery_no_insert", + "ticket_legacy_orchestrator_start_no_insert", + "plugin_plan_snapshot_immutable", + ] { + let (_dir, path, conn, _receipts) = populated107().await; + apply_migrations(&conn).unwrap(); + conn.execute_batch(&format!("DROP TRIGGER {trigger}")) + .unwrap(); + let before = snapshot(&conn); + drop(conn); + assert!(SqliteWorkspaceStore::open_for_test(&path).is_err()); + let conn = raw(&path); + assert_eq!(snapshot(&conn), before); + assert_health(&conn); + } + } + } } diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 2df738c82..178c988ac 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -16,6 +16,7 @@ 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_execution_plans", "plugin_references", "plugin_operations", "plugin_uploads", @@ -508,6 +509,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { // commit; update guards remain installed throughout. let mut ticket_delete_guards = Vec::new(); for (table, trigger) in [ + ("plugin_execution_plans", "plugin_plan_replay_retained"), ("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"), ("notification_job_targets", "notification_job_targets_no_delete"), ("notification_job_legacy_receipts", "notification_job_legacy_no_delete"), diff --git a/openapi/server-api.json b/openapi/server-api.json index a6b02936d..b86d0b4e0 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -19114,7 +19114,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472" + "x-yoi-source-digest": "sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3" }, "openapi": "3.1.0", "paths": { @@ -23631,6 +23631,113 @@ ] } }, + "/api/w/{workspace_id}/config/source-tree/preview": { + "post": { + "operationId": "workspace_config_tree_preview", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigCommitRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigTreeResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/drive/capabilities": { "get": { "operationId": "drive_capabilities", diff --git a/resources/config-schema/fixtures/plugin-preflight.json b/resources/config-schema/fixtures/plugin-preflight.json new file mode 100644 index 000000000..0784bd4cc --- /dev/null +++ b/resources/config-schema/fixtures/plugin-preflight.json @@ -0,0 +1,21 @@ +{ + "schema": "{ plugins = { enabled = { echo = { installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = { ...String } default {}; config = { mode = String; }; }; }; bindings = {} default {}; }; }", + "cases": [ + { + "source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = \"test\"; }; } as WorkspaceConfigSchema", + "mode": "test" + }, + { + "source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config = { mode = \"test\"; unknown = true; }; }; } as WorkspaceConfigSchema", + "diagnostic": "constraint_violation" + }, + { + "source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = 42; }; } as WorkspaceConfigSchema", + "diagnostic": "constraint_violation" + }, + { + "source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = String; }; } as WorkspaceConfigSchema", + "diagnostic": "materialize" + } + ] +} diff --git a/resources/config-schema/plugins.dcdl b/resources/config-schema/plugins.dcdl new file mode 100644 index 000000000..5d1be44db --- /dev/null +++ b/resources/config-schema/plugins.dcdl @@ -0,0 +1,31 @@ +let + PluginEnablement = { + installation_id = String; + expected_last_operation = String; + package_digest = String; + artifact_digest = String; + tools = [...String]; + logging = Bool default false; + connections = { ...String } default {}; + config = { ...Unknown } default {}; + }; +in { + plugins = { + enabled = { ...PluginEnablement } default {}; + bindings = { ...{ + origin = String; + network = String default "public_internet"; + methods = [...String]; + path_prefixes = [...String]; + request_headers = [...String] default []; + response_headers = [...String] default []; + secret_ref = String default ""; + limits = { + timeout_ms = Int; + max_request_bytes = Int; + max_response_bytes = Int; + max_header_bytes = Int; + }; + } } default {}; + } default {}; +} diff --git a/resources/config-schema/profile-authoring.dcdl b/resources/config-schema/profile-authoring.dcdl index d05bd5156..b57319a7d 100644 --- a/resources/config-schema/profile-authoring.dcdl +++ b/resources/config-schema/profile-authoring.dcdl @@ -76,6 +76,13 @@ let search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; }; fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; }; }; + plugins = { selections = [...{ + installation_id = String; tools = [...String]; logging = Bool; + https_slots = [...String]; + # 非秘密のoverrideは補完用のshapeではなく、導入済みpackageの + # closed typed schemaで検証する。 + config = { ...Unknown }; + }]; }; skills = { directories = [...String]; }; }; in { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index edd0077fb..bf02c94a5 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:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) +// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3) // 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/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 4ec456781..21028e393 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:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) +// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3) // 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 728827b8c..4f5f3a51e 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:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) +// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3) // 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 d59ea7b1e..0f2a05ca1 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:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) +// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3) // 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/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index e146390c0..daa8d0d8e 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) +// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_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 6e7171a00..d2c0a9ffc 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:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) +// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3) // 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 diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index 682d03284..993414c49 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -316,6 +316,21 @@ export async function fetchConfigHistory( ); } +export async function previewConfigTree( + workspaceId: string, + request: ConfigCommitRequest, + fetcher: typeof fetch = fetch, +): Promise { + return await readJson( + await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }), + parseWorkspaceConfigTreeResponse, + ); +} + export async function commitConfigTree( workspaceId: string, request: ConfigCommitRequest, diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts index 3868c3682..fda4a30cb 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts @@ -7,6 +7,12 @@ export function apply_changes(changes: any): any; export function changes_between(base: any, candidate: any): any; +/** + * Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset. + * + * Convert the returned start position back to UTF-16 so non-ASCII characters do not + * misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary. + */ export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any; export function compose_schema_bundle(contributions: any): any; @@ -21,6 +27,12 @@ export function format_source(source: string): string; export function set_schema_bundle(schema_bundle: any): void; +/** + * Retain the browser's edit candidate as a snapshot. + * + * Deserialization uses shared digest validation but neither saves to nor authorizes the Backend. + * Keep the schema bundle in separate state to distinguish sources from evaluation conditions. + */ export function set_snapshot(snapshot: any): void; export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js index 96db090e6..9cad45342 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js @@ -44,6 +44,10 @@ export function changes_between(base, candidate) { } /** + * Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset. + * + * Convert the returned start position back to UTF-16 so non-ASCII characters do not + * misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary. * @param {string} entrypoint * @param {string} source * @param {number} utf16_offset @@ -154,6 +158,10 @@ export function set_schema_bundle(schema_bundle) { } /** + * Retain the browser's edit candidate as a snapshot. + * + * Deserialization uses shared digest validation but neither saves to nor authorizes the Backend. + * Keep the schema bundle in separate state to distinguish sources from evaluation conditions. * @param {any} snapshot */ export function set_snapshot(snapshot) { diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm index 3bbf09b82..a00b070f6 100644 Binary files a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm and b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm differ diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts index 10426f333..505501185 100644 --- a/web/workspace/test/config-source/api.test.ts +++ b/web/workspace/test/config-source/api.test.ts @@ -13,6 +13,7 @@ import { fetchConfigHistory, fetchConfigTree, parseWorkspaceConfigTreeResponse, + previewConfigTree, } from "../../src/lib/workspace/config-source/api.ts"; function response(body: unknown, status = 200): Response { @@ -335,3 +336,24 @@ Deno.test("config source API carries bounded optional authoring schemas without }), ConfigSourceApiError); } }); + +Deno.test("config source API previews a candidate without calling commit", async () => { + const request = { + base_digest: "sha256:base", + changes: [], + entrypoints: ["main.dcdl"], + }; + const calls: string[] = []; + const result = await previewConfigTree( + "w/one", + request, + ((input: string | URL | Request, init?: RequestInit) => { + calls.push(String(input)); + assertEquals(init?.method, "POST"); + assertEquals(JSON.parse(String(init?.body)), request); + return Promise.resolve(response(tree)); + }) as typeof fetch, + ); + assertEquals(calls, ["/api/w/w%2Fone/config/source-tree/preview"]); + assertEquals(result, tree); +}); diff --git a/web/workspace/test/config-source/wasm-parity.test.ts b/web/workspace/test/config-source/wasm-parity.test.ts index 962409b6a..41f97dedd 100644 --- a/web/workspace/test/config-source/wasm-parity.test.ts +++ b/web/workspace/test/config-source/wasm-parity.test.ts @@ -985,3 +985,61 @@ Deno.test("generated WASM rejects forged content identity at the snapshot bounda assertThrows(() => set_snapshot(forged)); } }); + +Deno.test("Plugin closed preflight shares native projection and diagnostic fixture without catalog authority", async () => { + const fixture = JSON.parse( + await Deno.readTextFile( + new URL( + "../../../../resources/config-schema/fixtures/plugin-preflight.json", + import.meta.url, + ), + ), + ) as { + schema: string; + cases: Array<{ source: string; mode?: string; diagnostic?: string }>; + }; + const bundle = compose_schema_bundle([{ + provider_id: "plugin-preflight", + namespace: "plugins", + version: "1", + source: fixture.schema, + source_digest: await digestText(fixture.schema), + }]) as WorkspaceConfigSchemaBundle; + const contract: ToolchainContract = { + contract_version: 2, + decodal_version: "0.4.0", + schema_version: 1, + entrypoints: ["main.dcdl"], + import_policy_version: 1, + schema_bundle: bundle, + fingerprint: await toolchainFingerprint(["main.dcdl"], bundle), + }; + for (const testCase of fixture.cases) { + const candidate = fixtureSnapshot(schemaSnapshot(testCase.source)); + if (testCase.diagnostic) { + let failure: ProjectedDiagnostic[] = []; + try { + evaluate_snapshot(candidate, contract); + } catch (error) { + failure = error as ProjectedDiagnostic[]; + } + assertEquals(Array.isArray(failure), true); + assertEquals(failure[0].kind, testCase.diagnostic); + assertEquals(failure[0].path, "main.dcdl"); + } else { + const result = evaluate_snapshot(candidate, contract) as { + projections: Array< + { + data_json: { + plugins: { enabled: { echo: { config: { mode: string } } } }; + }; + } + >; + }; + assertEquals( + result.projections[0].data_json.plugins.enabled.echo.config.mode, + testCase.mode, + ); + } + } +});