merge: plugin package resolver

This commit is contained in:
Keisuke Hirata 2026-06-16 00:27:15 +09:00
commit f678383aad
No known key found for this signature in database
11 changed files with 2116 additions and 35 deletions

1
Cargo.lock generated
View File

@ -1798,6 +1798,7 @@ dependencies = [
"serde",
"serde_ignored",
"serde_json",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.18",
"toml",

View File

@ -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 }

View File

@ -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,

View File

@ -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");

File diff suppressed because it is too large Load Diff

View File

@ -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>,

View File

@ -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();

View File

@ -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)]

View File

@ -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()

View File

@ -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" }
```

View File

@ -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,