| 1 | //! Plugin acceptance at both compatibility and v0.9.1 bundle boundaries. |
| 2 | //! |
| 3 | //! Tests the plugin frontmatter scanner end-to-end from the binary level: |
| 4 | //! - Scripts with valid `# name:` frontmatter are discovered |
| 5 | //! - Approval levels (auto, suggest, required) are parsed correctly |
| 6 | //! - Hidden files and README.md are ignored |
| 7 | //! - Empty and missing directories are handled gracefully |
| 8 | //! - The distributed binary still loads after the plugin module migration |
| 9 | //! - A sealed real PTY exercises plugin.toml review/trust/enable/revoke, |
| 10 | //! reviewed Skill dispatch, and hermetic reviewed stdio MCP execution |
| 11 | |
| 12 | use std::path::PathBuf; |
| 13 | use std::process::Command; |
| 14 | |
| 15 | use cucumber::{World as _, given, then, when, writer::Stats as _}; |
| 16 | use tempfile::TempDir; |
| 17 | |
| 18 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 19 | #[path = "support/qa_harness/mod.rs"] |
| 20 | mod qa_harness; |
| 21 | |
| 22 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 23 | use qa_harness::harness::{Harness, make_sealed_workspace}; |
| 24 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 25 | use qa_harness::keys; |
| 26 | |
| 27 | const FEATURE_NAME: &str = "Plugin discovery and listing"; |
| 28 | const FEATURE_PATH: &str = concat!( |
| 29 | env!("CARGO_MANIFEST_DIR"), |
| 30 | "/tests/features/plugin_e2e_acceptance.feature" |
| 31 | ); |
| 32 | const DISCOVERY_SCENARIO: &str = |
| 33 | "Plugin scripts are discovered from the configured plugin directory"; |
| 34 | const EMPTY_SCENARIO: &str = "Empty plugin directory reports no plugins"; |
| 35 | const MISSING_SCENARIO: &str = "Missing plugin directory reports the path"; |
| 36 | |
| 37 | // --------------------------------------------------------------------------- |
| 38 | // Test-local plugin scanner |
| 39 | // |
| 40 | // Mirrors the real `scan_plugin_dir` from `crates/tui/src/tools/plugin.rs` |
| 41 | // so the test can run as a standalone integration test without relying on |
| 42 | // `#[path]` (which breaks on internal `crate::` and `super::` imports). |
| 43 | // The contract (frontmatter format, skip rules) matches exactly. |
| 44 | // --------------------------------------------------------------------------- |
| 45 | |
| 46 | #[derive(Debug, Clone, PartialEq)] |
| 47 | struct TestPluginMeta { |
| 48 | name: String, |
| 49 | description: String, |
| 50 | approval: TestApproval, |
| 51 | } |
| 52 | |
| 53 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 54 | enum TestApproval { |
| 55 | Auto, |
| 56 | Suggest, |
| 57 | Required, |
| 58 | } |
| 59 | |
| 60 | fn parse_frontmatter(content: &str) -> Option<TestPluginMeta> { |
| 61 | let mut name = String::new(); |
| 62 | let mut description = String::new(); |
| 63 | let mut approval_str = String::new(); |
| 64 | |
| 65 | for line in content.lines().take(20) { |
| 66 | let line = line.trim(); |
| 67 | let rest = line |
| 68 | .strip_prefix('#') |
| 69 | .or_else(|| line.strip_prefix("//")) |
| 70 | .or_else(|| line.strip_prefix("--")); |
| 71 | let Some(rest) = rest else { continue }; |
| 72 | let Some((key, value)) = rest.trim_start().split_once(':') else { |
| 73 | continue; |
| 74 | }; |
| 75 | match key.trim().to_lowercase().as_str() { |
| 76 | "name" => name = value.trim().to_string(), |
| 77 | "description" => description = value.trim().to_string(), |
| 78 | "approval" => approval_str = value.trim().to_string(), |
| 79 | _ => {} |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | if name.is_empty() { |
| 84 | return None; |
| 85 | } |
| 86 | |
| 87 | let approval = match approval_str.to_lowercase().as_str() { |
| 88 | "auto" => TestApproval::Auto, |
| 89 | "required" => TestApproval::Required, |
| 90 | _ => TestApproval::Suggest, |
| 91 | }; |
| 92 | |
| 93 | Some(TestPluginMeta { |
| 94 | name, |
| 95 | description: if description.is_empty() { |
| 96 | "User-provided plugin tool".to_string() |
| 97 | } else { |
| 98 | description |
| 99 | }, |
| 100 | approval, |
| 101 | }) |
| 102 | } |
| 103 | |
| 104 | fn scan_plugin_dir(dir: &std::path::Path) -> Vec<(PathBuf, TestPluginMeta)> { |
| 105 | let mut results = Vec::new(); |
| 106 | |
| 107 | let entries = match std::fs::read_dir(dir) { |
| 108 | Ok(entries) => entries, |
| 109 | Err(_) => return results, |
| 110 | }; |
| 111 | |
| 112 | let mut entries: Vec<_> = entries.flatten().collect(); |
| 113 | entries.sort_by_key(|entry| entry.file_name()); |
| 114 | |
| 115 | for entry in entries { |
| 116 | let path = entry.path(); |
| 117 | |
| 118 | if path.is_dir() { |
| 119 | continue; |
| 120 | } |
| 121 | |
| 122 | if let Some(name) = path.file_name().and_then(|n| n.to_str()) |
| 123 | && (name.starts_with('.') || name == "README.md") |
| 124 | { |
| 125 | continue; |
| 126 | } |
| 127 | |
| 128 | if let Ok(content) = std::fs::read_to_string(&path) |
| 129 | && let Some(meta) = parse_frontmatter(&content) |
| 130 | { |
| 131 | results.push((path, meta)); |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | results |
| 136 | } |
| 137 | |
| 138 | // --------------------------------------------------------------------------- |
| 139 | // Cucumber world |
| 140 | // --------------------------------------------------------------------------- |
| 141 | |
| 142 | #[derive(Debug, Default, cucumber::World)] |
| 143 | struct PluginE2EWorld { |
| 144 | /// TempDir holding the plugin directory. We keep a second TempDir as |
| 145 | /// the "workspace" so the plugin dir path stays valid after move. |
| 146 | _workspace: Option<TempDir>, |
| 147 | plugin_dir: Option<TempDir>, |
| 148 | discovered: Option<Vec<(PathBuf, TestPluginMeta)>>, |
| 149 | scanner_message: Option<String>, |
| 150 | } |
| 151 | |
| 152 | // --------------------------------------------------------------------------- |
| 153 | // Given steps |
| 154 | // --------------------------------------------------------------------------- |
| 155 | |
| 156 | #[given("an offline CodeWhale workspace with a configured plugin directory")] |
| 157 | fn offline_workspace_with_plugin_dir(world: &mut PluginE2EWorld) { |
| 158 | let workspace = TempDir::new().expect("workspace tempdir"); |
| 159 | let plugin_dir = TempDir::new().expect("plugin tempdir"); |
| 160 | world._workspace = Some(workspace); |
| 161 | world.plugin_dir = Some(plugin_dir); |
| 162 | } |
| 163 | |
| 164 | #[given(regex = r"^the plugin directory contains:$")] |
| 165 | fn plugin_directory_contains(world: &mut PluginE2EWorld, step: &cucumber::gherkin::Step) { |
| 166 | let dir = world |
| 167 | .plugin_dir |
| 168 | .as_ref() |
| 169 | .expect("plugin directory should be configured"); |
| 170 | |
| 171 | let table = step |
| 172 | .table |
| 173 | .as_ref() |
| 174 | .expect("step should include a data table"); |
| 175 | let mut rows = table.rows.iter(); |
| 176 | let headers = rows.next().expect("data table should include a header"); |
| 177 | let name_idx = headers |
| 178 | .iter() |
| 179 | .position(|h| h == "name") |
| 180 | .expect("data table should have a 'name' column"); |
| 181 | let desc_idx = headers |
| 182 | .iter() |
| 183 | .position(|h| h == "description") |
| 184 | .expect("data table should have a 'description' column"); |
| 185 | let approval_idx = headers |
| 186 | .iter() |
| 187 | .position(|h| h == "approval") |
| 188 | .expect("data table should have an 'approval' column"); |
| 189 | |
| 190 | for row in rows { |
| 191 | let name = row.get(name_idx).expect("plugin name"); |
| 192 | let description = row.get(desc_idx).expect("plugin description"); |
| 193 | let approval = row.get(approval_idx).expect("plugin approval"); |
| 194 | |
| 195 | let script_path = dir.path().join(format!("{name}.sh")); |
| 196 | let script_content = format!( |
| 197 | "# name: {name}\n\ |
| 198 | # description: {description}\n\ |
| 199 | # approval: {approval}\n\ |
| 200 | # schema: {{\"type\":\"object\"}}\n\ |
| 201 | echo hello\n" |
| 202 | ); |
| 203 | std::fs::write(&script_path, &script_content) |
| 204 | .unwrap_or_else(|e| panic!("write plugin script {name}.sh: {e}")); |
| 205 | |
| 206 | // Make executable on Unix |
| 207 | #[cfg(unix)] |
| 208 | { |
| 209 | use std::os::unix::fs::PermissionsExt; |
| 210 | std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) |
| 211 | .unwrap_or_else(|e| panic!("chmod {name}.sh: {e}")); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // Write a README.md and a hidden file that should be ignored |
| 216 | std::fs::write(dir.path().join("README.md"), "# Plugin Docs\n").expect("write README.md"); |
| 217 | std::fs::write( |
| 218 | dir.path().join(".hidden_script.sh"), |
| 219 | "# name: hidden\n# description: Should not appear\n", |
| 220 | ) |
| 221 | .expect("write hidden"); |
| 222 | } |
| 223 | |
| 224 | #[given("the plugin directory is empty")] |
| 225 | fn plugin_directory_empty(world: &mut PluginE2EWorld) { |
| 226 | // Replace with a fresh empty directory |
| 227 | let dir = TempDir::new().expect("empty plugin tempdir"); |
| 228 | world.plugin_dir = Some(dir); |
| 229 | } |
| 230 | |
| 231 | #[given("the plugin directory does not exist")] |
| 232 | fn plugin_directory_does_not_exist(world: &mut PluginE2EWorld) { |
| 233 | let base = TempDir::new().expect("base tempdir for non-existent path"); |
| 234 | let non_existent = base.path().join("nonexistent"); |
| 235 | // Ensure it truly doesn't exist |
| 236 | let _ = std::fs::remove_dir_all(&non_existent); |
| 237 | // Store the base so the path stays valid for the lifetime of the test |
| 238 | world._workspace = Some(base); |
| 239 | // Remove the previous plugin_dir so scanning uses the path deliberately |
| 240 | world.plugin_dir = None; |
| 241 | world.scanner_message = Some(format!( |
| 242 | "No plugin directory found at {}", |
| 243 | non_existent.display() |
| 244 | )); |
| 245 | } |
| 246 | |
| 247 | // --------------------------------------------------------------------------- |
| 248 | // When steps |
| 249 | // --------------------------------------------------------------------------- |
| 250 | |
| 251 | #[when("the plugin scanner discovers plugins")] |
| 252 | fn plugin_scanner_discovers_plugins(world: &mut PluginE2EWorld) { |
| 253 | let dir = world |
| 254 | .plugin_dir |
| 255 | .as_ref() |
| 256 | .expect("plugin directory should be configured"); |
| 257 | let discovered = scan_plugin_dir(dir.path()); |
| 258 | world.discovered = Some(discovered); |
| 259 | } |
| 260 | |
| 261 | #[when("the plugin scanner runs")] |
| 262 | fn plugin_scanner_runs(world: &mut PluginE2EWorld) { |
| 263 | // Use the stored non-existent path |
| 264 | let msg = world |
| 265 | .scanner_message |
| 266 | .as_ref() |
| 267 | .expect("missing path message"); |
| 268 | // Extract the path from the message |
| 269 | let path_str = msg |
| 270 | .strip_prefix("No plugin directory found at ") |
| 271 | .expect("message format"); |
| 272 | let path = std::path::Path::new(path_str); |
| 273 | let discovered = scan_plugin_dir(path); |
| 274 | world.discovered = Some(discovered); |
| 275 | } |
| 276 | |
| 277 | // --------------------------------------------------------------------------- |
| 278 | // Then steps |
| 279 | // --------------------------------------------------------------------------- |
| 280 | |
| 281 | #[then(regex = r"^the scanner should report (\d+) plugins?$")] |
| 282 | fn scanner_should_report_n_plugins(world: &mut PluginE2EWorld, expected_count: usize) { |
| 283 | let discovered = world.discovered.as_ref().expect("scanner should have run"); |
| 284 | assert_eq!( |
| 285 | discovered.len(), |
| 286 | expected_count, |
| 287 | "expected {expected_count} plugins, found {}: {discovered:#?}", |
| 288 | discovered.len() |
| 289 | ); |
| 290 | } |
| 291 | |
| 292 | #[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as description$"#)] |
| 293 | fn scanned_plugin_should_have_description( |
| 294 | world: &mut PluginE2EWorld, |
| 295 | name: String, |
| 296 | expected_description: String, |
| 297 | ) { |
| 298 | let discovered = world.discovered.as_ref().expect("scanner should have run"); |
| 299 | let meta = discovered |
| 300 | .iter() |
| 301 | .find(|(_, m)| m.name == name) |
| 302 | .map(|(_, m)| m) |
| 303 | .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results")); |
| 304 | |
| 305 | assert_eq!( |
| 306 | meta.description, expected_description, |
| 307 | "plugin \"{name}\" description mismatch" |
| 308 | ); |
| 309 | } |
| 310 | |
| 311 | #[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as approval$"#)] |
| 312 | fn scanned_plugin_should_have_approval( |
| 313 | world: &mut PluginE2EWorld, |
| 314 | name: String, |
| 315 | expected_approval: String, |
| 316 | ) { |
| 317 | let discovered = world.discovered.as_ref().expect("scanner should have run"); |
| 318 | let meta = discovered |
| 319 | .iter() |
| 320 | .find(|(_, m)| m.name == name) |
| 321 | .map(|(_, m)| m) |
| 322 | .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results")); |
| 323 | |
| 324 | let actual = match meta.approval { |
| 325 | TestApproval::Auto => "auto", |
| 326 | TestApproval::Suggest => "suggest", |
| 327 | TestApproval::Required => "required", |
| 328 | }; |
| 329 | assert_eq!( |
| 330 | actual, expected_approval, |
| 331 | "plugin \"{name}\" approval mismatch" |
| 332 | ); |
| 333 | } |
| 334 | |
| 335 | #[then(regex = r#"^the scanned plugin "([^"]+)" should not be found$"#)] |
| 336 | fn scanned_plugin_should_not_be_found(world: &mut PluginE2EWorld, name: String) { |
| 337 | let discovered = world.discovered.as_ref().expect("scanner should have run"); |
| 338 | assert!( |
| 339 | !discovered.iter().any(|(_, m)| m.name == name), |
| 340 | "plugin \"{name}\" should not be present in scan results, but was found" |
| 341 | ); |
| 342 | } |
| 343 | |
| 344 | #[then("the scanner should report the missing directory path")] |
| 345 | fn scanner_should_report_missing_path(world: &mut PluginE2EWorld) { |
| 346 | let discovered = world.discovered.as_ref().expect("scanner should have run"); |
| 347 | assert!( |
| 348 | discovered.is_empty(), |
| 349 | "expected empty results for missing directory, got: {discovered:#?}" |
| 350 | ); |
| 351 | let msg = world |
| 352 | .scanner_message |
| 353 | .as_deref() |
| 354 | .unwrap_or("scanner ran without message"); |
| 355 | assert!( |
| 356 | msg.contains("No plugin directory found"), |
| 357 | "expected missing directory message, got: {msg}" |
| 358 | ); |
| 359 | } |
| 360 | |
| 361 | // --------------------------------------------------------------------------- |
| 362 | // Binary smoke test |
| 363 | // --------------------------------------------------------------------------- |
| 364 | |
| 365 | /// Prove the binary still loads after the plugin module extraction. |
| 366 | #[tokio::test(flavor = "current_thread")] |
| 367 | async fn plugin_module_does_not_break_binary_load() { |
| 368 | let output = Command::new(codewhale_tui_binary()) |
| 369 | .arg("--version") |
| 370 | .output() |
| 371 | .expect("codewhale-tui --version should start"); |
| 372 | |
| 373 | assert!( |
| 374 | output.status.success(), |
| 375 | "codewhale-tui --version failed\nstderr:\n{}", |
| 376 | String::from_utf8_lossy(&output.stderr) |
| 377 | ); |
| 378 | |
| 379 | let version = String::from_utf8_lossy(&output.stdout); |
| 380 | assert!( |
| 381 | version.contains("codewhale"), |
| 382 | "version output should mention codewhale, got: {version}" |
| 383 | ); |
| 384 | } |
| 385 | |
| 386 | // --------------------------------------------------------------------------- |
| 387 | // Real plugin.toml binary/TUI acceptance |
| 388 | // --------------------------------------------------------------------------- |
| 389 | |
| 390 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 391 | const BINARY_ACCEPTANCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); |
| 392 | |
| 393 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 394 | fn write_reviewed_bundle_fixture(workspace: &std::path::Path) -> PathBuf { |
| 395 | use std::os::unix::fs::PermissionsExt as _; |
| 396 | |
| 397 | let bundle = workspace.join(".codewhale/plugins/demo"); |
| 398 | std::fs::create_dir_all(bundle.join("skills/review")).expect("plugin fixture directories"); |
| 399 | std::fs::write( |
| 400 | bundle.join("plugin.toml"), |
| 401 | r#"schema_version = 1 |
| 402 | [plugin] |
| 403 | name = "demo" |
| 404 | version = "1.0.0" |
| 405 | description = "Hermetic binary acceptance fixture" |
| 406 | |
| 407 | [skills] |
| 408 | path = "skills" |
| 409 | |
| 410 | [mcp_servers.local] |
| 411 | command = "./server.py" |
| 412 | connect_timeout = 5 |
| 413 | execute_timeout = 30 |
| 414 | read_timeout = 30 |
| 415 | |
| 416 | [mcp_servers.local.env] |
| 417 | ACCEPTANCE_LOG = "${PLUGIN_ACCEPTANCE_LOG}" |
| 418 | "#, |
| 419 | ) |
| 420 | .expect("plugin manifest"); |
| 421 | std::fs::write( |
| 422 | bundle.join("skills/review/SKILL.md"), |
| 423 | "---\nname: review\ndescription: reviewed binary acceptance Skill\n---\n\nUse the reviewed fixture.\n", |
| 424 | ) |
| 425 | .expect("plugin Skill"); |
| 426 | let server = bundle.join("server.py"); |
| 427 | std::fs::write( |
| 428 | &server, |
| 429 | r#"#!/usr/bin/env python3 |
| 430 | import json |
| 431 | import os |
| 432 | import signal |
| 433 | import sys |
| 434 | import time |
| 435 | |
| 436 | log_path = os.environ["ACCEPTANCE_LOG"] |
| 437 | |
| 438 | def record(event): |
| 439 | with open(log_path, "a", encoding="utf-8") as handle: |
| 440 | handle.write(event + "\n") |
| 441 | handle.flush() |
| 442 | |
| 443 | def stop(signum, _frame): |
| 444 | record("signal:" + str(signum)) |
| 445 | raise SystemExit(0) |
| 446 | |
| 447 | signal.signal(signal.SIGTERM, stop) |
| 448 | signal.signal(signal.SIGINT, stop) |
| 449 | record("started") |
| 450 | record("api-key-present:" + str("DEEPSEEK_API_KEY" in os.environ).lower()) |
| 451 | |
| 452 | for raw in sys.stdin: |
| 453 | message = json.loads(raw) |
| 454 | method = message.get("method") |
| 455 | request_id = message.get("id") |
| 456 | if method == "initialize": |
| 457 | result = { |
| 458 | "protocolVersion": "2024-11-05", |
| 459 | "capabilities": {"tools": {}}, |
| 460 | "serverInfo": {"name": "plugin-acceptance", "version": "1.0.0"}, |
| 461 | } |
| 462 | elif method == "tools/list": |
| 463 | record("tools:list") |
| 464 | result = {"tools": [{ |
| 465 | "name": "echo", |
| 466 | "description": "Hermetic plugin echo", |
| 467 | "inputSchema": { |
| 468 | "type": "object", |
| 469 | "properties": { |
| 470 | "text": {"type": "string"}, |
| 471 | "hang": {"type": "boolean"}, |
| 472 | }, |
| 473 | }, |
| 474 | }]} |
| 475 | elif method == "tools/call": |
| 476 | args = message.get("params", {}).get("arguments", {}) |
| 477 | if args.get("hang"): |
| 478 | record("call:hang") |
| 479 | while True: |
| 480 | time.sleep(0.05) |
| 481 | record("call:echo") |
| 482 | result = {"content": [{ |
| 483 | "type": "text", |
| 484 | "text": "plugin-echo:" + str(args.get("text", "")), |
| 485 | }]} |
| 486 | else: |
| 487 | if request_id is None: |
| 488 | continue |
| 489 | result = {} |
| 490 | sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n") |
| 491 | sys.stdout.flush() |
| 492 | "#, |
| 493 | ) |
| 494 | .expect("stdio MCP fixture"); |
| 495 | std::fs::set_permissions(&server, std::fs::Permissions::from_mode(0o755)) |
| 496 | .expect("executable MCP fixture"); |
| 497 | bundle |
| 498 | } |
| 499 | |
| 500 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 501 | fn sse_line(value: serde_json::Value) -> String { |
| 502 | format!( |
| 503 | "data: {}\n\n", |
| 504 | serde_json::to_string(&value).expect("SSE JSON") |
| 505 | ) |
| 506 | } |
| 507 | |
| 508 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 509 | fn text_sse(text: &str) -> String { |
| 510 | [ |
| 511 | sse_line(serde_json::json!({ |
| 512 | "id": "chatcmpl-plugin-acceptance", |
| 513 | "object": "chat.completion.chunk", |
| 514 | "model": "deepseek-v4-pro", |
| 515 | "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}] |
| 516 | })), |
| 517 | sse_line(serde_json::json!({ |
| 518 | "id": "chatcmpl-plugin-acceptance", |
| 519 | "object": "chat.completion.chunk", |
| 520 | "model": "deepseek-v4-pro", |
| 521 | "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], |
| 522 | "usage": {"prompt_tokens": 8, "completion_tokens": 4, "total_tokens": 12} |
| 523 | })), |
| 524 | "data: [DONE]\n\n".to_string(), |
| 525 | ] |
| 526 | .join("") |
| 527 | } |
| 528 | |
| 529 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 530 | fn tool_call_sse(hang: bool) -> String { |
| 531 | let call_id = if hang { |
| 532 | "call_plugin_hang" |
| 533 | } else { |
| 534 | "call_plugin_echo" |
| 535 | }; |
| 536 | let arguments = serde_json::to_string(&serde_json::json!({ |
| 537 | "text": "acceptance", |
| 538 | "hang": hang, |
| 539 | })) |
| 540 | .expect("tool args"); |
| 541 | [ |
| 542 | sse_line(serde_json::json!({ |
| 543 | "id": "chatcmpl-plugin-tool", |
| 544 | "object": "chat.completion.chunk", |
| 545 | "model": "deepseek-v4-pro", |
| 546 | "choices": [{ |
| 547 | "index": 0, |
| 548 | "delta": {"tool_calls": [{ |
| 549 | "index": 0, |
| 550 | "id": call_id, |
| 551 | "type": "function", |
| 552 | "function": { |
| 553 | "name": "mcp_plugin-4-demo-local_echo", |
| 554 | "arguments": arguments |
| 555 | } |
| 556 | }]}, |
| 557 | "finish_reason": null |
| 558 | }] |
| 559 | })), |
| 560 | sse_line(serde_json::json!({ |
| 561 | "id": "chatcmpl-plugin-tool", |
| 562 | "object": "chat.completion.chunk", |
| 563 | "model": "deepseek-v4-pro", |
| 564 | "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], |
| 565 | "usage": {"prompt_tokens": 10, "completion_tokens": 6, "total_tokens": 16} |
| 566 | })), |
| 567 | "data: [DONE]\n\n".to_string(), |
| 568 | ] |
| 569 | .join("") |
| 570 | } |
| 571 | |
| 572 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 573 | fn spawn_hermetic_model_server() -> ( |
| 574 | String, |
| 575 | std::sync::mpsc::Sender<()>, |
| 576 | std::thread::JoinHandle<()>, |
| 577 | ) { |
| 578 | use tiny_http::{Header, Method, Response, Server}; |
| 579 | |
| 580 | let server = Server::http("127.0.0.1:0").expect("loopback model server"); |
| 581 | let base_url = format!( |
| 582 | "http://{}/v1", |
| 583 | server.server_addr().to_ip().expect("loopback address") |
| 584 | ); |
| 585 | let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel(); |
| 586 | let handle = std::thread::spawn(move || { |
| 587 | loop { |
| 588 | let request = match server.recv_timeout(std::time::Duration::from_millis(100)) { |
| 589 | Ok(Some(request)) => request, |
| 590 | Ok(None) => { |
| 591 | if shutdown_rx.try_recv().is_ok() { |
| 592 | break; |
| 593 | } |
| 594 | continue; |
| 595 | } |
| 596 | Err(_) => break, |
| 597 | }; |
| 598 | let mut request = request; |
| 599 | let url = request.url().to_string(); |
| 600 | if request.method() == &Method::Get && url.ends_with("/models") { |
| 601 | let response = Response::from_string( |
| 602 | r#"{"object":"list","data":[{"id":"deepseek-v4-pro","object":"model"}]}"#, |
| 603 | ) |
| 604 | .with_header( |
| 605 | Header::from_bytes("content-type", "application/json").expect("JSON header"), |
| 606 | ); |
| 607 | let _ = request.respond(response); |
| 608 | continue; |
| 609 | } |
| 610 | let mut body = String::new(); |
| 611 | let _ = request.as_reader().read_to_string(&mut body); |
| 612 | let current_user = serde_json::from_str::<serde_json::Value>(&body) |
| 613 | .ok() |
| 614 | .and_then(|request| request.get("messages")?.as_array().cloned()) |
| 615 | .and_then(|messages| { |
| 616 | messages.into_iter().rev().find_map(|message| { |
| 617 | (message.get("role")?.as_str()? == "user") |
| 618 | .then(|| message.get("content")?.as_str().map(str::to_owned))? |
| 619 | }) |
| 620 | }) |
| 621 | .unwrap_or_default(); |
| 622 | let stream = if current_user.contains("hang plugin call") { |
| 623 | tool_call_sse(true) |
| 624 | } else if body.contains("plugin-echo:acceptance") { |
| 625 | text_sse("binary plugin call complete") |
| 626 | } else if body.contains("call plugin echo") { |
| 627 | tool_call_sse(false) |
| 628 | } else { |
| 629 | text_sse("binary fixture acknowledged") |
| 630 | }; |
| 631 | let response = Response::from_string(stream).with_header( |
| 632 | Header::from_bytes("content-type", "text/event-stream").expect("SSE header"), |
| 633 | ); |
| 634 | let _ = request.respond(response); |
| 635 | } |
| 636 | }); |
| 637 | (base_url, shutdown_tx, handle) |
| 638 | } |
| 639 | |
| 640 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 641 | fn submit_tui_command(tui: &mut Harness, text: &str) { |
| 642 | tui.send(keys::key::text(text)).expect("type TUI command"); |
| 643 | tui.wait_for_text(text, std::time::Duration::from_secs(3)) |
| 644 | .expect("typed command visible"); |
| 645 | std::thread::sleep(std::time::Duration::from_millis(180)); |
| 646 | tui.pump(); |
| 647 | tui.send(keys::key::enter()).expect("submit TUI command"); |
| 648 | } |
| 649 | |
| 650 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 651 | fn visible_review_confirmation(tui: &mut Harness) -> Option<String> { |
| 652 | tui.pump(); |
| 653 | review_confirmation_in_text(&tui.frame().text()) |
| 654 | } |
| 655 | |
| 656 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 657 | fn review_confirmation_in_text(text: &str) -> Option<String> { |
| 658 | text.lines().map(str::trim).find_map(|line| { |
| 659 | let token = line.strip_prefix("/plugin trust demo ")?; |
| 660 | (token.contains('.') |
| 661 | && token.len() >= 17 |
| 662 | && token.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '.')) |
| 663 | .then(|| line.to_string()) |
| 664 | }) |
| 665 | } |
| 666 | |
| 667 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 668 | fn wait_for_log(tui: &mut Harness, path: &std::path::Path, needle: &str) { |
| 669 | let deadline = std::time::Instant::now() + BINARY_ACCEPTANCE_TIMEOUT; |
| 670 | loop { |
| 671 | tui.pump(); |
| 672 | if std::fs::read_to_string(path).is_ok_and(|body| body.contains(needle)) { |
| 673 | return; |
| 674 | } |
| 675 | assert!( |
| 676 | std::time::Instant::now() < deadline, |
| 677 | "plugin MCP log did not contain {needle:?}\n{}", |
| 678 | tui.debug_dump() |
| 679 | ); |
| 680 | std::thread::sleep(std::time::Duration::from_millis(40)); |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | /// Exercise the distributed binary through a real PTY and a sealed home. The |
| 685 | /// only socket is a test-owned loopback model endpoint; plugin execution is |
| 686 | /// stdio-only and receives no real credentials or ambient secret environment. |
| 687 | #[cfg(all(unix, feature = "long-running-tests"))] |
| 688 | #[tokio::test(flavor = "current_thread")] |
| 689 | async fn plugin_toml_binary_lifecycle_skill_and_stdio_mcp_acceptance() { |
| 690 | static ACCEPTANCE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 691 | let _serial = ACCEPTANCE_LOCK |
| 692 | .lock() |
| 693 | .unwrap_or_else(|lock| lock.into_inner()); |
| 694 | let workspace = make_sealed_workspace().expect("sealed workspace"); |
| 695 | let bundle = write_reviewed_bundle_fixture(workspace.workspace()); |
| 696 | let mcp_log = workspace.home().join(".codewhale/plugin-acceptance.log"); |
| 697 | let (base_url, shutdown_tx, model_thread) = spawn_hermetic_model_server(); |
| 698 | let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui")) |
| 699 | .cwd(workspace.workspace()) |
| 700 | .clear_env() |
| 701 | .seal_home(workspace.home()) |
| 702 | .env("DEEPSEEK_API_KEY", "sealed-plugin-acceptance-key") |
| 703 | .env("DEEPSEEK_BASE_URL", &base_url) |
| 704 | .env("DEEPSEEK_MODEL", "deepseek-v4-pro") |
| 705 | .env("PLUGIN_ACCEPTANCE_LOG", mcp_log.to_string_lossy()) |
| 706 | .env("NO_ANIMATIONS", "1") |
| 707 | .env("RUST_LOG", "warn") |
| 708 | .args([ |
| 709 | "--workspace", |
| 710 | workspace.workspace().to_str().expect("workspace UTF-8"), |
| 711 | "--no-project-config", |
| 712 | "--skip-onboarding", |
| 713 | "--fresh", |
| 714 | ]) |
| 715 | .size(52, 200) |
| 716 | .spawn() |
| 717 | .expect("start distributed TUI binary"); |
| 718 | tui.wait_for_text("Write a task", BINARY_ACCEPTANCE_TIMEOUT) |
| 719 | .expect("TUI composer"); |
| 720 | |
| 721 | submit_tui_command(&mut tui, "/plugin show demo"); |
| 722 | tui.wait_for_text("Qualified skills: [demo:review]", BINARY_ACCEPTANCE_TIMEOUT) |
| 723 | .expect("show reviewed Skill inventory"); |
| 724 | assert!( |
| 725 | !workspace |
| 726 | .home() |
| 727 | .join(".codewhale/plugins/state.json") |
| 728 | .exists(), |
| 729 | "show must remain read-only" |
| 730 | ); |
| 731 | |
| 732 | submit_tui_command(&mut tui, "/plugin trust demo"); |
| 733 | tui.wait_for( |
| 734 | |frame| review_confirmation_in_text(&frame.text()).is_some(), |
| 735 | BINARY_ACCEPTANCE_TIMEOUT, |
| 736 | ) |
| 737 | .expect("review confirmation"); |
| 738 | let confirmation = visible_review_confirmation(&mut tui) |
| 739 | .unwrap_or_else(|| panic!("review confirmation not visible\n{}", tui.debug_dump())); |
| 740 | submit_tui_command(&mut tui, &confirmation); |
| 741 | tui.wait_for_text("Plugin bundle 'demo': trusted.", BINARY_ACCEPTANCE_TIMEOUT) |
| 742 | .expect("trust receipt"); |
| 743 | |
| 744 | submit_tui_command(&mut tui, "/plugin enable demo"); |
| 745 | tui.wait_for_text("Plugin bundle 'demo': enabled.", BINARY_ACCEPTANCE_TIMEOUT) |
| 746 | .expect("bundle enabled"); |
| 747 | submit_tui_command(&mut tui, "$demo:review"); |
| 748 | tui.wait_for_text("Activated skill: demo:review", BINARY_ACCEPTANCE_TIMEOUT) |
| 749 | .expect("reviewed Skill dispatch"); |
| 750 | |
| 751 | submit_tui_command(&mut tui, "call plugin echo"); |
| 752 | tui.wait_for_text("Do you want to proceed?", BINARY_ACCEPTANCE_TIMEOUT) |
| 753 | .expect("MCP approval prompt"); |
| 754 | tui.send(keys::key::ch('2')) |
| 755 | .expect("approve this reviewed MCP kind for the sealed session"); |
| 756 | wait_for_log(&mut tui, &mcp_log, "started"); |
| 757 | wait_for_log(&mut tui, &mcp_log, "api-key-present:false"); |
| 758 | wait_for_log(&mut tui, &mcp_log, "tools:list"); |
| 759 | wait_for_log(&mut tui, &mcp_log, "call:echo"); |
| 760 | tui.wait_for_text("binary plugin call complete", BINARY_ACCEPTANCE_TIMEOUT) |
| 761 | .expect("plugin tool result returned to model"); |
| 762 | |
| 763 | submit_tui_command(&mut tui, "hang plugin call"); |
| 764 | wait_for_log(&mut tui, &mcp_log, "call:hang"); |
| 765 | tui.send([0x03]).expect("interrupt hanging plugin turn"); |
| 766 | std::thread::sleep(std::time::Duration::from_millis(300)); |
| 767 | tui.pump(); |
| 768 | tui.send([0x15]) |
| 769 | .expect("clear the interrupted prompt restored into the composer"); |
| 770 | submit_tui_command(&mut tui, "/plugin revoke demo"); |
| 771 | tui.wait_for_text( |
| 772 | "Plugin bundle 'demo': trust-revoked.", |
| 773 | BINARY_ACCEPTANCE_TIMEOUT, |
| 774 | ) |
| 775 | .expect("bundle trust revoked"); |
| 776 | wait_for_log(&mut tui, &mcp_log, "signal:"); |
| 777 | |
| 778 | let state = std::fs::read_to_string(workspace.home().join(".codewhale/plugins/state.json")) |
| 779 | .expect("durable plugin state"); |
| 780 | assert!(state.contains("\"enabled\": true")); |
| 781 | assert!(state.contains("\"trust\": null")); |
| 782 | assert!(bundle.join("server.py").exists(), "source bundle preserved"); |
| 783 | |
| 784 | let _ = tui.shutdown(); |
| 785 | let _ = shutdown_tx.send(()); |
| 786 | let _ = model_thread.join(); |
| 787 | } |
| 788 | |
| 789 | // --------------------------------------------------------------------------- |
| 790 | // Scenario runners |
| 791 | // --------------------------------------------------------------------------- |
| 792 | |
| 793 | #[tokio::test(flavor = "current_thread")] |
| 794 | async fn plugin_discovery_happy_path() { |
| 795 | run_scenario(DISCOVERY_SCENARIO, 9).await; |
| 796 | } |
| 797 | |
| 798 | #[tokio::test(flavor = "current_thread")] |
| 799 | async fn plugin_discovery_empty_directory() { |
| 800 | run_scenario(EMPTY_SCENARIO, 4).await; |
| 801 | } |
| 802 | |
| 803 | #[tokio::test(flavor = "current_thread")] |
| 804 | async fn plugin_discovery_missing_directory() { |
| 805 | run_scenario(MISSING_SCENARIO, 4).await; |
| 806 | } |
| 807 | |
| 808 | async fn run_scenario(name: &'static str, expected_steps: usize) { |
| 809 | let writer = PluginE2EWorld::cucumber() |
| 810 | .fail_on_skipped() |
| 811 | .with_default_cli() |
| 812 | .filter_run(FEATURE_PATH, move |feature, _, scenario| { |
| 813 | feature.name == FEATURE_NAME && scenario.name == name |
| 814 | }) |
| 815 | .await; |
| 816 | assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}"); |
| 817 | assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}"); |
| 818 | assert_eq!( |
| 819 | writer.passed_steps(), |
| 820 | expected_steps, |
| 821 | "scenario did not run: {name}" |
| 822 | ); |
| 823 | } |
| 824 | |
| 825 | // --------------------------------------------------------------------------- |
| 826 | // Helpers |
| 827 | // --------------------------------------------------------------------------- |
| 828 | |
| 829 | fn codewhale_tui_binary() -> PathBuf { |
| 830 | if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") { |
| 831 | return PathBuf::from(path); |
| 832 | } |
| 833 | if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") { |
| 834 | return PathBuf::from(path); |
| 835 | } |
| 836 | |
| 837 | let mut path = std::env::current_exe().expect("current test executable path"); |
| 838 | path.pop(); |
| 839 | if path.ends_with("deps") { |
| 840 | path.pop(); |
| 841 | } |
| 842 | path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX)); |
| 843 | path |
| 844 | } |
| 845 |