| 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 |