返回 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 #[test]
12 fn extension_owner_report_escapes_every_plugin_controlled_field() {
13 struct Presentation(Locale);
14 impl CommandPresentationContext for Presentation {
15 fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
16 let id = crate::commands::contract::key_to_plugin_message_id(key).unwrap();
17 let mut output = codewhale_localization::tr(self.0, id).to_string();
18 for (name, value) in replacements {
19 output = output.replace(&format!("{{{name}}}"), value);
20 }
21 Ok(output)
22 }
23 }
24 let mut output = String::new();
25 append_host_owner_report(
26 &Presentation(Locale::En),
27 &mut output,
28 &crate::extension_host::OwnerReport {
29 state: Some(crate::extension_host::registry::OwnerState::Failed(
30 "\u{1b}[31m<script>".into(),
31 )),
32 tools: vec!["[tool](https://example.invalid)".into()],
33 diagnostics: vec!["\n# approved\u{202e}".into()],
34 },
35 );
36 assert!(output.contains("Extension host:"));
37 for value in [
38 "\u{1b}[31m<script>",
39 "[tool](https://example.invalid)",
40 "\n# approved\u{202e}",
41 ] {
42 assert!(output.contains(&escape_review_text(value)));
43 assert!(!output.contains(value));
44 }
45 let mut localized = String::new();
46 append_host_owner_report(
47 &Presentation(Locale::ZhHans),
48 &mut localized,
49 &crate::extension_host::OwnerReport {
50 state: None,
51 tools: vec![],
52 diagnostics: vec![],
53 },
54 );
55 assert_eq!(
56 localized,
57 "\n扩展宿主:\n 状态:未激活\n 活动工具(0):—"
58 );
59 }
60
61 #[test]
62 fn plugin_config_summary_lists_keys_never_values_and_escapes_them() {
63 let mut output = String::new();
64 append_plugin_config_summary(
65 &mut output,
66 "greeter",
67 &Ok(vec!["greeting".to_string(), "\u{1b}[31mkey".to_string()]),
68 );
69 assert!(output.contains("[plugins.\"greeter\".config]"), "{output}");
70 assert!(output.contains("values not shown"));
71 assert!(output.contains("greeting"));
72 assert!(output.contains(&escape_review_text("\u{1b}[31mkey")));
73 assert!(!output.contains('\u{1b}'));
74
75 let mut refused = String::new();
76 append_plugin_config_summary(
77 &mut refused,
78 "greeter",
79 &Err("config is 20000 bytes\n# approved".to_string()),
80 );
81 assert!(refused.contains("is refused: "), "{refused}");
82 assert!(!refused.contains("\n# approved"), "{refused}");
83 }
84
85 fn create_test_app(root: &Path) -> (App, TempDir) {
86 let temp = TempDir::new().expect("tempdir");
87 let config_path = temp.path().join("config.toml");
88 let tools_dir = root.join("tools");
89 fs::create_dir_all(&tools_dir).unwrap();
90 fs::write(
91 &config_path,
92 format!(
93 "[tools]\nplugin_dir = {}\n",
94 toml::Value::String(tools_dir.to_string_lossy().to_string())
95 ),
96 )
97 .unwrap();
98 let options = TuiOptions {
99 config_path: Some(config_path),
100 skills_dir: temp.path().join("skills"),
101 memory_path: temp.path().join("memory.md"),
102 notes_path: temp.path().join("notes.txt"),
103 mcp_config_path: temp.path().join("mcp.json"),
104 ..crate::test_support::test_tui_options(root)
105 };
106 let config = Config {
107 tools: Some(crate::config::ToolsConfig {
108 plugin_dir: Some(tools_dir.to_string_lossy().into_owned()),
109 ..Default::default()
110 }),
111 ..Default::default()
112 };
113 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
114 let registry = discovery.registry_for_workspace(root);
115 let mut app = App::new_with_plugin_registry(options, &config, registry);
116 app.ui_locale = Locale::En;
117 (app, temp)
118 }
119
120 fn write_bundle(root: &Path) {
121 let bundle = root.join(".codewhale/plugins/demo");
122 fs::create_dir_all(bundle.join("skills/hello")).unwrap();
123 fs::write(
124 bundle.join("plugin.toml"),
125 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\ndescription = \"Import spreadsheet data safely\"\n[skills]\npath = \"skills\"\n",
126 )
127 .unwrap();
128 fs::write(
129 bundle.join("skills/hello/SKILL.md"),
130 "---\nname: hello\ndescription: hello\n---\nbody\n",
131 )
132 .unwrap();
133 }
134
135 fn write_mcp_review_bundle(root: &Path) {
136 let bundle = root.join(".codewhale/plugins/review-mcp");
137 fs::create_dir_all(&bundle).unwrap();
138 fs::write(bundle.join("server.js"), "// reviewed entrypoint\n").unwrap();
139 fs::write(
140 bundle.join("plugin.toml"),
141 r#"schema_version = 1
142 [plugin]
143 name = "review-mcp"
144 version = "1.0.0"
145
146 [mcp_servers.local]
147 command = "node"
148 args = ["server.js", "--mode=worker", "-e", "console.log('ready')"]
149
150 [mcp_servers.local.env]
151 PLUGIN_TOKEN = "${PLUGIN_TOKEN_SOURCE}"
152
153 [mcp_servers.remote]
154 url = "https://example.invalid/mcp"
155 bearer_token_env_var = "REMOTE_TOKEN"
156
157 [mcp_servers.remote.env_headers]
158 X_Api_Key = "REMOTE_API_KEY"
159
160 [capabilities]
161 network_hosts = ["example.invalid"]
162 "#,
163 )
164 .unwrap();
165 }
166
167 #[test]
168 fn bare_plugin_command_opens_unified_extensions_modal() {
169 let _lock = crate::test_support::lock_test_env();
170 let root = TempDir::new().unwrap();
171 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
172 let (mut app, _temp) = create_test_app(root.path());
173
174 let result = plugins_with_kimi_home_override(&mut app, None, None);
175
176 assert!(matches!(
177 result.action,
178 Some(AppAction::OpenExtensions {
179 tab: crate::tui::views::extensions::ExtensionsTab::Plugins
180 })
181 ));
182 assert!(result.message.is_none());
183 }
184
185 #[test]
186 fn list_show_validate_are_read_only_and_label_legacy_tools() {
187 let _lock = crate::test_support::lock_test_env();
188 let root = TempDir::new().unwrap();
189 let codewhale_home = root.path().join("home");
190 // A configured user has a home; that is the state in which the built-in
191 // bundle is materialized and listed.
192 fs::create_dir_all(&codewhale_home).unwrap();
193 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
194 write_bundle(root.path());
195 let (mut app, _temp) = create_test_app(root.path());
196 fs::write(
197 root.path().join("tools/greet.sh"),
198 "# name: greet\n# description: hello\n",
199 )
200 .unwrap();
201 // The app already resolved the legacy tools path during startup.
202 // Read-only plugin commands must not reopen a credential-bearing
203 // config file merely to inventory those tools.
204 fs::write(
205 app.config_path.as_ref().unwrap(),
206 "api_key = [\"must-not-be-re-read\"\n",
207 )
208 .unwrap();
209 let state_path = codewhale_home.join("plugins/state.json");
210
211 for arg in [Some("list"), Some("show demo"), Some("validate")] {
212 let result = plugins_with_kimi_home_override(&mut app, arg, None);
213 assert!(!result.is_error, "{:?}", result.message);
214 assert!(!state_path.exists(), "read-only command wrote plugin state");
215 }
216 // PR #5865's call shape, with main's assertions: the workspace bundle plus
217 // the built-in computer-use bundle, which every binary now carries and
218 // which lists disabled until it is reviewed.
219 let list = plugins_with_kimi_home_override(&mut app, Some("list"), None)
220 .message
221 .unwrap();
222 assert!(list.contains("Plugin bundles (2)"), "{list}");
223 // The renderer escapes markdown, so the hyphen arrives backslashed.
224 assert!(list.contains(r"computer\-use"), "{list}");
225 assert!(list.contains("builtin · not-reviewed"), "{list}");
226 assert!(list.contains("disabled"));
227 assert!(list.contains("Legacy plugin tools (1)"));
228 }
229
230 #[test]
231 fn list_preserves_the_one_shot_on_disk_reload_nudge() {
232 let _lock = crate::test_support::lock_test_env();
233 let root = TempDir::new().unwrap();
234 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
235 let (mut app, _temp) = create_test_app(root.path());
236
237 // Mutate the on-disk catalog after discovery. Listing must report the
238 // current-main nudge without rediscovering or changing trust state.
239 write_bundle(root.path());
240 let first = plugins_with_kimi_home_override(&mut app, Some("list"), None)
241 .message
242 .unwrap();
243 assert!(
244 first.contains(crate::plugins::PLUGIN_RELOAD_NUDGE),
245 "{first}"
246 );
247
248 let second = plugins_with_kimi_home_override(&mut app, Some("list"), None)
249 .message
250 .unwrap();
251 assert!(
252 !second.contains(crate::plugins::PLUGIN_RELOAD_NUDGE),
253 "nudge must appear once per catalog stamp: {second}"
254 );
255 }
256
257 #[test]
258 fn suggest_ranks_installed_plugins_without_trusting_or_enabling_them() {
259 let _lock = crate::test_support::lock_test_env();
260 let root = TempDir::new().unwrap();
261 let codewhale_home = root.path().join("home");
262 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
263 write_bundle(root.path());
264 let (mut app, _temp) = create_test_app(root.path());
265
266 for arg in ["suggest", "suggest go"] {
267 let result = plugins_with_kimi_home_override(&mut app, Some(arg), None);
268 assert!(
269 result.is_error,
270 "expected usage error for {arg}: {result:?}"
271 );
272 }
273
274 let result =
275 plugins_with_kimi_home_override(&mut app, Some("suggest spreadsheet import"), None);
276 assert!(!result.is_error, "{result:?}");
277 let message = result.message.expect("suggestion message");
278 assert!(message.contains("Suggested plugins"), "{message}");
279 assert!(message.contains("demo — disabled"), "{message}");
280 assert!(message.contains("Why:"), "{message}");
281 assert!(message.contains("/plugin trust demo"), "{message}");
282 assert!(
283 message.contains("spreadsheet") || message.contains("import"),
284 "{message}"
285 );
286 assert!(message.contains("Nothing was installed, trusted, or enabled."));
287 assert!(!codewhale_home.join("plugins/state.json").exists());
288 let plugin = app.plugin_registry.get("demo").expect("demo plugin");
289 assert!(!plugin.enabled && !plugin.trusted());
290 }
291
292 #[test]
293 fn suggest_matches_manifest_keywords_for_a_named_integration() {
294 let _lock = crate::test_support::lock_test_env();
295 let root = TempDir::new().unwrap();
296 let codewhale_home = root.path().join("home");
297 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
298 let bundle = root.path().join(".codewhale/plugins/supabase");
299 fs::create_dir_all(&bundle).unwrap();
300 fs::write(
301 bundle.join("plugin.toml"),
302 "schema_version = 1\n[plugin]\nname = \"supabase\"\nversion = \"1.0.0\"\ndescription = \"Hosted Postgres and auth\"\nkeywords = [\"supabase\", \"postgres\"]\n",
303 )
304 .unwrap();
305 let (mut app, _temp) = create_test_app(root.path());
306
307 let result = plugins_with_kimi_home_override(&mut app, Some("suggest add supabase auth"), None);
308 assert!(!result.is_error, "{result:?}");
309 let message = result.message.expect("suggestion message");
310 assert!(message.contains("supabase"), "{message}");
311 assert!(message.contains("/plugin trust supabase"), "{message}");
312 assert!(message.contains("Nothing was installed, trusted, or enabled."));
313 }
314
315 #[test]
316 fn trust_requires_content_and_capability_bound_review_token() {
317 let _lock = crate::test_support::lock_test_env();
318 let root = TempDir::new().unwrap();
319 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
320 write_bundle(root.path());
321 let (mut app, _temp) = create_test_app(root.path());
322 let enable_review = plugins_with_kimi_home_override(&mut app, Some("enable demo"), None);
323 assert!(!enable_review.is_error);
324 assert!(
325 enable_review
326 .message
327 .as_deref()
328 .is_some_and(|message| message.contains("/plugin trust demo "))
329 );
330 assert!(!app.plugin_registry.get("demo").unwrap().trusted());
331
332 let review_result = plugins_with_kimi_home_override(&mut app, Some("trust demo"), None);
333 let Some(AppAction::OpenCommandReview {
334 content, command, ..
335 }) = review_result.action
336 else {
337 panic!("trust opens a confirmation control");
338 };
339 assert!(
340 !content.contains("/plugin trust demo "),
341 "the hash belongs to the control"
342 );
343 let review = review_result.message.unwrap();
344 let confirmation = review
345 .lines()
346 .find(|line| line.starts_with("/plugin trust demo "))
347 .unwrap();
348 assert_eq!(confirmation, command);
349 let token = confirmation
350 .split_whitespace()
351 .last()
352 .expect("review confirmation token");
353 let (content_digest, capability_digest) = token
354 .split_once('.')
355 .expect("content and capability digests");
356 assert_eq!(content_digest.len(), 64);
357 assert_eq!(capability_digest.len(), 64);
358 assert!(content_digest.bytes().all(|byte| byte.is_ascii_hexdigit()));
359 assert!(
360 capability_digest
361 .bytes()
362 .all(|byte| byte.is_ascii_hexdigit())
363 );
364 assert!(!app.plugin_registry.get("demo").unwrap().trusted());
365
366 assert!(plugins_with_kimi_home_override(&mut app, Some("trust demo wrong"), None).is_error);
367 let shortened = format!(
368 "trust demo {}.{}",
369 &content_digest[..12],
370 &capability_digest[..12]
371 );
372 assert!(
373 plugins_with_kimi_home_override(&mut app, Some(&shortened), None).is_error,
374 "the legacy 48-bit content prefix must not authorize trust"
375 );
376 let arg = confirmation.trim_start_matches("/plugin ");
377 assert!(!plugins_with_kimi_home_override(&mut app, Some(arg), None).is_error);
378 assert!(!plugins_with_kimi_home_override(&mut app, Some("enable demo"), None).is_error);
379 assert!(app.plugin_registry.is_active("demo"));
380 assert!(!plugins_with_kimi_home_override(&mut app, Some("disable demo"), None).is_error);
381 assert!(!app.plugin_registry.is_active("demo"));
382 }
383
384 fn write_mixed_bundle(root: &Path) {
385 let bundle = root.join(".codewhale/plugins/mixed");
386 fs::create_dir_all(bundle.join("skills/hello")).unwrap();
387 fs::create_dir_all(bundle.join("commands")).unwrap();
388 fs::create_dir_all(bundle.join("hooks")).unwrap();
389 fs::create_dir_all(bundle.join("lsp")).unwrap();
390 fs::write(
391 bundle.join("plugin.toml"),
392 "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",
393 )
394 .unwrap();
395 fs::write(
396 bundle.join("skills/hello/SKILL.md"),
397 "---\nname: hello\ndescription: hello\n---\nbody\n",
398 )
399 .unwrap();
400 }
401
402 #[test]
403 fn mixed_bundle_review_and_enable_keep_supported_components_active() {
404 let _lock = crate::test_support::lock_test_env();
405 let root = TempDir::new().unwrap();
406 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
407 write_mixed_bundle(root.path());
408 let (mut app, _temp) = create_test_app(root.path());
409
410 let list = plugins_with_kimi_home_override(&mut app, Some("list"), None)
411 .message
412 .unwrap();
413 assert!(list.contains("compatibility=partial"), "{list}");
414 assert!(list.contains("commands=1"), "{list}");
415 assert!(list.contains("hooks=1"), "{list}");
416
417 let show = plugins_with_kimi_home_override(&mut app, Some("show mixed"), None)
418 .message
419 .unwrap();
420 assert!(show.contains("Compatibility: partial"), "{show}");
421 assert!(show.contains("Inactive components: [lsp]"), "{show}");
422 assert!(show.contains("Active components: [none]"), "{show}");
423
424 let review = plugins_with_kimi_home_override(&mut app, Some("trust mixed"), None)
425 .message
426 .unwrap();
427 let confirmation = review
428 .lines()
429 .find(|line| line.starts_with("/plugin trust mixed "))
430 .unwrap();
431 let arg = confirmation.trim_start_matches("/plugin ");
432 assert!(!plugins_with_kimi_home_override(&mut app, Some(arg), None).is_error);
433 let enabled = plugins_with_kimi_home_override(&mut app, Some("enable mixed"), None);
434 assert!(!enabled.is_error, "{:?}", enabled.message);
435 let message = enabled.message.unwrap();
436 assert!(message.contains("Compatibility: partial"), "{message}");
437 assert!(message.contains("inactive: lsp"), "{message}");
438 assert!(app.plugin_registry.is_active("mixed"));
439 assert_eq!(
440 app.plugin_registry
441 .get("mixed")
442 .unwrap()
443 .compatibility()
444 .as_str(),
445 "partial"
446 );
447
448 let show = plugins_with_kimi_home_override(&mut app, Some("show mixed"), None)
449 .message
450 .unwrap();
451 assert!(show.contains("State: active"), "{show}");
452 assert!(show.contains("Inactive components: [lsp]"), "{show}");
453 assert!(
454 show.contains("Active components: [skills, commands, hooks]"),
455 "{show}"
456 );
457 assert!(show.contains("Qualified skills: [mixed:hello]"), "{show}");
458 }
459
460 #[test]
461 fn mcp_review_discloses_host_authority_and_names_without_secret_values() {
462 let _lock = crate::test_support::lock_test_env();
463 let root = TempDir::new().unwrap();
464 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
465 write_mcp_review_bundle(root.path());
466 let (mut app, _temp) = create_test_app(root.path());
467 let review = plugins_with_kimi_home_override(&mut app, Some("trust review-mcp"), None)
468 .message
469 .expect("review output");
470 assert!(review.contains("mcp=2 (stdio=1 remote=1)"));
471 assert!(review.contains("host-user filesystem/network authority"));
472 assert!(review.contains("PLUGIN\\_TOKEN <- PLUGIN\\_TOKEN\\_SOURCE"));
473 assert!(review.contains("X\\_Api\\_Key <- REMOTE\\_API\\_KEY"));
474 assert!(review.contains("bearer_env=REMOTE\\_TOKEN"));
475 assert!(review.contains("redirects=same-origin-only"));
476 assert!(review.contains("Qualified skills: [none]"));
477 assert!(review.contains("#2 value=\"--mode=worker\""));
478 assert!(review.contains("#3 value=\"-e\""));
479 assert!(review.contains("#4 value=\"console.log('ready')\""));
480 assert!(review.contains("oauth=disabled"));
481 }
482
483 #[test]
484 fn legacy_tool_detail_remains_available_under_tools_namespace() {
485 let _lock = crate::test_support::lock_test_env();
486 let root = TempDir::new().unwrap();
487 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
488 let (mut app, _temp) = create_test_app(root.path());
489 fs::write(
490 root.path().join("tools/greet.sh"),
491 "# name: greet\n# description: Say hello\n# approval: required\n",
492 )
493 .unwrap();
494 let result = plugins_with_kimi_home_override(&mut app, Some("tools greet"), None);
495 assert!(!result.is_error);
496 let message = result.message.unwrap();
497 assert!(message.contains("Say hello"));
498 assert!(message.contains("required"));
499 }
500
501 /// D4: `/plugin tools` names a script whose `approval: auto` was ignored and
502 /// shows the approval it actually runs with.
503 #[test]
504 fn legacy_tools_report_ignored_auto_approval() {
505 let _lock = crate::test_support::lock_test_env();
506 let root = TempDir::new().unwrap();
507 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
508 let (mut app, _temp) = create_test_app(root.path());
509 fs::write(
510 root.path().join("tools/greet.sh"),
511 "# name: greet\n# description: Say hello\n# approval: auto\n",
512 )
513 .unwrap();
514 fs::write(
515 root.path().join("tools/audit.sh"),
516 "# name: audit\n# description: Audit\n# approval: required\n",
517 )
518 .unwrap();
519 // The renderer escapes Markdown in plugin-controlled text.
520 let warning = "[script_tool_auto_approval_ignored]: script tool 'greet': \\`approval: auto\\` is no longer supported for script tools";
521
522 let list = plugins_with_kimi_home_override(&mut app, Some("tools"), None)
523 .message
524 .unwrap();
525 assert!(list.contains(warning), "{list}");
526 assert!(!list.contains("script tool 'audit'"), "{list}");
527
528 let detail = plugins_with_kimi_home_override(&mut app, Some("tools greet"), None)
529 .message
530 .unwrap();
531 assert!(detail.contains("suggest"), "{detail}");
532 assert!(detail.contains(warning), "{detail}");
533 let other = plugins_with_kimi_home_override(&mut app, Some("tools audit"), None)
534 .message
535 .unwrap();
536 assert!(
537 !other.contains("script_tool_auto_approval_ignored"),
538 "{other}"
539 );
540 }
541
542 #[test]
543 fn install_update_uninstall_verbs_validate_arguments() {
544 let _lock = crate::test_support::lock_test_env();
545 let root = TempDir::new().unwrap();
546 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
547 let (mut app, _temp) = create_test_app(root.path());
548 for arg in ["install", "update", "uninstall"] {
549 let result = plugins_with_kimi_home_override(&mut app, Some(arg), None);
550 assert!(result.is_error, "bare `{arg}` must print usage");
551 }
552 let invalid = plugins_with_kimi_home_override(&mut app, Some("install github:"), None);
553 assert!(invalid.is_error);
554 assert!(
555 invalid
556 .message
557 .unwrap()
558 .contains("Invalid plugin install source"),
559 "invalid specs must be rejected before any network or disk access"
560 );
561 }
562
563 #[test]
564 fn install_update_uninstall_verbs_drive_the_guided_trust_flow() {
565 let _lock = crate::test_support::lock_test_env();
566 let root = TempDir::new().unwrap();
567 let codewhale_home = root.path().join("home");
568 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
569
570 let source = root.path().join("source/installed-demo");
571 fs::create_dir_all(&source).unwrap();
572 fs::write(
573 source.join("plugin.toml"),
574 "schema_version = 1\n[plugin]\nname = \"installed-demo\"\nversion = \"1.0.0\"\n",
575 )
576 .unwrap();
577
578 let (mut app, _temp) = create_test_app(root.path());
579 let runtime = tokio::runtime::Builder::new_multi_thread()
580 .worker_threads(2)
581 .enable_all()
582 .build()
583 .unwrap();
584 runtime.block_on(async {
585 let installed = plugins_with_kimi_home_override(
586 &mut app,
587 Some(&format!("install {}", source.display())),
588 None,
589 );
590 assert!(!installed.is_error, "{:?}", installed.message);
591 let message = installed.message.unwrap();
592 assert!(message.contains("disabled and untrusted"), "{message}");
593 let confirmation = message
594 .lines()
595 .find(|line| line.starts_with("/plugin trust installed-demo "))
596 .expect("install must route into the trust review")
597 .to_string();
598 let plugin = app.plugin_registry.get("installed-demo").unwrap();
599 assert!(!plugin.enabled && !plugin.trusted());
600 assert!(
601 codewhale_home
602 .join("plugins/installed-demo/.installed-from")
603 .exists()
604 );
605
606 // Local-path installs cannot be updated from the network.
607 let update = plugins_with_kimi_home_override(&mut app, Some("update installed-demo"), None);
608 assert!(update.is_error);
609 assert!(update.message.unwrap().contains("local path"));
610
611 let arg = confirmation.trim_start_matches("/plugin ").to_string();
612 assert!(!plugins_with_kimi_home_override(&mut app, Some(&arg), None).is_error);
613 assert!(
614 !plugins_with_kimi_home_override(&mut app, Some("enable installed-demo"), None)
615 .is_error
616 );
617 assert!(app.plugin_registry.is_active("installed-demo"));
618
619 // Uninstall requires disabled, then removes bits and prunes state.
620 let refused =
621 plugins_with_kimi_home_override(&mut app, Some("uninstall installed-demo"), None);
622 assert!(refused.is_error);
623 assert!(codewhale_home.join("plugins/installed-demo").exists());
624 assert!(
625 !plugins_with_kimi_home_override(&mut app, Some("disable installed-demo"), None)
626 .is_error
627 );
628 let removed =
629 plugins_with_kimi_home_override(&mut app, Some("uninstall installed-demo"), None);
630 assert!(!removed.is_error, "{:?}", removed.message);
631 assert!(!codewhale_home.join("plugins/installed-demo").exists());
632 assert!(app.plugin_registry.get("installed-demo").is_none());
633 let raw = fs::read_to_string(codewhale_home.join("plugins/state.json")).unwrap();
634 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
635 assert!(
636 parsed["plugins"].as_object().unwrap().is_empty(),
637 "uninstall must prune the state entry: {raw}"
638 );
639 });
640 }
641
642 #[test]
643 fn dsh_import_reviews_without_installing_then_installs_the_exact_bundle() {
644 let _lock = crate::test_support::lock_test_env();
645 let root = TempDir::new().unwrap();
646 let codewhale_home = root.path().join("codewhale-home");
647 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
648 let package = root.path().join("dsh-package");
649 fs::create_dir_all(package.join("pack-skills/guide")).unwrap();
650 fs::write(
651 package.join("package.json"),
652 r#"{"name": "@demo/docs-dsh", "version": "2.0.0", "dsh": {"bundle": {"patch": "./cordis.patch.yml"}}}"#,
653 )
654 .unwrap();
655 fs::write(
656 package.join("cordis.patch.yml"),
657 "- insert:\n - id: docs\n name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: docs, transport: streamable-http, url: 'https://docs.example.invalid/mcp'}\n - id: skills\n name: '@deepseek-ai/dsh-skill-filesystem'\n config: {customSkillDirs: [pack-skills]}\n - id: theme\n name: '@deepseek-ai/dsh-client-ui-theme'\n",
658 )
659 .unwrap();
660 fs::write(
661 package.join("pack-skills/guide/SKILL.md"),
662 "---\nname: guide\ndescription: Bundled guide\n---\nBody.\n",
663 )
664 .unwrap();
665
666 let (mut app, _temp) = create_test_app(root.path());
667 let help = plugins_with_kimi_home_override(&mut app, Some("help"), None)
668 .message
669 .unwrap();
670 assert!(help.contains("/plugin import dsh <package-dir>"), "{help}");
671 let review = plugins_with_kimi_home_override(
672 &mut app,
673 Some(&format!("import dsh {}", package.display())),
674 None,
675 );
676 assert!(!review.is_error, "{:?}", review.message);
677 let message = review.message.unwrap();
678 for fact in [
679 "@demo/docs-dsh@2.0.0",
680 "plugin 'docs-dsh'",
681 "Skills: guide",
682 "Remote MCP servers: docs",
683 "Network hosts it will request: docs.example.invalid",
684 "theme",
685 "Nothing was installed",
686 ] {
687 // Untrusted package text is rendered with review escaping.
688 assert!(
689 message.replace('\\', "").contains(fact),
690 "{fact}: {message}"
691 );
692 }
693 let approval = message
694 .lines()
695 .find_map(|line| line.trim().strip_prefix("/plugin "))
696 .expect("review renders an exact approval command")
697 .to_string();
698 assert!(!codewhale_home.join("plugins/docs-dsh").exists());
699
700 let runtime = tokio::runtime::Builder::new_multi_thread()
701 .worker_threads(2)
702 .enable_all()
703 .build()
704 .unwrap();
705 runtime.block_on(async {
706 let installed = plugins_with_kimi_home_override(&mut app, Some(&approval), None);
707 assert!(!installed.is_error, "{:?}", installed.message);
708 assert!(
709 installed
710 .message
711 .as_deref()
712 .is_some_and(|m| m.contains("disabled and untrusted"))
713 );
714 });
715 let plugin = app.plugin_registry.get("docs-dsh").unwrap();
716 assert!(!plugin.enabled && !plugin.trusted());
717 assert!(
718 codewhale_home
719 .join("plugins/docs-dsh/CONVERSION.md")
720 .is_file()
721 );
722 }
723
724 #[test]
725 fn kimi_managed_import_is_read_only_until_hash_bound_approval() {
726 let _lock = crate::test_support::lock_test_env();
727 let root = TempDir::new().unwrap();
728 let codewhale_home = root.path().join("codewhale-home");
729 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
730 let managed = root.path().join(".kimi-code/plugins/managed/kimi-demo");
731 fs::create_dir_all(managed.join("skills/kimi-demo")).unwrap();
732 fs::write(
733 managed.join("kimi.plugin.json"),
734 r#"{
735 "name": "kimi-demo",
736 "version": "1.0.0",
737 "license": "Proprietary",
738 "skills": "./skills/",
739 "interface": {
740 "displayName": "Kimi Demo",
741 "hostKind": "local",
742 "platforms": ["macos"]
743 }
744 }"#,
745 )
746 .unwrap();
747 fs::write(
748 managed.join("skills/kimi-demo/SKILL.md"),
749 "---\nname: kimi-demo\ndescription: local managed fixture\n---\n",
750 )
751 .unwrap();
752
753 let (mut app, _temp) = create_test_app(root.path());
754 let help = plugins_with_kimi_home_override(&mut app, Some("help"), None)
755 .message
756 .unwrap();
757 assert!(help.contains("/plugin import kimi [list]"), "{help}");
758 let listed = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path());
759 assert!(!listed.is_error, "{:?}", listed.message);
760 let message = listed.message.unwrap();
761 assert!(
762 message.contains("Kimi Demo") || message.contains("kimi-demo"),
763 "{message}"
764 );
765 assert!(message.contains("license=Proprietary"), "{message}");
766 assert!(message.contains("content hash:"), "{message}");
767 assert!(message.contains("External Kimi apps"), "{message}");
768 let approval = message
769 .lines()
770 .find_map(|line| line.trim().strip_prefix("approve: /plugin "))
771 .expect("listing must render an exact approval command")
772 .to_string();
773 assert!(app.plugin_registry.get("kimi-demo").is_none());
774 assert!(!codewhale_home.join("plugins/kimi-demo").exists());
775 assert!(!codewhale_home.join("plugins/state.json").exists());
776
777 // The approval token is tied to the bytes that were inspected.
778 fs::write(
779 managed.join("skills/kimi-demo/SKILL.md"),
780 "---\nname: kimi-demo\ndescription: changed fixture\n---\n",
781 )
782 .unwrap();
783 let changed = plugins_with_kimi_home(&mut app, Some(&approval), root.path());
784 assert!(changed.is_error);
785 assert!(changed.message.unwrap().contains("changed since review"));
786 assert!(!codewhale_home.join("plugins/kimi-demo").exists());
787
788 let refreshed = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path())
789 .message
790 .unwrap();
791 let approval = refreshed
792 .lines()
793 .find_map(|line| line.trim().strip_prefix("approve: /plugin "))
794 .unwrap()
795 .to_string();
796 let runtime = tokio::runtime::Builder::new_multi_thread()
797 .worker_threads(2)
798 .enable_all()
799 .build()
800 .unwrap();
801 runtime.block_on(async {
802 let installed = plugins_with_kimi_home(&mut app, Some(&approval), root.path());
803 assert!(!installed.is_error, "{:?}", installed.message);
804 assert!(
805 installed
806 .message
807 .as_deref()
808 .is_some_and(|message| message.contains("disabled and untrusted"))
809 );
810 });
811 let plugin = app.plugin_registry.get("kimi-demo").unwrap();
812 assert!(!plugin.enabled && !plugin.trusted());
813 assert!(
814 codewhale_home
815 .join("plugins/kimi-demo/kimi.plugin.json")
816 .is_file()
817 );
818 }
819
820 #[test]
821 fn kimi_managed_import_renders_in_the_selected_non_english_locale() {
822 let root = TempDir::new().unwrap();
823 let (mut app, _temp) = create_test_app(root.path());
824 app.ui_locale = Locale::Es419;
825
826 let message = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path())
827 .message
828 .expect("localized Kimi listing");
829 assert!(
830 message.contains("Plugins gestionados por Kimi"),
831 "{message}"
832 );
833 assert!(
834 message.contains("No se encontraron plugins gestionados válidos"),
835 "{message}"
836 );
837 assert!(
838 !message.contains("No valid managed plugins found"),
839 "{message}"
840 );
841 }
842
843 #[cfg(unix)]
844 #[test]
845 fn kimi_managed_import_refuses_linked_children() {
846 use std::os::unix::fs::symlink;
847
848 let root = TempDir::new().unwrap();
849 let managed_root = root.path().join(".kimi-code/plugins/managed");
850 let outside = root.path().join("outside");
851 fs::create_dir_all(&managed_root).unwrap();
852 fs::create_dir_all(&outside).unwrap();
853 symlink(&outside, managed_root.join("linked-plugin")).unwrap();
854 let (mut app, _temp) = create_test_app(root.path());
855
856 let result = plugins_with_kimi_home(&mut app, Some("import kimi"), root.path());
857 assert!(!result.is_error);
858 let message = result.message.unwrap();
859 assert!(message.contains("Rejected entries"), "{message}");
860 assert!(message.contains("links and reparse points are refused"));
861 assert!(!message.contains("approve: /plugin import kimi approve linked-plugin"));
862 }
863
864 #[test]
865 fn export_verb_writes_agent_plugins_bundle() {
866 let _lock = crate::test_support::lock_test_env();
867 let root = TempDir::new().unwrap();
868 let codewhale_home = root.path().join("home");
869 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
870 write_bundle(root.path());
871 let (mut app, _temp) = create_test_app(root.path());
872
873 let usage = plugins_with_kimi_home_override(&mut app, Some("export"), None);
874 assert!(usage.is_error, "export without arguments is a usage error");
875 let missing = plugins_with_kimi_home_override(&mut app, Some("export nope out"), None);
876 assert!(missing.is_error, "exporting an unknown plugin fails");
877
878 let result = plugins_with_kimi_home_override(&mut app, Some("export demo exported/demo"), None);
879 assert!(!result.is_error, "{result:?}");
880 let message = result.message.expect("export message");
881 assert!(message.contains("Exported `demo`"), "{message}");
882 assert!(message.contains("plugin.json"), "{message}");
883
884 let bundle = root.path().join("exported/demo");
885 let plugin_json: serde_json::Value =
886 serde_json::from_str(&fs::read_to_string(bundle.join("plugin.json")).unwrap()).unwrap();
887 crate::plugins::agent_plugin::validate_plugin_json(&plugin_json).unwrap();
888 assert_eq!(plugin_json["name"], "demo");
889 assert_eq!(plugin_json["description"], "Import spreadsheet data safely");
890 assert!(bundle.join("skills/hello/SKILL.md").is_file());
891 assert!(!bundle.join("plugin.toml").exists());
892 // No MCP servers declared, so no mcp.json is written.
893 assert!(!bundle.join("mcp.json").exists());
894 // The installed bundle keeps its legacy manifest and stays untouched.
895 assert!(
896 root.path()
897 .join(".codewhale/plugins/demo/plugin.toml")
898 .exists()
899 );
900 }
901
902 #[test]
903 fn plugin_dismissals_list_and_reset_both_kinds() {
904 let _lock = crate::test_support::lock_test_env();
905 let root = TempDir::new().unwrap();
906 let codewhale_home = root.path().join("home");
907 fs::create_dir_all(&codewhale_home).unwrap();
908 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
909 let (mut app, _temp) = create_test_app(root.path());
910 crate::settings::Settings::transact_opt(|settings| {
911 Ok(settings
912 .dismissed_plugin_suggestions
913 .insert("keptaway".to_string())
914 .then_some(()))
915 })
916 .unwrap();
917 app.plugin_cta.dismissed.insert("keptaway".to_string());
918 app.plugin_cta.dismissed.insert("esconce".to_string());
919
920 let listed = plugins_with_kimi_home_override(&mut app, Some("dismissals"), None)
921 .message
922 .expect("dismissal list");
923 let kept = listed
924 .find("keptaway")
925 .unwrap_or_else(|| panic!("{listed}"));
926 let session = listed.find("esconce").unwrap_or_else(|| panic!("{listed}"));
927 assert!(listed.contains("Don't suggest again"), "{listed}");
928 assert!(listed.contains("This session only"), "{listed}");
929 assert!(kept < session, "{listed}");
930
931 let reset = plugins_with_kimi_home_override(&mut app, Some("dismissals reset KeptAway"), None)
932 .message
933 .expect("reset receipt");
934 assert!(reset.contains("keptaway"), "{reset}");
935 assert!(
936 crate::settings::Settings::load()
937 .unwrap()
938 .dismissed_plugin_suggestions
939 .is_empty(),
940 "reset must reach the saved choice"
941 );
942 assert!(!app.plugin_cta.dismissed.contains("keptaway"));
943 assert!(app.plugin_cta.dismissed.contains("esconce"));
944
945 plugins_with_kimi_home_override(&mut app, Some("dismissals reset"), None);
946 assert!(app.plugin_cta.dismissed.is_empty());
947 let empty = plugins_with_kimi_home_override(&mut app, Some("dismissals"), None)
948 .message
949 .expect("empty list");
950 assert!(empty.contains("No plugins are hidden"), "{empty}");
951 }
952
952 lines RUST