diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 0149d953a..91d1c325b 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -117,6 +117,15 @@ const allowedUncalledTauriCommands = [ 'stop_local_game_preview_if_matches', 'start_game_creator_external_mcp', 'stop_game_creator_external_mcp', + 'list_agc_plugins', + 'list_agc_extensions', + 'refresh_agc_plugins', + 'start_agc_plugin', + 'stop_agc_plugin', + 'reload_agc_plugin', + 'call_agc_plugin', + 'read_agc_plugin_panel', + 'set_agc_plugin_project_path', ]; const sourceExtensions = new Set([ '.json', diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs index 5cf4c6f12..e3f7ce96e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs @@ -23,6 +23,11 @@ pub(crate) struct ClientMcpRuntimeServer { pub(crate) config: BTreeMap, } +pub(crate) struct ClientPluginSource { + pub(crate) item: ClientExtensionItem, + pub(crate) root: PathBuf, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ClientExtensionItem { @@ -169,7 +174,7 @@ fn claim_client_mcp_connection_at( let enabled_ids = index .items .iter() - .filter(|item| item.extension_type == "mcp" && item.enabled) + .filter(|item| item.extension_type == "mcp" && extension_effectively_enabled(&index, item)) .map(|item| item.id.as_str()) .collect::>(); let mut owners = client_mcp_connection_owners() @@ -294,6 +299,13 @@ fn sorted_directory_files(root: &Path) -> Result, String> if metadata.file_type().is_symlink() { return Err(format!("扩展目录不能包含符号链接:{}", path.display())); } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err("扩展目录不能包含重解析点".to_string()); + } + } if metadata.is_dir() { visit(root, &path, entries)?; } else if metadata.is_file() { @@ -487,13 +499,34 @@ fn parse_mcp_config_file(path: &Path) -> Result .file_name() .and_then(|value| value.to_str()) .unwrap_or_default(); - if file_name.eq_ignore_ascii_case(".mcp.json") { + if file_name.eq_ignore_ascii_case(".mcp.json") || file_name.eq_ignore_ascii_case("mcp.json") { let Ok(content) = fs::read_to_string(path) else { return Ok(Vec::new()); }; let Ok(value) = serde_json::from_str::(&content) else { return Ok(Vec::new()); }; + if file_name.eq_ignore_ascii_case("mcp.json") { + if value.get("$schema").and_then(serde_json::Value::as_str) + != Some("https://agent-plugins.org/schemas/1.0.0/mcp.schema.json") + { + return Err("不支持的 Agent Plugins MCP schema".to_string()); + } + if let Some(servers) = value + .get("mcpServers") + .and_then(serde_json::Value::as_object) + { + for config in servers.values() { + match config.get("type").and_then(serde_json::Value::as_str) { + Some("stdio") + if config.get("command").is_some() && config.get("url").is_none() => {} + Some("streamable-http") + if config.get("url").is_some() && config.get("command").is_none() => {} + _ => return Err("Agent Plugins MCP transport 配置无效".to_string()), + } + } + } + } let Some(servers) = value .get("mcpServers") .and_then(serde_json::Value::as_object) @@ -530,7 +563,57 @@ fn parse_mcp_config_file(path: &Path) -> Result fn discover_candidates(payload: &Path) -> Result, String> { let files = sorted_directory_files(payload)?; let mut candidates = Vec::new(); + let has_manifest = |path: &Path| { + path.join("plugin.json").is_file() || path.join(".codex-plugin/plugin.json").is_file() + }; + let plugin_root = if has_manifest(payload) { + Some(payload.to_path_buf()) + } else { + let roots = fs::read_dir(payload) + .map_err(|_| "扩展来源不可读".to_string())? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir() && has_manifest(path)) + .collect::>(); + if roots.len() > 1 { + return Err("请一次导入一个 Plugin 目录".to_string()); + } + roots.into_iter().next() + }; + let portable = plugin_root + .as_ref() + .is_some_and(|path| path.join("plugin.json").is_file()); + let mut prefix = String::new(); + if let Some(plugin_root) = plugin_root.as_ref() { + let relative_root = plugin_root + .strip_prefix(payload) + .map_err(|_| "Plugin 来源越界".to_string())?; + if !relative_root.as_os_str().is_empty() { + prefix = format!("{}/", normalize_relative_path(relative_root)?); + } + let manifest = crate::plugin_host::read_plugin_manifest(plugin_root)?; + candidates.push(ImportedCandidate { + extension_type: "plugin".to_string(), + original_name: manifest.id, + source_relative_path: format!( + "{prefix}{}", + if portable { + "plugin.json" + } else { + ".codex-plugin/plugin.json" + } + ), + fingerprint: fingerprint_directory(plugin_root)?, + mcp_config: None, + }); + } for (relative, path) in files { + if portable + && !(relative.starts_with(&format!("{prefix}skills/")) && is_skill_file(&path) + || relative == format!("{prefix}mcp.json")) + { + continue; + } if is_skill_file(&path) { candidates.push(ImportedCandidate { extension_type: "skill".to_string(), @@ -607,7 +690,7 @@ fn stored_item_view( "unknown" } else if item.last_error.is_some() { "startup-failed" - } else if item.enabled { + } else if extension_effectively_enabled(index, item) { "enabled" } else { "disabled" @@ -629,7 +712,7 @@ fn client_skill_set_fingerprint(index: &ClientExtensionIndex) -> String { let mut entries = index .items .iter() - .filter(|item| item.extension_type == "skill" && item.enabled) + .filter(|item| item.extension_type == "skill" && extension_effectively_enabled(index, item)) .map(|item| { ( item.name.clone(), @@ -655,7 +738,7 @@ fn client_mcp_set_fingerprint(index: &ClientExtensionIndex) -> String { let mut entries = index .items .iter() - .filter(|item| item.extension_type == "mcp" && item.enabled) + .filter(|item| item.extension_type == "mcp" && extension_effectively_enabled(index, item)) .map(|item| { ( item.id.clone(), @@ -1040,7 +1123,7 @@ fn prepare_client_skill_runtime_root( for item in index .items .iter() - .filter(|item| item.extension_type == "skill" && item.enabled) + .filter(|item| item.extension_type == "skill" && extension_effectively_enabled(index, item)) { let Some(source) = index .sources @@ -1087,7 +1170,9 @@ pub(crate) fn prepare_enabled_client_mcp_servers() -> Result Result { - let Some(item) = index + let parent_disabled = index .items - .iter_mut() - .find(|item| item.extension_type == "mcp" && item.enabled && item.id == extension_id) - else { + .iter() + .filter(|item| item.extension_type == "plugin" && !item.enabled) + .map(|item| item.source_id.clone()) + .collect::>(); + let Some(item) = index.items.iter_mut().find(|item| { + item.extension_type == "mcp" + && item.enabled + && !parent_disabled.contains(&item.source_id) + && item.id == extension_id + }) else { return Ok(false); }; let last_error = match status { @@ -1265,7 +1357,7 @@ pub(crate) fn list_client_extensions() -> Result, Strin list_client_extensions_at(&root) } -fn list_client_extensions_at(root: &Path) -> Result, String> { +pub(crate) fn list_client_extensions_at(root: &Path) -> Result, String> { read_client_extension_index_locked(&root, |_, index| { Ok(index .items @@ -1275,9 +1367,69 @@ fn list_client_extensions_at(root: &Path) -> Result, St }) } +pub(crate) fn client_plugin_sources_at(root: &Path) -> Result, String> { + read_client_extension_index_locked(root, |_, index| { + index + .items + .iter() + .filter(|item| item.extension_type == "plugin") + .map(|item| { + let source = index + .sources + .iter() + .find(|source| source.id == item.source_id) + .ok_or_else(|| "插件来源记录不存在".to_string())?; + normalize_relative_path(Path::new(&source.storage_path))?; + normalize_relative_path(Path::new(&item.source_relative_path))?; + let manifest_path = root + .join(&source.storage_path) + .join(&item.source_relative_path); + let manifest_parent = manifest_path + .parent() + .ok_or_else(|| "插件 manifest 路径无效".to_string())?; + let source_root = if item + .source_relative_path + .ends_with(".codex-plugin/plugin.json") + { + manifest_parent + .parent() + .ok_or_else(|| "插件根路径无效".to_string())? + .to_path_buf() + } else { + manifest_parent.to_path_buf() + }; + if !source_root + .canonicalize() + .map_err(|_| "插件来源目录不可读".to_string())? + .starts_with( + root.canonicalize() + .map_err(|_| "扩展目录不可读".to_string())?, + ) + { + return Err("插件来源目录越界".to_string()); + } + Ok(ClientPluginSource { + item: stored_item_view(index, item), + root: source_root, + }) + }) + .collect() + }) +} + +fn extension_effectively_enabled(index: &ClientExtensionIndex, item: &StoredExtensionItem) -> bool { + item.enabled + && !index.items.iter().any(|parent| { + parent.extension_type == "plugin" + && parent.source_id == item.source_id + && !parent.enabled + }) +} + #[tauri::command] pub(crate) fn import_client_extension( source_path: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, ) -> Result { let source = PathBuf::from(source_path.trim()); if source.as_os_str().is_empty() { @@ -1298,14 +1450,18 @@ pub(crate) fn import_client_extension( )?; let root = extensions_root()?; - import_client_extension_at(&root, &source, &metadata) + let result = import_client_extension_at(&root, &source, &metadata)?; + host.refresh()?; + Ok(result) } -fn import_client_extension_at( +pub(crate) fn import_client_extension_at( root: &Path, source: &Path, metadata: &fs::Metadata, ) -> Result { + fs::create_dir_all(root.join(CLIENT_EXTENSIONS_SOURCES_DIR_NAME)) + .map_err(|error| format!("准备扩展来源目录失败:{error}"))?; let source_id = new_id("source"); let source_display_name = source_name(&source); let source_storage_relative = format!("{}/{}", CLIENT_EXTENSIONS_SOURCES_DIR_NAME, source_id); @@ -1424,9 +1580,12 @@ fn import_client_extension_at( pub(crate) fn set_client_extension_enabled( id: String, enabled: bool, + host: tauri::State<'_, crate::plugin_host::PluginHost>, ) -> Result { let root = extensions_root()?; - set_client_extension_enabled_at(&root, &id, enabled) + let item = set_client_extension_enabled_at(&root, &id, enabled)?; + host.refresh()?; + Ok(item) } fn set_client_extension_enabled_at( @@ -1447,6 +1606,18 @@ fn set_client_extension_enabled_at( item.enabled = enabled; item.last_error = None; let view_item = item.clone(); + if view_item.extension_type == "plugin" { + let child_ids = index + .items + .iter() + .filter(|item| item.source_id == view_item.source_id) + .map(|item| item.id.as_str()) + .collect::>(); + client_mcp_connection_owners() + .lock() + .map_err(|_| "客户端 MCP 连接锁已损坏".to_string())? + .retain(|id, _| !child_ids.contains(id.as_str())); + } Ok((stored_item_view(index, &view_item), true)) }) } @@ -1455,6 +1626,7 @@ fn set_client_extension_enabled_at( pub(crate) fn rename_client_extension( id: String, name: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, ) -> Result { let requested = name.trim(); if requested.is_empty() { @@ -1462,7 +1634,9 @@ pub(crate) fn rename_client_extension( } let normalized = native_name(requested); let root = extensions_root()?; - rename_client_extension_at(&root, &id, &normalized) + let item = rename_client_extension_at(&root, &id, &normalized)?; + host.refresh()?; + Ok(item) } fn rename_client_extension_at( @@ -1513,9 +1687,14 @@ fn rename_client_extension_at( } #[tauri::command] -pub(crate) fn remove_client_extension(id: String) -> Result<(), String> { +pub(crate) fn remove_client_extension( + id: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result<(), String> { let root = extensions_root()?; - remove_client_extension_at(&root, &id) + remove_client_extension_at(&root, &id)?; + host.refresh()?; + Ok(()) } fn remove_client_extension_at(root: &Path, id: &str) -> Result<(), String> { @@ -1526,7 +1705,22 @@ fn remove_client_extension_at(root: &Path, id: &str) -> Result<(), String> { .iter() .position(|item| item.id == id.trim()) .ok_or_else(|| "未找到客户端扩展".to_string())?; - index.items.remove(item_index); + let removed = index.items.remove(item_index); + if removed.extension_type == "plugin" { + let child_ids = index + .items + .iter() + .filter(|item| item.source_id == removed.source_id) + .map(|item| item.id.clone()) + .collect::>(); + client_mcp_connection_owners() + .lock() + .map_err(|_| "客户端 MCP 连接锁已损坏".to_string())? + .retain(|id, _| !child_ids.contains(id)); + index + .items + .retain(|item| item.source_id != removed.source_id); + } Ok(((), true)) }) } @@ -2149,7 +2343,7 @@ mod tests { } #[test] - fn plugin_source_discovers_skill_and_mcp_as_independent_items() { + fn plugin_source_discovers_package_and_skill_and_mcp_items() { let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("fixtures") @@ -2165,11 +2359,82 @@ mod tests { ) }) .collect::>(); - assert_eq!(candidates.len(), 2); + assert_eq!(candidates.len(), 3); + assert!(names.contains(&("plugin", "stage-0-fixture-plugin"))); assert!(names.contains(&("skill", "plugin-skill"))); assert!(names.contains(&("mcp", "plugin-search"))); } + #[test] + fn portable_plugin_parent_controls_component_injection_and_removal() { + let source = tempfile::tempdir().expect("plugin source"); + fs::create_dir_all(source.path().join("skills/help")).expect("skills"); + fs::write( + source.path().join("plugin.json"), + serde_json::json!({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "portable-tools" + }) + .to_string(), + ) + .expect("manifest"); + fs::write( + source.path().join("skills/help/SKILL.md"), + "---\nname: help\ndescription: Help with the project.\n---\nRead the project.\n", + ) + .expect("skill"); + fs::write(source.path().join("mcp.json"), serde_json::json!({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": {"docs": {"type":"streamable-http", "url":"http://127.0.0.1:12345/mcp"}} + }).to_string()).expect("MCP config"); + let (_directory, root) = test_extension_root(); + let imported = import_client_extension_at( + &root, + source.path(), + &fs::metadata(source.path()).expect("metadata"), + ) + .expect("import"); + assert_eq!(imported.imported.len(), 3); + let parent = imported + .imported + .iter() + .find(|item| item.extension_type == "plugin") + .expect("parent"); + let index = read_index(&root).expect("index"); + let skill_fingerprint = client_skill_set_fingerprint(&index); + let mcp_fingerprint = client_mcp_set_fingerprint(&index); + set_client_extension_enabled_at(&root, &parent.id, false).expect("disable package"); + let disabled = read_index(&root).expect("disabled index"); + assert_ne!(client_skill_set_fingerprint(&disabled), skill_fingerprint); + assert_ne!(client_mcp_set_fingerprint(&disabled), mcp_fingerprint); + assert!(disabled + .items + .iter() + .filter(|item| item.extension_type != "plugin") + .all(|item| !extension_effectively_enabled(&disabled, item))); + set_client_extension_enabled_at(&root, &parent.id, true).expect("enable package"); + let enabled = read_index(&root).expect("enabled index"); + assert_eq!(client_skill_set_fingerprint(&enabled), skill_fingerprint); + remove_client_extension_at(&root, &parent.id).expect("remove package"); + assert!(read_index(&root).expect("remaining index").items.is_empty()); + } + + #[test] + fn portable_mcp_transport_must_match_connection_fields() { + let directory = tempfile::tempdir().expect("config directory"); + let path = directory.path().join("mcp.json"); + fs::write( + &path, + serde_json::json!({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": {"docs": {"type":"stdio", "url":"https://example.invalid/mcp"}} + }) + .to_string(), + ) + .expect("MCP config"); + assert!(parse_mcp_config_file(&path).is_err()); + } + #[test] fn fixture_directory_splits_independent_skill_and_mcp_items() { let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs new file mode 100644 index 000000000..108099f8f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs @@ -0,0 +1,36 @@ +//! Editor adapters used by the generic AGC plugin host. +//! +//! The host owns plugin lifecycle, RPC, permissions and auditing. Adapters +//! only know how to find and talk to a particular editor. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorConnectionInfo { + pub(crate) adapter: String, + pub(crate) connected: bool, + pub(crate) pid: Option, + pub(crate) project_path: Option, + pub(crate) version: Option, +} + +/// Narrow seam between the generic plugin runtime and a target editor. +pub(crate) trait EditorAdapter: Send + Sync { + fn id(&self) -> &'static str; + fn detect(&self, project_path: &Path) -> Result; + fn connect( + &mut self, + pid: u32, + project_path: &Path, + version: &str, + ) -> Result; + fn disconnect(&mut self); + fn translate_rpc(&self, method: &str, params: Value) -> Result; + fn rpc(&self, _method: &str, _params: Value) -> Result { + Err("编辑器原生连接尚未建立".to_string()) + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 317b82348..2ef595083 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -258,6 +258,7 @@ mod context_menu; #[cfg(all(debug_assertions, not(test)))] mod debug; mod delegation; +mod editor_adapter; pub mod error_report; mod git_inspect; mod goal; @@ -266,6 +267,7 @@ mod image_inspect; mod isolated_agent; mod patchset; mod platform_session; +mod plugin_host; mod preview; mod process_session; mod process_session_bridge; @@ -302,6 +304,11 @@ use image_inspect::*; use isolated_agent::*; use patchset::*; use platform_session::*; +use plugin_host::{ + call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, + refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_project_path, start_agc_plugin, + stop_agc_plugin, PluginHost, +}; use preview::*; use process_session::*; use project::*; @@ -2336,6 +2343,7 @@ fn main() { .plugin(context_menu::init()) .manage(game_creator_preview_registry()) .manage(ProjectResourcePreviewReadManager::default()) + .manage(PluginHost::default()) .setup(move |app| { error_report::initialize_notifications(app.handle()); if let Some(path) = setup_log.as_deref() { @@ -2371,6 +2379,9 @@ fn main() { } error })?; + if let Err(error) = app.state::().initialize(&config_dir) { + app_log!("startup.plugin-host.initialize.failed: {error}"); + } load_platform_session_fixture_from_env(&config_dir).map_err(|error| { std::io::Error::new( std::io::ErrorKind::PermissionDenied, @@ -2472,6 +2483,15 @@ fn main() { set_client_extension_enabled, rename_client_extension, remove_client_extension, + list_agc_plugins, + list_agc_extensions, + refresh_agc_plugins, + start_agc_plugin, + stop_agc_plugin, + reload_agc_plugin, + call_agc_plugin, + read_agc_plugin_panel, + set_agc_plugin_project_path, open_local_project_directory, open_local_project_plan_gdd_markdown, control_agent_run, diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs new file mode 100644 index 000000000..b49833ae2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -0,0 +1,1745 @@ +//! 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 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) 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) 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, + 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) +} + +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()); + self.scan_locked(&mut state, &root) + } + + fn scan_locked(&self, state: &mut PluginHostState, root: &Path) -> Result<(), String> { + let sources = crate::client_extensions::client_plugin_sources_at(root)?; + let mut discovered = BTreeMap::new(); + for source in sources { + let id = source.item.id; + match read_plugin_manifest(&source.root) { + Ok(mut manifest) => { + manifest.enabled = source.item.enabled; + if source.item.name != source.item.original_name { + manifest.name = source.item.name; + } + 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.item.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)?; + state + .plugins + .values() + .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, + 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, + 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(); + 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) + } + + 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 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) = { + let mut state = self + .state + .lock() + .map_err(|_| "插件宿主锁已损坏".to_string())?; + let root = state + .root + .clone() + .ok_or_else(|| "插件宿主尚未初始化".to_string())?; + let running = state + .plugins + .get_mut(id) + .and_then(|record| 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), + ) + }; + let deadline = Instant::now() + RPC_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(), + )?; + Ok(json!({"subscriptionId": id})) + } + "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)?; + 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, + 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 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()?; + *state + .active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())? = project; + 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"}}), + ); + } + } + } + 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) 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); + } + + #[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()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index e01ac259d..7ce96436e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -204,7 +204,7 @@ pub(super) struct LiveProcessSession { } #[cfg(windows)] -pub(super) struct WindowsProcessJob(windows_sys::Win32::Foundation::HANDLE); +pub(crate) struct WindowsProcessJob(windows_sys::Win32::Foundation::HANDLE); #[cfg(windows)] unsafe impl Send for WindowsProcessJob {} @@ -215,6 +215,21 @@ unsafe impl Sync for WindowsProcessJob {} #[cfg(windows)] impl WindowsProcessJob { pub(super) fn assign(child: &dyn Child) -> Result { + let process = child + .as_raw_handle() + .ok_or_else(|| "command.start Windows child 缺少 process handle".to_string())? + as windows_sys::Win32::Foundation::HANDLE; + Self::assign_handle(process) + } + + pub(crate) fn assign_std(child: &std::process::Child) -> Result { + use std::os::windows::io::AsRawHandle; + Self::assign_handle( + AsRawHandle::as_raw_handle(child) as windows_sys::Win32::Foundation::HANDLE + ) + } + + fn assign_handle(process: windows_sys::Win32::Foundation::HANDLE) -> Result { use std::mem::size_of; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::System::JobObjects::{ @@ -223,10 +238,6 @@ impl WindowsProcessJob { JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }; - let process = child - .as_raw_handle() - .ok_or_else(|| "command.start Windows child 缺少 process handle".to_string())? - as windows_sys::Win32::Foundation::HANDLE; let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; if handle.is_null() || handle == INVALID_HANDLE_VALUE { return Err(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md index 957325e45..3c478d34c 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/README.md @@ -10,7 +10,7 @@ | `multi-skill/` | 2 个 Skill:`art-skill`、`code-skill` | 一个 Skill root,分别导入 | | `mcp-config/` | 2 个 MCP:`search`、`filesystem` | 原生 `config.toml` | | `mcp-json/` | 2 个 MCP:`search`、`filesystem` | 兼容 `.mcp.json` | -| `plugin/` | 1 个 Skill:`plugin-skill`、1 个 MCP:`plugin-search` | Plugin 只作为导入来源 | +| `plugin/` | 1 个 Plugin:`stage-0-fixture-plugin`、1 个 Skill:`plugin-skill`、1 个 MCP:`plugin-search` | Plugin 父项与组件共用来源 | | `mixed-source/` | 1 个 Skill:`mixed-skill`、2 个 MCP:`search`、`filesystem` | 一个来源拆成多个独立项 | | `unknown/` | 1 个未知项:`unknown.bin` | 不执行、不作为 MCP 入口 | @@ -23,4 +23,4 @@ - 同名或重复内容再次导入时保留新项,名称使用原生标识追加 `-2`、`-3`。 - 前端列表名称和 Codex 运行时名称相同,不维护两套名称。 - 单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。 -- Plugin 只提取支持的 Skill/MCP,不开启 hooks、apps 或完整 Plugin Runtime。 +- Plugin 父项控制其 Skill/MCP 子项的有效启用;Codex hooks/apps 不注入,AGC Runtime 入口由通用 Plugin Host 管理。 diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json index 698207aff..f5d3592dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/direct_extensions/expected-imports.json @@ -36,6 +36,7 @@ "id": "plugin", "source": "plugin", "items": [ + { "type": "plugin", "name": "stage-0-fixture-plugin" }, { "type": "skill", "name": "plugin-skill" }, { "type": "mcp", "name": "plugin-search" } ], diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 5400b1017..ae03fd698 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -251,6 +251,7 @@ import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWo import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { captureAgentRuntimeError } from './services/errorReporting'; +import { setAgcPluginProjectPath } from './services/pluginHost'; import type { HomeCreationType } from './view/home'; import { type ProjectAgentResultSummary, @@ -554,6 +555,16 @@ export function App({ const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + useEffect(() => { + if (supervisorChatOnly) return; + void setAgcPluginProjectPath(localProject?.projectPath ?? null).catch( + () => undefined, + ); + return () => { + void setAgcPluginProjectPath(null).catch(() => undefined); + }; + }, [localProject?.projectPath, supervisorChatOnly]); + const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( new Map< diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 9d74e0e04..24c2686c0 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -51,7 +51,7 @@ export type LauncherImportedAttachment = { size?: number; }; -export type ClientExtensionType = 'skill' | 'mcp' | 'unknown'; +export type ClientExtensionType = 'plugin' | 'skill' | 'mcp' | 'unknown'; export type ClientExtensionItem = { id: string; @@ -72,6 +72,59 @@ export type ClientExtensionImportResult = { duplicate: boolean; }; +export type AgcPluginStatus = + | 'package' + | 'discovered' + | 'stopped' + | 'running' + | 'failed' + | 'disabled' + | 'invalid'; + +export type AgcPluginCommand = { + id: string; + title: string; + description: string | null; +}; + +export type AgcPluginPanel = { + id: string; + title: string; + entry: string; + placement: string | null; +}; + +export type AgcPluginCapability = { + id: string; + description: string | null; +}; + +export type AgcPluginSummary = { + id: string; + name: string; + version: string; + apiVersion: string; + enabled: boolean; + hasRuntime: boolean; + status: AgcPluginStatus; + adapter: string | null; + permissions: string[]; + commands: AgcPluginCommand[]; + panels: AgcPluginPanel[]; + capabilities: AgcPluginCapability[]; + lastError: string | null; +}; + +export type AgcExtensionSummary = { + kind: 'plugin' | 'skill' | 'mcp' | 'unknown'; + id: string; + name: string; + enabled: boolean; + status: string; + plugin: AgcPluginSummary | null; + clientExtension: ClientExtensionItem | null; +}; + export type LocalProjectKind = 'web' | 'godot'; export type ProjectStartMode = 'planning' | 'direct-build'; diff --git a/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx b/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx new file mode 100644 index 000000000..08546c230 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; + +import type { AgcPluginPanel } from '../../app/types'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import { readAgcPluginPanel } from '../../services/pluginHost'; + +export type PluginPanelHostProps = { + pluginId: string; + panel: AgcPluginPanel; + onClose: () => void; +}; + +const PANEL_CSP = + ""; + +/** Renders registered plugin panels and removes their iframe on unmount. */ +export function PluginPanelHost({ + pluginId, + panel, + onClose, +}: PluginPanelHostProps) { + const [html, setHtml] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + let active = true; + setHtml(null); + setError(''); + void readAgcPluginPanel(pluginId, panel.id).then( + (result) => { + if (active) setHtml(result.html); + }, + () => { + if (active) setError('插件面板加载失败'); + }, + ); + return () => { + active = false; + }; + }, [panel.id, pluginId]); + + return ( + +
+ {panel.title} + +
+ {error ? ( +

{error}

+ ) : html === null ? ( +

正在加载

+ ) : ( +