From ede7acfdf6718a9ce37f4ed31176ad6f51d41caa Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 23:52:13 +0900 Subject: [PATCH] fix: pin plugin resolution metadata --- crates/manifest/src/config.rs | 5 + crates/manifest/src/lib.rs | 5 + crates/manifest/src/plugin.rs | 270 ++++++++++++++++++++++++++++++--- crates/pod/src/entrypoint.rs | 7 + docs/design/plugin-packages.md | 3 +- 5 files changed, 265 insertions(+), 25 deletions(-) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 52987bdb..bea5ae8c 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -470,7 +470,12 @@ impl SkillsConfig { } fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig { + let upper_has_resolved_plan = upper.has_resolved_plan(); base.enabled.extend(upper.enabled); + if upper_has_resolved_plan { + base.resolved = upper.resolved; + base.diagnostics = upper.diagnostics; + } base } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 9e9c56b7..707253dd 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -878,6 +878,7 @@ model_id = "claude-sonnet-4-20250514" "{MINIMAL_REQUIRED}\n\ [[plugins.enabled]]\n\ id = \"project:example\"\n\ + version = \"0.1.0\"\n\ digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\ surfaces = [\"hook\"]\n\n\ [plugins.enabled.config]\n\ @@ -887,6 +888,10 @@ model_id = "claude-sonnet-4-20250514" assert_eq!(manifest.plugins.enabled.len(), 1); let enabled = &manifest.plugins.enabled[0]; assert_eq!(enabled.id, "project:example"); + assert_eq!( + enabled.version.as_ref().map(|version| version.0.as_str()), + Some("0.1.0") + ); assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]); assert_eq!( enabled diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index a9eb1e55..d1a45243 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -19,11 +19,20 @@ const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000; #[serde(default, deny_unknown_fields)] pub struct PluginConfig { pub enabled: Vec, + /// Runtime restore metadata. Fresh resolution fills this from discovered packages; + /// restore uses it without selecting newer mutable-store contents. + pub resolved: Vec, + /// Safe bounded discovery/resolution diagnostics recorded with the resolved plan. + pub diagnostics: Vec, } impl PluginConfig { pub fn is_empty(&self) -> bool { - self.enabled.is_empty() + self.enabled.is_empty() && self.resolved.is_empty() + } + + pub fn has_resolved_plan(&self) -> bool { + !self.resolved.is_empty() || !self.diagnostics.is_empty() } } @@ -32,6 +41,8 @@ impl PluginConfig { pub struct PluginEnablementConfig { /// Source-qualified plugin id such as `user:example`, `project:example`, or `builtin:example`. pub id: String, + /// Optional exact package version requirement. Rich version constraints are deferred. + pub version: Option, /// Optional deterministic digest pin in `sha256:` form. pub digest: Option, /// Optional explicit surface subset. When omitted, all declared package surfaces are selected. @@ -42,6 +53,16 @@ pub struct PluginEnablementConfig { pub config: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PluginExactVersion(pub String); + +impl PluginExactVersion { + pub fn matches(&self, version: &str) -> bool { + self.0 == version + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PluginGrantConfig { @@ -156,7 +177,7 @@ pub enum PluginIdParseError { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginPackageManifest { - pub api_version: u32, + pub schema_version: u32, pub id: String, pub name: String, pub version: String, @@ -277,6 +298,7 @@ pub struct ResolvedPlugin { pub identity: SourceQualifiedPluginId, pub source: PluginSourceKind, pub package_path: PathBuf, + pub package_label: String, pub digest: String, pub manifest: PluginPackageManifest, pub enabled_surfaces: Vec, @@ -284,13 +306,44 @@ pub struct ResolvedPlugin { pub config: Option, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ResolvedPluginRecord { + pub identity: SourceQualifiedPluginId, + pub source: PluginSourceKind, + pub package_path: PathBuf, + pub package_label: String, + pub digest: String, + pub version: String, + pub manifest: PluginPackageManifest, + pub enabled_surfaces: Vec, + pub grants: PluginGrantConfig, + pub config: Option, +} + +impl ResolvedPluginRecord { + fn from_resolved(resolved: &ResolvedPlugin) -> Self { + Self { + identity: resolved.identity.clone(), + source: resolved.source, + package_path: resolved.package_path.clone(), + package_label: resolved.package_label.clone(), + digest: resolved.digest.clone(), + version: resolved.manifest.version.clone(), + manifest: resolved.manifest.clone(), + enabled_surfaces: resolved.enabled_surfaces.clone(), + grants: resolved.grants.clone(), + config: resolved.config.clone(), + } + } +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct PluginResolution { pub resolved: Vec, pub diagnostics: Vec, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PluginDiagnostic { pub kind: PluginDiagnosticKind, pub phase: PluginDiagnosticPhase, @@ -339,7 +392,8 @@ impl PluginDiagnostic { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum PluginDiagnosticKind { Missing, Duplicate, @@ -355,7 +409,8 @@ pub enum PluginDiagnosticKind { Io, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum PluginDiagnosticPhase { Discovery, Manifest, @@ -476,6 +531,23 @@ pub fn resolve_enabled_plugins( } } + if let Some(required_version) = &enablement.version { + if !required_version.matches(&package.manifest.version) { + resolution.diagnostics.push( + PluginDiagnostic::new( + PluginDiagnosticKind::Version, + PluginDiagnosticPhase::Resolution, + "enabled plugin exact version requirement does not match discovered package version", + ) + .with_source(identity.source) + .with_identity(&identity) + .with_package(&package.package_label) + .with_digest(&package.digest), + ); + continue; + } + } + if !enablement.grants.is_empty() { resolution.diagnostics.push( PluginDiagnostic::new( @@ -519,6 +591,7 @@ pub fn resolve_enabled_plugins( identity: identity.clone(), source: identity.source, package_path: package.package_path.clone(), + package_label: package.package_label.clone(), digest: package.digest.clone(), manifest: package.manifest.clone(), enabled_surfaces: selected_surfaces.into_iter().collect(), @@ -530,6 +603,27 @@ pub fn resolve_enabled_plugins( resolution } +pub fn resolve_plugin_config_for_startup( + config: &PluginConfig, + options: &PluginDiscoveryOptions, +) -> PluginConfig { + if config.enabled.is_empty() || config.has_resolved_plan() { + return config.clone(); + } + + let discovery = discover_plugins(options); + let resolution = resolve_enabled_plugins(config, &discovery); + let mut snapshot = config.clone(); + snapshot.resolved = resolution + .resolved + .iter() + .map(ResolvedPluginRecord::from_resolved) + .collect(); + snapshot.diagnostics = discovery.diagnostics; + snapshot.diagnostics.extend(resolution.diagnostics); + snapshot +} + #[derive(Clone, Debug)] struct PluginStore { source: PluginSourceKind, @@ -756,10 +850,7 @@ fn read_package( PluginDiagnostic::new( PluginDiagnosticKind::Malformed, PluginDiagnosticPhase::Manifest, - format!( - "plugin.toml could not be parsed: {}", - bounded_message(error.to_string()) - ), + safe_toml_parse_message(&error), ) .with_source(source) .with_package(label) @@ -784,11 +875,11 @@ fn validate_manifest( label: &str, source: PluginSourceKind, ) -> Result<(), PluginDiagnostic> { - if manifest.api_version != SUPPORTED_PLUGIN_API_VERSION { + if manifest.schema_version != SUPPORTED_PLUGIN_API_VERSION { return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Version, + PluginDiagnosticKind::Api, PluginDiagnosticPhase::Manifest, - "plugin API version is unsupported", + "plugin schema/API version is unsupported", ) .with_source(source) .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) @@ -1264,13 +1355,26 @@ fn safe_io_error(error: &io::Error) -> &'static str { } } +fn safe_toml_parse_message(error: &toml::de::Error) -> String { + let mut message = String::from("plugin.toml could not be parsed"); + if let Some(span) = error.span() { + message.push_str(&format!(" near byte span {}..{}", span.start, span.end)); + } + bounded_message(message) +} + fn bounded_message(message: String) -> String { const MAX: usize = 240; if message.len() <= MAX { - message - } else { - format!("{}…", &message[..MAX]) + return message; } + let end = message + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= MAX) + .last() + .unwrap_or(0); + format!("{}…", &message[..end]) } fn is_safe_id(value: &str) -> bool { @@ -1398,6 +1502,7 @@ mod tests { ..PluginEnablementConfig::default() }, ], + ..PluginConfig::default() }, &report, ); @@ -1427,6 +1532,7 @@ mod tests { digest: Some("sha256:0000".to_string()), ..PluginEnablementConfig::default() }], + ..PluginConfig::default() }, &report, ); @@ -1435,6 +1541,106 @@ mod tests { assert_eq!(resolution.diagnostics[0].kind, PluginDiagnosticKind::Digest); } + #[test] + fn exact_version_mismatch_fails_closed_with_distinct_diagnostic() { + let (report, _) = fixture_with_enabled_plugin(false); + let resolution = resolve_enabled_plugins( + &PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".to_string(), + version: Some(PluginExactVersion("9.9.9".to_string())), + ..PluginEnablementConfig::default() + }], + ..PluginConfig::default() + }, + &report, + ); + + assert!(resolution.resolved.is_empty()); + assert_eq!( + resolution.diagnostics[0].kind, + PluginDiagnosticKind::Version + ); + assert_eq!( + resolution.diagnostics[0].phase, + PluginDiagnosticPhase::Resolution + ); + assert!( + !resolution + .diagnostics + .iter() + .any(|diag| diag.kind == PluginDiagnosticKind::Api) + ); + } + + #[test] + fn resolved_plan_pins_unpinned_enablement_for_restore() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + let plugins = workspace.join(".yoi/plugins"); + fs::create_dir_all(&plugins).unwrap(); + let package = plugins.join("example.yoi-plugin"); + write_plugin_version( + &package, + "example", + "0.1.0", + &[PluginSurface::Hook], + &[("hooks/example.md", b"v1")], + ); + let options = PluginDiscoveryOptions::new(&workspace); + let config = PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".to_string(), + ..PluginEnablementConfig::default() + }], + ..PluginConfig::default() + }; + + let startup_snapshot = resolve_plugin_config_for_startup(&config, &options); + assert_eq!(startup_snapshot.resolved.len(), 1); + let restored_digest = startup_snapshot.resolved[0].digest.clone(); + assert_eq!(startup_snapshot.resolved[0].version, "0.1.0"); + + write_plugin_version( + &package, + "example", + "0.2.0", + &[PluginSurface::Hook], + &[("hooks/example.md", b"v2")], + ); + let fresh_snapshot = resolve_plugin_config_for_startup(&config, &options); + assert_ne!(fresh_snapshot.resolved[0].digest, restored_digest); + assert_eq!(fresh_snapshot.resolved[0].version, "0.2.0"); + + let restored_snapshot = resolve_plugin_config_for_startup(&startup_snapshot, &options); + assert_eq!(restored_snapshot.resolved[0].digest, restored_digest); + assert_eq!(restored_snapshot.resolved[0].version, "0.1.0"); + } + + #[test] + fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + let plugins = workspace.join(".yoi/plugins"); + fs::create_dir_all(&plugins).unwrap(); + let malformed = format!("schema_version = [\n# {}", "機密".repeat(200)); + write_stored_zip( + &plugins.join("bad-multibyte.yoi-plugin"), + &[("plugin.toml", malformed.into_bytes(), 0)], + ); + + let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); + + assert!(report.packages.is_empty()); + let diagnostic = report + .diagnostics + .iter() + .find(|diag| diag.kind == PluginDiagnosticKind::Malformed) + .unwrap(); + assert!(diagnostic.message.len() <= 241); + assert!(!diagnostic.message.contains("機密")); + } + #[test] fn traversal_root_escape_in_archive_fails_closed() { let temp = TempDir::new().unwrap(); @@ -1446,7 +1652,7 @@ mod tests { &[ ( "plugin.toml", - manifest("escape", &[PluginSurface::Hook]).into_bytes(), + manifest("escape", "0.1.0", &[PluginSurface::Hook]).into_bytes(), 0, ), ("../evil", b"x".to_vec(), 0), @@ -1490,10 +1696,10 @@ mod tests { let plugins = workspace.join(".yoi/plugins"); fs::create_dir_all(&plugins).unwrap(); write_stored_zip( - &plugins.join("bad-api.yoi-plugin"), + &plugins.join("bad-schema.yoi-plugin"), &[( "plugin.toml", - manifest_with_api("bad_api", 999).into_bytes(), + manifest_with_schema("bad_schema", "0.1.0", 999).into_bytes(), 0, )], ); @@ -1509,7 +1715,7 @@ mod tests { report .diagnostics .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Version) + .any(|diag| diag.kind == PluginDiagnosticKind::Api) ); assert!( report @@ -1539,6 +1745,7 @@ mod tests { ..PluginEnablementConfig::default() }, ], + ..PluginConfig::default() }, &report, ); @@ -1579,6 +1786,7 @@ mod tests { } else { vec![] }, + ..PluginConfig::default() }; (report, config) } @@ -1589,7 +1797,21 @@ mod tests { surfaces: &[PluginSurface], extra_files: &[(&str, &[u8])], ) { - let mut entries = vec![("plugin.toml", manifest(id, surfaces).into_bytes(), 0)]; + write_plugin_version(path, id, "0.1.0", surfaces, extra_files); + } + + fn write_plugin_version( + path: &Path, + id: &str, + version: &str, + surfaces: &[PluginSurface], + extra_files: &[(&str, &[u8])], + ) { + let mut entries = vec![( + "plugin.toml", + manifest(id, version, surfaces).into_bytes(), + 0, + )]; if surfaces.contains(&PluginSurface::Hook) && !extra_files .iter() @@ -1605,17 +1827,17 @@ mod tests { write_stored_zip(path, &entries); } - fn manifest(id: &str, surfaces: &[PluginSurface]) -> String { - let mut manifest = manifest_with_api(id, SUPPORTED_PLUGIN_API_VERSION); + fn manifest(id: &str, version: &str, surfaces: &[PluginSurface]) -> String { + let mut manifest = manifest_with_schema(id, version, SUPPORTED_PLUGIN_API_VERSION); if surfaces.contains(&PluginSurface::Hook) { manifest.push_str("\n[[hooks]]\nid = \"startup\"\nfile = \"hooks/example.md\"\n"); } manifest } - fn manifest_with_api(id: &str, api_version: u32) -> String { + fn manifest_with_schema(id: &str, version: &str, schema_version: u32) -> String { format!( - "api_version = {api_version}\nid = \"{id}\"\nname = \"Example\"\nversion = \"0.1.0\"\n" + "schema_version = {schema_version}\nid = \"{id}\"\nname = \"Example\"\nversion = \"{version}\"\n" ) } diff --git a/crates/pod/src/entrypoint.rs b/crates/pod/src/entrypoint.rs index a1e627ae..083472dc 100644 --- a/crates/pod/src/entrypoint.rs +++ b/crates/pod/src/entrypoint.rs @@ -7,6 +7,7 @@ use clap::{CommandFactory, FromArgMatches, Parser}; use manifest::{ Permission, PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule, paths, + plugin::{PluginDiscoveryOptions, resolve_plugin_config_for_startup}, }; use pod_store::{CombinedStore, FsPodStore, PodMetadataStore}; use session_store::{FsStore, SegmentId, Store}; @@ -184,9 +185,15 @@ where apply_profile_launch_policy(&mut manifest, &workspace_root, cli.ticket_role.as_deref())?; } apply_session_restore_overrides(&mut manifest, cli)?; + apply_plugin_resolution_plan(&mut manifest, &workspace_root); Ok((manifest, loader)) } +fn apply_plugin_resolution_plan(manifest: &mut PodManifest, workspace_root: &Path) { + let options = PluginDiscoveryOptions::new(workspace_root); + manifest.plugins = resolve_plugin_config_for_startup(&manifest.plugins, &options); +} + fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> { if let Some(pod_name) = cli.pod.as_deref() { manifest.pod.name = pod_name.to_string(); diff --git a/docs/design/plugin-packages.md b/docs/design/plugin-packages.md index fa5f68ca..7dc7fd35 100644 --- a/docs/design/plugin-packages.md +++ b/docs/design/plugin-packages.md @@ -104,11 +104,12 @@ Discovery is a read-only inventory operation. It may report package metadata, va Enablement is a resolved runtime plan. It should come from Profile/manifest configuration or another explicit local policy layer, then be recorded into the resolved Manifest/session metadata used to start the Pod. Restored Pods should use that resolved enabled-plugin plan instead of silently re-running fresh discovery and picking newer packages. Fresh discovery must not silently upgrade a restored Pod. -A future enablement record can be shaped like this, but the exact schema belongs to the implementation Ticket: +A minimal implemented enablement record is shaped like this. `version` is an exact package-version requirement; richer range constraints are deferred. `digest` is optional in authoring config, but fresh startup records the resolved digest into runtime metadata. ```toml [[plugins.enabled]] id = "user:example" +version = "0.1.0" # optional exact package-version requirement digest = "sha256:..." # optional pin in authoring, resolved in runtime metadata config = { level = "concise" } ```