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:
2026-07-22 07:37:03 +02:00
parent 47aacc6da9
commit bb35641715
24 changed files with 4304 additions and 41 deletions

2
Cargo.lock generated
View File

@ -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",

View File

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

View File

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

View File

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

View 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()))
}

View 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());
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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)),
}

View File

@ -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)),
}

View File

@ -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)),
}

View File

@ -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")]

View File

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

View File

@ -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,
}

View File

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

View File

@ -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);

View File

@ -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
View 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());
}
}

View File

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

View File

@ -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)),
}

View File

@ -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}");
}

View File

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

View File

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

View 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);
}
}