crates/tui/src/plugins_skill_tests.rs
Rama main · réplica de lectura del repositorio original.
use std::fs;
use std::path::{Path, PathBuf};
use codewhale_plugins::activation::PluginActivationCapability;
use codewhale_plugins::discovery::{DiscoveryConfig, discover_with_config};
use codewhale_plugins::manifest::{PluginCompatibility, capability_hash_v1, capability_hash_v2};
use codewhale_plugins::types::{PluginDiagnosticLevel, PluginTrustStatus};
fn config(root: &Path) -> DiscoveryConfig {
DiscoveryConfig {
workspace: root.join("project"),
user_plugins_dir: root.join("user"),
workspace_plugins_dir: root.join("workspace"),
builtin_plugin_dirs: Vec::new(),
state_path: root.join("state/plugin-state.json"),
}
}
fn write_plugin(config: &DiscoveryConfig, extra: &str) -> PathBuf {
write_named_plugin(config, "demo", extra)
}
fn write_named_plugin(config: &DiscoveryConfig, name: &str, extra: &str) -> PathBuf {
let plugin = config.user_plugins_dir.join(name);
fs::create_dir_all(&plugin).unwrap();
fs::write(
plugin.join("plugin.toml"),
format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n{extra}"),
)
.unwrap();
plugin
}
fn write_mixed_bundle(config: &DiscoveryConfig) -> PathBuf {
let plugin = write_plugin(
config,
"\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\n[hooks]\npath = \"hooks\"\n[lsp]\npath = \"lsp\"\n",
);
fs::create_dir_all(plugin.join("skills/demo")).unwrap();
fs::write(
plugin.join("skills/demo/SKILL.md"),
"---\nname: demo\ndescription: first\n---\nbody\n",
)
.unwrap();
fs::create_dir_all(plugin.join("commands")).unwrap();
fs::create_dir_all(plugin.join("hooks")).unwrap();
fs::create_dir_all(plugin.join("lsp")).unwrap();
plugin
}
fn replace_capability_hashes(value: &mut serde_json::Value, from: &str, to: &str) {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map.iter_mut() {
if key == "capability_hash" && child.as_str() == Some(from) {
*child = serde_json::Value::String(to.to_string());
} else {
replace_capability_hashes(child, from, to);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
replace_capability_hashes(item, from, to);
}
}
_ => {}
}
}
#[test]
fn mixed_supported_and_unsupported_components_activate_only_supported_surfaces() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_mixed_bundle(&config);
let mut registry = discover_with_config(&config);
let plugin = registry.get("demo").unwrap();
assert_eq!(plugin.compatibility(), PluginCompatibility::Partial);
assert_eq!(
plugin.inventory.supported_labels(),
vec!["skills", "commands", "hooks"]
);
assert_eq!(plugin.inventory.unsupported_labels(), vec!["lsp"]);
assert!(
plugin.diagnostics.iter().any(|diagnostic| {
diagnostic.level == PluginDiagnosticLevel::Warning
&& diagnostic.code == "component-inactive"
&& diagnostic.message.contains("lsp")
}),
"inactive components must stay visible in diagnostics: {:?}",
plugin.diagnostics
);
registry.trust("demo").unwrap();
registry.enable("demo").unwrap();
let plugin = registry.get("demo").unwrap();
assert!(plugin.active());
assert_eq!(plugin.state_label(), "active");
assert_eq!(plugin.compatibility(), PluginCompatibility::Partial);
assert_eq!(plugin.inventory.commands, 1);
assert_eq!(plugin.inventory.hooks, 1);
assert_eq!(plugin.skill_snapshots.len(), 1);
assert_eq!(plugin.skill_snapshots[0].name, "demo");
assert!(plugin.component_active(PluginActivationCapability::Skills));
assert!(plugin.component_active(PluginActivationCapability::Commands));
assert!(plugin.component_active(PluginActivationCapability::Hooks));
assert!(!plugin.component_active(PluginActivationCapability::Lsp));
let skills = codewhale_skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(®istry),
);
assert_eq!(skills.get("demo:demo").unwrap().body.trim(), "body");
}
#[test]
fn mixed_bundle_revocation_and_trust_changes_deactivate_supported_surfaces() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
let plugin = write_mixed_bundle(&config);
let mut registry = discover_with_config(&config);
registry.trust("demo").unwrap();
registry.enable("demo").unwrap();
assert!(registry.is_active("demo"));
registry.revoke_trust("demo").unwrap();
let revoked = registry.get("demo").unwrap();
assert!(revoked.enabled);
assert!(!revoked.trusted());
assert!(!revoked.active());
assert_eq!(revoked.compatibility(), PluginCompatibility::Partial);
let skills = codewhale_skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(®istry),
);
assert!(
skills.get("demo:demo").is_none(),
"revoking trust must drop the supported Skill adapter"
);
registry.trust("demo").unwrap();
assert!(
!registry.is_active("demo"),
"re-trust must not reuse the previous enablement bit"
);
registry.enable("demo").unwrap();
assert!(registry.is_active("demo"));
fs::write(
plugin.join("skills/demo/SKILL.md"),
"---\nname: demo\ndescription: changed\n---\nbody two\n",
)
.unwrap();
let changed = discover_with_config(&config);
let plugin = changed.get("demo").unwrap();
assert_eq!(plugin.trust_status, PluginTrustStatus::ContentChanged);
assert!(plugin.enabled);
assert!(!plugin.active());
let skills = codewhale_skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&changed),
);
assert!(skills.get("demo:demo").is_none());
}
#[test]
fn legacy_trust_receipts_fail_closed_as_needs_review_under_v3() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
let plugin = write_plugin(&config, "\n[skills]\npath = \"skills\"\n");
fs::create_dir_all(plugin.join("skills/demo")).unwrap();
fs::write(
plugin.join("skills/demo/SKILL.md"),
"---\nname: demo\ndescription: first\n---\nbody\n",
)
.unwrap();
let mut first = discover_with_config(&config);
first.trust("demo").unwrap();
first.enable("demo").unwrap();
assert!(first.is_active("demo"));
let plugin = first.get("demo").unwrap();
let legacy_hashes = [
("v1", capability_hash_v1(&plugin.inventory)),
("v2", capability_hash_v2(&plugin.inventory)),
];
let v3_hash = plugin.capability_hash.clone();
assert!(
legacy_hashes.iter().all(|(_, hash)| hash != &v3_hash),
"v3 receipts must not collide with either historical domain"
);
let content_hash = plugin.content_hash.clone();
let raw = fs::read_to_string(&config.state_path).unwrap();
for (version, legacy_hash) in legacy_hashes {
let mut parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
replace_capability_hashes(&mut parsed, &v3_hash, &legacy_hash);
fs::write(
&config.state_path,
serde_json::to_string_pretty(&parsed).unwrap(),
)
.unwrap();
let restarted = discover_with_config(&config);
let plugin = restarted.get("demo").unwrap();
assert_eq!(plugin.content_hash, content_hash);
assert_eq!(plugin.capability_hash, v3_hash);
assert_eq!(plugin.trust_status, PluginTrustStatus::CapabilitiesChanged);
assert!(plugin.enabled);
assert!(!plugin.trusted());
assert!(!plugin.active());
assert!(restarted.authority_for("demo").is_some());
let skills = codewhale_skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&restarted),
);
assert!(
skills.get("demo:demo").is_none(),
"a {version} receipt must not activate Skills after the v3 policy binding"
);
}
}
#[test]
fn workspace_scoped_registries_do_not_cross_load_skills() {
let tmp = tempfile::tempdir().unwrap();
let left_config = config(&tmp.path().join("left"));
let right_config = config(&tmp.path().join("right"));
for (config, body) in [(&left_config, "left body"), (&right_config, "right body")] {
let plugin = write_plugin(config, "\n[skills]\npath = \"skills\"\n");
fs::create_dir_all(plugin.join("skills/only")).unwrap();
fs::write(
plugin.join("skills/only/SKILL.md"),
format!("---\nname: only\ndescription: scoped\n---\n{body}\n"),
)
.unwrap();
}
let mut left = discover_with_config(&left_config);
left.trust("demo").unwrap();
left.enable("demo").unwrap();
let mut right = discover_with_config(&right_config);
right.trust("demo").unwrap();
right.enable("demo").unwrap();
let left_skills = codewhale_skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&left),
);
let right_skills = codewhale_skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&right),
);
assert_eq!(left_skills.get("demo:only").unwrap().body, "left body");
assert_eq!(right_skills.get("demo:only").unwrap().body, "right body");
assert_ne!(left.workspace(), right.workspace());
}
/// A DSH bundle package: one remote MCP row and one skill directory.
fn write_dsh_package(root: &Path, url: &str) -> PathBuf {
let package = root.join("dsh-source");
fs::create_dir_all(package.join("pack-skills/guide")).unwrap();
fs::write(
package.join("package.json"),
serde_json::json!({"name": "@demo/docs-dsh", "version": "1.0.0",
"dsh": {"bundle": {"patch": "./cordis.patch.yml"}}})
.to_string(),
)
.unwrap();
fs::write(
package.join("cordis.patch.yml"),
format!(
"- insert:\n - id: docs\n name: '@deepseek-ai/dsh-mcp-client'\n config: {{serverName: docs, transport: streamable-http, url: '{url}'}}\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"
),
)
.unwrap();
fs::write(
package.join("pack-skills/guide/SKILL.md"),
"---\nname: guide\ndescription: Bundled guide\n---\nBody.\n",
)
.unwrap();
package
}
fn allow_all_network() -> codewhale_net::network_policy::NetworkPolicy {
codewhale_net::network_policy::NetworkPolicy {
default: codewhale_net::network_policy::DecisionToml::Allow,
..Default::default()
}
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap()
.block_on(future)
}
/// DSH import is the ordinary reviewed install: the preview hash is what an
/// exact install stages, the bundle lands disabled and untrusted, update
/// re-converts the recorded package, and a changed package cannot be
/// installed against a stale review or keep its trust.
#[test]
fn dsh_packages_import_through_the_reviewed_install_and_update_flow() {
codewhale_engine::extension_host::install_ports();
let tmp = tempfile::tempdir().unwrap();
let _home = codewhale_runtime::test_support::SealedHome::at(tmp.path());
let config = config(tmp.path());
let network = allow_all_network();
let package = write_dsh_package(tmp.path(), "https://docs.example.invalid/mcp");
let (conversion, reviewed) = codewhale_plugins::install::preview_dsh(&package).unwrap();
assert_eq!(conversion.plugin_name, "docs-dsh");
assert_eq!(conversion.remote_servers, ["docs"]);
assert_eq!(conversion.skills, ["guide"]);
assert!(
conversion
.outcomes
.iter()
.any(|o| o.needs_manual_port() && o.row.as_deref() == Some("theme"))
);
let source =
codewhale_plugins::install::PluginInstallSource::parse(package.to_str().unwrap()).unwrap();
assert!(
matches!(
source,
codewhale_plugins::install::PluginInstallSource::Dsh(_)
),
"{source:?}"
);
let outcome = block_on(
codewhale_plugins::install::install_with_expected_content_hash(
source,
&config.user_plugins_dir,
codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
&network,
&|_| None,
&reviewed,
),
)
.unwrap();
let codewhale_plugins::install::PluginInstallOutcome::Installed(installed) = outcome else {
panic!("DSH import must install");
};
assert_eq!(installed.content_hash, reviewed);
let marker = fs::read_to_string(
config
.user_plugins_dir
.join("docs-dsh")
.join(codewhale_skill_install::install::INSTALLED_FROM_MARKER),
)
.unwrap();
assert!(marker.contains("dsh:"), "{marker}");
let mut registry = discover_with_config(&config);
let plugin = registry.get("docs-dsh").unwrap();
assert!(!plugin.enabled && !plugin.trusted());
registry.trust("docs-dsh").unwrap();
registry.enable("docs-dsh").unwrap();
assert!(registry.is_active("docs-dsh"));
let unchanged = block_on(codewhale_plugins::install::update(
"docs-dsh",
&config.user_plugins_dir,
codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
&network,
))
.unwrap();
assert!(matches!(
unchanged,
codewhale_plugins::install::PluginUpdateResult::NoChange
));
fs::remove_dir_all(&package).unwrap();
write_dsh_package(tmp.path(), "https://docs-v2.example.invalid/mcp");
let stale = block_on(
codewhale_plugins::install::install_with_expected_content_hash(
codewhale_plugins::install::PluginInstallSource::Dsh(package.clone()),
&tmp.path().join("other-plugins"),
codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
&network,
&|_| None,
&reviewed,
),
)
.unwrap_err();
assert!(
stale.to_string().contains("changed after review"),
"{stale:#}"
);
let updated = block_on(codewhale_plugins::install::update(
"docs-dsh",
&config.user_plugins_dir,
codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
&network,
))
.unwrap();
assert!(matches!(
updated,
codewhale_plugins::install::PluginUpdateResult::Updated(_)
));
let registry = discover_with_config(&config);
assert!(
!registry.get("docs-dsh").unwrap().trusted(),
"changed converted bytes invalidate the trust receipt"
);
}