| 1 | //! Command-surface tests for `/plugin marketplace` (#5311): add/list/show/ |
| 2 | //! remove over real catalog schemas, install routed through the existing |
| 3 | //! reviewed installer, and the no-auto-anything invariants. |
| 4 | |
| 5 | use super::*; |
| 6 | use crate::config::Config; |
| 7 | use crate::tui::app::{App, TuiOptions}; |
| 8 | use codewhale_localization::Locale; |
| 9 | use std::fs; |
| 10 | use std::path::Path; |
| 11 | use tempfile::TempDir; |
| 12 | |
| 13 | fn create_test_app(root: &Path) -> (App, TempDir) { |
| 14 | let temp = TempDir::new().expect("tempdir"); |
| 15 | let tools_dir = root.join("tools"); |
| 16 | fs::create_dir_all(&tools_dir).unwrap(); |
| 17 | let config_path = temp.path().join("config.toml"); |
| 18 | fs::write(&config_path, "[tools]\n").unwrap(); |
| 19 | let options = TuiOptions { |
| 20 | config_path: Some(config_path), |
| 21 | skills_dir: temp.path().join("skills"), |
| 22 | memory_path: temp.path().join("memory.md"), |
| 23 | notes_path: temp.path().join("notes.txt"), |
| 24 | mcp_config_path: temp.path().join("mcp.json"), |
| 25 | ..crate::test_support::test_tui_options(root) |
| 26 | }; |
| 27 | let config = Config::default(); |
| 28 | let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv(); |
| 29 | let registry = discovery.registry_for_workspace(root); |
| 30 | let mut app = App::new_with_plugin_registry(options, &config, registry); |
| 31 | app.ui_locale = Locale::En; |
| 32 | (app, temp) |
| 33 | } |
| 34 | |
| 35 | /// A real-schema Kimi catalog: two entries, one local (installable via the |
| 36 | /// reviewed installer once a bundle exists) and one zip URL (honestly |
| 37 | /// unsupported until fetch support exists). |
| 38 | fn write_kimi_catalog(dir: &Path) -> std::path::PathBuf { |
| 39 | let catalog = serde_json::json!({ |
| 40 | "version": "2", |
| 41 | "plugins": [ |
| 42 | { |
| 43 | "id": "demo-bundle", |
| 44 | "source": "./demo-bundle", |
| 45 | "tier": "official", |
| 46 | "displayName": "Demo Bundle", |
| 47 | "version": "1.2.3", |
| 48 | "description": "Local demo bundle", |
| 49 | "homepage": "https://example.invalid/demo", |
| 50 | "keywords": ["demo"] |
| 51 | }, |
| 52 | { |
| 53 | "id": "remote-thing", |
| 54 | "source": "https://example.invalid/remote-thing.zip", |
| 55 | "tier": "curated", |
| 56 | "displayName": "Remote Thing" |
| 57 | } |
| 58 | ] |
| 59 | }); |
| 60 | let path = dir.join("kimi-marketplace.json"); |
| 61 | fs::write(&path, serde_json::to_string_pretty(&catalog).unwrap()).unwrap(); |
| 62 | path |
| 63 | } |
| 64 | |
| 65 | fn write_demo_bundle(dir: &Path) { |
| 66 | let bundle = dir.join("demo-bundle"); |
| 67 | fs::create_dir_all(bundle.join("skills/hello")).unwrap(); |
| 68 | fs::write( |
| 69 | bundle.join("plugin.toml"), |
| 70 | "schema_version = 1\n[plugin]\nname = \"demo-bundle\"\nversion = \"1.0.0\"\ndescription = \"Demo\"\n[skills]\npath = \"skills\"\n", |
| 71 | ) |
| 72 | .unwrap(); |
| 73 | fs::write( |
| 74 | bundle.join("skills/hello/SKILL.md"), |
| 75 | "---\nname: hello\ndescription: hello\n---\nbody\n", |
| 76 | ) |
| 77 | .unwrap(); |
| 78 | } |
| 79 | |
| 80 | fn marketplace_state_path(codewhale_home: &Path) -> std::path::PathBuf { |
| 81 | codewhale_home.join("plugins/marketplaces.json") |
| 82 | } |
| 83 | |
| 84 | #[test] |
| 85 | fn marketplace_builtin_candidate_routes_to_existing_bundle_review() { |
| 86 | let _lock = crate::test_support::lock_test_env(); |
| 87 | let root = TempDir::new().unwrap(); |
| 88 | let codewhale_home = root.path().join("home"); |
| 89 | fs::create_dir_all(&codewhale_home).unwrap(); |
| 90 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 91 | let (mut app, _temp) = create_test_app(root.path()); |
| 92 | let original = app |
| 93 | .plugin_registry |
| 94 | .get("computer-use") |
| 95 | .expect("shipped bundle") |
| 96 | .clone(); |
| 97 | let result = |
| 98 | plugins_with_kimi_home_override(&mut app, Some("marketplace show codewhale"), None); |
| 99 | assert!(!result.is_error); |
| 100 | let text = result.message.unwrap(); |
| 101 | assert!( |
| 102 | !text.contains(r"/plugin marketplace install codewhale computer\-use"), |
| 103 | "{text}" |
| 104 | ); |
| 105 | assert!( |
| 106 | text.contains(&format!( |
| 107 | "/plugin show {}", |
| 108 | escape_review_text(original.id.as_str()) |
| 109 | )), |
| 110 | "{text}" |
| 111 | ); |
| 112 | assert!( |
| 113 | text.contains("/plugin marketplace install codewhale whalewiki"), |
| 114 | "{text}" |
| 115 | ); |
| 116 | let result = plugins_with_kimi_home_override( |
| 117 | &mut app, |
| 118 | Some("marketplace install codewhale computer-use"), |
| 119 | None, |
| 120 | ); |
| 121 | assert!(result.is_error); |
| 122 | assert!(result.message.unwrap().contains("already exists")); |
| 123 | let retained = app.plugin_registry.get("computer-use").unwrap(); |
| 124 | assert_eq!(retained.content_hash, original.content_hash); |
| 125 | assert_eq!(retained.trust_status, original.trust_status); |
| 126 | assert_eq!(retained.enabled, original.enabled); |
| 127 | assert!(!codewhale_home.join("plugins/computer-use").exists()); |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn marketplace_add_list_show_remove_roundtrip() { |
| 132 | let _lock = crate::test_support::lock_test_env(); |
| 133 | let root = TempDir::new().unwrap(); |
| 134 | let codewhale_home = root.path().join("home"); |
| 135 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 136 | let (mut app, _temp) = create_test_app(root.path()); |
| 137 | let catalogs = root.path().join("catalogs"); |
| 138 | fs::create_dir_all(&catalogs).unwrap(); |
| 139 | let catalog_path = write_kimi_catalog(&catalogs); |
| 140 | |
| 141 | // Usage errors are honest before anything is touched. |
| 142 | assert!(!plugins_with_kimi_home_override(&mut app, Some("marketplace"), None).is_error); // list, empty |
| 143 | assert!(plugins_with_kimi_home_override(&mut app, Some("marketplace add"), None).is_error); |
| 144 | assert!( |
| 145 | plugins_with_kimi_home_override(&mut app, Some("marketplace add 'bad name' x"), None) |
| 146 | .is_error |
| 147 | ); |
| 148 | |
| 149 | let added = plugins_with_kimi_home_override( |
| 150 | &mut app, |
| 151 | Some(&format!("marketplace add kimi {}", catalog_path.display())), |
| 152 | None, |
| 153 | ); |
| 154 | assert!(!added.is_error, "{:?}", added.message); |
| 155 | let message = added.message.unwrap(); |
| 156 | assert!(message.contains("Added marketplace `kimi`"), "{message}"); |
| 157 | assert!(message.contains("2 candidate(s)"), "{message}"); |
| 158 | assert!(message.contains("display-only"), "{message}"); |
| 159 | assert!(marketplace_state_path(&codewhale_home).exists()); |
| 160 | |
| 161 | let list = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None) |
| 162 | .message |
| 163 | .unwrap(); |
| 164 | eprintln!("LIST2 >>>{list}<<<"); |
| 165 | assert!(list.contains("`kimi`"), "{list}"); |
| 166 | assert!(list.contains(r"demo\-bundle"), "{list}"); |
| 167 | assert!(list.contains(r"remote\-thing"), "{list}"); |
| 168 | assert!(list.contains("tier=official"), "{list}"); |
| 169 | assert!(list.contains("tier=curated"), "{list}"); |
| 170 | |
| 171 | // Stored plans keep stable codes; rendering resolves the current locale. |
| 172 | app.ui_locale = Locale::Es419; |
| 173 | let localized = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None) |
| 174 | .message |
| 175 | .unwrap(); |
| 176 | assert!(localized.contains("no admite paquetes ZIP"), "{localized}"); |
| 177 | assert!(!localized.contains("kimi_zip_unsupported"), "{localized}"); |
| 178 | assert!( |
| 179 | !localized.contains("ZIP bundles are not supported"), |
| 180 | "{localized}" |
| 181 | ); |
| 182 | app.ui_locale = Locale::En; |
| 183 | |
| 184 | let show = plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None) |
| 185 | .message |
| 186 | .unwrap(); |
| 187 | assert!(show.contains("Demo Bundle"), "{show}"); |
| 188 | assert!(show.contains(r"v1\.2\.3"), "{show}"); |
| 189 | assert!(show.contains("catalogs"), "{show}"); |
| 190 | |
| 191 | // read-only verbs never rewrite the store |
| 192 | let before = fs::read_to_string(marketplace_state_path(&codewhale_home)).unwrap(); |
| 193 | plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None); |
| 194 | plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None); |
| 195 | let after = fs::read_to_string(marketplace_state_path(&codewhale_home)).unwrap(); |
| 196 | assert_eq!( |
| 197 | before, after, |
| 198 | "list/show must not rewrite marketplace state" |
| 199 | ); |
| 200 | |
| 201 | // Re-adding the same source is a refresh, not a duplicate: a catalog is |
| 202 | // keyed by its document, so the second add updates it in place. (The old |
| 203 | // refusal here is what produced a second snapshot of one marketplace |
| 204 | // under a hand-made name.) |
| 205 | let refreshed = plugins_with_kimi_home_override( |
| 206 | &mut app, |
| 207 | Some(&format!("marketplace add kimi {}", catalog_path.display())), |
| 208 | None, |
| 209 | ); |
| 210 | assert!(!refreshed.is_error, "{:?}", refreshed.message); |
| 211 | // A *different* source under the same name is still refused. |
| 212 | let other_catalog = catalogs.join("other-marketplace.json"); |
| 213 | fs::write( |
| 214 | &other_catalog, |
| 215 | serde_json::to_string_pretty(&serde_json::json!({ |
| 216 | "version": "2", |
| 217 | "plugins": [ |
| 218 | { |
| 219 | "id": "other-bundle", |
| 220 | "source": "./other-bundle", |
| 221 | "displayName": "Other Bundle" |
| 222 | } |
| 223 | ] |
| 224 | })) |
| 225 | .unwrap(), |
| 226 | ) |
| 227 | .unwrap(); |
| 228 | let clash = plugins_with_kimi_home_override( |
| 229 | &mut app, |
| 230 | Some(&format!("marketplace add kimi {}", other_catalog.display())), |
| 231 | None, |
| 232 | ); |
| 233 | assert!(clash.is_error, "{:?}", clash.message); |
| 234 | |
| 235 | let removed = plugins_with_kimi_home_override(&mut app, Some("marketplace remove kimi"), None); |
| 236 | assert!(!removed.is_error, "{:?}", removed.message); |
| 237 | assert!( |
| 238 | removed |
| 239 | .message |
| 240 | .unwrap() |
| 241 | .contains("Installed plugins and their trust state are unaffected") |
| 242 | ); |
| 243 | assert!( |
| 244 | plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None).is_error |
| 245 | ); |
| 246 | let empty = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None) |
| 247 | .message |
| 248 | .unwrap(); |
| 249 | assert!( |
| 250 | empty.contains("codewhale") && empty.contains("whalewiki"), |
| 251 | "{empty}" |
| 252 | ); |
| 253 | } |
| 254 | |
| 255 | #[test] |
| 256 | fn plugin_suggest_preserves_current_main_marketplace_candidates() { |
| 257 | let _lock = crate::test_support::lock_test_env(); |
| 258 | let root = TempDir::new().unwrap(); |
| 259 | let codewhale_home = root.path().join("home"); |
| 260 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 261 | let (mut app, _temp) = create_test_app(root.path()); |
| 262 | let catalogs = root.path().join("catalogs"); |
| 263 | fs::create_dir_all(&catalogs).unwrap(); |
| 264 | let catalog_path = write_kimi_catalog(&catalogs); |
| 265 | |
| 266 | let added = plugins_with_kimi_home_override( |
| 267 | &mut app, |
| 268 | Some(&format!("marketplace add kimi {}", catalog_path.display())), |
| 269 | None, |
| 270 | ); |
| 271 | assert!(!added.is_error, "{:?}", added.message); |
| 272 | |
| 273 | let suggested = plugins_with_kimi_home_override(&mut app, Some("suggest demo"), None); |
| 274 | assert!(!suggested.is_error, "{suggested:?}"); |
| 275 | let message = suggested.message.expect("suggestion message"); |
| 276 | assert!(message.contains("Suggested plugins"), "{message}"); |
| 277 | assert!( |
| 278 | message.contains(r"demo\-bundle — not installed"), |
| 279 | "{message}" |
| 280 | ); |
| 281 | assert!( |
| 282 | message.contains("/plugin marketplace install kimi demo-bundle"), |
| 283 | "{message}" |
| 284 | ); |
| 285 | assert!(message.contains("Nothing was installed, trusted, or enabled.")); |
| 286 | assert!(app.plugin_registry.get("demo-bundle").is_none()); |
| 287 | } |
| 288 | |
| 289 | #[test] |
| 290 | fn marketplace_add_rejects_symlinks_and_bad_documents() { |
| 291 | let _lock = crate::test_support::lock_test_env(); |
| 292 | let root = TempDir::new().unwrap(); |
| 293 | let codewhale_home = root.path().join("home"); |
| 294 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 295 | let (mut app, _temp) = create_test_app(root.path()); |
| 296 | let catalogs = root.path().join("catalogs"); |
| 297 | fs::create_dir_all(&catalogs).unwrap(); |
| 298 | let catalog_path = write_kimi_catalog(&catalogs); |
| 299 | |
| 300 | // missing file |
| 301 | let missing = plugins_with_kimi_home_override( |
| 302 | &mut app, |
| 303 | Some("marketplace add nope /nonexistent/x.json"), |
| 304 | None, |
| 305 | ); |
| 306 | assert!(missing.is_error); |
| 307 | |
| 308 | // symlink to a real catalog is refused, not followed |
| 309 | let link = catalogs.join("link.json"); |
| 310 | #[cfg(unix)] |
| 311 | std::os::unix::fs::symlink(&catalog_path, &link).unwrap(); |
| 312 | #[cfg(not(unix))] |
| 313 | fs::copy(&catalog_path, &link).unwrap(); |
| 314 | #[cfg(unix)] |
| 315 | { |
| 316 | let symlinked = plugins_with_kimi_home_override( |
| 317 | &mut app, |
| 318 | Some(&format!("marketplace add evil {}", link.display())), |
| 319 | None, |
| 320 | ); |
| 321 | assert!(symlinked.is_error); |
| 322 | assert!(symlinked.message.unwrap().contains("symlink")); |
| 323 | } |
| 324 | |
| 325 | // a document with no documented markers fails honestly and is not stored |
| 326 | let junk = catalogs.join("junk.json"); |
| 327 | fs::write(&junk, "{\"hello\": \"world\"}").unwrap(); |
| 328 | let bad = plugins_with_kimi_home_override( |
| 329 | &mut app, |
| 330 | Some(&format!("marketplace add junk {}", junk.display())), |
| 331 | None, |
| 332 | ); |
| 333 | assert!(bad.is_error); |
| 334 | assert!(bad.message.unwrap().contains("could not be parsed")); |
| 335 | assert!( |
| 336 | plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None) |
| 337 | .message |
| 338 | .unwrap() |
| 339 | .contains("codewhale") |
| 340 | ); |
| 341 | |
| 342 | // corrupt stored state fails closed and is never rewritten |
| 343 | let store_path = marketplace_state_path(&codewhale_home); |
| 344 | fs::create_dir_all(store_path.parent().unwrap()).unwrap(); |
| 345 | fs::write(&store_path, "{ not json").unwrap(); |
| 346 | let corrupt = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None); |
| 347 | assert!(corrupt.is_error); |
| 348 | assert!(corrupt.message.unwrap().contains("fail-closed")); |
| 349 | assert_eq!(fs::read_to_string(&store_path).unwrap(), "{ not json"); |
| 350 | } |
| 351 | |
| 352 | #[test] |
| 353 | fn marketplace_install_routes_through_reviewed_installer() { |
| 354 | let _lock = crate::test_support::lock_test_env(); |
| 355 | let root = TempDir::new().unwrap(); |
| 356 | let codewhale_home = root.path().join("home"); |
| 357 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 358 | let (mut app, _temp) = create_test_app(root.path()); |
| 359 | let catalogs = root.path().join("catalogs"); |
| 360 | fs::create_dir_all(&catalogs).unwrap(); |
| 361 | write_kimi_catalog(&catalogs); |
| 362 | write_demo_bundle(&catalogs); |
| 363 | assert!( |
| 364 | !plugins_with_kimi_home_override( |
| 365 | &mut app, |
| 366 | Some("marketplace add kimi catalogs/kimi-marketplace.json"), |
| 367 | None |
| 368 | ) |
| 369 | .is_error |
| 370 | ); |
| 371 | |
| 372 | // The unsupported plan is refused before any runtime or network work. |
| 373 | let remote = plugins_with_kimi_home_override( |
| 374 | &mut app, |
| 375 | Some("marketplace install kimi remote-thing"), |
| 376 | None, |
| 377 | ); |
| 378 | assert!(remote.is_error); |
| 379 | assert!(remote.message.unwrap().contains("cannot be installed")); |
| 380 | |
| 381 | let runtime = tokio::runtime::Builder::new_multi_thread() |
| 382 | .worker_threads(2) |
| 383 | .enable_all() |
| 384 | .build() |
| 385 | .unwrap(); |
| 386 | runtime.block_on(async { |
| 387 | let installed = plugins_with_kimi_home_override( |
| 388 | &mut app, |
| 389 | Some("marketplace install kimi demo-bundle"), |
| 390 | None, |
| 391 | ); |
| 392 | assert!(!installed.is_error, "{:?}", installed.message); |
| 393 | let message = installed.message.unwrap(); |
| 394 | assert!(message.contains("disabled and untrusted"), "{message}"); |
| 395 | assert!( |
| 396 | message |
| 397 | .lines() |
| 398 | .any(|line| line.starts_with("/plugin trust demo-bundle ")), |
| 399 | "marketplace install must route into the trust review:\n{message}" |
| 400 | ); |
| 401 | |
| 402 | // No auto-trust, no auto-enable, no inherited vendor trust. |
| 403 | let plugin = app.plugin_registry.get("demo-bundle").unwrap(); |
| 404 | assert!(!plugin.enabled); |
| 405 | assert!(!plugin.trusted()); |
| 406 | assert!( |
| 407 | codewhale_home |
| 408 | .join("plugins/demo-bundle/.installed-from") |
| 409 | .exists() |
| 410 | ); |
| 411 | }); |
| 412 | } |
| 413 | |
| 414 | /// A Codex catalog whose entry declares `INSTALLED_BY_DEFAULT`: the policy is |
| 415 | /// visible but nothing is auto-installed, and the npm source stays honestly |
| 416 | /// unsupported. |
| 417 | #[test] |
| 418 | fn marketplace_codex_installed_by_default_never_auto_installs() { |
| 419 | let _lock = crate::test_support::lock_test_env(); |
| 420 | let root = TempDir::new().unwrap(); |
| 421 | let codewhale_home = root.path().join("home"); |
| 422 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); |
| 423 | let (mut app, _temp) = create_test_app(root.path()); |
| 424 | let catalogs = root.path().join("catalogs"); |
| 425 | fs::create_dir_all(&catalogs).unwrap(); |
| 426 | let codex = serde_json::json!({ |
| 427 | "name": "codex-catalog", |
| 428 | "plugins": [ |
| 429 | { |
| 430 | "name": "defaulted-thing", |
| 431 | "source": { "source": "npm", "package": "@scope/defaulted-thing" }, |
| 432 | "policy": { "installation": "INSTALLED_BY_DEFAULT" } |
| 433 | } |
| 434 | ] |
| 435 | }); |
| 436 | let path = catalogs.join("codex-marketplace.json"); |
| 437 | fs::write(&path, serde_json::to_string_pretty(&codex).unwrap()).unwrap(); |
| 438 | |
| 439 | let added = plugins_with_kimi_home_override( |
| 440 | &mut app, |
| 441 | Some(&format!("marketplace add codex {}", path.display())), |
| 442 | None, |
| 443 | ); |
| 444 | assert!(!added.is_error, "{:?}", added.message); |
| 445 | |
| 446 | let list = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None) |
| 447 | .message |
| 448 | .unwrap(); |
| 449 | assert!(list.contains(r"defaulted\-thing"), "{list}"); |
| 450 | assert!(list.contains("not installable"), "{list}"); |
| 451 | assert!(list.contains("npm"), "{list}"); |
| 452 | // Foreign auto-install policy never ran anything: no bundle on disk. |
| 453 | assert!(!codewhale_home.join("plugins/defaulted-thing").exists()); |
| 454 | assert!(app.plugin_registry.get("defaulted-thing").is_none()); |
| 455 | } |
| 456 |