fix: pin plugin resolution metadata
This commit is contained in:
parent
a03a9da64a
commit
ede7acfdf6
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,11 +19,20 @@ const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000;
|
|||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PluginConfig {
|
||||
pub enabled: Vec<PluginEnablementConfig>,
|
||||
/// Runtime restore metadata. Fresh resolution fills this from discovered packages;
|
||||
/// restore uses it without selecting newer mutable-store contents.
|
||||
pub resolved: Vec<ResolvedPluginRecord>,
|
||||
/// Safe bounded discovery/resolution diagnostics recorded with the resolved plan.
|
||||
pub diagnostics: Vec<PluginDiagnostic>,
|
||||
}
|
||||
|
||||
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<PluginExactVersion>,
|
||||
/// Optional deterministic digest pin in `sha256:<hex>` form.
|
||||
pub digest: Option<String>,
|
||||
/// Optional explicit surface subset. When omitted, all declared package surfaces are selected.
|
||||
|
|
@ -42,6 +53,16 @@ pub struct PluginEnablementConfig {
|
|||
pub config: Option<toml::Value>,
|
||||
}
|
||||
|
||||
#[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<PluginSurface>,
|
||||
|
|
@ -284,13 +306,44 @@ pub struct ResolvedPlugin {
|
|||
pub config: Option<toml::Value>,
|
||||
}
|
||||
|
||||
#[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<PluginSurface>,
|
||||
pub grants: PluginGrantConfig,
|
||||
pub config: Option<toml::Value>,
|
||||
}
|
||||
|
||||
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<ResolvedPlugin>,
|
||||
pub diagnostics: Vec<PluginDiagnostic>,
|
||||
}
|
||||
|
||||
#[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"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user