Cause racine: PluginMcpServerSpec::client_name() encodait chaque octet non alphanumérique en _XX, produisant pour UnitySoundTool `idea_plugin__com_2eidea_2eunity_2ddeveloper_2dtools__unity_2ddeveloper_2dtools_2etools` (86 chars). Codex préfixe `mcp__` => 91 chars, limite API 64 => HTTP 400 `Invalid input.namespace string too long` sur chaque `codex exec resume` dont l'historique contenait un outil Unity, aucun agent_message, UI en fallback générique « réponse non finalisée ». - domain: client_name() borné à 59 bytes ASCII (=> 64 avec mcp__) — stem lisible (minuscules, non-alnum => '_', tronqué) + suffixe déterministe = 16 premiers hex de SHA-256(plugin_id + NUL + server_id) pour conserver la résistance aux collisions après troncature; tests Unity (59 exact), stabilité, slug ponctuation, collisions sur stems tronqués identiques - application: tests de projection TOML/JSON Codex et Claude adaptés au vrai namespace legacy encodé (86 chars) — vérifie que les tables utilisateur (mcp_servers.user_server, user-server) sont préservées pendant le retrait des tables managées périmées - infrastructure: parse_event Codex traite turn.failed/error comme ReplyEvent::Error avec le message fournisseur (fini le fallback générique muet), classification lisible MCP_NAMESPACE_TOO_LONG: pour les erreurs de longueur de namespace; tests Après déploiement: nouvelle conversation/restart requis pour les agents Codex existants — les historiques contiennent déjà le namespace invalide. Aucun push.
986 lines
29 KiB
Rust
986 lines
29 KiB
Rust
//! Plugin domain model and validated manifest image.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use sha2::{Digest, Sha256};
|
|
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,
|
|
/// Public tooling/build/debug services.
|
|
Tooling,
|
|
}
|
|
|
|
/// Manifest-declared runtime activation scope.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum PluginActivationScope {
|
|
/// Activate at app bootstrap, without requiring a focused project.
|
|
App,
|
|
/// Wait until a project is focused before the first activation.
|
|
Project,
|
|
}
|
|
|
|
impl Default for PluginActivationScope {
|
|
fn default() -> Self {
|
|
Self::App
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
/// Stable id of a skill contributed by a plugin manifest.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct PluginSkillId(String);
|
|
|
|
impl PluginSkillId {
|
|
/// Validates and creates a plugin skill 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
|
|
}
|
|
}
|
|
|
|
/// Read-only skill contribution whose body lives inside the plugin package.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginSkillContribution {
|
|
/// Stable id within the plugin.
|
|
pub id: PluginSkillId,
|
|
/// Agent-facing name accepted by `idea_skill_read`.
|
|
pub name: String,
|
|
/// Short affordance shown in the injected skill catalogue.
|
|
pub description: String,
|
|
/// Capability nature.
|
|
#[serde(default)]
|
|
pub kind: crate::SkillKind,
|
|
/// Package-relative Markdown file.
|
|
pub path: RelativePath,
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
/// Slash-command contribution backed by a plugin command callback.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginSlashCommandContribution {
|
|
/// Slash name, including the leading `/`.
|
|
pub name: String,
|
|
/// Short autocomplete/help description.
|
|
pub short_description: String,
|
|
/// Plugin command callback id registered through the SDK command registry.
|
|
pub command: PluginCommandId,
|
|
/// Whether the host should ask for confirmation before dispatching callback.
|
|
#[serde(default)]
|
|
pub requires_confirmation: bool,
|
|
/// Optional declarative condition reserved for host-side availability.
|
|
#[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,
|
|
/// Optional static tool catalogue for capability-management UI. This does
|
|
/// not replace MCP `tools/list` runtime introspection.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tools: Vec<PluginMcpToolMetadata>,
|
|
}
|
|
|
|
/// Optional static display metadata for a tool exposed by a plugin MCP server.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginMcpToolMetadata {
|
|
/// Exact MCP tool name advertised by the server.
|
|
pub name: String,
|
|
/// Optional human-readable summary for capability-management UI.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
/// Validated contribution set.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginContributionSet {
|
|
/// Read-only agent skills shipped in the package.
|
|
#[serde(default)]
|
|
pub skills: Vec<PluginSkillContribution>,
|
|
/// Top-level menus.
|
|
#[serde(default)]
|
|
pub menus: Vec<PluginTopLevelMenuContribution>,
|
|
/// Menu items.
|
|
#[serde(default)]
|
|
pub menu_items: Vec<PluginMenuItemContribution>,
|
|
/// Slash commands.
|
|
#[serde(default)]
|
|
pub slash_commands: Vec<PluginSlashCommandContribution>,
|
|
/// Layout contributions.
|
|
#[serde(default)]
|
|
pub layouts: Vec<PluginLayoutContribution>,
|
|
/// MCP server contributions.
|
|
#[serde(default)]
|
|
pub mcp_servers: Vec<PluginMcpServerContribution>,
|
|
}
|
|
|
|
/// Project-scoped plugin activation entry. It is retained when a plugin is
|
|
/// temporarily uninstalled so reinstalling does not lose user intent.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectPluginActivation {
|
|
/// Globally-installed plugin id.
|
|
pub plugin_id: PluginId,
|
|
/// Whether this project may expose the plugin's capabilities.
|
|
pub enabled: bool,
|
|
}
|
|
|
|
/// Reference to one plugin skill assigned to an agent.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginSkillRef {
|
|
/// Owning plugin.
|
|
pub plugin_id: PluginId,
|
|
/// Skill id within that plugin.
|
|
pub skill_id: PluginSkillId,
|
|
}
|
|
|
|
/// Stable namespace of one externally-provided MCP tool.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PluginToolRef {
|
|
/// Owning plugin.
|
|
pub plugin_id: PluginId,
|
|
/// MCP server id within that plugin.
|
|
pub server_id: PluginMcpServerId,
|
|
/// Tool name advertised by the server.
|
|
pub tool_name: String,
|
|
}
|
|
|
|
/// Plugin capabilities explicitly assigned to one project agent.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AgentPluginAssignments {
|
|
/// Agent receiving the capabilities.
|
|
pub agent_id: crate::AgentId,
|
|
/// Assigned plugin skills, in injection order.
|
|
#[serde(default)]
|
|
pub skills: Vec<PluginSkillRef>,
|
|
/// Assigned external MCP tools.
|
|
#[serde(default)]
|
|
pub tools: Vec<PluginToolRef>,
|
|
}
|
|
|
|
/// Versioned project-owned plugin settings persisted under
|
|
/// `.ideai/project-plugins.json`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectPluginSettings {
|
|
/// Schema version.
|
|
pub version: u32,
|
|
/// Explicit project activation choices.
|
|
#[serde(default)]
|
|
pub plugins: Vec<ProjectPluginActivation>,
|
|
/// Per-agent assignments. Dangling entries are intentional tombstones.
|
|
#[serde(default)]
|
|
pub agents: Vec<AgentPluginAssignments>,
|
|
}
|
|
|
|
impl Default for ProjectPluginSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
version: 1,
|
|
plugins: Vec::new(),
|
|
agents: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ProjectPluginSettings {
|
|
/// Whether a plugin is explicitly enabled for this project.
|
|
#[must_use]
|
|
pub fn is_enabled(&self, plugin_id: &PluginId) -> bool {
|
|
self.plugins
|
|
.iter()
|
|
.any(|p| &p.plugin_id == plugin_id && p.enabled)
|
|
}
|
|
|
|
/// Assignments for an agent, if any.
|
|
#[must_use]
|
|
pub fn assignments_for(&self, agent_id: crate::AgentId) -> Option<&AgentPluginAssignments> {
|
|
self.agents.iter().find(|a| a.agent_id == agent_id)
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
/// Activation scope. Missing in older manifests means app-level activation.
|
|
#[serde(default)]
|
|
pub activation_scope: PluginActivationScope,
|
|
/// 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,
|
|
}
|
|
|
|
impl PluginMcpServerSpec {
|
|
/// Stable, bounded MCP client name for this plugin server.
|
|
///
|
|
/// Codex prefixes this value with `mcp__` when it builds tool namespaces, whose
|
|
/// provider limit is 64 ASCII bytes. Keep this value at most 59 bytes and retain
|
|
/// collision resistance after truncating the readable stem by suffixing the
|
|
/// first 16 hexadecimal characters of SHA-256(`plugin_id + "\0" + server_id`).
|
|
#[must_use]
|
|
pub fn client_name(&self) -> String {
|
|
const PREFIX: &str = "idea_plugin__";
|
|
const HASH_HEX_LEN: usize = 16;
|
|
const MAX_CLIENT_NAME_LEN: usize = 59;
|
|
const SEPARATOR: &str = "__";
|
|
const MAX_STEM_LEN: usize =
|
|
MAX_CLIENT_NAME_LEN - PREFIX.len() - SEPARATOR.len() - HASH_HEX_LEN;
|
|
|
|
let raw_stem = format!("{}__{}", self.plugin_id.as_str(), self.server_id.as_str());
|
|
let stem: String = raw_stem
|
|
.bytes()
|
|
.map(|byte| {
|
|
if byte.is_ascii_alphanumeric() {
|
|
char::from(byte).to_ascii_lowercase()
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.take(MAX_STEM_LEN)
|
|
.collect();
|
|
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(self.plugin_id.as_str().as_bytes());
|
|
hasher.update([0]);
|
|
hasher.update(self.server_id.as_str().as_bytes());
|
|
let hash = hex::encode(hasher.finalize());
|
|
|
|
format!("{PREFIX}{stem}{SEPARATOR}{}", &hash[..HASH_HEX_LEN])
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_mcp_client_name_is_bounded_stable_and_collision_resistant() {
|
|
fn spec(plugin: &str, server: &str) -> PluginMcpServerSpec {
|
|
PluginMcpServerSpec {
|
|
plugin_id: PluginId::new(plugin).unwrap(),
|
|
server_id: PluginMcpServerId::new(server).unwrap(),
|
|
identity: String::new(),
|
|
display_name: String::new(),
|
|
command: String::new(),
|
|
args: Vec::new(),
|
|
env: Vec::new(),
|
|
cwd: String::new(),
|
|
transport: "stdio".to_owned(),
|
|
}
|
|
}
|
|
|
|
let unity = spec(
|
|
"com.idea.unity-developer-tools",
|
|
"unity-developer-tools.tools",
|
|
);
|
|
assert_eq!(
|
|
unity.client_name(),
|
|
"idea_plugin__com_idea_unity_developer_too__0d29c92c5eef2cb8"
|
|
);
|
|
assert!(unity.client_name().is_ascii());
|
|
assert_eq!(unity.client_name().len(), 59);
|
|
assert_eq!(format!("mcp__{}", unity.client_name()).len(), 64);
|
|
|
|
let punctuation = spec("com.idea-unity", "unity.tools-v1").client_name();
|
|
let stem = punctuation
|
|
.strip_prefix("idea_plugin__")
|
|
.unwrap()
|
|
.rsplit_once("__")
|
|
.unwrap()
|
|
.0;
|
|
assert!(stem
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'));
|
|
|
|
let first = spec(
|
|
"com.idea.same-prefix-aaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"server.one",
|
|
);
|
|
let second = spec(
|
|
"com.idea.same-prefix-aaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"server.two",
|
|
);
|
|
assert_eq!(
|
|
first.client_name(),
|
|
"idea_plugin__com_idea_same_prefix_aaaaaaa__09f05fca6c0d055e"
|
|
);
|
|
assert_eq!(first.client_name(), first.client_name());
|
|
assert_ne!(
|
|
first.client_name(),
|
|
second.client_name(),
|
|
"different full identities with the same truncated stem need distinct hashes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_capabilities_serialize_public_manifest_names() {
|
|
assert_eq!(
|
|
serde_json::to_value([
|
|
PluginCapability::Ui,
|
|
PluginCapability::Mcp,
|
|
PluginCapability::Tooling
|
|
])
|
|
.unwrap(),
|
|
serde_json::json!(["ui", "mcp", "tooling"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_activation_scope_defaults_to_app_and_serializes_public_names() {
|
|
assert_eq!(PluginActivationScope::default(), PluginActivationScope::App);
|
|
assert_eq!(
|
|
serde_json::to_value(PluginActivationScope::Project).unwrap(),
|
|
serde_json::json!("project")
|
|
);
|
|
}
|
|
}
|