diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index d1a45243..431d919a 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -1617,6 +1617,42 @@ mod tests { assert_eq!(restored_snapshot.resolved[0].version, "0.1.0"); } + #[test] + fn currently_documented_manifest_shape_is_accepted() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + let plugins = workspace.join(".yoi/plugins"); + fs::create_dir_all(&plugins).unwrap(); + let manifest = r#" +schema_version = 1 +id = "example.summarizer" +name = "Example Summarizer" +version = "0.1.0" +description = "Adds a custom summary command." +surfaces = ["hook"] + +[[hooks]] +id = "summary" +file = "hooks/summary.md" +"#; + write_stored_zip( + &plugins.join("documented.yoi-plugin"), + &[ + ("plugin.toml", manifest.as_bytes().to_vec(), 0), + ("hooks/summary.md", b"summarize".to_vec(), 0), + ], + ); + + let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); + + assert_eq!(report.diagnostics, vec![]); + assert_eq!(report.packages.len(), 1); + assert_eq!( + report.packages[0].identity.to_string(), + "project:example.summarizer" + ); + } + #[test] fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() { let temp = TempDir::new().unwrap(); diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 524e573e..fe155b63 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -924,11 +924,7 @@ impl Pod { } fn pod_metadata(&self, active: Option) -> PodMetadata { - let mut metadata = PodMetadata::new(self.manifest.pod.name.clone(), active); - if self.manifest.profile.is_some() { - metadata.resolved_manifest_snapshot = serde_json::to_value(&self.manifest).ok(); - } - metadata + pod_metadata_for_manifest(&self.manifest, active) } fn write_pod_metadata_pending(&self) -> Result<(), PodError> { @@ -4321,6 +4317,21 @@ fn request_config_from_worker_manifest(wm: &WorkerManifest) -> RequestConfig { config } +fn pod_metadata_for_manifest( + manifest: &PodManifest, + active: Option, +) -> PodMetadata { + let mut metadata = PodMetadata::new(manifest.pod.name.clone(), active); + if should_persist_resolved_manifest_snapshot(manifest) { + metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok(); + } + metadata +} + +fn should_persist_resolved_manifest_snapshot(manifest: &PodManifest) -> bool { + manifest.profile.is_some() || manifest.plugins.has_resolved_plan() +} + fn restore_manifest_from_pod_metadata_snapshot( pod_name: &str, snapshot: Option, @@ -5294,6 +5305,74 @@ permission = "write" Permission::Write ); } + + #[test] + fn plugin_resolved_manifest_snapshot_is_persisted_without_profile() { + let mut manifest = PodManifest::from_toml( + r#" +[pod] +name = "plugin-snapshot" + +[model] +scheme = "anthropic" +model_id = "claude-sonnet-4-20250514" + +[worker] +instruction = "saved" + +[[scope.allow]] +target = "/snapshot/workspace" +permission = "read" +"#, + ) + .unwrap(); + assert!(manifest.profile.is_none()); + assert!( + pod_metadata_for_manifest(&manifest, None) + .resolved_manifest_snapshot + .is_none() + ); + + manifest.plugins.resolved = vec![manifest::plugin::ResolvedPluginRecord { + identity: manifest::plugin::SourceQualifiedPluginId::new( + manifest::plugin::PluginSourceKind::Project, + "example", + ), + source: manifest::plugin::PluginSourceKind::Project, + package_path: PathBuf::from("/snapshot/workspace/.yoi/plugins/example.yoi-plugin"), + package_label: "example.yoi-plugin".to_string(), + digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + version: "0.1.0".to_string(), + manifest: manifest::plugin::PluginPackageManifest { + schema_version: 1, + id: "example".to_string(), + name: "Example".to_string(), + version: "0.1.0".to_string(), + description: None, + surfaces: vec![manifest::plugin::PluginSurface::Hook], + runtime: None, + hooks: vec![], + }, + enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], + grants: manifest::plugin::PluginGrantConfig::default(), + config: None, + }]; + + let metadata = pod_metadata_for_manifest(&manifest, None); + let snapshot = metadata + .resolved_manifest_snapshot + .expect("plugin-resolved manifest should be snapshotted"); + let restored: PodManifest = serde_json::from_value(snapshot).unwrap(); + + assert!(restored.profile.is_none()); + assert_eq!(restored.plugins.resolved.len(), 1); + assert_eq!( + restored.plugins.resolved[0].digest, + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!(restored.plugins.resolved[0].version, "0.1.0"); + } } #[cfg(test)] diff --git a/docs/design/plugin-packages.md b/docs/design/plugin-packages.md index 7dc7fd35..f152037b 100644 --- a/docs/design/plugin-packages.md +++ b/docs/design/plugin-packages.md @@ -22,51 +22,46 @@ LICENSE* # recommended license text assets/** # optional non-executable data assets ``` -The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register `hooks/*.toml`, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution. +The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register hook metadata, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution. ## `plugin.toml` `plugin.toml` is the package authority for package identity and declared needs. It is not the authority for runtime grants. -Illustrative manifest shape: +Currently implemented strict `plugin.toml` shape: ```toml schema_version = 1 -id = "example" -name = "Example Plugin" +id = "example.summarizer" +name = "Example Summarizer" version = "0.1.0" -description = "Demonstrates declarative hooks and an optional WASM module." - -[runtime] -kind = "wasm" # "declarative" or "wasm" for the initial plugin system -entry = "module.wasm" -abi = "yoi-plugin-wasm-1" - -[package] -readme = "README.md" -license = "LICENSE" - -[permissions] -tools = ["Bash"] -web = false -secrets = [] -filesystem = [] +description = "Adds a custom summary command." +surfaces = ["hook"] [[hooks]] -id = "summarize-ticket" -file = "hooks/summarize-ticket.toml" +id = "summary" +file = "hooks/summary.md" ``` -Fields proposed for the first implementation pass: +The package archive must contain both root `plugin.toml` and the referenced `hooks/summary.md` entry. Optional WASM metadata is accepted only for the declared future runtime boundary and is not executed: + +```toml +[runtime] +kind = "wasm" +entry = "plugin.wasm" +abi = "yoi-plugin-wasm-1" +``` + +First-pass fields accepted by the parser: - `schema_version`: required integer; unsupported versions fail closed. - `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself. - `name`, `version`, `description`: human metadata used in listings and diagnostics. -- `runtime.kind`: required runtime family. Initial values should be `declarative` and `wasm`. -- `runtime.entry`: required for `wasm`, forbidden or ignored for purely declarative packages. -- `runtime.abi`: required for `wasm` so the host can reject incompatible modules before initialization. -- `hooks`, `schemas`, `package.readme`, `package.license`: package-relative paths that must pass the same normalized-path validation as archive entries. -- `permissions`: requested authority. These declarations are requests only; they do not grant access. +- `surfaces`: optional declared contribution surface names. +- `runtime`: optional WASM metadata only. Discovery records metadata and never executes it. +- `hooks`: optional hook metadata. Discovery records metadata and does not register hooks. + +Future descriptor sections such as `[package]`, `[permissions]`, richer `contributions`, or `runtime.kind = "declarative"` are aspirational and are intentionally rejected by the current strict parser until implemented safely. The `source` is not read from `plugin.toml`. It is assigned by the store that discovered the package.