返回 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::localization::Locale;
6 use crate::tui::app::{App, TuiOptions};
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 list_show_validate_are_read_only_and_label_legacy_tools() {
95 let _lock = crate::test_support::lock_test_env();
96 let root = TempDir::new().unwrap();
97 let codewhale_home = root.path().join("home");
98 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
99 write_bundle(root.path());
100 let (mut app, _temp) = create_test_app(root.path());
101 fs::write(
102 root.path().join("tools/greet.sh"),
103 "# name: greet\n# description: hello\n",
104 )
105 .unwrap();
106 // The app already resolved the legacy tools path during startup.
107 // Read-only plugin commands must not reopen a credential-bearing
108 // config file merely to inventory those tools.
109 fs::write(
110 app.config_path.as_ref().unwrap(),
111 "api_key = [\"must-not-be-re-read\"\n",
112 )
113 .unwrap();
114 let state_path = codewhale_home.join("plugins/state.json");
115
116 for arg in [Some("list"), Some("show demo"), Some("validate")] {
117 let result = plugins(&mut app, arg);
118 assert!(!result.is_error, "{:?}", result.message);
119 assert!(!state_path.exists(), "read-only command wrote plugin state");
120 }
121 let list = plugins(&mut app, Some("list")).message.unwrap();
122 assert!(list.contains("Plugin bundles (1)"));
123 assert!(list.contains("disabled"));
124 assert!(list.contains("Legacy executable plugin tools (1)"));
125 }
126
127 #[test]
128 fn suggest_ranks_installed_plugins_without_trusting_or_enabling_them() {
129 let _lock = crate::test_support::lock_test_env();
130 let root = TempDir::new().unwrap();
131 let codewhale_home = root.path().join("home");
132 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
133 write_bundle(root.path());
134 let (mut app, _temp) = create_test_app(root.path());
135
136 for arg in ["suggest", "suggest go"] {
137 let result = plugins(&mut app, Some(arg));
138 assert!(
139 result.is_error,
140 "expected usage error for {arg}: {result:?}"
141 );
142 }
143
144 let result = plugins(&mut app, Some("suggest spreadsheet import"));
145 assert!(!result.is_error, "{result:?}");
146 let message = result.message.expect("suggestion message");
147 assert!(message.contains("Suggested installed plugins"), "{message}");
148 assert!(message.contains("demo — disabled"), "{message}");
149 assert!(message.contains("Why:"), "{message}");
150 assert!(message.contains("/plugin trust demo"), "{message}");
151 assert!(message.contains("Nothing was installed, trusted, or enabled."));
152 assert!(!codewhale_home.join("plugins/state.json").exists());
153 let plugin = app.plugin_registry.get("demo").expect("demo plugin");
154 assert!(!plugin.enabled && !plugin.trusted());
155 }
156
157 #[test]
158 fn trust_requires_content_and_capability_bound_review_token() {
159 let _lock = crate::test_support::lock_test_env();
160 let root = TempDir::new().unwrap();
161 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
162 write_bundle(root.path());
163 let (mut app, _temp) = create_test_app(root.path());
164 let enable_review = plugins(&mut app, Some("enable demo"));
165 assert!(!enable_review.is_error);
166 assert!(
167 enable_review
168 .message
169 .as_deref()
170 .is_some_and(|message| message.contains("/plugin trust demo "))
171 );
172 assert!(!app.plugin_registry.get("demo").unwrap().trusted());
173
174 let review = plugins(&mut app, Some("trust demo")).message.unwrap();
175 let confirmation = review
176 .lines()
177 .find(|line| line.starts_with("/plugin trust demo "))
178 .unwrap();
179 let token = confirmation
180 .split_whitespace()
181 .last()
182 .expect("review confirmation token");
183 let (content_digest, capability_digest) = token
184 .split_once('.')
185 .expect("content and capability digests");
186 assert_eq!(content_digest.len(), 64);
187 assert_eq!(capability_digest.len(), 64);
188 assert!(content_digest.bytes().all(|byte| byte.is_ascii_hexdigit()));
189 assert!(
190 capability_digest
191 .bytes()
192 .all(|byte| byte.is_ascii_hexdigit())
193 );
194 assert!(!app.plugin_registry.get("demo").unwrap().trusted());
195
196 assert!(plugins(&mut app, Some("trust demo wrong")).is_error);
197 let shortened = format!(
198 "trust demo {}.{}",
199 &content_digest[..12],
200 &capability_digest[..12]
201 );
202 assert!(
203 plugins(&mut app, Some(&shortened)).is_error,
204 "the legacy 48-bit content prefix must not authorize trust"
205 );
206 let arg = confirmation.trim_start_matches("/plugin ");
207 assert!(!plugins(&mut app, Some(arg)).is_error);
208 assert!(!plugins(&mut app, Some("enable demo")).is_error);
209 assert!(app.plugin_registry.is_active("demo"));
210 assert!(!plugins(&mut app, Some("disable demo")).is_error);
211 assert!(!app.plugin_registry.is_active("demo"));
212 }
213
214 #[test]
215 fn mcp_review_discloses_host_authority_and_names_without_secret_values() {
216 let _lock = crate::test_support::lock_test_env();
217 let root = TempDir::new().unwrap();
218 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
219 write_mcp_review_bundle(root.path());
220 let (mut app, _temp) = create_test_app(root.path());
221 let review = plugins(&mut app, Some("trust review-mcp"))
222 .message
223 .expect("review output");
224 assert!(review.contains("mcp=2 (stdio=1 remote=1)"));
225 assert!(review.contains("host-user filesystem/network authority"));
226 assert!(review.contains("PLUGIN\\_TOKEN <- PLUGIN\\_TOKEN\\_SOURCE"));
227 assert!(review.contains("X\\_Api\\_Key <- REMOTE\\_API\\_KEY"));
228 assert!(review.contains("bearer_env=REMOTE\\_TOKEN"));
229 assert!(review.contains("redirects=same-origin-only"));
230 assert!(review.contains("Qualified skills: [none]"));
231 assert!(review.contains("#2 value=\"--mode=worker\""));
232 assert!(review.contains("#3 value=\"-e\""));
233 assert!(review.contains("#4 value=\"console.log('ready')\""));
234 assert!(review.contains("oauth=disabled-v0.9.1"));
235 }
236
237 #[test]
238 fn legacy_tool_detail_remains_available_under_tools_namespace() {
239 let _lock = crate::test_support::lock_test_env();
240 let root = TempDir::new().unwrap();
241 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
242 let (mut app, _temp) = create_test_app(root.path());
243 fs::write(
244 root.path().join("tools/greet.sh"),
245 "# name: greet\n# description: Say hello\n# approval: required\n",
246 )
247 .unwrap();
248 let result = plugins(&mut app, Some("tools greet"));
249 assert!(!result.is_error);
250 let message = result.message.unwrap();
251 assert!(message.contains("Say hello"));
252 assert!(message.contains("required"));
253 }
254
255 #[test]
256 fn install_update_uninstall_verbs_validate_arguments() {
257 let _lock = crate::test_support::lock_test_env();
258 let root = TempDir::new().unwrap();
259 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
260 let (mut app, _temp) = create_test_app(root.path());
261 for arg in ["install", "update", "uninstall"] {
262 let result = plugins(&mut app, Some(arg));
263 assert!(result.is_error, "bare `{arg}` must print usage");
264 }
265 let invalid = plugins(&mut app, Some("install github:"));
266 assert!(invalid.is_error);
267 assert!(
268 invalid
269 .message
270 .unwrap()
271 .contains("Invalid plugin install source"),
272 "invalid specs must be rejected before any network or disk access"
273 );
274 }
275
276 #[test]
277 fn install_update_uninstall_verbs_drive_the_guided_trust_flow() {
278 let _lock = crate::test_support::lock_test_env();
279 let root = TempDir::new().unwrap();
280 let codewhale_home = root.path().join("home");
281 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
282
283 let source = root.path().join("source/installed-demo");
284 fs::create_dir_all(&source).unwrap();
285 fs::write(
286 source.join("plugin.toml"),
287 "schema_version = 1\n[plugin]\nname = \"installed-demo\"\nversion = \"1.0.0\"\n",
288 )
289 .unwrap();
290
291 let (mut app, _temp) = create_test_app(root.path());
292 let runtime = tokio::runtime::Builder::new_multi_thread()
293 .worker_threads(2)
294 .enable_all()
295 .build()
296 .unwrap();
297 runtime.block_on(async {
298 let installed = plugins(&mut app, Some(&format!("install {}", source.display())));
299 assert!(!installed.is_error, "{:?}", installed.message);
300 let message = installed.message.unwrap();
301 assert!(message.contains("disabled and untrusted"), "{message}");
302 let confirmation = message
303 .lines()
304 .find(|line| line.starts_with("/plugin trust installed-demo "))
305 .expect("install must route into the trust review")
306 .to_string();
307 let plugin = app.plugin_registry.get("installed-demo").unwrap();
308 assert!(!plugin.enabled && !plugin.trusted());
309 assert!(
310 codewhale_home
311 .join("plugins/installed-demo/.installed-from")
312 .exists()
313 );
314
315 // Local-path installs cannot be updated from the network.
316 let update = plugins(&mut app, Some("update installed-demo"));
317 assert!(update.is_error);
318 assert!(update.message.unwrap().contains("local path"));
319
320 let arg = confirmation.trim_start_matches("/plugin ").to_string();
321 assert!(!plugins(&mut app, Some(&arg)).is_error);
322 assert!(!plugins(&mut app, Some("enable installed-demo")).is_error);
323 assert!(app.plugin_registry.is_active("installed-demo"));
324
325 // Uninstall requires disabled, then removes bits and prunes state.
326 let refused = plugins(&mut app, Some("uninstall installed-demo"));
327 assert!(refused.is_error);
328 assert!(codewhale_home.join("plugins/installed-demo").exists());
329 assert!(!plugins(&mut app, Some("disable installed-demo")).is_error);
330 let removed = plugins(&mut app, Some("uninstall installed-demo"));
331 assert!(!removed.is_error, "{:?}", removed.message);
332 assert!(!codewhale_home.join("plugins/installed-demo").exists());
333 assert!(app.plugin_registry.get("installed-demo").is_none());
334 let raw = fs::read_to_string(codewhale_home.join("plugins/state.json")).unwrap();
335 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
336 assert!(
337 parsed["plugins"].as_object().unwrap().is_empty(),
338 "uninstall must prune the state entry: {raw}"
339 );
340 });
341 }
342
342 lines RUST