From 9b7a58d5c407fc253743c8516797c323adf67c9e Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 29 Oct 2026 08:46:39 +0900 Subject: [PATCH] feat: resolve Workspace Plugin config into immutable execution plans --- crates/config-source/src/lib.rs | 49 ++ crates/manifest/src/config.rs | 12 +- crates/manifest/src/lib.rs | 14 +- crates/manifest/src/plugin_profile.rs | 269 +++++++ crates/manifest/src/profile.rs | 3 + crates/server-api/src/lib.rs | 19 + crates/worker/src/spawn/tool.rs | 3 + .../src/assignment_tracking_tests.rs | 3 +- crates/workspace-server/src/config_source.rs | 108 ++- .../src/implementation_result_tests.rs | 4 +- crates/workspace-server/src/lib.rs | 3 + crates/workspace-server/src/plugin_binding.rs | 371 +++++++++ .../src/plugin_binding/tests.rs | 529 +++++++++++++ .../src/plugin_catalog/migration_tests.rs | 5 +- crates/workspace-server/src/plugin_plan.rs | 419 ++++++++++ .../src/plugin_plan/migration_tests.rs | 41 + .../src/plugin_plan/schema.sql | 27 + .../workspace-server/src/plugin_plan/store.rs | 331 ++++++++ .../workspace-server/src/plugin_plan/tests.rs | 736 ++++++++++++++++++ .../workspace-server/src/plugin_settings.rs | 697 +++++++++++++++++ .../src/plugin_settings/tests.rs | 523 +++++++++++++ .../src/schema_convergence_tests.rs | 2 + crates/workspace-server/src/server.rs | 34 +- .../src/server_plugin_tests.rs | 29 + .../src/server_workspace_config.rs | 5 +- crates/workspace-server/src/store.rs | 35 +- .../src/workspace_deletion.rs | 2 + openapi/server-api.json | 109 ++- .../fixtures/plugin-preflight.json | 21 + resources/config-schema/plugins.dcdl | 31 + .../config-schema/profile-authoring.dcdl | 7 + .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../src/lib/workspace/config-source/api.ts | 15 + .../generated/config_source_wasm.d.ts | 12 + .../generated/config_source_wasm.js | 8 + .../generated/config_source_wasm_bg.wasm | Bin 2028744 -> 2029282 bytes web/workspace/test/config-source/api.test.ts | 22 + .../test/config-source/wasm-parity.test.ts | 58 ++ 42 files changed, 4528 insertions(+), 38 deletions(-) create mode 100644 crates/manifest/src/plugin_profile.rs create mode 100644 crates/workspace-server/src/plugin_binding.rs create mode 100644 crates/workspace-server/src/plugin_binding/tests.rs create mode 100644 crates/workspace-server/src/plugin_plan.rs create mode 100644 crates/workspace-server/src/plugin_plan/migration_tests.rs create mode 100644 crates/workspace-server/src/plugin_plan/schema.sql create mode 100644 crates/workspace-server/src/plugin_plan/store.rs create mode 100644 crates/workspace-server/src/plugin_plan/tests.rs create mode 100644 crates/workspace-server/src/plugin_settings.rs create mode 100644 crates/workspace-server/src/plugin_settings/tests.rs create mode 100644 resources/config-schema/fixtures/plugin-preflight.json create mode 100644 resources/config-schema/plugins.dcdl 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 17eb9dd12..9492577d6 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, @@ -12806,6 +12820,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 284072ce6..976bc1e5a 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -480,7 +480,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip (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()) + (106, crate::plugin_catalog::MIGRATION_NAME.into()), + (107, "Immutable Worker Plugin plans and retained pins".into()) ] ); for (table, rows) in retained.iter().zip(original.iter()) { 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/implementation_result_tests.rs b/crates/workspace-server/src/implementation_result_tests.rs index 2315a9a9f..addbeb33c 100644 --- a/crates/workspace-server/src/implementation_result_tests.rs +++ b/crates/workspace-server/src/implementation_result_tests.rs @@ -372,9 +372,9 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no // and erasure guards in an otherwise legacy fixture. fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> { assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); - conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_operations; + conn.execute_batch("DROP TABLE plugin_execution_plans; DROP TABLE plugin_references; DROP TABLE plugin_operations; DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; - UPDATE __yoi_schema_migrations SET version=105 WHERE version=106;")?; + UPDATE __yoi_schema_migrations SET version=105 WHERE version=107;")?; let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("retained96.db"); prepare_retained_schema(&path, 96); 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 839bede2b..55032725f 100644 --- a/crates/workspace-server/src/plugin_catalog/migration_tests.rs +++ b/crates/workspace-server/src/plugin_catalog/migration_tests.rs @@ -27,7 +27,8 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() assert_eq!(catalog_schema(c),canonical); let after=workspace_schema_migration_history(c)?; assert!(after.starts_with(&before)); - assert_eq!(after.last().unwrap(),&(106,crate::plugin_catalog::MIGRATION_NAME.into())); + assert_eq!(after.last().unwrap(),&(107,"Immutable Worker Plugin plans and retained pins".into())); + assert!(after.contains(&(106,crate::plugin_catalog::MIGRATION_NAME.into()))); 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); assert!(c.execute("INSERT INTO plugin_installations VALUES('absent','i','alias','p','operation',0)",[]).is_err()); @@ -57,7 +58,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap(); migrated .with_conn(|c| { - assert_eq!(current_schema_version(c)?, 106); + assert_eq!(current_schema_version(c)?, 107); crate::plugin_catalog::verify(c)?; Ok(()) }) 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..a28479890 --- /dev/null +++ b/crates/workspace-server/src/plugin_plan/migration_tests.rs @@ -0,0 +1,41 @@ +//! 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| { + assert!(workspace_schema_migration_history(conn)?.starts_with(&before)); + 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..c9248a5de --- /dev/null +++ b/crates/workspace-server/src/plugin_plan/tests.rs @@ -0,0 +1,736 @@ +//! 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(&[]); + } +} diff --git a/crates/workspace-server/src/plugin_settings.rs b/crates/workspace-server/src/plugin_settings.rs new file mode 100644 index 000000000..c3350d4d4 --- /dev/null +++ b/crates/workspace-server/src/plugin_settings.rs @@ -0,0 +1,697 @@ +//! 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()); + } + validate_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 validate_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)?; + evaluate(&snapshot, &contract(bundle))?; + Ok(()) +} + +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..df40b0750 --- /dev/null +++ b/crates/workspace-server/src/plugin_settings/tests.rs @@ -0,0 +1,523 @@ +//! 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 { + 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("0.1.0"), + ) + .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 { + 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 = \"{}\"", + "config_schema = \"{ mode = String; }\"", + ); + 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 + ); +} diff --git a/crates/workspace-server/src/schema_convergence_tests.rs b/crates/workspace-server/src/schema_convergence_tests.rs index 19bc536de..a56ee0fb8 100644 --- a/crates/workspace-server/src/schema_convergence_tests.rs +++ b/crates/workspace-server/src/schema_convergence_tests.rs @@ -104,6 +104,7 @@ mod schema_convergence { version: 106, name: crate::plugin_catalog::MIGRATION_NAME.into(), }, + WorkspaceSchemaMigrationStep { version:107, name:"Immutable Worker Plugin plans and retained pins".into() }, ] ); assert_eq!( @@ -121,6 +122,7 @@ 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,"Immutable Worker Plugin plans and retained pins".into())); assert_eq!(workspace_schema_migration_history(conn)?, expected); for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] { assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}"); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index bb01ce571..7d640b8f1 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4434,7 +4434,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()?; @@ -7479,6 +7480,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(), )) @@ -9324,6 +9328,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_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 d07e1997b..7cd3036b1 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -127,7 +127,7 @@ pub use ticket::{ }; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 106; +const LATEST_SCHEMA_VERSION: i64 = 107; 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"; @@ -465,6 +465,11 @@ const MIGRATIONS: &[Migration] = &[ name: crate::plugin_catalog::MIGRATION_NAME, apply: crate::plugin_catalog::migrate, }, + Migration { + version: 107, + name: "Immutable Worker Plugin plans and retained pins", + apply: migrate_plugin_plans_v106_to_v107, + }, ]; // Both schema-103 identities shipped. Keep the alternate implementation and accept @@ -15755,10 +15760,24 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { // Fresh composition uses the posts contract. Retained fixtures pass frozen host // and component DDL to the shared cross-domain constraint composition below. +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(()) +} + 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(()) } @@ -16800,6 +16819,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)?; verify_workspace_runtime_binding_schema(conn)?; verify_workspace_runtime_verification_schema(conn)?; verify_workspace_deletion_schema(conn)?; @@ -19494,6 +19514,7 @@ 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:"Immutable Worker Plugin plans and retained pins".into()}, ] ); @@ -19610,6 +19631,7 @@ 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, "Immutable Worker Plugin plans and retained pins".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -22897,7 +22919,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (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())]); + (106, crate::plugin_catalog::MIGRATION_NAME.into()), + (107, "Immutable Worker Plugin plans and retained pins".into())]); for (table, rows) in unchanged.iter().zip(before.iter()) { assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); } @@ -27091,7 +27114,7 @@ INSERT INTO worker_registry ( .unwrap(); store.with_conn(|conn| { // This fixture deliberately reconstructs schema 102 from fresh. - conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?; + conn.execute_batch("DROP TABLE plugin_execution_plans; DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?; // Remove the newer Subject fence composition, not just its receipt. let triggers=conn.prepare("SELECT name,tbl_name FROM sqlite_schema WHERE type='trigger' AND tbl_name LIKE 'notification_%' AND name NOT LIKE 'subject_deleted_%' AND sql LIKE '%subject_deletion_jobs%'")? .query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::>>()?; @@ -27103,7 +27126,7 @@ INSERT INTO worker_registry ( DROP TRIGGER passkey_metadata_insert; DROP TABLE passkey_addition_receipts; DROP TABLE passkey_metadata; - UPDATE __yoi_schema_migrations SET version=102 WHERE version=106; + UPDATE __yoi_schema_migrations SET version=102 WHERE version=107; CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(()) }).unwrap(); drop(store); @@ -28419,3 +28442,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/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index b2519847f..872036fab 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 ad01a6caf..660e6c872 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -19062,7 +19062,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f" + "x-yoi-source-digest": "sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc" }, "openapi": "3.1.0", "paths": { @@ -23579,6 +23579,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 e99726a94..0b8f8d993 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc) // 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 f71281eb9..3394247e3 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc) // 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 efa2e84e4..3f898eb43 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc) // 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 b6953e4d0..d1f38ba00 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index ba17a053c..17da061f8 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) +// Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc) // 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 3bbf09b82beffedfeaa096f6916478857e3bc826..a00b070f63d4e96bb96d48e7ffea76af0fcc57df 100644 GIT binary patch delta 62581 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 z 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 CmF$K9 delta 62637 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 q(ix@LzJvP(E { + 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, + ); + } + } +});