From 9d2ad69a75b03402ee1aad945e8361aaf368032d Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 10 Sep 2026 14:37:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AGC=20Cocos=20=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立 crate server-rs/crates/cocos-editor-bridge:Creator 主进程发现与项目/PID 身份校验、named pipe 命令协议、可选 Windows DLL 注入,默认 feature 全部关闭。 新增 Creator 主进程内 payload:在 \\.\pipe\genarrative-cocos-editor-{pid} 上串行提供 ping/status/execute,execute 只接受有界 JavaScript 函数体,结果不确定时返回 ExecutionUncertain 并禁止自动重放。 AGC 新增 cocos_editor 模块与 prepare_cocos_editor_injection、inject_cocos_editor、ping_cocos_editor、status_cocos_editor、execute_cocos_editor_code 五条命令,注入只加载随包资源目录中的 DLL。 AGC 新增 cocos-editor、cocos-editor-execute、cocos-editor-injection 三层 feature 与资源映射,Windows 构建把 bridge DLL 暂存到 resources/cocos-editor-bridge,并改为按 Cargo profile 目录查找产物 DLL。 Runtime 新增 cocos.editor.execute:同步工具策略快照、并行账本、隔离 Agent 拒绝表、工具描述与动作审计,审计只记录代码长度与 SHA-256。 DirectProject 的 agc_tools 新增 agc_cocos_execute,只接收 code,复用项目写锁和当前项目权限,结果不确定时阻断后续调用。 共享契约新增 cocos.editor.execute(confirm 权限),当前分支命令契约从 64 条更新为 65 条。 新增技术方案文档与决策记录,并在文档索引登记;resources/cocos-editor-bridge 仅提交占位与 .gitignore,生成的 DLL 不入库。 移植到 codex/agc-agent-plugins 的适配:命令契约长度、Tauri 未调用命令 allowlist 与 Windows 资源清单校验随当前分支基线同步更新。 --- .../scripts/check-config.mjs | 12 +- .../src-tauri/Cargo.lock | 12 + .../src-tauri/Cargo.toml | 4 + apps/ai-game-creator-shell/src-tauri/build.rs | 30 + .../resources/cocos-editor-bridge/.gitignore | 1 + .../resources/cocos-editor-bridge/.gitkeep | 0 .../src-tauri/src/agent/direct_tool_bridge.rs | 67 + .../src-tauri/src/agent/direct_tools_mcp.rs | 29 + .../src/agent/runtime_actions/action_audit.rs | 11 + .../agent/runtime_actions/action_execution.rs | 10 + .../agent/runtime_actions/parallel_ledger.rs | 2 + .../runtime_actions/tool_policy_snapshot.rs | 8 +- .../src-tauri/src/agent/runtime_tools.rs | 2 + .../src/agent/runtime_tools/cocos_editor.rs | 109 ++ .../src-tauri/src/agent_native_tools.rs | 11 + .../src-tauri/src/cocos_editor.rs | 172 +++ .../src-tauri/src/isolated_agent.rs | 2 + .../src-tauri/src/main.rs | 9 +- .../src-tauri/tauri.windows.conf.json | 3 +- docs/README.md | 1 + .../shared-memory/decision-log.md | 8 + ...AGC Cocos Creator 编辑器桥接模块-2026-09-09.md | 95 ++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- .../src/contracts/gameCreationApp.test.ts | 3 +- .../shared/src/contracts/gameCreationApp.ts | 1 + server-rs/Cargo.toml | 1 + .../crates/cocos-editor-bridge/.gitignore | 2 + .../crates/cocos-editor-bridge/Cargo.toml | 46 + server-rs/crates/cocos-editor-bridge/build.rs | 14 + .../native/native_payload.cpp | 171 +++ .../cocos-editor-bridge/payload/bootstrap.cjs | 224 +++ .../payload/bootstrap.test.cjs | 76 + .../crates/cocos-editor-bridge/src/lib.rs | 1259 +++++++++++++++++ .../shared-contracts/src/game_creation_app.rs | 5 +- 34 files changed, 2391 insertions(+), 11 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/cocos_editor.rs create mode 100644 docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md create mode 100644 server-rs/crates/cocos-editor-bridge/.gitignore create mode 100644 server-rs/crates/cocos-editor-bridge/Cargo.toml create mode 100644 server-rs/crates/cocos-editor-bridge/build.rs create mode 100644 server-rs/crates/cocos-editor-bridge/native/native_payload.cpp create mode 100644 server-rs/crates/cocos-editor-bridge/payload/bootstrap.cjs create mode 100644 server-rs/crates/cocos-editor-bridge/payload/bootstrap.test.cjs create mode 100644 server-rs/crates/cocos-editor-bridge/src/lib.rs diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 36d2e3bf1..041c69e1b 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -126,6 +126,11 @@ const allowedUncalledTauriCommands = [ 'call_agc_plugin', 'read_agc_plugin_panel', 'set_agc_plugin_project_path', + 'prepare_cocos_editor_injection', + 'ping_cocos_editor', + 'status_cocos_editor', + 'execute_cocos_editor_code', + 'inject_cocos_editor', ]; const sourceExtensions = new Set([ '.json', @@ -1294,7 +1299,7 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') { throw new Error('AI game creator shell identifier drifted'); } -const expectedBundledCodexResources = { +const expectedBundledWindowsResources = { 'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe', 'resources/codex/win-x64/bin/codex-code-mode-host.exe': 'codex/win-x64/bin/codex-code-mode-host.exe', @@ -1308,6 +1313,7 @@ const expectedBundledCodexResources = { 'codex/win-x64/codex-package.json', 'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md', 'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json', + 'resources/cocos-editor-bridge': 'cocos-editor-bridge', }; if (tauriConfig.bundle?.resources !== undefined) { throw new Error( @@ -1316,8 +1322,8 @@ if (tauriConfig.bundle?.resources !== undefined) { } assert.deepEqual( windowsTauriConfig.bundle?.resources, - expectedBundledCodexResources, - 'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set', + expectedBundledWindowsResources, + 'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory', ); if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) { throw new Error( diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index f4ca27d53..7a2344a9e 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -733,6 +733,17 @@ dependencies = [ "error-code", ] +[[package]] +name = "cocos-editor-bridge" +version = "0.1.0" +dependencies = [ + "cc", + "serde", + "serde_json", + "sha2", + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -1709,6 +1720,7 @@ dependencies = [ "axum", "base64 0.22.1", "chromiumoxide", + "cocos-editor-bridge", "futures", "getrandom 0.3.4", "http", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 7aa0e1cb4..7197ad8a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -6,6 +6,9 @@ publish = false [features] default = [] +cocos-editor = ["cocos-editor-bridge/process-discovery"] +cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-transport"] +cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"] [build-dependencies] serde = { version = "1", features = ["derive"] } @@ -19,6 +22,7 @@ ts-rs = "12.0.1" typed_floats = { version = "1.0.7", features = ["serde"] } nalgebra = { version = "0.35.0", features = ["serde-serialize"] } agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } +cocos-editor-bridge = { path = "../../../server-rs/crates/cocos-editor-bridge", default-features = false } base64 = "0.22" axum = "0.8" chromiumoxide = "0.9.1" diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index d63faf051..45dbb5911 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -182,6 +182,7 @@ fn main() { ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); stage_bundled_codex_cli(&manifest_dir); + stage_cocos_editor_payload(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); validate_seed_task_catalog(&compiled); @@ -203,3 +204,32 @@ fn main() { } tauri_build::build() } + +#[cfg(windows)] +fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) { + if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() { + return; + } + let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); + let profile_dir = out_dir + .ancestors() + .find(|path| path.file_name().is_some_and(|name| name == "build")) + .and_then(|build_dir| build_dir.parent()) + .expect("AGC Cargo profile directory not found"); + let candidates = [ + profile_dir.join("deps/cocos_editor_bridge.dll"), + profile_dir.join("cocos_editor_bridge.dll"), + ]; + let source = candidates + .iter() + .find(|path| path.is_file()) + .unwrap_or_else(|| panic!("Cocos bridge native payload 未构建:{}", candidates.iter().map(|p| p.display().to_string()).collect::>().join(";"))); + let destination = manifest_dir.join("resources/cocos-editor-bridge/cocos-editor-bridge.dll"); + std::fs::create_dir_all(destination.parent().expect("payload resource parent")) + .expect("创建 Cocos bridge 资源目录失败"); + std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败"); + println!("cargo:rerun-if-changed={}", source.display()); +} + +#[cfg(not(windows))] +fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore new file mode 100644 index 000000000..6a7461313 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore @@ -0,0 +1 @@ +*.dll diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep new file mode 100644 index 000000000..e69de29bb 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 bdbeb222c..b5cd2ae74 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 @@ -54,6 +54,8 @@ struct DirectToolBridgeState { regeneration_gate: tokio::sync::Mutex<()>, resource_generation_gate: tokio::sync::Mutex<()>, image_generation_gate: tokio::sync::Mutex<()>, + #[cfg(all(windows, feature = "cocos-editor-execute"))] + cocos_execute_uncertain: tokio::sync::Mutex, } #[derive(Default)] @@ -687,6 +689,8 @@ fn direct_tool_bridge_state_with_search( regeneration_gate: tokio::sync::Mutex::new(()), resource_generation_gate: tokio::sync::Mutex::new(()), image_generation_gate: tokio::sync::Mutex::new(()), + #[cfg(all(windows, feature = "cocos-editor-execute"))] + cocos_execute_uncertain: tokio::sync::Mutex::new(false), }) } @@ -2295,6 +2299,67 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str) } } +#[cfg(all(windows, feature = "cocos-editor-execute"))] +async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { + let prepared = (|| { + bridge_reject_unknown_fields(arguments, &["code"])?; + enforce_project_permission_policy(&state.root, "cocos.editor.execute")?; + let code = arguments.get("code").and_then(Value::as_str) + .ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?; + cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string())?; + Ok::<_, String>(code.to_string()) + })(); + let code = match prepared { + Ok(code) => code, + Err(error) => return bridge_tool_result( + redact_agent_runtime_error(&state.root, &error, 480), Vec::new(), true, + ), + }; + let mut uncertain = state.cocos_execute_uncertain.lock().await; + if *uncertain { + return bridge_tool_result(json!({ + "status": "needs-reconciliation", "retryAllowed": false, + "message": "先前 Cocos execute 结果待核对,当前 bridge 不再发送执行命令" + }).to_string(), Vec::new(), true); + } + let root = state.root.clone(); + let result = tokio::task::spawn_blocking(move || { + let _lock = acquire_project_write_lock(&root, "direct-cocos.execute") + .map_err(cocos_editor_bridge::BridgeError::InvalidInput)?; + cocos_editor_bridge::execute_cocos_editor_code_for_project( + root.to_string_lossy().as_ref(), &code, cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS, + ) + }).await; + match result { + Ok(Ok(response)) => { + let is_error = !response.ok; + let text = json!({ + "status": if response.ok { "completed" } else { "failed" }, + "requestId": response.request_id, + "result": response.result, + "error": response.error, + }).to_string(); + bridge_tool_result(redact_agent_runtime_project_paths(&state.root, &text, 32_000), Vec::new(), is_error) + } + failed => { + let (is_uncertain, error) = match failed { + Ok(Err(error)) => ( + matches!(&error, cocos_editor_bridge::BridgeError::ExecutionUncertain(_)), + error.to_string(), + ), + Err(_) => (true, "Cocos execute worker 退出,执行结果需要核对".to_string()), + Ok(Ok(_)) => unreachable!(), + }; + *uncertain = is_uncertain; + bridge_tool_result(json!({ + "status": if is_uncertain { "needs-reconciliation" } else { "failed" }, + "retryAllowed": !is_uncertain, + "message": redact_agent_runtime_error(&state.root, &error, 480), + }).to_string(), Vec::new(), true) + } + } +} + async fn handle_direct_tool_bridge( State(state): State>, Json(request): Json, @@ -2308,6 +2373,8 @@ async fn handle_direct_tool_bridge( } "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), "agc_write_file" => bridge_write_file(&state.root, &request.arguments), + #[cfg(all(windows, feature = "cocos-editor-execute"))] + "agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments).await, "agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await, "agc_import_account_assets" => { bridge_import_account_assets(&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 3dde7a64a..9db357363 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 @@ -431,6 +431,17 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), ]; let mut tools = tools; + #[cfg(all(windows, feature = "cocos-editor-execute"))] + tools.push(json!({ + "name": "agc_cocos_execute", + "description": "在当前项目已连接的 Cocos Creator 主进程执行 JavaScript 函数体,支持 await 和 return。宿主绑定项目和目标进程,只提交 code。结果待核对或超时后禁止自动重发;使用 Editor.Message 调用 Creator API。", + "inputSchema": { + "type": "object", + "properties": { "code": { "type": "string", "minLength": 1, "maxLength": cocos_editor_bridge::MAX_EXECUTE_CODE_BYTES } }, + "required": ["code"], + "additionalProperties": false + } + })); if controlled_web_search { tools.push(json!({ "name": "agc_web_search", @@ -503,6 +514,19 @@ fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> { normalize_relative_path(&path).map(|_| ()) } +#[cfg(all(windows, feature = "cocos-editor-execute"))] +async fn call_agc_cocos_execute(arguments: &Value) -> Value { + let validated = validate_tool_object_fields(arguments, &["code"]).and_then(|()| { + let code = arguments.get("code").and_then(Value::as_str) + .ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?; + cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string()) + }); + if let Err(error) = validated { + return mcp_tool_result(error, Vec::new(), true); + } + call_client_tool_bridge("agc_cocos_execute", arguments).await +} + fn mcp_success(id: Value, result: Value) -> Value { json!({ "jsonrpc": "2.0", "id": id, "result": result }) } @@ -1575,6 +1599,8 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< "conversation.read" => external_mcp_conversation_read(root, &arguments), "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), "agc_write_file" => call_agc_write_file(&arguments).await, + #[cfg(all(windows, feature = "cocos-editor-execute"))] + "agc_cocos_execute" => call_agc_cocos_execute(&arguments).await, "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await, "agc_edit_image" => call_agc_edit_image(&arguments).await, @@ -1869,6 +1895,9 @@ mod tests { "agc_remove_background", "agc_browser_playtest", ] + .into_iter() + .chain(cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute")) + .collect::>() ); let serialized = specs.to_string(); assert!(!serialized.contains("agc_web_search")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 235177a31..5c9afc1a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -1606,6 +1606,17 @@ pub(crate) fn agent_runtime_tool_action_input_summary( .unwrap_or(160) ), "command.run_limited" => format!("commandId={}", text(&["commandId", "command_id", "id"])), + "cocos.editor.execute" => format!( + "codeChars={} · codeSha256={:x}", + chars(&["code"]), + Sha256::digest( + input + .get("code") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .as_bytes() + ) + ), "preview.validate" => { let viewports = input .get("viewports") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 23fd5d8e4..620aec6df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -362,6 +362,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ observe_agent_runtime_limited_command(root, agent_id, run_id, &action.input) } "preview.start" => observe_agent_runtime_preview_start(root, agent_id, run_id), + "cocos.editor.execute" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + true, + || observe_agent_runtime_cocos_editor_execute(root, action, pending_action), + ), "preview.validate" => { observe_agent_runtime_preview_validate( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index 48858c55c..e1fe9f4a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -95,6 +95,8 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "command.stdin" => Some("command.stdin"), "command.terminate" => Some("command.terminate"), "command.run_limited" => Some("command.run_limited"), + #[cfg(feature = "cocos-editor-execute")] + "cocos.editor.execute" => Some("cocos.editor.execute"), "preview.start" => Some("preview.start"), "preview.validate" => Some("preview.validate"), "image.inspect" => Some("image.inspect"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 539eb45d7..25fbc0bbc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -17,7 +17,7 @@ mod canvas_asset_kind_contract_tests { } pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { - vec![ + let tools = vec![ GAME_CREATOR_USER_INPUT_REQUEST_TOOL, "memory.read", "memory.write", @@ -63,7 +63,11 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "agent.schedule_ready", "agent.action_history", "agent.run_status", - ] + ]; + tools + .into_iter() + .chain(cfg!(feature = "cocos-editor-execute").then_some("cocos.editor.execute")) + .collect() } pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 633f5ffe8..6028ea788 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -2,6 +2,7 @@ use super::*; mod action_history; mod command_ops; +mod cocos_editor; mod context; mod delegation; mod delivery; @@ -21,6 +22,7 @@ mod ui_workflow; pub(in crate::agent) use action_history::*; pub(in crate::agent) use command_ops::*; +pub(in crate::agent) use cocos_editor::*; pub(in crate::agent) use context::*; pub(in crate::agent) use delegation::*; pub(in crate::agent) use delivery::*; 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 new file mode 100644 index 000000000..d839d8d8a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs @@ -0,0 +1,109 @@ +use super::*; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CocosEditorExecuteInput { + code: String, +} + +pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute( + root: &Path, + action: &AgentRuntimeToolAction, + pending_action: Option<&AgentRuntimePendingToolAction>, +) -> AgentRuntimeToolObservation { + let input = match serde_json::from_value::(action.input.clone()) { + Ok(input) => input, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "cocos.editor.execute".to_string(), + status: "failed".to_string(), + summary: sanitize_agent_runtime_text( + &format!("cocos.editor.execute 输入无效:{error}"), + 240, + ), + detail: None, + }; + } + }; + if pending_action.is_none() { + return AgentRuntimeToolObservation { + tool: "cocos.editor.execute".to_string(), + status: "failed".to_string(), + summary: "cocos.editor.execute 必须绑定 durable pending action".to_string(), + detail: None, + }; + } + let response = match cocos_editor_bridge::execute_cocos_editor_code_for_project( + root.to_string_lossy().as_ref(), + &input.code, + cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS, + ) { + Ok(response) => response, + Err(error) => { + let uncertain = matches!( + &error, + cocos_editor_bridge::BridgeError::ExecutionUncertain(_) + ); + return AgentRuntimeToolObservation { + tool: "cocos.editor.execute".to_string(), + status: if uncertain { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "failed" + } + .to_string(), + summary: redact_agent_runtime_project_paths(root, &error.to_string(), 240), + detail: Some( + serde_json::json!({ + "codeChars": input.code.chars().count(), + "error": redact_agent_runtime_project_paths(root, &error.to_string(), 500), + }) + .to_string(), + ), + }; + } + }; + let ok = response.ok; + let detail = serde_json::json!({ + "processId": response.process_id, + "requestId": response.request_id, + "ok": ok, + "result": response.result.clone(), + "error": response.error.clone(), + "codeChars": input.code.chars().count(), + }) + .to_string(); + AgentRuntimeToolObservation { + tool: "cocos.editor.execute".to_string(), + status: if ok { "ok" } else { "failed" }.to_string(), + summary: if ok { + format!( + "已在 Cocos Creator 执行 {} 字符代码", + input.code.chars().count() + ) + } else { + "Cocos Creator execute 返回失败".to_string() + }, + detail: Some(detail), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execute_requires_durable_pending_action_before_touching_editor() { + let action = AgentRuntimeToolAction { + tool: "cocos.editor.execute".to_string(), + reason: Some("test".to_string()), + input: serde_json::json!({"code": "return 1 + 1;"}), + }; + let observation = + observe_agent_runtime_cocos_editor_execute(Path::new("C:\\cocos"), &action, None); + assert_eq!(observation.status, "failed"); + assert!(observation + .summary + .contains("必须绑定 durable pending action")); + } +} 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 68ce6d844..17dce95af 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 @@ -1027,6 +1027,9 @@ fn runtime_tool_description(tool: &str) -> &'static str { "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" } + "cocos.editor.execute" => { + "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。" + } "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", "agent.delegate" => { @@ -1216,6 +1219,14 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }), "command.exec" | "command.start" => command_start_input_schema(), + "cocos.editor.execute" => json!({ + "type": "object", + "required": ["code"], + "additionalProperties": false, + "properties": { + "code": { "type": "string", "minLength": 1, "maxLength": 131072 } + } + }), "command.output_read" => json!({ "type": "object", "required": ["actionId", "startLine", "maxLines"], "additionalProperties": false, "properties": { diff --git a/apps/ai-game-creator-shell/src-tauri/src/cocos_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/cocos_editor.rs new file mode 100644 index 000000000..48cca6ab8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/cocos_editor.rs @@ -0,0 +1,172 @@ +#[cfg(feature = "cocos-editor-injection")] +use cocos_editor_bridge::CocosEditorInjectionRequest; +use cocos_editor_bridge::{ + CocosEditorCommandResponse, CocosEditorInjectionResult, CocosEditorProcess, +}; +use serde::Deserialize; +#[cfg(feature = "cocos-editor-injection")] +use tauri::Manager; +#[cfg(feature = "cocos-editor-injection")] +const BUNDLED_COCOS_BRIDGE_PAYLOAD: &str = "cocos-editor-bridge/cocos-editor-bridge.dll"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CocosEditorTargetRequest { + process_id: u32, + project_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CocosEditorCommandInput { + process_id: u32, + project_path: String, + #[serde(default = "default_command_timeout_ms")] + timeout_ms: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CocosEditorInjectionInput { + process_id: u32, + project_path: String, + #[serde(default = "default_injection_timeout_ms")] + timeout_ms: u32, +} + +fn default_command_timeout_ms() -> u32 { + cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS +} + +fn default_injection_timeout_ms() -> u32 { + cocos_editor_bridge::DEFAULT_INJECTION_TIMEOUT_MS +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CocosEditorExecuteInput { + process_id: u32, + project_path: String, + code: String, + #[serde(default = "default_command_timeout_ms")] + timeout_ms: u32, +} + +#[tauri::command] +pub(crate) fn prepare_cocos_editor_injection( + request: CocosEditorTargetRequest, +) -> Result { + #[cfg(feature = "cocos-editor")] + { + return cocos_editor_bridge::validate_injection_target( + request.process_id, + &request.project_path, + ) + .map_err(|error| error.to_string()); + } + #[cfg(not(feature = "cocos-editor"))] + { + let _ = (request.process_id, request.project_path); + Err("Cocos Editor bridge feature 未启用".to_string()) + } +} + +#[tauri::command] +pub(crate) fn ping_cocos_editor( + request: CocosEditorCommandInput, +) -> Result { + #[cfg(feature = "cocos-editor-execute")] + { + return cocos_editor_bridge::ping_cocos_editor( + request.process_id, + &request.project_path, + request.timeout_ms, + ) + .map_err(|error| error.to_string()); + } + #[cfg(not(feature = "cocos-editor-execute"))] + { + let _ = (request.process_id, request.project_path, request.timeout_ms); + Err("Cocos Editor execute feature 未启用".to_string()) + } +} + +#[tauri::command] +pub(crate) fn status_cocos_editor( + request: CocosEditorCommandInput, +) -> Result { + #[cfg(feature = "cocos-editor-execute")] + { + return cocos_editor_bridge::status_cocos_editor( + request.process_id, + &request.project_path, + request.timeout_ms, + ) + .map_err(|error| error.to_string()); + } + #[cfg(not(feature = "cocos-editor-execute"))] + { + let _ = (request.process_id, request.project_path, request.timeout_ms); + Err("Cocos Editor execute feature 未启用".to_string()) + } +} + +#[tauri::command] +pub(crate) fn execute_cocos_editor_code( + request: CocosEditorExecuteInput, +) -> Result { + #[cfg(feature = "cocos-editor-execute")] + { + return cocos_editor_bridge::execute_cocos_editor_code( + request.process_id, + &request.project_path, + &request.code, + request.timeout_ms, + ) + .map_err(|error| error.to_string()); + } + #[cfg(not(feature = "cocos-editor-execute"))] + { + let _ = ( + request.process_id, + request.project_path, + request.code, + request.timeout_ms, + ); + Err("Cocos Editor execute feature 未启用".to_string()) + } +} + +#[tauri::command] +pub(crate) fn inject_cocos_editor( + app: tauri::AppHandle, + input: CocosEditorInjectionInput, +) -> Result { + #[cfg(feature = "cocos-editor-injection")] + { + let payload = app + .path() + .resource_dir() + .map_err(|error| format!("解析 AGC 资源目录失败:{error}"))? + .join(BUNDLED_COCOS_BRIDGE_PAYLOAD); + if !payload.is_file() { + return Err(format!( + "AGC 未随包提供 Cocos bridge payload:{}", + BUNDLED_COCOS_BRIDGE_PAYLOAD + )); + } + let request = CocosEditorInjectionRequest { + process_id: input.process_id, + project_path: input.project_path, + bridge_dll_path: payload.to_string_lossy().into_owned(), + timeout_ms: input.timeout_ms, + }; + return cocos_editor_bridge::inject_bridge_dll(&request).map_err(|error| error.to_string()); + } + #[cfg(not(feature = "cocos-editor-injection"))] + { + let _ = app; + let _ = (input.process_id, input.project_path, input.timeout_ms); + Err("Cocos Editor injection feature 未启用;当前构建只支持目标预检".to_string()) + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index 22b04c37b..2e267b28f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -43,6 +43,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ "command.exec", "command.start", "command.stdin", + "cocos.editor.execute", "preview.start", "agent.delegate", "agent.spawn_isolated", @@ -62,6 +63,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[ "command.exec", "command.start", "command.stdin", + "cocos.editor.execute", "preview.start", "agent.delegate", "agent.spawn_isolated", diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 8f63a5a99..bcb776d7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -251,6 +251,7 @@ mod command_exec; mod command_output; mod command_sandbox; mod command_sandbox_trampoline; +mod cocos_editor; mod commands; mod config; mod context_compaction; @@ -293,6 +294,7 @@ use collaboration::*; use command_exec::*; use command_output::*; use command_sandbox::*; +use cocos_editor::*; use commands::*; use config::*; use context_compaction::*; @@ -2728,7 +2730,12 @@ fn main() { read_diagnostic_logs, report_client_error, get_pending_error_reports, - ack_error_reports + ack_error_reports, + prepare_cocos_editor_injection, + ping_cocos_editor, + status_cocos_editor, + execute_cocos_editor_code, + inject_cocos_editor, ]) .build(tauri_context); let app = match app { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json index ad932ab58..f1b354361 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json @@ -11,7 +11,8 @@ "resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe": "codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe", "resources/codex/win-x64/codex-package.json": "codex/win-x64/codex-package.json", "resources/codex/win-x64/NOTICE.md": "codex/win-x64/NOTICE.md", - "resources/codex/win-x64/manifest.json": "codex/win-x64/manifest.json" + "resources/codex/win-x64/manifest.json": "codex/win-x64/manifest.json", + "resources/cocos-editor-bridge": "cocos-editor-bridge" } } } diff --git a/docs/README.md b/docs/README.md index 1021692d3..21efd5e0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ - [DirectProject Codex 原始历史与异常恢复](./technical/【技术方案】DirectProject%20Codex原始历史与异常恢复-2026-09-04.md):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 +- [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5776f80ae..e2073640d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8203,3 +8203,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 用户侧 AGC Plugin 按 OpenAI Agent Plugins 组合模型吸收现有 Skill/MCP:统一 catalog、来源和审计,但 Skill 仍由 Codex 原生读取、MCP 仍由 MCP transport 启动。新增通用 `plugin_host` 和 `@genarrative/agc-plugin-sdk`;扫描、manifest 校验、Runtime Plugin 子进程启停/热重载、行分隔 JSON-RPC、UI/Capability 注册、权限和审计统一由宿主负责。 - 插件 manifest 使用 Agent Plugins 根目录 `plugin.json` 和标准 schema,兼容 `.codex-plugin/plugin.json`;AGC Runtime 字段放在 `extensions.world.genarrative.agc`,来源和父/子项统一保存到既有 `extensions` 索引。入口和面板资源只能是插件目录内普通文件;权限采用白名单,进程继承最小系统环境,不接收客户端凭据。 - 目标编辑器只实现 `EditorAdapter` 的查找、PID/项目/版本校验、连接和请求转换;本次仅保留通用 registry 和 trait,不随标准 Plugin 核心内置具体编辑器适配器。 + +## 2026-09-09 AGC Cocos Creator 编辑器桥接独立 crate + +- Cocos Creator bridge 核心位于 `server-rs/crates/cocos-editor-bridge`,与 Tauri、Agent Runtime 和服务端解耦;默认 feature 关闭,桌面宿主按需启用 `process-discovery` 或 `windows-injection`。 +- 进程发现只用于把 CocosCreator 主进程 PID 与 `--project` 和 Creator 版本绑定,排除 Electron 子进程;AGC 默认不加载该能力。 +- 注入仅加载随 AGC 资源目录提供的 DLL,结果先标记 `injected-unverified`,必须由 payload 完成握手后才可开放有限 Cocos 操作;Runtime 的 execute 代码有界并受确认策略保护,不开放未受限 eval 或项目扩展自动写入。 +- Runtime 第一阶段只广告 `cocos.editor.execute`,代码长度有界、默认走确认策略,项目根和目标 PID 不交给模型;`ping/status` 先作为宿主命令保留,不扩大全局 Agent 工具面。 +- DirectProject 的 `agc_tools` 对应入口是 `agc_cocos_execute`,同样只接收 code,并沿用当前项目权限。2026-09-10 已通过临时真实 Creator 3.8.8 验证 Node 的 Windows 调试 handler 激活 Inspector、注入 bootstrap、pipe execute 及关闭 Inspector 后继续执行;此路线尚未替换当前 native DLL 源码。现有 `RequestInterrupt` 回调不能调用 JavaScript,不能把 DLL 加载和窗口线程钩子当作可用握手。执行发送后的未知结果禁止自动重放,Direct bridge 会阻断后续 execute。详细步骤、版本/fuse 和端口边界见 Cocos bridge 技术方案。 diff --git a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md new file mode 100644 index 000000000..91bf4179a --- /dev/null +++ b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md @@ -0,0 +1,95 @@ +# AGC Cocos Creator 编辑器桥接模块 + +## 目标 + +目标是在用户已打开 Cocos Creator 项目时,由 AGC 识别正确的 Creator 主进程,并在进程内注入随包 JavaScript bootstrap;用户不需要在 Cocos 项目中手动安装扩展。桥接核心独立于 Tauri,位于 `server-rs/crates/cocos-editor-bridge`。2026-09-10 已验证通过 Node 自带的运行中 Inspector 激活入口完成引导,具体见本文“Inspector 注入调研”;这条路径尚未替换当前 crate 的 DLL 实现。 + +## 边界 + +Cocos Creator 3.x 是 Electron/Node 编辑器,不能复用 Unity Mono 的 CoreCLR/Roslyn 进程内调用方式。Core crate 只负责: + +1. 读取 Creator 主进程的 PID、父 PID、可执行文件和 `--project` 参数;Electron renderer、GPU、utility、crashpad 子进程被排除。 +2. 对 PID、项目目录和 payload 做同一目标校验。项目目录必须是绝对路径、可解析目录并包含 `package.json`;Creator 版本只从 `package.json.creator.version` 读取。 +3. 在启用 `windows-injection` feature 时,通过 `OpenProcess`、`VirtualAllocEx`、`WriteProcessMemory` 和 `CreateRemoteThread(LoadLibraryW)` 加载受信任 DLL。 +4. 提供 `ping/status/execute` 协议、Windows pipe 客户端和 `payload/bootstrap.cjs`。native payload 只在具备受支持的 Node/V8 上下文调度时尝试送入 Creator 主进程并调用 `install(Editor)`,不写入项目扩展目录。 + +当前 DLL 实现仍只返回 `injected-unverified`,不能作为可交付的注入入口。它在 `windows-injection` 下构建 C++ payload 并尝试通过窗口钩子调用 V8;其中 `RequestInterrupt → run_bootstrap → Script::Run` 违反 V8 的中断回调约束,不能因编译或 DLL 加载成功而认定安全可用。`HandleScope` 的存储大小和 C++ Local/MaybeLocal 的调用约定也不能通过裸指针替代来推断。后续接入采用下述 Inspector 引导,不再依赖这条未经验证的 native 路径。 + +## Feature 开关 + +crate 默认不启用任何宿主集成: + +```toml +cocos-editor-bridge = { path = ".../server-rs/crates/cocos-editor-bridge", default-features = false, features = ["process-discovery"] } +``` + +- `process-discovery`:启用 Windows Creator 主进程发现;不加载 Windows 注入 API。 +- `windows-transport`:在已注入 payload 后启用本机 named pipe 的 `ping/status/execute` 命令传输。 +- `windows-injection`:隐含启用 `windows-transport`,并启用 Windows native DLL 注入实现。 + +AGC 或其它桌面宿主应将 `windows-injection` 作为单独的发行构建开关,服务端和非桌面构建保持 `default-features = false`。 + +当前 AGC Tauri adapter 只暴露 `prepare_cocos_editor_injection`(确认前预检)、`inject_cocos_editor`(确认后注入)、`ping_cocos_editor`、`status_cocos_editor` 和 `execute_cocos_editor_code`。Runtime 只广告一个 `cocos.editor.execute` 工具,代码输入使用当前项目根,目标 PID 由 crate 内部唯一匹配;默认命令权限为 confirm,具体运行档沿用已有 Runtime 策略。进程发现留在 crate 内部作为目标校验步骤,不建立客户端扫描服务或独立发现入口。默认 AGC 构建不启用 Cocos 集成;桌面构建需显式传 `--features cocos-editor`,命令执行需传 `--features cocos-editor-execute`,注入构建再传 `--features cocos-editor-injection`。注入命令不接收 DLL 路径,只加载资源目录中的 `cocos-editor-bridge/cocos-editor-bridge.dll`,避免把 Tauri command 变成任意 DLL 注入器。 + +## 第一阶段命令协议 + +DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` feature 注册 `agc_cocos_execute`,参数只有 `code`。客户端在 blocking worker 内调用 crate,保留项目锁和现有项目权限;当前 bridge 出现执行结果不确定后拒绝后续 execute。旧 Runtime 的对应工具名为 `cocos.editor.execute`,继续使用它已有的 pending action、权限和恢复语义。 + +注入 payload 在目标 Creator 主进程内监听 `\\.\pipe\genarrative-cocos-editor-{pid}`,使用换行分隔的 JSON。crate 只生成三种操作: + +```json +{"schemaVersion":"game-creator-cocos-editor-bridge.v1","requestId":"cocos-42-...","processId":42,"projectPath":"C:\\demo","command":{"op":"ping"}} +{"schemaVersion":"game-creator-cocos-editor-bridge.v1","requestId":"cocos-42-...","processId":42,"projectPath":"C:\\demo","command":{"op":"status"}} +{"schemaVersion":"game-creator-cocos-editor-bridge.v1","requestId":"cocos-42-...","processId":42,"projectPath":"C:\\demo","command":{"op":"execute","code":"return Editor.Project.path"}} +``` + +回执必须回传相同的 `schemaVersion/requestId/processId`。`execute.code` 是支持 `await` 和 `return` 的 JavaScript 函数体,上限为 128 KiB,单次回执上限为 2 MiB;bootstrap 通过 `AsyncFunction('Editor', 'require', code)` 在 Creator 主进程事件循环串行执行,并返回可序列化 JSON。它使用 Creator/Node 的现有权限,不是代码沙箱;AGC 不维护额外的 Cocos 业务 API 名单。 + +Rust 客户端在写入前通过 `GetNamedPipeServerProcessId` 验证 pipe 属于目标 PID,读写使用 overlapped I/O 和 deadline。execute 开始写入后遇到断线、超时或无可信回执,返回 `ExecutionUncertain`,Runtime 进入 `needs-reconciliation`,不得自动重放。客户端超时不等于 JavaScript 已取消,bootstrap 保持同一串行队列直到原执行结束;同步死循环仍可能阻塞 Creator,需要真实集成阶段提供运行时中断方案。 + +## 安全与失败关闭 + +- PowerShell 查询脚本为固定常量,用户输入不拼接进 shell。 +- 目标 PID 必须是没有 Electron `--type` 参数的 `CocosCreator.exe`,且其 `--project` 与请求目录规范化后相同;不能仅凭进程名注入。 +- bridge DLL 必须是绝对路径、普通文件、非符号链接、`.dll` 扩展名且大小不超过 64 MiB。AGC adapter 还必须把路径限制在签名/随包资源目录;core 不接受任意下载 URL。 +- 注入超时不会释放仍可能被远程线程使用的内存,并返回人工核对错误,避免在不确定状态下破坏目标进程。 +- 非 Windows、feature 未启用、目标不存在、身份不匹配或 payload 不合规均直接失败,不启动 Cocos、不关闭 Cocos、不修改项目文件。 + +## 后续接入 + +下一步是在独立 crate 内实现 Inspector 引导并替换 DLL 装载入口,保留 feature 开关、项目/PID 绑定和 `ping/status/execute` 三条命令。进程发现仍只是内部目标校验,不增加客户端扫描服务。隔离验证脚本使用 Node 的 `_debugProcess`;正式 Rust 实现可直接调用同一组 Win32 API,无需附带额外 Node 运行时。源码接入、默认/启用 feature 编译和 AGC 发行包验收仍未完成。 + +当前验证:crate 全 feature 单元测试、Node bootstrap fixture、Rust 到 Node bootstrap 的真实命名管道回环(含 execute 超时不确定结果)通过;AGC feature 编译使用临时工作树与 Codex 资源替身,只属于编译检查,不代表发布包或真实 Creator 验收。当前工作区完整 AGC 检查受缺失内置 Codex CLI 阻断,TypeScript 契约测试受缺失 Vitest 阻断。 + +## Inspector 注入调研(2026-09-10) + +### 入口与执行链 + +Node 20.15.1 的 Windows 调试信号初始化会创建 `node-debug-handler-{pid}` 命名共享内存,其中存放目标进程自身 `StartIoThreadProc` 的函数地址。`process._debugProcess(pid)` 的内部实现是 `OpenProcess → OpenFileMappingW(FILE_MAP_READ) → MapViewOfFile → CreateRemoteThread(目标 handler)`。handler 通过 Node 自己的 libuv/interrupt 调度启动 Inspector,宿主无需写入 DLL、修改机器指令或猜测 V8 对象布局。 + +接入次序: + +1. 校验 Creator 主进程、项目身份、位数和调试入口是否可用;共享内存给出的入口应与目标进程已加载的可执行映像匹配,不能仅信任一个带 PID 的对象名。 +2. 如已有目标 Inspector,复用且记录原有状态;否则激活目标 handler。只连接属于该 PID 的回环监听端口,随后再次核对 `process.pid`、`Editor.Project.path` 和运行版本。已有进程通常使用默认调试端口;端口被其它进程占用时应失败,不连接其它目标。AGC 自己启动的 Creator 可预设 `--inspect-port=127.0.0.1:0`,该参数本身不启用 Inspector。 +3. 使用 Inspector `Runtime.evaluate` 在 Node 主上下文求值 CommonJS 包装器,把 `require` 显式传给 bootstrap,并调用 `install(Editor)`。不增加业务工具列表。 +4. 等待 bootstrap 的 pipe 就绪,按既有身份合同验证 `ping`,再开放 `execute`。 +5. 如 Inspector 是本次引导开启的,先断开 Inspector WebSocket,再从 pipe 执行 `require('node:inspector').close()`;已有用户调试会话保持原状态。之后持续命令全部走 pipe。 + +### 已验证与限制 + +- 使用本机 Creator 3.8.8 的真实 GUI 主进程和由官方 Empty(3D) 模板创建的临时项目,未安装项目扩展。运行时为 Electron 31.3.1、Node 20.15.1、V8 12.6.228.28-electron.0。 +- 在进程启动后激活 Inspector,注入当前 `payload/bootstrap.cjs`,实际 `ping`、`execute` 返回目标 PID、真实项目路径和 Creator 版本。 +- 编辑器就绪后,`Editor.Message.request('scene', 'query-node-tree')` 成功返回 `null`:空白项目未打开场景。这证明消息调用链可用,不代表已验证非空场景的读取或修改。 +- 关闭 Inspector 后,pipe 的 `execute` 仍成功;验证结束正常退出临时 Creator。用户原有 Creator 仅做共享内存入口和监听端口的只读检查。 +- 本次真实 Electron Inspector 对 `awaitPromise: true` 曾返回 `Promise was collected`。验证改为同步 evaluate 启动安装、保存就绪状态并轮询;异步业务代码由 pipe 内的 Node 队列执行。不要把单次 Inspector 异常当作 bootstrap 未执行并自动重放。 +- Inspector 的命令行 API `require` 在求值之外不保证可访问;异步闭包须捕获或显式传入该函数,不能假设 `globalThis.require` 存在。 +- 本机二进制的 `EnableNodeCliInspectArguments` fuse 开启,当前已打开的 Creator 也存在调试 handler。其它版本或 fuse 被关闭的发行包不能推断支持;入口缺失应失败,不能修改 fuse 或回退到未验证的函数打补丁。 + +### 上游依据 + +- [Node 20.15.1:Windows DebugProcess](https://github.com/nodejs/node/blob/v20.15.1/src/node_process_methods.cc#L377-L448) +- [Node 20.15.1:注册目标调试 handler](https://github.com/nodejs/node/blob/v20.15.1/src/inspector_agent.cc#L143-L197) +- [Electron 31.3.1:NodeBindings 与 Inspector fuse](https://github.com/electron/electron/blob/v31.3.1/shell/common/node_bindings.cc) +- [Electron Inspector fuse 说明](https://github.com/electron/electron/blob/v31.3.1/docs/tutorial/fuses.md#nodecliinspect) +- [V8 12.6:RequestInterrupt 的回调禁止重入 isolate](https://github.com/v8/v8/blob/12.6.228/include/v8-isolate.h) +- [Node inspector.close:等待现有连接关闭后停用 Inspector](https://github.com/nodejs/node/blob/v20.15.1/doc/api/inspector.md#inspectorclose) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9313fba40..adc5b85cb 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -183,7 +183,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 ## 目标 -在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置,设置弹窗同时提供独立“关于”页并显示从客户端构建版本注入的版本号。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制只通过显式开发调试入口查看,不随普通客户端启动额外打开窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:用户选择的目录始终作为工作区根,`.agent/`、Session、Runtime、文件工具和外围资料都留在该根;客户端检查根目录及一层直接子目录中的普通文件 `project.godot`,将唯一命中的实际目录以工作区相对 `godotProjectRoot` 记录到 manifest。Agent 使用标准运行档继续修改,不创建 `game/`、`assets/`、`memory/`、`exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。 +在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置,设置弹窗同时提供独立“关于”页并显示从客户端构建版本注入的版本号。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制只通过显式开发调试入口查看,不随普通客户端启动额外打开窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:用户选择的目录始终作为工作区根,`.agent/`、Session、Runtime、文件工具和外围资料都留在该根;客户端检查根目录及一层直接子目录中的普通文件 `project.godot`,将唯一命中的实际目录以工作区相对 `godotProjectRoot` 记录到 manifest。Agent 使用标准运行档继续修改,不创建 `game/`、`assets/`、`memory/`、`exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。新增的 Cocos Creator bridge 核心独立为 `server-rs/crates/cocos-editor-bridge`,AGC 仅通过 feature 转发桌面进程发现、受控 execute 和 Windows 注入能力;它不改变服务端路线,也不把原始 pipe、句柄或未绑定项目身份的代码执行面暴露给 Agent。 ## 技术选择 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 1742f5956..7ea4cc3ba 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -19,7 +19,7 @@ describe('AI 游戏创作 App 共享契约', () => { it('keeps command permissions explicit', () => { const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id); - expect(GAME_CREATION_APP_COMMANDS).toHaveLength(64); + expect(GAME_CREATION_APP_COMMANDS).toHaveLength(65); expect(commandIds).toContain('project.git_inspect'); expect(commandIds).toContain('project.git_commit'); expect(commandIds).toContain('project.patchset'); @@ -29,6 +29,7 @@ describe('AI 游戏创作 App 共享契约', () => { expect(commandIds).toContain('command.poll'); expect(commandIds).toContain('command.stdin'); expect(commandIds).toContain('command.terminate'); + expect(commandIds).toContain('cocos.editor.execute'); expect(commandIds).toContain('mcp.call'); expect(commandIds.indexOf('command.exec')).toBe( commandIds.indexOf('command.run_limited') + 1, diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 1f39ad546..bac24dc3d 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -67,6 +67,7 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'command.poll', permission: 'auto' }, { id: 'command.stdin', permission: 'confirm' }, { id: 'command.terminate', permission: 'confirm' }, + { id: 'cocos.editor.execute', permission: 'confirm' }, { id: 'canvas.project_open', permission: 'confirm' }, { id: 'canvas.project_sync', permission: 'confirm' }, { id: 'canvas.asset_import', permission: 'confirm' }, diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 83e472426..0dedf52f2 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -9,6 +9,7 @@ default-members = [ exclude = [ "crates/agent-runtime-core", "crates/agent-runtime-orchestration", + "crates/cocos-editor-bridge", "crates/platform-agent", ] members = [ diff --git a/server-rs/crates/cocos-editor-bridge/.gitignore b/server-rs/crates/cocos-editor-bridge/.gitignore new file mode 100644 index 000000000..e9e21997b --- /dev/null +++ b/server-rs/crates/cocos-editor-bridge/.gitignore @@ -0,0 +1,2 @@ +/target/ +/Cargo.lock diff --git a/server-rs/crates/cocos-editor-bridge/Cargo.toml b/server-rs/crates/cocos-editor-bridge/Cargo.toml new file mode 100644 index 000000000..ea75061bf --- /dev/null +++ b/server-rs/crates/cocos-editor-bridge/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "cocos-editor-bridge" +version = "0.1.0" +edition = "2021" +license = "UNLICENSED" +publish = false +build = "build.rs" +description = "可选的 Cocos Creator 编辑器进程发现与 Windows bridge 注入核心" + +[lib] +crate-type = ["rlib", "cdylib"] + +[features] +# The crate is intentionally inert unless a consumer opts into one of the +# integration layers. This keeps server and non-desktop builds free of +# process access and Windows injection code. +default = [] +process-discovery = [] +windows-transport = [ + "process-discovery", + "dep:windows-sys", + "windows-sys/Win32_Foundation", + "windows-sys/Win32_Security", + "windows-sys/Win32_Storage_FileSystem", + "windows-sys/Win32_System_IO", + "windows-sys/Win32_System_Pipes", + "windows-sys/Win32_System_Threading", +] +windows-injection = [ + "windows-transport", + "dep:sha2", + "windows-sys/Win32_System_Diagnostics_Debug", + "windows-sys/Win32_System_LibraryLoader", + "windows-sys/Win32_System_Memory", +] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = { version = "0.10", optional = true } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", optional = true, default-features = false } + +[build-dependencies] +cc = "1" diff --git a/server-rs/crates/cocos-editor-bridge/build.rs b/server-rs/crates/cocos-editor-bridge/build.rs new file mode 100644 index 000000000..2193ad70e --- /dev/null +++ b/server-rs/crates/cocos-editor-bridge/build.rs @@ -0,0 +1,14 @@ +fn main() { + println!("cargo:rerun-if-changed=native/native_payload.cpp"); + println!("cargo:rerun-if-changed=payload/bootstrap.cjs"); + if std::env::var_os("CARGO_CFG_WINDOWS").is_some() + && std::env::var_os("CARGO_FEATURE_WINDOWS_INJECTION").is_some() + { + println!("cargo:rustc-link-lib=user32"); + cc::Build::new() + .cpp(true) + .file("native/native_payload.cpp") + .flag_if_supported("/std:c++17") + .compile("cocos_editor_bridge_native"); + } +} diff --git a/server-rs/crates/cocos-editor-bridge/native/native_payload.cpp b/server-rs/crates/cocos-editor-bridge/native/native_payload.cpp new file mode 100644 index 000000000..41592ce3e --- /dev/null +++ b/server-rs/crates/cocos-editor-bridge/native/native_payload.cpp @@ -0,0 +1,171 @@ +// Cocos Creator 3.8.x native payload. +// +// This file intentionally uses dynamic symbol lookup instead of linking to +// Electron. Creator ships a private Electron build and has no stable import +// library. The symbols below are the exported V8/Node ABI of Electron 31. +// The payload is loaded only after the Rust side has validated the exact +// Creator PID and project identity. +#include +#include +#include + +extern "C" const char* cocos_editor_bridge_bootstrap_source(); +extern "C" void cocos_editor_bridge_native_anchor() {} + +namespace { +using Isolate = void; +using Local = void*; +using Environment = void; +using MaybeLocal = void*; + +template T symbol(const char* name) { + return reinterpret_cast(GetProcAddress(GetModuleHandleW(nullptr), name)); +} + +using GetCurrentIsolate = Isolate* (*)(); +using GetCurrentContext = Local (*)(Isolate*); +using GetCurrentEnvironment = Environment* (*)(Local); +using GetMainContext = Local (*)(Environment*); +using HandleScopeCtor = void (*)(void*, Isolate*); +using HandleScopeDtor = void (*)(void*); +using ContextEnter = void (*)(Local); +using ContextExit = void (*)(Local); +using NewString = MaybeLocal (*)(Isolate*, const char*, int, int); +using CompileScript = MaybeLocal (*)(Local, Local, void*); +using RunScript = MaybeLocal (*)(void*, Local); +using RequestInterrupt = void (*)(Isolate*, void (*)(Isolate*, void*), void*); + +constexpr const char* kGetCurrentIsolate = "?TryGetCurrent@Isolate@v8@@SAPEAV12@XZ"; +constexpr const char* kGetCurrentContext = "?GetCurrentContext@Isolate@v8@@QEAA?AV?$Local@VContext@v8@@@2@XZ"; +constexpr const char* kGetCurrentEnvironment = "?GetCurrentEnvironment@node@@YAPEAVEnvironment@1@V?$Local@VContext@v8@@@v8@@@Z"; +constexpr const char* kGetMainContext = "?GetMainContext@node@@YA?AV?$Local@VContext@v8@@@v8@@PEAVEnvironment@1@@Z"; +constexpr const char* kHandleScopeCtor = "??0HandleScope@v8@@QEAA@PEAVIsolate@1@@Z"; +constexpr const char* kHandleScopeDtor = "??1HandleScope@v8@@QEAA@XZ"; +constexpr const char* kContextEnter = "?Enter@Context@v8@@QEAAXXZ"; +constexpr const char* kContextExit = "?Exit@Context@v8@@QEAAXXZ"; +constexpr const char* kNewString = "?NewFromUtf8@String@v8@@SA?AV?$MaybeLocal@VString@v8@@@2@PEAVIsolate@2@PEBDW4NewStringType@2@H@Z"; +constexpr const char* kCompileScript = "?Compile@Script@v8@@SA?AV?$MaybeLocal@VScript@v8@@@2@V?$Local@VContext@v8@@@2@V?$Local@VString@v8@@@2@PEAVScriptOrigin@2@@Z"; +constexpr const char* kRunScript = "?Run@Script@v8@@QEAA?AV?$MaybeLocal@VValue@v8@@@2@V?$Local@VContext@v8@@@2@@Z"; +constexpr const char* kRequestInterrupt = "?RequestInterrupt@Isolate@v8@@QEAAXP6AXPEAV12@PEAX@Z1@Z"; + +HHOOK g_hook = nullptr; +HMODULE g_instance = nullptr; +DWORD g_ui_thread = 0; +HWND g_window = nullptr; +volatile LONG g_attempted = 0; +void interrupt_bootstrap(Isolate*, void*); + +void trace(const char* message) { + OutputDebugStringA(message); + OutputDebugStringA("\n"); +} + +BOOL CALLBACK find_window(HWND hwnd, LPARAM) { + DWORD pid = 0; + DWORD thread = GetWindowThreadProcessId(hwnd, &pid); + if (pid == GetCurrentProcessId() && thread != 0 && IsWindowVisible(hwnd)) { + g_window = hwnd; + g_ui_thread = thread; + return FALSE; + } + return TRUE; +} + +bool run_bootstrap() { + auto current = symbol(kGetCurrentIsolate); + auto get_context = symbol(kGetCurrentContext); + auto get_env = symbol(kGetCurrentEnvironment); + auto get_main_context = symbol(kGetMainContext); + auto scope_ctor = symbol(kHandleScopeCtor); + auto scope_dtor = symbol(kHandleScopeDtor); + auto enter = symbol(kContextEnter); + auto exit = symbol(kContextExit); + auto new_string = symbol(kNewString); + auto compile = symbol(kCompileScript); + auto run = symbol(kRunScript); + if (!current || !get_context || !get_env || !get_main_context || !scope_ctor || + !scope_dtor || !enter || !exit || !new_string || !compile || !run) { + trace("missing-v8-symbol"); + return false; + } + Isolate* isolate = current(); + if (!isolate) { trace("no-current-isolate"); return false; } + Local context = get_context(isolate); + Environment* env = context ? get_env(context) : nullptr; + if (!env) { trace("no-current-environment"); return false; } + Local main_context = get_main_context(env); + if (!main_context) { trace("no-main-context"); return false; } + + alignas(16) unsigned char handle_scope[64] = {}; + scope_ctor(handle_scope, isolate); + enter(main_context); + bool ok = false; + do { + const char* source = cocos_editor_bridge_bootstrap_source(); + if (!source) { trace("no-bootstrap-source"); break; } + std::string script; + script.reserve(strlen(source) + 256); + script += "(function(){const module={exports:{}};const exports=module.exports;const require=globalThis.require;const Editor=globalThis.Editor;"; + script += source; + script += ";return module.exports.install(Editor);})()"; + Local source_string = reinterpret_cast(new_string(isolate, script.c_str(), 0, static_cast(script.size()))); + if (!source_string) { trace("new-string-failed"); break; } + Local compiled = reinterpret_cast(compile(main_context, source_string, nullptr)); + if (!compiled) { trace("compile-failed"); break; } + Local result = reinterpret_cast(run(compiled, main_context)); + ok = result != nullptr; + trace(ok ? "bootstrap-ok" : "run-failed"); + } while (false); + exit(main_context); + scope_dtor(handle_scope); + return ok; +} + +void interrupt_bootstrap(Isolate*, void*) { + if (run_bootstrap()) { + InterlockedExchange(&g_attempted, 2); + if (g_hook) { + UnhookWindowsHookEx(g_hook); + g_hook = nullptr; + } + } else { + InterlockedExchange(&g_attempted, 0); + } +} + +LRESULT CALLBACK call_window_proc(int code, WPARAM wparam, LPARAM lparam) { + if (code >= 0 && !InterlockedCompareExchange(&g_attempted, 1, 0)) { + auto current = symbol(kGetCurrentIsolate); + auto request_interrupt = symbol(kRequestInterrupt); + Isolate* isolate = current ? current() : nullptr; + if (isolate && request_interrupt) { + trace("request-interrupt"); + request_interrupt(isolate, interrupt_bootstrap, nullptr); + } else { + InterlockedExchange(&g_attempted, 0); + } + } + return CallNextHookEx(g_hook, code, wparam, lparam); +} + +DWORD WINAPI bootstrap_thread(void*) { + trace("bootstrap-thread"); + for (int i = 0; i < 120 && !g_window; ++i) { + EnumWindows(find_window, 0); + if (!g_window) Sleep(100); + } + if (!g_ui_thread) { trace("no-ui-thread"); return 0; } + g_hook = SetWindowsHookExW(WH_CALLWNDPROC, call_window_proc, g_instance, g_ui_thread); + if (!g_hook) { trace("hook-failed"); return 0; } + trace("hook-installed"); + if (g_hook && g_window) PostMessageW(g_window, WM_NULL, 0, 0); + return 0; +} +} // namespace + +extern "C" void cocos_editor_bridge_native_process_attach(HMODULE instance) { + g_instance = instance; + DisableThreadLibraryCalls(instance); + HANDLE thread = CreateThread(nullptr, 0, bootstrap_thread, nullptr, 0, nullptr); + if (thread) CloseHandle(thread); +} diff --git a/server-rs/crates/cocos-editor-bridge/payload/bootstrap.cjs b/server-rs/crates/cocos-editor-bridge/payload/bootstrap.cjs new file mode 100644 index 000000000..9f184a806 --- /dev/null +++ b/server-rs/crates/cocos-editor-bridge/payload/bootstrap.cjs @@ -0,0 +1,224 @@ +'use strict'; + +// This module is evaluated in the Creator main-process Node context by the +// native bootstrap. It is not a Creator extension and writes no project files. +const net = require('node:net'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SCHEMA_VERSION = 'game-creator-cocos-editor-bridge.v1'; +const MAX_CODE_BYTES = 128 * 1024; +const MAX_FRAME_BYTES = MAX_CODE_BYTES * 6 + 16 * 1024; +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const INSTANCE_KEY = Symbol.for('genarrative.cocos-editor-bridge.v1'); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + +function normalizeProjectPath(value) { + if (typeof value !== 'string' || !path.isAbsolute(value)) { + throw new Error('projectPath must be absolute'); + } + const canonical = fs.realpathSync(value).replace(/^\\\\\?\\/, ''); + return process.platform === 'win32' ? canonical.toLowerCase() : canonical; +} + +function validateEnvelope(request, projectPath) { + if (!request || typeof request !== 'object' || Array.isArray(request)) { + throw new Error('request must be an object'); + } + const keys = Object.keys(request).sort().join(','); + if (keys !== 'command,processId,projectPath,requestId,schemaVersion') { + throw new Error('unknown or missing request fields'); + } + if ( + request.schemaVersion !== SCHEMA_VERSION || + request.processId !== process.pid + ) { + throw new Error('bridge identity mismatch'); + } + if ( + typeof request.requestId !== 'string' || + !request.requestId || + request.requestId.length > 128 + ) { + throw new Error('invalid requestId'); + } + if (normalizeProjectPath(request.projectPath) !== projectPath) { + throw new Error('project identity mismatch'); + } + const command = request.command; + if (!command || typeof command !== 'object' || Array.isArray(command)) { + throw new Error('command must be an object'); + } + if (command.op === 'ping' || command.op === 'status') { + if (Object.keys(command).length !== 1) + throw new Error('unknown command fields'); + } else if (command.op === 'execute') { + if (Object.keys(command).sort().join(',') !== 'code,op') + throw new Error('unknown execute fields'); + if ( + typeof command.code !== 'string' || + !command.code.trim() || + command.code.includes('\0') + ) { + throw new Error('execute.code must be non-empty text without NUL'); + } + if (Buffer.byteLength(command.code, 'utf8') > MAX_CODE_BYTES) + throw new Error('execute.code exceeds limit'); + } else { + throw new Error('unsupported command'); + } +} + +function install(Editor, options = {}) { + if ( + !Editor?.Project?.path || + typeof Editor?.Message?.request !== 'function' + ) { + throw new Error('bootstrap requires the Creator main-process Editor API'); + } + const projectPath = normalizeProjectPath(Editor.Project.path); + const existing = globalThis[INSTANCE_KEY]; + if (existing) { + if (existing.projectPath !== projectPath) + throw new Error('bridge is bound to another project'); + return existing; + } + const endpoint = + options.endpoint ?? `\\\\.\\pipe\\genarrative-cocos-editor-${process.pid}`; + let busy = false; + let pendingExecutions = 0; + let queue = Promise.resolve(); + const sockets = new Set(); + const response = (request, ok, result, error) => ({ + schemaVersion: SCHEMA_VERSION, + requestId: typeof request?.requestId === 'string' ? request.requestId : '', + processId: process.pid, + ok, + ...(ok + ? { result: result === undefined ? null : result } + : { error: String(error) }), + }); + function send(socket, value) { + if (socket.destroyed) return; + let payload; + try { + payload = JSON.stringify(value); + if (Buffer.byteLength(payload, 'utf8') + 1 > MAX_RESPONSE_BYTES) + throw new Error('result exceeds limit'); + } catch (error) { + payload = JSON.stringify( + response( + value, + false, + null, + `result is not serializable: ${error.message}`, + ), + ); + } + socket.end(`${payload}\n`); + } + async function dispatch(socket, request) { + try { + validateEnvelope(request, projectPath); + if (normalizeProjectPath(Editor.Project.path) !== projectPath) + throw new Error('Creator project changed'); + if (request.command.op === 'ping') { + send(socket, response(request, true, { ready: true })); + } else if (request.command.op === 'status') { + send( + socket, + response(request, true, { + ready: true, + busy, + creatorVersion: + typeof Editor.App?.version === 'string' + ? Editor.App.version + : null, + }), + ); + } else { + if (pendingExecutions >= 8) throw new Error('execute queue is full'); + pendingExecutions += 1; + // Serialize mutations. A client timeout does not cancel JavaScript or + // permit replay: the next execution waits until this one has settled. + queue = queue.then(async () => { + busy = true; + try { + if (normalizeProjectPath(Editor.Project.path) !== projectPath) + throw new Error('Creator project changed'); + const execute = new AsyncFunction( + 'Editor', + 'require', + `"use strict";\n${request.command.code}`, + ); + const result = await execute(Editor, require); + send(socket, response(request, true, result)); + } catch (error) { + send(socket, response(request, false, null, error?.stack ?? error)); + } finally { + busy = false; + pendingExecutions -= 1; + } + }); + await queue; + } + } catch (error) { + send(socket, response(request, false, null, error?.message ?? error)); + } + } + const server = net.createServer((socket) => { + sockets.add(socket); + let buffer = Buffer.alloc(0); + let received = false; + socket.on('error', () => {}); + socket.on('close', () => sockets.delete(socket)); + socket.on('data', (chunk) => { + if (received) return; + if (buffer.length + chunk.length > MAX_FRAME_BYTES) { + received = true; + socket.destroy(); + return; + } + buffer = Buffer.concat([buffer, chunk]); + const newline = buffer.indexOf(10); + if (newline === -1) return; + received = true; + let request; + try { + request = JSON.parse(buffer.subarray(0, newline).toString('utf8')); + } catch { + socket.destroy(); + return; + } + void dispatch(socket, request); + }); + }); + server.maxConnections = 8; + const ready = new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(endpoint, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + // A duplicate bootstrap uses the same instance rather than rebinding or + // creating a second queue. Disposal is a native-loader lifecycle API. + const instance = { + projectPath, + ready, + async dispose() { + await queue; + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + if (globalThis[INSTANCE_KEY] === instance) + delete globalThis[INSTANCE_KEY]; + }, + }; + globalThis[INSTANCE_KEY] = instance; + ready.catch(() => { + if (globalThis[INSTANCE_KEY] === instance) delete globalThis[INSTANCE_KEY]; + }); + return instance; +} + +module.exports = { install, SCHEMA_VERSION }; diff --git a/server-rs/crates/cocos-editor-bridge/payload/bootstrap.test.cjs b/server-rs/crates/cocos-editor-bridge/payload/bootstrap.test.cjs new file mode 100644 index 000000000..b1a88bf8a --- /dev/null +++ b/server-rs/crates/cocos-editor-bridge/payload/bootstrap.test.cjs @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const net = require('node:net'); +const { install, SCHEMA_VERSION } = require('./bootstrap.cjs'); + +test('Creator bootstrap executes async Editor code and keeps a three-command surface', async () => { + const endpoint = + process.platform === 'win32' + ? `\\\\.\\pipe\\genarrative-cocos-test-${process.pid}` + : `/tmp/genarrative-cocos-test-${process.pid}.sock`; + const Editor = { + Project: { path: __dirname }, + App: { version: 'test' }, + Message: { request: async (channel, message) => ({ channel, message }) }, + }; + const bridge = install(Editor, { endpoint }); + await bridge.ready; + let sequence = 0; + function request(command, override = {}) { + const envelope = { + schemaVersion: SCHEMA_VERSION, + requestId: `test-${++sequence}`, + processId: process.pid, + projectPath: __dirname, + command, + ...override, + }; + return new Promise((resolve, reject) => { + const socket = net.connect(endpoint); + let data = ''; + socket.setTimeout(3000, () => + socket.destroy(new Error('fixture timeout')), + ); + socket.on('error', reject); + socket.on('connect', () => socket.write(`${JSON.stringify(envelope)}\n`)); + socket.on('data', (chunk) => { + data += chunk.toString(); + }); + socket.on('end', () => resolve(JSON.parse(data))); + }); + } + try { + assert.equal((await request({ op: 'ping' })).result.ready, true); + assert.equal( + (await request({ op: 'status' })).result.creatorVersion, + 'test', + ); + const result = await request({ + op: 'execute', + code: "return await Editor.Message.request('scene', 'query-node-tree');", + }); + assert.equal(result.ok, true); + assert.deepEqual(result.result, { + channel: 'scene', + message: 'query-node-tree', + }); + assert.equal((await request({ op: 'deleteScene' })).ok, false); + assert.equal((await request({ op: 'execute', code: '' })).ok, false); + assert.equal( + (await request({ op: 'execute', code: 'x'.repeat(128 * 1024 + 1) })).ok, + false, + ); + assert.equal( + (await request({ op: 'execute', code: "throw new Error('expected');" })) + .ok, + false, + ); + assert.equal( + (await request({ op: 'ping' }, { processId: process.pid + 1 })).ok, + false, + ); + assert.equal(install(Editor, { endpoint }), bridge); + } finally { + await bridge.dispose(); + } +}); diff --git a/server-rs/crates/cocos-editor-bridge/src/lib.rs b/server-rs/crates/cocos-editor-bridge/src/lib.rs new file mode 100644 index 000000000..820e9c726 --- /dev/null +++ b/server-rs/crates/cocos-editor-bridge/src/lib.rs @@ -0,0 +1,1259 @@ +//! Cocos Creator editor bridge core. +//! +//! The crate deliberately separates three concerns: +//! +//! * [`discover_cocos_editors`] reads the currently running Creator host +//! processes on Windows and projects their command line to a safe identity. +//! * [`validate_injection_request`] checks that a requested PID, project and +//! payload describe the same editor before any mutation is attempted. +//! * [`inject_bridge_dll`] is an explicitly opt-in Windows implementation of +//! the standard `LoadLibraryW` remote-thread bootstrap. The injected DLL is +//! responsible for the Cocos/Electron side of the bridge protocol; loading a +//! DLL alone is never reported as a connected editor. +//! * The first command surface is intentionally small: `ping`, `status`, and a +//! bounded `execute` code request. +//! +//! Consumers should keep `default-features = false` and enable +//! `process-discovery`, `windows-transport`, or `windows-injection` only in a +//! desktop adapter. + +use serde::{Deserialize, Serialize}; +#[cfg(all(feature = "windows-injection", windows))] +use sha2::{Digest, Sha256}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +#[cfg(feature = "process-discovery")] +use std::process::{Command, Stdio}; + +pub const BRIDGE_SCHEMA_VERSION: &str = "game-creator-cocos-editor-bridge.v1"; +pub const MAX_DISCOVERY_OUTPUT_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_BRIDGE_PAYLOAD_BYTES: u64 = 64 * 1024 * 1024; +pub const MAX_EXECUTE_CODE_BYTES: usize = 128 * 1024; +pub const MAX_COMMAND_REQUEST_BYTES: usize = MAX_EXECUTE_CODE_BYTES * 6 + 16 * 1024; +pub const MAX_COMMAND_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +pub const DEFAULT_INJECTION_TIMEOUT_MS: u32 = 15_000; +pub const DEFAULT_COMMAND_TIMEOUT_MS: u32 = 5_000; +/// CommonJS bootstrap to evaluate inside the Creator main-process Node +/// context, then call `install(Editor)`. This is not a native DLL entrypoint. +pub const COCOS_EDITOR_BOOTSTRAP_SOURCE: &str = include_str!("../payload/bootstrap.cjs"); + +/// Exported for the optional native payload DLL. The DLL is linked from this +/// crate when `windows-injection` is enabled, so it carries the exact same +/// bootstrap source as the Rust transport tests. +#[cfg(all(feature = "windows-injection", windows))] +#[no_mangle] +pub extern "C" fn cocos_editor_bridge_bootstrap_source() -> *const std::ffi::c_char { + static SOURCE: &[u8] = concat!(include_str!("../payload/bootstrap.cjs"), "\0").as_bytes(); + SOURCE.as_ptr().cast() +} + +#[cfg(all(feature = "windows-injection", windows))] +extern "C" { + fn cocos_editor_bridge_native_anchor(); + fn cocos_editor_bridge_native_process_attach(instance: *mut std::ffi::c_void); +} + +#[cfg(all(feature = "windows-injection", windows))] +#[used] +static COCOS_EDITOR_NATIVE_PAYLOAD_LINK: unsafe extern "C" fn() = cocos_editor_bridge_native_anchor; + +#[cfg(all(feature = "windows-injection", windows))] +#[no_mangle] +pub unsafe extern "system" fn DllMain( + instance: *mut std::ffi::c_void, + reason: u32, + _reserved: *mut std::ffi::c_void, +) -> i32 { + if reason == 1 { + cocos_editor_bridge_native_process_attach(instance); + } + 1 +} +const BRIDGE_PIPE_PREFIX: &str = r"\\.\pipe\genarrative-cocos-editor-"; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CocosEditorProcess { + pub process_id: u32, + pub parent_process_id: u32, + pub executable_path: String, + pub command_line: Option, + pub project_path: Option, + pub creator_version: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CocosEditorDiscovery { + pub schema_version: String, + pub supported: bool, + pub processes: Vec, + pub warning: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CocosEditorInjectionRequest { + pub process_id: u32, + pub project_path: String, + pub bridge_dll_path: String, + #[serde(default = "default_injection_timeout_ms")] + pub timeout_ms: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CocosEditorInjectionResult { + pub schema_version: String, + pub status: CocosEditorInjectionStatus, + pub process_id: u32, + pub project_path: String, + pub creator_version: Option, + pub payload_sha256: Option, + pub detail: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields, tag = "op", rename_all = "camelCase")] +pub enum CocosEditorCommand { + Ping, + Status, + Execute { code: String }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CocosEditorCommandEnvelope { + pub schema_version: String, + pub request_id: String, + pub process_id: u32, + pub project_path: String, + pub command: CocosEditorCommand, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CocosEditorCommandResponse { + pub schema_version: String, + pub request_id: String, + pub process_id: u32, + pub ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Stable pipe name shared by the injected payload and the desktop adapter. +/// The PID is deliberately part of the name so a second Creator instance +/// cannot accidentally receive a command intended for the first one. +pub fn bridge_pipe_name(process_id: u32) -> String { + format!("{BRIDGE_PIPE_PREFIX}{process_id}") +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CocosEditorInjectionStatus { + InjectedUnverified, + UnsupportedPlatform, + FeatureDisabled, + TargetNotFound, + IdentityMismatch, + PayloadRejected, + InjectionFailed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BridgeError { + FeatureDisabled, + UnsupportedPlatform, + Discovery(String), + InvalidInput(String), + TargetNotFound(u32), + TargetAmbiguous(String), + IdentityMismatch(String), + PayloadRejected(String), + InjectionFailed(String), + Transport(String), + ExecutionUncertain(String), +} + +impl fmt::Display for BridgeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FeatureDisabled => f.write_str("Cocos Editor bridge feature 未启用"), + Self::UnsupportedPlatform => f.write_str("当前平台不支持 Cocos Editor 进程桥接"), + Self::Discovery(message) => write!(f, "Cocos Editor 进程发现失败:{message}"), + Self::InvalidInput(message) => write!(f, "Cocos Editor bridge 输入无效:{message}"), + Self::TargetNotFound(pid) => write!(f, "未找到 Cocos Creator 编辑器进程 {pid}"), + Self::TargetAmbiguous(path) => { + write!(f, "项目存在多个 Cocos Creator 编辑器进程:{path}") + } + Self::IdentityMismatch(message) => write!(f, "Cocos Creator 目标身份不匹配:{message}"), + Self::PayloadRejected(message) => write!(f, "Cocos bridge payload 被拒绝:{message}"), + Self::InjectionFailed(message) => write!(f, "Cocos bridge 注入失败:{message}"), + Self::Transport(message) => write!(f, "Cocos bridge 命令传输失败:{message}"), + Self::ExecutionUncertain(message) => { + write!( + f, + "Cocos execute 已发送但结果无法确认,不能自动重放:{message}" + ) + } + } + } +} + +impl std::error::Error for BridgeError {} + +fn default_injection_timeout_ms() -> u32 { + DEFAULT_INJECTION_TIMEOUT_MS +} + +/// Discover the root Cocos Creator host process on Windows. +/// +/// Creator starts several Electron child processes with the same executable +/// name. Only a process without an Electron `--type` argument and with a +/// `--project` argument is considered an editor host. The PowerShell query is +/// fixed text; caller input is never interpolated into a shell command. +#[cfg(feature = "process-discovery")] +pub fn discover_cocos_editors() -> Result { + #[cfg(windows)] + { + let mut command = Command::new("powershell.exe"); + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + command + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$items = Get-CimInstance Win32_Process -Filter \"name='CocosCreator.exe'\" | Select-Object ProcessId,ParentProcessId,ExecutablePath,CommandLine; $items | ConvertTo-Json -Compress -Depth 3", + ]) + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()); + let output = command + .output() + .map_err(|error| BridgeError::Discovery(error.to_string()))?; + if output.stdout.len() > MAX_DISCOVERY_OUTPUT_BYTES { + return Err(BridgeError::Discovery("进程查询输出超过上限".to_string())); + } + if !output.status.success() { + return Err(BridgeError::Discovery("系统进程查询命令失败".to_string())); + } + let text = String::from_utf8(output.stdout) + .map_err(|_| BridgeError::Discovery("进程查询输出不是 UTF-8".to_string()))?; + let rows = parse_process_rows(&text)?; + let processes = rows + .into_iter() + .filter_map(|row| { + let command_line = row.command_line.filter(|value| !value.trim().is_empty()); + if command_line + .as_deref() + .is_some_and(command_line_is_electron_child) + { + return None; + } + let project_path = command_line + .as_deref() + .and_then(parse_project_argument) + .and_then(|path| normalize_existing_directory(Path::new(&path)).ok()); + let creator_version = project_path + .as_deref() + .and_then(|path| read_creator_version(Path::new(path)).ok().flatten()); + Some(CocosEditorProcess { + process_id: row.process_id, + parent_process_id: row.parent_process_id, + executable_path: row.executable_path, + command_line, + project_path, + creator_version, + }) + }) + .collect(); + Ok(CocosEditorDiscovery { + schema_version: BRIDGE_SCHEMA_VERSION.to_string(), + supported: true, + processes, + warning: None, + }) + } + #[cfg(not(windows))] + { + Err(BridgeError::UnsupportedPlatform) + } +} + +#[cfg(not(feature = "process-discovery"))] +pub fn discover_cocos_editors() -> Result { + Err(BridgeError::FeatureDisabled) +} + +#[cfg(feature = "process-discovery")] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct ProcessRow { + #[serde(default)] + process_id: u32, + #[serde(default)] + parent_process_id: u32, + #[serde(default)] + executable_path: String, + #[serde(default)] + command_line: Option, +} + +#[cfg(feature = "process-discovery")] +fn parse_process_rows(text: &str) -> Result, BridgeError> { + if text.trim().is_empty() { + return Ok(Vec::new()); + } + if text.trim_start().starts_with('[') { + serde_json::from_str(text) + .map_err(|error| BridgeError::Discovery(format!("进程查询 JSON 无效:{error}"))) + } else { + serde_json::from_str::(text) + .map(|row| vec![row]) + .map_err(|error| BridgeError::Discovery(format!("进程查询 JSON 无效:{error}"))) + } +} + +#[cfg(feature = "process-discovery")] +fn command_line_is_electron_child(command_line: &str) -> bool { + let tokens = tokenize_windows_command_line(command_line); + tokens.iter().any(|token| { + token == "--type" + || token + .strip_prefix("--type=") + .is_some_and(|value| !value.trim().is_empty()) + }) +} + +/// Parse the project argument without treating arbitrary command-line text as +/// a path. Both `--project path` and `--project=path` are accepted. +pub fn parse_project_argument(command_line: &str) -> Option { + let tokens = tokenize_windows_command_line(command_line); + if let Some(index) = tokens.iter().position(|token| token == "--project") { + if let Some(value) = tokens + .get(index + 1) + .filter(|value| !value.trim().is_empty()) + { + return Some(value.clone()); + } + } + tokens.iter().find_map(|token| { + token + .strip_prefix("--project=") + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }) +} + +/// Small Windows command-line tokenizer sufficient for Creator's quoted +/// executable/project arguments. It follows the CommandLineToArgvW quote and +/// backslash rules and does not invoke a shell. +pub fn tokenize_windows_command_line(command_line: &str) -> Vec { + let mut result = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + let mut slash_count = 0usize; + let push = |result: &mut Vec, current: &mut String| { + if !current.is_empty() { + result.push(std::mem::take(current)); + } + }; + for ch in command_line.chars() { + if ch == '\\' { + slash_count += 1; + continue; + } + if ch == '"' { + current.extend(std::iter::repeat_n('\\', slash_count / 2)); + if slash_count % 2 == 1 { + current.push('"'); + } else { + quoted = !quoted; + } + slash_count = 0; + continue; + } + current.extend(std::iter::repeat_n('\\', slash_count)); + slash_count = 0; + if ch.is_whitespace() && !quoted { + push(&mut result, &mut current); + } else { + current.push(ch); + } + } + current.extend(std::iter::repeat_n('\\', slash_count)); + push(&mut result, &mut current); + result +} + +fn normalize_existing_directory(path: &Path) -> Result { + if !path.is_absolute() { + return Err(BridgeError::InvalidInput( + "Cocos 项目路径必须是绝对路径".to_string(), + )); + } + let canonical = fs::canonicalize(path) + .map_err(|error| BridgeError::InvalidInput(format!("解析 Cocos 项目路径失败:{error}")))?; + if !canonical.is_dir() { + return Err(BridgeError::InvalidInput( + "Cocos 项目路径必须是目录".to_string(), + )); + } + Ok(canonical.to_string_lossy().into_owned()) +} + +fn read_creator_version(project_path: &Path) -> Result, BridgeError> { + let package_path = project_path.join("package.json"); + if !package_path.is_file() { + return Ok(None); + } + let metadata = fs::metadata(&package_path).map_err(|error| { + BridgeError::InvalidInput(format!("读取 Cocos package.json 失败:{error}")) + })?; + if metadata.len() > 256 * 1024 { + return Err(BridgeError::InvalidInput( + "Cocos package.json 超过大小上限".to_string(), + )); + } + let content = fs::read_to_string(package_path).map_err(|error| { + BridgeError::InvalidInput(format!("读取 Cocos package.json 失败:{error}")) + })?; + let value: serde_json::Value = serde_json::from_str(&content) + .map_err(|error| BridgeError::InvalidInput(format!("Cocos package.json 无效:{error}")))?; + Ok(value + .pointer("/creator/version") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned)) +} + +fn validate_payload(path: &Path) -> Result { + if !path.is_absolute() { + return Err(BridgeError::PayloadRejected( + "bridge DLL 路径必须是绝对路径".to_string(), + )); + } + let metadata = fs::symlink_metadata(path) + .map_err(|error| BridgeError::PayloadRejected(format!("读取 bridge DLL 失败:{error}")))?; + if metadata.file_type().is_symlink() { + return Err(BridgeError::PayloadRejected( + "bridge DLL 不允许是符号链接".to_string(), + )); + } + if !metadata.is_file() { + return Err(BridgeError::PayloadRejected( + "bridge DLL 必须是普通文件".to_string(), + )); + } + if metadata.len() == 0 || metadata.len() > MAX_BRIDGE_PAYLOAD_BYTES { + return Err(BridgeError::PayloadRejected( + "bridge DLL 大小超出允许范围".to_string(), + )); + } + if path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + != Some("dll".to_string()) + { + return Err(BridgeError::PayloadRejected( + "bridge payload 必须是 .dll".to_string(), + )); + } + fs::canonicalize(path) + .map_err(|error| BridgeError::PayloadRejected(format!("解析 bridge DLL 路径失败:{error}"))) +} + +#[cfg(all(feature = "windows-injection", windows))] +fn payload_sha256(path: &Path) -> Result { + let bytes = fs::read(path) + .map_err(|error| BridgeError::PayloadRejected(format!("读取 bridge DLL 失败:{error}")))?; + if bytes.len() as u64 > MAX_BRIDGE_PAYLOAD_BYTES { + return Err(BridgeError::PayloadRejected( + "bridge DLL 大小超出允许范围".to_string(), + )); + } + Ok(format!("{:x}", Sha256::digest(bytes))) +} + +/// Validate the process/project/payload identity before mutation. +pub fn validate_injection_target( + process_id: u32, + project_path: &str, +) -> Result { + if process_id == 0 { + return Err(BridgeError::InvalidInput( + "processId 必须大于 0".to_string(), + )); + } + let project_path = normalize_existing_directory(Path::new(project_path))?; + if !Path::new(&project_path).join("package.json").is_file() { + return Err(BridgeError::IdentityMismatch( + "目标目录缺少 Cocos package.json".to_string(), + )); + } + let discovery = discover_cocos_editors()?; + let target = discovery + .processes + .into_iter() + .find(|process| process.process_id == process_id) + .ok_or(BridgeError::TargetNotFound(process_id))?; + let target_project = target.project_path.as_deref().ok_or_else(|| { + BridgeError::IdentityMismatch("目标进程没有可识别的 --project".to_string()) + })?; + if !paths_equal(Path::new(target_project), Path::new(&project_path)) { + return Err(BridgeError::IdentityMismatch( + "目标 PID 的 --project 与请求项目不一致".to_string(), + )); + } + if !is_cocos_creator_executable(Path::new(&target.executable_path)) { + return Err(BridgeError::IdentityMismatch( + "目标进程可执行文件不是 CocosCreator.exe".to_string(), + )); + } + Ok(CocosEditorProcess { + creator_version: read_creator_version(Path::new(&project_path))?, + project_path: Some(project_path), + ..target + }) +} + +/// Validate the process/project/payload identity before mutation. +pub fn validate_injection_request( + request: &CocosEditorInjectionRequest, +) -> Result { + if request.timeout_ms == 0 || request.timeout_ms > 60_000 { + return Err(BridgeError::InvalidInput( + "timeoutMs 必须在 1..=60000".to_string(), + )); + } + let _payload = validate_payload(Path::new(&request.bridge_dll_path))?; + validate_injection_target(request.process_id, &request.project_path) +} + +/// Validate code before invoking any process discovery or transport. +pub fn validate_execute_code(code: &str) -> Result<(), BridgeError> { + if code.trim().is_empty() { + return Err(BridgeError::InvalidInput( + "execute.code 不能为空".to_string(), + )); + } + if code.as_bytes().len() > MAX_EXECUTE_CODE_BYTES { + return Err(BridgeError::InvalidInput(format!( + "execute.code 超过 {} 字节上限", + MAX_EXECUTE_CODE_BYTES + ))); + } + if code.chars().any(|character| character == '\0') { + return Err(BridgeError::InvalidInput( + "execute.code 不能包含 NUL 字符".to_string(), + )); + } + Ok(()) +} + +fn new_command_request_id(process_id: u32) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); + format!("cocos-{process_id}-{now:032x}-{sequence:016x}") +} + +#[cfg(any(all(feature = "windows-transport", windows), test))] +fn parse_command_response( + bytes: &[u8], + envelope: &CocosEditorCommandEnvelope, +) -> Result { + if bytes.len() > MAX_COMMAND_RESPONSE_BYTES { + return Err(BridgeError::Transport( + "bridge 响应超过大小上限".to_string(), + )); + } + let response = serde_json::from_slice::(bytes) + .map_err(|error| BridgeError::Transport(format!("bridge 响应 JSON 无效:{error}")))?; + if response.schema_version != BRIDGE_SCHEMA_VERSION + || response.request_id != envelope.request_id + || response.process_id != envelope.process_id + { + return Err(BridgeError::Transport( + "bridge 响应身份与请求不匹配".to_string(), + )); + } + if !response.ok + && response + .error + .as_deref() + .is_none_or(|error| error.trim().is_empty()) + { + return Err(BridgeError::Transport( + "bridge 失败响应缺少错误信息".to_string(), + )); + } + Ok(response) +} + +#[cfg(all(feature = "windows-transport", windows))] +fn send_command_over_pipe( + process_id: u32, + envelope: &CocosEditorCommandEnvelope, + timeout_ms: u32, +) -> Result { + use std::ptr; + use std::time::{Duration, Instant}; + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_IO_PENDING, GENERIC_READ, GENERIC_WRITE, HANDLE, + INVALID_HANDLE_VALUE, WAIT_OBJECT_0, + }; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, ReadFile, WriteFile, FILE_FLAG_OVERLAPPED, FILE_SHARE_NONE, OPEN_EXISTING, + SECURITY_IDENTIFICATION, SECURITY_SQOS_PRESENT, + }; + use windows_sys::Win32::System::Pipes::{GetNamedPipeServerProcessId, WaitNamedPipeW}; + use windows_sys::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; + use windows_sys::Win32::System::IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}; + + fn pipe_io(handle: HANDLE, deadline: Instant, start: F) -> Result + where + F: FnOnce(*mut OVERLAPPED, *mut u32) -> i32, + { + let event = unsafe { CreateEventW(ptr::null(), 1, 0, ptr::null()) }; + if event.is_null() { + return Err(BridgeError::Transport( + "创建 pipe I/O event 失败".to_string(), + )); + } + let mut overlapped = OVERLAPPED::default(); + overlapped.hEvent = event; + let mut transferred = 0; + let started = start(&mut overlapped, &mut transferred); + let error = if started == 0 { + unsafe { GetLastError() } + } else { + 0 + }; + let result = if started != 0 { + Ok(transferred) + } else if error != ERROR_IO_PENDING { + Err(BridgeError::Transport(format!( + "pipe I/O 失败,错误码 {error}" + ))) + } else { + let remaining = deadline.saturating_duration_since(Instant::now()); + let wait_ms = remaining.as_millis().min(u32::MAX as u128) as u32; + if unsafe { WaitForSingleObject(event, wait_ms) } != WAIT_OBJECT_0 { + // Drain cancellation before the stack OVERLAPPED or its buffer + // can be dropped. No I/O worker retains a borrowed pointer. + unsafe { + CancelIoEx(handle, &overlapped); + GetOverlappedResult(handle, &overlapped, &mut transferred, 1); + } + Err(BridgeError::Transport("bridge pipe I/O 超时".to_string())) + } else if unsafe { GetOverlappedResult(handle, &overlapped, &mut transferred, 0) } == 0 + { + Err(BridgeError::Transport(format!( + "pipe I/O 失败,错误码 {}", + unsafe { GetLastError() } + ))) + } else { + Ok(transferred) + } + }; + unsafe { CloseHandle(event) }; + result + } + + let pipe_name = bridge_pipe_name(process_id); + let wide_name = pipe_name + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + let handle: HANDLE = loop { + let handle = unsafe { + CreateFileW( + wide_name.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_NONE, + ptr::null(), + OPEN_EXISTING, + FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, + ptr::null_mut(), + ) + }; + if handle != INVALID_HANDLE_VALUE && !handle.is_null() { + break handle; + } + let error = unsafe { GetLastError() }; + if Instant::now() >= deadline { + return Err(BridgeError::Transport(format!( + "打开 bridge pipe 超时(错误码 {error})" + ))); + } + if error != 2 && error != 231 { + return Err(BridgeError::Transport(format!( + "打开 bridge pipe 失败,错误码 {error}" + ))); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + let wait_ms = remaining.as_millis().clamp(1, u32::MAX as u128) as u32; + let _ = unsafe { WaitNamedPipeW(wide_name.as_ptr(), wait_ms) }; + if error == 2 { + std::thread::sleep(Duration::from_millis(5).min(remaining)); + } + }; + + let mut dispatched = false; + let response = (|| { + let mut server_pid = 0; + if unsafe { GetNamedPipeServerProcessId(handle, &mut server_pid) } == 0 + || server_pid != process_id + { + return Err(BridgeError::Transport( + "bridge pipe 服务端 PID 不匹配,未发送代码".to_string(), + )); + } + let mut request_bytes = serde_json::to_vec(envelope) + .map_err(|error| BridgeError::Transport(format!("序列化 Cocos 命令失败:{error}")))?; + request_bytes.push(b'\n'); + if request_bytes.len() > MAX_COMMAND_REQUEST_BYTES { + return Err(BridgeError::Transport( + "Cocos 命令帧超过大小上限".to_string(), + )); + } + dispatched = true; + let written = pipe_io(handle, deadline, |overlapped, written| unsafe { + WriteFile( + handle, + request_bytes.as_ptr(), + request_bytes.len() as u32, + written, + overlapped, + ) + })?; + if written as usize != request_bytes.len() { + return Err(BridgeError::Transport("bridge 命令未完整写入".to_string())); + } + + let mut response_bytes = Vec::new(); + let mut buffer = [0u8; 8192]; + loop { + if Instant::now() >= deadline { + return Err(BridgeError::Transport("等待 bridge 响应超时".to_string())); + } + let read = pipe_io(handle, deadline, |overlapped, read| unsafe { + ReadFile( + handle, + buffer.as_mut_ptr(), + buffer.len() as u32, + read, + overlapped, + ) + })?; + if read == 0 { + return Err(BridgeError::Transport("bridge 未返回完整响应".to_string())); + } + response_bytes.extend_from_slice(&buffer[..read as usize]); + if response_bytes.len() > MAX_COMMAND_RESPONSE_BYTES { + return Err(BridgeError::Transport( + "bridge 响应超过大小上限".to_string(), + )); + } + if let Some(position) = response_bytes.iter().position(|byte| *byte == b'\n') { + response_bytes.truncate(position); + break; + } + } + parse_command_response(&response_bytes, envelope) + })(); + unsafe { CloseHandle(handle) }; + response.map_err(|error| { + if dispatched && matches!(envelope.command, CocosEditorCommand::Execute { .. }) { + BridgeError::ExecutionUncertain(error.to_string()) + } else { + error + } + }) +} + +fn execute_cocos_editor_command( + process_id: u32, + project_path: &str, + command: CocosEditorCommand, + timeout_ms: u32, +) -> Result { + if timeout_ms == 0 || timeout_ms > 60_000 { + return Err(BridgeError::InvalidInput( + "timeoutMs 必须在 1..=60000".to_string(), + )); + } + if let CocosEditorCommand::Execute { code } = &command { + validate_execute_code(code)?; + } + validate_injection_target(process_id, project_path)?; + let envelope = CocosEditorCommandEnvelope { + schema_version: BRIDGE_SCHEMA_VERSION.to_string(), + request_id: new_command_request_id(process_id), + process_id, + project_path: normalize_existing_directory(Path::new(project_path))?, + command, + }; + #[cfg(all(feature = "windows-transport", windows))] + { + return send_command_over_pipe(process_id, &envelope, timeout_ms); + } + #[cfg(not(all(feature = "windows-transport", windows)))] + { + let _ = envelope; + Err(if cfg!(windows) { + BridgeError::FeatureDisabled + } else { + BridgeError::UnsupportedPlatform + }) + } +} + +pub fn ping_cocos_editor( + process_id: u32, + project_path: &str, + timeout_ms: u32, +) -> Result { + execute_cocos_editor_command( + process_id, + project_path, + CocosEditorCommand::Ping, + timeout_ms, + ) +} + +pub fn status_cocos_editor( + process_id: u32, + project_path: &str, + timeout_ms: u32, +) -> Result { + execute_cocos_editor_command( + process_id, + project_path, + CocosEditorCommand::Status, + timeout_ms, + ) +} + +pub fn execute_cocos_editor_code( + process_id: u32, + project_path: &str, + code: &str, + timeout_ms: u32, +) -> Result { + execute_cocos_editor_command( + process_id, + project_path, + CocosEditorCommand::Execute { + code: code.to_string(), + }, + timeout_ms, + ) +} + +/// Execute code against the unique Creator host opened on `project_path`. +/// This is the Runtime-facing entry point: callers do not need to expose a +/// PID or a process-list command to the model. Multiple Creator hosts for the +/// same project fail closed rather than guessing. +pub fn execute_cocos_editor_code_for_project( + project_path: &str, + code: &str, + timeout_ms: u32, +) -> Result { + validate_execute_code(code)?; + if timeout_ms == 0 || timeout_ms > 60_000 { + return Err(BridgeError::InvalidInput( + "timeoutMs 必须在 1..=60000".to_string(), + )); + } + let normalized_project = normalize_existing_directory(Path::new(project_path))?; + let discovery = discover_cocos_editors()?; + let mut matches = discovery.processes.into_iter().filter(|process| { + process.project_path.as_deref().is_some_and(|candidate| { + paths_equal(Path::new(candidate), Path::new(&normalized_project)) + }) + }); + let target = matches.next().ok_or_else(|| { + BridgeError::IdentityMismatch("项目没有唯一匹配的已打开 Cocos Creator 主进程".to_string()) + })?; + if matches.next().is_some() { + return Err(BridgeError::TargetAmbiguous(normalized_project)); + } + execute_cocos_editor_code(target.process_id, &normalized_project, code, timeout_ms) +} + +fn paths_equal(left: &Path, right: &Path) -> bool { + let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + #[cfg(windows)] + { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) + } + #[cfg(not(windows))] + { + left == right + } +} + +fn is_cocos_creator_executable(path: &Path) -> bool { + path.file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("CocosCreator.exe")) +} + +/// Inject a trusted native bridge DLL into the already-running Creator host. +/// +/// The feature is deliberately separate from discovery. Without +/// `windows-injection`, this function fails closed. Successful DLL loading is +/// reported as `injected-unverified`: a real connection requires the payload +/// to establish the documented bridge handshake after it enters Electron. +#[cfg(all(feature = "windows-injection", windows))] +pub fn inject_bridge_dll( + request: &CocosEditorInjectionRequest, +) -> Result { + use std::ffi::c_void; + use std::mem::transmute; + use std::ptr; + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, HANDLE, HMODULE, WAIT_OBJECT_0, + }; + use windows_sys::Win32::System::Diagnostics::Debug::WriteProcessMemory; + use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; + use windows_sys::Win32::System::Memory::{ + VirtualAllocEx, VirtualFreeEx, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, + }; + use windows_sys::Win32::System::Threading::{ + CreateRemoteThread, GetExitCodeThread, OpenProcess, WaitForSingleObject, + PROCESS_CREATE_THREAD, PROCESS_QUERY_INFORMATION, PROCESS_VM_OPERATION, PROCESS_VM_READ, + PROCESS_VM_WRITE, + }; + + let target = validate_injection_request(request)?; + let payload = validate_payload(Path::new(&request.bridge_dll_path))?; + let payload_sha256 = payload_sha256(&payload)?; + let payload_text = payload.to_string_lossy(); + let mut wide: Vec = payload_text.encode_utf16().collect(); + wide.push(0); + let access = PROCESS_CREATE_THREAD + | PROCESS_QUERY_INFORMATION + | PROCESS_VM_OPERATION + | PROCESS_VM_READ + | PROCESS_VM_WRITE; + let process: HANDLE = unsafe { OpenProcess(access, 0, request.process_id) }; + if process.is_null() { + return Err(BridgeError::InjectionFailed(format!( + "OpenProcess 失败,错误码 {}", + unsafe { GetLastError() } + ))); + } + let result = (|| { + let allocation = unsafe { + VirtualAllocEx( + process, + ptr::null(), + wide.len() * std::mem::size_of::(), + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE, + ) + }; + if allocation.is_null() { + return Err(BridgeError::InjectionFailed(format!( + "VirtualAllocEx 失败,错误码 {}", + unsafe { GetLastError() } + ))); + } + let mut written = 0usize; + let write_ok = unsafe { + WriteProcessMemory( + process, + allocation, + wide.as_ptr().cast::(), + wide.len() * std::mem::size_of::(), + &mut written, + ) + } != 0; + if !write_ok || written != wide.len() * std::mem::size_of::() { + unsafe { VirtualFreeEx(process, allocation, 0, MEM_RELEASE) }; + return Err(BridgeError::InjectionFailed(format!( + "WriteProcessMemory 失败,错误码 {}", + unsafe { GetLastError() } + ))); + } + let kernel = unsafe { GetModuleHandleA(c"kernel32.dll".as_ptr().cast()) } as HMODULE; + let load_library = unsafe { GetProcAddress(kernel, c"LoadLibraryW".as_ptr().cast()) }; + let Some(load_library) = load_library else { + unsafe { VirtualFreeEx(process, allocation, 0, MEM_RELEASE) }; + return Err(BridgeError::InjectionFailed( + "找不到 LoadLibraryW".to_string(), + )); + }; + let start_routine = unsafe { + transmute::< + unsafe extern "system" fn() -> isize, + unsafe extern "system" fn(*mut c_void) -> u32, + >(load_library) + }; + let thread = unsafe { + CreateRemoteThread( + process, + ptr::null(), + 0, + Some(start_routine), + allocation, + 0, + ptr::null_mut(), + ) + }; + if thread.is_null() { + unsafe { VirtualFreeEx(process, allocation, 0, MEM_RELEASE) }; + return Err(BridgeError::InjectionFailed(format!( + "CreateRemoteThread 失败,错误码 {}", + unsafe { GetLastError() } + ))); + } + let wait = unsafe { WaitForSingleObject(thread, request.timeout_ms) }; + if wait != WAIT_OBJECT_0 { + unsafe { CloseHandle(thread) }; + // Keep the allocation alive when the remote loader is still running. + return Err(BridgeError::InjectionFailed( + "等待远程 LoadLibrary 线程超时,状态需要人工核对".to_string(), + )); + } + let mut exit_code = 0u32; + let exit_ok = unsafe { GetExitCodeThread(thread, &mut exit_code) } != 0; + unsafe { + CloseHandle(thread); + VirtualFreeEx(process, allocation, 0, MEM_RELEASE); + } + if !exit_ok || exit_code == 0 { + return Err(BridgeError::InjectionFailed( + "远程 LoadLibraryW 未返回成功模块句柄".to_string(), + )); + } + Ok(()) + })(); + unsafe { CloseHandle(process) }; + result?; + Ok(CocosEditorInjectionResult { + schema_version: BRIDGE_SCHEMA_VERSION.to_string(), + status: CocosEditorInjectionStatus::InjectedUnverified, + process_id: target.process_id, + project_path: target.project_path.unwrap_or_default(), + creator_version: target.creator_version, + payload_sha256: Some(payload_sha256), + detail: Some("DLL 已加载;等待 payload 建立 Cocos/Electron bridge 握手".to_string()), + }) +} + +#[cfg(not(all(feature = "windows-injection", windows)))] +pub fn inject_bridge_dll( + _request: &CocosEditorInjectionRequest, +) -> Result { + if cfg!(windows) { + Err(BridgeError::FeatureDisabled) + } else { + Err(BridgeError::UnsupportedPlatform) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + #[cfg(feature = "process-discovery")] + fn tokenizes_creator_command_line_and_extracts_project() { + let line = r#"E:\Software\Cocos Creator\CocosCreator.exe --project "F:\Projects\demo game" --can-show-upgrade-dialog true"#; + assert_eq!( + parse_project_argument(line).as_deref(), + Some(r#"F:\Projects\demo game"#) + ); + assert_eq!( + parse_project_argument(r#"C:\Creator\CocosCreator.exe --project=F:\Projects\demo"#) + .as_deref(), + Some(r#"F:\Projects\demo"#) + ); + assert!(!command_line_is_electron_child(line)); + } + + #[test] + #[cfg(feature = "process-discovery")] + fn filters_electron_child_processes() { + assert!(command_line_is_electron_child( + r#"C:\Creator\CocosCreator.exe --type=renderer --project C:\demo"# + )); + assert!(command_line_is_electron_child( + r#"C:\Creator\CocosCreator.exe --type gpu-process"# + )); + } + + #[test] + #[cfg(feature = "process-discovery")] + fn parses_single_and_array_process_json() { + let single = parse_process_rows( + r#"{"ProcessId":42,"ParentProcessId":1,"ExecutablePath":"C:\\CocosCreator.exe","CommandLine":null}"#, + ) + .expect("single row"); + assert_eq!(single[0].process_id, 42); + let array = parse_process_rows( + r#"[{"ProcessId":42,"ParentProcessId":1,"ExecutablePath":"C:\\CocosCreator.exe"}]"#, + ) + .expect("array rows"); + assert_eq!(array.len(), 1); + } + + #[test] + fn rejects_non_dll_payload_and_missing_project_identity() { + let directory = tempfile_dir(); + let payload = directory.join("bridge.txt"); + fs::write(&payload, b"payload").expect("payload"); + assert!(matches!( + validate_payload(&payload), + Err(BridgeError::PayloadRejected(_)) + )); + } + + #[test] + fn execute_command_is_small_and_explicitly_tagged() { + let command = CocosEditorCommand::Execute { + code: "return 1 + 1;".to_string(), + }; + let value = serde_json::to_value(command).expect("serialize execute command"); + assert_eq!(value["op"], "execute"); + assert_eq!(value["code"], "return 1 + 1;"); + } + + #[test] + fn command_response_requires_matching_identity_and_error_on_failure() { + let envelope = CocosEditorCommandEnvelope { + schema_version: BRIDGE_SCHEMA_VERSION.to_string(), + request_id: "request-1".to_string(), + process_id: 42, + project_path: "C:\\demo".to_string(), + command: CocosEditorCommand::Ping, + }; + let ok = serde_json::json!({ + "schemaVersion": BRIDGE_SCHEMA_VERSION, + "requestId": "request-1", + "processId": 42, + "ok": true, + "result": {"ready": true} + }); + assert!(parse_command_response(ok.to_string().as_bytes(), &envelope).is_ok()); + let mismatch = serde_json::json!({ + "schemaVersion": BRIDGE_SCHEMA_VERSION, + "requestId": "other", + "processId": 42, + "ok": true + }); + assert!(matches!( + parse_command_response(mismatch.to_string().as_bytes(), &envelope), + Err(BridgeError::Transport(_)) + )); + let missing_error = serde_json::json!({ + "schemaVersion": BRIDGE_SCHEMA_VERSION, + "requestId": "request-1", + "processId": 42, + "ok": false + }); + assert!(matches!( + parse_command_response(missing_error.to_string().as_bytes(), &envelope), + Err(BridgeError::Transport(_)) + )); + } + + #[test] + fn execute_code_rejects_empty_nul_and_oversized_payload_before_discovery() { + for code in [ + String::new(), + "\0".to_string(), + "x".repeat(MAX_EXECUTE_CODE_BYTES + 1), + ] { + assert!(matches!( + execute_cocos_editor_code(1, "C:\\missing", &code, DEFAULT_COMMAND_TIMEOUT_MS), + Err(BridgeError::InvalidInput(_)) + )); + } + } + + #[cfg(all(windows, feature = "windows-transport"))] + #[test] + #[ignore = "requires Node.js; uses an owned fake Editor process, never a real Creator"] + fn named_pipe_roundtrip_with_node_bootstrap() { + use std::os::windows::process::CommandExt; + struct OwnedChild(std::process::Child); + impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let root = tempfile_dir(); + let bootstrap = Path::new(env!("CARGO_MANIFEST_DIR")).join("payload/bootstrap.cjs"); + let child = Command::new("node") + .args(["-e", "require(process.argv[1]).install({Project:{path:process.argv[2]},App:{version:'fixture'},Message:{request:async(...args)=>args}}).ready.catch(()=>process.exit(1));"]) + .arg(bootstrap) + .arg(&root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(0x0800_0000) + .spawn() + .expect("spawn owned Node fixture"); + let owned = OwnedChild(child); + let pid = owned.0.id(); + let envelope = CocosEditorCommandEnvelope { + schema_version: BRIDGE_SCHEMA_VERSION.to_string(), + request_id: "pipe-roundtrip".to_string(), + process_id: pid, + project_path: root.to_string_lossy().into_owned(), + command: CocosEditorCommand::Execute { + code: "return await Editor.Message.request('scene', 'query-node-tree');" + .to_string(), + }, + }; + let response = send_command_over_pipe(pid, &envelope, 5_000).expect("pipe execute"); + assert!(response.ok); + assert_eq!( + response.result, + Some(serde_json::json!(["scene", "query-node-tree"])) + ); + let timeout = CocosEditorCommandEnvelope { + request_id: "pipe-timeout".to_string(), + command: CocosEditorCommand::Execute { + code: "await new Promise(() => {});".to_string(), + }, + ..envelope + }; + assert!(matches!( + send_command_over_pipe(pid, &timeout, 50), + Err(BridgeError::ExecutionUncertain(_)) + )); + } + + fn tempfile_dir() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("cocos-editor-bridge-test-{}", std::process::id())); + fs::create_dir_all(&path).expect("temp dir"); + let _ = std::fs::File::create(path.join("keep")) + .and_then(|mut file| file.write_all(b"fixture")); + path + } +} diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 0737447c7..a6000cf6f 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 64] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 65] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.rename", GameCreationAppPermission::Confirm), @@ -76,6 +76,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 64] = [ command("command.poll", GameCreationAppPermission::Auto), command("command.stdin", GameCreationAppPermission::Confirm), command("command.terminate", GameCreationAppPermission::Confirm), + command("cocos.editor.execute", GameCreationAppPermission::Confirm), command("canvas.project_open", GameCreationAppPermission::Confirm), command("canvas.project_sync", GameCreationAppPermission::Confirm), command("canvas.asset_import", GameCreationAppPermission::Confirm), @@ -966,7 +967,7 @@ mod tests { #[test] fn command_contract_keeps_expected_permissions() { - assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 64); + assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 65); let command_ids = GAME_CREATION_APP_COMMANDS .iter()