feat(plugins): activate trusted declarative adapters

Signed-off-by: Hunter Bown <hmbown@gmail.com>
This commit is contained in:
Hunter Bown
2026-08-19 20:55:44 -07:00
parent de062ebd16
commit 5444ab10bb
32 changed files with 1217 additions and 189 deletions
+1
View File
@@ -84,6 +84,7 @@ mod tests {
pausable: false,
aliases: aliases.iter().map(|s| s.to_string()).collect(),
hidden,
plugin_authority: None,
}
}
@@ -802,7 +802,7 @@ fn mutate_bundle(app: &mut App, selector: &str, mutation: Mutation<'_>) -> Comma
if !inactive.is_empty() {
message.push(' ');
message.push_str(&format!(
"Compatibility: {}. Supported Skills/MCP are active; inactive: {}.",
"Compatibility: {}. Supported declarative components are active; inactive: {}.",
plugin.compatibility().as_str(),
inactive.join(", ")
));
@@ -67,7 +67,7 @@ pub(super) fn render_bundle_detail(
.collect::<Vec<_>>();
let _ = write!(
output,
"\nCompatibility: {}\nActive components: [{active_components}]\nInactive components: [{unsupported}]\nQualified skills: [{}]\nActivation boundary: trust stages the exact reviewed content but does not activate it; enable rebuilds this workspace's Skill/MCP catalog immediately; disable or revoke cancels in-flight plugin MCP operations and denies queued Skills. Commands, agents, hooks, LSP, native, filesystem-roots, and lifecycle-mutation stay inventoried and inactive.",
"\nCompatibility: {}\nActive components: [{active_components}]\nInactive components: [{unsupported}]\nQualified skills: [{}]\nActivation boundary: trust stages the exact reviewed content but does not activate it; enable rebuilds this workspace's Skills, MCP, Commands, Agents, and Hooks immediately. Every plugin command dispatch, Agent spawn, Hook process start, Skill use, and MCP call rechecks current authority. LSP, native, filesystem-roots, and lifecycle-mutation stay inventoried and inactive.",
plugin.compatibility().as_str(),
if skills.is_empty() {
"none".to_string()
@@ -234,9 +234,10 @@ fn write_mixed_bundle(root: &Path) {
fs::create_dir_all(bundle.join("skills/hello")).unwrap();
fs::create_dir_all(bundle.join("commands")).unwrap();
fs::create_dir_all(bundle.join("hooks")).unwrap();
fs::create_dir_all(bundle.join("lsp")).unwrap();
fs::write(
bundle.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"mixed\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\n[hooks]\npath = \"hooks\"\n",
"schema_version = 1\n[plugin]\nname = \"mixed\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\n[hooks]\npath = \"hooks\"\n[lsp]\npath = \"lsp\"\n",
)
.unwrap();
fs::write(
@@ -261,10 +262,7 @@ fn mixed_bundle_review_and_enable_keep_supported_components_active() {
let show = plugins(&mut app, Some("show mixed")).message.unwrap();
assert!(show.contains("Compatibility: partial"), "{show}");
assert!(
show.contains("Inactive components: [commands, hooks]"),
"{show}"
);
assert!(show.contains("Inactive components: [lsp]"), "{show}");
assert!(show.contains("Active components: [none]"), "{show}");
let review = plugins(&mut app, Some("trust mixed")).message.unwrap();
@@ -278,7 +276,7 @@ fn mixed_bundle_review_and_enable_keep_supported_components_active() {
assert!(!enabled.is_error, "{:?}", enabled.message);
let message = enabled.message.unwrap();
assert!(message.contains("Compatibility: partial"), "{message}");
assert!(message.contains("inactive: commands, hooks"), "{message}");
assert!(message.contains("inactive: lsp"), "{message}");
assert!(app.plugin_registry.is_active("mixed"));
assert_eq!(
app.plugin_registry
@@ -291,9 +289,9 @@ fn mixed_bundle_review_and_enable_keep_supported_components_active() {
let show = plugins(&mut app, Some("show mixed")).message.unwrap();
assert!(show.contains("State: active"), "{show}");
assert!(show.contains("Active components: [skills]"), "{show}");
assert!(show.contains("Inactive components: [lsp]"), "{show}");
assert!(
show.contains("Inactive components: [commands, hooks]"),
show.contains("Active components: [skills, commands, hooks]"),
"{show}"
);
assert!(show.contains("Qualified skills: [mixed:hello]"), "{show}");
+32 -7
View File
@@ -137,15 +137,40 @@ fn synthesize_workflow_command(name: &str, description: &str, path: &Path) -> St
/// Scan a single commands directory for `.md` files and return
/// `(name, content)` pairs. Errors are silently skipped.
pub(crate) fn load_commands_from_dir(dir: &Path) -> Vec<(String, String)> {
let mut commands: Vec<(String, String)> = Vec::new();
load_command_entries_from_component(dir)
.into_iter()
.map(|(name, content, _)| (name, content))
.collect()
}
if !dir.is_dir() {
return commands;
/// Load one reviewed command component from an immutable plugin snapshot.
/// Components may name either one markdown file or a directory of markdown
/// files; every returned entry retains the exact staged path for diagnostics.
pub(crate) fn load_command_entries_from_component(
component: &Path,
) -> Vec<(String, String, PathBuf)> {
if component.is_file() {
if component.extension().and_then(|value| value.to_str()) != Some("md") {
return Vec::new();
}
let Some(stem) = component.file_stem().and_then(|value| value.to_str()) else {
return Vec::new();
};
return std::fs::read_to_string(component)
.ok()
.map(|content| vec![(stem.to_lowercase(), content, component.to_path_buf())])
.unwrap_or_default();
}
let entries = match std::fs::read_dir(dir) {
let mut commands: Vec<(String, String, PathBuf)> = Vec::new();
if !component.is_dir() {
return Vec::new();
}
let entries = match std::fs::read_dir(component) {
Ok(entries) => entries,
Err(_) => return commands,
Err(_) => return Vec::new(),
};
for entry in entries.flatten() {
@@ -161,9 +186,9 @@ pub(crate) fn load_commands_from_dir(dir: &Path) -> Vec<(String, String)> {
Ok(c) => c,
Err(_) => continue,
};
commands.push((stem, content));
commands.push((stem, content, path));
}
commands.sort_by(|left, right| left.0.cmp(&right.0));
commands
}
+195 -20
View File
@@ -22,6 +22,9 @@ struct UserCommandRegistryState {
initialized: bool,
workspace: Option<PathBuf>,
command_dirs_snapshot: Vec<CommandDirSnapshot>,
plugin_workspace: Option<PathBuf>,
plugin_sources: Vec<crate::plugins::runtime::PluginComponentSource>,
plugin_errors: Vec<String>,
registry: UserCommandRegistry,
}
@@ -51,6 +54,7 @@ pub struct UserCommandMetadata {
pub pausable: bool,
pub aliases: Vec<String>,
pub hidden: bool,
pub plugin_authority: Option<crate::plugins::types::PluginAuthority>,
}
impl UserCommandMetadata {
@@ -102,6 +106,7 @@ impl UserCommandRegistry {
Self::default()
}
#[cfg(test)]
pub fn load(workspace: Option<&Path>) -> Self {
// The user_commands module is the permanent lower-level file scanning
// and parsing boundary; this registry owns metadata, shadowing, and
@@ -109,28 +114,52 @@ impl UserCommandRegistry {
Self::load_with_sources(
&user_commands::commands_dirs(workspace),
&user_commands::workflow_dirs(workspace),
&[],
&[],
)
}
pub(crate) fn load_with_sources(md_dirs: &[PathBuf], workflow_dirs: &[PathBuf]) -> Self {
pub(crate) fn load_with_sources(
md_dirs: &[PathBuf],
workflow_dirs: &[PathBuf],
plugin_sources: &[crate::plugins::runtime::PluginComponentSource],
plugin_errors: &[String],
) -> Self {
let mut registry = Self::load_from_paths(md_dirs);
// Saved workflows become slash commands after explicit .md commands,
// so a hand-written command with the same name always wins without a
// noisy duplicate-definition warning.
let mut workflow_entries: Vec<(String, String, PathBuf)> = Vec::new();
let mut workflow_entries: Vec<CommandSourceEntry> = Vec::new();
for dir in workflow_dirs {
for (name, content, path) in user_commands::load_workflow_commands_from_dir(dir) {
if registry.get(&name).is_none()
&& !workflow_entries
.iter()
.any(|(existing, _, _)| *existing == name)
.any(|existing| existing.name == name)
{
workflow_entries.push((name, content, path));
workflow_entries.push(CommandSourceEntry::plain(name, content, path));
}
}
}
registry.load_from_entries(workflow_entries);
for error in plugin_errors {
registry.record_load_error(PathBuf::from("plugin-runtime"), error.clone());
}
let mut plugin_entries = Vec::new();
for source in plugin_sources {
for (name, content, path) in
user_commands::load_command_entries_from_component(&source.path)
{
plugin_entries.push(CommandSourceEntry {
name,
content,
path,
plugin_authority: Some(source.authority.clone()),
});
}
}
registry.load_from_entries(plugin_entries);
registry
}
@@ -145,7 +174,11 @@ impl UserCommandRegistry {
for (name, content) in directory_commands {
let canonical = normalize_name(&name);
if seen.insert(canonical.clone()) {
loaded.push((name, content, dir.join(format!("{canonical}.md"))));
loaded.push(CommandSourceEntry::plain(
name,
content,
dir.join(format!("{canonical}.md")),
));
} else {
registry.record_load_error(
dir.join(format!("{canonical}.md")),
@@ -167,18 +200,21 @@ impl UserCommandRegistry {
.into_iter()
.map(|(name, content)| {
let path = PathBuf::from(format!("{}.md", normalize_name(&name)));
(name, content, path)
CommandSourceEntry::plain(name, content, path)
})
.collect();
registry.load_from_entries(loaded);
registry
}
fn load_from_entries(&mut self, commands: Vec<(String, String, PathBuf)>) {
fn load_from_entries(&mut self, commands: Vec<CommandSourceEntry>) {
let parsed_commands = commands
.into_iter()
.map(|(name, content, path)| {
let (metadata, errors) = parse_metadata(name, &content, &path);
.map(|entry| {
let (mut metadata, errors) =
parse_metadata(entry.name, &entry.content, &entry.path);
metadata.plugin_authority = entry.plugin_authority;
let path = entry.path;
(metadata, errors, path)
})
.collect::<Vec<_>>();
@@ -252,6 +288,11 @@ impl UserCommandRegistry {
}
pub fn get(&self, name: &str) -> Option<&UserCommandMetadata> {
self.get_unchecked(name)
.filter(|command| plugin_command_is_current(command))
}
fn get_unchecked(&self, name: &str) -> Option<&UserCommandMetadata> {
let key = normalize_name(name);
self.commands.get(&key).or_else(|| {
self.aliases
@@ -266,17 +307,25 @@ impl UserCommandRegistry {
self.aliases
.get(&key)
.and_then(|canonical| self.commands.get(canonical))
.filter(|command| plugin_command_is_current(command))
}
#[cfg(test)]
pub fn names(&self) -> Vec<String> {
let mut names: Vec<String> = self.commands.keys().cloned().collect();
let mut names: Vec<String> = self
.commands
.values()
.filter(|command| plugin_command_is_current(command))
.map(|command| command.name.clone())
.collect();
names.sort();
names
}
pub fn iter(&self) -> impl Iterator<Item = &UserCommandMetadata> {
self.commands.values()
self.commands
.values()
.filter(|command| plugin_command_is_current(command))
}
#[cfg(test)]
@@ -318,6 +367,7 @@ fn parse_metadata(
pausable: false,
aliases: Vec::new(),
hidden: false,
plugin_authority: None,
};
let mut configured_name = None;
@@ -362,6 +412,31 @@ fn parse_metadata(
(command, errors)
}
#[derive(Debug, Clone)]
struct CommandSourceEntry {
name: String,
content: String,
path: PathBuf,
plugin_authority: Option<crate::plugins::types::PluginAuthority>,
}
impl CommandSourceEntry {
fn plain(name: String, content: String, path: PathBuf) -> Self {
Self {
name,
content,
path,
plugin_authority: None,
}
}
}
fn plugin_command_is_current(command: &UserCommandMetadata) -> bool {
command.plugin_authority.as_ref().is_none_or(|authority| {
crate::plugins::registry::verify_plugin_state_authority(authority).is_ok()
})
}
fn validate_command_content(canonical: &str, content: &str, path: &Path) -> Vec<LoadError> {
let mut errors = Vec::new();
if canonical.is_empty() {
@@ -450,7 +525,15 @@ fn normalize_workspace(workspace: Option<&Path>) -> Option<PathBuf> {
workspace.map(Path::to_path_buf)
}
#[cfg(test)]
fn command_dirs_snapshot(workspace: Option<&Path>) -> Vec<CommandDirSnapshot> {
command_dirs_snapshot_with_plugins(workspace, &[])
}
fn command_dirs_snapshot_with_plugins(
workspace: Option<&Path>,
plugin_sources: &[crate::plugins::runtime::PluginComponentSource],
) -> Vec<CommandDirSnapshot> {
user_commands::commands_dirs(workspace)
.into_iter()
.map(|path| snapshot_dir(path, |name| name.ends_with(".md")))
@@ -463,6 +546,11 @@ fn command_dirs_snapshot(workspace: Option<&Path>) -> Vec<CommandDirSnapshot> {
})
}),
)
.chain(
plugin_sources
.iter()
.map(|source| snapshot_dir(source.path.clone(), |name| name.ends_with(".md"))),
)
.collect()
}
@@ -548,8 +636,16 @@ pub fn with_registry_for_workspace<R>(
f: impl FnOnce(&UserCommandRegistry) -> R,
) -> R {
let workspace = normalize_workspace(workspace);
let snapshot = command_dirs_snapshot(workspace.as_deref());
let lock = registry_lock();
let (plugin_sources, plugin_errors) = {
let guard = lock.read().expect("user command registry lock poisoned");
if guard.plugin_workspace == workspace {
(guard.plugin_sources.clone(), guard.plugin_errors.clone())
} else {
(Vec::new(), Vec::new())
}
};
let snapshot = command_dirs_snapshot_with_plugins(workspace.as_deref(), &plugin_sources);
{
let guard = lock.read().expect("user command registry lock poisoned");
if !registry_needs_reload(&guard, &workspace, &snapshot) {
@@ -557,7 +653,12 @@ pub fn with_registry_for_workspace<R>(
}
}
let replacement = UserCommandRegistry::load(workspace.as_deref());
let replacement = UserCommandRegistry::load_with_sources(
&user_commands::commands_dirs(workspace.as_deref()),
&user_commands::workflow_dirs(workspace.as_deref()),
&plugin_sources,
&plugin_errors,
);
let mut guard = lock.write().expect("user command registry lock poisoned");
if registry_needs_reload(&guard, &workspace, &snapshot) {
guard.initialized = true;
@@ -568,6 +669,28 @@ pub fn with_registry_for_workspace<R>(
f(&guard.registry)
}
/// Install the current workspace's reviewed plugin command snapshot into the
/// existing process-global command registry. The next read rebuilds the
/// catalogue atomically; dispatch still revalidates authority immediately
/// before expanding the command body.
pub fn install_plugin_registry(
workspace: &Path,
plugins: &crate::plugins::PluginRegistry,
) -> Vec<String> {
let (sources, errors) = crate::plugins::runtime::active_component_sources(
plugins,
crate::plugins::activation::PluginActivationCapability::Commands,
);
let mut guard = registry_lock()
.write()
.expect("user command registry lock poisoned");
guard.initialized = false;
guard.plugin_workspace = Some(workspace.to_path_buf());
guard.plugin_sources = sources;
guard.plugin_errors = errors.clone();
errors
}
pub fn try_dispatch(app: &mut App, input: &str) -> Option<CommandResult> {
let parts: Vec<&str> = input.trim().splitn(2, ' ').collect();
let command = normalize_name(parts.first().copied().unwrap_or_default());
@@ -575,16 +698,31 @@ pub fn try_dispatch(app: &mut App, input: &str) -> Option<CommandResult> {
let (dispatch_error, metadata) =
with_registry_for_workspace(Some(&app.workspace), |registry| {
(
registry.dispatch_error(&command),
registry.get(&command).cloned(),
)
// Dispatch must see a just-revoked plugin command long enough to
// return a visible authority error. Discovery and palettes use
// `get`/`iter`, which hide it immediately.
let metadata = registry.get_unchecked(&command).cloned();
let dispatch_error = metadata
.as_ref()
.and_then(|_| registry.dispatch_error(&command));
(dispatch_error, metadata)
});
if let Some(error) = dispatch_error {
return Some(CommandResult::error(error));
}
let metadata = metadata?;
if let Some(authority) = metadata.plugin_authority.as_ref()
&& let Err(reason) = crate::plugins::registry::verify_plugin_component_authority(
authority,
crate::plugins::activation::PluginActivationCapability::Commands,
)
{
return Some(CommandResult::error(format!(
"Plugin command '/{}' was denied: {reason}. Reload, review, trust, and enable the bundle before retrying.",
metadata.name
)));
}
app.goal.objective = None;
app.goal.started_at = None;
@@ -652,8 +790,12 @@ mod tests {
)
.expect("write workflow");
let registry =
UserCommandRegistry::load_with_sources(&[], std::slice::from_ref(&workflow_dir));
let registry = UserCommandRegistry::load_with_sources(
&[],
std::slice::from_ref(&workflow_dir),
&[],
&[],
);
let command = registry.get("pr-review").expect("workflow command");
assert_eq!(
command.description.as_deref(),
@@ -690,7 +832,7 @@ mod tests {
std::fs::write(workflow_dir.join("triage.workflow.js"), "phase('x');\n")
.expect("write workflow");
let registry = UserCommandRegistry::load_with_sources(&[md_dir], &[workflow_dir]);
let registry = UserCommandRegistry::load_with_sources(&[md_dir], &[workflow_dir], &[], &[]);
let command = registry.get("triage").expect("command");
assert_eq!(command.body, "hand-written triage $ARGUMENTS");
assert!(
@@ -841,6 +983,39 @@ mod tests {
}
}
#[test]
fn plugin_command_dispatch_survives_restart_and_revocation_is_visible() {
let _lock = crate::test_support::lock_test_env();
let fixture = crate::plugins::test_fixture::DeclarativePluginFixture::new();
let mut app = test_app(fixture.workspace.clone());
install_plugin_registry(&fixture.workspace, &fixture.registry);
let result = try_dispatch(&mut app, "/plugin-hello ocean")
.expect("active plugin command dispatches");
assert!(!result.is_error);
assert_eq!(sent_message(result), "hello from plugin ocean");
let inactive = fixture.revoke_from_fresh_registry();
let denied = try_dispatch(&mut app, "/plugin-hello ocean")
.expect("stale command returns a visible denial");
assert!(denied.is_error);
assert!(
denied
.message
.as_deref()
.is_some_and(|message| message.contains("was denied")),
"{denied:?}"
);
install_plugin_registry(&fixture.workspace, &inactive);
assert!(
with_registry_for_workspace(Some(&fixture.workspace), |registry| {
registry.get("plugin-hello").is_none()
}),
"a reload removes revoked plugin commands"
);
}
#[test]
fn dispatch_prefers_user_command_over_builtin_with_same_name() {
let tmp = TempDir::new().unwrap();
+1
View File
@@ -1698,6 +1698,7 @@ fn exact_member_profile(
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("<exact fleet>")),
origin: ProfileOrigin::Config,
plugin_authority: None,
}
}
+1
View File
@@ -837,6 +837,7 @@ mod tests {
},
source: std::path::PathBuf::from(format!("{id}.toml")),
origin: crate::fleet::roster::ProfileOrigin::Workspace,
plugin_authority: None,
}
}
+30
View File
@@ -74,6 +74,9 @@ pub struct AgentProfile {
/// File-based loading in this module always yields `Workspace`; the
/// roster stamps `BuiltIn` / `Config` for the other layers.
pub origin: ProfileOrigin,
/// Runtime authority for a profile loaded from an immutable plugin
/// snapshot. Rechecked at Agent spawn so another process can revoke it.
pub plugin_authority: Option<crate::plugins::types::PluginAuthority>,
}
/// The minimum profile information needed to prevent a save from clobbering
@@ -228,6 +231,32 @@ pub fn load_agent_profiles_from_dir_tolerant(
Ok((profiles, issues))
}
pub(crate) fn load_plugin_agent_profiles_from_component(
component: &Path,
authority: &crate::plugins::types::PluginAuthority,
) -> Result<(Vec<AgentProfile>, Vec<String>)> {
let (mut profiles, issues) = if component.is_dir() {
load_agent_profiles_from_dir_tolerant(component, ProfileOrigin::Plugin)?
} else if component.is_file() {
match load_agent_profile_file(component) {
Ok(mut profile) => {
profile.origin = ProfileOrigin::Plugin;
(vec![profile], Vec::new())
}
Err(error) => (Vec::new(), vec![format!("{error:#}")]),
}
} else {
return Err(anyhow!(
"plugin Agent component is unavailable: {}",
component.display()
));
};
for profile in &mut profiles {
profile.plugin_authority = Some(authority.clone());
}
Ok((profiles, issues))
}
/// Read only the identity-bearing fields from workspace profiles for the
/// authoring collision gate. Unknown legacy fields are harmless here because
/// no profile behavior is loaded or executed from this representation.
@@ -374,6 +403,7 @@ fn agent_profile_from_toml(path: &Path, parsed: AgentProfileToml) -> Result<Agen
profile,
source: path.to_path_buf(),
origin: ProfileOrigin::Workspace,
plugin_authority: None,
})
}
+79 -8
View File
@@ -11,7 +11,7 @@
//! - personal `$CODEWHALE_HOME/agents/*.toml` profile files,
//! - workspace `.codewhale/agents/*.toml` profile files.
//!
//! Precedence is Workspace > Personal > Config > BuiltIn, merged by id. Loading never
//! Precedence is Workspace > Personal > Config > Plugin > BuiltIn, merged by id. Loading never
//! fails the session: an unreadable workspace profile dir degrades to the
//! built-in + config layers with a log line.
//!
@@ -38,16 +38,17 @@ use codewhale_config::{
};
use super::profile::{
AgentProfile, load_agent_profiles_from_dir_tolerant, load_workspace_agent_profiles_tolerant,
personal_agent_profile_dir,
AgentProfile, load_agent_profiles_from_dir_tolerant, load_plugin_agent_profiles_from_component,
load_workspace_agent_profiles_tolerant, personal_agent_profile_dir,
};
/// Which layer a roster member came from. Higher layers override lower ones
/// by id (Workspace > Personal > Config > BuiltIn).
/// by id (Workspace > Personal > Config > Plugin > BuiltIn).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileOrigin {
BuiltIn,
Plugin,
Config,
Personal,
Workspace,
@@ -57,6 +58,7 @@ impl std::fmt::Display for ProfileOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::BuiltIn => "built-in",
Self::Plugin => "plugin",
Self::Config => "config",
Self::Personal => "personal",
Self::Workspace => "project",
@@ -109,9 +111,10 @@ pub struct MultiLayerProfile {
fn origin_precedence(origin: ProfileOrigin) -> u8 {
match origin {
ProfileOrigin::Workspace => 3,
ProfileOrigin::Personal => 2,
ProfileOrigin::Config => 1,
ProfileOrigin::Workspace => 4,
ProfileOrigin::Personal => 3,
ProfileOrigin::Config => 2,
ProfileOrigin::Plugin => 1,
ProfileOrigin::BuiltIn => 0,
}
}
@@ -169,11 +172,29 @@ impl FleetRoster {
#[must_use]
pub fn load(fleet_config: &FleetConfigToml, workspace: &Path) -> Self {
let personal_dir = personal_agent_profile_dir().ok();
Self::load_with_personal_dir(
Self::load_with_personal_dir_and_plugins(
fleet_config,
workspace,
personal_dir.as_deref(),
project_agent_profiles_enabled(),
None,
)
}
/// Load the ordinary roster plus trusted, enabled plugin Agent profiles.
#[must_use]
pub fn load_with_plugins(
fleet_config: &FleetConfigToml,
workspace: &Path,
plugins: &crate::plugins::PluginRegistry,
) -> Self {
let personal_dir = personal_agent_profile_dir().ok();
Self::load_with_personal_dir_and_plugins(
fleet_config,
workspace,
personal_dir.as_deref(),
project_agent_profiles_enabled(),
Some(plugins),
)
}
@@ -182,11 +203,59 @@ impl FleetRoster {
workspace: &Path,
personal_dir: Option<&Path>,
include_workspace_profiles: bool,
) -> Self {
Self::load_with_personal_dir_and_plugins(
fleet_config,
workspace,
personal_dir,
include_workspace_profiles,
None,
)
}
fn load_with_personal_dir_and_plugins(
fleet_config: &FleetConfigToml,
workspace: &Path,
personal_dir: Option<&Path>,
include_workspace_profiles: bool,
plugins: Option<&crate::plugins::PluginRegistry>,
) -> Self {
let mut built_ins = Self::built_in_members();
let mut extras: Vec<AgentProfile> = Vec::new();
let mut shadowed: Vec<ShadowedProfile> = Vec::new();
if let Some(plugins) = plugins {
let (sources, errors) = crate::plugins::runtime::active_component_sources(
plugins,
crate::plugins::activation::PluginActivationCapability::Agents,
);
for error in errors {
tracing::warn!("fleet roster: {error}");
}
for source in sources {
match load_plugin_agent_profiles_from_component(&source.path, &source.authority) {
Ok((profiles, issues)) => {
for issue in issues {
tracing::warn!(
plugin = %source.plugin_name,
"fleet roster: skipping invalid plugin Agent profile: {issue}"
);
}
for member in profiles {
record_shadow(
merge_member(&mut built_ins, &mut extras, member),
&mut shadowed,
);
}
}
Err(error) => tracing::warn!(
plugin = %source.plugin_name,
"fleet roster: failed to load plugin Agent profiles: {error:#}"
),
}
}
}
for (id, profile) in &fleet_config.profiles {
let mut profile = profile.clone();
profile.role.name = super::profile::canonical_public_role_name(&profile.role.name);
@@ -198,6 +267,7 @@ impl FleetRoster {
profile,
source: PathBuf::from("config.toml"),
origin: ProfileOrigin::Config,
plugin_authority: None,
};
record_shadow(
merge_member(&mut built_ins, &mut extras, member),
@@ -416,6 +486,7 @@ impl FleetRoster {
},
source: PathBuf::from("built-in"),
origin: ProfileOrigin::BuiltIn,
plugin_authority: None,
})
.collect()
}
+2
View File
@@ -1261,6 +1261,7 @@ pub(crate) fn network_posture_warning_for_task(
fn profile_origin_label(origin: crate::fleet::roster::ProfileOrigin) -> &'static str {
match origin {
crate::fleet::roster::ProfileOrigin::BuiltIn => "built_in",
crate::fleet::roster::ProfileOrigin::Plugin => "plugin",
crate::fleet::roster::ProfileOrigin::Config => "config",
crate::fleet::roster::ProfileOrigin::Personal => "personal",
crate::fleet::roster::ProfileOrigin::Workspace => "workspace",
@@ -1571,6 +1572,7 @@ mod tests {
},
source: std::path::PathBuf::from(format!("{id}.toml")),
origin: crate::fleet::roster::ProfileOrigin::Workspace,
plugin_authority: None,
}
}
+104 -1
View File
@@ -247,6 +247,12 @@ pub struct Hook {
/// Optional name for logging/debugging
#[serde(default)]
pub name: Option<String>,
/// Content- and generation-bound authority for a plugin-contributed hook.
/// Never accepted from TOML; only the reviewed staged adapter may attach
/// it after parsing immutable bytes.
#[serde(skip)]
pub plugin_authority: Option<crate::plugins::types::PluginAuthority>,
}
fn default_timeout() -> u64 {
@@ -269,6 +275,7 @@ impl Hook {
background: false,
continue_on_error: true,
name: None,
plugin_authority: None,
}
}
@@ -382,7 +389,49 @@ impl HooksConfig {
/// Trusted project hooks are appended after global hooks. A malformed
/// trusted project file logs a warning and falls back to global-only.
pub fn load_with_project(global: HooksConfig, workspace: &Path) -> HooksConfig {
Self::load_with_project_and_plugins(global, workspace, None)
}
/// Merge global, reviewed plugin, then trusted project hooks.
///
/// Project hooks intentionally remain last because that is the existing
/// tie-breaking contract for mutable `message_submit` transformations.
/// Plugin files are read only from Codewhale's immutable staged snapshot;
/// their attached authority is rechecked at every process-spawn boundary.
pub fn load_with_project_and_plugins(
global: HooksConfig,
workspace: &Path,
plugins: Option<&crate::plugins::PluginRegistry>,
) -> HooksConfig {
let mut merged = global;
if let Some(plugins) = plugins {
let (sources, adapter_errors) = crate::plugins::runtime::active_component_sources(
plugins,
crate::plugins::activation::PluginActivationCapability::Hooks,
);
for error in adapter_errors {
merged.problems.push(HookConfigProblem {
name: None,
event: None,
detail: error,
rejected: true,
});
}
for source in sources {
match load_plugin_hook_component(&source.path, &source.authority) {
Ok(mut plugin) => {
merged.problems.append(&mut plugin.problems);
merged.hooks.append(&mut plugin.hooks);
}
Err(error) => merged.problems.push(HookConfigProblem {
name: Some(source.plugin_name),
event: None,
detail: error,
rejected: true,
}),
}
}
}
let project_path = workspace.join(".codewhale").join("hooks.toml");
if project_path.exists() && workspace_allows_project_hooks(workspace) {
match read_project_hooks_file(&project_path) {
@@ -519,6 +568,7 @@ impl HooksConfig {
/// Rejection is by position, so a broken entry never takes an innocent one
/// with it just because the two share a name (or share the absence of one).
fn apply_validation(&mut self) {
let inherited_problems = std::mem::take(&mut self.problems);
let setting_problems = self.validate_settings();
// Reject the value, not just report it: the executor reads
// `default_timeout_secs` directly, so leaving `Some(0)` in place would
@@ -546,8 +596,9 @@ impl HooksConfig {
keep
});
}
self.problems = setting_problems
self.problems = inherited_problems
.into_iter()
.chain(setting_problems)
.chain(problems.into_iter().map(|(_, problem)| problem))
.collect();
}
@@ -592,6 +643,58 @@ impl HooksConfig {
}
}
fn load_plugin_hook_component(
component: &Path,
authority: &crate::plugins::types::PluginAuthority,
) -> Result<HooksConfig, String> {
let mut paths = if component.is_file() {
vec![component.to_path_buf()]
} else if component.is_dir() {
let mut paths = std::fs::read_dir(component)
.map_err(|error| format!("failed to read plugin Hooks component: {error}"))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("toml"))
.collect::<Vec<_>>();
paths.sort();
paths
} else {
return Err("plugin Hooks component is unavailable".to_string());
};
if paths.is_empty() {
return Err("plugin Hooks component contains no TOML configuration".to_string());
}
let mut merged = HooksConfig::default();
for path in paths.drain(..) {
let contents = read_project_hooks_file(&path)
.map_err(|error| format!("failed to read plugin Hooks file: {error}"))?;
let mut parsed: HooksConfig = toml::from_str(&contents)
.map_err(|error| format!("failed to parse plugin Hooks file: {error}"))?;
if parsed.working_dir.is_some() {
return Err(
"plugin Hooks may not set working_dir; hooks run in the active workspace"
.to_string(),
);
}
parsed.apply_validation();
if !parsed.enabled {
continue;
}
if let Some(timeout) = parsed.default_timeout_secs.filter(|value| *value > 0) {
for hook in &mut parsed.hooks {
hook.timeout_secs = timeout;
}
}
for hook in &mut parsed.hooks {
hook.plugin_authority = Some(authority.clone());
}
merged.problems.append(&mut parsed.problems);
merged.hooks.append(&mut parsed.hooks);
}
Ok(merged)
}
fn workspace_allows_project_hooks(workspace: &Path) -> bool {
crate::config::is_workspace_trusted(workspace)
}
+147
View File
@@ -1395,6 +1395,7 @@ struct BackgroundHookJob {
stdin_bytes: Option<Vec<u8>>,
label: String,
timeout: Duration,
plugin_authority: Option<crate::plugins::types::PluginAuthority>,
}
impl BackgroundHookJob {
@@ -1406,7 +1407,22 @@ impl BackgroundHookJob {
stdin_bytes,
label,
timeout,
plugin_authority,
} = self;
if let Some(authority) = plugin_authority.as_ref()
&& let Err(error) = crate::plugins::registry::verify_plugin_component_authority(
authority,
crate::plugins::activation::PluginActivationCapability::Hooks,
)
{
tracing::warn!(
target: "hooks",
hook = %label,
error = %error,
"denied queued plugin hook after authority changed"
);
return;
}
let timeout_secs = timeout.as_secs();
let mut command = HookExecutor::build_shell_command(&command_text);
command
@@ -2121,6 +2137,24 @@ impl HookExecutor {
stdin_json: Option<&serde_json::Value>,
) -> HookResult {
let started = Instant::now();
if let Some(authority) = hook.plugin_authority.as_ref()
&& let Err(reason) = crate::plugins::registry::verify_plugin_component_authority(
authority,
crate::plugins::activation::PluginActivationCapability::Hooks,
)
{
return HookResult {
name: hook.name.clone(),
background: false,
strict: !hook.continue_on_error,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(format!("Plugin hook authority was denied: {reason}")),
};
}
let working_dir = self
.config
.working_dir
@@ -2360,6 +2394,24 @@ impl HookExecutor {
stdin_json: Option<&serde_json::Value>,
) -> HookResult {
let started = Instant::now();
if let Some(authority) = hook.plugin_authority.as_ref()
&& let Err(reason) = crate::plugins::registry::verify_plugin_component_authority(
authority,
crate::plugins::activation::PluginActivationCapability::Hooks,
)
{
return HookResult {
name: hook.name.clone(),
background: true,
strict: false,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(format!("Plugin hook authority was denied: {reason}")),
};
}
let working_dir = self
.config
.working_dir
@@ -2389,6 +2441,7 @@ impl HookExecutor {
stdin_bytes,
label: sanitize_hook_label(hook.name.as_deref()),
timeout: Duration::from_secs(self.effective_timeout_secs(hook)),
plugin_authority: hook.plugin_authority.clone(),
});
// The result describes the bounded submission, not the run: no caller
@@ -2993,6 +3046,100 @@ mod tests {
assert_eq!(hooks.len(), 1);
}
#[cfg(unix)]
#[test]
fn plugin_hook_runs_after_restart_and_process_spawn_rechecks_revocation() {
let _lock = lock_test_env();
let fixture = crate::plugins::test_fixture::DeclarativePluginFixture::new();
let config = HooksConfig::load_with_project_and_plugins(
HooksConfig {
enabled: true,
..HooksConfig::default()
},
&fixture.workspace,
Some(&fixture.registry),
);
assert!(config.problems.is_empty(), "{:?}", config.problems);
assert_eq!(config.hooks.len(), 1);
assert!(config.hooks[0].plugin_authority.is_some());
let executor = HookExecutor::new(config, fixture.workspace.clone());
let context = HookContext::new().with_workspace(fixture.workspace.clone());
let ran = executor.execute(HookEvent::SessionStart, &context);
assert_eq!(ran.len(), 1);
assert!(ran[0].success, "{:?}", ran[0].error);
assert_eq!(
std::fs::read_to_string(&fixture.marker).expect("plugin hook marker"),
"plugin-hook-ran"
);
std::fs::remove_file(&fixture.marker).expect("clear marker");
let inactive = fixture.revoke_from_fresh_registry();
let denied = executor.execute(HookEvent::SessionStart, &context);
assert_eq!(denied.len(), 1);
assert!(!denied[0].success);
assert!(
denied[0]
.error
.as_deref()
.is_some_and(|error| error.contains("authority was denied")),
"{:?}",
denied[0].error
);
assert!(
!fixture.marker.exists(),
"revoked hook must be denied before process spawn"
);
let reloaded = HooksConfig::load_with_project_and_plugins(
HooksConfig {
enabled: true,
..HooksConfig::default()
},
&fixture.workspace,
Some(&inactive),
);
assert!(
reloaded.hooks.is_empty(),
"reload removes the revoked plugin Hook"
);
}
#[cfg(unix)]
#[test]
fn queued_plugin_hook_rechecks_revocation_at_dequeue() {
let _lock = lock_test_env();
let fixture = crate::plugins::test_fixture::DeclarativePluginFixture::new();
let blocker_one = Hook::new(HookEvent::SessionStart, "sleep 1").background();
let blocker_two = Hook::new(HookEvent::SessionStart, "sleep 1").background();
let mut config = HooksConfig::load_with_project_and_plugins(
HooksConfig {
enabled: true,
hooks: vec![blocker_one, blocker_two],
..HooksConfig::default()
},
&fixture.workspace,
Some(&fixture.registry),
);
assert_eq!(config.hooks.len(), 3);
config.hooks[2].background = true;
let executor = HookExecutor::new(config, fixture.workspace.clone());
let submitted = executor.execute(
HookEvent::SessionStart,
&HookContext::new().with_workspace(fixture.workspace.clone()),
);
assert_eq!(submitted.len(), 3);
assert!(submitted.iter().all(|result| result.background));
fixture.revoke_from_fresh_registry();
std::thread::sleep(Duration::from_millis(1_500));
assert!(
!fixture.marker.exists(),
"queued hook must recheck authority after the preceding job finishes"
);
}
#[test]
fn executor_type_is_available_from_executor_module() {
let executor = crate::hooks::executor::HookExecutor::disabled();
+16 -12
View File
@@ -1975,9 +1975,9 @@ async fn run_async_main_dispatch(
// Plugins own one read-only discovery snapshot per process. Initialize it
// before the subcommand match so plain launch, resume, fork, exec, serve,
// and every other runtime surface feed Skills and MCP from the same trust
// decision (#3916, #4399). Discovery never enables, trusts, executes, or
// persists a bundle.
// and every other runtime surface use the same plugin trust decision
// (#3916, #4399). Discovery never enables, trusts, executes, or persists a
// bundle.
// Handle subcommands first
if let Some(command) = command {
@@ -3353,12 +3353,13 @@ fn plugins_readme_template() -> &'static str {
content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
authentication must name environment sources; never store secret values\n\
in `plugin.toml`.\n\n\
Codewhale activates only declarative Skills and MCP servers through their\n\
existing engines. Commands, agents, hooks, LSP, native extensions,\n\
filesystem grants, and lifecycle mutation stay inventoried and inactive;\n\
a mixed bundle can still activate its supported Skills and MCP.\n\
There is no marketplace, install, update, ambient compatibility scan, or\n\
automatic trust surface in this release.\n"
Codewhale activates declarative Skills, MCP servers, Commands, Agent\n\
profiles, and Hooks through their existing engines. LSP, native\n\
extensions, filesystem grants, and lifecycle mutation stay inventoried\n\
and inactive; a mixed bundle can still activate supported components.\n\
Marketplace catalogs, install, update, and uninstall all feed this same\n\
disabled-and-untrusted review path; none grants automatic trust. Codewhale\n\
does not scan other applications for ambient plugins.\n"
}
fn plugin_example_manifest_template() -> &'static str {
@@ -6085,19 +6086,21 @@ fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_
let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
let mut built_in_members = 0usize;
let mut plugin_members = 0usize;
let mut config_members = 0usize;
let mut personal_members = 0usize;
let mut workspace_members = 0usize;
for member in roster.members() {
match member.origin {
crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
crate::fleet::roster::ProfileOrigin::Plugin => plugin_members += 1,
crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
}
}
let roster_members = roster.members().len();
let custom_members = config_members + personal_members + workspace_members;
let custom_members = plugin_members + config_members + personal_members + workspace_members;
let roster_ready = roster_members > 0;
let runtime_ready =
subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
@@ -11515,7 +11518,7 @@ async fn run_exec_agent(
active_route_limits,
workspace: workspace.clone(),
subagent_state_root: None,
plugin_registry: Some(engine_plugin_registry),
plugin_registry: Some(std::sync::Arc::clone(&engine_plugin_registry)),
allow_shell: exec_allow_shell,
trust_mode,
notes_path: execution_config.notes_path(),
@@ -11568,9 +11571,10 @@ async fn run_exec_agent(
lsp_config,
runtime_services,
subagent_model_overrides: execution_config.subagent_model_overrides(),
fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load_with_plugins(
&execution_config.fleet_config(),
&workspace,
engine_plugin_registry.as_ref(),
)),
subagent_api_timeout: std::time::Duration::from_secs(
execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
+8 -4
View File
@@ -9,13 +9,17 @@
use sha2::Digest;
/// Capability-hash domain for the current activation-policy binding.
pub const CAPABILITY_HASH_DOMAIN_V3: &[u8] = b"codewhale-plugin-capabilities-v3\0";
/// Historical policy domain kept so persisted v2 receipts are intentionally
/// invalidated when the declarative Commands, Agents, and Hooks adapters ship.
pub const CAPABILITY_HASH_DOMAIN_V2: &[u8] = b"codewhale-plugin-capabilities-v2\0";
/// Historical domain used before the activation policy was bound into the
/// receipt. Kept so discovery can prove a v1 receipt no longer matches.
pub const CAPABILITY_HASH_DOMAIN_V1: &[u8] = b"codewhale-plugin-capabilities-v1\0";
pub const ACTIVATION_POLICY_VERSION: u32 = 2;
pub const ACTIVATION_POLICY_VERSION: u32 = 3;
/// A runtime adapter or inventoried capability that a bundle may declare.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -82,11 +86,11 @@ impl PluginActivationPolicy {
PluginActivationCapability::Skills,
PluginActivationCapability::McpStdio,
PluginActivationCapability::McpRemote,
],
inactive: &[
PluginActivationCapability::Commands,
PluginActivationCapability::Agents,
PluginActivationCapability::Hooks,
],
inactive: &[
PluginActivationCapability::Lsp,
PluginActivationCapability::Native,
PluginActivationCapability::FilesystemRoots,
@@ -101,7 +105,7 @@ impl PluginActivationPolicy {
}
pub fn write_hash_material(self, hasher: &mut impl Digest) {
hasher.update(CAPABILITY_HASH_DOMAIN_V2);
hasher.update(CAPABILITY_HASH_DOMAIN_V3);
hasher.update(b"policy-version\0");
hasher.update(self.version.to_string().as_bytes());
hasher.update(b"\0");
+67 -18
View File
@@ -7,6 +7,8 @@ use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(test)]
use super::activation::CAPABILITY_HASH_DOMAIN_V2;
use super::activation::{
CAPABILITY_HASH_DOMAIN_V1, PluginActivationCapability, PluginActivationPolicy,
};
@@ -128,7 +130,7 @@ impl PluginPathSpec {
#[serde(deny_unknown_fields)]
pub struct PluginCapabilities {
/// Requested filesystem roots are inventoried and stay inactive. They do
/// not block Skills or MCP once those supported adapters can activate.
/// not block the bundle's supported declarative adapters from activating.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub filesystem_roots: Vec<String>,
/// Requested hosts are inventory-only. MCP URL hosts are added to the
@@ -136,7 +138,7 @@ pub struct PluginCapabilities {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub network_hosts: Vec<String>,
/// Lifecycle mutation is inventoried but unsupported. It does not block
/// Skills or MCP once those supported adapters can activate.
/// the bundle's supported declarative adapters from activating.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lifecycle_mutation: bool,
}
@@ -204,7 +206,7 @@ pub struct PluginInventory {
pub enum PluginCompatibility {
/// Every declared surface has an adapter, or the bundle is empty.
Full,
/// Skills and/or MCP can activate; other declared surfaces stay inactive.
/// Supported adapters can activate; other declared surfaces stay inactive.
Partial,
/// The bundle only declares surfaces Codewhale cannot activate yet.
Unsupported,
@@ -316,7 +318,7 @@ impl PluginInventory {
}
/// Empty or fully-supported bundles can activate; mixed bundles can
/// activate their Skills/MCP; all-unsupported bundles cannot.
/// activate their supported adapters; all-unsupported bundles cannot.
#[must_use]
pub fn can_activate_supported_components(&self) -> bool {
!matches!(self.compatibility(), PluginCompatibility::Unsupported)
@@ -1679,8 +1681,8 @@ fn hash_inventory_with_policy(
hex_digest(hasher.finalize())
}
/// Pre-policy capability digest. Discovery uses this only to prove that a v1
/// trust receipt cannot match a v2 capability hash.
/// Pre-policy capability digest. Tests use this to prove that a v1 trust
/// receipt cannot match the current capability hash.
pub(crate) fn capability_hash_v1(inventory: &PluginInventory) -> String {
let mut hasher = Sha256::new();
hasher.update(CAPABILITY_HASH_DOMAIN_V1);
@@ -1693,6 +1695,46 @@ pub(crate) fn capability_hash_v1(inventory: &PluginInventory) -> String {
hex_digest(hasher.finalize())
}
/// Historical v2 capability digest. Kept only to prove that receipts from the
/// Skills/MCP-only activation policy fail closed when v3 enables additional
/// declarative adapters.
#[cfg(test)]
pub(crate) fn capability_hash_v2(inventory: &PluginInventory) -> String {
let mut hasher = Sha256::new();
hasher.update(CAPABILITY_HASH_DOMAIN_V2);
hasher.update(b"policy-version\0");
hasher.update(b"2\0");
for capability in [
PluginActivationCapability::Skills,
PluginActivationCapability::McpStdio,
PluginActivationCapability::McpRemote,
] {
hasher.update(b"supported\0");
hasher.update(capability.as_str().as_bytes());
hasher.update(b"\0");
}
for capability in [
PluginActivationCapability::Commands,
PluginActivationCapability::Agents,
PluginActivationCapability::Hooks,
PluginActivationCapability::Lsp,
PluginActivationCapability::Native,
PluginActivationCapability::FilesystemRoots,
PluginActivationCapability::LifecycleMutation,
] {
hasher.update(b"inactive\0");
hasher.update(capability.as_str().as_bytes());
hasher.update(b"\0");
}
for (key, value) in hash_inventory_counts(inventory) {
hasher.update(key.as_bytes());
hasher.update(b"\0");
hasher.update(value.as_bytes());
hasher.update(b"\0");
}
hex_digest(hasher.finalize())
}
#[cfg(test)]
pub(crate) fn capability_hash_with_policy(
inventory: &PluginInventory,
@@ -2029,7 +2071,7 @@ mod tests {
);
let validated = PluginManifest::validate_from_path(&path).unwrap();
assert!(validated.inventory.has_unsupported_capabilities());
assert!(validated.inventory.unsupported_labels().contains(&"hooks"));
assert!(validated.inventory.supported_labels().contains(&"hooks"));
assert!(
validated
.inventory
@@ -2039,7 +2081,7 @@ mod tests {
assert_eq!(
validated.inventory.compatibility(),
PluginCompatibility::Partial,
"write_manifest always includes Skills, so hooks stay partial rather than unsupported"
"Skills and Hooks activate while explicit filesystem/lifecycle capabilities stay inactive"
);
assert!(validated.inventory.can_activate_supported_components());
}
@@ -2048,12 +2090,19 @@ mod tests {
fn mixed_supported_and_unsupported_components_are_partial() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("commands")).unwrap();
let path = write_manifest(tmp.path(), "\n[commands]\npath = \"commands\"\n");
fs::create_dir_all(tmp.path().join("lsp")).unwrap();
let path = write_manifest(
tmp.path(),
"\n[commands]\npath = \"commands\"\n[lsp]\npath = \"lsp\"\n",
);
let validated = PluginManifest::validate_from_path(&path).unwrap();
assert!(validated.inventory.has_supported_components());
assert!(validated.inventory.has_unsupported_capabilities());
assert_eq!(validated.inventory.supported_labels(), vec!["skills"]);
assert_eq!(validated.inventory.unsupported_labels(), vec!["commands"]);
assert_eq!(
validated.inventory.supported_labels(),
vec!["skills", "commands"]
);
assert_eq!(validated.inventory.unsupported_labels(), vec!["lsp"]);
assert_eq!(
validated.inventory.compatibility(),
PluginCompatibility::Partial
@@ -2069,16 +2118,16 @@ mod tests {
};
let current_policy = PluginActivationPolicy::current();
let current = capability_hash_with_policy(&inventory, current_policy);
let commands_supported = PluginActivationPolicy {
let hooks_inactive = PluginActivationPolicy {
version: current_policy.version,
supported: &[
PluginActivationCapability::Skills,
PluginActivationCapability::McpStdio,
PluginActivationCapability::McpRemote,
PluginActivationCapability::Commands,
PluginActivationCapability::Agents,
],
inactive: &[
PluginActivationCapability::Agents,
PluginActivationCapability::Hooks,
PluginActivationCapability::Lsp,
PluginActivationCapability::Native,
@@ -2088,8 +2137,8 @@ mod tests {
};
assert_ne!(
current,
capability_hash_with_policy(&inventory, commands_supported),
"enabling a previously inactive adapter must move the capability hash"
capability_hash_with_policy(&inventory, hooks_inactive),
"changing the executable adapter set must move the capability hash"
);
let bumped = PluginActivationPolicy {
version: current_policy.version + 1,
@@ -2111,16 +2160,16 @@ mod tests {
#[test]
fn all_unsupported_inventory_cannot_activate() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("commands")).unwrap();
fs::create_dir_all(tmp.path().join("lsp")).unwrap();
let path = tmp.path().join("plugin.toml");
fs::write(
&path,
"schema_version = 1\n[plugin]\nname = \"commands-only\"\nversion = \"1.0.0\"\n[commands]\npath = \"commands\"\n",
"schema_version = 1\n[plugin]\nname = \"lsp-only\"\nversion = \"1.0.0\"\n[lsp]\npath = \"lsp\"\n",
)
.unwrap();
let validated = PluginManifest::validate_from_path(&path).unwrap();
assert!(!validated.inventory.has_supported_components());
assert_eq!(validated.inventory.unsupported_labels(), vec!["commands"]);
assert_eq!(validated.inventory.unsupported_labels(), vec!["lsp"]);
assert_eq!(
validated.inventory.compatibility(),
PluginCompatibility::Unsupported
+3
View File
@@ -11,8 +11,11 @@ pub mod marketplace;
pub mod mutation;
mod path_identity;
pub mod registry;
pub mod runtime;
pub mod types;
#[cfg(test)]
pub(crate) mod test_fixture;
#[cfg(test)]
mod tests;
+4 -4
View File
@@ -402,7 +402,7 @@ impl PluginRegistry {
if !plugin.inventory.can_activate_supported_components() {
let unsupported = plugin.inventory.unsupported_labels();
return Err(format!(
"Plugin bundle `{}` has no supported Skills or MCP components to activate; inactive: {}",
"Plugin bundle `{}` has no supported declarative components to activate; inactive: {}",
plugin.name(),
unsupported.join(", ")
));
@@ -1918,9 +1918,9 @@ fn preserve_owner_only_file_mode(path: &Path, _source: &fs::Metadata) -> Result<
/// Recheck a persisted plugin receipt, the mutable reviewed source, and the
/// Codewhale-owned immutable runtime copy, then require the named adapter to
/// be in this build's activation policy. Skills and MCP must call this with
/// their specific capability rather than treating bundle-wide activity as
/// authority to run every inventoried surface.
/// be in this build's activation policy. Every adapter must call this with its
/// specific capability rather than treating bundle-wide activity as authority
/// to run every inventoried surface.
pub fn verify_plugin_component_authority(
authority: &PluginAuthority,
capability: PluginActivationCapability,
+127
View File
@@ -0,0 +1,127 @@
//! Runtime adapters for reviewed, content-addressed plugin components.
//!
//! This module is the only place that translates mutable discovery paths into
//! immutable staged component paths. Consumers still revalidate the attached
//! [`PluginAuthority`] at their execution boundary so disable, revoke, and
//! uninstall transitions in another process fail closed immediately.
use std::path::{Path, PathBuf};
use super::PluginRegistry;
use super::activation::PluginActivationCapability;
use super::registry::verify_plugin_component_authority;
use super::types::{LoadedPlugin, PluginAuthority, PluginScope};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginComponentSource {
pub plugin_name: String,
pub path: PathBuf,
pub authority: PluginAuthority,
}
fn component_paths(plugin: &LoadedPlugin, capability: PluginActivationCapability) -> &[PathBuf] {
match capability {
PluginActivationCapability::Commands => &plugin.components.commands,
PluginActivationCapability::Agents => &plugin.components.agents,
PluginActivationCapability::Hooks => &plugin.components.hooks,
PluginActivationCapability::Skills
| PluginActivationCapability::McpStdio
| PluginActivationCapability::McpRemote
| PluginActivationCapability::Lsp
| PluginActivationCapability::Native
| PluginActivationCapability::FilesystemRoots
| PluginActivationCapability::LifecycleMutation => &[],
}
}
fn scope_precedence(scope: PluginScope) -> u8 {
match scope {
PluginScope::Workspace => 0,
PluginScope::User => 1,
PluginScope::Builtin => 2,
}
}
/// Resolve one active component kind into immutable staged paths.
///
/// Workspace bundles win same-name collisions over user and built-in bundles;
/// consumers retain their existing non-plugin precedence above this list.
pub fn active_component_sources(
registry: &PluginRegistry,
capability: PluginActivationCapability,
) -> (Vec<PluginComponentSource>, Vec<String>) {
let mut plugins = registry
.active_plugins()
.into_iter()
.filter(|plugin| plugin.component_active(capability))
.collect::<Vec<_>>();
plugins.sort_by(|left, right| {
scope_precedence(left.scope)
.cmp(&scope_precedence(right.scope))
.then_with(|| left.name().cmp(right.name()))
.then_with(|| left.id.cmp(&right.id))
});
let mut sources = Vec::new();
let mut errors = Vec::new();
for plugin in plugins {
let Some(authority) = registry.authority_for(plugin.id.as_str()) else {
errors.push(format!(
"Plugin `{}` has no persisted runtime authority",
plugin.name()
));
continue;
};
if let Err(reason) = verify_plugin_component_authority(&authority, capability) {
errors.push(format!(
"Plugin `{}` {} adapter was denied: {reason}",
plugin.name(),
capability.as_str()
));
continue;
}
let Some(staged_root) = plugin.staged_root.as_deref() else {
errors.push(format!(
"Plugin `{}` has no immutable runtime snapshot",
plugin.name()
));
continue;
};
for source_path in component_paths(plugin, capability) {
match staged_component_path(&plugin.canonical_root, staged_root, source_path) {
Ok(path) => sources.push(PluginComponentSource {
plugin_name: plugin.name().to_string(),
path,
authority: authority.clone(),
}),
Err(reason) => errors.push(format!(
"Plugin `{}` {} component was denied: {reason}",
plugin.name(),
capability.as_str()
)),
}
}
}
(sources, errors)
}
fn staged_component_path(
canonical_root: &Path,
staged_root: &Path,
source_path: &Path,
) -> Result<PathBuf, String> {
let relative = source_path
.strip_prefix(canonical_root)
.map_err(|_| "reviewed component escaped the plugin root".to_string())?;
let staged_root = staged_root
.canonicalize()
.map_err(|_| "runtime snapshot is unavailable".to_string())?;
let candidate = staged_root.join(relative);
let candidate = candidate
.canonicalize()
.map_err(|_| "runtime component is unavailable".to_string())?;
if !candidate.starts_with(&staged_root) {
return Err("runtime component escaped the immutable snapshot".to_string());
}
Ok(candidate)
}
+91
View File
@@ -0,0 +1,91 @@
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
use super::PluginRegistry;
use super::discovery::{DiscoveryConfig, discover_with_config};
pub(crate) struct DeclarativePluginFixture {
_temp: TempDir,
pub workspace: PathBuf,
pub marker: PathBuf,
pub config: DiscoveryConfig,
pub registry: PluginRegistry,
}
impl DeclarativePluginFixture {
pub fn new() -> Self {
let temp = TempDir::new().expect("plugin fixture tempdir");
let workspace = temp.path().join("project");
let user_plugins_dir = temp.path().join("user");
let plugin = user_plugins_dir.join("runtime-demo");
let marker = workspace.join("plugin-hook-ran");
fs::create_dir_all(&workspace).expect("workspace");
fs::create_dir_all(plugin.join("commands")).expect("commands");
fs::create_dir_all(plugin.join("agents")).expect("agents");
fs::create_dir_all(plugin.join("hooks")).expect("hooks");
fs::write(
plugin.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"runtime-demo\"\nversion = \"1.0.0\"\n[commands]\npath = \"commands\"\n[agents]\npath = \"agents\"\n[hooks]\npath = \"hooks\"\n",
)
.expect("manifest");
fs::write(
plugin.join("commands/plugin-hello.md"),
"---\ndescription: Send a reviewed plugin greeting\n---\nhello from plugin $ARGUMENTS",
)
.expect("command");
fs::write(
plugin.join("agents/plugin-scout.toml"),
"id = \"plugin-scout\"\ndisplay_name = \"Plugin Scout\"\ndescription = \"Reviewed staged scout\"\nrole_hint = \"scout\"\n",
)
.expect("agent");
let command = format!("printf plugin-hook-ran > {}", marker.display());
fs::write(
plugin.join("hooks/hooks.toml"),
format!(
"enabled = true\n\n[[hooks]]\nname = \"plugin-start\"\nevent = \"session_start\"\ncommand = {}\ncontinue_on_error = false\n",
toml::Value::String(command)
),
)
.expect("hooks");
let config = DiscoveryConfig {
workspace: workspace.clone(),
user_plugins_dir,
workspace_plugins_dir: workspace.join(".codewhale/plugins"),
builtin_plugin_dirs: Vec::new(),
state_path: temp.path().join("state/plugin-state.json"),
};
let mut registry = discover_with_config(&config);
registry.trust("runtime-demo").expect("trust plugin");
registry.enable("runtime-demo").expect("enable plugin");
let registry = discover_with_config(&config);
assert!(
registry.is_active("runtime-demo"),
"restart restores plugin"
);
Self {
_temp: temp,
workspace,
marker,
config,
registry,
}
}
pub fn disable_from_fresh_registry(&self) -> PluginRegistry {
let mut registry = discover_with_config(&self.config);
registry.disable("runtime-demo").expect("disable plugin");
discover_with_config(&self.config)
}
pub fn revoke_from_fresh_registry(&self) -> PluginRegistry {
let mut registry = discover_with_config(&self.config);
registry
.revoke_trust("runtime-demo")
.expect("revoke plugin");
discover_with_config(&self.config)
}
}
+71 -41
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use super::activation::PluginActivationCapability;
use super::discovery::{DiscoveryConfig, discover_with_config};
use super::manifest::{PluginCompatibility, capability_hash_v1};
use super::manifest::{PluginCompatibility, capability_hash_v1, capability_hash_v2};
use super::types::{PluginDiagnosticLevel, PluginTrustStatus};
fn config(root: &Path) -> DiscoveryConfig {
@@ -110,6 +110,28 @@ fn trust_and_enablement_are_separate_atomic_state_transitions() {
);
}
#[test]
fn declarative_runtime_sources_survive_restart_only_from_the_staged_snapshot() {
let fixture = super::test_fixture::DeclarativePluginFixture::new();
let plugin = fixture.registry.get("runtime-demo").expect("plugin");
let staged_root = plugin.staged_root.as_deref().expect("staged root");
for capability in [
PluginActivationCapability::Commands,
PluginActivationCapability::Agents,
PluginActivationCapability::Hooks,
] {
let (sources, errors) =
super::runtime::active_component_sources(&fixture.registry, capability);
assert!(errors.is_empty(), "{capability:?}: {errors:?}");
assert_eq!(sources.len(), 1, "{capability:?}");
assert!(sources[0].path.starts_with(staged_root), "{capability:?}");
assert!(
!sources[0].path.starts_with(&plugin.canonical_root),
"{capability:?} must never execute from mutable source"
);
}
}
#[test]
fn content_change_invalidates_trust_without_changing_capabilities() {
let tmp = tempfile::tempdir().unwrap();
@@ -291,7 +313,7 @@ fn revoking_trust_does_not_rewrite_enablement() {
fn write_mixed_bundle(config: &DiscoveryConfig) -> PathBuf {
let plugin = write_plugin(
config,
"\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\n[hooks]\npath = \"hooks\"\n",
"\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\n[hooks]\npath = \"hooks\"\n[lsp]\npath = \"lsp\"\n",
);
fs::create_dir_all(plugin.join("skills/demo")).unwrap();
fs::write(
@@ -301,6 +323,7 @@ fn write_mixed_bundle(config: &DiscoveryConfig) -> PathBuf {
.unwrap();
fs::create_dir_all(plugin.join("commands")).unwrap();
fs::create_dir_all(plugin.join("hooks")).unwrap();
fs::create_dir_all(plugin.join("lsp")).unwrap();
plugin
}
@@ -313,17 +336,16 @@ fn mixed_supported_and_unsupported_components_activate_only_supported_surfaces()
let mut registry = discover_with_config(&config);
let plugin = registry.get("demo").unwrap();
assert_eq!(plugin.compatibility(), PluginCompatibility::Partial);
assert_eq!(plugin.inventory.supported_labels(), vec!["skills"]);
assert_eq!(
plugin.inventory.unsupported_labels(),
vec!["commands", "hooks"]
plugin.inventory.supported_labels(),
vec!["skills", "commands", "hooks"]
);
assert_eq!(plugin.inventory.unsupported_labels(), vec!["lsp"]);
assert!(
plugin.diagnostics.iter().any(|diagnostic| {
diagnostic.level == PluginDiagnosticLevel::Warning
&& diagnostic.code == "component-inactive"
&& diagnostic.message.contains("commands")
&& diagnostic.message.contains("hooks")
&& diagnostic.message.contains("lsp")
}),
"inactive components must stay visible in diagnostics: {:?}",
plugin.diagnostics
@@ -340,8 +362,9 @@ fn mixed_supported_and_unsupported_components_activate_only_supported_surfaces()
assert_eq!(plugin.skill_snapshots.len(), 1);
assert_eq!(plugin.skill_snapshots[0].name, "demo");
assert!(plugin.component_active(PluginActivationCapability::Skills));
assert!(!plugin.component_active(PluginActivationCapability::Commands));
assert!(!plugin.component_active(PluginActivationCapability::Hooks));
assert!(plugin.component_active(PluginActivationCapability::Commands));
assert!(plugin.component_active(PluginActivationCapability::Hooks));
assert!(!plugin.component_active(PluginActivationCapability::Lsp));
let skills = crate::skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
@@ -354,8 +377,8 @@ fn mixed_supported_and_unsupported_components_activate_only_supported_surfaces()
fn all_unsupported_bundles_can_be_reviewed_but_not_enabled() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
let plugin = write_plugin(&config, "\n[commands]\npath = \"commands\"\n");
fs::create_dir_all(plugin.join("commands")).unwrap();
let plugin = write_plugin(&config, "\n[lsp]\npath = \"lsp\"\n");
fs::create_dir_all(plugin.join("lsp")).unwrap();
let mut registry = discover_with_config(&config);
let plugin = registry.get("demo").unwrap();
@@ -364,10 +387,10 @@ fn all_unsupported_bundles_can_be_reviewed_but_not_enabled() {
registry.trust("demo").unwrap();
let error = registry.enable("demo").unwrap_err();
assert!(
error.contains("no supported Skills or MCP"),
error.contains("no supported declarative components"),
"all-unsupported enable must name the missing supported surfaces: {error}"
);
assert!(error.contains("commands"), "{error}");
assert!(error.contains("lsp"), "{error}");
assert!(!registry.is_active("demo"));
let plugin = registry.get("demo").unwrap();
assert!(plugin.trusted());
@@ -459,7 +482,7 @@ fn mixed_bundle_revocation_and_trust_changes_deactivate_supported_surfaces() {
}
#[test]
fn v1_trust_receipts_fail_closed_as_needs_review_under_v2() {
fn legacy_trust_receipts_fail_closed_as_needs_review_under_v3() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
let plugin = write_plugin(&config, "\n[skills]\npath = \"skills\"\n");
@@ -475,38 +498,45 @@ fn v1_trust_receipts_fail_closed_as_needs_review_under_v2() {
first.enable("demo").unwrap();
assert!(first.is_active("demo"));
let plugin = first.get("demo").unwrap();
let v1_hash = capability_hash_v1(&plugin.inventory);
let v2_hash = plugin.capability_hash.clone();
assert_ne!(
v1_hash, v2_hash,
"v2 receipts must not collide with the pre-policy v1 domain"
let legacy_hashes = [
("v1", capability_hash_v1(&plugin.inventory)),
("v2", capability_hash_v2(&plugin.inventory)),
];
let v3_hash = plugin.capability_hash.clone();
assert!(
legacy_hashes.iter().all(|(_, hash)| hash != &v3_hash),
"v3 receipts must not collide with either historical domain"
);
let content_hash = plugin.content_hash.clone();
let raw = fs::read_to_string(&config.state_path).unwrap();
let mut parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
replace_capability_hashes(&mut parsed, &v2_hash, &v1_hash);
fs::write(
&config.state_path,
serde_json::to_string_pretty(&parsed).unwrap(),
)
.unwrap();
for (version, legacy_hash) in legacy_hashes {
let mut parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
replace_capability_hashes(&mut parsed, &v3_hash, &legacy_hash);
fs::write(
&config.state_path,
serde_json::to_string_pretty(&parsed).unwrap(),
)
.unwrap();
let second = discover_with_config(&config);
let plugin = second.get("demo").unwrap();
assert_eq!(plugin.content_hash, content_hash);
assert_eq!(plugin.capability_hash, v2_hash);
assert_eq!(plugin.trust_status, PluginTrustStatus::CapabilitiesChanged);
assert!(plugin.enabled);
assert!(!plugin.trusted());
assert!(!plugin.active());
assert!(second.authority_for("demo").is_some());
let skills =
crate::skills::discover_from_directories_with_plugins(Vec::<PathBuf>::new(), Some(&second));
assert!(
skills.get("demo:demo").is_none(),
"a v1 receipt must not activate Skills after the v2 policy binding"
);
let restarted = discover_with_config(&config);
let plugin = restarted.get("demo").unwrap();
assert_eq!(plugin.content_hash, content_hash);
assert_eq!(plugin.capability_hash, v3_hash);
assert_eq!(plugin.trust_status, PluginTrustStatus::CapabilitiesChanged);
assert!(plugin.enabled);
assert!(!plugin.trusted());
assert!(!plugin.active());
assert!(restarted.authority_for("demo").is_some());
let skills = crate::skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&restarted),
);
assert!(
skills.get("demo:demo").is_none(),
"a {version} receipt must not activate Skills after the v3 policy binding"
);
}
}
fn replace_capability_hashes(value: &mut serde_json::Value, from: &str, to: &str) {
+19 -7
View File
@@ -6604,15 +6604,16 @@ impl RuntimeThreadManager {
let max_subagents = cfg
.max_subagents_for_provider(provider)
.clamp(1, MAX_SUBAGENTS);
let thread_plugin_registry = self
.plugin_registry
.as_ref()
.map(|registry| registry.rediscover_for_workspace(&thread.workspace));
let engine_cfg = EngineConfig {
model: route_model.clone(),
active_route_limits: route_limits,
workspace: thread.workspace.clone(),
subagent_state_root: None,
plugin_registry: self
.plugin_registry
.as_ref()
.map(|registry| registry.rediscover_for_workspace(&thread.workspace)),
plugin_registry: thread_plugin_registry.clone(),
allow_shell: thread.allow_shell,
trust_mode: thread.trust_mode,
notes_path: cfg.notes_path(),
@@ -6663,9 +6664,20 @@ impl RuntimeThreadManager {
rlm_sessions: crate::rlm::session::new_shared_rlm_session_store(),
},
subagent_model_overrides: cfg.subagent_model_overrides(),
fleet_roster: Arc::new(crate::fleet::roster::FleetRoster::load(
&cfg.fleet_config(),
&thread.workspace,
fleet_roster: Arc::new(thread_plugin_registry.as_deref().map_or_else(
|| {
crate::fleet::roster::FleetRoster::load(
&cfg.fleet_config(),
&thread.workspace,
)
},
|plugins| {
crate::fleet::roster::FleetRoster::load_with_plugins(
&cfg.fleet_config(),
&thread.workspace,
plugins,
)
},
)),
subagent_api_timeout: std::time::Duration::from_secs(
cfg.subagent_api_timeout_secs_for_provider(provider),
+26 -2
View File
@@ -12644,8 +12644,21 @@ fn refresh_spawn_route_sources(runtime: &mut SubAgentRuntime) {
let Some(config) = runtime.api_config.as_deref() else {
return;
};
let roster =
crate::fleet::roster::FleetRoster::load(&config.fleet_config(), &runtime.context.workspace);
let roster = runtime.context.plugin_registry.as_deref().map_or_else(
|| {
crate::fleet::roster::FleetRoster::load(
&config.fleet_config(),
&runtime.context.workspace,
)
},
|plugins| {
crate::fleet::roster::FleetRoster::load_with_plugins(
&config.fleet_config(),
&runtime.context.workspace,
plugins,
)
},
);
let mut role_models = roster.model_overrides();
role_models.extend(config.subagent_model_overrides());
runtime.role_models = role_models;
@@ -12692,6 +12705,17 @@ fn apply_spawn_profile(
Type aliases: {VALID_ROLE_ALIASES}. See /fleet."
)));
};
if let Some(authority) = member.plugin_authority.as_ref()
&& let Err(reason) = crate::plugins::registry::verify_plugin_component_authority(
authority,
crate::plugins::activation::PluginActivationCapability::Agents,
)
{
return Err(ToolError::execution_failed(format!(
"Plugin Agent profile '{}' was denied: {reason}. Reload, review, trust, and enable the bundle before retrying.",
member.id
)));
}
let member_type = crate::fleet::worker_runtime::roster_member_agent_type(member);
if request.agent_type_explicit && request.agent_type != member_type {
+50
View File
@@ -3575,6 +3575,7 @@ fn isolated_fleet_roster_with(
profile,
source: std::path::PathBuf::from("test"),
origin: crate::fleet::roster::ProfileOrigin::Config,
plugin_authority: None,
}])
}
@@ -4756,6 +4757,54 @@ fn test_invalid_role_error_lists_real_aliases() {
);
}
#[test]
fn plugin_agent_profile_survives_restart_and_spawn_rechecks_disable() {
let _lock = crate::test_support::lock_test_env();
let fixture = crate::plugins::test_fixture::DeclarativePluginFixture::new();
let config = codewhale_config::FleetConfigToml::default();
let roster = FleetRoster::load_with_plugins(&config, &fixture.workspace, &fixture.registry);
let member = roster.get("plugin-scout").expect("plugin Agent is loaded");
assert_eq!(member.origin, crate::fleet::roster::ProfileOrigin::Plugin);
assert!(member.plugin_authority.is_some());
assert!(
member.source.starts_with(
fixture
.registry
.get("runtime-demo")
.and_then(|plugin| plugin.staged_root.as_deref())
.expect("staged root")
),
"Agent profile must execute from the immutable staged snapshot"
);
let mut request = parse_spawn_request(&json!({
"prompt": "inspect the plugin boundary",
"profile": "plugin-scout"
}))
.expect("spawn request parses");
let applied = apply_spawn_profile(&mut request, &roster)
.expect("active plugin Agent passes the spawn boundary")
.expect("profile resolves");
assert_eq!(applied.id, "plugin-scout");
let inactive = fixture.disable_from_fresh_registry();
let mut stale_request = parse_spawn_request(&json!({
"prompt": "must fail closed",
"profile": "plugin-scout"
}))
.expect("spawn request parses");
let denied = apply_spawn_profile(&mut stale_request, &roster)
.expect_err("a stale roster cannot spawn a disabled plugin Agent")
.to_string();
assert!(denied.contains("was denied"), "{denied}");
let reloaded = FleetRoster::load_with_plugins(&config, &fixture.workspace, &inactive);
assert!(
reloaded.get("plugin-scout").is_none(),
"reload removes the disabled plugin Agent"
);
}
fn schema_property_description<'a>(schema: &'a Value, property: &str) -> &'a str {
schema["properties"][property]["description"]
.as_str()
@@ -12416,6 +12465,7 @@ fn member_pinning_provider(provider: &str, model: &str) -> crate::fleet::profile
profile,
source: std::path::PathBuf::from(format!("{provider}-worker.toml")),
origin: crate::fleet::roster::ProfileOrigin::Workspace,
plugin_authority: None,
}
}
@@ -278,6 +278,7 @@ async fn issue_5305_unbuildable_provider_refuses_before_admission() {
profile,
source: std::path::PathBuf::from("private/profile.toml"),
origin: crate::fleet::roster::ProfileOrigin::Personal,
plugin_authority: None,
},
]));
let context = runtime.context.clone();
+14 -4
View File
@@ -587,10 +587,20 @@ impl App {
};
let shell_manager = new_shared_shell_manager(workspace.clone());
// Initialize hooks executor from config, merged with project-local
// `.codewhale/hooks.toml` (#3026).
let hooks_config =
crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &workspace);
for error in crate::commands::user_registry::install_plugin_registry(
&workspace,
plugin_registry.as_ref(),
) {
tracing::warn!(target: "plugins", "{error}");
}
// Initialize hooks executor from config, reviewed plugin snapshots,
// then project-local `.codewhale/hooks.toml` (#3026).
let hooks_config = crate::hooks::HooksConfig::load_with_project_and_plugins(
config.hooks_config(),
&workspace,
Some(plugin_registry.as_ref()),
);
let hooks = HookExecutor::new(hooks_config, workspace.clone());
// Initialize plan state
+34 -1
View File
@@ -997,6 +997,29 @@ pub(crate) async fn apply_command_result(
}
}
AppAction::PluginRegistryChanged => {
let command_errors = crate::commands::user_registry::install_plugin_registry(
&app.workspace,
app.plugin_registry.as_ref(),
);
app.hooks = app.hooks.rebind(
crate::hooks::HooksConfig::load_with_project_and_plugins(
config.hooks_config(),
&app.workspace,
Some(app.plugin_registry.as_ref()),
),
app.workspace.clone(),
);
app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
if !command_errors.is_empty() {
app.set_sticky_status(
format!(
"Plugin runtime activation failed: {}",
command_errors.join("; ")
),
StatusToastLevel::Error,
None,
);
}
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
if !app.api_messages.is_empty() {
@@ -1889,12 +1912,22 @@ pub(crate) fn apply_workspace_runtime_state(app: &mut App, config: &Config, work
app.workspace = workspace.clone();
app.coordination_detail = None;
app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
for error in crate::commands::user_registry::install_plugin_registry(
&workspace,
app.plugin_registry.as_ref(),
) {
tracing::warn!(target: "plugins", "{error}");
}
app.active_skill = None;
app.active_skill_provenance = None;
// Switching workspace reloads the hook set (project hooks are per-repo)
// but stays inside the same TUI session, so the session id is preserved.
app.hooks = app.hooks.rebind(
crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &workspace),
crate::hooks::HooksConfig::load_with_project_and_plugins(
config.hooks_config(),
&workspace,
Some(app.plugin_registry.as_ref()),
),
workspace.clone(),
);
app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config);
+2 -1
View File
@@ -257,9 +257,10 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
.map(crate::config::LspConfigToml::into_runtime),
runtime_services: app.runtime_services.clone(),
subagent_model_overrides: config.subagent_model_overrides(),
fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load_with_plugins(
&config.fleet_config(),
&app.workspace,
app.plugin_registry.as_ref(),
)),
subagent_api_timeout: Duration::from_secs(
config.subagent_api_timeout_secs_for_provider(provider),
+12 -6
View File
@@ -1298,8 +1298,11 @@ pub(crate) async fn handle_view_events(
app.status_message = Some(message);
// Refresh the dispatch roster from the fleet-aware source so
// selection changes take effect for the next spawn.
let roster =
crate::fleet::roster::FleetRoster::load(&config.fleet_config(), &app.workspace);
let roster = crate::fleet::roster::FleetRoster::load_with_plugins(
&config.fleet_config(),
&app.workspace,
app.plugin_registry.as_ref(),
);
let _ = engine_handle.try_send(Op::SetFleetRoster {
roster: std::sync::Arc::new(roster),
});
@@ -1484,10 +1487,13 @@ pub(crate) async fn handle_view_events(
txn.stage(target.clone(), draft.render_toml().into_bytes());
match txn.commit() {
Ok(()) => {
let roster = std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
&config.fleet_config(),
&app.workspace,
));
let roster = std::sync::Arc::new(
crate::fleet::roster::FleetRoster::load_with_plugins(
&config.fleet_config(),
&app.workspace,
app.plugin_registry.as_ref(),
),
);
let roster_refresh_failed = engine_handle
.try_send(Op::SetFleetRoster { roster })
.is_err();
+5 -1
View File
@@ -17,7 +17,11 @@ pub(crate) fn complete_trust_directory_onboarding(
// reported a `DEEPSEEK_SESSION_ID`, and it has to keep meaning the same
// session afterwards.
app.hooks = app.hooks.rebind(
crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &app.workspace),
crate::hooks::HooksConfig::load_with_project_and_plugins(
config.hooks_config(),
&app.workspace,
Some(app.plugin_registry.as_ref()),
),
app.workspace.clone(),
);
app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
+6 -2
View File
@@ -129,7 +129,11 @@ impl FleetRosterView {
});
let mut view = Self::from_parts(
OperatorInfo::from_app(app),
FleetRoster::load(&config.fleet_config(), &app.workspace),
FleetRoster::load_with_plugins(
&config.fleet_config(),
&app.workspace,
app.plugin_registry.as_ref(),
),
selected_fleet,
);
view.locale = app.ui_locale;
@@ -664,7 +668,7 @@ fn member_shadow_badge(
ProfileOrigin::Workspace => MessageId::FleetRosterShadowBadgeProjectOverride,
ProfileOrigin::Personal => MessageId::FleetRosterShadowBadgePersonalOverride,
ProfileOrigin::Config => MessageId::FleetRosterShadowBadgeConfigOverride,
ProfileOrigin::BuiltIn => return None,
ProfileOrigin::Plugin | ProfileOrigin::BuiltIn => return None,
}
};
Some(format!(" {}", tr(locale, id)))
+61 -40
View File
@@ -1,11 +1,11 @@
# Plugin bundles
Codewhale supports a deliberately small plugin-bundle boundary. The boundary
was drawn in v0.9.1 and still holds as of v0.9.9: a bundle may contribute
declarative Skills and MCP server configuration through Codewhale's existing
engines, and nothing else activates. Unsupported declarations stay inventoried
instead of disabling a mixed bundle. Discovery alone never executes, enables,
trusts, downloads, updates, or installs anything.
was drawn in v0.9.1 and is extended deliberately in v0.9.10: a bundle may
contribute declarative Skills, MCP configuration, Commands, Agent profiles,
and Hooks through Codewhale's existing engines. Unsupported declarations stay
inventoried instead of disabling a mixed bundle. Discovery alone never
executes, enables, trusts, downloads, updates, or installs anything.
This document owns the bundle format (both manifest encodings), discovery,
validation, and the trust/enable/runtime contract. [PLUGINS.md](PLUGINS.md)
@@ -39,7 +39,7 @@ never scanned.
Pre-v0.9.1 `overrides.json` enablement was intentionally not imported as
trust; every bundle activates only through the content-hash and
`codewhale-plugin-capabilities-v2` activation-policy review below.
`codewhale-plugin-capabilities-v3` activation-policy review below.
## Manifest
@@ -88,6 +88,15 @@ author = "Example Author"
[skills]
path = "skills"
[commands]
path = "commands"
[agents]
path = "agents"
[hooks]
path = "hooks"
[mcp_servers.local]
command = "node"
args = ["server.js"]
@@ -150,21 +159,19 @@ a manifest declaring OAuth fields on a plugin MCP server fails validation.
### Active and inactive component surfaces
`[skills]` and `[mcp_servers.*]` are the only active component adapters as of
v0.9.9. The manifest can additionally inventory the following future
surfaces. Those declarations stay hashed, reviewed, and displayed, but they
do not activate and they no longer disable the whole bundle:
Codewhale 0.9.10 activates declarative `[skills]`, `[mcp_servers.*]`,
`[commands]`, `[agents]`, and `[hooks]` components from its content-addressed
runtime snapshot. Commands use markdown command files, Agents use Fleet TOML
profiles, and Hooks use `HooksConfig` TOML files. A component may name one file
or a directory of the corresponding files. Ordinary user/workspace commands
and Agent profiles keep precedence over plugin contributions; trusted project
hooks run after plugin hooks.
The manifest can additionally inventory the following inactive surfaces.
Those declarations stay hashed, reviewed, and displayed, but do not activate
and no longer disable the whole bundle:
```toml
[commands]
path = "commands"
[agents] # TOML alias: [profiles]
path = "agents"
[hooks]
path = "hooks"
[lsp] # TOML alias: [lsp_servers]
path = "lsp"
@@ -183,22 +190,22 @@ lifecycle_mutation = true
The accept/reject behavior is deliberately loud, never silent:
- Compatibility is per-component: `full` when every declared surface has an
adapter (or the bundle is empty), `partial` when Skills and/or MCP can
adapter (or the bundle is empty), `partial` when supported components can
activate beside named inactive surfaces, and `unsupported` when the bundle
only declares surfaces Codewhale cannot activate yet. The same versioned
activation policy (v2) drives those labels, the runtime adapters, and the
capability hash. A future Codewhale that starts executing commands, agents,
hooks, LSP, or native code must change that policy, which changes the
capability hash and forces re-review. Pre-policy (v1) trust receipts fail
closed as `capabilities-changed`.
- A **recognized-but-inactive** declaration (`commands`, `agents`, `hooks`,
`lsp`, `native`, a non-empty `capabilities.filesystem_roots`, or
activation policy (v3) drives those labels, the runtime adapters, and the
capability hash. A future Codewhale that starts executing LSP or native code
must change that policy, which changes the capability hash and forces
re-review. v1 and v2 trust receipts fail closed as
`capabilities-changed`.
- A **recognized-but-inactive** declaration (`lsp`, `native`, a non-empty
`capabilities.filesystem_roots`, or
`capabilities.lifecycle_mutation = true`) parses and is validated like any
component (contained, present, link-free). It is counted in the inventory,
hashed into the capability receipt, shown in review and `/plugin show` as
inactive, and never executed. A reviewed, trusted, applicable mixed bundle
can still be enabled: Skills and MCP become active, and the inactive
surfaces stay named as inactive.
can still be enabled: supported declarative components become active, and
the inactive surfaces stay named as inactive.
- An **all-unsupported** bundle can be reviewed and trusted, but `/plugin
enable` fails closed and names the inactive surfaces. There is nothing
Codewhale can honestly activate.
@@ -235,7 +242,7 @@ hashes, and inactive declarations. It also prints an exact confirmation:
Run that exact command only after reviewing the bundle. The confirmation token
uses both complete SHA-256 receipts rather than display prefixes. The
capability receipt is the v2 digest: it still hashes the complete inventory
capability receipt is the v3 digest: it still hashes the complete inventory
and also binds this build's activation policy (which adapters are executable
versus inventoried-only). Trust first
copies the complete reviewed tree into a Codewhale-owned, content-addressed
@@ -255,11 +262,11 @@ bits themselves and always drop into this same review — see
installed.)
Trust, enable, disable, revoke, and reload rebuild the current workspace's
Skill catalogue and MCP pool immediately. Each persisted transition advances a
per-bundle generation under a stable cross-process lock. A generation change
cancels in-flight MCP work, removes cached catalog entries, terminates an idle
plugin stdio child, and denies persisted queued Skills carrying the older
authority receipt.
Skills, MCP, Commands, Agent profiles, and Hooks immediately. Each persisted
transition advances a per-bundle generation under a stable cross-process lock.
A generation change cancels in-flight MCP work, removes cached catalog
entries, terminates an idle plugin stdio child, and denies persisted queued
Skills carrying the older authority receipt.
The review distinguishes remote MCP endpoints from local stdio MCP servers.
A local stdio server is a child process running with the Codewhale user's host
@@ -291,7 +298,7 @@ on: replaced bytes stop matching the receipt, forcing re-review.
An active bundle must be enabled, trusted for its current hashes, applicable to
the host, and free of validation errors. A reviewed mixed bundle may be
active, but only supported components in the reviewed v2 activation mask are
active, but only supported components in the reviewed v3 activation mask are
consumable. Unsupported components remain listed, hashed, reviewed, and
inactive.
@@ -317,8 +324,22 @@ inactive.
boundary and drops the stale connection/catalogue entry, but is not claimed
to interrupt a call already executing. Every failure includes instructions
to reload, review, trust, and enable the bundle again.
- Commands load after ordinary user/workspace commands and saved workflows, so
existing definitions keep precedence and collisions are visible. The
palette hides a revoked command immediately; dispatch rechecks the full
receipt before expanding its body and reports a visible denial on stale
input.
- Agent profiles join the Fleet roster below explicit config, personal, and
workspace profiles but above built-ins. Roster collisions retain the
existing visible shadow record. Every Agent spawn rebuilds from the current
registry and rechecks the selected plugin profile's authority before its
prompt or route can be used.
- Hooks merge after global hooks and before trusted project hooks. Foreground
Hooks recheck authority immediately before process spawn; background Hooks
check before enqueue and again at dequeue so a queued, revoked Hook cannot
start later.
- Plain launch, resume, fork, exec, and serve each construct an immutable
workspace-scoped registry before constructing their Skill or MCP catalogue.
workspace-scoped registry before constructing their plugin-backed catalogues.
- Constitution, repository instructions, permission rules, sandbox policy,
and MCP tool approval continue to outrank plugin instructions.
@@ -330,15 +351,15 @@ plugin-originated errors suppress URL query, authentication, argv, and
environment material. Legacy executable tools under `[tools].plugin_dir`
remain a distinct system and are listed under `/plugin tools`.
## Explicit non-goals as of v0.9.6
## Explicit non-goals as of v0.9.10
Federated marketplace catalogs (`/plugin marketplace add|list|show|remove|install`)
parse local Kimi-, Claude-, Codex-, and Codewhale-format catalog documents; see
the marketplace section below (`/plugin install` fetches
one reviewed source, and `/plugin suggest` ranks only what is already
installed), no ambient compatibility discovery, no automatic trust, no
plugin-contributed MCP OAuth, no hook adapter, command adapter, agent adapter,
LSP adapter, native extension runtime, or MCP subscription adapter, no
plugin-contributed MCP OAuth, no LSP adapter, native extension runtime, or MCP
subscription adapter, no
migration of another application's bundle, and no on-disk auto-migration of a
legacy `plugin.toml` to `plugin.json`. These remain later work rather than
implied capabilities.