Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs
T
kdletters 288e1d4f4a
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 13s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 13s
Project CI / AI game creator shell Rust smoke (push) Failing after 12s
Project CI / AI game creator shell Rust crates (push) Failing after 20s
Project CI / Backend tests (push) Failing after 23s
Project CI / Native shell tests (push) Failing after 12s
Project CI / Frontend tests (push) Failing after 5s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 13s
同步最新主干并修复 Godot 插件启动竞态
合入主干 Rust 1.98.1 工具链及对应 CI 文档更新
Godot 插件先接收受控项目快照再注册命令和连接能力
补充启动竞态回归、失败回执诊断及插件启动合同
2026-09-20 17:46:53 +08:00

2913 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, 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 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 EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90);
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
const MAX_RPC_BYTES: usize = 2 * 1024 * 1024;
const KNOWN_PERMISSIONS: &[&str] = &[
"events.subscribe",
"project.read",
"editor.rpc",
"ui.register",
"capability.register",
];
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PluginPanelManifest {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) entry: String,
#[serde(default)]
pub(crate) placement: Option<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,
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)
}
/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `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,
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(|_| "读取插件输出失败".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 = 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 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)?;
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())?;
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());
}
let mut cleanup = Ok(());
if !enabled {
let _ = self.stop(id);
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 || {
while let Ok(line) = lines.recv() {
if !active.load(Ordering::SeqCst) {
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}})
}
};
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" => {
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(())
}
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(())
}
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
.try_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
.try_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
.try_lock()
.map_err(|_| "编辑器注册表锁已损坏".to_string())?;
if adapter == "godot-editor" {
if !editors.contains_key(&adapter) {
return Err(format!("未知编辑器适配器:{adapter}"));
}
let project = state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.clone();
drop(editors);
drop(state);
return crate::editor_adapters::disconnect_managed_editor_project(
crate::editor_adapters::ManagedEditor::Godot,
project.as_deref(),
);
}
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
.try_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) async fn set_agc_plugin_project_path(
project_path: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
app.state::<PluginHost>().set_active_project(project_path)
})
.await
.map_err(|_| "切换插件项目上下文任务失败".to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
#[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
}) {
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 {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Cocos 插件未在期限内完成注册"
);
thread::sleep(Duration::from_millis(25));
}
let response = host
.call(
"agc-cocos-editor",
"cocos.editor.execute".to_string(),
json!({"code": "return 1 + 1;"}),
)
.expect("cocos execute rpc");
assert_eq!(response["status"], "completed");
assert_eq!(response["response"]["method"], "editor.execute");
assert_eq!(response["response"]["params"]["projectPath"], project);
assert_eq!(response["response"]["params"]["code"], "return 1 + 1;");
assert_eq!(
host.stop("agc-cocos-editor").expect("stop plugin").status,
"stopped"
);
}
#[test]
fn cocos_plugin_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"));
}
}