| 1 | use super::*; |
| 2 | // `fs` was a cfg(test) import on the parent, and `Path` is now only used by |
| 3 | // the legacy/render seams. Both belong here. |
| 4 | use crate::config::Config; |
| 5 | use crate::tui::app::{App, TuiOptions}; |
| 6 | use codewhale_localization::Locale; |
| 7 | use std::fs; |
| 8 | use std::path::Path; |
| 9 | use tempfile::TempDir; |
| 10 | |
| 11 | fn create_test_app(root: &Path) -> (App, TempDir) { |
| 12 | let temp = TempDir::new().expect("tempdir"); |
| 13 | let config_path = temp.path().join("config.toml"); |
| 14 | let tools_dir = root.join("tools"); |
| 15 | fs::create_dir_all(&tools_dir).unwrap(); |
| 16 | fs::write( |
| 17 | &config_path, |
| 18 | format!( |
| 19 | "[tools]\nplugin_dir = {}\n", |
| 20 | toml::Value::String(tools_dir.to_string_lossy().to_string()) |
| 21 | ), |
| 22 | ) |
| 23 | .unwrap(); |
| 24 | let options = TuiOptions { |
| 25 | config_path: Some(config_path), |
| 26 | skills_dir: temp.path().join("skills"), |
| 27 | memory_path: temp.path().join("memory.md"), |
| 28 | notes_path: temp.path().join("notes.txt"), |
| 29 | mcp_config_path: temp.path().join("mcp.json"), |
| 30 | ..crate::test_support::test_tui_options(root) |
| 31 | }; |
| 32 | let config = Config { |
| 33 | tools: Some(crate::config::ToolsConfig { |
| 34 | plugin_dir: Some(tools_dir.to_string_lossy().into_owned()), |
| 35 | ..Default::default() |
| 36 | }), |
| 37 | ..Default::default() |
| 38 | }; |
| 39 | let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv(); |
| 40 | let registry = discovery.registry_for_workspace(root); |
| 41 | let mut app = App::new_with_plugin_registry(options, &config, registry); |
| 42 | app.ui_locale = Locale::En; |
| 43 | (app, temp) |
| 44 | } |
| 45 | |
| 46 | fn write_bundle(root: &Path) { |
| 47 | let bundle = root.join(".codewhale/plugins/demo"); |
| 48 | fs::create_dir_all(bundle.join("skills/hello")).unwrap(); |
| 49 | fs::write( |
| 50 | bundle.join("plugin.toml"), |
| 51 | "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\ndescription = \"Import spreadsheet data safely\"\n[skills]\npath = \"skills\"\n", |
| 52 | ) |
| 53 | .unwrap(); |
| 54 | fs::write( |
| 55 | bundle.join("skills/hello/SKILL.md"), |
| 56 | "---\nname: hello\ndescription: hello\n---\nbody\n", |
| 57 | ) |
| 58 | .unwrap(); |
| 59 | } |
| 60 | |
| 61 | fn write_mcp_review_bundle(root: &Path) { |
| 62 | let bundle = root.join(".codewhale/plugins/review-mcp"); |
| 63 | fs::create_dir_all(&bundle).unwrap(); |
| 64 | fs::write(bundle.join("server.js"), "// reviewed entrypoint\n").unwrap(); |
| 65 | fs::write( |
| 66 | bundle.join("plugin.toml"), |
| 67 | r#"schema_version = 1 |
| 68 | [plugin] |
| 69 | name = "review-mcp" |
| 70 | version = "1.0.0" |
| 71 | |
| 72 | [mcp_servers.local] |
| 73 | command = "node" |
| 74 | args = ["server.js", "--mode=worker", "-e", "console.log('ready')"] |
| 75 | |
| 76 | [mcp_servers.local.env] |
| 77 | PLUGIN_TOKEN = "${PLUGIN_TOKEN_SOURCE}" |
| 78 | |
| 79 | [mcp_servers.remote] |
| 80 | url = "https://example.invalid/mcp" |
| 81 | bearer_token_env_var = "REMOTE_TOKEN" |
| 82 | |
| 83 | [mcp_servers.remote.env_headers] |
| 84 | X_Api_Key = "REMOTE_API_KEY" |
| 85 | |
| 86 | [capabilities] |
| 87 | network_hosts = ["example.invalid"] |
| 88 | "#, |
| 89 | ) |
| 90 | .unwrap(); |
| 91 | } |
| 92 | |
| 93 | #[test] |
| 94 | fn bare_plugin_command_opens_unified_extensions_modal() { |
| 95 | let _lock = crate::test_support::lock_test_env(); |
| 96 | let root = TempDir::new().unwrap(); |
| 97 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 98 | let (mut app, _temp) = create_test_app(root.path()); |
| 99 | |
| 100 | let result = plugins_with_kimi_home_override(&mut app, None, None); |
| 101 | |
| 102 | assert!(matches!( |
| 103 | result.action, |
| 104 | Some(AppAction::OpenExtensions { |
| 105 | tab: crate::tui::views::extensions::ExtensionsTab::Plugins |
| 106 | }) |
| 107 | )); |
| 108 | assert!(result.message.is_none()); |
| 109 | } |
| 110 | |
| 111 | #[test] |
| 112 | fn list_show_validate_are_read_only_and_label_legacy_tools() { |
| 113 | let _lock = crate::test_support::lock_test_env(); |
| 114 | let root = TempDir::new().unwrap(); |
| 115 | let codewhale_home = root.path().join("home"); |
| 116 | // A configured user has a home; that is the state in which the built-in |
| 117 | // bundle is materialized and listed. |
| 118 | fs::create_dir_all(&codewhale_home).unwrap(); |
| 119 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 120 | write_bundle(root.path()); |
| 121 | let (mut app, _temp) = create_test_app(root.path()); |
| 122 | fs::write( |
| 123 | root.path().join("tools/greet.sh"), |
| 124 | "# name: greet\n# description: hello\n", |
| 125 | ) |
| 126 | .unwrap(); |
| 127 | // The app already resolved the legacy tools path during startup. |
| 128 | // Read-only plugin commands must not reopen a credential-bearing |
| 129 | // config file merely to inventory those tools. |
| 130 | fs::write( |
| 131 | app.config_path.as_ref().unwrap(), |
| 132 | "api_key = [\"must-not-be-re-read\"\n", |
| 133 | ) |
| 134 | .unwrap(); |
| 135 | let state_path = codewhale_home.join("plugins/state.json"); |
| 136 | |
| 137 | for arg in [Some("list"), Some("show demo"), Some("validate")] { |
| 138 | let result = plugins_with_kimi_home_override(&mut app, arg, None); |
| 139 | assert!(!result.is_error, "{:?}", result.message); |
| 140 | assert!(!state_path.exists(), "read-only command wrote plugin state"); |
| 141 | } |
| 142 | // PR #5865's call shape, with main's assertions: the workspace bundle plus |
| 143 | // the built-in computer-use bundle, which every binary now carries and |
| 144 | // which lists disabled until it is reviewed. |
| 145 | let list = plugins_with_kimi_home_override(&mut app, Some("list"), None) |
| 146 | .message |
| 147 | .unwrap(); |
| 148 | assert!(list.contains("Plugin bundles (2)"), "{list}"); |
| 149 | // The renderer escapes markdown, so the hyphen arrives backslashed. |
| 150 | assert!(list.contains(r"computer\-use"), "{list}"); |
| 151 | assert!(list.contains("builtin · not-reviewed"), "{list}"); |
| 152 | assert!(list.contains("disabled")); |
| 153 | assert!(list.contains("Legacy plugin tools (1)")); |
| 154 | } |
| 155 | |
| 156 | #[test] |
| 157 | fn list_preserves_the_one_shot_on_disk_reload_nudge() { |
| 158 | let _lock = crate::test_support::lock_test_env(); |
| 159 | let root = TempDir::new().unwrap(); |
| 160 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 161 | let (mut app, _temp) = create_test_app(root.path()); |
| 162 | |
| 163 | // Mutate the on-disk catalog after discovery. Listing must report the |
| 164 | // current-main nudge without rediscovering or changing trust state. |
| 165 | write_bundle(root.path()); |
| 166 | let first = plugins_with_kimi_home_override(&mut app, Some("list"), None) |
| 167 | .message |
| 168 | .unwrap(); |
| 169 | assert!( |
| 170 | first.contains(crate::plugins::PLUGIN_RELOAD_NUDGE), |
| 171 | "{first}" |
| 172 | ); |
| 173 | |
| 174 | let second = plugins_with_kimi_home_override(&mut app, Some("list"), None) |
| 175 | .message |
| 176 | .unwrap(); |
| 177 | assert!( |
| 178 | !second.contains(crate::plugins::PLUGIN_RELOAD_NUDGE), |
| 179 | "nudge must appear once per catalog stamp: {second}" |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | #[test] |
| 184 | fn suggest_ranks_installed_plugins_without_trusting_or_enabling_them() { |
| 185 | let _lock = crate::test_support::lock_test_env(); |
| 186 | let root = TempDir::new().unwrap(); |
| 187 | let codewhale_home = root.path().join("home"); |
| 188 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 189 | write_bundle(root.path()); |
| 190 | let (mut app, _temp) = create_test_app(root.path()); |
| 191 | |
| 192 | for arg in ["suggest", "suggest go"] { |
| 193 | let result = plugins_with_kimi_home_override(&mut app, Some(arg), None); |
| 194 | assert!( |
| 195 | result.is_error, |
| 196 | "expected usage error for {arg}: {result:?}" |
| 197 | ); |
| 198 | } |
| 199 | |
| 200 | let result = |
| 201 | plugins_with_kimi_home_override(&mut app, Some("suggest spreadsheet import"), None); |
| 202 | assert!(!result.is_error, "{result:?}"); |
| 203 | let message = result.message.expect("suggestion message"); |
| 204 | assert!(message.contains("Suggested plugins"), "{message}"); |
| 205 | assert!(message.contains("demo — disabled"), "{message}"); |
| 206 | assert!(message.contains("Why:"), "{message}"); |
| 207 | assert!(message.contains("/plugin trust demo"), "{message}"); |
| 208 | assert!( |
| 209 | message.contains("spreadsheet") || message.contains("import"), |
| 210 | "{message}" |
| 211 | ); |
| 212 | assert!(message.contains("Nothing was installed, trusted, or enabled.")); |
| 213 | assert!(!codewhale_home.join("plugins/state.json").exists()); |
| 214 | let plugin = app.plugin_registry.get("demo").expect("demo plugin"); |
| 215 | assert!(!plugin.enabled && !plugin.trusted()); |
| 216 | } |
| 217 | |
| 218 | #[test] |
| 219 | fn suggest_matches_manifest_keywords_for_a_named_integration() { |
| 220 | let _lock = crate::test_support::lock_test_env(); |
| 221 | let root = TempDir::new().unwrap(); |
| 222 | let codewhale_home = root.path().join("home"); |
| 223 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 224 | let bundle = root.path().join(".codewhale/plugins/supabase"); |
| 225 | fs::create_dir_all(&bundle).unwrap(); |
| 226 | fs::write( |
| 227 | bundle.join("plugin.toml"), |
| 228 | "schema_version = 1\n[plugin]\nname = \"supabase\"\nversion = \"1.0.0\"\ndescription = \"Hosted Postgres and auth\"\nkeywords = [\"supabase\", \"postgres\"]\n", |
| 229 | ) |
| 230 | .unwrap(); |
| 231 | let (mut app, _temp) = create_test_app(root.path()); |
| 232 | |
| 233 | let result = plugins_with_kimi_home_override(&mut app, Some("suggest add supabase auth"), None); |
| 234 | assert!(!result.is_error, "{result:?}"); |
| 235 | let message = result.message.expect("suggestion message"); |
| 236 | assert!(message.contains("supabase"), "{message}"); |
| 237 | assert!(message.contains("/plugin trust supabase"), "{message}"); |
| 238 | assert!(message.contains("Nothing was installed, trusted, or enabled.")); |
| 239 | } |
| 240 | |
| 241 | #[test] |
| 242 | fn trust_requires_content_and_capability_bound_review_token() { |
| 243 | let _lock = crate::test_support::lock_test_env(); |
| 244 | let root = TempDir::new().unwrap(); |
| 245 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 246 | write_bundle(root.path()); |
| 247 | let (mut app, _temp) = create_test_app(root.path()); |
| 248 | let enable_review = plugins_with_kimi_home_override(&mut app, Some("enable demo"), None); |
| 249 | assert!(!enable_review.is_error); |
| 250 | assert!( |
| 251 | enable_review |
| 252 | .message |
| 253 | .as_deref() |
| 254 | .is_some_and(|message| message.contains("/plugin trust demo ")) |
| 255 | ); |
| 256 | assert!(!app.plugin_registry.get("demo").unwrap().trusted()); |
| 257 | |
| 258 | let review_result = plugins_with_kimi_home_override(&mut app, Some("trust demo"), None); |
| 259 | let Some(AppAction::OpenCommandReview { |
| 260 | content, command, .. |
| 261 | }) = review_result.action |
| 262 | else { |
| 263 | panic!("trust opens a confirmation control"); |
| 264 | }; |
| 265 | assert!( |
| 266 | !content.contains("/plugin trust demo "), |
| 267 | "the hash belongs to the control" |
| 268 | ); |
| 269 | let review = review_result.message.unwrap(); |
| 270 | let confirmation = review |
| 271 | .lines() |
| 272 | .find(|line| line.starts_with("/plugin trust demo ")) |
| 273 | .unwrap(); |
| 274 | assert_eq!(confirmation, command); |
| 275 | let token = confirmation |
| 276 | .split_whitespace() |
| 277 | .last() |
| 278 | .expect("review confirmation token"); |
| 279 | let (content_digest, capability_digest) = token |
| 280 | .split_once('.') |
| 281 | .expect("content and capability digests"); |
| 282 | assert_eq!(content_digest.len(), 64); |
| 283 | assert_eq!(capability_digest.len(), 64); |
| 284 | assert!(content_digest.bytes().all(|byte| byte.is_ascii_hexdigit())); |
| 285 | assert!( |
| 286 | capability_digest |
| 287 | .bytes() |
| 288 | .all(|byte| byte.is_ascii_hexdigit()) |
| 289 | ); |
| 290 | assert!(!app.plugin_registry.get("demo").unwrap().trusted()); |
| 291 | |
| 292 | assert!(plugins_with_kimi_home_override(&mut app, Some("trust demo wrong"), None).is_error); |
| 293 | let shortened = format!( |
| 294 | "trust demo {}.{}", |
| 295 | &content_digest[..12], |
| 296 | &capability_digest[..12] |
| 297 | ); |
| 298 | assert!( |
| 299 | plugins_with_kimi_home_override(&mut app, Some(&shortened), None).is_error, |
| 300 | "the legacy 48-bit content prefix must not authorize trust" |
| 301 | ); |
| 302 | let arg = confirmation.trim_start_matches("/plugin "); |
| 303 | assert!(!plugins_with_kimi_home_override(&mut app, Some(arg), None).is_error); |
| 304 | assert!(!plugins_with_kimi_home_override(&mut app, Some("enable demo"), None).is_error); |
| 305 | assert!(app.plugin_registry.is_active("demo")); |
| 306 | assert!(!plugins_with_kimi_home_override(&mut app, Some("disable demo"), None).is_error); |
| 307 | assert!(!app.plugin_registry.is_active("demo")); |
| 308 | } |
| 309 | |
| 310 | fn write_mixed_bundle(root: &Path) { |
| 311 | let bundle = root.join(".codewhale/plugins/mixed"); |
| 312 | fs::create_dir_all(bundle.join("skills/hello")).unwrap(); |
| 313 | fs::create_dir_all(bundle.join("commands")).unwrap(); |
| 314 | fs::create_dir_all(bundle.join("hooks")).unwrap(); |
| 315 | fs::create_dir_all(bundle.join("lsp")).unwrap(); |
| 316 | fs::write( |
| 317 | bundle.join("plugin.toml"), |
| 318 | "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", |
| 319 | ) |
| 320 | .unwrap(); |
| 321 | fs::write( |
| 322 | bundle.join("skills/hello/SKILL.md"), |
| 323 | "---\nname: hello\ndescription: hello\n---\nbody\n", |
| 324 | ) |
| 325 | .unwrap(); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn mixed_bundle_review_and_enable_keep_supported_components_active() { |
| 330 | let _lock = crate::test_support::lock_test_env(); |
| 331 | let root = TempDir::new().unwrap(); |
| 332 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 333 | write_mixed_bundle(root.path()); |
| 334 | let (mut app, _temp) = create_test_app(root.path()); |
| 335 | |
| 336 | let list = plugins_with_kimi_home_override(&mut app, Some("list"), None) |
| 337 | .message |
| 338 | .unwrap(); |
| 339 | assert!(list.contains("compatibility=partial"), "{list}"); |
| 340 | assert!(list.contains("commands=1"), "{list}"); |
| 341 | assert!(list.contains("hooks=1"), "{list}"); |
| 342 | |
| 343 | let show = plugins_with_kimi_home_override(&mut app, Some("show mixed"), None) |
| 344 | .message |
| 345 | .unwrap(); |
| 346 | assert!(show.contains("Compatibility: partial"), "{show}"); |
| 347 | assert!(show.contains("Inactive components: [lsp]"), "{show}"); |
| 348 | assert!(show.contains("Active components: [none]"), "{show}"); |
| 349 | |
| 350 | let review = plugins_with_kimi_home_override(&mut app, Some("trust mixed"), None) |
| 351 | .message |
| 352 | .unwrap(); |
| 353 | let confirmation = review |
| 354 | .lines() |
| 355 | .find(|line| line.starts_with("/plugin trust mixed ")) |
| 356 | .unwrap(); |
| 357 | let arg = confirmation.trim_start_matches("/plugin "); |
| 358 | assert!(!plugins_with_kimi_home_override(&mut app, Some(arg), None).is_error); |
| 359 | let enabled = plugins_with_kimi_home_override(&mut app, Some("enable mixed"), None); |
| 360 | assert!(!enabled.is_error, "{:?}", enabled.message); |
| 361 | let message = enabled.message.unwrap(); |
| 362 | assert!(message.contains("Compatibility: partial"), "{message}"); |
| 363 | assert!(message.contains("inactive: lsp"), "{message}"); |
| 364 | assert!(app.plugin_registry.is_active("mixed")); |
| 365 | assert_eq!( |
| 366 | app.plugin_registry |
| 367 | .get("mixed") |
| 368 | .unwrap() |
| 369 | .compatibility() |
| 370 | .as_str(), |
| 371 | "partial" |
| 372 | ); |
| 373 | |
| 374 | let show = plugins_with_kimi_home_override(&mut app, Some("show mixed"), None) |
| 375 | .message |
| 376 | .unwrap(); |
| 377 | assert!(show.contains("State: active"), "{show}"); |
| 378 | assert!(show.contains("Inactive components: [lsp]"), "{show}"); |
| 379 | assert!( |
| 380 | show.contains("Active components: [skills, commands, hooks]"), |
| 381 | "{show}" |
| 382 | ); |
| 383 | assert!(show.contains("Qualified skills: [mixed:hello]"), "{show}"); |
| 384 | } |
| 385 | |
| 386 | #[test] |
| 387 | fn mcp_review_discloses_host_authority_and_names_without_secret_values() { |
| 388 | let _lock = crate::test_support::lock_test_env(); |
| 389 | let root = TempDir::new().unwrap(); |
| 390 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 391 | write_mcp_review_bundle(root.path()); |
| 392 | let (mut app, _temp) = create_test_app(root.path()); |
| 393 | let review = plugins_with_kimi_home_override(&mut app, Some("trust review-mcp"), None) |
| 394 | .message |
| 395 | .expect("review output"); |
| 396 | assert!(review.contains("mcp=2 (stdio=1 remote=1)")); |
| 397 | assert!(review.contains("host-user filesystem/network authority")); |
| 398 | assert!(review.contains("PLUGIN\\_TOKEN <- PLUGIN\\_TOKEN\\_SOURCE")); |
| 399 | assert!(review.contains("X\\_Api\\_Key <- REMOTE\\_API\\_KEY")); |
| 400 | assert!(review.contains("bearer_env=REMOTE\\_TOKEN")); |
| 401 | assert!(review.contains("redirects=same-origin-only")); |
| 402 | assert!(review.contains("Qualified skills: [none]")); |
| 403 | assert!(review.contains("#2 value=\"--mode=worker\"")); |
| 404 | assert!(review.contains("#3 value=\"-e\"")); |
| 405 | assert!(review.contains("#4 value=\"console.log('ready')\"")); |
| 406 | assert!(review.contains("oauth=disabled")); |
| 407 | } |
| 408 | |
| 409 | #[test] |
| 410 | fn legacy_tool_detail_remains_available_under_tools_namespace() { |
| 411 | let _lock = crate::test_support::lock_test_env(); |
| 412 | let root = TempDir::new().unwrap(); |
| 413 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 414 | let (mut app, _temp) = create_test_app(root.path()); |
| 415 | fs::write( |
| 416 | root.path().join("tools/greet.sh"), |
| 417 | "# name: greet\n# description: Say hello\n# approval: required\n", |
| 418 | ) |
| 419 | .unwrap(); |
| 420 | let result = plugins_with_kimi_home_override(&mut app, Some("tools greet"), None); |
| 421 | assert!(!result.is_error); |
| 422 | let message = result.message.unwrap(); |
| 423 | assert!(message.contains("Say hello")); |
| 424 | assert!(message.contains("required")); |
| 425 | } |
| 426 | |
| 427 | #[test] |
| 428 | fn install_update_uninstall_verbs_validate_arguments() { |
| 429 | let _lock = crate::test_support::lock_test_env(); |
| 430 | let root = TempDir::new().unwrap(); |
| 431 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); |
| 432 | let (mut app, _temp) = create_test_app(root.path()); |
| 433 | for arg in ["install", "update", "uninstall"] { |
| 434 | let result = plugins_with_kimi_home_override(&mut app, Some(arg), None); |
| 435 | assert!(result.is_error, "bare `{arg}` must print usage"); |
| 436 | } |
| 437 | let invalid = plugins_with_kimi_home_override(&mut app, Some("install github:"), None); |
| 438 | assert!(invalid.is_error); |
| 439 | assert!( |
| 440 | invalid |
| 441 | .message |
| 442 | .unwrap() |
| 443 | .contains("Invalid plugin install source"), |
| 444 | "invalid specs must be rejected before any network or disk access" |
| 445 | ); |
| 446 | } |
| 447 | |
| 448 | #[test] |
| 449 | fn install_update_uninstall_verbs_drive_the_guided_trust_flow() { |
| 450 | let _lock = crate::test_support::lock_test_env(); |
| 451 | let root = TempDir::new().unwrap(); |
| 452 | let codewhale_home = root.path().join("home"); |
| 453 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 454 | |
| 455 | let source = root.path().join("source/installed-demo"); |
| 456 | fs::create_dir_all(&source).unwrap(); |
| 457 | fs::write( |
| 458 | source.join("plugin.toml"), |
| 459 | "schema_version = 1\n[plugin]\nname = \"installed-demo\"\nversion = \"1.0.0\"\n", |
| 460 | ) |
| 461 | .unwrap(); |
| 462 | |
| 463 | let (mut app, _temp) = create_test_app(root.path()); |
| 464 | let runtime = tokio::runtime::Builder::new_multi_thread() |
| 465 | .worker_threads(2) |
| 466 | .enable_all() |
| 467 | .build() |
| 468 | .unwrap(); |
| 469 | runtime.block_on(async { |
| 470 | let installed = plugins_with_kimi_home_override( |
| 471 | &mut app, |
| 472 | Some(&format!("install {}", source.display())), |
| 473 | None, |
| 474 | ); |
| 475 | assert!(!installed.is_error, "{:?}", installed.message); |
| 476 | let message = installed.message.unwrap(); |
| 477 | assert!(message.contains("disabled and untrusted"), "{message}"); |
| 478 | let confirmation = message |
| 479 | .lines() |
| 480 | .find(|line| line.starts_with("/plugin trust installed-demo ")) |
| 481 | .expect("install must route into the trust review") |
| 482 | .to_string(); |
| 483 | let plugin = app.plugin_registry.get("installed-demo").unwrap(); |
| 484 | assert!(!plugin.enabled && !plugin.trusted()); |
| 485 | assert!( |
| 486 | codewhale_home |
| 487 | .join("plugins/installed-demo/.installed-from") |
| 488 | .exists() |
| 489 | ); |
| 490 | |
| 491 | // Local-path installs cannot be updated from the network. |
| 492 | let update = plugins_with_kimi_home_override(&mut app, Some("update installed-demo"), None); |
| 493 | assert!(update.is_error); |
| 494 | assert!(update.message.unwrap().contains("local path")); |
| 495 | |
| 496 | let arg = confirmation.trim_start_matches("/plugin ").to_string(); |
| 497 | assert!(!plugins_with_kimi_home_override(&mut app, Some(&arg), None).is_error); |
| 498 | assert!( |
| 499 | !plugins_with_kimi_home_override(&mut app, Some("enable installed-demo"), None) |
| 500 | .is_error |
| 501 | ); |
| 502 | assert!(app.plugin_registry.is_active("installed-demo")); |
| 503 | |
| 504 | // Uninstall requires disabled, then removes bits and prunes state. |
| 505 | let refused = |
| 506 | plugins_with_kimi_home_override(&mut app, Some("uninstall installed-demo"), None); |
| 507 | assert!(refused.is_error); |
| 508 | assert!(codewhale_home.join("plugins/installed-demo").exists()); |
| 509 | assert!( |
| 510 | !plugins_with_kimi_home_override(&mut app, Some("disable installed-demo"), None) |
| 511 | .is_error |
| 512 | ); |
| 513 | let removed = |
| 514 | plugins_with_kimi_home_override(&mut app, Some("uninstall installed-demo"), None); |
| 515 | assert!(!removed.is_error, "{:?}", removed.message); |
| 516 | assert!(!codewhale_home.join("plugins/installed-demo").exists()); |
| 517 | assert!(app.plugin_registry.get("installed-demo").is_none()); |
| 518 | let raw = fs::read_to_string(codewhale_home.join("plugins/state.json")).unwrap(); |
| 519 | let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); |
| 520 | assert!( |
| 521 | parsed["plugins"].as_object().unwrap().is_empty(), |
| 522 | "uninstall must prune the state entry: {raw}" |
| 523 | ); |
| 524 | }); |
| 525 | } |
| 526 | |
| 527 | #[test] |
| 528 | fn kimi_managed_import_is_read_only_until_hash_bound_approval() { |
| 529 | let _lock = crate::test_support::lock_test_env(); |
| 530 | let root = TempDir::new().unwrap(); |
| 531 | let codewhale_home = root.path().join("codewhale-home"); |
| 532 | let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 533 | let managed = root.path().join(".kimi-code/plugins/managed/kimi-demo"); |
| 534 | fs::create_dir_all(managed.join("skills/kimi-demo")).unwrap(); |
| 535 | fs::write( |
| 536 | managed.join("kimi.plugin.json"), |
| 537 | r#"{ |
| 538 | "name": "kimi-demo", |
| 539 | "version": "1.0.0", |
| 540 | "license": "Proprietary", |
| 541 | "skills": "./skills/", |
| 542 | "interface": { |
| 543 | "displayName": "Kimi Demo", |
| 544 | "hostKind": "local", |
| 545 | "platforms": ["macos"] |
| 546 | } |
| 547 | }"#, |
| 548 | ) |
| 549 | .unwrap(); |
| 550 | fs::write( |
| 551 | managed.join("skills/kimi-demo/SKILL.md"), |
| 552 | "---\nname: kimi-demo\ndescription: local managed fixture\n---\n", |
| 553 | ) |
| 554 | .unwrap(); |
| 555 | |
| 556 | let (mut app, _temp) = create_test_app(root.path()); |
| 557 | let help = plugins_with_kimi_home_override(&mut app, Some("help"), None) |
| 558 | .message |
| 559 | .unwrap(); |
| 560 | assert!(help.contains("/plugin import kimi [list]"), "{help}"); |
| 561 | let listed = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path()); |
| 562 | assert!(!listed.is_error, "{:?}", listed.message); |
| 563 | let message = listed.message.unwrap(); |
| 564 | assert!( |
| 565 | message.contains("Kimi Demo") || message.contains("kimi-demo"), |
| 566 | "{message}" |
| 567 | ); |
| 568 | assert!(message.contains("license=Proprietary"), "{message}"); |
| 569 | assert!(message.contains("content hash:"), "{message}"); |
| 570 | assert!(message.contains("External Kimi apps"), "{message}"); |
| 571 | let approval = message |
| 572 | .lines() |
| 573 | .find_map(|line| line.trim().strip_prefix("approve: /plugin ")) |
| 574 | .expect("listing must render an exact approval command") |
| 575 | .to_string(); |
| 576 | assert!(app.plugin_registry.get("kimi-demo").is_none()); |
| 577 | assert!(!codewhale_home.join("plugins/kimi-demo").exists()); |
| 578 | assert!(!codewhale_home.join("plugins/state.json").exists()); |
| 579 | |
| 580 | // The approval token is tied to the bytes that were inspected. |
| 581 | fs::write( |
| 582 | managed.join("skills/kimi-demo/SKILL.md"), |
| 583 | "---\nname: kimi-demo\ndescription: changed fixture\n---\n", |
| 584 | ) |
| 585 | .unwrap(); |
| 586 | let changed = plugins_with_kimi_home(&mut app, Some(&approval), root.path()); |
| 587 | assert!(changed.is_error); |
| 588 | assert!(changed.message.unwrap().contains("changed since review")); |
| 589 | assert!(!codewhale_home.join("plugins/kimi-demo").exists()); |
| 590 | |
| 591 | let refreshed = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path()) |
| 592 | .message |
| 593 | .unwrap(); |
| 594 | let approval = refreshed |
| 595 | .lines() |
| 596 | .find_map(|line| line.trim().strip_prefix("approve: /plugin ")) |
| 597 | .unwrap() |
| 598 | .to_string(); |
| 599 | let runtime = tokio::runtime::Builder::new_multi_thread() |
| 600 | .worker_threads(2) |
| 601 | .enable_all() |
| 602 | .build() |
| 603 | .unwrap(); |
| 604 | runtime.block_on(async { |
| 605 | let installed = plugins_with_kimi_home(&mut app, Some(&approval), root.path()); |
| 606 | assert!(!installed.is_error, "{:?}", installed.message); |
| 607 | assert!( |
| 608 | installed |
| 609 | .message |
| 610 | .as_deref() |
| 611 | .is_some_and(|message| message.contains("disabled and untrusted")) |
| 612 | ); |
| 613 | }); |
| 614 | let plugin = app.plugin_registry.get("kimi-demo").unwrap(); |
| 615 | assert!(!plugin.enabled && !plugin.trusted()); |
| 616 | assert!( |
| 617 | codewhale_home |
| 618 | .join("plugins/kimi-demo/kimi.plugin.json") |
| 619 | .is_file() |
| 620 | ); |
| 621 | } |
| 622 | |
| 623 | #[test] |
| 624 | fn kimi_managed_import_renders_in_the_selected_non_english_locale() { |
| 625 | let root = TempDir::new().unwrap(); |
| 626 | let (mut app, _temp) = create_test_app(root.path()); |
| 627 | app.ui_locale = Locale::Es419; |
| 628 | |
| 629 | let message = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path()) |
| 630 | .message |
| 631 | .expect("localized Kimi listing"); |
| 632 | assert!( |
| 633 | message.contains("Plugins gestionados por Kimi"), |
| 634 | "{message}" |
| 635 | ); |
| 636 | assert!( |
| 637 | message.contains("No se encontraron plugins gestionados válidos"), |
| 638 | "{message}" |
| 639 | ); |
| 640 | assert!( |
| 641 | !message.contains("No valid managed plugins found"), |
| 642 | "{message}" |
| 643 | ); |
| 644 | } |
| 645 | |
| 646 | #[cfg(unix)] |
| 647 | #[test] |
| 648 | fn kimi_managed_import_refuses_linked_children() { |
| 649 | use std::os::unix::fs::symlink; |
| 650 | |
| 651 | let root = TempDir::new().unwrap(); |
| 652 | let managed_root = root.path().join(".kimi-code/plugins/managed"); |
| 653 | let outside = root.path().join("outside"); |
| 654 | fs::create_dir_all(&managed_root).unwrap(); |
| 655 | fs::create_dir_all(&outside).unwrap(); |
| 656 | symlink(&outside, managed_root.join("linked-plugin")).unwrap(); |
| 657 | let (mut app, _temp) = create_test_app(root.path()); |
| 658 | |
| 659 | let result = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path()); |
| 660 | assert!(!result.is_error); |
| 661 | let message = result.message.unwrap(); |
| 662 | assert!(message.contains("Rejected entries"), "{message}"); |
| 663 | assert!(message.contains("links and reparse points are refused")); |
| 664 | assert!(!message.contains("approve: /plugin import kimi approve linked-plugin")); |
| 665 | } |
| 666 | |
| 667 | #[test] |
| 668 | fn export_verb_writes_agent_plugins_bundle() { |
| 669 | let _lock = crate::test_support::lock_test_env(); |
| 670 | let root = TempDir::new().unwrap(); |
| 671 | let codewhale_home = root.path().join("home"); |
| 672 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 673 | write_bundle(root.path()); |
| 674 | let (mut app, _temp) = create_test_app(root.path()); |
| 675 | |
| 676 | let usage = plugins_with_kimi_home_override(&mut app, Some("export"), None); |
| 677 | assert!(usage.is_error, "export without arguments is a usage error"); |
| 678 | let missing = plugins_with_kimi_home_override(&mut app, Some("export nope out"), None); |
| 679 | assert!(missing.is_error, "exporting an unknown plugin fails"); |
| 680 | |
| 681 | let result = plugins_with_kimi_home_override(&mut app, Some("export demo exported/demo"), None); |
| 682 | assert!(!result.is_error, "{result:?}"); |
| 683 | let message = result.message.expect("export message"); |
| 684 | assert!(message.contains("Exported `demo`"), "{message}"); |
| 685 | assert!(message.contains("plugin.json"), "{message}"); |
| 686 | |
| 687 | let bundle = root.path().join("exported/demo"); |
| 688 | let plugin_json: serde_json::Value = |
| 689 | serde_json::from_str(&fs::read_to_string(bundle.join("plugin.json")).unwrap()).unwrap(); |
| 690 | crate::plugins::agent_plugin::validate_plugin_json(&plugin_json).unwrap(); |
| 691 | assert_eq!(plugin_json["name"], "demo"); |
| 692 | assert_eq!(plugin_json["description"], "Import spreadsheet data safely"); |
| 693 | assert!(bundle.join("skills/hello/SKILL.md").is_file()); |
| 694 | assert!(!bundle.join("plugin.toml").exists()); |
| 695 | // No MCP servers declared, so no mcp.json is written. |
| 696 | assert!(!bundle.join("mcp.json").exists()); |
| 697 | // The installed bundle keeps its legacy manifest and stays untouched. |
| 698 | assert!( |
| 699 | root.path() |
| 700 | .join(".codewhale/plugins/demo/plugin.toml") |
| 701 | .exists() |
| 702 | ); |
| 703 | } |
| 704 |