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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user