Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs
T
lhk229 55375b1406
Project CI / AI game creator shell Rust crates (push) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m30s
Project CI / Backend tests (push) Successful in 3m58s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m12s
Project CI / Native shell tests (push) Successful in 5m49s
Project CI / Frontend tests (push) Successful in 2m18s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m50s
Project CI / AI game creator shell web tests (push) Failing after 2m11s
Project CI / Repository checks (push) Successful in 2m55s
清理测试编译warning (#509)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/509
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-01 15:29:23 +08:00

2736 lines
100 KiB
Rust

//! 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 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 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)
}
/// 统一后的插件来源,屏蔽“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)
}
/// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。
///
/// 该状态同时被 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(())
}
#[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"));
}
}