diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 705acfd041..b6ff7b79d2 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -587,7 +587,12 @@ impl LocalBackend { } pub async fn open(path: &Path) -> Result { - Self::open_with_app_version(path, env!("CARGO_PKG_VERSION")).await + // The standalone MCP binary and CLI are versioned independently from + // the DBX app, so their crate version must not stand in for the app + // version during plugin `engines.dbx` checks: a plugin requiring + // DBX >= 0.5.68 would be rejected against e.g. 0.4.90 (#9595). An + // empty version makes the compatibility check skip that requirement. + Self::open_with_app_version(path, "").await } /// Same as [`open`], but lets tests and embedded callers pin the app version @@ -3828,6 +3833,43 @@ mod tests { assert_eq!(backend.state().agent_manager.base_dir(), &agent_dir); } + #[tokio::test] + async fn local_backend_standalone_open_skips_plugin_dbx_engine_gate() { + let data_dir = tempfile::tempdir().unwrap(); + let database_path = data_dir.path().join("dbx.db"); + let plugin_dir = data_dir.path().join("plugins").join("io.dbx.gated"); + std::fs::create_dir_all(plugin_dir.join("ui")).unwrap(); + std::fs::write(plugin_dir.join("ui").join("index.html"), "").unwrap(); + std::fs::write( + plugin_dir.join("manifest.json"), + r#"{ + "manifest_version": 1, + "id": "io.dbx.gated", + "name": "Gated", + "version": "1.0.0", + "publisher": "example", + "engines": { "dbx": ">=999.0.0", "host_api": "^1.0" }, + "entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }, + "permissions": ["host.events"] + }"#, + ) + .unwrap(); + let storage = Storage::open(&database_path).await.unwrap(); + drop(storage); + + // The standalone host has no app version to compare against (#9595). + let backend = LocalBackend::open(&database_path).await.unwrap(); + let installed = backend.state().plugins.list_installed().unwrap(); + let plugin = installed.iter().find(|plugin| plugin.manifest.id == "io.dbx.gated").unwrap(); + assert!(plugin.compatibility.compatible, "{:?}", plugin.compatibility.errors); + + // A host that knows the app version keeps enforcing the requirement. + let backend = LocalBackend::open_with_app_version(&database_path, "0.6.16").await.unwrap(); + let installed = backend.state().plugins.list_installed().unwrap(); + let plugin = installed.iter().find(|plugin| plugin.manifest.id == "io.dbx.gated").unwrap(); + assert!(!plugin.compatibility.compatible, "{:?}", plugin.compatibility.errors); + } + #[test] fn local_plugin_directory_honors_desktop_storage_settings() { let data_dir = Path::new("C:/Users/user/AppData/Roaming/com.dbx.app"); diff --git a/crates/dbx-plugin-runtime/src/plugins/manifest.rs b/crates/dbx-plugin-runtime/src/plugins/manifest.rs index a80df0f850..d42fa367f1 100644 --- a/crates/dbx-plugin-runtime/src/plugins/manifest.rs +++ b/crates/dbx-plugin-runtime/src/plugins/manifest.rs @@ -1009,6 +1009,13 @@ fn validate_engine_requirement(label: &str, requirement: &str, actual: &str, err if requirement.trim().is_empty() { return; } + // An empty installed version means the host could not identify itself (the + // standalone MCP binary and CLI are versioned independently from the DBX + // app). The requirement is unverifiable there, not unsatisfied, so skip it + // instead of failing every plugin (#9595). + if actual.trim().is_empty() { + return; + } let requirement = match VersionReq::parse(requirement.trim()) { Ok(requirement) => requirement, Err(error) => { @@ -1608,6 +1615,34 @@ mod tests { assert_eq!(manifest.localizations["zh-CN"].name.as_deref(), Some("本地化插件")); } + #[test] + fn compatibility_skips_dbx_engine_gate_for_unknown_host_version() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("ui")).unwrap(); + std::fs::write(dir.path().join("ui").join("index.html"), "").unwrap(); + let manifest: PluginManifest = serde_json::from_value(serde_json::json!({ + "manifest_version": 1, + "id": "io.dbx.example", + "name": "Example", + "version": "1.0.0", + "publisher": "example", + "engines": { "dbx": ">=999.0.0", "host_api": "^1.0" }, + "entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }, + "permissions": ["host.events"] + })) + .unwrap(); + + // A standalone host reports no app version: the requirement is + // unverifiable there, not unsatisfied (#9595). + let compatibility = manifest.compatibility(dir.path(), ""); + assert!(compatibility.compatible, "{:?}", compatibility.errors); + + // A host that knows the app version keeps enforcing the gate. + let compatibility = manifest.compatibility(dir.path(), "0.6.16"); + assert!(!compatibility.compatible, "{:?}", compatibility.errors); + assert!(compatibility.errors.iter().any(|error| error.contains(">=999.0.0")), "{:?}", compatibility.errors); + } + #[test] fn rejects_invalid_localization_metadata() { let dir = tempfile::tempdir().unwrap();