Cocos 插件按当前项目类型暴露
Project CI / Repository checks (push) Successful in 2m21s
Project CI / Frontend tests (push) Successful in 2m41s
Project CI / Backend tests (push) Successful in 5m21s
Project CI / Native shell tests (push) Successful in 19m48s

新增 Cocos 项目根识别门禁,限制插件、面板、RPC 和编辑器执行范围
让 DirectProject MCP 工具目录按当前项目类型动态过滤
切换离开 Cocos 项目时停止已运行的插件实例
补充项目级测试、Cocos 技术方案和插件说明
This commit is contained in:
2026-09-13 17:12:37 +08:00
parent 96b45e6ffc
commit c19b321785
8 changed files with 203 additions and 13 deletions
@@ -2349,9 +2349,10 @@ async fn bridge_cocos_call(
arguments: &Value,
operation: Option<&str>,
) -> Value {
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&state.root) {
return bridge_tool_result(
"Cocos Creator 插件已禁用,agc_cocos_execute 不可用".to_string(),
"当前项目不是 Cocos Creator 项目或 Cocos 插件不可用,agc_cocos_execute 不可用"
.to_string(),
Vec::new(),
true,
);
@@ -2416,9 +2417,9 @@ async fn bridge_cocos_call(
// validated Inspector/pipe bridge. It does not mutate AGC's project
// files or manifest, so it must not wait on `.agent/project.lock`.
// File-writing tools keep their own project lock separately.
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&root) {
return Err(cocos_editor_bridge::BridgeError::InvalidInput(
"Cocos Creator 插件已禁".to_string(),
"当前项目不是 Cocos Creator 项目或 Cocos 插件不可".to_string(),
));
}
cocos_editor_bridge::execute_cocos_editor_code_for_project(
@@ -2521,7 +2522,7 @@ async fn handle_direct_tool_bridge(
let result = match request.tool.as_str() {
// 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。
"builtin.plugins.tools" => bridge_tool_result(
json!({"tools": crate::builtin_plugins::available_agent_tools()}).to_string(),
json!({"tools": crate::builtin_plugins::available_agent_tools_for_project(&state.root)}).to_string(),
Vec::new(),
false,
),
@@ -1892,7 +1892,13 @@ mod tests {
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-");
// 本用例只访问可用工具摘要和禁用入口,无需初始化完整游戏项目
// 工具目录现在按当前项目类型过滤,fixture 必须具备最小 Cocos Creator 结构
std::fs::write(
project.path().join("package.json"),
r#"{"creator":{"version":"3.8.8"}}"#,
)
.unwrap();
std::fs::create_dir(project.path().join("assets")).unwrap();
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
let bridge =
@@ -33,11 +33,11 @@ pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute(
detail: None,
};
}
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(root) {
return AgentRuntimeToolObservation {
tool: "cocos.editor.execute".to_string(),
status: "failed".to_string(),
summary: "Cocos Creator 插件已禁".to_string(),
summary: "当前项目不是 Cocos Creator 项目或 Cocos 插件不可".to_string(),
detail: None,
};
}
@@ -245,6 +245,17 @@ pub(crate) fn cocos_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::CocosEditor)
}
/// Cocos 编辑器插件只对当前确认为 Cocos Creator 的项目可用。
///
/// 项目类型以项目根的真实结构为准,不能仅凭插件开关或编译 feature 推断。
pub(crate) fn cocos_editor_agent_tool_available_for_project(root: &Path) -> bool {
cocos_editor_agent_tool_available()
&& crate::project::discover_local_cocos_project_root(root)
.ok()
.flatten()
.is_some()
}
pub(crate) fn available_agent_tools() -> Vec<&'static str> {
if cocos_editor_agent_tool_available() {
let mut tools = vec![AGC_COCOS_EDITOR_TOOL_NAME];
@@ -259,6 +270,15 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> {
}
}
/// Project-scoped variant used by the isolated DirectProject MCP bridge.
/// Without a project root the safe result is an empty Cocos tool set.
pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str> {
if !cocos_editor_agent_tool_available_for_project(root) {
return Vec::new();
}
available_agent_tools()
}
#[cfg(test)]
pub(crate) use tests::test_lock;
@@ -435,4 +455,32 @@ mod tests {
tool_visible_when_enabled
);
}
#[test]
fn project_scoped_availability_requires_a_cocos_creator_root() {
let _guard = test_lock();
let directory = tempdir().expect("temp config");
initialize(directory.path()).expect("initialize");
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
let non_cocos = tempdir().expect("non-cocos project");
assert!(!cocos_editor_agent_tool_available_for_project(
non_cocos.path()
));
let cocos = tempdir().expect("cocos project");
fs::write(
cocos.path().join("package.json"),
r#"{"creator":{"version":"3.8.8"}}"#,
)
.expect("cocos package");
fs::create_dir(cocos.path().join("assets")).expect("cocos assets");
assert_eq!(
cocos_editor_agent_tool_available_for_project(cocos.path()),
cfg!(feature = "cocos-editor-execute")
);
assert_eq!(
available_agent_tools_for_project(non_cocos.path()),
Vec::<&'static str>::new()
);
}
}
@@ -937,9 +937,23 @@ impl PluginHost {
.clone()
.ok_or_else(|| "插件宿主尚未初始化".to_string())?;
self.scan_locked(&mut state, &root)?;
let cocos_project = state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.as_deref()
.is_some_and(|path| {
crate::project::discover_local_cocos_project_root(path)
.ok()
.flatten()
.is_some()
});
state
.plugins
.values()
.filter(|record| {
record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_project
})
.map(|record| self.summary_locked(record))
.collect()
}
@@ -1003,6 +1017,20 @@ impl PluginHost {
.clone()
.ok_or_else(|| "插件宿主尚未初始化".to_string())?;
let active_project = state.active_project.clone();
if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID
&& !active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.as_deref()
.is_some_and(|path| {
crate::project::discover_local_cocos_project_root(path)
.ok()
.flatten()
.is_some()
})
{
return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string());
}
let editors = state.editors.clone();
let record = state
.plugins
@@ -1100,6 +1128,21 @@ impl PluginHost {
.plugins
.get(id)
.ok_or_else(|| "插件不存在".to_string())?;
if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID
&& !state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.as_deref()
.is_some_and(|path| {
crate::project::discover_local_cocos_project_root(path)
.ok()
.flatten()
.is_some()
})
{
return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string());
}
if record.running.is_none() || !record.manifest.permissions.contains("ui.register") {
return Err("插件面板未激活".to_string());
}
@@ -1145,6 +1188,21 @@ impl PluginHost {
.root
.clone()
.ok_or_else(|| "插件宿主尚未初始化".to_string())?;
if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID
&& !state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.as_deref()
.is_some_and(|path| {
crate::project::discover_local_cocos_project_root(path)
.ok()
.flatten()
.is_some()
})
{
return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string());
}
let record = state
.plugins
.get_mut(id)
@@ -1478,6 +1536,20 @@ impl PluginHost {
}
"host.rpc" => {
let input: EditorRpcInput = descriptor_from_params(params)?;
if manifest.id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID
&& !active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())?
.as_deref()
.is_some_and(|path| {
crate::project::discover_local_cocos_project_root(path)
.ok()
.flatten()
.is_some()
})
{
return Err("Cocos 编辑器插件只对当前 Cocos Creator 项目可用".to_string());
}
let adapter = input
.adapter
.or_else(|| manifest.adapter.clone())
@@ -1534,7 +1606,7 @@ impl PluginHost {
}
pub(crate) fn set_active_project(&self, project_path: Option<String>) -> Result<(), String> {
let state = self
let mut state = self
.state
.lock()
.map_err(|_| "插件宿主锁已损坏".to_string())?;
@@ -1548,10 +1620,30 @@ impl PluginHost {
.map_err(|_| "项目目录不可读".to_string())
})
.transpose()?;
let is_cocos_project = project.as_deref().is_some_and(|path| {
crate::project::discover_local_cocos_project_root(path)
.ok()
.flatten()
.is_some()
});
*state
.active_project
.lock()
.map_err(|_| "项目上下文锁已损坏".to_string())? = project;
if !is_cocos_project {
if let Some(record) = state
.plugins
.get_mut(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID)
{
if let Some(mut running) = record.running.take() {
let _ = running.child.kill();
let _ = running.child.wait();
}
if record.manifest.enabled {
record.status = "stopped".to_string();
}
}
}
for record in state.plugins.values() {
if let Some(running) = record.running.as_ref() {
let subscribed = running
@@ -2020,12 +2112,20 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
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.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()
@@ -2060,6 +2160,12 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
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();
@@ -2108,4 +2214,25 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
"stopped"
);
}
#[test]
fn cocos_plugin_is_hidden_and_cannot_start_for_non_cocos_project() {
let _guard = crate::builtin_plugins::test_lock();
let directory = tempdir().expect("temp config");
crate::builtin_plugins::initialize(directory.path()).expect("builtin state");
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins");
let host = PluginHost::default();
host.initialize(directory.path()).expect("initialize");
host.set_plugin_workspace(workspace).expect("set workspace");
let project = tempdir().expect("web project");
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
.expect("set active project");
assert!(host
.list()
.expect("list plugins")
.into_iter()
.all(|plugin| plugin.id != "agc-cocos-editor"));
assert!(host.start("agc-cocos-editor").is_err());
}
}
@@ -8250,3 +8250,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 不做什么:不放宽 `.agent/project.lock` 的项目级串行化语义,不引入可重入项目锁,不改“同一调用链禁止二次获取 `.agent/project.lock`”的既有约定,不改 AGC 多进程拓扑,不改 `pendingOperations` 语义,也不改“活持有者始终不回收”的既有判据。其它仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)不在本次范围。
- 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、有界等待不占 runtime worker(默认 `current_thread` runtime 加心跳任务,同步阻塞会立刻让心跳停摆)、ACL 拒绝不投影成争用四条;`project/write_lock` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 4 条、`parent_wake` 16 条、`waits_across` 3 条全过;CI`71e9ad313`)四个 job 全绿,其中 Native shell tests 的首轮失败正是第 ⑤ 条平台判据缺陷。
- 关联文档:`docs/project-memory/shared-memory/pitfalls.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、Issue #318
## 2026-09-13 Cocos 插件按当前项目类型暴露
- 决策:`agc-cocos-editor` 只有在当前受控项目通过 Cocos Creator 根目录识别(`package.json.creator.version` + 普通 `assets/`)时才暴露插件、面板和 Cocos 工具;无项目或其它项目类型均隐藏并失败关闭。
- 决策:项目切换离开 Cocos 时立即停止已运行的插件实例;启动、面板读取、插件 RPC、Runtime execute 和 DirectProject MCP 工具目录/执行入口全部再次校验项目类型。Cocos 编辑器操作优先经内置插件入口,禁止回退到项目 `extensions/``package.json` 插件或第三方 MCP。
- 验证:新增 builtin/plugin host 项目级门禁测试,Direct MCP fixture 补最小 Cocos 工程结构;Rust 定向测试、显式 `cocos-editor-execute` feature 编译、编码检查和 `git diff --check` 已执行。
@@ -71,7 +71,7 @@ plugins/agc-cocos-editor/
宿主按 `AGC_PLUGIN_WORKSPACE`、随包 `<resource_dir>/plugins`、开发构建仓库 `plugins/` 的顺序解析工作区;插件包内的 `native/payload` 由构建脚本随包映射,生成的 DLL 不入库。插件协议、权限和面板挂载全部复用通用宿主,Cocos 专属逻辑只存在于本插件包:进程名与 `--project` 解析、Creator 版本校验、named pipe 协议和 Windows 注入。
该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。禁用后插件进程停止且不能启动,通用 execute 和全部 `cocos_*` 工具从 Agent 目录消失;直接请求隐藏工具也会在宿主执行前被拒绝。开关状态保存在 AppData `extensions/builtin-plugins.json`,隔离 MCP 每次 tools/list 都向绑定宿主询问当前状态。
该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。除开关和 feature 外,还必须满足“当前受控项目已识别为 Cocos Creator 项目”这一门禁;没有当前项目或项目类型不是 Cocos 时,插件不出现在插件列表、面板、Agent 工具目录或 MCP tools/list 中,启动、面板读取、RPC 和编辑器执行也会失败关闭。项目切换离开 Cocos 后,已运行实例立即停止。Cocos 编辑器操作统一优先通过该内置插件的 `cocos.editor.execute` / `cocos.editor.operation`DirectProject 对应 `agc_cocos_execute`);不得改走项目目录 `extensions/``package.json` 插件或第三方 MCP。开关状态保存在 AppData `extensions/builtin-plugins.json`,隔离 MCP 每次 tools/list 都向绑定宿主询问当前状态与项目门禁
## AGC 项目打开入口
+5 -3
View File
@@ -3,9 +3,11 @@
Cocos Creator 编辑器桥接插件。用户侧看到的是一个普通 AGC 插件:插件生命周期、UI、
RPC、权限和能力注册全部由通用宿主负责,只有“如何连接 Cocos Creator”属于本插件。
它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。用户在运行时设置里只能切换
“是否可用”,禁用后插件不能启动,`agc_cocos_execute` 与全部 `cocos_*` Agent 工具也会
从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后立即恢复。
它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。只有当前受控目录被识别为 Cocos
Creator 项目时才会暴露插件、面板和工具;切换到其它项目会立即停止插件实例并隐藏对应能力。
用户在运行时设置里只能切换“是否可用”,禁用后插件不能启动,`agc_cocos_execute` 与全部
`cocos_*` Agent 工具也会从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后仍需
满足当前项目是 Cocos 的门禁。
Cocos Creator 项目目录中的 `extensions/``package.json` 插件声明或第三方 MCP 包不属于
AGC Cocos 桥接来源。Agent 处理 Cocos 请求时只使用客户端登记的 `agc-cocos-editor`