merge: plugin package resolver
This commit is contained in:
commit
f678383aad
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1798,6 +1798,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ protocol = { workspace = true }
|
|||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_ignored = "0.1.14"
|
||||
sha2 = "0.10"
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::defaults;
|
||||
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig,
|
||||
PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig,
|
||||
|
|
@ -52,6 +53,10 @@ pub struct PodManifestConfig {
|
|||
/// disabled after cascade merge.
|
||||
#[serde(default)]
|
||||
pub feature: FeatureConfigPartial,
|
||||
/// Explicit plugin package enablement entries. Discovery/resolution is a
|
||||
/// separate step and does not run during config merge.
|
||||
#[serde(default)]
|
||||
pub plugins: PluginConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfigPartial>,
|
||||
/// First-class web tool opt-in. See [`WebConfig`].
|
||||
|
|
@ -444,6 +449,7 @@ impl PodManifestConfig {
|
|||
PermissionConfigPartial::merge,
|
||||
),
|
||||
feature: self.feature.merge(upper.feature),
|
||||
plugins: merge_plugin_config(self.plugins, upper.plugins),
|
||||
compaction: merge_option(
|
||||
self.compaction,
|
||||
upper.compaction,
|
||||
|
|
@ -463,6 +469,16 @@ 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
|
||||
}
|
||||
|
||||
impl WebConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
|
|
@ -827,6 +843,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
|||
session,
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
plugins: cfg.plugins,
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
memory: cfg.memory,
|
||||
|
|
@ -873,6 +890,7 @@ mod tests {
|
|||
delegation_scope: ScopeConfig::default(),
|
||||
permissions: None,
|
||||
feature: FeatureConfigPartial::default(),
|
||||
plugins: PluginConfig::default(),
|
||||
session: None,
|
||||
compaction: None,
|
||||
web: None,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ mod config;
|
|||
pub mod defaults;
|
||||
mod model;
|
||||
pub mod paths;
|
||||
pub mod plugin;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
|
|
@ -57,6 +58,10 @@ pub struct PodManifest {
|
|||
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
||||
#[serde(default)]
|
||||
pub feature: FeatureConfig,
|
||||
/// Explicit plugin package enablement. Discovery remains read-only; only
|
||||
/// source-qualified entries listed here may resolve to active plugin metadata.
|
||||
#[serde(default)]
|
||||
pub plugins: plugin::PluginConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// Memory subsystem configuration. Presence of `[memory]` configures memory
|
||||
|
|
@ -867,6 +872,37 @@ model_id = "claude-sonnet-4-20250514"
|
|||
assert!(PodManifest::from_toml(toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plugin_enablement_config() {
|
||||
let toml = format!(
|
||||
"{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\
|
||||
greeting = \"hello\"\n"
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
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
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("greeting"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("hello")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_max_turns() {
|
||||
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");
|
||||
|
|
|
|||
1938
crates/manifest/src/plugin.rs
Normal file
1938
crates/manifest/src/plugin.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -17,6 +17,7 @@ use crate::config::{
|
|||
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
||||
};
|
||||
use crate::model::{AuthRef, ModelManifest};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError,
|
||||
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths,
|
||||
|
|
@ -626,6 +627,7 @@ fn resolve_lua_profile_value(
|
|||
session: profile.session,
|
||||
permissions: profile.permissions,
|
||||
feature: profile.feature,
|
||||
plugins: profile.plugins,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
memory: profile.memory,
|
||||
|
|
@ -687,6 +689,8 @@ struct ProfileConfig {
|
|||
#[serde(default)]
|
||||
feature: FeatureConfigPartial,
|
||||
#[serde(default)]
|
||||
plugins: PluginConfig,
|
||||
#[serde(default)]
|
||||
compaction: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
web: Option<WebConfig>,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -924,11 +924,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
}
|
||||
|
||||
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> 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<PodActiveSegmentRef>,
|
||||
) -> 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<serde_json::Value>,
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -775,6 +775,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig {
|
|||
rules: p.rules.clone(),
|
||||
}),
|
||||
feature: manifest.feature.clone().into(),
|
||||
plugins: manifest.plugins.clone(),
|
||||
compaction: manifest
|
||||
.compaction
|
||||
.as_ref()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -104,11 +99,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" }
|
||||
```
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-pIDYnbBs3U8Z3IndgH10rirv8/IdFv1WlgwpCbKXy+M=";
|
||||
cargoHash = "sha256-Y1siH1oDe9It7ntx83DJO5fzV9LtC7+qq9V6RPlRxUY=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user