| 1 | //! Codewhale bundle lifecycle and legacy executable plugin-tool inventory. |
| 2 | //! |
| 3 | //! `/plugin` owns declarative bundles (`plugin.toml`). Script tools under |
| 4 | //! `[tools].plugin_dir` remain supported, but are labeled as legacy executable |
| 5 | //! tools and never share bundle trust state. |
| 6 | //! |
| 7 | //! # Module map |
| 8 | //! |
| 9 | //! This file is the command surface: registration, the `/plugin` verb |
| 10 | //! dispatch, and the bundle lifecycle verbs (list/show/trust/validate/ |
| 11 | //! install/update/uninstall/enable/disable/revoke). Two seams live next |
| 12 | //! door: |
| 13 | //! |
| 14 | //! * [`render`] — every string the user reads: bundle detail, the |
| 15 | //! capability review body, diagnostics, and the escaping that keeps |
| 16 | //! manifest-controlled text from forging review output. |
| 17 | //! * [`legacy`] — the separate `[tools].plugin_dir` executable inventory, |
| 18 | //! which shares no trust state with declarative bundles. |
| 19 | |
| 20 | use std::collections::BTreeMap; |
| 21 | use std::fmt::Write as _; |
| 22 | |
| 23 | use crate::commands::CommandResult; |
| 24 | use crate::commands::traits::{ |
| 25 | Command, CommandGroup, CommandInfo, FunctionCommand, RegisterCommand, |
| 26 | }; |
| 27 | use crate::localization::{MessageId, tr}; |
| 28 | use crate::plugins::types::{LoadedPlugin, PluginDiagnosticLevel}; |
| 29 | use crate::tui::app::{App, AppAction}; |
| 30 | |
| 31 | mod legacy; |
| 32 | mod render; |
| 33 | |
| 34 | #[cfg(test)] |
| 35 | mod tests; |
| 36 | |
| 37 | use legacy::{legacy_tools, scan_legacy_tools}; |
| 38 | use render::{ |
| 39 | append_diagnostics, escape_review_path, escape_review_text, render_bundle_detail, review_token, |
| 40 | }; |
| 41 | |
| 42 | pub struct PluginsCommands; |
| 43 | |
| 44 | impl CommandGroup for PluginsCommands { |
| 45 | fn commands(&self) -> &'static [Box<dyn Command>] { |
| 46 | cached_command_list!(vec![Box::new(FunctionCommand::new( |
| 47 | PluginsCmd::info(), |
| 48 | PluginsCmd::execute, |
| 49 | ))]) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo { |
| 54 | name: "plugin", |
| 55 | aliases: &["plugins"], |
| 56 | usage: "/plugin [list|show|suggest|validate|install|update|uninstall|trust|enable|disable|revoke|reload|tools]", |
| 57 | description_id: MessageId::CmdPluginDescription, |
| 58 | }; |
| 59 | |
| 60 | pub(in crate::commands) struct PluginsCmd; |
| 61 | |
| 62 | impl RegisterCommand for PluginsCmd { |
| 63 | fn info() -> &'static CommandInfo { |
| 64 | &PLUGINS_INFO |
| 65 | } |
| 66 | |
| 67 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 68 | plugins(app, arg) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | fn plugins(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 73 | let words = arg |
| 74 | .unwrap_or_default() |
| 75 | .split_whitespace() |
| 76 | .collect::<Vec<_>>(); |
| 77 | match words.as_slice() { |
| 78 | [] | ["list"] => list_bundles_and_legacy_tools(app), |
| 79 | ["help"] => CommandResult::message(tr(app.ui_locale, MessageId::CmdPluginBundleUsage)), |
| 80 | ["show", selector] => show_bundle(app, selector), |
| 81 | ["suggest"] | ["recommend"] => CommandResult::error("Usage: /plugin suggest <task>"), |
| 82 | ["suggest", task @ ..] | ["recommend", task @ ..] => suggest_bundles(app, &task.join(" ")), |
| 83 | ["validate"] => validate_bundles(app, None), |
| 84 | ["validate", selector] => validate_bundles(app, Some(selector)), |
| 85 | ["install"] => CommandResult::error(tr(app.ui_locale, MessageId::CmdPluginBundleUsage)), |
| 86 | ["install", rest @ ..] => install_bundle(app, &rest.join(" ")), |
| 87 | ["update"] | ["uninstall"] => { |
| 88 | CommandResult::error(tr(app.ui_locale, MessageId::CmdPluginBundleUsage)) |
| 89 | } |
| 90 | ["update", selector] => update_bundle(app, selector), |
| 91 | ["uninstall", selector] => uninstall_bundle(app, selector), |
| 92 | ["trust", selector] => review_bundle(app, selector), |
| 93 | ["trust", selector, token] => mutate_bundle(app, selector, Mutation::Trust(token)), |
| 94 | ["enable", selector] => mutate_bundle(app, selector, Mutation::Enable), |
| 95 | ["disable", selector] => mutate_bundle(app, selector, Mutation::Disable), |
| 96 | ["revoke", selector] => mutate_bundle(app, selector, Mutation::Revoke), |
| 97 | ["reload"] => { |
| 98 | app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&app.workspace); |
| 99 | app.refresh_skill_cache(); |
| 100 | let count = app.plugin_registry.len(); |
| 101 | CommandResult::with_message_and_action( |
| 102 | tr(app.ui_locale, MessageId::CmdPluginBundleReloaded) |
| 103 | .replace("{count}", &count.to_string()) |
| 104 | .replace("{workspace}", &app.workspace.display().to_string()), |
| 105 | AppAction::PluginRegistryChanged, |
| 106 | ) |
| 107 | } |
| 108 | ["tools"] => legacy_tools(app, None), |
| 109 | ["tools", name] => legacy_tools(app, Some(name)), |
| 110 | [selector] => { |
| 111 | if app.plugin_registry.get(selector).is_some() { |
| 112 | show_bundle(app, selector) |
| 113 | } else { |
| 114 | // Preserve `/plugin <script-tool>` compatibility while making |
| 115 | // its distinct execution model explicit in the output. |
| 116 | legacy_tools(app, Some(selector)) |
| 117 | } |
| 118 | } |
| 119 | _ => CommandResult::error(tr(app.ui_locale, MessageId::CmdPluginBundleUsage)), |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /// Rank already installed bundle metadata for a task without changing trust, |
| 124 | /// enablement, disk state, or network state. A full remote plugin marketplace |
| 125 | /// needs separately curated publisher/provenance policy; the existing plugin |
| 126 | /// registry is intentionally local-only for this release. |
| 127 | fn suggest_bundles(app: &App, task: &str) -> CommandResult { |
| 128 | let task = task.trim(); |
| 129 | if task.chars().count() < 3 { |
| 130 | return CommandResult::error("Usage: /plugin suggest <task of at least 3 characters>"); |
| 131 | } |
| 132 | |
| 133 | let mut skills = BTreeMap::new(); |
| 134 | for plugin in app.plugin_registry.list() { |
| 135 | let mut description_parts = plugin |
| 136 | .manifest |
| 137 | .plugin |
| 138 | .description |
| 139 | .iter() |
| 140 | .cloned() |
| 141 | .collect::<Vec<_>>(); |
| 142 | let mut keywords = Vec::new(); |
| 143 | for skill in &plugin.skill_snapshots { |
| 144 | description_parts.push(skill.name.clone()); |
| 145 | description_parts.push(skill.description.clone()); |
| 146 | keywords.push(skill.name.clone()); |
| 147 | keywords.extend(skill.aliases.iter().cloned()); |
| 148 | } |
| 149 | skills.insert( |
| 150 | plugin.name().to_string(), |
| 151 | crate::skills::RegistryEntry { |
| 152 | source: plugin.id.as_str().to_string(), |
| 153 | description: (!description_parts.is_empty()).then(|| description_parts.join(" ")), |
| 154 | keywords, |
| 155 | domains: plugin.inventory.network_hosts.clone(), |
| 156 | }, |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | let index = crate::skills::RegistryDocument { skills }; |
| 161 | let recommendations = crate::skills::recommend::recommend_remote_skills(task, &index, 3); |
| 162 | if recommendations.is_empty() { |
| 163 | return CommandResult::message(format!( |
| 164 | "No installed plugin bundles matched `{}`.\n\nInstall a reviewed bundle with /plugin install <source>. Nothing was installed, trusted, or enabled.", |
| 165 | escape_review_text(task) |
| 166 | )); |
| 167 | } |
| 168 | |
| 169 | let mut output = format!( |
| 170 | "Suggested installed plugins for `{}`:\n", |
| 171 | escape_review_text(task) |
| 172 | ); |
| 173 | output.push_str("─────────────────────────────\n"); |
| 174 | for recommendation in recommendations { |
| 175 | let Some(plugin) = app.plugin_registry.get(&recommendation.entry.source) else { |
| 176 | continue; |
| 177 | }; |
| 178 | let description = plugin |
| 179 | .manifest |
| 180 | .plugin |
| 181 | .description |
| 182 | .as_deref() |
| 183 | .filter(|description| !description.trim().is_empty()) |
| 184 | .unwrap_or("No description provided."); |
| 185 | let why = recommendation |
| 186 | .matched_terms |
| 187 | .iter() |
| 188 | .map(|term| escape_review_text(term)) |
| 189 | .collect::<Vec<_>>() |
| 190 | .join(", "); |
| 191 | let next_step = if plugin.active() { |
| 192 | format!("Already active: /plugin show {}", plugin.name()) |
| 193 | } else if !plugin.trusted() { |
| 194 | format!("Review before enabling: /plugin trust {}", plugin.name()) |
| 195 | } else if !plugin.enabled { |
| 196 | format!( |
| 197 | "Enable if that review still applies: /plugin enable {}", |
| 198 | plugin.name() |
| 199 | ) |
| 200 | } else { |
| 201 | format!("Inspect its inactive state: /plugin show {}", plugin.name()) |
| 202 | }; |
| 203 | let _ = writeln!( |
| 204 | output, |
| 205 | " {} — {} · {}", |
| 206 | escape_review_text(plugin.name()), |
| 207 | plugin.state_label(), |
| 208 | escape_review_text(description) |
| 209 | ); |
| 210 | let _ = writeln!(output, " Why: {why}"); |
| 211 | let _ = writeln!(output, " {next_step}"); |
| 212 | } |
| 213 | output.push_str("\nNothing was installed, trusted, or enabled."); |
| 214 | CommandResult::message(output) |
| 215 | } |
| 216 | |
| 217 | fn list_bundles_and_legacy_tools(app: &App) -> CommandResult { |
| 218 | let mut output = { |
| 219 | let registry = app.plugin_registry.as_ref(); |
| 220 | let plugins = registry.list(); |
| 221 | let mut output = if plugins.is_empty() { |
| 222 | tr(app.ui_locale, MessageId::CmdPluginBundleNoneFound).into_owned() |
| 223 | } else { |
| 224 | let mut output = tr(app.ui_locale, MessageId::CmdPluginBundleListHeader) |
| 225 | .replace("{count}", &plugins.len().to_string()); |
| 226 | output.push('\n'); |
| 227 | for plugin in plugins { |
| 228 | let _ = writeln!( |
| 229 | output, |
| 230 | "• {} — {}\n {} · {} · {}\n {}", |
| 231 | escape_review_text(plugin.name()), |
| 232 | plugin.state_label(), |
| 233 | plugin.scope, |
| 234 | plugin.trust_status.as_str(), |
| 235 | plugin.inventory.summary(), |
| 236 | escape_review_text(plugin.id.as_str()) |
| 237 | ); |
| 238 | } |
| 239 | output |
| 240 | }; |
| 241 | append_diagnostics(app, &mut output, registry.diagnostics()); |
| 242 | output |
| 243 | }; |
| 244 | |
| 245 | if let Some((dir, tools)) = scan_legacy_tools(app) { |
| 246 | output.push('\n'); |
| 247 | output.push_str( |
| 248 | &tr(app.ui_locale, MessageId::CmdPluginLegacyListHeader) |
| 249 | .replace("{count}", &tools.len().to_string()) |
| 250 | .replace("{dir}", &dir.display().to_string()), |
| 251 | ); |
| 252 | output.push('\n'); |
| 253 | for (path, metadata) in tools { |
| 254 | let _ = writeln!( |
| 255 | output, |
| 256 | "• {} — {}\n {}", |
| 257 | escape_review_text(&metadata.name), |
| 258 | escape_review_text(&metadata.description), |
| 259 | escape_review_path(&path) |
| 260 | ); |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | CommandResult::message(output) |
| 265 | } |
| 266 | |
| 267 | fn show_bundle(app: &App, selector: &str) -> CommandResult { |
| 268 | let Some(plugin) = app.plugin_registry.get(selector).cloned() else { |
| 269 | return CommandResult::error( |
| 270 | tr(app.ui_locale, MessageId::CmdPluginBundleNotFound).replace("{name}", selector), |
| 271 | ); |
| 272 | }; |
| 273 | CommandResult::message(render_bundle_detail(app, &plugin, true)) |
| 274 | } |
| 275 | |
| 276 | fn review_bundle(app: &App, selector: &str) -> CommandResult { |
| 277 | let Some(plugin) = app.plugin_registry.get(selector).cloned() else { |
| 278 | return CommandResult::error( |
| 279 | tr(app.ui_locale, MessageId::CmdPluginBundleNotFound).replace("{name}", selector), |
| 280 | ); |
| 281 | }; |
| 282 | let mut output = render_bundle_detail(app, &plugin, true); |
| 283 | let _ = writeln!( |
| 284 | output, |
| 285 | "\n/plugin trust {} {}", |
| 286 | plugin.name(), |
| 287 | review_token(&plugin) |
| 288 | ); |
| 289 | CommandResult::message(output) |
| 290 | } |
| 291 | |
| 292 | fn validate_bundles(app: &App, selector: Option<&str>) -> CommandResult { |
| 293 | let (plugins, diagnostics, clean) = { |
| 294 | let registry = app.plugin_registry.as_ref(); |
| 295 | let plugins: Vec<LoadedPlugin> = match selector { |
| 296 | Some(selector) => registry.get(selector).cloned().into_iter().collect(), |
| 297 | None => registry.list().into_iter().cloned().collect(), |
| 298 | }; |
| 299 | ( |
| 300 | plugins, |
| 301 | registry.diagnostics().to_vec(), |
| 302 | registry.validation_is_clean(), |
| 303 | ) |
| 304 | }; |
| 305 | if app.plugin_registry.is_empty() && selector.is_none() { |
| 306 | return CommandResult::error(tr(app.ui_locale, MessageId::CmdPluginBundleNoneFound)); |
| 307 | }; |
| 308 | if selector.is_some() && plugins.is_empty() { |
| 309 | return CommandResult::error( |
| 310 | tr(app.ui_locale, MessageId::CmdPluginBundleNotFound) |
| 311 | .replace("{name}", selector.unwrap_or_default()), |
| 312 | ); |
| 313 | } |
| 314 | |
| 315 | let mut output = String::new(); |
| 316 | for plugin in &plugins { |
| 317 | let _ = writeln!( |
| 318 | output, |
| 319 | "{} — {} — {}", |
| 320 | plugin.name(), |
| 321 | if plugin |
| 322 | .diagnostics |
| 323 | .iter() |
| 324 | .any(|diagnostic| diagnostic.level == PluginDiagnosticLevel::Error) |
| 325 | { |
| 326 | "invalid" |
| 327 | } else { |
| 328 | "valid" |
| 329 | }, |
| 330 | plugin.inventory.summary() |
| 331 | ); |
| 332 | append_diagnostics(app, &mut output, &plugin.diagnostics); |
| 333 | } |
| 334 | append_diagnostics(app, &mut output, &diagnostics); |
| 335 | if output.is_empty() { |
| 336 | output.push_str(if clean { "valid" } else { "invalid" }); |
| 337 | } |
| 338 | CommandResult::message(output) |
| 339 | } |
| 340 | |
| 341 | // ─── /plugin install | update | uninstall (#5182) ────────────────────────── |
| 342 | // |
| 343 | // The fetch/place on-ramp. All writes go through `plugins::mutation`; after a |
| 344 | // successful install or update the command rediscovers and drops the user |
| 345 | // into the existing trust review (`review_bundle`) — installed or replaced |
| 346 | // bits are always disabled and untrusted until the hash-bound trust flow runs. |
| 347 | |
| 348 | fn install_bundle(app: &mut App, spec: &str) -> CommandResult { |
| 349 | use crate::plugins::mutation::{ |
| 350 | PluginMutationContext, PluginMutationOutcome, PluginMutationRequest, |
| 351 | }; |
| 352 | |
| 353 | let source = match crate::plugins::install::PluginInstallSource::parse(spec) { |
| 354 | Ok(source) => source, |
| 355 | Err(error) => { |
| 356 | return CommandResult::error(format!( |
| 357 | "Invalid plugin install source `{spec}`: {error:#}\n\ |
| 358 | Expected a local path, github:owner/repo, or an HTTPS tarball URL." |
| 359 | )); |
| 360 | } |
| 361 | }; |
| 362 | let network = plugin_network_policy(); |
| 363 | let registry = std::sync::Arc::make_mut(&mut app.plugin_registry); |
| 364 | let outcome = run_async(async move { |
| 365 | let ctx = PluginMutationContext { |
| 366 | network: &network, |
| 367 | max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES, |
| 368 | }; |
| 369 | crate::plugins::mutation::execute(PluginMutationRequest::Install { source }, &ctx, registry) |
| 370 | .await |
| 371 | }); |
| 372 | |
| 373 | match outcome { |
| 374 | Ok(receipt) => match receipt.outcome { |
| 375 | PluginMutationOutcome::Installed => { |
| 376 | let name = receipt.name.clone(); |
| 377 | let path = receipt |
| 378 | .path |
| 379 | .as_deref() |
| 380 | .map(|path| path.display().to_string()) |
| 381 | .unwrap_or_default(); |
| 382 | app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&app.workspace); |
| 383 | app.refresh_skill_cache(); |
| 384 | let mut output = format!( |
| 385 | "Installed plugin '{name}' to {path}.\n\ |
| 386 | It is disabled and untrusted. Review its requested authority below, then trust and enable it.\n" |
| 387 | ); |
| 388 | if let Some(review) = review_bundle(app, &name).message { |
| 389 | output.push('\n'); |
| 390 | output.push_str(&review); |
| 391 | } |
| 392 | CommandResult::with_message_and_action(output, AppAction::PluginRegistryChanged) |
| 393 | } |
| 394 | PluginMutationOutcome::NeedsApproval(host) => { |
| 395 | CommandResult::error(needs_approval_message(&host)) |
| 396 | } |
| 397 | PluginMutationOutcome::NetworkDenied(host) => { |
| 398 | CommandResult::error(network_denied_message(&host)) |
| 399 | } |
| 400 | other => CommandResult::error(format!("Unexpected install outcome: {other:?}")), |
| 401 | }, |
| 402 | Err(error) => action_error(app, &format!("Plugin install failed: {error:#}")), |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | fn update_bundle(app: &mut App, selector: &str) -> CommandResult { |
| 407 | use crate::plugins::mutation::{ |
| 408 | PluginMutationContext, PluginMutationOutcome, PluginMutationRequest, |
| 409 | }; |
| 410 | |
| 411 | let network = plugin_network_policy(); |
| 412 | let selector_owned = selector.to_string(); |
| 413 | let registry = std::sync::Arc::make_mut(&mut app.plugin_registry); |
| 414 | let outcome = run_async(async move { |
| 415 | let ctx = PluginMutationContext { |
| 416 | network: &network, |
| 417 | max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES, |
| 418 | }; |
| 419 | crate::plugins::mutation::execute( |
| 420 | PluginMutationRequest::Update { |
| 421 | selector: selector_owned, |
| 422 | }, |
| 423 | &ctx, |
| 424 | registry, |
| 425 | ) |
| 426 | .await |
| 427 | }); |
| 428 | |
| 429 | match outcome { |
| 430 | Ok(receipt) => match receipt.outcome { |
| 431 | PluginMutationOutcome::Updated => { |
| 432 | let name = receipt.name.clone(); |
| 433 | app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&app.workspace); |
| 434 | app.refresh_skill_cache(); |
| 435 | let mut output = format!( |
| 436 | "Updated plugin '{name}'. Its content changed, so the previous trust receipt no \ |
| 437 | longer matches — review and trust it again before enabling.\n" |
| 438 | ); |
| 439 | if let Some(review) = review_bundle(app, &name).message { |
| 440 | output.push('\n'); |
| 441 | output.push_str(&review); |
| 442 | } |
| 443 | CommandResult::with_message_and_action(output, AppAction::PluginRegistryChanged) |
| 444 | } |
| 445 | PluginMutationOutcome::NoChange => { |
| 446 | CommandResult::message(format!("Plugin '{}' is already up to date.", receipt.name)) |
| 447 | } |
| 448 | PluginMutationOutcome::NeedsApproval(host) => { |
| 449 | CommandResult::error(needs_approval_message(&host)) |
| 450 | } |
| 451 | PluginMutationOutcome::NetworkDenied(host) => { |
| 452 | CommandResult::error(network_denied_message(&host)) |
| 453 | } |
| 454 | other => CommandResult::error(format!("Unexpected update outcome: {other:?}")), |
| 455 | }, |
| 456 | Err(error) => action_error(app, &format!("Plugin update failed: {error:#}")), |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | fn uninstall_bundle(app: &mut App, selector: &str) -> CommandResult { |
| 461 | use crate::plugins::mutation::{ |
| 462 | PluginMutationContext, PluginMutationOutcome, PluginMutationRequest, |
| 463 | }; |
| 464 | |
| 465 | let network = plugin_network_policy(); |
| 466 | let selector_owned = selector.to_string(); |
| 467 | let registry = std::sync::Arc::make_mut(&mut app.plugin_registry); |
| 468 | let outcome = run_async(async move { |
| 469 | let ctx = PluginMutationContext { |
| 470 | network: &network, |
| 471 | max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES, |
| 472 | }; |
| 473 | crate::plugins::mutation::execute( |
| 474 | PluginMutationRequest::Uninstall { |
| 475 | selector: selector_owned, |
| 476 | }, |
| 477 | &ctx, |
| 478 | registry, |
| 479 | ) |
| 480 | .await |
| 481 | }); |
| 482 | |
| 483 | match outcome { |
| 484 | Ok(receipt) => { |
| 485 | debug_assert!(matches!( |
| 486 | receipt.outcome, |
| 487 | PluginMutationOutcome::Uninstalled |
| 488 | )); |
| 489 | app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&app.workspace); |
| 490 | app.refresh_skill_cache(); |
| 491 | app.active_skill = None; |
| 492 | app.active_skill_provenance = None; |
| 493 | CommandResult::with_message_and_action( |
| 494 | format!("Uninstalled plugin '{}'.", receipt.name), |
| 495 | AppAction::PluginRegistryChanged, |
| 496 | ) |
| 497 | } |
| 498 | Err(error) => action_error(app, &format!("Plugin uninstall failed: {error:#}")), |
| 499 | } |
| 500 | } |
| 501 | |
| 502 | /// Read the active network policy for plugin downloads. Mirrors the skill |
| 503 | /// installer's on-demand `Config::load` (`App` carries no `Config` field); |
| 504 | /// a parse failure falls back to the prompt-default policy so the download |
| 505 | /// stays gated rather than crashing. |
| 506 | fn plugin_network_policy() -> crate::network_policy::NetworkPolicy { |
| 507 | crate::config::Config::load(None, None) |
| 508 | .unwrap_or_default() |
| 509 | .network |
| 510 | .map(|policy| policy.into_runtime()) |
| 511 | .unwrap_or_default() |
| 512 | } |
| 513 | |
| 514 | fn run_async<F, T>(future: F) -> T |
| 515 | where |
| 516 | F: std::future::Future<Output = T>, |
| 517 | { |
| 518 | // Same bridge as the skill commands: the TUI thread is part of the |
| 519 | // multi-threaded runtime, so `block_in_place` + `block_on` brings the |
| 520 | // sync slash-command handler back into the async ecosystem. |
| 521 | tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)) |
| 522 | } |
| 523 | |
| 524 | fn needs_approval_message(host: &str) -> String { |
| 525 | format!( |
| 526 | "Network policy requires approval for {host}.\n\ |
| 527 | Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry." |
| 528 | ) |
| 529 | } |
| 530 | |
| 531 | fn network_denied_message(host: &str) -> String { |
| 532 | format!( |
| 533 | "Network policy denied access to {host}.\n\ |
| 534 | Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator." |
| 535 | ) |
| 536 | } |
| 537 | |
| 538 | #[derive(Clone, Copy)] |
| 539 | enum Mutation<'a> { |
| 540 | Trust(&'a str), |
| 541 | Enable, |
| 542 | Disable, |
| 543 | Revoke, |
| 544 | } |
| 545 | |
| 546 | fn mutate_bundle(app: &mut App, selector: &str, mutation: Mutation<'_>) -> CommandResult { |
| 547 | if matches!(mutation, Mutation::Enable) { |
| 548 | let needs_review = app |
| 549 | .plugin_registry |
| 550 | .get(selector) |
| 551 | .is_some_and(|plugin| !plugin.trusted()); |
| 552 | if needs_review { |
| 553 | // Enabling is the natural entry point. Open the exact capability |
| 554 | // review instead of leaving the user at an opaque denial. |
| 555 | return review_bundle(app, selector); |
| 556 | } |
| 557 | } |
| 558 | if let Mutation::Trust(token) = mutation { |
| 559 | let Some(expected) = app.plugin_registry.get(selector).map(review_token) else { |
| 560 | return CommandResult::error( |
| 561 | tr(app.ui_locale, MessageId::CmdPluginBundleNotFound).replace("{name}", selector), |
| 562 | ); |
| 563 | }; |
| 564 | if token != expected { |
| 565 | return action_error( |
| 566 | app, |
| 567 | "Review token does not match this bundle content and capability set; run `/plugin trust <name>` again", |
| 568 | ); |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | let result = match mutation { |
| 573 | Mutation::Trust(_) => std::sync::Arc::make_mut(&mut app.plugin_registry) |
| 574 | .trust(selector) |
| 575 | .map(|()| "trusted"), |
| 576 | Mutation::Enable => std::sync::Arc::make_mut(&mut app.plugin_registry) |
| 577 | .enable(selector) |
| 578 | .map(|()| "enabled"), |
| 579 | Mutation::Disable => std::sync::Arc::make_mut(&mut app.plugin_registry) |
| 580 | .disable(selector) |
| 581 | .map(|()| "disabled"), |
| 582 | Mutation::Revoke => std::sync::Arc::make_mut(&mut app.plugin_registry) |
| 583 | .revoke_trust(selector) |
| 584 | .map(|()| "trust-revoked"), |
| 585 | }; |
| 586 | match result { |
| 587 | Ok(action) => { |
| 588 | app.refresh_skill_cache(); |
| 589 | if matches!(mutation, Mutation::Disable | Mutation::Revoke) { |
| 590 | app.active_skill = None; |
| 591 | app.active_skill_provenance = None; |
| 592 | } |
| 593 | CommandResult::with_message_and_action( |
| 594 | tr(app.ui_locale, MessageId::CmdPluginBundleMutationSuccess) |
| 595 | .replace("{name}", selector) |
| 596 | .replace("{action}", action), |
| 597 | AppAction::PluginRegistryChanged, |
| 598 | ) |
| 599 | } |
| 600 | Err(error) => action_error(app, &error), |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | fn action_error(app: &App, error: &str) -> CommandResult { |
| 605 | CommandResult::error( |
| 606 | tr(app.ui_locale, MessageId::CmdPluginActionFailed).replace("{error}", error), |
| 607 | ) |
| 608 | } |
| 609 |