feat: compose Subject Ticket assignment with selected dependency contracts
Adapt T-778 source6d15569and selected T-465 plugin plans on T-80351f0c7e0. Preserve frozen Workspace107 and append Subject108, current assignee/target checks, recipient-scoped notification recovery and public contract generation. This is a candidate composition, not target adoption.
This commit is contained in:
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
|
||||
/// never starts a local MCP subprocess.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Omission retains the lower layer; an explicit section replaces the entire
|
||||
/// selection list, including an empty list. Never unions Plugin grants.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plugins: Option<crate::PluginProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfigPartial>,
|
||||
/// First-class web tool opt-in. See [`WebConfig`].
|
||||
@@ -800,11 +804,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
||||
"unknown field in manifest: memory (removed; configure feature.memory)",
|
||||
));
|
||||
}
|
||||
if value.get("plugins").is_some() {
|
||||
return Err(toml::de::Error::custom(
|
||||
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
|
||||
));
|
||||
}
|
||||
if value
|
||||
.get("feature")
|
||||
.and_then(toml::Value::as_table)
|
||||
@@ -935,6 +934,7 @@ impl WorkerManifestConfig {
|
||||
),
|
||||
feature: self.feature.merge(upper.feature),
|
||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||
plugins: upper.plugins.or(self.plugins),
|
||||
compaction: merge_option(
|
||||
self.compaction,
|
||||
upper.compaction,
|
||||
@@ -1450,6 +1450,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
mcp: cfg.mcp,
|
||||
plugins: cfg.plugins.unwrap_or_default(),
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
skills: cfg.skills,
|
||||
@@ -1497,6 +1498,7 @@ mod tests {
|
||||
permissions: None,
|
||||
feature: FeatureConfigPartial::default(),
|
||||
mcp: McpConfig::default(),
|
||||
plugins: None,
|
||||
session: None,
|
||||
compaction: None,
|
||||
web: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ mod model;
|
||||
pub mod model_catalog;
|
||||
pub mod paths;
|
||||
pub mod plugin;
|
||||
mod plugin_profile;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
@@ -24,6 +25,7 @@ pub use model::{
|
||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||
};
|
||||
pub use paths::user_profiles_path;
|
||||
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection};
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
||||
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
|
||||
/// consumer boundary.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
|
||||
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
|
||||
/// their serialized fingerprint inputs.
|
||||
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
|
||||
pub plugins: PluginProfileConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// First-class web tools configuration. Network access remains fail-closed
|
||||
@@ -1119,6 +1126,7 @@ impl Default for CompactionConfig {
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
|| !self.plugins.is_empty()
|
||||
|| self.feature.memory.workspace_settings.is_some()
|
||||
|| self.feature.subjektiv.workspace_settings.is_some()
|
||||
}
|
||||
@@ -1598,9 +1606,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
);
|
||||
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("dynamic Plugins are not supported"),
|
||||
error.to_string().contains("unknown field `enabled`"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
@@ -1620,7 +1626,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
}),
|
||||
);
|
||||
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown field `plugins`"));
|
||||
assert!(error.to_string().contains("unknown field `resolved`"));
|
||||
|
||||
let mut nested = base;
|
||||
nested
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Narrowing requests for Workspace-managed Plugin installations.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Profile input only, not Workspace enablement or a validated execution plan.
|
||||
///
|
||||
/// The Workspace owner must resolve each installation against its enabled exact
|
||||
/// artifact and reject selections outside its Tool/HTTPS/logging ceiling. Neither
|
||||
/// these names nor config values grant authority, select package paths, or cause
|
||||
/// discovery, dynamic registration, or guest execution.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginProfileConfig {
|
||||
#[serde(default)]
|
||||
pub selections: Vec<PluginProfileSelection>,
|
||||
}
|
||||
|
||||
impl PluginProfileConfig {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.selections.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// One installation's explicit subset; omitted surfaces and capabilities are off.
|
||||
///
|
||||
/// `tools` and `https_slots` are package-declared names, not public Tool names,
|
||||
/// URLs, host paths, or connection bindings. `config` contains non-secret override
|
||||
/// candidates only: the Workspace schema validator must reject them unless the
|
||||
/// package's closed typed schema permits those overrides. Deserialization alone
|
||||
/// cannot certify that arbitrary package-specific values are non-secret or valid.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginProfileSelection {
|
||||
pub installation_id: String,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub logging: bool,
|
||||
#[serde(default)]
|
||||
pub https_slots: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub config: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
ProfileSource, WorkerManifest, WorkerManifestConfig,
|
||||
read_persisted_worker_manifest_snapshot, resolve_profile_artifact_value,
|
||||
write_persisted_worker_manifest_snapshot,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn partial(plugins: Value) -> WorkerManifestConfig {
|
||||
serde_json::from_value(json!({
|
||||
"worker": { "name": "plugin-worker" },
|
||||
"plugins": plugins,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn resolve_profile(plugins: Value) -> crate::ResolvedProfile {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
resolve_profile_artifact_value(
|
||||
json!({ "plugins": plugins }),
|
||||
ProfileSource::Archive {
|
||||
archive_id: "profile-input".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_plugin_selections_and_grants_default_off() {
|
||||
let omitted = WorkerManifestConfig::from_toml("[worker]\nname = 'plain'\n").unwrap();
|
||||
assert!(
|
||||
serde_json::to_value(&omitted)
|
||||
.unwrap()
|
||||
.get("plugins")
|
||||
.is_none()
|
||||
);
|
||||
let manifest = WorkerManifest::try_from(omitted).unwrap();
|
||||
assert!(manifest.plugins.selections.is_empty());
|
||||
assert!(!manifest.requires_persisted_execution_snapshot());
|
||||
assert!(
|
||||
serde_json::to_value(manifest)
|
||||
.unwrap()
|
||||
.get("plugins")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let manifest = WorkerManifest::try_from(partial(json!({
|
||||
"selections": [{ "installation_id": "installation-a" }]
|
||||
})))
|
||||
.unwrap();
|
||||
let selection = &manifest.plugins.selections[0];
|
||||
assert!(selection.tools.is_empty());
|
||||
assert!(!selection.logging);
|
||||
assert!(selection.https_slots.is_empty());
|
||||
assert!(selection.config.is_empty());
|
||||
assert!(manifest.requires_persisted_execution_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_partial_merge_replaces_instead_of_unioning_and_can_clear() {
|
||||
let lower = partial(json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read", "write"],
|
||||
"logging": true, "https_slots": ["remote"], "config": {"limit": 10}
|
||||
}] }));
|
||||
let omitted = lower.clone().merge(WorkerManifestConfig::default());
|
||||
assert_eq!(omitted.plugins, lower.plugins);
|
||||
|
||||
let upper = partial(json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read"]
|
||||
}] }));
|
||||
let merged = lower.clone().merge(upper.clone());
|
||||
assert_eq!(merged.plugins, upper.plugins);
|
||||
let round_trip: WorkerManifestConfig =
|
||||
serde_json::from_value(serde_json::to_value(&merged).unwrap()).unwrap();
|
||||
assert_eq!(round_trip.plugins, merged.plugins);
|
||||
|
||||
for empty in [json!({}), json!({ "selections": [] })] {
|
||||
let cleared = lower.clone().merge(partial(empty));
|
||||
assert_eq!(cleared.plugins, Some(PluginProfileConfig::default()));
|
||||
let manifest = WorkerManifest::try_from(cleared).unwrap();
|
||||
assert!(manifest.plugins.selections.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_profile_toml_and_resolved_snapshots_preserve_typed_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = r#"
|
||||
[[plugins.selections]]
|
||||
installation_id = "installation-a"
|
||||
tools = ["lookup"]
|
||||
logging = true
|
||||
https_slots = ["api"]
|
||||
[plugins.selections.config]
|
||||
limit = 5
|
||||
labels = ["one", "two"]
|
||||
[plugins.selections.config.nested]
|
||||
strict = true
|
||||
"#;
|
||||
let parsed = WorkerManifestConfig::from_toml(source).unwrap();
|
||||
let authored: toml::Value = toml::from_str(source).unwrap();
|
||||
let resolved = resolve_profile_artifact_value(
|
||||
serde_json::to_value(authored).unwrap(),
|
||||
ProfileSource::Archive {
|
||||
archive_id: "typed-profile".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(Some(&resolved.manifest.plugins), parsed.plugins.as_ref());
|
||||
assert_eq!(
|
||||
resolved.manifest.plugins.selections[0].config["nested"],
|
||||
json!({"strict": true})
|
||||
);
|
||||
let snapshot = write_persisted_worker_manifest_snapshot(&resolved.manifest).unwrap();
|
||||
let restored = read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
|
||||
assert_eq!(restored.plugins, resolved.manifest.plugins);
|
||||
assert_eq!(
|
||||
write_persisted_worker_manifest_snapshot(&restored).unwrap(),
|
||||
snapshot
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored).unwrap(),
|
||||
resolved.manifest_snapshot
|
||||
);
|
||||
let toml_round_trip = toml::to_string(&resolved.manifest).unwrap();
|
||||
assert_eq!(
|
||||
WorkerManifest::from_toml(&toml_round_trip).unwrap().plugins,
|
||||
resolved.manifest.plugins
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_fields_change_serialized_profile_fingerprint_input() {
|
||||
let baseline = json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read"],
|
||||
"logging": false, "https_slots": [], "config": {"limit": 5}
|
||||
}] });
|
||||
let fingerprint = |plugins| {
|
||||
let resolved = resolve_profile(plugins);
|
||||
Sha256::digest(serde_json::to_vec(&resolved.manifest_snapshot).unwrap())
|
||||
};
|
||||
let original = fingerprint(baseline.clone());
|
||||
assert_eq!(fingerprint(baseline.clone()), original);
|
||||
for (field, value) in [
|
||||
("installation_id", json!("installation-b")),
|
||||
("tools", json!(["write"])),
|
||||
("logging", json!(true)),
|
||||
("https_slots", json!(["api"])),
|
||||
("config", json!({"limit": 6})),
|
||||
] {
|
||||
let mut changed = baseline.clone();
|
||||
changed["selections"][0][field] = value;
|
||||
assert_ne!(fingerprint(changed), original, "{field} was lost");
|
||||
}
|
||||
assert_eq!(
|
||||
fingerprint(json!({})),
|
||||
fingerprint(json!({"selections": []}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_config_rejects_legacy_package_and_unknown_authority_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut invalid = vec![
|
||||
json!({ "enabled": [{ "id": "project:legacy" }] }),
|
||||
json!({ "packages": ["/tmp/legacy.yoi-plugin"] }),
|
||||
json!({ "resolved": [{ "package_path": "/tmp/legacy.yoi-plugin" }] }),
|
||||
json!({ "enable": true }),
|
||||
];
|
||||
for (field, value) in [
|
||||
("package_path", json!("/tmp/legacy.yoi-plugin")),
|
||||
("path", json!("./plugin")),
|
||||
("url", json!("https://example.invalid/plugin")),
|
||||
("artifact_digest", json!("sha256:uninstalled")),
|
||||
("secret", json!("not-a-secret-reference")),
|
||||
("data", json!(true)),
|
||||
("events", json!(["changed"])),
|
||||
("websocket", json!(true)),
|
||||
("workdir", json!(true)),
|
||||
("unknown", json!(true)),
|
||||
] {
|
||||
let mut selection = json!({ "installation_id": "installation-a" });
|
||||
selection[field] = value;
|
||||
invalid.push(json!({ "selections": [selection] }));
|
||||
}
|
||||
invalid.extend([
|
||||
json!({ "selections": [{ "tools": ["read"] }] }),
|
||||
json!({ "selections": [{ "installation_id": "a", "config": "raw" }] }),
|
||||
]);
|
||||
for plugins in invalid {
|
||||
let partial_input = json!({ "plugins": plugins.clone() });
|
||||
assert!(
|
||||
serde_json::from_value::<WorkerManifestConfig>(partial_input.clone()).is_err(),
|
||||
"{plugins}"
|
||||
);
|
||||
let toml = toml::to_string(&partial_input).unwrap();
|
||||
assert!(WorkerManifestConfig::from_toml(&toml).is_err(), "{toml}");
|
||||
let error = resolve_profile_artifact_value(
|
||||
partial_input,
|
||||
ProfileSource::Archive {
|
||||
archive_id: "invalid".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("failed to decode Profile"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,7 @@ fn resolve_profile_value(
|
||||
permissions: profile.permissions,
|
||||
feature: profile.feature,
|
||||
mcp: profile.mcp,
|
||||
plugins: profile.plugins,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
skills: profile.skills,
|
||||
@@ -700,6 +701,8 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
plugins: Option<crate::PluginProfileConfig>,
|
||||
#[serde(default)]
|
||||
compaction: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
web: Option<WebConfig>,
|
||||
|
||||
Reference in New Issue
Block a user