From 02ff914bd3b810b17bb7497ea39609f7ec8731b4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 10 Sep 2026 21:37:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=85=E7=BD=AE=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=B7=A8=E8=BF=9B=E7=A8=8B=E5=BC=80=E5=85=B3=E4=B8=8E?= =?UTF-8?q?=E4=B8=8D=E7=A1=AE=E5=AE=9A=E6=89=A7=E8=A1=8C=E9=98=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 Runner 和 CLI 查询持久化开关并即时感知 GUI 状态更新 让隔离 MCP 经现有工具桥获取可用工具,并将开关纳入会话缓存标识 在执行入口复查禁用状态,固定原生函数缓存构建使用的工具快照 修复损坏开关文件保存成功后仍保持关闭的状态 保留插件执行结果不确定的结构化回执和宿主适配器阻断,拒绝并发积压与自动重发 新增跨进程工具目录、热切换、坏文件恢复及不确定执行回归测试并同步文档 --- .../src-tauri/src/agent/codex_app_server.rs | 37 ++++ .../src-tauri/src/agent/direct_tool_bridge.rs | 11 ++ .../src-tauri/src/agent/direct_tools_mcp.rs | 152 ++++++++++++++++- .../src/agent/runtime_tools/cocos_editor.rs | 8 + .../src-tauri/src/agent_native_tools.rs | 12 +- .../src-tauri/src/builtin_plugins.rs | 161 +++++++++++++++--- .../src-tauri/src/plugin_host.rs | 3 + .../shared-memory/decision-log.md | 2 + ...AGC Cocos Creator 编辑器桥接模块-2026-09-09.md | 2 + ...案】AGC通用插件宿主与编辑器适配-2026-09-09.md | 2 + plugins/agc-cocos-editor/README.md | 4 + .../native/cocos-editor-bridge/src/adapter.rs | 65 ++++++- plugins/agc-cocos-editor/src/entry.mjs | 44 ++++- plugins/agc-cocos-editor/src/entry.test.mjs | 108 +++++++++++- 14 files changed, 564 insertions(+), 47 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index dab131406..f1227e680 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1070,6 +1070,7 @@ fn game_creator_codex_app_server_pool_key( "skillPackIdentity": skill_pack_identity, "clientSkillIdentity": client_skill_identity, "clientMcpIdentity": client_mcp_identity, + "builtinPluginTools": crate::builtin_plugins::available_agent_tools(), "controlledWebSearch": llm.web_search_enabled, "directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" }, "providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" }, @@ -5050,6 +5051,42 @@ while IFS= read -r line; do :; done assert_ne!(disabled, enabled); } + #[cfg(feature = "cocos-editor-execute")] + #[test] + fn codex_app_server_pool_key_tracks_builtin_plugin_switch() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let key = || { + game_creator_codex_app_server_pool_key( + &test_llm(), + "codex-cli 0.147.0", + &test_snapshot(), + "credential", + CodexAppServerWorkspaceMode::DirectProject, + ) + }; + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + false, + ) + .unwrap(); + let disabled = key(); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + true, + ) + .unwrap(); + let enabled = key(); + assert_ne!(disabled, enabled); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + false, + ) + .unwrap(); + assert_eq!(disabled, key()); + } + #[test] fn direct_file_change_approval_is_limited_to_workspace() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 5f94f6e0a..c9e9a74d0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2344,6 +2344,11 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) let result = tokio::task::spawn_blocking(move || { let _lock = acquire_project_write_lock(&root, "direct-cocos.execute") .map_err(cocos_editor_bridge::BridgeError::InvalidInput)?; + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { + return Err(cocos_editor_bridge::BridgeError::InvalidInput( + "Cocos Creator 插件已禁用".to_string(), + )); + } cocos_editor_bridge::execute_cocos_editor_code_for_project( root.to_string_lossy().as_ref(), &code, @@ -2402,6 +2407,12 @@ async fn handle_direct_tool_bridge( Json(request): Json, ) -> Json { 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(), + Vec::new(), + false, + ), "taonier_prepare_game_art" => bridge_prepare_game_art(&state, &request.arguments).await, "agc_generate_image" => bridge_generate_image(&state, &request.arguments).await, "agc_edit_image" => bridge_edit_image(&state, &request.arguments).await, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index edf69c1b5..0dcd215a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -74,11 +74,36 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option }) } -fn direct_tools_mcp_specs() -> Value { - direct_tools_mcp_specs_for(controlled_web_search_enabled()) +async fn direct_tools_mcp_specs() -> Value { + let mut cocos_editor_available = false; + if cfg!(all(windows, feature = "cocos-editor-execute")) { + // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。 + if let Ok(result) = tokio::time::timeout( + std::time::Duration::from_secs(5), + call_client_tool_bridge("builtin.plugins.tools", &json!({})), + ) + .await + { + if result["isError"] == false { + let availability = result + .pointer("/content/0/text") + .and_then(Value::as_str) + .and_then(|text| serde_json::from_str::(text).ok()); + cocos_editor_available = availability + .as_ref() + .and_then(|v| v["tools"].as_array()) + .is_some_and(|tools| { + tools + .iter() + .any(|tool| tool == crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) + }); + } + } + } + direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available) } -fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { +fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value { let tools = vec![ json!({ "name": "client.session.info", @@ -432,7 +457,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { ]; let mut tools = tools; #[cfg(all(windows, feature = "cocos-editor-execute"))] - if crate::builtin_plugins::cocos_editor_agent_tool_available() { + if _cocos_editor_available { tools.push(json!({ "name": "agc_cocos_execute", "description": "在当前项目已连接的 Cocos Creator 主进程执行 JavaScript 函数体,支持 await 和 return。宿主绑定项目和目标进程,只提交 code。结果待核对或超时后禁止自动重发;使用 Editor.Message 调用 Creator API。", @@ -1584,7 +1609,7 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< )) } } - "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())), + "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs().await)), "tools/call" => { let tool = request .pointer("/params/name") @@ -1797,6 +1822,119 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + + #[cfg(all(windows, feature = "cocos-editor-execute"))] + #[test] + fn builtin_mcp_process_probe() { + let Ok(expected) = std::env::var("AGC_MCP_TEST_COCOS_EXPECTED") else { + return; + }; + assert!(crate::game_creator_runtime_config_dir().is_none()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let specs = runtime.block_on(direct_tools_mcp_specs()); + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "agc_cocos_execute"), + expected == "true" + ); + } + + #[cfg(all(windows, feature = "cocos-editor-execute"))] + #[tokio::test] + async fn builtin_tools_follow_host_switch_in_isolated_mcp_processes() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-"); + // 本用例只访问可用工具摘要和禁用入口,无需初始化完整游戏项目。 + std::fs::create_dir_all(project.path().join(".agent")).unwrap(); + std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); + let bridge = + super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) + .await + .unwrap(); + for enabled in [false, true, false, true] { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + enabled, + ) + .unwrap(); + // 同一个 MCP 服务重复 tools/list,同时覆盖原生函数永久缓存的切换。 + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "agc_cocos_execute"), + enabled + ); + assert_eq!( + crate::agent_native_tools::native_runtime_function_name( + crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME + ) + .is_some(), + enabled + ); + if !enabled { + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + call_agc_cocos_execute(&json!({"code":"return 1;"})), + ) + .await; + assert_eq!(response["isError"], true); + assert!(response.to_string().contains("插件已禁用")); + } + // 子进程没有真实 AppData,必须只从绑定宿主获取可用性。 + let url = bridge.url().to_string(); + let result = tokio::task::spawn_blocking(move || { + std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "agent::direct_tools_mcp::tests::builtin_mcp_process_probe", + "--nocapture", + ]) + .env(DIRECT_TOOL_BRIDGE_URL_ENV, url) + .env("AGC_MCP_TEST_COCOS_EXPECTED", enabled.to_string()) + .output() + .unwrap() + }) + .await + .unwrap(); + assert!( + result.status.success(), + "{} {}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert!( + String::from_utf8_lossy(&result.stdout).contains("1 passed"), + "child probe must run" + ); + } + std::fs::write(config.path().join("extensions/builtin-plugins.json"), "{").unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + assert!(!specs.to_string().contains("agc_cocos_execute")); + drop(bridge); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope( + "http://127.0.0.1:1/tool-unavailable".to_string(), + direct_tools_mcp_specs(), + ) + .await; + assert!(!specs.to_string().contains("agc_cocos_execute")); + } use std::io::{Read, Write}; #[tokio::test] @@ -1872,7 +2010,7 @@ mod tests { DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024, "MCP request envelope must fit the advertised file-write payload" ); - let specs = direct_tools_mcp_specs_for(false); + let specs = direct_tools_mcp_specs_for(false, true); let names = specs["tools"] .as_array() .expect("tool array") @@ -2010,7 +2148,7 @@ mod tests { #[test] fn tool_catalog_adds_controlled_web_search_only_when_enabled() { - let specs = direct_tools_mcp_specs_for(true); + let specs = direct_tools_mcp_specs_for(true, true); let search = specs["tools"] .as_array() .expect("tool array") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs index d839d8d8a..5cf29088e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs @@ -33,6 +33,14 @@ pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute( detail: None, }; } + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { + return AgentRuntimeToolObservation { + tool: "cocos.editor.execute".to_string(), + status: "failed".to_string(), + summary: "Cocos Creator 插件已禁用".to_string(), + detail: None, + }; + } let response = match cocos_editor_bridge::execute_cocos_editor_code_for_project( root.to_string_lossy().as_ref(), &input.code, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index dc2528401..6a35ce93e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -231,8 +231,10 @@ fn native_runtime_function_name_for_tool(tool: &str) -> String { ) } -fn build_agent_runtime_native_capability_registry() -> Result, String> { - let definitions = agent_runtime_native_executable_tools() +fn build_agent_runtime_native_capability_registry( + tools: Vec<&'static str>, +) -> Result, String> { + let definitions = tools .into_iter() .map(|tool| { CapabilityDefinition::try_new( @@ -256,13 +258,15 @@ fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegis static ENABLED_REGISTRY: OnceLock, String>> = OnceLock::new(); static DISABLED_REGISTRY: OnceLock, String>> = OnceLock::new(); - let cache = if crate::builtin_plugins::cocos_editor_agent_tool_available() { + // 缓存选择与构建消费同一份快照,避免开关变化污染另一份永久缓存。 + let tools = agent_runtime_native_executable_tools(); + let cache = if tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME) { &ENABLED_REGISTRY } else { &DISABLED_REGISTRY }; cache - .get_or_init(build_agent_runtime_native_capability_registry) + .get_or_init(|| build_agent_runtime_native_capability_registry(tools)) .as_ref() .map_err(Clone::clone) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index 2107b78c1..e30fb0268 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -92,32 +92,41 @@ pub(crate) fn initialize(config_dir: &Path) -> Result<(), String> { mark_fail_closed(Some(path)); return Err(format!("准备内置插件目录失败:{error}")); } - let loaded = match fs::read(&path) { - Ok(bytes) => match serde_json::from_slice::(&bytes) { - Ok(file) if file.schema_version.as_deref() == Some(STATE_SCHEMA_VERSION) => file, - Ok(_) => { - mark_fail_closed(Some(path)); - return Err(format!( - "内置插件开关文件版本不受支持,需要 {STATE_SCHEMA_VERSION}" - )); - } - Err(error) => { - mark_fail_closed(Some(path)); - return Err(format!("内置插件开关文件无效:{error}")); - } - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - BuiltinPluginStateFile::default() - } - Err(error) => { - mark_fail_closed(Some(path)); - return Err(format!("读取内置插件开关失败:{error}")); - } - }; let mut guard = state() .lock() .map_err(|_| "内置插件状态锁已损坏".to_string())?; - guard.path = Some(path); + guard.path = Some(path.clone()); + reload_state(&mut guard, &path) +} + +fn read_state_file(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(file) if file.schema_version.as_deref() == Some(STATE_SCHEMA_VERSION) => Ok(file), + Ok(_) => Err(format!( + "内置插件开关文件版本不受支持,需要 {STATE_SCHEMA_VERSION}" + )), + Err(error) => Err(format!("内置插件开关文件无效:{error}")), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(BuiltinPluginStateFile::default()) + } + Err(error) => Err(format!("读取内置插件开关失败:{error}")), + } +} + +fn reload_state(guard: &mut BuiltinPluginState, path: &Path) -> Result<(), String> { + let loaded = match read_state_file(path) { + Ok(loaded) => loaded, + Err(error) => { + guard.fail_closed = true; + guard.enabled = BUILTIN_PLUGINS + .iter() + .map(|plugin| (plugin.id().to_string(), false)) + .collect(); + return Err(error); + } + }; guard.fail_closed = false; // 只接受登记表里的 id,避免坏文件把未知对象带进运行时。 guard.enabled = loaded @@ -143,9 +152,23 @@ pub(crate) fn is_enabled(id: &str) -> bool { let Some(plugin) = builtin_plugin(id) else { return false; }; + // Runner/CLI 已绑定配置根,但不会执行 GUI setup;不推断隔离子进程的 AppData。 + let config_dir = crate::game_creator_runtime_config_dir_lock() + .lock() + .ok() + .and_then(|path| path.clone()); state() .lock() - .map(|guard| { + .map(|mut guard| { + let Some(path) = guard + .path + .clone() + .or_else(|| config_dir.map(|root| root.join("extensions").join(STATE_FILE_NAME))) + else { + return false; + }; + // 每次查询读取持久化权威,使运行中的其它进程立即感知开关变化。 + let _ = reload_state(&mut guard, &path); if guard.fail_closed { return false; } @@ -171,6 +194,12 @@ pub(crate) fn set_enabled(id: &str, enabled: bool) -> Result { let mut guard = state() .lock() .map_err(|_| "内置插件状态锁已损坏".to_string())?; + let path = guard + .path + .clone() + .ok_or_else(|| "内置插件开关尚未初始化".to_string())?; + // 保留其它进程刚写入的开关;损坏文件按全部禁用起步,允许用户显式修复。 + let _ = reload_state(&mut guard, &path); let previous = guard.enabled.get(plugin.id()).copied(); guard.enabled.insert(plugin.id().to_string(), enabled); if let Err(error) = persist(&guard) { @@ -184,6 +213,7 @@ pub(crate) fn set_enabled(id: &str, enabled: bool) -> Result { } return Err(error); } + guard.fail_closed = false; Ok(enabled) } @@ -215,12 +245,23 @@ pub(crate) fn cocos_editor_agent_tool_available() -> bool { agent_tool_available(BuiltinPlugin::CocosEditor) } +pub(crate) fn available_agent_tools() -> Vec<&'static str> { + if cocos_editor_agent_tool_available() { + vec![AGC_COCOS_EDITOR_TOOL_NAME] + } else { + Vec::new() + } +} + +#[cfg(test)] +pub(crate) use tests::test_lock; + #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; - fn test_lock() -> std::sync::MutexGuard<'static, ()> { + pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() @@ -273,6 +314,76 @@ mod tests { assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(false)); } + #[test] + fn enabling_after_corruption_recovers_without_reinitializing() { + let _guard = test_lock(); + let directory = tempdir().unwrap(); + fs::create_dir_all(directory.path().join("extensions")).unwrap(); + fs::write( + directory.path().join("extensions/builtin-plugins.json"), + "{", + ) + .unwrap(); + assert!(initialize(directory.path()).is_err()); + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap(); + assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID)); + } + + #[test] + fn availability_reloads_changes_written_by_another_process() { + let _guard = test_lock(); + let directory = tempdir().unwrap(); + initialize(directory.path()).unwrap(); + let path = directory.path().join("extensions/builtin-plugins.json"); + for enabled in [false, true, false] { + fs::write( + &path, + serde_json::json!({ + "schemaVersion": STATE_SCHEMA_VERSION, + "enabled": {AGC_COCOS_EDITOR_PLUGIN_ID: enabled} + }) + .to_string(), + ) + .unwrap(); + assert_eq!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID), enabled); + } + fs::write(&path, "{").unwrap(); + assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID)); + } + + #[test] + fn runner_process_reads_disabled_state_without_gui_setup() { + let _guard = test_lock(); + let directory = tempdir().unwrap(); + initialize(directory.path()).unwrap(); + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "builtin_plugins::tests::runner_process_probe", + "--nocapture", + ]) + .env("AGC_BUILTIN_TEST_CONFIG_DIR", directory.path()) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stdout) + ); + assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed")); + } + + #[test] + fn runner_process_probe() { + let Some(config_dir) = std::env::var_os("AGC_BUILTIN_TEST_CONFIG_DIR") else { + return; + }; + // Runner/CLI 只绑定配置根目录,不进入 Tauri GUI setup。 + crate::set_game_creator_runtime_config_dir(PathBuf::from(config_dir)); + assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID)); + } + #[test] fn unsupported_schema_fails_closed() { let _guard = test_lock(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 451a61ddb..7a7e90b63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -2007,6 +2007,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p #[test] fn builtin_plugin_toggle_controls_availability() { + let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); @@ -2046,7 +2047,9 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p #[test] fn workspace_cocos_plugin_round_trips_editor_rpc() { + let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); + crate::builtin_plugins::initialize(directory.path()).expect("builtin state"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f52faf5b0..889e4a4fb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8224,6 +8224,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:`plugins/` 工作区里的插件按内置插件处理,随客户端分发、不能卸载或删除;同名 AppData 导入插件不覆盖内置定义。内置插件在 `PluginSummary` / `AgcExtensionSummary` 里带 `builtin`,前端只显示可用开关。 - 决策:唯一开关入口为 `set_agc_plugin_enabled`,只接受登记过的内置插件 id,状态持久化到 AppData `extensions/builtin-plugins.json`(`schemaVersion = agc.builtin-plugins.v1`);文件缺失按 manifest `enabled` 处理,坏文件失败关闭。 +- 决策:GUI、Runner 和 CLI 查询及执行均读取当前持久化开关;隔离 MCP 通过已有工具桥查询可用工具,不读取真实 AppData。开关纳入 app-server pool identity,后续回合重建目录;已发出的模型上下文不回撤,执行入口实时拒绝禁用能力。坏文件被用户成功保存为有效开关后立即恢复。 +- 决策:插件适配器对 execute 的 `ExecutionUncertain` 返回结构化 `needs-reconciliation / retryAllowed=false`,保留独立于连接与插件进程的阻断状态;插件入口拒绝并发 execute,并在宿主超时或断线后停止后续发送。用户核对编辑器后才能重启宿主恢复;发送前失败不阻断后续修正调用。 - 决策:禁用时先停止运行中的插件进程并让 `start_agc_plugin` 失败;同时把对应 Runtime 工具从 `agent_runtime_executable_tools()` 移除,使其不再进入工具策略快照、原生函数目录和系统提示词工具目录,DirectProject 的 `agc_tools` 规格与 bridge 执行入口同步拒绝。启用后立即恢复,不需要重启客户端。 - 边界:导入扩展的启用状态仍走既有 `set_client_extension_enabled` 和扩展索引,不并入内置插件开关文件;内置插件开关不改变 manifest、权限或审计协议。 - 验证:`builtin_plugins` 单测覆盖默认值、持久化往返、坏文件失败关闭和“禁用后工具目录不再出现该工具”;`plugin_host` 单测覆盖禁用后不能启动、导入 id 被拒绝、启用后回到 stopped。 diff --git a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md index e29ae9e2e..511db1b39 100644 --- a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md +++ b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md @@ -64,6 +64,8 @@ DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` Rust 客户端在写入前通过 `GetNamedPipeServerProcessId` 验证 pipe 属于目标 PID,读写使用 overlapped I/O 和 deadline。execute 开始写入后遇到断线、超时或无可信回执,返回 `ExecutionUncertain`,Runtime 进入 `needs-reconciliation`,不得自动重放。客户端超时不等于 JavaScript 已取消,bootstrap 保持同一串行队列直到原执行结束;同步死循环仍可能阻塞 Creator,需要真实集成阶段提供运行时中断方案。 +插件 `EditorAdapter` 将执行结果不确定保留为结构化 `status=needs-reconciliation / retryAllowed=false / ok=false`,并在适配器实例中阻断后续 execute;断开连接、重新连接、插件进程重载或切换项目均不清除此阻断。必须由用户核对编辑器状态后重启客户端,不能自动恢复或重试。发送前的校验/连接失败仍为可修正的普通失败。插件入口同样在宿主 RPC 超时、断线或收到不确定结果时停止发送后续 execute,并保留结构化状态。 + ## 安全与失败关闭 - PowerShell 查询脚本为固定常量,用户输入不拼接进 shell。 diff --git a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md index 80a3234ec..b45644b78 100644 --- a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md +++ b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md @@ -76,6 +76,8 @@ OpenAI 的标准模型是“Plugin 作为可安装包,组合 Skills、可选 M 唯一的开关入口是 Tauri 命令 `set_agc_plugin_enabled`,它只接受登记过的内置插件 id;导入扩展继续使用既有 `set_client_extension_enabled`。 +开关文件是跨进程权威:GUI、Runner 与 CLI 在查询和执行时读取其绑定 AppData 下的当前文件;未知配置根、文件损坏或版本不支持时关闭能力,成功保存有效开关后立即恢复。隔离的 MCP 子进程通过现有客户端工具桥查询可用工具,不获得 AppData 路径或目录权限;查询失败按空插件工具集处理。开关变化进入 app-server pool identity,使后续回合重建工具目录;已发给模型的上下文不回撤,执行入口仍实时拒绝已禁用工具。 + ## 运行和 RPC 宿主以已安装插件目录为 cwd 启动入口;JavaScript 入口使用系统 `node` 执行,其它入口直接执行。环境先清空,再保留 PATH、Windows 系统目录和临时目录等必要变量,并注入插件身份和协议版本;不继承客户端凭据。Windows 复用进程模块的 Job Object,Unix 使用独立进程组,停止/卸载时回收自有进程。 diff --git a/plugins/agc-cocos-editor/README.md b/plugins/agc-cocos-editor/README.md index adb9df1f0..00ffc243f 100644 --- a/plugins/agc-cocos-editor/README.md +++ b/plugins/agc-cocos-editor/README.md @@ -29,6 +29,10 @@ native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pip `execute`、`inject`,与 native 适配器的 `COCOS_EDITOR_RPC_METHODS` 一一对应; `src/entry.test.mjs` 会校验两边不会漂移。 +execute 不接受并发积压。结果不确定时返回 `needs-reconciliation` 与 +`retryAllowed: false` 并阻止后续发送;native 适配器的阻断不会被 disconnect +或插件进程重载清除。请先核对编辑器状态,再重启客户端恢复。 + ## 项目上下文 插件从宿主获得当前受控项目路径: diff --git a/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/adapter.rs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/adapter.rs index 636b76064..7e49ba15a 100644 --- a/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/adapter.rs +++ b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/adapter.rs @@ -84,6 +84,8 @@ impl AdapterRpcParams { pub struct CocosEditorAdapter { payload_candidates: Vec, connection: Mutex>, + // 独立于连接/插件进程生命周期,未知执行结果只能在人工核对后重启宿主恢复。 + execution_uncertain: Mutex, } impl Default for CocosEditorAdapter { @@ -97,6 +99,7 @@ impl CocosEditorAdapter { Self { payload_candidates, connection: Mutex::new(None), + execution_uncertain: Mutex::new(false), } } @@ -180,7 +183,8 @@ impl CocosEditorAdapter { let code = params.code.clone().ok_or_else(|| "缺少 code".to_string())?; validate_execute_code(&code).map_err(|error| error.to_string())?; let timeout_ms = params.timeout_ms(); - let response = match self.project_connection(&project_path)? { + let connection = self.project_connection(&project_path)?; + self.execute_with(|| match connection { Some(connection) => execute_cocos_editor_code( connection.process_id, &connection.project_path, @@ -188,9 +192,33 @@ impl CocosEditorAdapter { timeout_ms, ), None => execute_cocos_editor_code_for_project(&project_path, &code, timeout_ms), + }) + } + + fn execute_with( + &self, + execute: impl FnOnce() -> Result, + ) -> Result { + let mut uncertain = self + .execution_uncertain + .lock() + .map_err(|_| "Cocos 执行状态不可用,执行结果需要核对".to_string())?; + if *uncertain { + return Ok( + json!({"ok": false, "status": "needs-reconciliation", "retryAllowed": false, + "error": "先前 Cocos execute 结果待核对,当前适配器不再发送执行命令"}), + ); + } + // 持锁串行执行,后续调用必须先观察前一次是否产生不确定结果。 + match execute() { + Ok(response) => serde_json::to_value(response).map_err(|error| error.to_string()), + Err(error) => { + *uncertain = matches!(&error, crate::BridgeError::ExecutionUncertain(_)); + Ok(json!({"ok": false, + "status": if *uncertain { "needs-reconciliation" } else { "failed" }, + "retryAllowed": !*uncertain, "error": error.to_string()})) + } } - .map_err(|error| error.to_string())?; - serde_json::to_value(response).map_err(|error| error.to_string()) } fn rpc_inject(&self, params: &AdapterRpcParams) -> Result { @@ -347,6 +375,37 @@ impl EditorAdapter for CocosEditorAdapter { mod tests { use super::*; + #[test] + fn uncertain_execute_blocks_subsequent_dispatch_even_after_disconnect() { + let mut adapter = CocosEditorAdapter::default(); + let result = adapter + .execute_with(|| { + Err(crate::BridgeError::ExecutionUncertain( + "timeout".to_string(), + )) + }) + .unwrap(); + assert_eq!(result["status"], "needs-reconciliation"); + assert_eq!(result["retryAllowed"], false); + adapter.disconnect(); + let result = adapter + .execute_with(|| panic!("must not dispatch again")) + .unwrap(); + assert_eq!(result["retryAllowed"], false); + } + + #[test] + fn pre_dispatch_errors_do_not_latch_reconciliation() { + let adapter = CocosEditorAdapter::default(); + for _ in 0..2 { + let result = adapter + .execute_with(|| Err(crate::BridgeError::TargetNotFound(42))) + .unwrap(); + assert_eq!(result["status"], "failed"); + assert_eq!(result["retryAllowed"], true); + } + } + #[test] fn adapter_id_matches_plugin_manifest_adapter() { let adapter = CocosEditorAdapter::default(); diff --git a/plugins/agc-cocos-editor/src/entry.mjs b/plugins/agc-cocos-editor/src/entry.mjs index 8d358ecfa..ab911b06b 100644 --- a/plugins/agc-cocos-editor/src/entry.mjs +++ b/plugins/agc-cocos-editor/src/entry.mjs @@ -40,6 +40,8 @@ export function createCocosEditorPlugin({ let nextId = 1; let activeProjectPath = null; let disposed = false; + let executionUncertain = false; + let executionPending = false; const pending = new Map(); const handlers = new Map([ @@ -89,14 +91,41 @@ export function createCocosEditorPlugin({ async function handleExecute(params) { const code = params?.code; validateExecuteCode(code); - const response = await callEditor('execute', { - projectPath: resolveProjectPath(params), - code, - }); - return { - status: response?.ok ? 'completed' : 'failed', + const projectPath = resolveProjectPath(params); + const reconcile = (response) => ({ + status: 'needs-reconciliation', + retryAllowed: false, response, - }; + }); + if (executionUncertain) + return reconcile({ ok: false, error: '先前执行结果待核对' }); + // 不积压稍后执行的 mutation,避免调用方超时后请求仍从队列发出。 + if (executionPending) + return { + status: 'failed', + retryAllowed: false, + response: { + ok: false, + error: '已有 Cocos execute 正在执行,请等待回执', + }, + }; + executionPending = true; + try { + const response = await callEditor('execute', { projectPath, code }); + if (response?.status === 'needs-reconciliation') { + executionUncertain = true; + return reconcile(response); + } + if (typeof response?.ok !== 'boolean') + throw new Error('宿主缺少可信执行回执'); + return { status: response.ok ? 'completed' : 'failed', response }; + } catch (error) { + // 已交给宿主的 execute 超时/断线不能推断为未执行。 + executionUncertain = true; + return reconcile({ ok: false, error: error.message }); + } finally { + executionPending = false; + } } async function handleConnection(params) { @@ -104,6 +133,7 @@ export function createCocosEditorPlugin({ if (!COCOS_EDITOR_OPERATIONS.includes(operation)) { throw new Error(`不支持的能力操作:${operation}`); } + if (operation === 'execute') return handleExecute(params); if (operation === 'disconnect') { return callEditor('disconnect', {}); } diff --git a/plugins/agc-cocos-editor/src/entry.test.mjs b/plugins/agc-cocos-editor/src/entry.test.mjs index 9efe0eb46..4894143b3 100644 --- a/plugins/agc-cocos-editor/src/entry.test.mjs +++ b/plugins/agc-cocos-editor/src/entry.test.mjs @@ -23,9 +23,10 @@ const pluginRoot = path.join(here, '..'); const tick = () => new Promise((resolve) => setImmediate(resolve)); -function createHarness() { +function createHarness(timeoutMs) { const outbound = []; const plugin = createCocosEditorPlugin({ + timeoutMs, send: (message) => outbound.push(structuredClone(message)), }); const respond = (id, result) => @@ -161,6 +162,111 @@ test('project.changed event updates the cached project path', async () => { assert.equal(harness.plugin.activeProjectPath, null); }); +test('execute rejects concurrent requests and blocks later requests after uncertainty', async () => { + const harness = createHarness(); + await startPlugin(harness); + const first = harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 71, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 1;' }, + }); + const second = harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 72, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 2;' }, + }); + await tick(); + const requests = harness.outbound.filter( + (item) => item.method === 'host.rpc', + ); + assert.equal(requests.length, 1); + await harness.respond(requests[0].id, { + ok: false, + status: 'needs-reconciliation', + retryAllowed: false, + }); + await Promise.all([first, second]); + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 73, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 3;' }, + }); + assert.equal( + harness.outbound.filter((item) => item.method === 'host.rpc').length, + 1, + ); + assert.equal( + harness.outbound.find((item) => item.id === 72 && item.result).result + .retryAllowed, + false, + ); + for (const id of [71, 73]) { + const reply = harness.outbound.find( + (item) => item.id === id && item.result, + ); + assert.equal(reply.result.status, 'needs-reconciliation'); + assert.equal(reply.result.retryAllowed, false); + } +}); + +test('host RPC failure blocks later execute without resending', async () => { + const harness = createHarness(); + await startPlugin(harness); + const first = harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 81, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 1;' }, + }); + await tick(); + const rpc = harness.outbound.at(-1); + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: rpc.id, + error: { message: 'connection closed' }, + }); + await first; + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 82, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 2;' }, + }); + assert.equal( + harness.outbound.filter((item) => item.method === 'host.rpc').length, + 1, + ); + assert.equal(harness.outbound.at(-1).result.retryAllowed, false); +}); + +test('execute timeout keeps later requests blocked even after a late success', async () => { + const harness = createHarness(100); + await startPlugin(harness); + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 91, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 1;' }, + }); + const rpc = harness.outbound.find((item) => item.method === 'host.rpc'); + assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation'); + await harness.respond(rpc.id, { ok: true }); + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 92, + method: COCOS_CONNECTION_CAPABILITY_ID, + params: { operation: 'execute', code: 'return 2;' }, + }); + assert.equal( + harness.outbound.filter((item) => item.method === 'host.rpc').length, + 1, + ); + assert.equal(harness.outbound.at(-1).result.retryAllowed, false); +}); + test('adapter request builder enforces per-operation parameters', () => { assert.deepEqual( buildEditorRpcRequest('status', {