Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs
T
kdletters 02ff914bd3 修复内置插件跨进程开关与不确定执行阻断
让 Runner 和 CLI 查询持久化开关并即时感知 GUI 状态更新
让隔离 MCP 经现有工具桥获取可用工具,并将开关纳入会话缓存标识
在执行入口复查禁用状态,固定原生函数缓存构建使用的工具快照
修复损坏开关文件保存成功后仍保持关闭的状态
保留插件执行结果不确定的结构化回执和宿主适配器阻断,拒绝并发积压与自动重发
新增跨进程工具目录、热切换、坏文件恢复及不确定执行回归测试并同步文档
2026-09-10 21:37:51 +08:00

2101 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Mutex<BTreeMap<String, Box<dyn EditorAdapter>>>>;
type ProjectContext = Arc<Mutex<Option<PathBuf>>>;
type PendingRpc = Arc<Mutex<BTreeMap<u64, Sender<Result<Value, String>>>>>;
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<String>,
}
#[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<String>,
#[serde(default)]
pub(crate) permissions: BTreeSet<String>,
#[serde(default = "default_enabled")]
pub(crate) enabled: bool,
#[serde(default)]
pub(crate) adapter: Option<String>,
#[serde(default)]
pub(crate) panels: Vec<PluginPanelManifest>,
}
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<String>,
}
#[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<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PluginCapabilityDescriptor {
pub(crate) id: String,
pub(crate) description: Option<String>,
}
#[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<String>,
pub(crate) permissions: Vec<String>,
pub(crate) commands: Vec<PluginCommandDescriptor>,
pub(crate) panels: Vec<PluginPanelDescriptor>,
pub(crate) capabilities: Vec<PluginCapabilityDescriptor>,
pub(crate) last_error: Option<String>,
}
/// 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<PluginSummary>,
pub(crate) client_extension: Option<crate::client_extensions::ClientExtensionItem>,
}
struct RunningPlugin {
child: Child,
#[cfg(windows)]
_job: crate::process_session::WindowsProcessJob,
stdin: Arc<Mutex<ChildStdin>>,
lines: Option<Receiver<String>>,
pending: PendingRpc,
registrations: Arc<Mutex<PluginRegistrations>>,
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<String, PluginCommandDescriptor>,
panels: BTreeMap<String, PluginPanelDescriptor>,
capabilities: BTreeMap<String, PluginCapabilityDescriptor>,
subscriptions: BTreeMap<String, String>,
}
#[derive(Default)]
struct PluginRegistrationsSnapshot {
commands: Vec<PluginCommandDescriptor>,
panels: Vec<PluginPanelDescriptor>,
capabilities: Vec<PluginCapabilityDescriptor>,
}
fn register_entry<T>(
entries: &mut BTreeMap<String, T>,
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<String>,
running: Option<RunningPlugin>,
}
#[derive(Default)]
struct PluginHostState {
root: Option<PathBuf>,
workspace: Option<PathBuf>,
plugins: BTreeMap<String, PluginRecord>,
active_project: ProjectContext,
editors: EditorRegistry,
}
#[derive(Default)]
pub(crate) struct PluginHost {
state: Mutex<PluginHostState>,
}
#[derive(Debug, Deserialize)]
struct RpcEnvelope {
#[serde(default)]
jsonrpc: Option<String>,
#[serde(default)]
id: Option<Value>,
#[serde(default)]
method: Option<String>,
#[serde(default)]
params: Option<Value>,
#[serde(default, deserialize_with = "deserialize_rpc_result")]
result: Option<Value>,
#[serde(default)]
error: Option<Value>,
}
fn deserialize_rpc_result<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Value>, 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<PathBuf, String> {
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<PathBuf> {
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<String>,
name: String,
#[serde(default)]
version: Option<String>,
#[serde(default)]
extensions: BTreeMap<String, Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AgcRuntimeExtension {
#[serde(default)]
api_version: Option<String>,
#[serde(default)]
entry: Option<String>,
#[serde(default)]
permissions: BTreeSet<String>,
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default)]
adapter: Option<String>,
#[serde(default)]
panels: Vec<PluginPanelManifest>,
}
pub(crate) fn read_plugin_manifest(root: &Path) -> Result<PluginManifest, String> {
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::<RawPluginManifest>(&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::<AgcRuntimeExtension>(
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<PathBuf, String> {
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<PathBuf> {
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<bool>,
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<Vec<ScannedPluginSource>, 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<RunningPlugin, String> {
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<Option<String>, 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<Mutex<ChildStdin>>, value: &Value) -> Result<(), String> {
let mut stdin = stdin
.lock()
.map_err(|_| "插件 stdin 锁已损坏".to_string())?;
write_rpc(&mut stdin, value)
}
fn descriptor_from_params<T: for<'de> Deserialize<'de>>(
params: Option<Value>,
) -> Result<T, String> {
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<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterPanelInput {
id: String,
title: String,
entry: String,
#[serde(default)]
placement: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterCapabilityInput {
id: String,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectReadInput {
path: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EditorRpcInput {
#[serde(default)]
adapter: Option<String>,
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::<Vec<_>>();
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<Vec<PluginSummary>, 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<Vec<PluginSummary>, String> {
self.list()
}
pub(crate) fn list_extensions(&self) -> Result<Vec<AgcExtensionSummary>, 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::<Vec<_>>();
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<PluginSummary, String> {
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<PluginSummary, String> {
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<PluginSummary, String> {
let _ = self.stop(id)?;
self.refresh()?;
self.start(id)
}
/// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。
///
/// 该状态同时被 Agent 工具目录消费,禁用后插件不能启动,对应 Runtime 工具
/// 也不再出现在工具列表与 Agent 上下文里。
pub(crate) fn set_enabled(
&self,
id: &str,
enabled: bool,
) -> Result<Vec<PluginSummary>, 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<PluginPanelContent, String> {
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<Value, String> {
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::<RpcEnvelope>(&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,
&registrations,
&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<Mutex<PluginRegistrations>>,
method: &str,
params: Option<Value>,
) -> Result<Value, String> {
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)?;
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<PluginSummary, String> {
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::<BTreeMap<_, _>>();
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<String>) -> 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",
"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<dyn EditorAdapter>,
) -> 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<EditorConnectionInfo, String> {
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<EditorConnectionInfo, 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}"))?
.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<Value, String> {
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<Vec<PluginSummary>, String> {
host.list()
}
#[tauri::command]
pub(crate) fn list_agc_extensions(
host: State<'_, PluginHost>,
) -> Result<Vec<AgcExtensionSummary>, String> {
host.list_extensions()
}
#[tauri::command]
pub(crate) fn refresh_agc_plugins(
host: State<'_, PluginHost>,
) -> Result<Vec<PluginSummary>, String> {
host.refresh()
}
#[tauri::command]
pub(crate) fn start_agc_plugin(
id: String,
host: State<'_, PluginHost>,
) -> Result<PluginSummary, String> {
host.start(id.trim())
}
#[tauri::command]
pub(crate) fn stop_agc_plugin(
id: String,
host: State<'_, PluginHost>,
) -> Result<PluginSummary, String> {
host.stop(id.trim())
}
#[tauri::command]
pub(crate) fn reload_agc_plugin(
id: String,
host: State<'_, PluginHost>,
) -> Result<PluginSummary, String> {
host.reload(id.trim())
}
#[tauri::command]
pub(crate) fn set_agc_plugin_enabled(
id: String,
enabled: bool,
host: State<'_, PluginHost>,
) -> Result<Vec<PluginSummary>, 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<Value, String> {
if !valid_text(&method, 120) || method.chars().any(char::is_control) {
return Err("插件 RPC 方法无效".to_string());
}
tauri::async_runtime::spawn_blocking(move || {
app.state::<PluginHost>().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<PluginPanelContent, String> {
host.read_panel(&id, &panel_id)
}
#[tauri::command]
pub(crate) fn set_agc_plugin_project_path(
project_path: Option<String>,
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,
&registrations,
"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<EditorConnectionInfo, String> {
Err("stub adapter 不探测进程".to_string())
}
fn connect(
&mut self,
_pid: u32,
_project_path: &Path,
_version: &str,
) -> Result<EditorConnectionInfo, String> {
Err("stub adapter 不建立连接".to_string())
}
fn disconnect(&mut self) {}
fn translate_rpc(&self, _method: &str, params: Value) -> Result<Value, String> {
Ok(params)
}
fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
// 与 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");
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");
let summary = |list: Vec<PluginSummary>| {
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");
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() == 1
&& 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"
);
}
}