feat(plugins): activate trusted declarative adapters
Signed-off-by: Hunter Bown <hmbown@gmail.com>
This commit is contained in:
@@ -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}");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -837,6 +837,7 @@ mod tests {
|
||||
},
|
||||
source: std::path::PathBuf::from(format!("{id}.toml")),
|
||||
origin: crate::fleet::roster::ProfileOrigin::Workspace,
|
||||
plugin_authority: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user