feat(backend): système de plugins — domaine, application, infrastructure (#43)
Lots B1-B4 : modèle de domaine des plugins (manifeste, capacités menus/layouts/MCP), port et registre applicatif, chargement/validation en infrastructure, exposition DTO et commandes Tauri. Tests cargo verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -1971,6 +1971,7 @@ dependencies = [
|
||||
"fastembed",
|
||||
"futures-util",
|
||||
"git2",
|
||||
"hex",
|
||||
"landlock",
|
||||
"notify",
|
||||
"portable-pty",
|
||||
@ -1978,6 +1979,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
|
||||
@ -26,6 +26,7 @@ hex = "0.4"
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
getrandom = "0.3"
|
||||
http = "1"
|
||||
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
|
||||
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
||||
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||
|
||||
@ -32,12 +32,12 @@ tauri-plugin-dialog = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
http = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
base64 = "0.22"
|
||||
bytes = "1.11"
|
||||
cookie = "0.18"
|
||||
http = "1.4"
|
||||
http-body-util = "0.1"
|
||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@ -21,6 +21,7 @@ pub mod events;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod openai_tools;
|
||||
pub mod plugins;
|
||||
pub mod pty;
|
||||
pub mod server;
|
||||
pub mod state;
|
||||
@ -133,6 +134,9 @@ pub fn dispatch() -> ExitCode {
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.register_uri_scheme_protocol("idea-plugin", |ctx, request| {
|
||||
plugins::plugin_asset_protocol(ctx.app_handle(), request)
|
||||
})
|
||||
.setup(|app| {
|
||||
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
|
||||
// and build the composition root once the app handle exists, so the
|
||||
@ -154,6 +158,7 @@ pub fn run() {
|
||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||
|
||||
let embedded_server = Arc::clone(&app_state.embedded_server);
|
||||
let plugin_mcp_reconcile = Arc::clone(&app_state.reconcile_plugin_mcp_servers);
|
||||
let core = app_state.core();
|
||||
app.manage(app_state);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@ -165,6 +170,11 @@ pub fn run() {
|
||||
);
|
||||
}
|
||||
});
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = plugin_mcp_reconcile.execute().await {
|
||||
application::diag!("[plugins] MCP reconcile at boot failed: {err}");
|
||||
}
|
||||
});
|
||||
|
||||
// Kill all live PTYs cleanly when the main window is closing. This is
|
||||
// independent of the per-view (navigation/layout) lifecycle — those
|
||||
@ -334,6 +344,14 @@ pub fn run() {
|
||||
commands::cancel_background_task,
|
||||
commands::retry_background_task,
|
||||
commands::list_background_tasks,
|
||||
plugins::plugin_list_plugins,
|
||||
plugins::plugin_review_package,
|
||||
plugins::plugin_install_from_archive,
|
||||
plugins::plugin_install_from_directory,
|
||||
plugins::plugin_set_enabled,
|
||||
plugins::plugin_uninstall,
|
||||
plugins::plugin_list_runtime_contributions,
|
||||
plugins::plugin_open_plugins_folder,
|
||||
commands::get_server_exposure_settings,
|
||||
commands::save_server_exposure_settings,
|
||||
commands::preview_server_exposure_settings,
|
||||
|
||||
293
crates/app-tauri/src/plugins.rs
Normal file
293
crates/app-tauri/src/plugins.rs
Normal file
@ -0,0 +1,293 @@
|
||||
//! Plugin Tauri commands and asset protocol.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
|
||||
use backend::dto::{
|
||||
ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto,
|
||||
PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto,
|
||||
};
|
||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||
use domain::{PluginId, RelativePath};
|
||||
use http::{header, Response, StatusCode};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Lists installed plugins for the admin surface.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_list_plugins(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<PluginAdminDto>, ErrorDto> {
|
||||
state
|
||||
.list_plugins
|
||||
.execute()
|
||||
.await
|
||||
.map(|plugins| plugins.into_iter().map(PluginAdminDto::from).collect())
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Reviews a local plugin package without committing it.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_review_package(
|
||||
input: ReviewPluginPackageDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginReviewDto, ErrorDto> {
|
||||
let input = match input.source_kind.as_str() {
|
||||
"archive" => ReviewPluginPackageInput::Archive { path: input.path },
|
||||
"directory" => ReviewPluginPackageInput::Directory { path: input.path },
|
||||
other => {
|
||||
return Err(ErrorDto::invalid(format!(
|
||||
"invalid plugin source kind: {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
state
|
||||
.review_plugin_package
|
||||
.execute(input)
|
||||
.await
|
||||
.map(PluginReviewDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Installs a plugin from a local archive.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_install_from_archive(
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||
state
|
||||
.install_plugin_from_archive
|
||||
.execute(path)
|
||||
.await
|
||||
.map(PluginInstallResultDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Installs a plugin from a local directory snapshot.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_install_from_directory(
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||
state
|
||||
.install_plugin_from_directory
|
||||
.execute(path)
|
||||
.await
|
||||
.map(PluginInstallResultDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Enables or disables a plugin.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_set_enabled(
|
||||
plugin_id: String,
|
||||
enabled: bool,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginAdminDto, ErrorDto> {
|
||||
state
|
||||
.set_plugin_enabled
|
||||
.execute(SetPluginEnabledInput { plugin_id, enabled })
|
||||
.await
|
||||
.map(PluginAdminDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Uninstalls a plugin.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_uninstall(
|
||||
plugin_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginUninstallResultDto, ErrorDto> {
|
||||
state
|
||||
.uninstall_plugin
|
||||
.execute(UninstallPluginInput { plugin_id })
|
||||
.await
|
||||
.map(PluginUninstallResultDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Lists active runtime contributions for frontend bootstrap.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_list_runtime_contributions(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PluginRuntimeContributionCatalogDto, ErrorDto> {
|
||||
state
|
||||
.list_plugin_runtime_contributions
|
||||
.execute()
|
||||
.await
|
||||
.map(PluginRuntimeContributionCatalogDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Opens the plugin store folder, or one plugin folder when an id is provided.
|
||||
#[tauri::command]
|
||||
pub fn plugin_open_plugins_folder(
|
||||
plugin_id: Option<String>,
|
||||
app: AppHandle,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let mut path = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| ErrorDto::invalid(e.to_string()))?
|
||||
.join("plugins");
|
||||
if let Some(id) = plugin_id {
|
||||
let id = PluginId::new(id).map_err(|e| ErrorDto::invalid(e.to_string()))?;
|
||||
path = path.join("installed").join(id.as_str());
|
||||
}
|
||||
open_folder(&path)
|
||||
}
|
||||
|
||||
/// Serves `idea-plugin://<pluginId>/<version>/<contentHash>/<relativePath>` after
|
||||
/// checking registry state, hash and root confinement.
|
||||
pub fn plugin_asset_protocol(
|
||||
app: &AppHandle,
|
||||
request: http::Request<Vec<u8>>,
|
||||
) -> Response<Vec<u8>> {
|
||||
match plugin_asset_response(app, request) {
|
||||
Ok(response) => response,
|
||||
Err((status, message)) => Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(message.into_bytes())
|
||||
.expect("valid protocol error response"),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_asset_response(
|
||||
app: &AppHandle,
|
||||
request: http::Request<Vec<u8>>,
|
||||
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
||||
let uri = request.uri();
|
||||
let plugin_id = uri
|
||||
.host()
|
||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing plugin id".to_owned()))
|
||||
.and_then(|raw| {
|
||||
PluginId::new(raw.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||
})?;
|
||||
let mut parts = uri.path().trim_start_matches('/').splitn(3, '/');
|
||||
let version_segment = parts.next().unwrap_or_default();
|
||||
let hash = parts.next().unwrap_or_default();
|
||||
let rel = parts.next().unwrap_or_default();
|
||||
if version_segment.is_empty() || hash.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid plugin asset URL".to_owned(),
|
||||
));
|
||||
}
|
||||
let rel =
|
||||
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
let state = app.state::<AppState>();
|
||||
let allowed = tauri::async_runtime::block_on(asset_allowed(
|
||||
&plugin_id,
|
||||
hash,
|
||||
&rel,
|
||||
state.plugin_registry_store.as_ref(),
|
||||
state.plugin_package_store.as_ref(),
|
||||
state.plugin_manifest_validator.as_ref(),
|
||||
))?;
|
||||
if !allowed {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"plugin asset is not active".to_owned(),
|
||||
));
|
||||
}
|
||||
let app_data = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let root = app_data
|
||||
.join("plugins")
|
||||
.join("installed")
|
||||
.join(plugin_id.as_str());
|
||||
let target = root.join(rel.as_str());
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
let target = target
|
||||
.canonicalize()
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
if !target.starts_with(&root) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"asset escapes plugin root".to_owned(),
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type(&target))
|
||||
.body(bytes)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||
}
|
||||
|
||||
async fn asset_allowed(
|
||||
plugin_id: &PluginId,
|
||||
hash: &str,
|
||||
rel: &RelativePath,
|
||||
registry_store: &dyn PluginRegistryStore,
|
||||
package_store: &dyn PluginPackageStore,
|
||||
validator: &dyn PluginManifestValidator,
|
||||
) -> Result<bool, (StatusCode, String)> {
|
||||
let registry = registry_store
|
||||
.load_registry()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let Some(entry) = registry.find(plugin_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !entry.lifecycle_state.is_runtime_active() || entry.content_hash.as_str() != hash {
|
||||
return Ok(false);
|
||||
}
|
||||
let package = domain::PluginPackageRef {
|
||||
plugin_id: Some(plugin_id.clone()),
|
||||
root: plugin_id.as_str().to_owned(),
|
||||
};
|
||||
let manifest_bytes = package_store
|
||||
.read_manifest(&package)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
let manifest = validator
|
||||
.validate(&manifest_bytes.bytes, &package)
|
||||
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
|
||||
let declared_icon = manifest.icon.as_ref() == Some(rel);
|
||||
let declared_main = manifest.main == *rel;
|
||||
Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
|
||||
}
|
||||
|
||||
fn content_type(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
|
||||
let mut command = if cfg!(target_os = "macos") {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.arg(path);
|
||||
c
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.args(["/C", "start", ""]).arg(path);
|
||||
c
|
||||
} else {
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
command
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| ErrorDto::invalid(e.to_string()))
|
||||
}
|
||||
65
crates/app-tauri/tests/dto_plugins.rs
Normal file
65
crates/app-tauri/tests/dto_plugins.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use app_tauri_lib::dto::{
|
||||
PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto,
|
||||
PluginRuntimePluginDto,
|
||||
};
|
||||
use domain::{PluginContributionSet, PluginLifecycleState, PluginTrustLevel};
|
||||
|
||||
#[test]
|
||||
fn plugin_admin_dto_serialises_exact_contract_shape() {
|
||||
let dto = PluginAdminDto {
|
||||
id: "dev.acme.gitgraph".to_owned(),
|
||||
display_name: "Git Graph".to_owned(),
|
||||
publisher: Some("Acme".to_owned()),
|
||||
version: "1.2.3".to_owned(),
|
||||
description: Some("Graph".to_owned()),
|
||||
icon_url: Some("idea-plugin://dev.acme.gitgraph/1.2.3/abc/assets/icon.svg".to_owned()),
|
||||
source_kind: "archive".to_owned(),
|
||||
source_label: Some("/tmp/gitgraph.ideaplug".to_owned()),
|
||||
lifecycle_state: PluginLifecycleState::PendingDisable,
|
||||
enabled: false,
|
||||
pending_enable_state: Some(false),
|
||||
pending_uninstall: false,
|
||||
restart_required: true,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
contribution_summary: PluginContributionSummaryDto {
|
||||
top_level_menus: 1,
|
||||
menu_items: 2,
|
||||
layouts: 3,
|
||||
mcp_servers: 4,
|
||||
},
|
||||
error: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(dto).unwrap();
|
||||
assert_eq!(value["displayName"], "Git Graph");
|
||||
assert_eq!(value["lifecycleState"], "pending-disable");
|
||||
assert_eq!(value["trustLevel"], "full");
|
||||
assert_eq!(value["contributionSummary"]["topLevelMenus"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
|
||||
let dto = PluginRuntimeContributionCatalogDto {
|
||||
plugins: vec![PluginRuntimePluginDto {
|
||||
id: "dev.acme.gitgraph".to_owned(),
|
||||
display_name: "Git Graph".to_owned(),
|
||||
publisher: None,
|
||||
version: "1.2.3".to_owned(),
|
||||
bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(),
|
||||
icon_url: None,
|
||||
content_hash: "abc".to_owned(),
|
||||
contributes: PluginContributionSet::default(),
|
||||
}],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(dto).unwrap();
|
||||
assert_eq!(
|
||||
value["plugins"][0]["bundleUrl"],
|
||||
"idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js"
|
||||
);
|
||||
assert_eq!(value["plugins"][0]["contentHash"], "abc");
|
||||
assert!(value["plugins"][0]["contributes"]["menus"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
@ -27,6 +27,7 @@ pub mod memory;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod plugin;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
@ -139,6 +140,13 @@ pub use permission::{
|
||||
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
||||
UpdateProjectPermissionsInput,
|
||||
};
|
||||
pub use plugin::{
|
||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator,
|
||||
ListPluginRuntimeContributions, ListPlugins, PluginAdmin, PluginContributionSummary,
|
||||
PluginInstallResult, PluginReview, PluginRuntimeCatalog, PluginRuntimePlugin,
|
||||
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
||||
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
|
||||
1714
crates/application/src/plugin/mod.rs
Normal file
1714
crates/application/src/plugin/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -629,6 +629,7 @@ fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option<String>, bool)> {
|
||||
Some((l.conversation_id.clone(), l.agent_was_running))
|
||||
}
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::CustomPluginLayout(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||
}
|
||||
@ -645,6 +646,7 @@ fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option<Option<String>> {
|
||||
match n {
|
||||
LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::CustomPluginLayout(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||
}
|
||||
|
||||
@ -218,6 +218,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
||||
match node {
|
||||
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::CustomPluginLayout(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
||||
match node {
|
||||
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::CustomPluginLayout(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||
}
|
||||
|
||||
@ -20,6 +20,253 @@ use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, Turn
|
||||
|
||||
pub use crate::ticket_dto::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugins (#43)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Plugin contribution summary DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginContributionSummaryDto {
|
||||
/// Top-level menus count.
|
||||
pub top_level_menus: usize,
|
||||
/// Menu items count.
|
||||
pub menu_items: usize,
|
||||
/// Layouts count.
|
||||
pub layouts: usize,
|
||||
/// MCP servers count.
|
||||
pub mcp_servers: usize,
|
||||
}
|
||||
|
||||
impl From<application::PluginContributionSummary> for PluginContributionSummaryDto {
|
||||
fn from(value: application::PluginContributionSummary) -> Self {
|
||||
Self {
|
||||
top_level_menus: value.top_level_menus,
|
||||
menu_items: value.menu_items,
|
||||
layouts: value.layouts,
|
||||
mcp_servers: value.mcp_servers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin plugin DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginAdminDto {
|
||||
/// Plugin id.
|
||||
pub id: String,
|
||||
/// Display name.
|
||||
pub display_name: String,
|
||||
/// Publisher.
|
||||
pub publisher: Option<String>,
|
||||
/// Version.
|
||||
pub version: String,
|
||||
/// Description.
|
||||
pub description: Option<String>,
|
||||
/// Icon URL.
|
||||
pub icon_url: Option<String>,
|
||||
/// Source kind.
|
||||
pub source_kind: String,
|
||||
/// Source label.
|
||||
pub source_label: Option<String>,
|
||||
/// Lifecycle state.
|
||||
pub lifecycle_state: domain::PluginLifecycleState,
|
||||
/// Enabled flag.
|
||||
pub enabled: bool,
|
||||
/// Pending enable state.
|
||||
pub pending_enable_state: Option<bool>,
|
||||
/// Pending uninstall flag.
|
||||
pub pending_uninstall: bool,
|
||||
/// Restart required flag.
|
||||
pub restart_required: bool,
|
||||
/// Trust level.
|
||||
pub trust_level: domain::PluginTrustLevel,
|
||||
/// Contribution summary.
|
||||
pub contribution_summary: PluginContributionSummaryDto,
|
||||
/// Optional error.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl From<application::PluginAdmin> for PluginAdminDto {
|
||||
fn from(value: application::PluginAdmin) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
display_name: value.display_name,
|
||||
publisher: value.publisher,
|
||||
version: value.version,
|
||||
description: value.description,
|
||||
icon_url: value.icon_url,
|
||||
source_kind: value.source_kind,
|
||||
source_label: value.source_label,
|
||||
lifecycle_state: value.lifecycle_state,
|
||||
enabled: value.enabled,
|
||||
pending_enable_state: value.pending_enable_state,
|
||||
pending_uninstall: value.pending_uninstall,
|
||||
restart_required: value.restart_required,
|
||||
trust_level: value.trust_level,
|
||||
contribution_summary: value.contribution_summary.into(),
|
||||
error: value.error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Review request DTO.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReviewPluginPackageDto {
|
||||
/// Source kind: `archive` or `directory`.
|
||||
pub source_kind: String,
|
||||
/// Local source path.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Plugin review DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginReviewDto {
|
||||
/// Manifest id.
|
||||
pub id: String,
|
||||
/// Display name.
|
||||
pub display_name: String,
|
||||
/// Publisher.
|
||||
pub publisher: Option<String>,
|
||||
/// Version.
|
||||
pub version: String,
|
||||
/// Description.
|
||||
pub description: Option<String>,
|
||||
/// Source kind.
|
||||
pub source_kind: String,
|
||||
/// Source label.
|
||||
pub source_label: Option<String>,
|
||||
/// Content hash.
|
||||
pub content_hash: String,
|
||||
/// Trust level.
|
||||
pub trust_level: domain::PluginTrustLevel,
|
||||
/// Summary.
|
||||
pub contribution_summary: PluginContributionSummaryDto,
|
||||
/// Manifest contributions.
|
||||
pub contributes: domain::PluginContributionSet,
|
||||
}
|
||||
|
||||
impl From<application::PluginReview> for PluginReviewDto {
|
||||
fn from(value: application::PluginReview) -> Self {
|
||||
Self {
|
||||
id: value.manifest.id.as_str().to_owned(),
|
||||
display_name: value.manifest.display_name,
|
||||
publisher: value.manifest.publisher,
|
||||
version: value.manifest.version.as_str().to_owned(),
|
||||
description: value.manifest.description,
|
||||
source_kind: value.source.kind().to_owned(),
|
||||
source_label: Some(value.source.label().to_owned()),
|
||||
content_hash: value.content_hash,
|
||||
trust_level: value.trust_level,
|
||||
contribution_summary: value.contribution_summary.into(),
|
||||
contributes: value.manifest.contributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin install result DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginInstallResultDto {
|
||||
/// Installed plugin.
|
||||
pub plugin: PluginAdminDto,
|
||||
/// Review.
|
||||
pub review: PluginReviewDto,
|
||||
/// Restart required.
|
||||
pub restart_required: bool,
|
||||
}
|
||||
|
||||
impl From<application::PluginInstallResult> for PluginInstallResultDto {
|
||||
fn from(value: application::PluginInstallResult) -> Self {
|
||||
Self {
|
||||
plugin: value.plugin.into(),
|
||||
review: value.review.into(),
|
||||
restart_required: value.restart_required,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin uninstall result DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginUninstallResultDto {
|
||||
/// Plugin id.
|
||||
pub plugin_id: String,
|
||||
/// Removal outcome.
|
||||
pub removal_outcome: domain::RemovalOutcome,
|
||||
/// Restart required.
|
||||
pub restart_required: bool,
|
||||
}
|
||||
|
||||
impl From<application::UninstallPluginResult> for PluginUninstallResultDto {
|
||||
fn from(value: application::UninstallPluginResult) -> Self {
|
||||
Self {
|
||||
plugin_id: value.plugin_id,
|
||||
removal_outcome: value.removal_outcome,
|
||||
restart_required: value.restart_required,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime catalog DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRuntimeContributionCatalogDto {
|
||||
/// Runtime plugins.
|
||||
pub plugins: Vec<PluginRuntimePluginDto>,
|
||||
}
|
||||
|
||||
/// Runtime plugin DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRuntimePluginDto {
|
||||
/// Plugin id.
|
||||
pub id: String,
|
||||
/// Display name.
|
||||
pub display_name: String,
|
||||
/// Publisher.
|
||||
pub publisher: Option<String>,
|
||||
/// Version.
|
||||
pub version: String,
|
||||
/// Bundle URL.
|
||||
pub bundle_url: String,
|
||||
/// Icon URL.
|
||||
pub icon_url: Option<String>,
|
||||
/// Content hash.
|
||||
pub content_hash: String,
|
||||
/// Contributions.
|
||||
pub contributes: domain::PluginContributionSet,
|
||||
}
|
||||
|
||||
impl From<application::PluginRuntimeCatalog> for PluginRuntimeContributionCatalogDto {
|
||||
fn from(value: application::PluginRuntimeCatalog) -> Self {
|
||||
Self {
|
||||
plugins: value
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(PluginRuntimePluginDto::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
|
||||
fn from(value: application::PluginRuntimePlugin) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
display_name: value.display_name,
|
||||
publisher: value.publisher,
|
||||
version: value.version,
|
||||
bundle_url: value.bundle_url,
|
||||
icon_url: value.icon_url,
|
||||
content_hash: value.content_hash,
|
||||
contributes: value.contributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for the `health` command.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@ -119,6 +119,60 @@ pub enum DomainEventDto {
|
||||
/// Project id (UUID string).
|
||||
project_id: String,
|
||||
},
|
||||
/// A plugin was installed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginInstalled {
|
||||
/// Plugin id.
|
||||
plugin_id: String,
|
||||
/// Installed version.
|
||||
version: String,
|
||||
},
|
||||
/// A plugin was enabled.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginEnabled {
|
||||
/// Plugin id.
|
||||
plugin_id: String,
|
||||
},
|
||||
/// A plugin was disabled.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginDisabled {
|
||||
/// Plugin id.
|
||||
plugin_id: String,
|
||||
/// Whether a restart is needed for full JS purge.
|
||||
restart_required: bool,
|
||||
},
|
||||
/// A plugin was uninstalled.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginUninstalled {
|
||||
/// Plugin id.
|
||||
plugin_id: String,
|
||||
/// Whether a restart is needed for full JS purge.
|
||||
restart_required: bool,
|
||||
},
|
||||
/// A plugin failed to load.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginLoadFailed {
|
||||
/// Plugin id.
|
||||
plugin_id: String,
|
||||
/// Failure reason.
|
||||
reason: String,
|
||||
},
|
||||
/// A plugin MCP server started.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginMcpServerStarted {
|
||||
/// Plugin id.
|
||||
plugin_id: String,
|
||||
/// Server id.
|
||||
server_id: String,
|
||||
},
|
||||
/// A plugin MCP server stopped.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PluginMcpServerStopped {
|
||||
/// Plugin id.
|
||||
plugin_id: String,
|
||||
/// Server id.
|
||||
server_id: String,
|
||||
},
|
||||
/// An agent was launched.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentLaunched {
|
||||
@ -677,6 +731,45 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
||||
project_id: project_id.to_string(),
|
||||
},
|
||||
DomainEvent::PluginInstalled { plugin_id, version } => Self::PluginInstalled {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
version: version.as_str().to_owned(),
|
||||
},
|
||||
DomainEvent::PluginEnabled { plugin_id } => Self::PluginEnabled {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
},
|
||||
DomainEvent::PluginDisabled {
|
||||
plugin_id,
|
||||
restart_required,
|
||||
} => Self::PluginDisabled {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
restart_required: *restart_required,
|
||||
},
|
||||
DomainEvent::PluginUninstalled {
|
||||
plugin_id,
|
||||
restart_required,
|
||||
} => Self::PluginUninstalled {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
restart_required: *restart_required,
|
||||
},
|
||||
DomainEvent::PluginLoadFailed { plugin_id, reason } => Self::PluginLoadFailed {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
reason: reason.clone(),
|
||||
},
|
||||
DomainEvent::PluginMcpServerStarted {
|
||||
plugin_id,
|
||||
server_id,
|
||||
} => Self::PluginMcpServerStarted {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
server_id: server_id.as_str().to_owned(),
|
||||
},
|
||||
DomainEvent::PluginMcpServerStopped {
|
||||
plugin_id,
|
||||
server_id,
|
||||
} => Self::PluginMcpServerStopped {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
server_id: server_id.as_str().to_owned(),
|
||||
},
|
||||
DomainEvent::AgentLaunched {
|
||||
agent_id,
|
||||
session_id,
|
||||
|
||||
@ -24,27 +24,30 @@ use application::{
|
||||
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates,
|
||||
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
||||
LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
||||
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
|
||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
||||
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice,
|
||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice,
|
||||
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
||||
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||
ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories,
|
||||
ListModelServers, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
||||
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
||||
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
||||
SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||
UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions,
|
||||
UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill,
|
||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue,
|
||||
UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
@ -53,9 +56,10 @@ use domain::ports::{
|
||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||
PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler,
|
||||
SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker,
|
||||
WakeError, WakeReason, WindowStateStore,
|
||||
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask,
|
||||
Scheduler, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore,
|
||||
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -73,10 +77,11 @@ use infrastructure::{
|
||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog,
|
||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
||||
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore,
|
||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
@ -1062,6 +1067,29 @@ pub struct BackendCore {
|
||||
pub retry_background_task: Arc<RetryBackgroundTask>,
|
||||
/// Store handle used by `list_background_tasks` to read the task read-model.
|
||||
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
||||
// --- Plugins (#43) ---
|
||||
/// Review a local plugin package before install.
|
||||
pub review_plugin_package: Arc<ReviewPluginPackage>,
|
||||
/// Install a plugin archive.
|
||||
pub install_plugin_from_archive: Arc<InstallPluginFromArchive>,
|
||||
/// Install a plugin directory snapshot.
|
||||
pub install_plugin_from_directory: Arc<InstallPluginFromDirectory>,
|
||||
/// List installed plugins for admin UI.
|
||||
pub list_plugins: Arc<ListPlugins>,
|
||||
/// Enable or disable a plugin.
|
||||
pub set_plugin_enabled: Arc<SetPluginEnabled>,
|
||||
/// Uninstall a plugin.
|
||||
pub uninstall_plugin: Arc<UninstallPlugin>,
|
||||
/// List runtime contributions for UI bootstrap.
|
||||
pub list_plugin_runtime_contributions: Arc<ListPluginRuntimeContributions>,
|
||||
/// Reconcile external MCP plugin servers.
|
||||
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
|
||||
/// Package store exposed for the Tauri asset protocol adapter.
|
||||
pub plugin_package_store: Arc<FsPluginPackageStore>,
|
||||
/// Registry store exposed for the Tauri asset protocol adapter.
|
||||
pub plugin_registry_store: Arc<dyn PluginRegistryStore>,
|
||||
/// Manifest validator exposed for the Tauri asset protocol adapter.
|
||||
pub plugin_manifest_validator: Arc<dyn PluginManifestValidator>,
|
||||
// --- Templates & sync (L7) ---
|
||||
/// Create a template in the global store.
|
||||
pub create_template: Arc<CreateTemplate>,
|
||||
@ -1233,6 +1261,17 @@ impl BackendCore {
|
||||
let pair_attempt_limiter_port =
|
||||
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
|
||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
|
||||
let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
|
||||
let plugin_validator =
|
||||
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
|
||||
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
|
||||
let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
|
||||
let plugin_manifest_validator =
|
||||
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
||||
let plugin_mcp_supervisor_port =
|
||||
Arc::clone(&plugin_mcp_supervisor) as Arc<dyn PluginMcpSupervisor>;
|
||||
|
||||
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
||||
let health = Arc::new(HealthUseCase::new(
|
||||
@ -2089,6 +2128,53 @@ impl BackendCore {
|
||||
Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
|
||||
Arc::clone(&spawn_background_command),
|
||||
));
|
||||
let review_plugin_package = Arc::new(ReviewPluginPackage::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
));
|
||||
let list_plugins = Arc::new(ListPlugins::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
));
|
||||
let install_plugin_from_archive = Arc::new(InstallPluginFromArchive::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
));
|
||||
let install_plugin_from_directory = Arc::new(InstallPluginFromDirectory::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
));
|
||||
let set_plugin_enabled = Arc::new(SetPluginEnabled::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
));
|
||||
let uninstall_plugin = Arc::new(UninstallPlugin::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
));
|
||||
let list_plugin_runtime_contributions = Arc::new(ListPluginRuntimeContributions::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
));
|
||||
let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&plugin_manifest_validator),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
));
|
||||
let background_wake = Arc::new(AgentWakeService::new(
|
||||
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
|
||||
Arc::clone(&input_mediator),
|
||||
@ -2613,6 +2699,17 @@ impl BackendCore {
|
||||
cancel_background_task,
|
||||
retry_background_task,
|
||||
background_task_store: Arc::clone(&background_tasks_port),
|
||||
review_plugin_package,
|
||||
install_plugin_from_archive,
|
||||
install_plugin_from_directory,
|
||||
list_plugins,
|
||||
set_plugin_enabled,
|
||||
uninstall_plugin,
|
||||
list_plugin_runtime_contributions,
|
||||
reconcile_plugin_mcp_servers,
|
||||
plugin_package_store: Arc::clone(&plugin_packages),
|
||||
plugin_registry_store: Arc::clone(&plugin_registry_store),
|
||||
plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
|
||||
ticket_tool_binder,
|
||||
template_tool_binder,
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ use crate::ids::{
|
||||
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||
use crate::mailbox::TicketId;
|
||||
use crate::memory::MemorySlug;
|
||||
use crate::plugin::{PluginId, PluginMcpServerId, PluginVersion};
|
||||
use crate::sprint::{SprintOrder, SprintVersion};
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
@ -41,6 +42,53 @@ pub enum DomainEvent {
|
||||
/// The new project.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// A plugin was installed.
|
||||
PluginInstalled {
|
||||
/// Plugin id.
|
||||
plugin_id: PluginId,
|
||||
/// Installed version.
|
||||
version: PluginVersion,
|
||||
},
|
||||
/// A plugin was enabled.
|
||||
PluginEnabled {
|
||||
/// Plugin id.
|
||||
plugin_id: PluginId,
|
||||
},
|
||||
/// A plugin was disabled.
|
||||
PluginDisabled {
|
||||
/// Plugin id.
|
||||
plugin_id: PluginId,
|
||||
/// Whether a restart is needed for full JS purge.
|
||||
restart_required: bool,
|
||||
},
|
||||
/// A plugin was uninstalled.
|
||||
PluginUninstalled {
|
||||
/// Plugin id.
|
||||
plugin_id: PluginId,
|
||||
/// Whether a restart is needed for full JS purge.
|
||||
restart_required: bool,
|
||||
},
|
||||
/// A plugin failed to load.
|
||||
PluginLoadFailed {
|
||||
/// Plugin id.
|
||||
plugin_id: PluginId,
|
||||
/// Failure reason.
|
||||
reason: String,
|
||||
},
|
||||
/// A plugin MCP server started.
|
||||
PluginMcpServerStarted {
|
||||
/// Plugin id.
|
||||
plugin_id: PluginId,
|
||||
/// Server id.
|
||||
server_id: PluginMcpServerId,
|
||||
},
|
||||
/// A plugin MCP server stopped.
|
||||
PluginMcpServerStopped {
|
||||
/// Plugin id.
|
||||
plugin_id: PluginId,
|
||||
/// Server id.
|
||||
server_id: PluginMcpServerId,
|
||||
},
|
||||
/// An agent was launched in a terminal.
|
||||
AgentLaunched {
|
||||
/// The agent.
|
||||
|
||||
@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};
|
||||
use crate::plugin::{PluginId, PluginLayoutType};
|
||||
|
||||
/// Direction of a [`SplitContainer`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@ -160,6 +161,24 @@ pub struct GridContainer {
|
||||
pub cells: Vec<GridCell>,
|
||||
}
|
||||
|
||||
/// Persisted custom layout cell provided by an installed plugin.
|
||||
///
|
||||
/// The domain stores only the stable provider identity, layout type, and opaque
|
||||
/// state. It never stores or resolves the React component used to render it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomPluginLayoutCell {
|
||||
/// Node identifier.
|
||||
pub id: NodeId,
|
||||
/// Provider plugin id.
|
||||
pub plugin_id: PluginId,
|
||||
/// Persisted layout type declared by the provider plugin.
|
||||
pub layout_type: PluginLayoutType,
|
||||
/// Opaque plugin-owned state.
|
||||
#[serde(default)]
|
||||
pub state: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A node in the layout tree.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
|
||||
@ -170,6 +189,8 @@ pub enum LayoutNode {
|
||||
Split(SplitContainer),
|
||||
/// A spreadsheet-style grid.
|
||||
Grid(GridContainer),
|
||||
/// A plugin-provided custom layout leaf.
|
||||
CustomPluginLayout(CustomPluginLayoutCell),
|
||||
}
|
||||
|
||||
/// The root of a layout (one per tab).
|
||||
@ -663,6 +684,7 @@ impl LayoutTree {
|
||||
out.push((leaf.id, agent));
|
||||
}
|
||||
}
|
||||
LayoutNode::CustomPluginLayout(_) => {}
|
||||
LayoutNode::Split(split) => {
|
||||
for child in &split.children {
|
||||
walk(&child.node, out);
|
||||
@ -770,6 +792,7 @@ impl LayoutTree {
|
||||
match n {
|
||||
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::CustomPluginLayout(_) => None,
|
||||
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
||||
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
||||
}
|
||||
@ -784,6 +807,7 @@ impl LayoutTree {
|
||||
match node {
|
||||
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::CustomPluginLayout(_) => None,
|
||||
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
||||
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
||||
}
|
||||
@ -797,6 +821,7 @@ impl LayoutTree {
|
||||
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
|
||||
let rebuilt = match node {
|
||||
LayoutNode::Leaf(_) => node.clone(),
|
||||
LayoutNode::CustomPluginLayout(_) => node.clone(),
|
||||
LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
|
||||
id: split.id,
|
||||
direction: split.direction,
|
||||
@ -844,6 +869,7 @@ fn validate_node(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
LayoutNode::CustomPluginLayout(_) => Ok(()),
|
||||
LayoutNode::Split(split) => {
|
||||
if split.children.is_empty() {
|
||||
return Err(LayoutError::EmptySplit);
|
||||
|
||||
@ -54,6 +54,7 @@ pub mod memory_harvest;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod plugin;
|
||||
pub mod ports;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
@ -178,10 +179,10 @@ pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
pub use git::GitRepository;
|
||||
|
||||
pub use layout::{
|
||||
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
||||
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
|
||||
PersistedWindowState, SplitContainer, Tab, WeightedChild, Window, WindowStateSnapshot,
|
||||
Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
|
||||
CustomPluginLayoutCell, Direction, GridCell, GridContainer, LayoutError, LayoutNode,
|
||||
LayoutTree, LeafCell, PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition,
|
||||
PersistedWindowSize, PersistedWindowState, SplitContainer, Tab, WeightedChild, Window,
|
||||
WindowStateSnapshot, Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
|
||||
};
|
||||
|
||||
pub use events::{DomainEvent, OrchestrationSource};
|
||||
@ -193,6 +194,16 @@ pub use permission::{
|
||||
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||
};
|
||||
|
||||
pub use plugin::{
|
||||
ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId,
|
||||
PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource,
|
||||
PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest,
|
||||
PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus,
|
||||
PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, PluginRegistry,
|
||||
PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
|
||||
RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||
};
|
||||
|
||||
pub use sandbox::{
|
||||
compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError,
|
||||
SandboxKind, SandboxPlan, SandboxStatus,
|
||||
@ -210,11 +221,13 @@ pub use ports::{
|
||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
||||
IssueStoreError, LiveStateStore, McpToolPermissionStore, MemoryError, MemoryQuery,
|
||||
IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery,
|
||||
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PreparedContext, ProcessError,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
|
||||
RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
||||
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler,
|
||||
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||
StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore,
|
||||
};
|
||||
|
||||
681
crates/domain/src/plugin.rs
Normal file
681
crates/domain/src/plugin.rs
Normal file
@ -0,0 +1,681 @@
|
||||
//! Plugin domain model and validated manifest image.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Plugin domain validation error.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PluginError {
|
||||
/// A required field is empty or malformed.
|
||||
#[error("invalid plugin field {field}: {reason}")]
|
||||
InvalidField {
|
||||
/// Field name.
|
||||
field: &'static str,
|
||||
/// Human-readable reason.
|
||||
reason: String,
|
||||
},
|
||||
/// A path escaped the plugin root.
|
||||
#[error("invalid plugin path {field}: {path}")]
|
||||
InvalidPath {
|
||||
/// Field name.
|
||||
field: &'static str,
|
||||
/// Offending path.
|
||||
path: String,
|
||||
},
|
||||
/// A contribution id is duplicated within the same plugin.
|
||||
#[error("duplicate contribution id: {0}")]
|
||||
DuplicateContribution(String),
|
||||
}
|
||||
|
||||
/// Stable plugin identifier.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PluginId(String);
|
||||
|
||||
impl PluginId {
|
||||
/// Validates and creates a plugin id.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||
let raw = raw.into();
|
||||
let valid_len = (3..=128).contains(&raw.len());
|
||||
let mut chars = raw.chars();
|
||||
let first = chars.next().unwrap_or('\0');
|
||||
let valid_first = first.is_ascii_lowercase() || first.is_ascii_digit();
|
||||
let valid_rest =
|
||||
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-');
|
||||
if valid_len && valid_first && valid_rest {
|
||||
Ok(Self(raw))
|
||||
} else {
|
||||
Err(PluginError::InvalidField {
|
||||
field: "id",
|
||||
reason: "expected [a-z0-9][a-z0-9.-]{2,127}".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw id.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PluginId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// SemVer-like plugin version.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PluginVersion(String);
|
||||
|
||||
impl PluginVersion {
|
||||
/// Validates and creates a version.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||
let raw = raw.into();
|
||||
let core = raw.split_once('-').map_or(raw.as_str(), |(a, _)| a);
|
||||
let parts: Vec<&str> = core.split('.').collect();
|
||||
let ok = parts.len() == 3
|
||||
&& parts.iter().all(|p| {
|
||||
!p.is_empty()
|
||||
&& p.chars().all(|c| c.is_ascii_digit())
|
||||
&& (p == &"0" || !p.starts_with('0'))
|
||||
});
|
||||
if ok {
|
||||
Ok(Self(raw))
|
||||
} else {
|
||||
Err(PluginError::InvalidField {
|
||||
field: "version",
|
||||
reason: "expected semantic version MAJOR.MINOR.PATCH".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw version.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative path inside a plugin package.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct RelativePath(String);
|
||||
|
||||
impl RelativePath {
|
||||
/// Validates a manifest path as relative, normalized, and root-confined.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||
let raw = raw.into();
|
||||
if is_safe_relative_path(&raw) {
|
||||
Ok(Self(raw.replace('\\', "/")))
|
||||
} else {
|
||||
Err(PluginError::InvalidPath {
|
||||
field: "path",
|
||||
path: raw,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the relative path.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn is_safe_relative_path(raw: &str) -> bool {
|
||||
if raw.is_empty()
|
||||
|| raw.starts_with('/')
|
||||
|| raw.starts_with('\\')
|
||||
|| raw.contains('\0')
|
||||
|| raw.contains(':')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
raw.replace('\\', "/")
|
||||
.split('/')
|
||||
.all(|p| !p.is_empty() && p != "." && p != "..")
|
||||
}
|
||||
|
||||
/// Content hash of a plugin bundle/package.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ContentHash(String);
|
||||
|
||||
impl ContentHash {
|
||||
/// Creates a content hash value.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||
let raw = raw.into();
|
||||
if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
Ok(Self(raw))
|
||||
} else {
|
||||
Err(PluginError::InvalidField {
|
||||
field: "contentHash",
|
||||
reason: "expected non-empty hexadecimal hash".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the hash.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// URL exposed to the frontend for a plugin bundle or asset.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PluginBundleUrl(String);
|
||||
|
||||
impl PluginBundleUrl {
|
||||
/// Creates a bundle URL.
|
||||
#[must_use]
|
||||
pub fn new(raw: impl Into<String>) -> Self {
|
||||
Self(raw.into())
|
||||
}
|
||||
|
||||
/// Returns the URL.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Package reference managed by the store adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginPackageRef {
|
||||
/// Plugin id if the package is already committed.
|
||||
pub plugin_id: Option<PluginId>,
|
||||
/// Opaque adapter-owned root path label.
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// Staged package produced by install/review.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StagedPluginPackage {
|
||||
/// Opaque staging root path.
|
||||
pub root: String,
|
||||
/// Original source label.
|
||||
pub source: PluginInstallSource,
|
||||
/// Hash of package contents at staging time.
|
||||
pub content_hash: ContentHash,
|
||||
}
|
||||
|
||||
/// Plugin install source persisted for admin display.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum PluginInstallSource {
|
||||
/// Local archive source.
|
||||
Archive {
|
||||
/// Human-readable path label.
|
||||
path_label: String,
|
||||
},
|
||||
/// Local directory source.
|
||||
Directory {
|
||||
/// Human-readable path label.
|
||||
path_label: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PluginInstallSource {
|
||||
/// Returns the stable DTO source kind.
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Archive { .. } => "archive",
|
||||
Self::Directory { .. } => "directory",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the optional label.
|
||||
#[must_use]
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::Archive { path_label } | Self::Directory { path_label } => path_label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin lifecycle state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PluginLifecycleState {
|
||||
/// Installed and enabled.
|
||||
Enabled,
|
||||
/// Installed and disabled.
|
||||
Disabled,
|
||||
/// Enable requested.
|
||||
PendingEnable,
|
||||
/// Disable requested.
|
||||
PendingDisable,
|
||||
/// Uninstall requested.
|
||||
PendingUninstall,
|
||||
/// Invalid manifest/incompatible engine.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl PluginLifecycleState {
|
||||
/// Whether the plugin can expose runtime contributions.
|
||||
#[must_use]
|
||||
pub fn is_runtime_active(self) -> bool {
|
||||
matches!(self, Self::Enabled | Self::PendingEnable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trust level supported by v1.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PluginTrustLevel {
|
||||
/// Full-trust plugin.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Declared plugin capability.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PluginCapability {
|
||||
/// UI bundle/contributions.
|
||||
Ui,
|
||||
/// External MCP server declarations.
|
||||
Mcp,
|
||||
}
|
||||
|
||||
/// Plugin command id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PluginCommandId(String);
|
||||
|
||||
impl PluginCommandId {
|
||||
/// Validates and creates a command id.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||
let raw = raw.into();
|
||||
if raw.len() >= 3
|
||||
&& raw
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
|
||||
{
|
||||
Ok(Self(raw))
|
||||
} else {
|
||||
Err(PluginError::InvalidField {
|
||||
field: "command",
|
||||
reason: "invalid command id".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw id.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin layout type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PluginLayoutType(String);
|
||||
|
||||
impl PluginLayoutType {
|
||||
/// Validates and creates a layout type.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||
PluginCommandId::new(raw).map(|v| Self(v.0))
|
||||
}
|
||||
|
||||
/// Returns the raw type.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin MCP server id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct PluginMcpServerId(String);
|
||||
|
||||
impl PluginMcpServerId {
|
||||
/// Validates and creates a server id.
|
||||
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||
PluginCommandId::new(raw).map(|v| Self(v.0))
|
||||
}
|
||||
|
||||
/// Returns the raw id.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level menu contribution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginTopLevelMenuContribution {
|
||||
/// Menu id.
|
||||
pub id: String,
|
||||
/// Display label.
|
||||
pub label: String,
|
||||
/// Must be true for this contribution kind.
|
||||
pub top_level: bool,
|
||||
/// Sort order.
|
||||
#[serde(default)]
|
||||
pub order: Option<i32>,
|
||||
/// Optional icon path.
|
||||
#[serde(default)]
|
||||
pub icon: Option<RelativePath>,
|
||||
}
|
||||
|
||||
/// Menu item contribution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginMenuItemContribution {
|
||||
/// Item id.
|
||||
pub id: String,
|
||||
/// Target menu id.
|
||||
pub target_menu_id: String,
|
||||
/// Display label.
|
||||
pub label: String,
|
||||
/// Command id.
|
||||
pub command: PluginCommandId,
|
||||
/// Sort order.
|
||||
#[serde(default)]
|
||||
pub order: Option<i32>,
|
||||
/// Optional icon path.
|
||||
#[serde(default)]
|
||||
pub icon: Option<RelativePath>,
|
||||
/// Optional declarative condition.
|
||||
#[serde(default)]
|
||||
pub when: Option<String>,
|
||||
}
|
||||
|
||||
/// Layout contribution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLayoutContribution {
|
||||
/// Persisted layout type.
|
||||
#[serde(rename = "type")]
|
||||
pub layout_type: PluginLayoutType,
|
||||
/// Display label.
|
||||
pub label: String,
|
||||
/// Component export name.
|
||||
pub component: String,
|
||||
/// Sort order.
|
||||
#[serde(default)]
|
||||
pub order: Option<i32>,
|
||||
/// Optional icon path.
|
||||
#[serde(default)]
|
||||
pub icon: Option<RelativePath>,
|
||||
/// Optional declarative condition.
|
||||
#[serde(default)]
|
||||
pub when: Option<String>,
|
||||
}
|
||||
|
||||
/// MCP server contribution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginMcpServerContribution {
|
||||
/// Server id.
|
||||
pub id: PluginMcpServerId,
|
||||
/// Display name.
|
||||
pub display_name: String,
|
||||
/// Executable command path or absolute command when allowed.
|
||||
pub command: String,
|
||||
/// Arguments.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Environment variables.
|
||||
#[serde(default)]
|
||||
pub env: Vec<(String, String)>,
|
||||
/// Working directory.
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
/// Transport, v1 only `stdio`.
|
||||
pub transport: String,
|
||||
/// Auto start flag.
|
||||
#[serde(default)]
|
||||
pub auto_start: bool,
|
||||
/// Development-only escape hatch for absolute commands.
|
||||
#[serde(default)]
|
||||
pub allow_absolute_command: bool,
|
||||
}
|
||||
|
||||
/// Validated contribution set.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginContributionSet {
|
||||
/// Top-level menus.
|
||||
#[serde(default)]
|
||||
pub menus: Vec<PluginTopLevelMenuContribution>,
|
||||
/// Menu items.
|
||||
#[serde(default)]
|
||||
pub menu_items: Vec<PluginMenuItemContribution>,
|
||||
/// Layout contributions.
|
||||
#[serde(default)]
|
||||
pub layouts: Vec<PluginLayoutContribution>,
|
||||
/// MCP server contributions.
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<PluginMcpServerContribution>,
|
||||
}
|
||||
|
||||
/// Validated plugin manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManifest {
|
||||
/// Manifest schema version.
|
||||
pub idea_plugin_manifest_version: u32,
|
||||
/// Plugin id.
|
||||
pub id: PluginId,
|
||||
/// Display name.
|
||||
pub display_name: String,
|
||||
/// Publisher.
|
||||
#[serde(default)]
|
||||
pub publisher: Option<String>,
|
||||
/// Version.
|
||||
pub version: PluginVersion,
|
||||
/// Description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Engine constraint for IdeA.
|
||||
#[serde(default)]
|
||||
pub engine_idea: Option<String>,
|
||||
/// Main ESM bundle.
|
||||
pub main: RelativePath,
|
||||
/// Optional icon path.
|
||||
#[serde(default)]
|
||||
pub icon: Option<RelativePath>,
|
||||
/// Trust level.
|
||||
pub trust_level: PluginTrustLevel,
|
||||
/// Capabilities.
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// Contributions.
|
||||
pub contributes: PluginContributionSet,
|
||||
}
|
||||
|
||||
/// Persisted registry entry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRegistryEntry {
|
||||
/// Plugin id.
|
||||
pub id: PluginId,
|
||||
/// Lifecycle state.
|
||||
pub lifecycle_state: PluginLifecycleState,
|
||||
/// Source.
|
||||
pub source: PluginInstallSource,
|
||||
/// Content hash.
|
||||
pub content_hash: ContentHash,
|
||||
/// Restart required marker.
|
||||
#[serde(default)]
|
||||
pub restart_required: bool,
|
||||
/// Optional error.
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Persisted global registry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRegistry {
|
||||
/// Schema version.
|
||||
pub version: u32,
|
||||
/// Registry entries.
|
||||
pub plugins: Vec<PluginRegistryEntry>,
|
||||
}
|
||||
|
||||
impl Default for PluginRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
plugins: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
/// Finds a plugin entry.
|
||||
#[must_use]
|
||||
pub fn find(&self, id: &PluginId) -> Option<&PluginRegistryEntry> {
|
||||
self.plugins.iter().find(|p| &p.id == id)
|
||||
}
|
||||
|
||||
/// Upserts a registry entry, preserving id uniqueness.
|
||||
pub fn upsert(&mut self, entry: PluginRegistryEntry) {
|
||||
if let Some(slot) = self.plugins.iter_mut().find(|p| p.id == entry.id) {
|
||||
*slot = entry;
|
||||
} else {
|
||||
self.plugins.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes an entry.
|
||||
pub fn remove(&mut self, id: &PluginId) -> Option<PluginRegistryEntry> {
|
||||
let index = self.plugins.iter().position(|p| &p.id == id)?;
|
||||
Some(self.plugins.remove(index))
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin descriptor assembled from manifest + registry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginDescriptor {
|
||||
/// Manifest.
|
||||
pub manifest: PluginManifest,
|
||||
/// Registry entry.
|
||||
pub registry: PluginRegistryEntry,
|
||||
}
|
||||
|
||||
impl PluginDescriptor {
|
||||
/// Returns true if runtime contributions are active.
|
||||
#[must_use]
|
||||
pub fn exposes_runtime_contributions(&self) -> bool {
|
||||
self.registry.lifecycle_state.is_runtime_active()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of removing package files.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RemovalOutcome {
|
||||
/// Files were removed.
|
||||
Removed,
|
||||
/// Files were moved to trash for later cleanup.
|
||||
Tombstoned,
|
||||
/// No package files existed.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Resolved MCP server spec passed to the supervisor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginMcpServerSpec {
|
||||
/// Plugin id.
|
||||
pub plugin_id: PluginId,
|
||||
/// Manifest server id.
|
||||
pub server_id: PluginMcpServerId,
|
||||
/// Stable external identity `plugin:<pluginId>:<serverId>`.
|
||||
pub identity: String,
|
||||
/// Display name.
|
||||
pub display_name: String,
|
||||
/// Command.
|
||||
pub command: String,
|
||||
/// Args.
|
||||
pub args: Vec<String>,
|
||||
/// Env.
|
||||
pub env: Vec<(String, String)>,
|
||||
/// Cwd.
|
||||
pub cwd: String,
|
||||
/// Transport.
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
/// MCP server status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginMcpStatus {
|
||||
/// Identity.
|
||||
pub identity: String,
|
||||
/// Running flag.
|
||||
pub running: bool,
|
||||
/// Optional error.
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// MCP status set.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginMcpStatusSet {
|
||||
/// Statuses.
|
||||
pub servers: Vec<PluginMcpStatus>,
|
||||
}
|
||||
|
||||
/// Custom plugin layout persisted in a layout leaf's opaque content.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomPluginLayout {
|
||||
/// Provider plugin id.
|
||||
pub plugin_id: PluginId,
|
||||
/// Provider display name if known.
|
||||
#[serde(default)]
|
||||
pub provider_plugin_display_name: Option<String>,
|
||||
/// Layout type.
|
||||
pub layout_type: PluginLayoutType,
|
||||
/// Opaque plugin state.
|
||||
#[serde(default)]
|
||||
pub state: Value,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugin_id_pattern_is_enforced() {
|
||||
assert!(PluginId::new("dev.acme.gitgraph").is_ok());
|
||||
assert!(PluginId::new("De.acme").is_err());
|
||||
assert!(PluginId::new("a").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_cannot_escape_root() {
|
||||
assert_eq!(
|
||||
RelativePath::new("dist/index.js").unwrap().as_str(),
|
||||
"dist/index.js"
|
||||
);
|
||||
assert!(RelativePath::new("../dist/index.js").is_err());
|
||||
assert!(RelativePath::new("/tmp/index.js").is_err());
|
||||
assert!(RelativePath::new("dist/../index.js").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_and_pending_uninstall_are_not_runtime_active() {
|
||||
assert!(!PluginLifecycleState::Disabled.is_runtime_active());
|
||||
assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active());
|
||||
assert!(PluginLifecycleState::Enabled.is_runtime_active());
|
||||
}
|
||||
}
|
||||
@ -49,6 +49,11 @@ use crate::model_server::{
|
||||
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
||||
};
|
||||
use crate::permission::ProjectPermissions;
|
||||
use crate::plugin::{
|
||||
ContentHash, PluginBundleUrl, PluginId, PluginManifest, PluginMcpServerSpec,
|
||||
PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome,
|
||||
StagedPluginPackage,
|
||||
};
|
||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
@ -279,6 +284,166 @@ impl RemotePath {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque local path supplied by a driving adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct LocalPath(pub String);
|
||||
|
||||
impl LocalPath {
|
||||
/// Wraps a raw path.
|
||||
#[must_use]
|
||||
pub fn new(p: impl Into<String>) -> Self {
|
||||
Self(p.into())
|
||||
}
|
||||
|
||||
/// Returns the path as a string slice.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw manifest bytes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginManifestBytes {
|
||||
/// Bytes of `idea-plugin.json`.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Plugin package store errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PluginStoreError {
|
||||
/// Package was not found.
|
||||
#[error("plugin package not found")]
|
||||
NotFound,
|
||||
/// Invalid input/source.
|
||||
#[error("invalid plugin package: {0}")]
|
||||
Invalid(String),
|
||||
/// I/O error.
|
||||
#[error("plugin package I/O error: {0}")]
|
||||
Io(String),
|
||||
/// Serialization or archive error.
|
||||
#[error("plugin package format error: {0}")]
|
||||
Format(String),
|
||||
}
|
||||
|
||||
/// Plugin registry errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PluginRegistryError {
|
||||
/// I/O error.
|
||||
#[error("plugin registry I/O error: {0}")]
|
||||
Io(String),
|
||||
/// Serialization error.
|
||||
#[error("plugin registry serialization error: {0}")]
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
/// Manifest validation errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PluginManifestError {
|
||||
/// Invalid JSON.
|
||||
#[error("invalid plugin manifest JSON: {0}")]
|
||||
Json(String),
|
||||
/// Invalid manifest data.
|
||||
#[error("invalid plugin manifest: {0}")]
|
||||
Invalid(String),
|
||||
/// Incompatible engine.
|
||||
#[error("incompatible IdeA engine: {0}")]
|
||||
IncompatibleEngine(String),
|
||||
}
|
||||
|
||||
/// Plugin MCP errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PluginMcpError {
|
||||
/// Process/supervisor failure.
|
||||
#[error("plugin MCP error: {0}")]
|
||||
Process(String),
|
||||
}
|
||||
|
||||
/// Store for installed plugin packages under the global app data directory.
|
||||
#[async_trait]
|
||||
pub trait PluginPackageStore: Send + Sync {
|
||||
/// Lists committed package roots.
|
||||
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError>;
|
||||
|
||||
/// Reads `idea-plugin.json` from a package root.
|
||||
async fn read_manifest(
|
||||
&self,
|
||||
package: &PluginPackageRef,
|
||||
) -> Result<PluginManifestBytes, PluginStoreError>;
|
||||
|
||||
/// Stages an archive for validation.
|
||||
async fn install_from_archive(
|
||||
&self,
|
||||
archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError>;
|
||||
|
||||
/// Stages a directory snapshot for validation.
|
||||
async fn install_from_directory(
|
||||
&self,
|
||||
dir: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError>;
|
||||
|
||||
/// Commits a staged install into `installed/<pluginId>`.
|
||||
async fn commit_install(
|
||||
&self,
|
||||
staged: StagedPluginPackage,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<PluginPackageRef, PluginStoreError>;
|
||||
|
||||
/// Removes a committed package.
|
||||
async fn remove_package(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStoreError>;
|
||||
|
||||
/// Builds a protocol URL for a bundle/asset.
|
||||
fn bundle_url(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
entry: &RelativePath,
|
||||
hash: &ContentHash,
|
||||
) -> Result<PluginBundleUrl, PluginStoreError>;
|
||||
|
||||
/// Returns the global application data directory label when the adapter can
|
||||
/// expose it for manifest variable substitution.
|
||||
fn app_data_dir_label(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Store for the global plugin registry.
|
||||
#[async_trait]
|
||||
pub trait PluginRegistryStore: Send + Sync {
|
||||
/// Loads the registry, returning an empty document if missing.
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError>;
|
||||
|
||||
/// Persists the registry.
|
||||
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError>;
|
||||
}
|
||||
|
||||
/// Manifest parser and validator.
|
||||
pub trait PluginManifestValidator: Send + Sync {
|
||||
/// Validates raw manifest bytes.
|
||||
fn validate(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
package_root: &PluginPackageRef,
|
||||
) -> Result<PluginManifest, PluginManifestError>;
|
||||
}
|
||||
|
||||
/// Supervisor for external MCP servers declared by plugins.
|
||||
#[async_trait]
|
||||
pub trait PluginMcpSupervisor: Send + Sync {
|
||||
/// Reconciles the desired active server set.
|
||||
async fn reconcile(
|
||||
&self,
|
||||
active_servers: Vec<PluginMcpServerSpec>,
|
||||
) -> Result<PluginMcpStatusSet, PluginMcpError>;
|
||||
|
||||
/// Stops every server owned by one plugin.
|
||||
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError>;
|
||||
}
|
||||
|
||||
/// A single directory entry returned by [`FileSystem::list`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirEntry {
|
||||
|
||||
@ -44,6 +44,7 @@ fn leaf_for(tree: &LayoutTree, id: domain::NodeId) -> Option<LeafCell> {
|
||||
match n {
|
||||
LayoutNode::Leaf(l) if l.id == id => Some(l.clone()),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::CustomPluginLayout(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
||||
}
|
||||
@ -235,6 +236,7 @@ fn session_for(tree: &LayoutTree, id: domain::NodeId) -> Option<domain::SessionI
|
||||
match n {
|
||||
LayoutNode::Leaf(l) if l.id == id => Some(l.session),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::CustomPluginLayout(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
||||
}
|
||||
|
||||
@ -4,10 +4,10 @@
|
||||
mod helpers;
|
||||
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
|
||||
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
|
||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth,
|
||||
TemplateVersion, WeightedChild,
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection,
|
||||
CustomPluginLayoutCell, Direction, LayoutNode, LayoutTree, LeafCell, ManifestEntry,
|
||||
MarkdownDoc, PluginId, PluginLayoutType, Project, ProjectPath, RemoteRef, SessionStrategy,
|
||||
Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild,
|
||||
};
|
||||
use helpers::{node, session};
|
||||
use uuid::Uuid;
|
||||
@ -472,3 +472,29 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
|
||||
"agent field should be omitted when None; json was {json2}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_plugin_layout_roundtrips_with_opaque_state() {
|
||||
let tree = LayoutTree::new(LayoutNode::CustomPluginLayout(CustomPluginLayoutCell {
|
||||
id: node(43),
|
||||
plugin_id: PluginId::new("dev.acme.gitgraph").unwrap(),
|
||||
layout_type: PluginLayoutType::new("dev.acme.gitgraph.layout").unwrap(),
|
||||
state: serde_json::json!({"branch":"main","zoom":2}),
|
||||
}));
|
||||
|
||||
assert_eq!(roundtrip(&tree), tree);
|
||||
let json = serde_json::to_string(&tree).unwrap();
|
||||
assert!(
|
||||
json.contains("\"type\":\"customPluginLayout\""),
|
||||
"json was {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"pluginId\":\"dev.acme.gitgraph\""),
|
||||
"json was {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"layoutType\":\"dev.acme.gitgraph.layout\""),
|
||||
"json was {json}"
|
||||
);
|
||||
assert!(json.contains("\"state\""), "json was {json}");
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ futures-util = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
|
||||
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
|
||||
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
||||
@ -49,6 +51,7 @@ fastembed = { version = "5", default-features = false, features = ["hf-hub-rustl
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
landlock = "0.4.5"
|
||||
|
||||
|
||||
[features]
|
||||
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
||||
|
||||
@ -30,6 +30,7 @@ pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod pair_attempt_limiter;
|
||||
pub mod permission;
|
||||
pub mod plugin;
|
||||
pub mod process;
|
||||
pub mod pty;
|
||||
pub mod ratelimit;
|
||||
@ -80,6 +81,7 @@ pub use orchestrator::{
|
||||
};
|
||||
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||
pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore};
|
||||
pub use process::LocalProcessSpawner;
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
|
||||
755
crates/infrastructure/src/plugin/mod.rs
Normal file
755
crates/infrastructure/src/plugin/mod.rs
Normal file
@ -0,0 +1,755 @@
|
||||
//! Filesystem plugin stores and external MCP supervisor.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||
};
|
||||
use domain::{
|
||||
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
|
||||
PluginMcpStatus, PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath,
|
||||
RemovalOutcome, StagedPluginPackage,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::process::Child;
|
||||
|
||||
const REGISTRY_FILE: &str = "registry.json";
|
||||
const MANIFEST_FILE: &str = "idea-plugin.json";
|
||||
|
||||
/// Filesystem package store under app-data `plugins/`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsPluginPackageStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsPluginPackageStore {
|
||||
/// Builds the store.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
root: app_data_dir.into().join("plugins"),
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_dir(&self) -> PathBuf {
|
||||
self.root.join("installed")
|
||||
}
|
||||
|
||||
/// Returns the global plugin store root.
|
||||
#[must_use]
|
||||
pub fn plugins_root(&self) -> PathBuf {
|
||||
self.root.clone()
|
||||
}
|
||||
|
||||
/// Returns the managed directory for one installed plugin id.
|
||||
#[must_use]
|
||||
pub fn installed_plugin_dir(&self, plugin_id: &PluginId) -> PathBuf {
|
||||
self.installed_dir().join(plugin_id.as_str())
|
||||
}
|
||||
|
||||
fn staging_dir(&self) -> PathBuf {
|
||||
self.root.join("_staging")
|
||||
}
|
||||
|
||||
fn trash_dir(&self) -> PathBuf {
|
||||
self.root.join("_trash")
|
||||
}
|
||||
|
||||
fn package_root(&self, package: &PluginPackageRef) -> PathBuf {
|
||||
match &package.plugin_id {
|
||||
Some(id) => self.installed_dir().join(id.as_str()),
|
||||
None => PathBuf::from(&package.root),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an asset path if the request is root-confined.
|
||||
pub fn resolve_asset_path(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
path: &RelativePath,
|
||||
) -> Result<PathBuf, PluginStoreError> {
|
||||
let root = self.installed_dir().join(plugin_id.as_str());
|
||||
let candidate = root.join(path.as_str());
|
||||
let canonical_root = root
|
||||
.canonicalize()
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let canonical = candidate
|
||||
.canonicalize()
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
if canonical.starts_with(canonical_root) {
|
||||
Ok(canonical)
|
||||
} else {
|
||||
Err(PluginStoreError::Invalid(
|
||||
"plugin asset escapes package root".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_root(&self) -> Result<PathBuf, PluginStoreError> {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||
.as_nanos();
|
||||
let path = self.staging_dir().join(format!("stage-{ts}"));
|
||||
fs::create_dir_all(&path).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageStore for FsPluginPackageStore {
|
||||
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||
let dir = self.installed_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(dir).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
if !entry
|
||||
.file_type()
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||
.is_dir()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if let Ok(id) = PluginId::new(name) {
|
||||
out.push(PluginPackageRef {
|
||||
plugin_id: Some(id),
|
||||
root: entry.path().to_string_lossy().into_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn read_manifest(
|
||||
&self,
|
||||
package: &PluginPackageRef,
|
||||
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||
let bytes = fs::read(self.package_root(package).join(MANIFEST_FILE)).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
PluginStoreError::NotFound
|
||||
} else {
|
||||
PluginStoreError::Io(e.to_string())
|
||||
}
|
||||
})?;
|
||||
Ok(PluginManifestBytes { bytes })
|
||||
}
|
||||
|
||||
async fn install_from_archive(
|
||||
&self,
|
||||
archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
let stage = self.stage_root()?;
|
||||
let status = std::process::Command::new("unzip")
|
||||
.arg("-q")
|
||||
.arg(archive.as_str())
|
||||
.arg("-d")
|
||||
.arg(&stage)
|
||||
.status()
|
||||
.map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(PluginStoreError::Format(format!(
|
||||
"unzip exited with status {status}"
|
||||
)));
|
||||
}
|
||||
ensure_manifest(&stage)?;
|
||||
let content_hash = hash_dir(&stage)?;
|
||||
Ok(StagedPluginPackage {
|
||||
root: stage.to_string_lossy().into_owned(),
|
||||
source: PluginInstallSource::Archive {
|
||||
path_label: archive.as_str().to_owned(),
|
||||
},
|
||||
content_hash,
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_from_directory(
|
||||
&self,
|
||||
dir: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
let source = PathBuf::from(dir.as_str());
|
||||
if !source.is_dir() {
|
||||
return Err(PluginStoreError::NotFound);
|
||||
}
|
||||
let stage = self.stage_root()?;
|
||||
copy_dir_all(&source, &stage)?;
|
||||
ensure_manifest(&stage)?;
|
||||
let content_hash = hash_dir(&stage)?;
|
||||
Ok(StagedPluginPackage {
|
||||
root: stage.to_string_lossy().into_owned(),
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: dir.as_str().to_owned(),
|
||||
},
|
||||
content_hash,
|
||||
})
|
||||
}
|
||||
|
||||
async fn commit_install(
|
||||
&self,
|
||||
staged: StagedPluginPackage,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||
fs::create_dir_all(self.installed_dir())
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let target = self.installed_dir().join(plugin_id.as_str());
|
||||
let tmp = self
|
||||
.installed_dir()
|
||||
.join(format!(".{}-new", plugin_id.as_str()));
|
||||
if tmp.exists() {
|
||||
fs::remove_dir_all(&tmp).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
fs::rename(&staged.root, &tmp).or_else(|_| {
|
||||
copy_dir_all(Path::new(&staged.root), &tmp)?;
|
||||
fs::remove_dir_all(&staged.root).map_err(|e| PluginStoreError::Io(e.to_string()))
|
||||
})?;
|
||||
if target.exists() {
|
||||
fs::remove_dir_all(&target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
fs::rename(&tmp, &target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
Ok(PluginPackageRef {
|
||||
plugin_id: Some(plugin_id.clone()),
|
||||
root: target.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn remove_package(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||
let target = self.installed_dir().join(plugin_id.as_str());
|
||||
if !target.exists() {
|
||||
return Ok(RemovalOutcome::NotFound);
|
||||
}
|
||||
match fs::remove_dir_all(&target) {
|
||||
Ok(()) => Ok(RemovalOutcome::Removed),
|
||||
Err(_) => {
|
||||
fs::create_dir_all(self.trash_dir())
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||
.as_secs();
|
||||
let tombstone = self
|
||||
.trash_dir()
|
||||
.join(format!("{}-{ts}", plugin_id.as_str()));
|
||||
fs::rename(&target, tombstone).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
Ok(RemovalOutcome::Tombstoned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bundle_url(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
entry: &RelativePath,
|
||||
hash: &ContentHash,
|
||||
) -> Result<PluginBundleUrl, PluginStoreError> {
|
||||
Ok(PluginBundleUrl::new(format!(
|
||||
"idea-plugin://{}/current/{}{}{}",
|
||||
plugin_id.as_str(),
|
||||
hash.as_str(),
|
||||
"/",
|
||||
entry.as_str()
|
||||
)))
|
||||
}
|
||||
|
||||
fn app_data_dir_label(&self) -> Option<String> {
|
||||
self.root
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> {
|
||||
if root.join(MANIFEST_FILE).is_file() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PluginStoreError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let ty = entry
|
||||
.file_type()
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let dest = target.join(entry.file_name());
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(&entry.path(), &dest)?;
|
||||
} else if ty.is_file() {
|
||||
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_dir(root: &Path) -> Result<ContentHash, PluginStoreError> {
|
||||
let mut files = Vec::new();
|
||||
collect_files(root, &mut files)?;
|
||||
files.sort();
|
||||
let mut hasher = Sha256::new();
|
||||
for path in files {
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update([0]);
|
||||
let mut file = fs::File::open(&path).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
hasher.update(buf);
|
||||
hasher.update([0]);
|
||||
}
|
||||
ContentHash::new(hex::encode(hasher.finalize()))
|
||||
.map_err(|e| PluginStoreError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStoreError> {
|
||||
for entry in fs::read_dir(root).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let ty = entry
|
||||
.file_type()
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
if ty.is_dir() {
|
||||
collect_files(&entry.path(), files)?;
|
||||
} else if ty.is_file() {
|
||||
files.push(entry.path());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Filesystem registry store.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsPluginRegistryStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsPluginRegistryStore {
|
||||
/// Builds the store.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
root: app_data_dir.into().join("plugins"),
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> PathBuf {
|
||||
self.root.join(REGISTRY_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for FsPluginRegistryStore {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||
match fs::read(self.path()) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|e| PluginRegistryError::Serialization(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PluginRegistry::default()),
|
||||
Err(e) => Err(PluginRegistryError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
|
||||
fs::create_dir_all(&self.root).map_err(|e| PluginRegistryError::Io(e.to_string()))?;
|
||||
let bytes = serde_json::to_vec_pretty(registry)
|
||||
.map_err(|e| PluginRegistryError::Serialization(e.to_string()))?;
|
||||
fs::write(self.path(), bytes).map_err(|e| PluginRegistryError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
trait ExternalMcpServerHandle: Send {
|
||||
async fn stop(&mut self) -> Result<(), PluginMcpError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
trait ExternalMcpServerBridge: Send + Sync {
|
||||
async fn start(
|
||||
&self,
|
||||
spec: &PluginMcpServerSpec,
|
||||
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError>;
|
||||
}
|
||||
|
||||
struct ProcessMcpServerHandle {
|
||||
child: Child,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExternalMcpServerHandle for ProcessMcpServerHandle {
|
||||
async fn stop(&mut self) -> Result<(), PluginMcpError> {
|
||||
self.child
|
||||
.kill()
|
||||
.await
|
||||
.map_err(|e| PluginMcpError::Process(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct StdioExternalMcpServerBridge;
|
||||
|
||||
#[async_trait]
|
||||
impl ExternalMcpServerBridge for StdioExternalMcpServerBridge {
|
||||
async fn start(
|
||||
&self,
|
||||
spec: &PluginMcpServerSpec,
|
||||
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError> {
|
||||
if spec.transport != "stdio" {
|
||||
return Err(PluginMcpError::Process(format!(
|
||||
"unsupported plugin MCP transport: {}",
|
||||
spec.transport
|
||||
)));
|
||||
}
|
||||
let mut cmd = tokio::process::Command::new(&spec.command);
|
||||
cmd.args(&spec.args)
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let env: BTreeMap<_, _> = spec.env.iter().cloned().collect();
|
||||
cmd.envs(env);
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| PluginMcpError::Process(e.to_string()))?;
|
||||
Ok(Box::new(ProcessMcpServerHandle { child }))
|
||||
}
|
||||
}
|
||||
|
||||
/// External process supervisor for plugin MCP servers.
|
||||
pub struct ExternalMcpPluginSupervisor {
|
||||
bridge: Arc<dyn ExternalMcpServerBridge>,
|
||||
children: Mutex<HashMap<String, Box<dyn ExternalMcpServerHandle>>>,
|
||||
}
|
||||
|
||||
impl ExternalMcpPluginSupervisor {
|
||||
/// Builds the supervisor.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
bridge: Arc::new(StdioExternalMcpServerBridge),
|
||||
children: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_bridge(bridge: Arc<dyn ExternalMcpServerBridge>) -> Self {
|
||||
Self {
|
||||
bridge,
|
||||
children: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExternalMcpPluginSupervisor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginMcpSupervisor for ExternalMcpPluginSupervisor {
|
||||
async fn reconcile(
|
||||
&self,
|
||||
active_servers: Vec<PluginMcpServerSpec>,
|
||||
) -> Result<PluginMcpStatusSet, PluginMcpError> {
|
||||
let desired: HashSet<String> = active_servers.iter().map(|s| s.identity.clone()).collect();
|
||||
let to_stop = {
|
||||
let children = self
|
||||
.children
|
||||
.lock()
|
||||
.expect("plugin mcp supervisor poisoned");
|
||||
children
|
||||
.keys()
|
||||
.filter(|id| !desired.contains(*id))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for id in to_stop {
|
||||
let child = self
|
||||
.children
|
||||
.lock()
|
||||
.expect("plugin mcp supervisor poisoned")
|
||||
.remove(&id);
|
||||
if let Some(mut child) = child {
|
||||
let _ = child.stop().await;
|
||||
}
|
||||
}
|
||||
let mut statuses = Vec::new();
|
||||
for spec in active_servers {
|
||||
let already = self
|
||||
.children
|
||||
.lock()
|
||||
.expect("plugin mcp supervisor poisoned")
|
||||
.contains_key(&spec.identity);
|
||||
if already {
|
||||
statuses.push(PluginMcpStatus {
|
||||
identity: spec.identity,
|
||||
running: true,
|
||||
error: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
match self.bridge.start(&spec).await {
|
||||
Ok(handle) => {
|
||||
self.children
|
||||
.lock()
|
||||
.expect("plugin mcp supervisor poisoned")
|
||||
.insert(spec.identity.clone(), handle);
|
||||
statuses.push(PluginMcpStatus {
|
||||
identity: spec.identity,
|
||||
running: true,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => statuses.push(PluginMcpStatus {
|
||||
identity: spec.identity,
|
||||
running: false,
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(PluginMcpStatusSet { servers: statuses })
|
||||
}
|
||||
|
||||
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> {
|
||||
let prefix = format!("plugin:{}:", plugin_id.as_str());
|
||||
let ids = {
|
||||
let children = self
|
||||
.children
|
||||
.lock()
|
||||
.expect("plugin mcp supervisor poisoned");
|
||||
children
|
||||
.keys()
|
||||
.filter(|id| id.starts_with(&prefix))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for id in ids {
|
||||
let child = self
|
||||
.children
|
||||
.lock()
|
||||
.expect("plugin mcp supervisor poisoned")
|
||||
.remove(&id);
|
||||
if let Some(mut child) = child {
|
||||
child.stop().await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ports::PluginPackageStore;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_dir(label: &str) -> PathBuf {
|
||||
let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"idea-plugin-test-{label}-{}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn write_plugin(root: &Path, main_body: &str) {
|
||||
fs::create_dir_all(root.join("dist")).unwrap();
|
||||
fs::write(root.join("dist/index.js"), main_body).unwrap();
|
||||
fs::write(
|
||||
root.join(MANIFEST_FILE),
|
||||
r#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingBridge {
|
||||
started: Mutex<Vec<PluginMcpServerSpec>>,
|
||||
stopped: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
struct RecordingHandle {
|
||||
identity: String,
|
||||
stopped: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExternalMcpServerHandle for RecordingHandle {
|
||||
async fn stop(&mut self) -> Result<(), PluginMcpError> {
|
||||
self.stopped.lock().unwrap().push(self.identity.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExternalMcpServerBridge for RecordingBridge {
|
||||
async fn start(
|
||||
&self,
|
||||
spec: &PluginMcpServerSpec,
|
||||
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError> {
|
||||
assert_eq!(spec.transport, "stdio");
|
||||
self.started.lock().unwrap().push(spec.clone());
|
||||
Ok(Box::new(RecordingHandle {
|
||||
identity: spec.identity.clone(),
|
||||
stopped: Arc::clone(&self.stopped),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_spec(plugin_id: &str, server_id: &str) -> PluginMcpServerSpec {
|
||||
let plugin_id = PluginId::new(plugin_id).unwrap();
|
||||
let server_id = domain::PluginMcpServerId::new(server_id).unwrap();
|
||||
PluginMcpServerSpec {
|
||||
identity: format!("plugin:{}:{}", plugin_id.as_str(), server_id.as_str()),
|
||||
plugin_id,
|
||||
server_id,
|
||||
display_name: "Plugin Tools".to_owned(),
|
||||
command: "/plugin/servers/tool".to_owned(),
|
||||
args: vec!["--stdio".to_owned()],
|
||||
env: vec![("PLUGIN_ROOT".to_owned(), "/plugin".to_owned())],
|
||||
cwd: "/plugin".to_owned(),
|
||||
transport: "stdio".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installs_directory_snapshot_and_uninstalls_cleanly() {
|
||||
let app = temp_dir("app");
|
||||
let source = temp_dir("source");
|
||||
write_plugin(&source, "one");
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
let staged = store
|
||||
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(staged.root, source.to_string_lossy());
|
||||
let id = PluginId::new("dev.acme.test").unwrap();
|
||||
let package = store.commit_install(staged, &id).await.unwrap();
|
||||
assert!(PathBuf::from(package.root).join(MANIFEST_FILE).exists());
|
||||
assert_eq!(
|
||||
store.remove_package(&id).await.unwrap(),
|
||||
RemovalOutcome::Removed
|
||||
);
|
||||
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_hash_changes_when_bundle_changes() {
|
||||
let app = temp_dir("hash-app");
|
||||
let source = temp_dir("hash-source");
|
||||
write_plugin(&source, "one");
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
let first = store
|
||||
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||
.await
|
||||
.unwrap()
|
||||
.content_hash;
|
||||
fs::write(source.join("dist/index.js"), "two").unwrap();
|
||||
let second = store
|
||||
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||
.await
|
||||
.unwrap()
|
||||
.content_hash;
|
||||
assert_ne!(first, second);
|
||||
let _ = fs::remove_dir_all(app);
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_archive_without_path_escape() {
|
||||
if std::process::Command::new("zip")
|
||||
.arg("-h")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
|| std::process::Command::new("unzip")
|
||||
.arg("-h")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let app = temp_dir("archive-app");
|
||||
let source = temp_dir("archive-source");
|
||||
write_plugin(&source, "bundle");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
{
|
||||
let status = std::process::Command::new("zip")
|
||||
.arg("-qr")
|
||||
.arg(&archive_path)
|
||||
.arg(".")
|
||||
.current_dir(&source)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
}
|
||||
let store = FsPluginPackageStore::new(app.join("data"));
|
||||
let staged = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
|
||||
let bridge = Arc::new(RecordingBridge::default());
|
||||
let supervisor = ExternalMcpPluginSupervisor::with_bridge(bridge.clone());
|
||||
let spec = mcp_spec("dev.acme.gitgraph", "dev.acme.gitgraph.mcp");
|
||||
|
||||
let statuses = supervisor.reconcile(vec![spec.clone()]).await.unwrap();
|
||||
|
||||
assert_eq!(statuses.servers.len(), 1);
|
||||
assert_eq!(statuses.servers[0].identity, spec.identity);
|
||||
assert!(statuses.servers[0].running);
|
||||
assert_eq!(&*bridge.started.lock().unwrap(), &[spec]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_stop_plugin_stops_only_matching_plugin_servers() {
|
||||
let bridge = Arc::new(RecordingBridge::default());
|
||||
let supervisor = ExternalMcpPluginSupervisor::with_bridge(bridge.clone());
|
||||
let target = mcp_spec("dev.acme.gitgraph", "dev.acme.gitgraph.mcp");
|
||||
let other = mcp_spec("dev.other.tools", "dev.other.tools.mcp");
|
||||
supervisor
|
||||
.reconcile(vec![target.clone(), other.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
supervisor.stop_plugin(&target.plugin_id).await.unwrap();
|
||||
|
||||
assert_eq!(&*bridge.stopped.lock().unwrap(), &[target.identity.clone()]);
|
||||
let statuses = supervisor.reconcile(vec![other.clone()]).await.unwrap();
|
||||
assert_eq!(statuses.servers.len(), 1);
|
||||
assert_eq!(statuses.servers[0].identity, other.identity);
|
||||
assert_eq!(bridge.started.lock().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user