Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs
T
kdletters cc11da1edd 保留插件 RPC 回写失败正文
宿主响应写入失败终止插件 RPC 循环并传给等待调用

补充插件通道排障记录
2026-10-06 14:03:32 +08:00

2835 lines
105 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, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
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 crate::editor_adapter::EditorAdapter;
#[cfg(not(test))]
mod desktop;
#[cfg(not(test))]
pub(crate) use desktop::*;
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 EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90);
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
const MAX_RPC_BYTES: usize = 2 * 1024 * 1024;
const MAX_PLUGIN_STDERR_BYTES: usize = 64 * 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<Result<String, String>>>,
pending: PendingRpc,
registrations: Arc<Mutex<PluginRegistrations>>,
stderr_summary: Arc<Mutex<String>>,
next_request_id: u64,
editor_context: Option<ProjectContext>,
active: Arc<AtomicBool>,
}
impl Drop for RunningPlugin {
fn drop(&mut self) {
self.active.store(false, Ordering::SeqCst);
if let Some(context) = &self.editor_context {
if let Ok(mut project) = context.try_lock() {
*project = None;
}
}
#[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)
}
/// 统一后的插件来源,屏蔽“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 {
let entry = entry.map_err(|error| format!("读取插件工作区目录项失败:{error}"))?;
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::piped());
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}"))?;
let stderr = match child.stderr.take() {
Some(stderr) => stderr,
None => {
let _ = child.kill();
let _ = child.wait();
return Err("插件 stderr 不可用".to_string());
}
};
let stderr_summary = Arc::new(Mutex::new(String::new()));
let stderr_summary_writer = Arc::clone(&stderr_summary);
thread::spawn(move || {
let mut bytes = Vec::new();
let result = stderr
.take((MAX_PLUGIN_STDERR_BYTES + 1) as u64)
.read_to_end(&mut bytes);
let summary = match result {
Ok(_) if bytes.len() > MAX_PLUGIN_STDERR_BYTES => {
"插件 stderr 超过大小上限".to_string()
}
Ok(_) => crate::sanitize_diagnostic_message(&String::from_utf8_lossy(&bytes), None)
.chars()
.take(1200)
.collect(),
Err(error) => format!("读取插件 stderr 失败:{error}"),
};
if let Ok(mut stored) = stderr_summary_writer.lock() {
*stored = summary;
}
});
#[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);
loop {
match read_bounded_rpc_line(&mut reader) {
Ok(Some(line)) => {
if sender.send(Ok(line)).is_err() {
break;
}
}
Ok(None) => break,
Err(error) => {
let _ = sender.send(Err(error));
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())),
stderr_summary,
next_request_id: 1,
editor_context: None,
active: Arc::new(AtomicBool::new(true)),
})
}
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(|error| format!("读取插件输出失败:{error}"))?;
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(|error| format!("插件输出不是 UTF-8:{error}"));
}
}
}
fn write_rpc(stdin: &mut ChildStdin, value: &Value) -> Result<(), String> {
let payload = serialize_rpc(value)?;
stdin
.write_all(&payload)
.map_err(|error| format!("写入插件 RPC 失败:{error}"))?;
stdin
.flush()
.map_err(|error| format!("刷新插件 RPC 失败:{error}"))
}
fn serialize_rpc(value: &Value) -> Result<Vec<u8>, String> {
let mut payload =
serde_json::to_vec(value).map_err(|error| format!("序列化插件 RPC 失败:{error}"))?;
if payload.len() > MAX_RPC_BYTES {
return Err("插件 RPC 请求过大".to_string());
}
payload.push(b'\n');
Ok(payload)
}
fn write_prepared_rpc(
writer: &mut impl Write,
payload: &[u8],
phase: &AtomicU8,
) -> Result<(), String> {
// 0=未写,1=可能已写,2=截止前取消;取消后后台线程不得补发。
phase
.compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst)
.map_err(|_| "插件 RPC 已在写入前取消".to_string())?;
writer
.write_all(payload)
.and_then(|_| writer.flush())
.map_err(|error| format!("写入插件 RPC 失败:{error}"))
}
fn cancel_rpc_before_write(phase: &AtomicU8) -> bool {
phase
.compare_exchange(0, 2, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
fn finalize_managed_plugin_result(
phase: &AtomicU8,
result: Result<Value, String>,
mark_uncertain: impl FnOnce() -> Result<(), String>,
) -> Result<Value, String> {
if phase.load(Ordering::SeqCst) == 1
&& !result.as_ref().is_ok_and(|value| {
crate::editor_adapters::editor_execute_receipt_is_valid(value)
&& value["status"] != "needs-reconciliation"
})
{
let message = if mark_uncertain().is_ok() {
"插件执行回执丢失或无效,请人工核对,禁止重放"
} else {
"插件执行结果待核对,持久阻断记录未能确认,请停止执行并人工核对"
};
Ok(crate::editor_adapters::editor_reconciliation(message))
} else {
result
}
}
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,
}
}
fn has_editor_adapter(editors: &EditorRegistry, adapter: &str) -> Result<bool, String> {
Ok(editors
.try_lock()
.map_err(|_| "编辑器注册表锁已损坏".to_string())?
.contains_key(adapter))
}
fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), String> {
let adapter = match id {
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => "cocos-editor",
crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => "unity-editor",
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID => "godot-editor",
_ => return Ok(()),
};
if !has_editor_adapter(editors, adapter)? {
let name = match adapter {
"cocos-editor" => "Cocos",
"unity-editor" => "Unity",
_ => "Godot",
};
return Err(format!("当前客户端不支持 {name} 编辑器桥接"));
}
Ok(())
}
fn plugin_matches_project(id: &str, project: Option<&Path>) -> bool {
match id {
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID => project.is_some_and(|path| {
crate::project::discover_local_godot_project_root(path)
.ok()
.flatten()
.is_some()
}),
_ => true,
}
}
fn require_plugin_project(id: &str, project: &ProjectContext) -> Result<(), String> {
if !plugin_matches_project(
id,
project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.as_deref(),
) {
return Err("编辑器插件与当前项目类型不匹配".to_string());
}
Ok(())
}
fn controlled_editor_params(project: &Path, mut params: Value) -> Result<Value, String> {
if params.is_null() {
params = json!({});
}
let params = params
.as_object_mut()
.ok_or_else(|| "编辑器参数必须是对象".to_string())?;
let canonical = project
.canonicalize()
.map_err(|_| "当前项目目录不可读".to_string())?;
if let Some(explicit) = params.get("projectPath") {
let explicit = explicit
.as_str()
.ok_or_else(|| "projectPath 必须是字符串".to_string())?;
let explicit = Path::new(explicit)
.canonicalize()
.map_err(|_| "编辑器项目目录不可读".to_string())?;
if explicit != canonical {
return Err("编辑器 projectPath 必须匹配当前受控项目".to_string());
}
}
params.insert(
"projectPath".to_string(),
json!(canonical.to_string_lossy()),
);
Ok(Value::Object(params.clone()))
}
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 process_error =
running
.as_mut()
.and_then(|running| match running.child.try_wait() {
Ok(Some(status)) => Some(if let Some(code) = status.code() {
format!(
"插件进程已退出:exitCode={code}{}",
running
.stderr_summary
.lock()
.ok()
.filter(|summary| !summary.is_empty())
.map(|summary| format!(";stderr={summary}"))
.unwrap_or_default()
)
} else {
format!(
"插件进程已被信号终止{}",
running
.stderr_summary
.lock()
.ok()
.filter(|summary| !summary.is_empty())
.map(|summary| format!(";stderr={summary}"))
.unwrap_or_default()
)
}),
Ok(None) => None,
Err(error) => Some(format!("读取插件进程状态失败:{error}")),
});
let exited = process_error.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: process_error.or(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)?;
let project = state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.clone();
state
.plugins
.values()
.filter(|record| {
plugin_matches_project(&record.id, project.as_deref())
&& require_plugin_adapter(&record.id, &state.editors).is_ok()
})
.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();
require_plugin_adapter(id, &state.editors)?;
require_plugin_project(id, &active_project)?;
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())?;
let mut stop_error = None;
if let Some(mut running) = record.running.take() {
if let Err(error) = running.child.kill() {
stop_error = Some(format!("终止插件进程失败:{error}"));
}
match running.child.wait() {
Ok(_) if stop_error.is_none() => {}
Err(error) => {
stop_error = Some(format!("等待插件进程退出失败:{error}"));
}
_ => {}
}
}
record.status = if stop_error.is_some() {
"failed".to_string()
} else if record.manifest.enabled {
"stopped".to_string()
} else {
"disabled".to_string()
};
record.last_error = stop_error.clone();
audit(
&root,
id,
"stop",
stop_error.is_none(),
stop_error.as_deref(),
);
if let Some(error) = stop_error {
return Err(error);
}
self.summary_locked(record)
}
/// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。
///
/// 该状态同时被 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());
}
let mut cleanup = Ok(());
if !enabled {
self.stop(id)
.map(|_| ())
.map_err(|error| format!("插件停止失败,未切换禁用状态:{error}"))?;
if let Some(editor) = crate::editor_adapters::ManagedEditor::for_plugin(id) {
let state = self
.state
.lock()
.map_err(|_| "插件宿主锁已损坏".to_string())?;
let project = state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.clone();
drop(state);
cleanup = crate::editor_adapters::disconnect_managed_editor_connection(editor)
.and_then(|_| {
crate::editor_adapters::disconnect_managed_editor_project(
editor,
project.as_deref(),
)
});
}
}
crate::builtin_plugins::set_enabled(id, enabled)?;
let summaries = self.refresh()?;
cleanup.map_err(|error| format!("插件已禁用,编辑器资源暂未清理:{error}"))?;
Ok(summaries)
}
pub(crate) fn read_panel(
&self,
id: &str,
panel_id: &str,
) -> Result<PluginPanelContent, String> {
let state = self
.state
.lock()
.map_err(|_| "插件宿主锁已损坏".to_string())?;
require_plugin_adapter(id, &state.editors)?;
let record = state
.plugins
.get(id)
.ok_or_else(|| "插件不存在".to_string())?;
require_plugin_project(id, &state.active_project)?;
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, response_timeout, payload) = {
let mut state = self
.state
.lock()
.map_err(|_| "插件宿主锁已损坏".to_string())?;
let root = state
.root
.clone()
.ok_or_else(|| "插件宿主尚未初始化".to_string())?;
require_plugin_project(id, &state.active_project)?;
let record = state
.plugins
.get_mut(id)
.ok_or_else(|| "插件不存在".to_string())?;
let response_timeout = if record.manifest.adapter.is_some() {
EDITOR_RPC_TIMEOUT
} else {
RPC_TIMEOUT
};
let running = record
.running
.as_mut()
.ok_or_else(|| "插件尚未启动".to_string())?;
let request_id = running.next_request_id;
running.next_request_id = request_id
.checked_add(1)
.ok_or_else(|| "插件 RPC id 已耗尽".to_string())?;
let (sender, receiver) = mpsc::channel();
let payload = serialize_rpc(
&json!({"jsonrpc":"2.0", "id":request_id, "method":method, "params":params}),
)?;
{
let mut pending = running
.pending
.lock()
.map_err(|_| "插件 RPC 等待队列锁已损坏".to_string())?;
if pending.len() >= 32 {
return Err("插件 RPC 并发请求过多".to_string());
}
pending.insert(request_id, sender);
}
(
root,
request_id,
receiver,
Arc::clone(&running.pending),
Arc::clone(&running.stdin),
response_timeout,
payload,
)
};
let deadline = Instant::now() + response_timeout;
let managed_execute =
crate::editor_adapters::ManagedEditor::for_plugin(id).filter(|editor| match editor {
crate::editor_adapters::ManagedEditor::Unity => {
method == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME
}
crate::editor_adapters::ManagedEditor::Godot => {
method == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME
}
});
let (write_sender, write_receiver) = mpsc::channel();
let write_phase = Arc::new(AtomicU8::new(0));
let phase = Arc::clone(&write_phase);
thread::spawn(move || {
let result = writer
.lock()
.map_err(|_| "插件 stdin 锁已损坏".to_string())
.and_then(|mut writer| write_prepared_rpc(&mut *writer, &payload, &phase));
let _ = write_sender.send(result);
});
let mut 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(_) => {
cancel_rpc_before_write(&write_phase);
self.terminate_rpc_instance(id, &pending);
Err("插件 RPC 写入超时".to_string())
}
};
if let Ok(mut pending) = pending.lock() {
pending.remove(&request_id);
}
if let Some(editor) = managed_execute {
// 最后一跳丢失同样不能通过插件重载解除执行阻断。
result = finalize_managed_plugin_result(&write_phase, result, || {
crate::runner::mark_external_editor_uncertain(editor)
});
}
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 active = Arc::clone(&running.active);
let active_project = if record.id == crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID {
let context = Arc::new(Mutex::new(
active_project
.lock()
.ok()
.and_then(|project| project.clone()),
));
running.editor_context = Some(Arc::clone(&context));
context
} else {
active_project
};
let manifest = record.manifest.clone();
let root = root.to_path_buf();
thread::spawn(move || {
let mut terminal_error = None;
while let Ok(line_result) = lines.recv() {
if !active.load(Ordering::SeqCst) {
break;
}
let line = match line_result {
Ok(line) => line,
Err(error) => {
terminal_error = Some(error);
break;
}
};
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}})
}
};
if let Err(error) = write_rpc_shared(&writer, &payload) {
terminal_error = Some(format!("插件 RPC 响应写入失败:{error}"));
break;
}
}
}
}
if let Ok(mut waiting) = pending.lock() {
let remaining = std::mem::take(&mut *waiting);
let terminal_error = terminal_error.unwrap_or_else(|| "插件进程已退出".to_string());
for (_, sender) in remaining {
let _ = sender.send(Err(terminal_error.clone()));
}
}
});
}
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" => {
require_plugin_adapter(&manifest.id, editors)?;
if crate::builtin_plugins::toggle_state(&manifest.id) == Some(false) {
return Err("插件已禁用".to_string());
}
let input: EditorRpcInput = descriptor_from_params(params)?;
let adapter = manifest
.adapter
.as_deref()
.ok_or_else(|| "插件未指定编辑器适配器".to_string())?;
if input
.adapter
.as_deref()
.is_some_and(|requested| requested != adapter)
{
return Err("插件不能覆盖 manifest 声明的编辑器适配器".to_string());
}
// 不在全局锁后排队,避免外层已超时的写操作稍后才派发。
let editors = editors
.try_lock()
.map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?;
let editor = editors
.get(adapter)
.ok_or_else(|| format!("未知编辑器适配器:{adapter}"))?;
let project = active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?;
if !plugin_matches_project(&manifest.id, project.as_deref()) {
return Err("编辑器插件与当前项目类型不匹配".to_string());
}
let project = project
.as_deref()
.ok_or_else(|| "尚未设置当前项目".to_string())?;
let params = controlled_editor_params(project, input.params)?;
editor.rpc(&input.method, 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> {
self.set_active_project_with_cleanup(project_path, |previous| {
if previous.is_some() {
crate::editor_adapters::disconnect_managed_editor_project(
crate::editor_adapters::ManagedEditor::Godot,
previous,
)?;
}
Ok(())
})
}
fn set_active_project_with_cleanup(
&self,
project_path: Option<String>,
cleanup: impl FnOnce(Option<&Path>) -> Result<(), String>,
) -> Result<(), String> {
let mut state = self
.state
.lock()
.map_err(|_| "插件宿主锁已损坏".to_string())?;
let project = project_path
.map(|path| {
let path = PathBuf::from(path);
if !path.is_dir() {
return Err("项目路径必须是目录".to_string());
}
path.canonicalize()
.map_err(|_| "项目目录不可读".to_string())
})
.transpose()?;
let previous = state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.clone();
if previous != project {
let mut editors = state
.editors
.try_lock()
.map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?;
if let Some(editor) = editors.get_mut("cocos-editor") {
editor.disconnect();
}
crate::editor_adapters::disconnect_unity_editor_connection();
drop(editors);
// Godot 的受管桥独占原项目上下文;先撤销旧授权,再发布新项目。
for record in state
.plugins
.values_mut()
.filter(|record| record.id == crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID)
{
if let Some(running) = record.running.take() {
drop(running);
}
if record.manifest.enabled {
record.status = "stopped".to_string();
}
}
}
*state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())? = project.clone();
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())},
},
}),
);
}
}
}
drop(state);
if previous != project {
cleanup(previous.as_deref())
.map_err(|error| format!("当前项目已切换,旧编辑器桥仍待清理:{error}"))?;
}
Ok(())
}
#[cfg(any(
test,
all(windows, feature = "cocos-editor-execute"),
all(
windows,
target_arch = "x86_64",
any(feature = "unity-editor-execute", feature = "godot-editor-execute")
)
))]
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
.try_lock()
.map_err(|_| "编辑器注册表锁已损坏".to_string())?;
if editors.contains_key(adapter.id()) {
return Err("编辑器适配器已注册".to_string());
}
editors.insert(adapter.id().to_string(), adapter);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::editor_adapter::EditorConnectionInfo;
#[test]
fn writer_receipt_loss_after_json_write_is_persistently_uncertain() {
let config = tempfile::tempdir().unwrap();
let phase = AtomicU8::new(0);
let payload = serialize_rpc(&json!({"jsonrpc":"2.0","id":1,"method":"godot.editor.execute","params":{"code":"return 42"}})).unwrap();
let mut sink = Vec::new();
write_prepared_rpc(&mut sink, &payload, &phase).unwrap();
// JSON 已完整到达对端,但 writer 的最后一跳完成通知丢失。
assert_eq!(sink, payload);
assert!(!cancel_rpc_before_write(&phase));
let result =
finalize_managed_plugin_result(&phase, Err("writer 回执丢失".into()), || {
crate::editor_adapters::mark_editor_execution_uncertain_at(
crate::editor_adapters::ManagedEditor::Godot,
config.path(),
)
})
.unwrap();
assert_eq!(result["status"], "needs-reconciliation");
assert!(crate::editor_adapters::editor_uncertain_fence_path(
crate::editor_adapters::ManagedEditor::Godot,
config.path()
)
.exists());
}
#[test]
fn writer_cancelled_before_start_never_dispatches_later() {
let phase = AtomicU8::new(0);
assert!(cancel_rpc_before_write(&phase));
let mut sink = Vec::new();
assert!(write_prepared_rpc(&mut sink, b"{}\n", &phase).is_err());
assert!(sink.is_empty());
assert!(
finalize_managed_plugin_result(&phase, Err("未发送".into()), || panic!(
"不应标记已派发"
))
.is_err()
);
assert!(serialize_rpc(&json!({"code":"x".repeat(MAX_RPC_BYTES)})).is_err());
}
#[test]
fn project_switch_cleanup_failure_keeps_new_project_and_revokes_old_editor_process() {
let _guard = crate::builtin_plugins::test_lock();
let config = tempfile::tempdir().unwrap();
let old = tempfile::tempdir().unwrap();
let new = tempfile::tempdir().unwrap();
fs::write(old.path().join("project.godot"), "config_version=5\n").unwrap();
fs::write(new.path().join("project.godot"), "config_version=5\n").unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let host = PluginHost::default();
host.initialize(config.path()).unwrap();
host.register_editor_adapter(Box::new(StubManagedAdapter("godot-editor")))
.unwrap();
host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"))
.unwrap();
host.set_active_project_with_cleanup(Some(old.path().to_string_lossy().into()), |_| Ok(()))
.unwrap();
host.start("agc-godot-editor").unwrap();
let context = host.state.lock().unwrap().plugins["agc-godot-editor"]
.running
.as_ref()
.unwrap()
.editor_context
.clone()
.unwrap();
let result = host
.set_active_project_with_cleanup(Some(new.path().to_string_lossy().into()), |_| {
Err("旧桥清理失败".into())
});
assert!(result.unwrap_err().contains("已切换"));
let state = host.state.lock().unwrap();
assert_eq!(
*state.active_project.lock().unwrap(),
Some(new.path().canonicalize().unwrap())
);
assert!(state.plugins["agc-godot-editor"].running.is_none());
assert!(context.lock().unwrap().is_none());
}
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());
}
#[test]
fn editor_rpc_enforces_manifest_adapter_and_current_project_without_queueing() {
let root = tempdir().unwrap();
let other = tempdir().unwrap();
let project = Arc::new(Mutex::new(Some(root.path().to_path_buf())));
let editors: EditorRegistry = Arc::new(Mutex::new(BTreeMap::from([(
"cocos-editor".to_string(),
Box::new(StubCocosAdapter::default()) as Box<dyn EditorAdapter>,
)])));
let registrations = Arc::new(Mutex::new(PluginRegistrations::default()));
let mut manifest = manifest();
manifest.adapter = Some("cocos-editor".to_string());
manifest.permissions.insert("editor.rpc".to_string());
let call = |params| {
PluginHost::handle_host_request(
root.path(),
&project,
&editors,
&manifest,
&registrations,
"host.rpc",
Some(params),
)
};
assert!(call(json!({"adapter":"unity-editor","method":"editor.execute","params":{"code":"return 1;"}})).unwrap_err().contains("manifest"));
assert!(call(json!({"method":"editor.execute","params":{"projectPath":other.path(),"code":"return 1;"}})).unwrap_err().contains("受控项目"));
let result =
call(json!({"method":"editor.execute","params":{"code":"return 1;"}})).unwrap();
assert_eq!(
result["params"]["projectPath"],
root.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.as_ref()
);
let _busy = editors.lock().unwrap();
let start = std::time::Instant::now();
assert!(call(json!({"method":"editor.execute","params":{"code":"return 1;"}})).is_err());
assert!(start.elapsed() < Duration::from_millis(100));
}
#[derive(Default)]
struct StubCocosAdapter {
disconnects: Arc<std::sync::atomic::AtomicUsize>,
}
struct StubManagedAdapter(&'static str);
impl EditorAdapter for StubManagedAdapter {
fn id(&self) -> &'static str {
self.0
}
fn detect(&self, _project_path: &Path) -> Result<EditorConnectionInfo, String> {
Ok(EditorConnectionInfo::disconnected(self.0))
}
fn connect(
&mut self,
_pid: u32,
_project_path: &Path,
_version: &str,
) -> Result<EditorConnectionInfo, String> {
Err("test does not connect".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> {
Ok(
json!({"ok":true,"status":"completed","retryAllowed":false,"dispatched":true,"result":{"code":params["code"],"projectPath":params["projectPath"]}}),
)
}
}
#[test]
fn workspace_unity_plugin_round_trips_across_project_contexts() {
let _guard = crate::builtin_plugins::test_lock();
let config = tempdir().unwrap();
let project = tempdir().unwrap();
for directory in ["Assets", "Packages", "ProjectSettings"] {
fs::create_dir(project.path().join(directory)).unwrap();
}
fs::write(
project.path().join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 6000.0.1f1",
)
.unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let host = PluginHost::default();
host.initialize(config.path()).unwrap();
host.register_editor_adapter(Box::new(StubManagedAdapter("unity-editor")))
.unwrap();
host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"))
.unwrap();
assert!(host
.list()
.unwrap()
.iter()
.any(|plugin| plugin.id == "agc-unity-editor"));
host.start("agc-unity-editor").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if host.list().unwrap().iter().any(|plugin| {
plugin.id == "agc-unity-editor"
&& plugin.commands.len() == 1
&& plugin.capabilities.len() == 1
}) && host.state.lock().unwrap().plugins["agc-unity-editor"]
.running
.as_ref()
.unwrap()
.registrations
.lock()
.unwrap()
.subscriptions
.values()
.any(|event| event == "project.changed")
{
// 后续切换项目会发送事件;先订阅,避免注册完成后丢失项目通知。
break;
}
assert!(Instant::now() < deadline);
thread::sleep(Duration::from_millis(20));
}
let plugin_pid = host.state.lock().unwrap().plugins["agc-unity-editor"]
.running
.as_ref()
.unwrap()
.child
.id();
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
.unwrap();
let response = host
.call(
"agc-unity-editor",
"unity.editor.execute".to_string(),
json!({"code":"return 2;"}),
)
.unwrap();
assert_eq!(response["status"], "completed");
assert_eq!(
response["result"]["projectPath"],
project
.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.as_ref()
);
let other = tempdir().unwrap();
host.set_active_project(Some(other.path().to_string_lossy().into_owned()))
.unwrap();
let response = host
.call(
"agc-unity-editor",
"unity.editor.execute".to_string(),
json!({"code":"return 3;"}),
)
.unwrap();
assert_eq!(response["status"], "completed");
assert_eq!(
response["result"]["projectPath"],
other
.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.as_ref()
);
host.set_active_project(None).unwrap();
assert!(host
.list()
.unwrap()
.iter()
.any(|plugin| plugin.id == "agc-unity-editor"));
assert_eq!(
host.state.lock().unwrap().plugins["agc-unity-editor"]
.running
.as_ref()
.unwrap()
.child
.id(),
plugin_pid
);
let response = host
.call(
"agc-unity-editor",
"unity.editor.execute".to_string(),
json!({"code":"return 4;"}),
)
.unwrap();
assert_eq!(response["status"], "failed");
assert_eq!(response["dispatched"], false);
host.stop("agc-unity-editor").unwrap();
}
#[test]
fn workspace_godot_plugin_round_trips_and_stops_when_leaving_project() {
let (plugin_id, adapter_id, execute_tool) =
("agc-godot-editor", "godot-editor", "godot.editor.execute");
let _guard = crate::builtin_plugins::test_lock();
let config = tempdir().unwrap();
let project = tempdir().unwrap();
fs::write(project.path().join("project.godot"), "config_version=5\n").unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let host = PluginHost::default();
host.initialize(config.path()).unwrap();
host.register_editor_adapter(Box::new(StubManagedAdapter(adapter_id)))
.unwrap();
host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"))
.unwrap();
assert!(!host
.list()
.unwrap()
.iter()
.any(|plugin| plugin.id == plugin_id));
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
.unwrap();
host.start(plugin_id).unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if host.list().unwrap().iter().any(|plugin| {
plugin.id == plugin_id
&& plugin.commands.len() == 1
&& plugin.capabilities.len() == 1
}) {
break;
}
assert!(Instant::now() < deadline);
thread::sleep(Duration::from_millis(20));
}
let response = host
.call(
plugin_id,
execute_tool.to_string(),
json!({"code":"return 2"}),
)
.unwrap();
assert_eq!(
response["status"], "completed",
"Godot RPC 回执:{response}"
);
assert_eq!(
response["result"]["projectPath"],
project
.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.as_ref()
);
host.set_active_project(None).unwrap();
assert!(!host
.list()
.unwrap()
.iter()
.any(|plugin| plugin.id == plugin_id));
assert!(host.state.lock().unwrap().plugins[plugin_id]
.running
.is_none());
}
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) {
self.disconnects.fetch_add(1, Ordering::SeqCst);
}
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");
fs::write(
directory.path().join("package.json"),
r#"{"creator":{"version":"3.8.8"}}"#,
)
.expect("cocos package");
fs::create_dir(directory.path().join("assets")).expect("cocos assets");
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins");
let host = PluginHost::default();
crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state");
host.initialize(directory.path()).expect("initialize");
host.register_editor_adapter(Box::new(StubCocosAdapter::default()))
.expect("register adapter");
host.set_plugin_workspace(workspace)
.expect("set plugins workspace");
host.set_active_project(Some(directory.path().to_string_lossy().into_owned()))
.expect("set cocos project");
let summary = |list: Vec<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");
fs::write(
directory.path().join("package.json"),
r#"{"creator":{"version":"3.8.8"}}"#,
)
.expect("cocos package");
fs::create_dir(directory.path().join("assets")).expect("cocos assets");
crate::builtin_plugins::initialize(directory.path()).expect("builtin state");
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins");
let host = PluginHost::default();
host.initialize(directory.path()).expect("initialize");
host.set_plugin_workspace(workspace)
.expect("set plugins workspace");
host.register_editor_adapter(Box::new(StubCocosAdapter::default()))
.expect("register adapter");
let project = fs::canonicalize(directory.path())
.expect("canonical project")
.to_string_lossy()
.into_owned();
host.set_active_project(Some(project.clone()))
.expect("set active project");
host.start("agc-cocos-editor").expect("start plugin");
let deadline = std::time::Instant::now() + Duration::from_secs(15);
loop {
let registered = host.list().expect("list").into_iter().any(|plugin| {
plugin.id == "agc-cocos-editor"
&& plugin.commands.len() == 2
&& plugin.capabilities.len() == 1
&& plugin.panels.len() == 1
});
if registered {
// 注册完成早于项目订阅快照到达;只读查询确认插件已收到上下文。
match host.call(
"agc-cocos-editor",
"cocos.editor.connection".to_string(),
json!({"operation": "status", "processId": 1}),
) {
Ok(response) => {
assert_eq!(response["params"]["projectPath"], project);
break;
}
Err(error) if error.contains("当前没有受控项目路径") => {}
Err(error) => panic!("cocos context readiness rpc: {error}"),
}
}
assert!(
std::time::Instant::now() < deadline,
"Cocos 插件未在期限内完成注册及项目上下文初始化"
);
thread::sleep(Duration::from_millis(25));
}
let response = host
.call(
"agc-cocos-editor",
"cocos.editor.execute".to_string(),
json!({"code": "return 1 + 1;"}),
)
.expect("cocos execute rpc");
assert_eq!(response["status"], "completed");
assert_eq!(response["response"]["method"], "editor.execute");
assert_eq!(response["response"]["params"]["projectPath"], project);
assert_eq!(response["response"]["params"]["code"], "return 1 + 1;");
assert_eq!(
host.stop("agc-cocos-editor").expect("stop plugin").status,
"stopped"
);
}
#[test]
fn cocos_plugin_stays_available_across_project_contexts() {
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");
let adapter = StubCocosAdapter::default();
let disconnects = Arc::clone(&adapter.disconnects);
host.register_editor_adapter(Box::new(adapter))
.expect("register adapter");
host.set_plugin_workspace(workspace).expect("set workspace");
assert!(host
.list()
.expect("list plugins")
.into_iter()
.any(|plugin| plugin.id == "agc-cocos-editor"));
host.start("agc-cocos-editor")
.expect("start without a project");
let deadline = Instant::now() + Duration::from_secs(15);
while host.read_panel("agc-cocos-editor", "cocos-editor").is_err()
|| !host.state.lock().unwrap().plugins["agc-cocos-editor"]
.running
.as_ref()
.unwrap()
.registrations
.lock()
.unwrap()
.subscriptions
.values()
.any(|event| event == "project.changed")
{
assert!(Instant::now() < deadline, "Cocos panel was not registered");
thread::sleep(Duration::from_millis(25));
}
let plugin_pid = host.state.lock().unwrap().plugins["agc-cocos-editor"]
.running
.as_ref()
.unwrap()
.child
.id();
let project = tempdir().expect("web project");
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
.expect("set active project");
assert_eq!(disconnects.load(Ordering::SeqCst), 1);
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
.expect("keep the same active project");
assert_eq!(disconnects.load(Ordering::SeqCst), 1);
let response = host
.call(
"agc-cocos-editor",
"cocos.editor.execute".to_string(),
json!({"code":"return 1;"}),
)
.expect("RPC reaches the adapter without a project type gate");
assert_eq!(response["status"], "completed");
assert_eq!(
response["response"]["params"]["projectPath"],
project
.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.as_ref()
);
host.set_active_project(None).unwrap();
assert_eq!(disconnects.load(Ordering::SeqCst), 2);
assert!(host
.list_extensions()
.unwrap()
.iter()
.any(|plugin| plugin.id == "agc-cocos-editor"));
host.read_panel("agc-cocos-editor", "cocos-editor")
.expect("panel remains available");
assert_eq!(
host.state.lock().unwrap().plugins["agc-cocos-editor"]
.running
.as_ref()
.unwrap()
.child
.id(),
plugin_pid
);
assert!(host
.call(
"agc-cocos-editor",
"cocos.editor.execute".to_string(),
json!({"code":"return 1;"}),
)
.is_err());
host.stop("agc-cocos-editor").unwrap();
}
#[test]
fn cocos_plugin_requires_registered_adapter_even_for_a_cocos_project() {
let _guard = crate::builtin_plugins::test_lock();
let directory = tempdir().expect("temp config");
fs::write(
directory.path().join("package.json"),
r#"{"creator":{"version":"3.8.8"}}"#,
)
.unwrap();
fs::create_dir(directory.path().join("assets")).unwrap();
crate::builtin_plugins::initialize(directory.path()).unwrap();
let host = PluginHost::default();
host.initialize(directory.path()).unwrap();
host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"))
.unwrap();
host.set_active_project(Some(directory.path().to_string_lossy().into_owned()))
.unwrap();
assert!(host
.list()
.unwrap()
.iter()
.all(|plugin| plugin.id != "agc-cocos-editor"));
assert!(host
.list_extensions()
.unwrap()
.iter()
.all(|plugin| plugin.id != "agc-cocos-editor"));
assert!(host
.start("agc-cocos-editor")
.err()
.expect("unsupported adapter")
.contains("不支持 Cocos"));
assert!(host
.read_panel("agc-cocos-editor", "cocos-editor")
.err()
.expect("unsupported adapter")
.contains("不支持 Cocos"));
assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"]
.running
.is_none());
host.register_editor_adapter(Box::new(StubCocosAdapter::default()))
.unwrap();
assert!(host
.list()
.unwrap()
.iter()
.any(|plugin| plugin.id == "agc-cocos-editor"));
}
}