返回 CodeWhale
tests.rs
根目录 / crates / tui / src / commands / groups / plugins / tests.rs
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
704 lines RUST