From fc1115b1674a4b90de2541ebd0b9b4b0e3a2fdd2 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 29 Oct 2026 09:17:52 +0900 Subject: [PATCH] fix: pin evaluated Plugin config after Profile overrides --- .../workspace-server/src/plugin_settings.rs | 20 ++++-- .../src/plugin_settings/tests.rs | 65 ++++++++++++++++++- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/crates/workspace-server/src/plugin_settings.rs b/crates/workspace-server/src/plugin_settings.rs index c3350d4d4..36899e56b 100644 --- a/crates/workspace-server/src/plugin_settings.rs +++ b/crates/workspace-server/src/plugin_settings.rs @@ -406,7 +406,8 @@ fn project_profile_plugins( for (key, value) in &selection.config { object.insert(key.clone(), value.clone()); } - validate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?; + let config = + evaluate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?; let mut grants = Vec::new(); if selection.logging { grants.push(PluginGrant::Logging); @@ -442,7 +443,7 @@ fn project_profile_plugins( Ok(pins) } -fn validate_effective_config(value: &serde_json::Value, schema: &str) -> Result<()> { +fn evaluate_effective_config(value: &serde_json::Value, schema: &str) -> Result { use config_source::{ConfigContentType, ConfigEntry, VirtualPath}; if serde_json::to_vec(value).map_err(config_error)?.len() > 64 * 1024 { return Err(config_error("non-secret config exceeds 64 KiB")); @@ -466,8 +467,19 @@ fn validate_effective_config(value: &serde_json::Value, schema: &str) -> Result< ) .map_err(config_error)?]) .map_err(config_error)?; - evaluate(&snapshot, &contract(bundle))?; - Ok(()) + // Static singleton fields can concretize omitted nested override values. + // Pin the complete projection, not just the candidate that passed validation. + let evaluated = evaluate(&snapshot, &contract(bundle))?; + let config = evaluated + .projections + .first() + .and_then(|projection| projection.data_json.get("config")) + .filter(|config| config.is_object()) + .ok_or_else(|| config_error("effective Plugin config projection must be an object"))?; + if serde_json::to_vec(config).map_err(config_error)?.len() > 64 * 1024 { + return Err(config_error("non-secret config exceeds 64 KiB")); + } + Ok(config.clone()) } pub(crate) fn render_value(value: &serde_json::Value) -> Result { diff --git a/crates/workspace-server/src/plugin_settings/tests.rs b/crates/workspace-server/src/plugin_settings/tests.rs index df40b0750..ca7279044 100644 --- a/crates/workspace-server/src/plugin_settings/tests.rs +++ b/crates/workspace-server/src/plugin_settings/tests.rs @@ -15,6 +15,9 @@ struct Fixture { } impl Fixture { async fn new() -> Self { + Self::with_schema("{ mode = String; }").await + } + async fn with_schema(schema: &str) -> Self { let dir = tempdir().unwrap(); let store = crate::SqliteWorkspaceStore::open_for_test(dir.path().join("db")).unwrap(); for id in ["a", "b"] { @@ -35,7 +38,7 @@ impl Fixture { &dir.path().join("artifacts"), "a", "owner", - &package("0.1.0"), + &package_with_schema("0.1.0", schema), ) .unwrap(); let installed = store @@ -135,6 +138,9 @@ fn bundle() -> WorkspaceConfigSchemaBundle { .unwrap() } fn package(version: &str) -> Vec { + package_with_schema(version, "{ mode = String; }") +} +fn package_with_schema(version: &str, schema: &str) -> Vec { use manifest::plugin::*; let d = tempdir().unwrap(); let source = @@ -142,7 +148,7 @@ fn package(version: &str) -> Vec { .replace("version = \"0.1.0\"", &format!("version = \"{version}\"")) .replace( "config_schema = \"{}\"", - "config_schema = \"{ mode = String; }\"", + &format!("config_schema = {}", serde_json::to_string(schema).unwrap()), ); std::fs::write(d.path().join("plugin.toml"), source).unwrap(); std::fs::write( @@ -521,3 +527,58 @@ async fn https_config_binding_is_validated_pinned_by_digest_and_never_copied_to_ receipt ); } + +#[tokio::test] +async fn nested_profile_override_pins_evaluated_singletons_through_create_restart_and_replay() { + use crate::plugin_plan::{WorkerPluginExecutionPlan, WorkerPluginPlanInput}; + let f = Fixture::with_schema("{ nested = { mode = String; count = 1; }; }").await; + let mut source = f.source(); + source["plugins"]["enabled"]["echo"]["config"] = json!({"nested":{"mode":"base","count":1}}); + source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] = + json!({"nested":{"mode":"override"}}); + let state = f.commit(source); + let plan = f.plan(&state); + let expected = json!({"nested":{"count":1,"mode":"override"}}); + assert_eq!(plan.plugins()[0].projection().config, expected); + assert_eq!( + serde_json::to_value(&plan).unwrap()["input"]["plugins"][0]["config_digest"], + config_source::digest_bytes(&serde_json::to_vec(&expected).unwrap()) + ); + let receipt = f + .store + .plugin_plan_create("host", "singletons", &plan) + .unwrap(); + assert_eq!(receipt.plan.plugins()[0].projection().config, expected); + let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap(); + let stored = reopened.plugin_plan_load("a", &receipt.plan_id).unwrap(); + assert_eq!(stored.plan, plan); + assert_eq!( + reopened + .plugin_plan_create("host", "singletons", &plan) + .unwrap(), + receipt + ); + // Even a canonically rehashed candidate cannot substitute the raw override + // for the complete projection reconstructed at the public create fence. + let mut forged = serde_json::to_value(&plan).unwrap()["input"].clone(); + forged["plugins"][0]["projection"]["config"] = json!({"nested":{"mode":"override"}}); + let input: WorkerPluginPlanInput = serde_json::from_value(forged).unwrap(); + let incomplete = WorkerPluginExecutionPlan::canonicalize(input).unwrap(); + assert!( + reopened + .plugin_plan_create("host", "raw-override", &incomplete) + .is_err() + ); +} + +#[test] +fn effective_config_size_limit_applies_after_schema_concretization() { + let schema = format!("{{ value = {}; }}", json!("a".repeat(64 * 1024))); + let error = evaluate_effective_config(&json!({}), &schema).unwrap_err(); + assert!( + error + .to_string() + .contains("non-secret config exceeds 64 KiB"), + "{error}" + ); +}