//! 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) -> Result { 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) -> Result { 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) -> Result { 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) -> Result { 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) -> 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, /// 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, } /// Declared impact of one generic workspace tool invocation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PluginWorkspaceExecEffect { /// The command is expected to inspect state only. Read, /// The command may modify project-owned files. Write, /// The command may use the network. Network, /// The command may perform destructive project-owned mutations. Destructive, } /// One manifest-declared allow rule for short, hidden workspace tool execution. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginWorkspaceExecRule { /// Bare executable name resolved by the host without a shell. pub command: String, /// Exact argv tokens. `*` matches one token, or the remaining suffix when last. #[serde(default)] pub args: Vec, /// Allowed normalized project-relative cwd. `*` allows any confined cwd. #[serde(default = "default_workspace_exec_cwd")] pub cwd: String, /// Declared impact used by host policy and audit. pub effect: PluginWorkspaceExecEffect, } fn default_workspace_exec_cwd() -> String { ".".to_owned() } impl PluginWorkspaceExecRule { /// Whether this rule authorizes the exact executable, argv, and confined cwd. #[must_use] pub fn matches(&self, command: &str, args: &[String], cwd: &str) -> bool { self.command == command && (self.cwd == "*" || self.cwd == cwd) && workspace_exec_args_match(&self.args, args) } } fn workspace_exec_args_match(pattern: &[String], args: &[String]) -> bool { let Some((last, prefix)) = pattern.split_last() else { return args.is_empty(); }; if last == "*" { return args.len() >= prefix.len() && prefix .iter() .zip(args) .all(|(expected, actual)| expected == "*" || expected == actual); } pattern.len() == args.len() && pattern .iter() .zip(args) .all(|(expected, actual)| expected == "*" || expected == actual) } /// Manifest permission block for generic project-scoped executable access. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginWorkspaceExecPermission { /// Explicit allow rules. An empty list denies every invocation. #[serde(default)] pub allow: Vec, } /// Security-sensitive permissions requested by a plugin manifest. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginPermissions { /// Short, hidden external-tool execution permissions. #[serde(default)] pub workspace_exec: Option, } /// Audited terminal outcome of a plugin workspace execution request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PluginWorkspaceExecAuditOutcome { /// A manifest/project/runtime permission rejected the request. Denied, /// The allowed process was supervised to a terminal result. Completed, /// The allowed process could not be started or supervised. Failed, } /// Metadata-only audit event. Captured stdout/stderr contents are never included. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginWorkspaceExecAuditEntry { /// Calling plugin identity injected by the host runtime. pub plugin_id: PluginId, /// Project that confined the invocation. pub project_id: crate::ProjectId, /// Bare executable name. pub command: String, /// Argument count; argument values are intentionally omitted. pub arg_count: usize, /// Normalized project-relative cwd. pub cwd: String, /// Manifest-declared effect, absent for a request denied before rule matching. pub effect: Option, /// Index of the manifest allow rule used, when allowed. pub permission_rule_index: Option, /// Terminal audit outcome. pub outcome: PluginWorkspaceExecAuditOutcome, /// Exit code when available. pub exit_code: Option, /// Whether the process hit the host timeout. pub timed_out: bool, /// Retained stdout byte count, never content. pub stdout_bytes: usize, /// Retained stderr byte count, never content. pub stderr_bytes: usize, /// Execution duration. pub duration_ms: u64, /// Audit timestamp from the host clock. pub occurred_at_ms: i64, } /// 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) -> Result { 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) -> Result { 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) -> Result { 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) -> Result { 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, /// Optional icon path. #[serde(default)] pub icon: Option, } /// 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, /// Optional icon path. #[serde(default)] pub icon: Option, /// Optional declarative condition. #[serde(default)] pub when: Option, } /// 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, } /// 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, /// Optional icon path. #[serde(default)] pub icon: Option, /// Optional declarative condition. #[serde(default)] pub when: Option, } /// 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, /// Environment variables. #[serde(default)] pub env: Vec<(String, String)>, /// Working directory. #[serde(default)] pub cwd: Option, /// 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, } /// 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, } /// 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, /// Top-level menus. #[serde(default)] pub menus: Vec, /// Menu items. #[serde(default)] pub menu_items: Vec, /// Slash commands. #[serde(default)] pub slash_commands: Vec, /// Layout contributions. #[serde(default)] pub layouts: Vec, /// MCP server contributions. #[serde(default)] pub mcp_servers: Vec, } /// 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, /// Assigned external MCP tools. #[serde(default)] pub tools: Vec, } /// 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, /// Per-agent assignments. Dangling entries are intentional tombstones. #[serde(default)] pub agents: Vec, } 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, /// Version. pub version: PluginVersion, /// Description. #[serde(default)] pub description: Option, /// Engine constraint for IdeA. #[serde(default)] pub engine_idea: Option, /// Main ESM bundle. pub main: RelativePath, /// Optional icon path. #[serde(default)] pub icon: Option, /// Trust level. pub trust_level: PluginTrustLevel, /// Capabilities. #[serde(default)] pub capabilities: Vec, /// Activation scope. Missing in older manifests means app-level activation. #[serde(default)] pub activation_scope: PluginActivationScope, /// Security-sensitive host permissions requested by the plugin. #[serde(default)] pub permissions: PluginPermissions, /// 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, } /// 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, } 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 { 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::`. pub identity: String, /// Display name. pub display_name: String, /// Command. pub command: String, /// Args. pub args: Vec, /// Env. pub env: Vec<(String, String)>, /// Cwd. pub cwd: String, /// Transport. pub transport: String, /// Exact MCP tool names authorized for the target agent. /// /// This allowlist is carried with the launch spec so provider adapters never /// have to infer authorization from the server's advertised catalogue. #[serde(default)] pub authorized_tools: Vec, } 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, } /// MCP status set. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginMcpStatusSet { /// Statuses. pub servers: Vec, } /// 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, /// 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(), authorized_tools: Vec::new(), } } 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") ); } #[test] fn workspace_exec_rule_matches_exact_tokens_and_trailing_suffix_wildcard() { let rule = PluginWorkspaceExecRule { command: "tool".to_owned(), args: vec!["status".to_owned(), "*".to_owned()], cwd: ".".to_owned(), effect: PluginWorkspaceExecEffect::Read, }; assert!(rule.matches("tool", &["status".to_owned()], ".")); assert!(rule.matches("tool", &["status".to_owned(), "--short".to_owned()], ".")); assert!(!rule.matches("tool", &["commit".to_owned()], ".")); assert!(!rule.matches("other", &["status".to_owned()], ".")); assert!(!rule.matches("tool", &["status".to_owned()], "nested")); } }