//! Generic AGC plugin host. //! //! This module deliberately contains no target-editor knowledge. Plugin //! discovery, manifest validation, process lifecycle, JSON-RPC, UI/capability //! registration, permission checks and audit records are shared by every //! editor. Target-specific work is delegated to `editor_adapter`. use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::Arc; use std::sync::Mutex; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tauri::{Manager, State}; use crate::editor_adapter::{EditorAdapter, EditorConnectionInfo}; type EditorRegistry = Arc>>>; type ProjectContext = Arc>>; type PendingRpc = Arc>>>>; const PLUGIN_MANIFEST_FILE_NAME: &str = "plugin.json"; const PLUGIN_MANIFEST_FALLBACK: &str = ".codex-plugin/plugin.json"; const AGENT_PLUGINS_SCHEMA: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; const AGC_EXTENSION_NAMESPACE: &str = "world.genarrative.agc"; const PLUGIN_PROTOCOL_VERSION: &str = "agc.plugin.v1"; const PLUGIN_API_VERSION: &str = "v1"; const AUDIT_FILE_NAME: &str = "audit.jsonl"; const RPC_TIMEOUT: Duration = Duration::from_secs(10); // 编辑器连接可包含受控引导;外层必须覆盖引导和编辑器命令的完整期限。 const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90); const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_RPC_BYTES: usize = 2 * 1024 * 1024; const KNOWN_PERMISSIONS: &[&str] = &[ "events.subscribe", "project.read", "editor.rpc", "ui.register", "capability.register", ]; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginPanelManifest { pub(crate) id: String, pub(crate) title: String, pub(crate) entry: String, #[serde(default)] pub(crate) placement: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginManifest { pub(crate) id: String, pub(crate) name: String, pub(crate) version: String, #[serde(default = "default_api_version")] pub(crate) api_version: String, pub(crate) entry: Option, #[serde(default)] pub(crate) permissions: BTreeSet, #[serde(default = "default_enabled")] pub(crate) enabled: bool, #[serde(default)] pub(crate) adapter: Option, #[serde(default)] pub(crate) panels: Vec, } fn default_api_version() -> String { PLUGIN_API_VERSION.to_string() } fn default_enabled() -> bool { true } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginCommandDescriptor { pub(crate) id: String, pub(crate) title: String, pub(crate) description: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginPanelDescriptor { pub(crate) id: String, pub(crate) title: String, pub(crate) entry: String, pub(crate) placement: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginCapabilityDescriptor { pub(crate) id: String, pub(crate) description: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginPanelContent { panel: PluginPanelDescriptor, html: String, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PluginSummary { pub(crate) id: String, pub(crate) name: String, pub(crate) version: String, pub(crate) api_version: String, pub(crate) enabled: bool, /// 内置插件随包分发,不能卸载,只能通过可用开关启停。 pub(crate) builtin: bool, pub(crate) has_runtime: bool, pub(crate) status: String, pub(crate) adapter: Option, pub(crate) permissions: Vec, pub(crate) commands: Vec, pub(crate) panels: Vec, pub(crate) capabilities: Vec, pub(crate) last_error: Option, } /// Unified catalog entry. Skills and MCPs keep their existing runtime /// adapters, while executable plugins use this host's lifecycle and RPC. #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AgcExtensionSummary { pub(crate) kind: String, pub(crate) id: String, pub(crate) name: String, pub(crate) enabled: bool, pub(crate) builtin: bool, pub(crate) status: String, pub(crate) plugin: Option, pub(crate) client_extension: Option, } struct RunningPlugin { child: Child, #[cfg(windows)] _job: crate::process_session::WindowsProcessJob, stdin: Arc>, lines: Option>, pending: PendingRpc, registrations: Arc>, next_request_id: u64, } impl Drop for RunningPlugin { fn drop(&mut self) { #[cfg(unix)] unsafe { libc::kill(-(self.child.id() as i32), libc::SIGKILL); } let _ = self.child.kill(); let _ = self.child.wait(); } } #[derive(Default)] struct PluginRegistrations { commands: BTreeMap, panels: BTreeMap, capabilities: BTreeMap, subscriptions: BTreeMap, } #[derive(Default)] struct PluginRegistrationsSnapshot { commands: Vec, panels: Vec, capabilities: Vec, } fn register_entry( entries: &mut BTreeMap, id: String, entry: T, ) -> Result<(), String> { if entries.len() >= 128 || entries.contains_key(&id) { return Err("插件注册项重复或超过数量限制".to_string()); } entries.insert(id, entry); Ok(()) } struct PluginRecord { id: String, manifest: PluginManifest, root: PathBuf, status: String, last_error: Option, running: Option, } #[derive(Default)] struct PluginHostState { root: Option, workspace: Option, plugins: BTreeMap, active_project: ProjectContext, editors: EditorRegistry, } #[derive(Default)] pub(crate) struct PluginHost { state: Mutex, } #[derive(Debug, Deserialize)] struct RpcEnvelope { #[serde(default)] jsonrpc: Option, #[serde(default)] id: Option, #[serde(default)] method: Option, #[serde(default)] params: Option, #[serde(default, deserialize_with = "deserialize_rpc_result")] result: Option, #[serde(default)] error: Option, } fn deserialize_rpc_result<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { Value::deserialize(deserializer).map(Some) } fn valid_identifier(value: &str) -> bool { let mut chars = value.chars(); matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit()) && value.len() <= 64 && value .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.')) } fn valid_text(value: &str, max: usize) -> bool { let trimmed = value.trim(); !trimmed.is_empty() && trimmed.len() <= max && !trimmed.chars().any(char::is_control) } fn validate_relative_path(value: &str) -> Result<(), String> { let path = Path::new(value.strip_prefix("./").unwrap_or(value)); if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) || value.contains(['\\', ':']) || path.is_absolute() { return Err("插件入口必须是相对路径".to_string()); } if path .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err("插件入口不能包含越界或特殊路径".to_string()); } Ok(()) } fn project_read_path(project: &Path, relative: &str) -> Result { validate_relative_path(relative)?; let canonical_project = project .canonicalize() .map_err(|_| "当前项目不可读".to_string())?; let mut candidate = canonical_project.clone(); for component in Path::new(relative).components() { let Component::Normal(name) = component else { return Err("项目文件路径无效".to_string()); }; let name_text = name.to_string_lossy().to_ascii_lowercase(); if matches!( name_text.as_str(), ".agent" | ".agents" | ".codex" | ".git" | ".env" ) || name_text.starts_with(".env.") { return Err("插件不能读取项目控制或凭据目录".to_string()); } candidate.push(name); let metadata = fs::symlink_metadata(&candidate).map_err(|_| "项目文件不可读".to_string())?; if metadata.file_type().is_symlink() { return Err("插件不能读取符号链接".to_string()); } #[cfg(windows)] { use std::os::windows::fs::MetadataExt; if metadata.file_attributes() & 0x400 != 0 { return Err("插件不能读取重解析点".to_string()); } } } let canonical = candidate .canonicalize() .map_err(|_| "项目文件不可读".to_string())?; if !canonical.starts_with(&canonical_project) { return Err("插件文件读取越出项目目录".to_string()); } Ok(canonical) } pub(crate) fn validate_manifest(manifest: &PluginManifest) -> Result<(), String> { if !valid_identifier(&manifest.id) { return Err("插件 id 必须使用小写字母、数字、点、短横线或下划线".to_string()); } if !valid_text(&manifest.name, 80) || !valid_text(&manifest.version, 32) { return Err("插件名称或版本无效".to_string()); } if manifest.api_version != PLUGIN_API_VERSION { return Err(format!("不支持的插件 API 版本:{}", manifest.api_version)); } if let Some(entry) = manifest.entry.as_deref() { validate_relative_path(entry)?; } if manifest .permissions .iter() .any(|permission| !KNOWN_PERMISSIONS.contains(&permission.as_str())) { return Err("插件声明了未知权限".to_string()); } if let Some(adapter) = &manifest.adapter { if !valid_identifier(adapter) { return Err("编辑器适配器标识无效".to_string()); } } let mut panel_ids = BTreeSet::new(); for panel in &manifest.panels { if !valid_identifier(&panel.id) || !valid_text(&panel.title, 80) || !panel_ids.insert(panel.id.clone()) { return Err("插件面板声明无效或存在重复 id".to_string()); } validate_relative_path(&panel.entry)?; } Ok(()) } fn manifest_path(root: &Path) -> Option { for candidate in [PLUGIN_MANIFEST_FILE_NAME, PLUGIN_MANIFEST_FALLBACK] { let path = root.join(candidate); if path.is_file() { return Some(path); } } None } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawPluginManifest { #[serde(rename = "$schema")] schema: Option, name: String, #[serde(default)] version: Option, #[serde(default)] extensions: BTreeMap, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct AgcRuntimeExtension { #[serde(default)] api_version: Option, #[serde(default)] entry: Option, #[serde(default)] permissions: BTreeSet, #[serde(default = "default_enabled")] enabled: bool, #[serde(default)] adapter: Option, #[serde(default)] panels: Vec, } pub(crate) fn read_plugin_manifest(root: &Path) -> Result { let path = manifest_path(root).ok_or_else(|| "缺少 plugin.json manifest".to_string())?; let metadata = fs::symlink_metadata(&path).map_err(|error| format!("读取插件 manifest 失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > MAX_MANIFEST_BYTES { return Err("插件 manifest 必须是受限的普通文件".to_string()); } let bytes = fs::read(&path).map_err(|error| format!("读取插件 manifest 失败:{error}"))?; let raw = serde_json::from_slice::(&bytes) .map_err(|error| format!("解析插件 manifest 失败:{error}"))?; if path == root.join(PLUGIN_MANIFEST_FILE_NAME) && raw.schema.as_deref() != Some(AGENT_PLUGINS_SCHEMA) { return Err("不支持的 Agent Plugins schema".to_string()); } if path == root.join(PLUGIN_MANIFEST_FILE_NAME) && raw.name.split('-').any(|part| { part.is_empty() || !part .chars() .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) }) { return Err("Agent Plugin name 必须使用 kebab-case".to_string()); } let runtime = serde_json::from_value::( raw.extensions .get(AGC_EXTENSION_NAMESPACE) .cloned() .unwrap_or_else(|| json!({})), ) .map_err(|error| format!("AGC 插件运行扩展无效:{error}"))?; let display_name = raw .extensions .get("com.openai") .and_then(|extension| extension.get("interface")) .and_then(|interface| interface.get("displayName")) .and_then(Value::as_str) .unwrap_or(&raw.name) .to_string(); let manifest = PluginManifest { id: raw.name, name: display_name, version: raw.version.unwrap_or_else(|| "0.0.0".to_string()), api_version: runtime.api_version.unwrap_or_else(default_api_version), entry: runtime.entry, permissions: runtime.permissions, enabled: runtime.enabled, adapter: runtime.adapter, panels: runtime.panels, }; validate_manifest(&manifest)?; if let Some(entry) = manifest.entry.as_deref() { let entry = project_read_path(root, entry.strip_prefix("./").unwrap_or(entry))?; let metadata = fs::symlink_metadata(&entry).map_err(|error| format!("插件入口不可读:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("插件入口必须是普通文件".to_string()); } } Ok(manifest) } fn plugin_root(config_dir: &Path) -> Result { let root = config_dir.join("extensions"); fs::create_dir_all(&root).map_err(|error| format!("准备插件目录失败:{error}"))?; Ok(root) } /// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`, /// 开发构建再回退仓库里的 `plugins/` 工作区。 pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option { if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { let workspace = PathBuf::from(workspace); if workspace.is_dir() { return Some(workspace); } } if let Ok(resource_dir) = app.path().resource_dir() { let bundled = resource_dir.join("plugins"); if bundled.is_dir() { return Some(bundled); } } #[cfg(debug_assertions)] { let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); let workspace = repo_root.join("plugins"); if workspace.is_dir() { return Some(workspace); } } None } /// 统一后的插件来源,屏蔽“AppData 导入”和“plugins/ 工作区”的差别。 #[derive(Clone, Debug)] struct ScannedPluginSource { id: String, name: String, original_name: String, /// `Some` 表示启用状态由来源索引决定;`None` 表示沿用插件 manifest 声明。 enabled: Option, root: PathBuf, } impl ScannedPluginSource { fn from_imported(source: crate::client_extensions::ClientPluginSource) -> Self { Self { id: source.item.id, name: source.item.name, original_name: source.item.original_name, enabled: Some(source.item.enabled), root: source.root, } } } /// 扫描 `plugins/` 工作区:每个含根目录 `plugin.json` 的子目录是一个插件包。 /// /// 工作区插件随包分发:内置插件按用户可用开关决定启用状态,其它工作区插件 /// 沿用 manifest 声明。 fn workspace_plugin_sources(root: &Path) -> Result, String> { let entries = match fs::read_dir(root) { Ok(entries) => entries, Err(error) => return Err(format!("读取插件工作区失败:{error}")), }; let mut sources = Vec::new(); for entry in entries.flatten() { let plugin_root = entry.path(); if !plugin_root.is_dir() || !plugin_root.join(PLUGIN_MANIFEST_FILE_NAME).is_file() { continue; } let id = entry.file_name().to_string_lossy().into_owned(); let enabled = crate::builtin_plugins::toggle_state(&id); sources.push(ScannedPluginSource { id: id.clone(), name: id.clone(), original_name: id, enabled, root: plugin_root, }); } sources.sort_by(|left, right| left.id.cmp(&right.id)); Ok(sources) } fn audit_path(root: &Path) -> PathBuf { root.join(AUDIT_FILE_NAME) } fn audit(root: &Path, plugin_id: &str, action: &str, allowed: bool, reason: Option<&str>) { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_millis()) .unwrap_or_default(); let record = json!({ "schemaVersion": PLUGIN_PROTOCOL_VERSION, "timestamp": timestamp, "pluginId": plugin_id, "action": action, "allowed": allowed, "reason": reason, }); let _ = crate::append_bounded_diagnostic_line(&audit_path(root), &record.to_string()); } fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result { let entry = root.join( manifest .entry .as_deref() .ok_or_else(|| "该 Plugin 只包含 Skill/MCP,不能作为进程启动".to_string())?, ); let mut command = if matches!( entry.extension().and_then(|value| value.to_str()), Some("js" | "mjs" | "cjs") ) { let mut command = Command::new("node"); command.arg(&entry); command } else { Command::new(&entry) }; command .env_clear() .current_dir(root) .env("AGC_PLUGIN_ID", &manifest.id) .env("AGC_PLUGIN_PROTOCOL", PLUGIN_PROTOCOL_VERSION) .env("AGC_PLUGIN_API_VERSION", &manifest.api_version) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); for variable in [ "PATH", "SystemRoot", "WINDIR", "SystemDrive", "ComSpec", "TEMP", "TMP", "PATHEXT", ] { if let Some(value) = std::env::var_os(variable) { command.env(variable, value); } } #[cfg(windows)] { use std::os::windows::process::CommandExt; command.creation_flags(0x0800_0000); } #[cfg(unix)] { use std::os::unix::process::CommandExt; command.process_group(0); } let mut child = command .spawn() .map_err(|error| format!("启动插件失败:{error}"))?; #[cfg(windows)] let job = match crate::process_session::WindowsProcessJob::assign_std(&child) { Ok(job) => job, Err(error) => { let _ = child.kill(); let _ = child.wait(); return Err(error); } }; let stdin = Arc::new(Mutex::new( child .stdin .take() .ok_or_else(|| "插件 stdin 不可用".to_string())?, )); let stdout = child .stdout .take() .ok_or_else(|| "插件 stdout 不可用".to_string())?; let (sender, receiver) = mpsc::sync_channel(16); thread::spawn(move || { let mut reader = BufReader::new(stdout); while let Ok(Some(line)) = read_bounded_rpc_line(&mut reader) { if sender.send(line).is_err() { break; } } }); Ok(RunningPlugin { child, #[cfg(windows)] _job: job, stdin, lines: Some(receiver), pending: Arc::new(Mutex::new(BTreeMap::new())), registrations: Arc::new(Mutex::new(PluginRegistrations::default())), next_request_id: 1, }) } fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result, String> { let mut bytes = Vec::new(); loop { let buffer = reader .fill_buf() .map_err(|_| "读取插件输出失败".to_string())?; if buffer.is_empty() { return if bytes.is_empty() { Ok(None) } else { Err("插件输出缺少换行".to_string()) }; } let newline = buffer.iter().position(|byte| *byte == b'\n'); let count = newline.map_or(buffer.len(), |index| index + 1); if bytes.len() + count > MAX_RPC_BYTES { return Err("插件输出超过大小限制".to_string()); } bytes.extend_from_slice(&buffer[..count]); reader.consume(count); if newline.is_some() { return String::from_utf8(bytes) .map(Some) .map_err(|_| "插件输出不是 UTF-8".to_string()); } } } fn write_rpc(stdin: &mut ChildStdin, value: &Value) -> Result<(), String> { let payload = serde_json::to_string(value).map_err(|error| format!("序列化插件 RPC 失败:{error}"))?; if payload.len() > MAX_RPC_BYTES { return Err("插件 RPC 请求过大".to_string()); } writeln!(stdin, "{payload}").map_err(|error| format!("写入插件 RPC 失败:{error}"))?; stdin .flush() .map_err(|error| format!("刷新插件 RPC 失败:{error}")) } fn write_rpc_shared(stdin: &Arc>, value: &Value) -> Result<(), String> { let mut stdin = stdin .lock() .map_err(|_| "插件 stdin 锁已损坏".to_string())?; write_rpc(&mut stdin, value) } fn descriptor_from_params Deserialize<'de>>( params: Option, ) -> Result { serde_json::from_value(params.unwrap_or_else(|| json!({}))) .map_err(|error| format!("插件注册参数无效:{error}")) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterCommandInput { id: String, title: String, #[serde(default)] description: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterPanelInput { id: String, title: String, entry: String, #[serde(default)] placement: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterCapabilityInput { id: String, #[serde(default)] description: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ProjectReadInput { path: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EditorRpcInput { #[serde(default)] adapter: Option, method: String, #[serde(default)] params: Value, } fn permission_for_method(method: &str) -> Option<&'static str> { match method { "host.project.read" => Some("project.read"), "host.rpc" => Some("editor.rpc"), "host.events.subscribe" | "host.events.unsubscribe" => Some("events.subscribe"), "host.registerPanel" | "host.unregisterPanel" => Some("ui.register"), "host.registerCapability" | "host.unregisterCapability" => Some("capability.register"), "host.registerCommand" | "host.unregisterCommand" => Some("ui.register"), _ => None, } } impl PluginHost { pub(crate) fn initialize(&self, config_dir: &Path) -> Result<(), String> { let root = plugin_root(config_dir)?; let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; state.root = Some(root.clone()); if state.workspace.is_none() { if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { let workspace = PathBuf::from(workspace); if workspace.is_dir() { state.workspace = Some(workspace); } } } self.scan_locked(&mut state, &root) } /// 注册 `plugins/` 工作区目录,让随包插件无需 AppData 导入即可被发现。 pub(crate) fn set_plugin_workspace(&self, workspace: PathBuf) -> Result<(), String> { if !workspace.is_dir() { return Err("插件工作区必须是目录".to_string()); } let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; state.workspace = Some(workspace); let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root) } fn scan_locked(&self, state: &mut PluginHostState, root: &Path) -> Result<(), String> { let workspace_sources = match state.workspace.clone() { Some(workspace) => workspace_plugin_sources(&workspace)?, None => Vec::new(), }; // 内置插件优先:随包插件不能被 AppData 同名导入覆盖,也不能被卸载。 let mut sources = workspace_sources .iter() .filter(|source| crate::builtin_plugins::is_builtin(&source.id)) .cloned() .collect::>(); for source in crate::client_extensions::client_plugin_sources_at(root)? .into_iter() .map(ScannedPluginSource::from_imported) { if !sources.iter().any(|existing| existing.id == source.id) { sources.push(source); } } for source in workspace_sources { if !sources.iter().any(|existing| existing.id == source.id) { sources.push(source); } } let mut discovered = BTreeMap::new(); for source in sources { let id = source.id.clone(); match read_plugin_manifest(&source.root) { Ok(mut manifest) => { if let Some(enabled) = source.enabled { manifest.enabled = enabled; } if source.name != source.original_name { manifest.name = source.name.clone(); } let existing = state .plugins .remove(&id) .filter(|existing| existing.manifest == manifest && manifest.enabled); let previous_error = existing .as_ref() .and_then(|existing| existing.last_error.clone()); let previously_failed = existing .as_ref() .is_some_and(|existing| existing.status == "failed"); let mut running = existing.and_then(|existing| existing.running); let exited = running .as_mut() .and_then(|running| running.child.try_wait().ok().flatten()) .is_some(); if exited { running = None; } let status = if !manifest.enabled { "disabled" } else if exited || previously_failed { "failed" } else if running.is_some() { "running" } else if manifest.entry.is_none() { "package" } else { "stopped" } .to_string(); discovered.insert( id.clone(), PluginRecord { id, manifest, root: source.root, status, last_error: if exited { Some("插件进程已退出".to_string()) } else { previous_error }, running, }, ); } Err(error) => { discovered.insert( id.clone(), PluginRecord { id: id.clone(), manifest: PluginManifest { id, name: source.name, version: "0".to_string(), api_version: PLUGIN_API_VERSION.to_string(), entry: None, permissions: BTreeSet::new(), enabled: false, adapter: None, panels: Vec::new(), }, root: source.root, status: "invalid".to_string(), last_error: Some(error), running: None, }, ); } } } state.plugins = discovered; Ok(()) } fn registrations(record: &PluginRecord) -> PluginRegistrationsSnapshot { let Some(running) = record.running.as_ref() else { return PluginRegistrationsSnapshot::default(); }; let Ok(registrations) = running.registrations.lock() else { return PluginRegistrationsSnapshot::default(); }; PluginRegistrationsSnapshot { commands: registrations.commands.values().cloned().collect(), panels: registrations.panels.values().cloned().collect(), capabilities: registrations.capabilities.values().cloned().collect(), } } pub(crate) fn list(&self) -> Result, String> { let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root)?; let cocos_project = state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .as_deref() .is_some_and(|path| { crate::project::discover_local_cocos_project_root(path) .ok() .flatten() .is_some() }); state .plugins .values() .filter(|record| { record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_project }) .map(|record| self.summary_locked(record)) .collect() } pub(crate) fn refresh(&self) -> Result, String> { self.list() } pub(crate) fn list_extensions(&self) -> Result, String> { let plugins = self.list()?; let mut entries = plugins .into_iter() .map(|plugin| AgcExtensionSummary { kind: "plugin".to_string(), id: plugin.id.clone(), name: plugin.name.clone(), enabled: plugin.enabled, builtin: plugin.builtin, status: plugin.status.clone(), plugin: Some(plugin), client_extension: None, }) .collect::>(); let root = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())? .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; for extension in crate::client_extensions::list_client_extensions_at(&root)? { if extension.extension_type == "plugin" { if let Some(entry) = entries.iter_mut().find(|entry| entry.id == extension.id) { entry.client_extension = Some(extension); } continue; } entries.push(AgcExtensionSummary { kind: extension.extension_type.clone(), id: extension.id.clone(), name: extension.name.clone(), enabled: extension.enabled, builtin: false, status: extension.status.clone(), plugin: None, client_extension: Some(extension), }); } entries.sort_by(|left, right| left.name.cmp(&right.name)); Ok(entries) } pub(crate) fn start(&self, id: &str) -> Result { self.refresh()?; let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .as_deref() .is_some_and(|path| { crate::project::discover_local_cocos_project_root(path) .ok() .flatten() .is_some() }) { return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); } let editors = state.editors.clone(); let record = state .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; if !record.manifest.enabled { return Err("插件已禁用".to_string()); } if record.manifest.entry.is_none() { return Err("该 Plugin 的 Skill/MCP 使用各自的运行适配器".to_string()); } if record.running.is_some() { return self.summary_locked(record); } match spawn_plugin(&record.manifest, &record.root) { Ok(running) => { record.running = Some(running); record.status = "running".to_string(); record.last_error = None; Self::start_plugin_pump(&root, active_project, editors, record); audit(&root, id, "start", true, None); } Err(error) => { record.status = "failed".to_string(); record.last_error = Some(error.clone()); audit(&root, id, "start", false, Some("spawn-failed")); return Err(error); } } self.summary_locked(record) } pub(crate) fn stop(&self, id: &str) -> Result { let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let record = state .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; if let Some(mut running) = record.running.take() { let _ = running.child.kill(); let _ = running.child.wait(); } record.status = if record.manifest.enabled { "stopped".to_string() } else { "disabled".to_string() }; record.last_error = None; audit(&root, id, "stop", true, None); self.summary_locked(record) } pub(crate) fn reload(&self, id: &str) -> Result { let _ = self.stop(id)?; self.refresh()?; self.start(id) } /// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。 /// /// 该状态同时被 Agent 工具目录消费,禁用后插件不能启动,对应 Runtime 工具 /// 也不再出现在工具列表与 Agent 上下文里。 pub(crate) fn set_enabled( &self, id: &str, enabled: bool, ) -> Result, String> { if !crate::builtin_plugins::is_builtin(id) { return Err("只有内置插件可以使用可用开关;导入扩展请使用扩展启用状态".to_string()); } if !enabled { let _ = self.stop(id); } crate::builtin_plugins::set_enabled(id, enabled)?; self.refresh() } pub(crate) fn read_panel( &self, id: &str, panel_id: &str, ) -> Result { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let record = state .plugins .get(id) .ok_or_else(|| "插件不存在".to_string())?; if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .as_deref() .is_some_and(|path| { crate::project::discover_local_cocos_project_root(path) .ok() .flatten() .is_some() }) { return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); } if record.running.is_none() || !record.manifest.permissions.contains("ui.register") { return Err("插件面板未激活".to_string()); } let panel = Self::registrations(record) .panels .into_iter() .find(|panel| panel.id == panel_id) .or_else(|| { record .manifest .panels .iter() .find(|panel| panel.id == panel_id) .map(|panel| PluginPanelDescriptor { id: panel.id.clone(), title: panel.title.clone(), entry: panel.entry.clone(), placement: panel.placement.clone(), }) }) .ok_or_else(|| "插件面板未注册".to_string())?; let path = project_read_path( &record.root, panel.entry.strip_prefix("./").unwrap_or(&panel.entry), )?; let bytes = fs::metadata(&path) .map_err(|_| "插件面板不可读".to_string())? .len(); if bytes > MAX_RPC_BYTES as u64 { return Err("插件面板过大".to_string()); } let html = fs::read_to_string(path).map_err(|_| "插件面板不是有效文本".to_string())?; Ok(PluginPanelContent { panel, html }) } pub(crate) fn call(&self, id: &str, method: String, params: Value) -> Result { let (root, request_id, response_receiver, pending, writer, response_timeout) = { let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let root = state .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .as_deref() .is_some_and(|path| { crate::project::discover_local_cocos_project_root(path) .ok() .flatten() .is_some() }) { return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); } let record = state .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; let response_timeout = if record.manifest.adapter.is_some() { EDITOR_RPC_TIMEOUT } else { RPC_TIMEOUT }; let running = record .running .as_mut() .ok_or_else(|| "插件尚未启动".to_string())?; let request_id = running.next_request_id; running.next_request_id = request_id .checked_add(1) .ok_or_else(|| "插件 RPC id 已耗尽".to_string())?; let (sender, receiver) = mpsc::channel(); { let mut pending = running .pending .lock() .map_err(|_| "插件 RPC 等待队列锁已损坏".to_string())?; if pending.len() >= 32 { return Err("插件 RPC 并发请求过多".to_string()); } pending.insert(request_id, sender); } ( root, request_id, receiver, Arc::clone(&running.pending), Arc::clone(&running.stdin), response_timeout, ) }; let deadline = Instant::now() + response_timeout; let (write_sender, write_receiver) = mpsc::channel(); thread::spawn(move || { let _ = write_sender.send(write_rpc_shared( &writer, &json!({"jsonrpc":"2.0", "id":request_id, "method":method, "params":params}), )); }); let result = match write_receiver.recv_timeout(RPC_TIMEOUT) { Ok(Ok(())) => match response_receiver .recv_timeout(deadline.saturating_duration_since(Instant::now())) { Ok(result) => result, Err(RecvTimeoutError::Timeout) => Err("插件 RPC 响应超时".to_string()), Err(RecvTimeoutError::Disconnected) => Err("插件进程已退出".to_string()), }, Ok(Err(error)) => Err(error), Err(_) => { self.terminate_rpc_instance(id, &pending); Err("插件 RPC 写入超时".to_string()) } }; if let Ok(mut pending) = pending.lock() { pending.remove(&request_id); } audit( &root, id, "rpc", result.is_ok(), result.as_ref().err().map(|_| "rpc-failed"), ); result } fn terminate_rpc_instance(&self, id: &str, pending: &PendingRpc) { if let Ok(mut state) = self.state.lock() { if let Some(record) = state.plugins.get_mut(id) { if record .running .as_ref() .is_some_and(|running| Arc::ptr_eq(&running.pending, pending)) { record.running = None; record.status = "failed".to_string(); record.last_error = Some("插件 RPC 写入超时".to_string()); } } } } fn start_plugin_pump( root: &Path, active_project: ProjectContext, editors: EditorRegistry, record: &mut PluginRecord, ) { let Some(running) = record.running.as_mut() else { return; }; let Some(lines) = running.lines.take() else { return; }; let writer = Arc::clone(&running.stdin); let pending = Arc::clone(&running.pending); let registrations = Arc::clone(&running.registrations); let manifest = record.manifest.clone(); let root = root.to_path_buf(); thread::spawn(move || { while let Ok(line) = lines.recv() { let Ok(envelope) = serde_json::from_str::(&line) else { continue; }; if envelope.jsonrpc.as_deref() != Some("2.0") { continue; } if envelope.method.is_none() { if let Some(id) = envelope.id.as_ref().and_then(Value::as_u64) { if let Ok(mut waiting) = pending.lock() { if let Some(sender) = waiting.remove(&id) { let result = envelope.error.map_or_else( || { envelope .result .ok_or_else(|| "插件 RPC 缺少 result".to_string()) }, |error| Err(format!("插件 RPC 错误:{error}")), ); let _ = sender.send(result); continue; } } } } if let Some(method) = envelope.method { let response = Self::handle_host_request( &root, &active_project, &editors, &manifest, ®istrations, &method, envelope.params, ); audit( &root, &manifest.id, &method, response.is_ok(), response.as_ref().err().map(|_| "host-request-failed"), ); if let Some(id) = envelope.id { let payload = match response { Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}), Err(error) => { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32001,"message":error}}) } }; let _ = write_rpc_shared(&writer, &payload); } } } if let Ok(mut waiting) = pending.lock() { let remaining = std::mem::take(&mut *waiting); for (_, sender) in remaining { let _ = sender.send(Err("插件进程已退出".to_string())); } } }); } fn handle_host_request( root: &Path, active_project: &ProjectContext, editors: &EditorRegistry, manifest: &PluginManifest, registrations: &Arc>, method: &str, params: Option, ) -> Result { if let Some(permission) = permission_for_method(method) { if !manifest.permissions.contains(permission) { audit(root, &manifest.id, method, false, Some("permission-denied")); return Err(format!("插件缺少权限:{permission}")); } } match method { "host.registerCommand" => { let input: RegisterCommandInput = descriptor_from_params(params)?; if !valid_identifier(&input.id) || !valid_text(&input.title, 80) { return Err("命令声明无效".to_string()); } register_entry( &mut registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .commands, input.id.clone(), PluginCommandDescriptor { id: input.id, title: input.title, description: input.description, }, )?; Ok(json!({"registered": true})) } "host.unregisterCommand" => { let id = params .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) .ok_or_else(|| "缺少命令 id".to_string())?; registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .commands .remove(&id); Ok(json!({"unregistered": true})) } "host.registerPanel" => { let input: RegisterPanelInput = descriptor_from_params(params)?; if !valid_identifier(&input.id) || !valid_text(&input.title, 80) { return Err("面板声明无效".to_string()); } validate_relative_path(&input.entry)?; register_entry( &mut registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .panels, input.id.clone(), PluginPanelDescriptor { id: input.id, title: input.title, entry: input.entry, placement: input.placement, }, )?; Ok(json!({"registered": true})) } "host.unregisterPanel" => { let id = params .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) .ok_or_else(|| "缺少面板 id".to_string())?; registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .panels .remove(&id); Ok(json!({"unregistered": true})) } "host.registerCapability" => { let input: RegisterCapabilityInput = descriptor_from_params(params)?; if !valid_identifier(&input.id) { return Err("能力声明无效".to_string()); } register_entry( &mut registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .capabilities, input.id.clone(), PluginCapabilityDescriptor { id: input.id, description: input.description, }, )?; Ok(json!({"registered": true})) } "host.unregisterCapability" => { let id = params .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) .ok_or_else(|| "缺少能力 id".to_string())?; registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .capabilities .remove(&id); Ok(json!({"unregistered": true})) } "host.events.subscribe" => { let event_type = params .as_ref() .and_then(|params| params.get("type")) .and_then(Value::as_str) .filter(|name| valid_text(name, 120)) .ok_or_else(|| "事件名称无效".to_string())?; let id = format!("sub-{}", uuid::Uuid::new_v4().simple()); register_entry( &mut registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .subscriptions, id.clone(), event_type.to_string(), )?; let project_path = active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .clone() .map(|path| path.to_string_lossy().into_owned()); Ok(json!({"subscriptionId": id, "projectPath": project_path})) } "host.events.unsubscribe" => { let id = params .as_ref() .and_then(|params| params.get("subscriptionId")) .and_then(Value::as_str) .ok_or_else(|| "缺少订阅 id".to_string())?; registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .subscriptions .remove(id); Ok(json!({"unsubscribed": true})) } "host.project.read" => { let input: ProjectReadInput = descriptor_from_params(params)?; let project = active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .clone() .ok_or_else(|| "尚未设置当前项目".to_string())?; let path = project_read_path(&project, &input.path)?; let metadata = fs::symlink_metadata(&path) .map_err(|error| format!("读取项目文件失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > MAX_RPC_BYTES as u64 { return Err("项目文件不可读或超过大小限制".to_string()); } let content = fs::read_to_string(&path) .map_err(|error| format!("读取项目文件失败:{error}"))?; Ok(json!({"path": input.path, "content": content})) } "host.rpc" => { let input: EditorRpcInput = descriptor_from_params(params)?; if manifest.id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .as_deref() .is_some_and(|path| { crate::project::discover_local_cocos_project_root(path) .ok() .flatten() .is_some() }) { return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string()); } let adapter = input .adapter .or_else(|| manifest.adapter.clone()) .ok_or_else(|| "插件未指定编辑器适配器".to_string())?; let editors = editors .lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; let editor = editors .get(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))?; editor.rpc(&input.method, input.params) } _ => Err(format!("宿主不支持 RPC 方法:{method}")), } } fn summary_locked(&self, record: &PluginRecord) -> Result { let registrations = Self::registrations(record); let mut panels = record .manifest .panels .iter() .map(|panel| { ( panel.id.clone(), PluginPanelDescriptor { id: panel.id.clone(), title: panel.title.clone(), entry: panel.entry.clone(), placement: panel.placement.clone(), }, ) }) .collect::>(); for panel in registrations.panels { panels.insert(panel.id.clone(), panel); } Ok(PluginSummary { id: record.id.clone(), name: record.manifest.name.clone(), version: record.manifest.version.clone(), api_version: record.manifest.api_version.clone(), enabled: record.manifest.enabled, builtin: crate::builtin_plugins::is_builtin(&record.id), has_runtime: record.manifest.entry.is_some(), status: record.status.clone(), adapter: record.manifest.adapter.clone(), permissions: record.manifest.permissions.iter().cloned().collect(), commands: registrations.commands, panels: panels.into_values().collect(), capabilities: registrations.capabilities, last_error: record.last_error.clone(), }) } pub(crate) fn set_active_project(&self, project_path: Option) -> Result<(), String> { let mut state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let project = project_path .map(|path| { let path = PathBuf::from(path); if !path.is_dir() { return Err("项目路径必须是目录".to_string()); } path.canonicalize() .map_err(|_| "项目目录不可读".to_string()) }) .transpose()?; let is_cocos_project = project.as_deref().is_some_and(|path| { crate::project::discover_local_cocos_project_root(path) .ok() .flatten() .is_some() }); *state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? = project; if !is_cocos_project { if let Some(record) = state .plugins .get_mut(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) { if let Some(mut running) = record.running.take() { let _ = running.child.kill(); let _ = running.child.wait(); } if record.manifest.enabled { record.status = "stopped".to_string(); } } } for record in state.plugins.values() { if let Some(running) = record.running.as_ref() { let subscribed = running .registrations .lock() .map_err(|_| "插件注册表锁已损坏".to_string())? .subscriptions .values() .any(|name| name == "project.changed"); if subscribed { let _ = write_rpc_shared( &running.stdin, &json!({ "jsonrpc":"2.0", "method":"host.event", "params":{ "type":"project.changed", "payload":{"projectPath": state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? .as_ref() .map(|path| path.to_string_lossy().into_owned())}, }, }), ); } } } Ok(()) } pub(crate) fn register_editor_adapter( &self, adapter: Box, ) -> Result<(), String> { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors .lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; if editors.contains_key(adapter.id()) { return Err("编辑器适配器已注册".to_string()); } editors.insert(adapter.id().to_string(), adapter); Ok(()) } pub(crate) fn detect_editor( &self, adapter: String, project_path: String, ) -> Result { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let editors = state .editors .lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? .detect(Path::new(project_path.trim())) } pub(crate) fn connect_editor( &self, adapter: String, pid: u32, project_path: String, version: String, ) -> Result { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors .lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get_mut(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? .connect(pid, Path::new(project_path.trim()), version.trim()) } pub(crate) fn disconnect_editor(&self, adapter: String) -> Result<(), String> { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let mut editors = state .editors .lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get_mut(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? .disconnect(); Ok(()) } pub(crate) fn translate_editor_rpc( &self, adapter: String, method: String, params: Value, ) -> Result { let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; let editors = state .editors .lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; editors .get(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? .translate_rpc(&method, params) } } #[tauri::command] pub(crate) fn list_agc_plugins(host: State<'_, PluginHost>) -> Result, String> { host.list() } #[tauri::command] pub(crate) fn list_agc_extensions( host: State<'_, PluginHost>, ) -> Result, String> { host.list_extensions() } #[tauri::command] pub(crate) fn refresh_agc_plugins( host: State<'_, PluginHost>, ) -> Result, String> { host.refresh() } #[tauri::command] pub(crate) fn start_agc_plugin( id: String, host: State<'_, PluginHost>, ) -> Result { host.start(id.trim()) } #[tauri::command] pub(crate) fn stop_agc_plugin( id: String, host: State<'_, PluginHost>, ) -> Result { host.stop(id.trim()) } #[tauri::command] pub(crate) fn reload_agc_plugin( id: String, host: State<'_, PluginHost>, ) -> Result { host.reload(id.trim()) } #[tauri::command] pub(crate) fn set_agc_plugin_enabled( id: String, enabled: bool, host: State<'_, PluginHost>, ) -> Result, String> { host.set_enabled(id.trim(), enabled) } #[tauri::command] pub(crate) async fn call_agc_plugin( id: String, method: String, params: Value, app: tauri::AppHandle, ) -> Result { if !valid_text(&method, 120) || method.chars().any(char::is_control) { return Err("插件 RPC 方法无效".to_string()); } tauri::async_runtime::spawn_blocking(move || { app.state::().call(id.trim(), method, params) }) .await .map_err(|_| "插件 RPC 任务失败".to_string())? } #[tauri::command] pub(crate) fn read_agc_plugin_panel( id: String, panel_id: String, host: State<'_, PluginHost>, ) -> Result { host.read_panel(&id, &panel_id) } #[tauri::command] pub(crate) fn set_agc_plugin_project_path( project_path: Option, host: State<'_, PluginHost>, ) -> Result<(), String> { host.set_active_project(project_path) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn manifest() -> PluginManifest { PluginManifest { id: "sample-plugin".to_string(), name: "Sample".to_string(), version: "1.0.0".to_string(), api_version: PLUGIN_API_VERSION.to_string(), entry: Some("index.js".to_string()), permissions: ["ui.register".to_string()].into_iter().collect(), enabled: true, adapter: None, panels: Vec::new(), } } fn write_fixture(plugin: &Path, script: &str) { fs::create_dir_all(plugin).expect("plugin directory"); fs::write( plugin.join("plugin.json"), json!({ "$schema": AGENT_PLUGINS_SCHEMA, "name": "sample-plugin", "version": "1.0.0", "extensions": { "world.genarrative.agc": { "entry": "index.js", "permissions": ["ui.register"] } } }) .to_string(), ) .expect("manifest"); fs::write(plugin.join("index.js"), script).expect("entry"); } fn import_fixture(config: &Path, plugin: &Path) -> String { crate::client_extensions::import_client_extension_at( &config.join("extensions"), plugin, &fs::metadata(plugin).expect("metadata"), ) .expect("import plugin") .imported .into_iter() .find(|item| item.extension_type == "plugin") .expect("plugin item") .id } #[test] fn validates_manifest_and_rejects_traversal() { let mut value = manifest(); assert!(validate_manifest(&value).is_ok()); value.entry = Some("../escape.js".to_string()); assert!(validate_manifest(&value).is_err()); } #[test] fn scans_and_lists_plugin_manifest() { let directory = tempdir().expect("temp config"); let plugin = directory.path().join("plugins/sample"); write_fixture(&plugin, "process.stdin.resume();"); let id = import_fixture(directory.path(), &plugin); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); let list = host.list().expect("list"); assert_eq!(list.len(), 1); assert_eq!(list[0].id, id); } fn write_workspace_plugin(workspace: &Path, name: &str, enabled: bool) { let plugin = workspace.join(name); fs::create_dir_all(&plugin).expect("plugin directory"); fs::write( plugin.join("plugin.json"), json!({ "$schema": AGENT_PLUGINS_SCHEMA, "name": name, "version": "1.0.0", "extensions": { "world.genarrative.agc": { "entry": "index.js", "permissions": ["ui.register"], "enabled": enabled } } }) .to_string(), ) .expect("manifest"); fs::write(plugin.join("index.js"), "process.stdin.resume();").expect("entry"); } #[test] fn scans_plugins_workspace_and_honors_manifest_enabled_flag() { let directory = tempdir().expect("temp config"); let workspace = directory.path().join("workspace"); write_workspace_plugin(&workspace, "sample-plugin", true); write_workspace_plugin(&workspace, "disabled-plugin", false); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); assert!(host.list().expect("list before workspace").is_empty()); host.set_plugin_workspace(workspace.clone()) .expect("set workspace"); let list = host.list().expect("list after workspace"); assert_eq!(list.len(), 2); let enabled = list .iter() .find(|plugin| plugin.id == "sample-plugin") .expect("enabled plugin"); assert!(enabled.enabled); assert_eq!(enabled.status, "stopped"); assert_eq!(enabled.adapter, None); let disabled = list .iter() .find(|plugin| plugin.id == "disabled-plugin") .expect("disabled plugin"); assert!(!disabled.enabled); assert_eq!(disabled.status, "disabled"); } #[test] fn denies_ungranted_host_registration() { let directory = tempdir().expect("temp config"); let plugin = directory.path().join("plugins/sample"); write_fixture(&plugin, "process.stdin.resume();"); let id = import_fixture(directory.path(), &plugin); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); let mut state = host.state.lock().expect("host lock"); let record = state.plugins.get_mut(&id).expect("record"); let context = ProjectContext::default(); let editors = EditorRegistry::default(); let registrations = Arc::new(Mutex::new(PluginRegistrations::default())); assert!(PluginHost::handle_host_request( directory.path().join("extensions").as_path(), &context, &editors, &record.manifest, ®istrations, "host.project.read", None ) .is_err()); } #[test] fn portable_skill_only_package_does_not_require_a_process_entry() { let directory = tempdir().expect("temp plugin"); fs::write( directory.path().join("plugin.json"), json!({ "$schema": AGENT_PLUGINS_SCHEMA, "name": "skills-only" }) .to_string(), ) .expect("manifest"); let parsed = read_plugin_manifest(directory.path()).expect("portable manifest"); assert!(parsed.entry.is_none()); assert_eq!(parsed.id, "skills-only"); } #[test] fn process_handles_idle_registration_rpc_and_stop() { let directory = tempdir().expect("temp config"); let plugin = directory.path().join("plugins/sample"); write_fixture( &plugin, r#" const readline = require('node:readline'); const send = value => process.stdout.write(JSON.stringify(value) + '\n'); readline.createInterface({ input: process.stdin }).on('line', line => { const message = JSON.parse(line); if (message.method === 'echo') send({ jsonrpc: '2.0', id: message.id, result: message.params }); }); setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', params: { id: 'hello', title: 'Hello' } }), 100); "#, ); let id = import_fixture(directory.path(), &plugin); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); host.start(&id).expect("start"); let deadline = std::time::Instant::now() + Duration::from_secs(3); loop { if host.list().expect("list")[0].commands.len() == 1 { break; } assert!( std::time::Instant::now() < deadline, "idle registration was not dispatched" ); thread::sleep(Duration::from_millis(25)); } assert_eq!( host.call(&id, "echo".to_string(), json!({"ok": true})) .expect("RPC"), json!({"ok": true}) ); assert_eq!( host.call(&id, "echo".to_string(), Value::Null) .expect("null RPC result"), Value::Null ); assert_eq!(host.stop(&id).expect("stop").status, "stopped"); } #[test] fn output_without_a_bounded_newline_is_rejected() { let mut reader = std::io::Cursor::new(vec![b'a'; MAX_RPC_BYTES + 1]); assert!(read_bounded_rpc_line(&mut reader).is_err()); } struct StubCocosAdapter; impl EditorAdapter for StubCocosAdapter { fn id(&self) -> &'static str { "cocos-editor" } fn detect(&self, _project_path: &Path) -> Result { Err("stub adapter 不探测进程".to_string()) } fn connect( &mut self, _pid: u32, _project_path: &Path, _version: &str, ) -> Result { Err("stub adapter 不建立连接".to_string()) } fn disconnect(&mut self) {} fn translate_rpc(&self, _method: &str, params: Value) -> Result { Ok(params) } fn rpc(&self, method: &str, params: Value) -> Result { // 与 native 适配器的 CocosEditorCommandResponse 同形,供插件入口判断 status。 Ok(json!({"ok": true, "method": method, "params": params})) } } #[test] fn builtin_plugin_toggle_controls_availability() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); fs::write( directory.path().join("package.json"), r#"{"creator":{"version":"3.8.8"}}"#, ) .expect("cocos package"); fs::create_dir(directory.path().join("assets")).expect("cocos assets"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); host.initialize(directory.path()).expect("initialize"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) .expect("set cocos project"); let summary = |list: Vec| { list.into_iter() .find(|plugin| plugin.id == "agc-cocos-editor") .expect("built-in plugin") }; let enabled = summary(host.list().expect("list")); assert!(enabled.builtin); assert!(enabled.enabled); let disabled = summary( host.set_enabled("agc-cocos-editor", false) .expect("disable built-in plugin"), ); assert!(!disabled.enabled); assert_eq!(disabled.status, "disabled"); assert!(host.start("agc-cocos-editor").is_err()); assert!(host .set_enabled("imported-extension", false) .expect_err("imported extensions use the extension index") .contains("只有内置插件")); let re_enabled = summary( host.set_enabled("agc-cocos-editor", true) .expect("enable built-in plugin"), ); assert!(re_enabled.enabled); assert_eq!(re_enabled.status, "stopped"); } #[test] fn workspace_cocos_plugin_round_trips_editor_rpc() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); fs::write( directory.path().join("package.json"), r#"{"creator":{"version":"3.8.8"}}"#, ) .expect("cocos package"); fs::create_dir(directory.path().join("assets")).expect("cocos assets"); crate::builtin_plugins::initialize(directory.path()).expect("builtin state"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); host.register_editor_adapter(Box::new(StubCocosAdapter)) .expect("register adapter"); let project = fs::canonicalize(directory.path()) .expect("canonical project") .to_string_lossy() .into_owned(); host.set_active_project(Some(project.clone())) .expect("set active project"); host.start("agc-cocos-editor").expect("start plugin"); let deadline = std::time::Instant::now() + Duration::from_secs(15); loop { let registered = host.list().expect("list").into_iter().any(|plugin| { plugin.id == "agc-cocos-editor" && plugin.commands.len() == 2 && plugin.capabilities.len() == 1 && plugin.panels.len() == 1 }); if registered { break; } assert!( std::time::Instant::now() < deadline, "Cocos 插件未在期限内完成注册" ); thread::sleep(Duration::from_millis(25)); } let response = host .call( "agc-cocos-editor", "cocos.editor.execute".to_string(), json!({"code": "return 1 + 1;"}), ) .expect("cocos execute rpc"); assert_eq!(response["status"], "completed"); assert_eq!(response["response"]["method"], "editor.execute"); assert_eq!(response["response"]["params"]["projectPath"], project); assert_eq!(response["response"]["params"]["code"], "return 1 + 1;"); assert_eq!( host.stop("agc-cocos-editor").expect("stop plugin").status, "stopped" ); } #[test] fn cocos_plugin_is_hidden_and_cannot_start_for_non_cocos_project() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); crate::builtin_plugins::initialize(directory.path()).expect("builtin state"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); host.set_plugin_workspace(workspace).expect("set workspace"); let project = tempdir().expect("web project"); host.set_active_project(Some(project.path().to_string_lossy().into_owned())) .expect("set active project"); assert!(host .list() .expect("list plugins") .into_iter() .all(|plugin| plugin.id != "agc-cocos-editor")); assert!(host.start("agc-cocos-editor").is_err()); } }