接入 Godot 编辑器插件与受控执行链路

新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存
接入 AGC 插件开关、Runner、Agent 工具与权限审计
完善执行回执确认、不确定状态阻断及卸载恢复
补齐 Windows 分发资源、定向测试与实机验收文档
This commit is contained in:
kdletters
2026-09-20 16:35:04 +08:00
parent 15774a1c02
commit 54d0fb75ea
77 changed files with 11545 additions and 582 deletions
@@ -106,6 +106,7 @@ export const agcReleasePathPatterns = [
'server-rs/crates/',
'plugins/agc-cocos-editor/',
'plugins/agc-unity-editor/',
'plugins/agc-godot-editor/',
'apps/desktop-shell/src-tauri/icons/',
'package.json',
'package-lock.json',
@@ -452,7 +452,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
spawn: (_binary, command) => {
assert.ok(
command.includes(
'--features=cocos-editor-execute,unity-editor-execute',
'--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute',
),
);
assert.ok(command.includes('user-config.json'));
@@ -19,6 +19,6 @@ export function withDefaultCargoFeatures(argv, features) {
export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute', 'unity-editor-execute']
? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute']
: [];
}
@@ -9,7 +9,7 @@ test('Windows release includes the same editor feature as development', () => {
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
[
'build',
'--features=cocos-editor-execute,unity-editor-execute',
'--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute',
'--target',
'x86_64-pc-windows-msvc',
],
+13
View File
@@ -1755,6 +1755,7 @@ dependencies = [
"editor-adapter-api",
"futures",
"getrandom 0.3.4",
"godot-editor-bridge",
"http",
"image",
"jsonschema",
@@ -1981,6 +1982,18 @@ dependencies = [
"system-deps",
]
[[package]]
name = "godot-editor-bridge"
version = "0.1.0"
dependencies = [
"editor-adapter-api",
"serde",
"serde_json",
"sha2",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "gtk"
version = "0.18.2"
@@ -13,6 +13,7 @@ cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
unity-editor-execute = []
godot-editor-execute = []
[build-dependencies]
serde = { version = "1", features = ["derive"] }
@@ -29,6 +30,7 @@ agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false }
editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" }
unity-editor-bridge = { path = "../../../plugins/agc-unity-editor/native/unity-editor-bridge" }
godot-editor-bridge = { path = "../../../plugins/agc-godot-editor/native/godot-editor-bridge" }
base64 = "0.22"
axum = "0.8"
chromiumoxide = "0.9.1"
@@ -2,6 +2,8 @@
mod codex_bundle;
#[path = "build_support/frontend_dist_guard.rs"]
mod frontend_dist_guard;
#[path = "build_support/godot_bundle.rs"]
mod godot_bundle;
#[path = "build_support/runtime_prompt_bundle.rs"]
mod runtime_prompt_bundle;
@@ -197,6 +199,7 @@ fn main() {
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir);
prepare_unity_editor_helper(&manifest_dir);
prepare_godot_editor_extension(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
@@ -368,6 +371,39 @@ fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec<PathBu
}
}
fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) {
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GODOT_EDITOR_EXECUTE");
if env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_none()
|| env::var("TARGET").expect("Cargo TARGET") != "x86_64-pc-windows-msvc"
{
return;
}
let root = manifest_dir.join("../../../plugins/agc-godot-editor/native/gdextension");
for source in godot_bundle::source_files(&root).unwrap_or_else(|error| panic!("{error}")) {
println!("cargo:rerun-if-changed={}", source.display());
}
assert!(
cfg!(windows),
"构建 Godot 原生扩展需要 Windows x64 C 编译器"
);
let status = std::process::Command::new("powershell.exe")
// Cargo 可能从 PowerShell 7 启动,Windows PowerShell 应使用自身模块目录。
.env_remove("PSModulePath")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(root.join("build.ps1"))
.current_dir(&root)
.status()
.expect("无法启动 Godot 原生扩展构建脚本");
assert!(status.success(), "Godot 原生扩展构建失败");
godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}"));
}
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
@@ -428,6 +464,15 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
}
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
}
if name == "agc-godot-editor" {
godot_bundle::stage(
&plugin_root.join("native/gdextension"),
&destination.join("native/gdextension"),
&target,
env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(),
)
.unwrap_or_else(|error| panic!("{error}"));
}
println!("cargo:rerun-if-changed={}", plugin_root.display());
}
}
@@ -0,0 +1,215 @@
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
pub const BUNDLE_FILES: [&str; 4] = [
"bin/win-x64/agc_godot_editor.dll",
"bin/win-x64/metadata.json",
"vendor/LICENSE.txt",
"vendor/provenance.json",
];
fn plain_metadata(path: &Path) -> Result<fs::Metadata, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("Godot 资源不可读 {}{error}", path.display()))?;
#[cfg(windows)]
let linked = {
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x400 != 0
};
#[cfg(not(windows))]
let linked = metadata.file_type().is_symlink();
if linked {
return Err(format!("Godot 资源不能经过链接:{}", path.display()));
}
Ok(metadata)
}
fn read_bundle_file(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
plain_metadata(root)?;
let mut path = root.to_path_buf();
for component in Path::new(relative).components() {
path.push(component);
plain_metadata(&path)?;
}
let metadata = plain_metadata(&path)?;
if !metadata.is_file() || metadata.len() == 0 {
return Err(format!("Godot 随包资源缺失或为空:{}", path.display()));
}
if relative.ends_with("metadata.json") && metadata.len() > 64 * 1024 {
return Err("Godot 构建元数据超过 64 KiB".to_string());
}
fs::read(&path).map_err(|error| format!("读取 Godot 资源失败:{error}"))
}
pub fn validate(root: &Path) -> Result<Vec<(&'static str, Vec<u8>)>, String> {
let files = BUNDLE_FILES
.iter()
.map(|relative| read_bundle_file(root, relative).map(|bytes| (*relative, bytes)))
.collect::<Result<Vec<_>, _>>()?;
let metadata: serde_json::Value = serde_json::from_slice(&files[1].1)
.map_err(|error| format!("Godot 构建元数据无效:{error}"))?;
for (field, expected) in [
("protocol", "agc.godot.editor.v1"),
("platform", "windows"),
("arch", "x86_64"),
("entrySymbol", "agc_godot_editor_init"),
("minimumGodotVersion", "4.7"),
] {
if metadata[field].as_str() != Some(expected) {
return Err(format!("Godot 构建元数据 {field} 不匹配"));
}
}
if !metadata["buildId"].as_str().is_some_and(|value| {
value.strip_prefix("sha256:").is_some_and(|digest| {
digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
})
}) {
return Err("Godot 构建身份无效".to_string());
}
let actual_sha256 = format!("{:x}", Sha256::digest(&files[0].1));
if metadata["sha256"].as_str() != Some(actual_sha256.as_str()) {
return Err("Godot DLL 与构建元数据 SHA256 不匹配".to_string());
}
Ok(files)
}
pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> {
if target != "x86_64-pc-windows-msvc" || !enabled {
return Ok(());
}
for (relative, bytes) in validate(root)? {
let path = destination.join(relative);
fs::create_dir_all(path.parent().expect("Godot resource parent"))
.map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?;
fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?;
}
Ok(())
}
pub fn source_files(root: &Path) -> Result<Vec<PathBuf>, String> {
plain_metadata(root)?;
let mut sources = Vec::new();
for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))?
{
let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?;
if matches!(entry.file_name().to_str(), Some("bin" | ".build")) {
continue;
}
let metadata = plain_metadata(&entry.path())?;
if metadata.is_dir() {
sources.extend(source_files(&entry.path())?);
} else if metadata.is_file() {
sources.push(entry.path());
}
}
sources.sort();
Ok(sources)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(root: &Path) {
for relative in BUNDLE_FILES {
let path = root.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, b"fixture").unwrap();
}
fs::write(
root.join(BUNDLE_FILES[1]),
serde_json::to_vec(&serde_json::json!({
"protocol": "agc.godot.editor.v1",
"platform": "windows",
"arch": "x86_64",
"entrySymbol": "agc_godot_editor_init",
"minimumGodotVersion": "4.7",
"buildId": format!("sha256:{}", "a".repeat(64)),
"sha256": format!("{:x}", Sha256::digest(b"fixture")),
}))
.unwrap(),
)
.unwrap();
}
#[test]
fn stage_only_verified_windows_runtime_and_not_build_inputs() {
let source = tempfile::tempdir().unwrap();
let destination = tempfile::tempdir().unwrap();
fixture(source.path());
fs::write(source.path().join("bridge.gd"), "source").unwrap();
fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap();
stage(
source.path(),
destination.path(),
"x86_64-pc-windows-msvc",
true,
)
.unwrap();
for relative in BUNDLE_FILES {
assert_eq!(
fs::read(source.path().join(relative)).unwrap(),
fs::read(destination.path().join(relative)).unwrap()
);
}
assert!(!destination.path().join("bridge.gd").exists());
assert!(!destination.path().join("bin/win-x64/extra.dll").exists());
}
#[test]
fn unsupported_or_disabled_targets_need_no_native_artifacts() {
let destination = tempfile::tempdir().unwrap();
for (target, enabled) in [
("aarch64-apple-darwin", true),
("x86_64-apple-darwin", true),
("x86_64-unknown-linux-gnu", true),
("aarch64-pc-windows-msvc", true),
("x86_64-pc-windows-msvc", false),
] {
stage(
Path::new("missing-godot-native"),
destination.path(),
target,
enabled,
)
.unwrap();
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
}
}
#[test]
fn incomplete_or_tampered_bundle_fails_before_copying() {
let source = tempfile::tempdir().unwrap();
let destination = tempfile::tempdir().unwrap();
fixture(source.path());
fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap();
assert!(stage(
source.path(),
destination.path(),
"x86_64-pc-windows-msvc",
true
)
.unwrap_err()
.contains("SHA256"));
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
fixture(source.path());
fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap();
assert!(validate(source.path()).is_err());
}
#[test]
fn source_watch_list_excludes_build_outputs() {
let source = tempfile::tempdir().unwrap();
fixture(source.path());
fs::create_dir(source.path().join(".build")).unwrap();
fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap();
fs::write(source.path().join("bridge.gd"), "source").unwrap();
let sources = source_files(source.path()).unwrap();
assert_eq!(sources.len(), 3);
assert!(sources.contains(&source.path().join("bridge.gd")));
assert!(!sources.iter().any(|path| path
.components()
.any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build")));
}
}
File diff suppressed because one or more lines are too long
@@ -2610,16 +2610,56 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str)
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
bridge_editor_execute(
state,
arguments,
"unity.editor.execute",
"Unity",
"C# 代码",
crate::builtin_plugins::unity_editor_agent_tool_available_for_project,
crate::editor_adapters::execute_unity_editor_code,
)
.await
}
#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))]
async fn bridge_godot_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
bridge_editor_execute(
state,
arguments,
"godot.editor.execute",
"Godot",
"GDScript 函数体",
crate::builtin_plugins::godot_editor_agent_tool_available_for_project,
crate::editor_adapters::execute_godot_editor_code,
)
.await
}
#[cfg(all(
windows,
target_arch = "x86_64",
any(feature = "unity-editor-execute", feature = "godot-editor-execute")
))]
async fn bridge_editor_execute(
state: &DirectToolBridgeState,
arguments: &Value,
tool: &'static str,
editor: &'static str,
language: &str,
available: fn(&Path) -> bool,
execute: fn(&Path, &str) -> Result<Value, String>,
) -> Value {
let prepared = (|| {
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&state.root) {
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
if !available(&state.root) {
return Err(format!("当前项目不是 {editor} 项目或 {editor} 插件不可用"));
}
enforce_project_permission_policy(&state.root, "unity.editor.execute")?;
enforce_project_permission_policy(&state.root, tool)?;
bridge_reject_unknown_fields(arguments, &["code"])?;
let code = arguments
.get("code")
.and_then(Value::as_str)
.ok_or_else(|| "code 必须是 C# 代码".to_string())?;
.ok_or_else(|| format!("code 必须是 {language}"))?;
if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') {
return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string());
}
@@ -2637,10 +2677,10 @@ async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value)
};
let root = state.root.clone();
let result = tokio::task::spawn_blocking(move || {
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&root) {
return Err("当前 Unity 插件不可用".to_string());
if !available(&root) {
return Err(format!("当前 {editor} 插件不可用"));
}
crate::editor_adapters::execute_unity_editor_code(&root, &code)
execute(&root, &code)
})
.await;
match result {
@@ -2649,7 +2689,7 @@ async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value)
bridge_tool_result(redact_agent_runtime_error(&state.root, &response.to_string(), 32_000), Vec::new(), failed)
}
Ok(Err(error)) => bridge_tool_result(redact_agent_runtime_error(&state.root, &error, 480), Vec::new(), true),
Err(_) => bridge_tool_result(json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"error":"Unity 执行任务异常,请人工核对结果"}).to_string(), Vec::new(), true),
Err(_) => bridge_tool_result(json!({"ok":false,"status":"needs-reconciliation","dispatched":true,"retryAllowed":false,"error":format!("{editor} 执行任务异常,请人工核对结果")}).to_string(), Vec::new(), true),
}
}
@@ -2855,6 +2895,8 @@ async fn handle_direct_tool_bridge(
"agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments).await,
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
"agc_unity_execute" => bridge_unity_execute(&state, &request.arguments).await,
#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))]
"agc_godot_execute" => bridge_godot_execute(&state, &request.arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
operation if cocos_editor_bridge::is_cocos_operation(operation) => {
bridge_cocos_call(&state, &request.arguments, Some(operation)).await
@@ -76,12 +76,18 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32>
async fn direct_tools_mcp_specs() -> Value {
let mut cocos_editor_available = false;
let mut unity_editor_available = false;
let mut godot_editor_available = false;
if cfg!(all(windows, feature = "cocos-editor-execute"))
|| cfg!(all(
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
))
|| cfg!(all(
windows,
target_arch = "x86_64",
feature = "godot-editor-execute"
))
{
// 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。
if let Ok(result) = tokio::time::timeout(
@@ -111,6 +117,14 @@ async fn direct_tools_mcp_specs() -> Value {
.iter()
.any(|tool| tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)
});
godot_editor_available = availability
.as_ref()
.and_then(|v| v["tools"].as_array())
.is_some_and(|tools| {
tools
.iter()
.any(|tool| tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME)
});
}
}
}
@@ -118,6 +132,7 @@ async fn direct_tools_mcp_specs() -> Value {
controlled_web_search_enabled(),
cocos_editor_available,
unity_editor_available,
godot_editor_available,
)
}
@@ -149,13 +164,14 @@ fn resource_tool_prompt_schema_max_chars() -> usize {
#[cfg(test)]
fn direct_tools_mcp_specs_for(controlled_web_search: bool, cocos_editor_available: bool) -> Value {
direct_tools_mcp_specs_for_plugins(controlled_web_search, cocos_editor_available, false)
direct_tools_mcp_specs_for_plugins(controlled_web_search, cocos_editor_available, false, false)
}
fn direct_tools_mcp_specs_for_plugins(
controlled_web_search: bool,
_cocos_editor_available: bool,
_unity_editor_available: bool,
_godot_editor_available: bool,
) -> Value {
let tools = vec![
json!({
@@ -604,6 +620,14 @@ fn direct_tools_mcp_specs_for_plugins(
"inputSchema": {"type":"object", "properties":{"code":{"type":"string", "minLength":1, "maxLength":131072}}, "required":["code"], "additionalProperties":false}
}));
}
#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))]
if _godot_editor_available {
tools.push(json!({
"name": "agc_godot_execute",
"description": "在当前项目已打开的 Windows x64 Godot 4.7+ 标准编辑器执行支持 return/await 的 GDScript 函数体。宿主管理安装目录 DLL 和受管描述文件,聚焦自动加载,无需手跑脚本。仅提交 code;结果不确定时禁止自动重发。",
"inputSchema": {"type":"object", "properties":{"code":{"type":"string", "minLength":1, "maxLength":131072}}, "required":["code"], "additionalProperties":false}
}));
}
if controlled_web_search {
tools.push(json!({
"name": "agc_web_search",
@@ -693,11 +717,25 @@ async fn call_agc_cocos_execute(arguments: &Value) -> Value {
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
async fn call_agc_unity_execute(arguments: &Value) -> Value {
call_agc_editor_execute("agc_unity_execute", "C# 代码", arguments).await
}
#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))]
async fn call_agc_godot_execute(arguments: &Value) -> Value {
call_agc_editor_execute("agc_godot_execute", "GDScript 函数体", arguments).await
}
#[cfg(all(
windows,
target_arch = "x86_64",
any(feature = "unity-editor-execute", feature = "godot-editor-execute")
))]
async fn call_agc_editor_execute(tool: &str, language: &str, 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 必须是 C# 代码".to_string())?;
.ok_or_else(|| format!("code 必须是 {language}"))?;
if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') {
return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string());
}
@@ -706,7 +744,7 @@ async fn call_agc_unity_execute(arguments: &Value) -> Value {
if let Err(error) = validated {
return mcp_tool_result(error, Vec::new(), true);
}
call_client_tool_bridge("agc_unity_execute", arguments).await
call_client_tool_bridge(tool, arguments).await
}
fn mcp_success(id: Value, result: Value) -> Value {
@@ -1813,6 +1851,8 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
"agc_cocos_execute" => call_agc_cocos_execute(&arguments).await,
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
"agc_unity_execute" => call_agc_unity_execute(&arguments).await,
#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))]
"agc_godot_execute" => call_agc_godot_execute(&arguments).await,
#[cfg(all(windows, feature = "cocos-editor-execute"))]
operation if cocos_editor_bridge::is_cocos_operation(operation) => {
call_client_tool_bridge(operation, &arguments).await
@@ -2012,6 +2052,115 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn godot_tool_schema_is_code_only_and_follows_host_availability() {
for available in [false, true] {
let specs = direct_tools_mcp_specs_for_plugins(false, false, false, available);
let tool = specs["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "agc_godot_execute");
assert_eq!(
tool.is_some(),
available
&& cfg!(all(
windows,
target_arch = "x86_64",
feature = "godot-editor-execute"
))
);
if let Some(tool) = tool {
assert_eq!(tool["inputSchema"]["additionalProperties"], false);
assert_eq!(tool["inputSchema"]["required"], json!(["code"]));
assert_eq!(
tool["inputSchema"]["properties"].as_object().unwrap().len(),
1
);
}
}
}
#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))]
#[tokio::test]
async fn godot_mcp_rejects_target_override_and_invalid_code_before_bridge() {
for arguments in [
json!({"code":"return 42", "projectPath":"C:/other"}),
json!({"code":"return 42", "processId":123}),
json!({"code":"return 42", "dllPath":"C:/other.dll"}),
json!({"code":""}),
json!({"code":"a\u{0}b"}),
json!({"code":"".repeat(44_000)}),
] {
let response = call_agc_godot_execute(&arguments).await;
assert_eq!(response["isError"], true);
let text = response["content"][0]["text"].as_str().unwrap();
assert!(!text.contains("bridge"), "输入校验不应访问 bridge{text}");
}
}
#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))]
#[tokio::test]
async fn godot_tools_follow_bound_host_project_and_plugin_switch() {
let _guard = crate::builtin_plugins::test_lock();
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let project = crate::tests::canonical_test_tempdir("godot-mcp-project-");
std::fs::create_dir(project.path().join("game")).unwrap();
std::fs::write(
project.path().join("game/project.godot"),
"config_version=5\n",
)
.unwrap();
std::fs::create_dir(project.path().join(".agent")).unwrap();
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
let bridge =
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
.await
.unwrap();
for enabled in [false, true, false, true] {
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID,
enabled,
)
.unwrap();
let specs = EXTERNAL_MCP_BRIDGE_URL
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
.await;
assert_eq!(
specs["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| tool["name"] == "agc_godot_execute"),
enabled
);
if !enabled {
let response = EXTERNAL_MCP_BRIDGE_URL
.scope(
bridge.url().to_string(),
call_agc_godot_execute(&json!({"code":"return 42"})),
)
.await;
assert_eq!(response["isError"], true);
assert!(response.to_string().contains("不可用"));
}
}
std::fs::remove_file(project.path().join("game/project.godot")).unwrap();
let specs = EXTERNAL_MCP_BRIDGE_URL
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
.await;
assert!(!specs.to_string().contains("agc_godot_execute"));
drop(bridge);
let specs = EXTERNAL_MCP_BRIDGE_URL
.scope(
"http://127.0.0.1:1/unavailable".to_string(),
direct_tools_mcp_specs(),
)
.await;
assert!(!specs.to_string().contains("agc_godot_execute"));
}
#[test]
fn remove_background_arguments_enforce_mode_color_contract() {
for fields in [
@@ -1620,7 +1620,7 @@ 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" | "unity.editor.execute" => format!(
"cocos.editor.execute" | "unity.editor.execute" | "godot.editor.execute" => format!(
"codeChars={} · codeSha256={:x}",
chars(&["code"]),
Sha256::digest(
@@ -393,6 +393,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
true,
|| observe_agent_runtime_unity_editor_execute(root, action, pending_action),
),
"godot.editor.execute" => observe_agent_runtime_project_snapshot_with_lock(
root,
agent_id,
run_id,
action,
&action_fingerprint,
pending_action,
true,
|| observe_agent_runtime_godot_editor_execute(root, action, pending_action),
),
"preview.validate" => {
observe_agent_runtime_preview_validate(
root,
@@ -98,6 +98,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
#[cfg(feature = "cocos-editor-execute")]
"cocos.editor.execute" => Some("cocos.editor.execute"),
"unity.editor.execute" => Some("unity.editor.execute"),
"godot.editor.execute" => Some("godot.editor.execute"),
"preview.start" => Some("preview.start"),
"preview.validate" => Some("preview.validate"),
"image.inspect" => Some("image.inspect"),
@@ -78,6 +78,9 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
if crate::builtin_plugins::unity_editor_agent_tool_available() {
tools.push(crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME);
}
if crate::builtin_plugins::godot_editor_agent_tool_available() {
tools.push(crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME);
}
tools
}
@@ -167,6 +170,11 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
{
continue;
}
if tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME
&& !crate::builtin_plugins::godot_editor_agent_tool_available_for_project(root)
{
continue;
}
if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) {
denied_tools.push(tool.to_string());
continue;
@@ -213,6 +221,10 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
*tool != crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME
|| crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root)
})
.filter(|tool| {
*tool != crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME
|| crate::builtin_plugins::godot_editor_agent_tool_available_for_project(root)
})
.map(str::to_string)
.collect(),
auto_tools,
@@ -6,12 +6,12 @@ mod command_ops;
mod context;
mod delegation;
mod delivery;
mod editor_execute;
mod file_ops;
mod goal_contract;
mod helpers;
mod isolated_joins;
mod media;
mod unity_editor;
pub(in crate::agent) use media::design_foundation_ui_page_output_path_is_valid;
mod policy;
mod preview;
@@ -27,6 +27,7 @@ pub(in crate::agent) use command_ops::*;
pub(in crate::agent) use context::*;
pub(in crate::agent) use delegation::*;
pub(in crate::agent) use delivery::*;
pub(in crate::agent) use editor_execute::*;
pub(in crate::agent) use file_ops::*;
pub(in crate::agent) use goal_contract::*;
pub(in crate::agent) use helpers::*;
@@ -39,7 +40,6 @@ pub(in crate::agent) use project_ops::*;
pub(in crate::agent) use run_status::*;
pub(in crate::agent) use task_ops::*;
pub(in crate::agent) use ui_workflow::*;
pub(in crate::agent) use unity_editor::*;
#[cfg(test)]
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
@@ -0,0 +1,135 @@
use super::*;
use serde_json::Value;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct EditorExecuteInput {
code: String,
}
pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute(
root: &Path,
action: &AgentRuntimeToolAction,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation {
observe_agent_runtime_editor_execute(
root,
action,
pending_action,
"unity.editor.execute",
"Unity",
crate::builtin_plugins::unity_editor_agent_tool_available_for_project,
crate::editor_adapters::execute_unity_editor_code,
)
}
pub(in crate::agent) fn observe_agent_runtime_godot_editor_execute(
root: &Path,
action: &AgentRuntimeToolAction,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation {
observe_agent_runtime_editor_execute(
root,
action,
pending_action,
"godot.editor.execute",
"Godot",
crate::builtin_plugins::godot_editor_agent_tool_available_for_project,
crate::editor_adapters::execute_godot_editor_code,
)
}
fn observe_agent_runtime_editor_execute(
root: &Path,
action: &AgentRuntimeToolAction,
pending_action: Option<&AgentRuntimePendingToolAction>,
tool: &str,
editor: &str,
available: fn(&Path) -> bool,
execute: fn(&Path, &str) -> Result<Value, String>,
) -> AgentRuntimeToolObservation {
let execution = (|| {
let input: EditorExecuteInput = serde_json::from_value(action.input.clone())
.map_err(|error| format!("{tool} 输入无效:{error}"))?;
if pending_action.is_none() {
return Err(format!("{tool} 必须绑定 durable pending action"));
}
if !available(root) {
return Err(format!("当前项目不是 {editor} 项目或 {editor} 插件不可用"));
}
execute(root, &input.code)
})();
match execution {
Ok(response) => {
let status = match response["status"].as_str() {
Some("completed") if response["ok"] == true => "ok",
Some("needs-reconciliation") => {
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
}
_ => "failed",
};
AgentRuntimeToolObservation {
tool: tool.to_string(),
status: status.to_string(),
summary: match status {
"ok" => format!("{editor} 编辑器已返回执行成功回执"),
"needs-reconciliation" => format!("{editor} 执行结果待人工核对,禁止自动重发"),
_ => format!("{editor} 编辑器执行失败"),
},
detail: Some(redact_agent_runtime_project_paths(
root,
&response.to_string(),
32_000,
)),
}
}
Err(error) => AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 480),
detail: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unity_execute_requires_pending_action_and_rejects_project_override() {
for input in [
serde_json::json!({"code":"return 2;"}),
serde_json::json!({"code":"return 2;", "projectPath":"C:/other"}),
] {
let action = AgentRuntimeToolAction {
tool: "unity.editor.execute".to_string(),
reason: None,
input,
};
let observation =
observe_agent_runtime_unity_editor_execute(Path::new("C:/unity"), &action, None);
assert_eq!(observation.status, "failed");
}
}
#[test]
fn godot_execute_requires_pending_action_and_rejects_target_overrides() {
for input in [
serde_json::json!({"code":"return 42"}),
serde_json::json!({"code":"return 42", "projectPath":"C:/other"}),
serde_json::json!({"code":"return 42", "processId":123}),
serde_json::json!({"code":"return 42", "dllPath":"C:/other.dll"}),
] {
let action = AgentRuntimeToolAction {
tool: "godot.editor.execute".to_string(),
reason: None,
input,
};
let observation =
observe_agent_runtime_godot_editor_execute(Path::new("C:/godot"), &action, None);
assert_eq!(observation.tool, "godot.editor.execute");
assert_eq!(observation.status, "failed");
}
}
}
@@ -1,79 +0,0 @@
use super::*;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct UnityEditorExecuteInput {
code: String,
}
pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute(
root: &Path,
action: &AgentRuntimeToolAction,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation {
let execution = (|| {
let input: UnityEditorExecuteInput = serde_json::from_value(action.input.clone())
.map_err(|error| format!("unity.editor.execute 输入无效:{error}"))?;
if pending_action.is_none() {
return Err("unity.editor.execute 必须绑定 durable pending action".to_string());
}
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) {
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
}
crate::editor_adapters::execute_unity_editor_code(root, &input.code)
})();
match execution {
Ok(response) => {
let status = match response["status"].as_str() {
Some("completed") if response["ok"] == true => "ok",
Some("needs-reconciliation") => {
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
}
_ => "failed",
};
AgentRuntimeToolObservation {
tool: "unity.editor.execute".to_string(),
status: status.to_string(),
summary: match status {
"ok" => "Unity 编辑器已返回执行成功回执",
"needs-reconciliation" => "Unity 执行结果待人工核对,禁止自动重发",
_ => "Unity 编辑器执行失败",
}
.to_string(),
detail: Some(redact_agent_runtime_project_paths(
root,
&response.to_string(),
32_000,
)),
}
}
Err(error) => AgentRuntimeToolObservation {
tool: "unity.editor.execute".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 480),
detail: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unity_execute_requires_pending_action_and_rejects_project_override() {
for input in [
serde_json::json!({"code":"return 2;"}),
serde_json::json!({"code":"return 2;", "projectPath":"C:/other"}),
] {
let action = AgentRuntimeToolAction {
tool: "unity.editor.execute".to_string(),
reason: None,
input,
};
let observation =
observe_agent_runtime_unity_editor_execute(Path::new("C:/unity"), &action, None);
assert_eq!(observation.status, "failed");
}
}
}
@@ -253,12 +253,13 @@ fn build_agent_runtime_native_capability_registry(
fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry<String>, String>
{
// 个独立开关产生份目录,使用同一快照选缓存并构建。
static REGISTRIES: [OnceLock<Result<CapabilityRegistry<String>, String>>; 4] =
[const { OnceLock::new() }; 4];
// 个独立开关产生份目录,使用同一快照选缓存并构建。
static REGISTRIES: [OnceLock<Result<CapabilityRegistry<String>, String>>; 8] =
[const { OnceLock::new() }; 8];
let tools = agent_runtime_native_executable_tools();
let index = usize::from(tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME))
| (usize::from(tools.contains(&crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)) << 1);
| (usize::from(tools.contains(&crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)) << 1)
| (usize::from(tools.contains(&crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME)) << 2);
let cache = &REGISTRIES[index];
cache
.get_or_init(|| build_agent_runtime_native_capability_registry(tools))
@@ -315,6 +316,10 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_project(
let name = native_runtime_function_name_for_tool("unity.editor.execute");
tools.retain(|tool| tool.name != name);
}
if !crate::builtin_plugins::godot_editor_agent_tool_available_for_project(root) {
let name = native_runtime_function_name_for_tool("godot.editor.execute");
tools.retain(|tool| tool.name != name);
}
Ok(tools)
}
@@ -1052,6 +1057,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。"
}
"unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
"godot.editor.execute" => "在当前 Godot 项目已打开的 Windows x64 标准编辑器中执行支持 return/await 的 GDScript 函数体。DLL 原件保留在安装目录,宿主在私有缓存准备每实例加载副本,受管描述文件引用该副本,重新聚焦后自动加载;仅提交 code,结果待核对时禁止自动重发。",
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
"agent.delegate" => {
@@ -1249,7 +1255,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
}
}),
"command.exec" | "command.start" => command_start_input_schema(),
"cocos.editor.execute" | "unity.editor.execute" => json!({
"cocos.editor.execute" | "unity.editor.execute" | "godot.editor.execute" => json!({
"type": "object",
"required": ["code"],
"additionalProperties": false,
@@ -1863,6 +1869,64 @@ mod tests {
}
}
#[test]
fn godot_native_registry_follows_toggle_without_reusing_other_editor_cache() {
let _guard = crate::builtin_plugins::test_lock();
let config = tempfile::tempdir().unwrap();
crate::builtin_plugins::initialize(config.path()).unwrap();
let project = tempfile::tempdir().unwrap();
std::fs::create_dir(project.path().join("game")).unwrap();
std::fs::write(
project.path().join("game/project.godot"),
"config_version=5\n",
)
.unwrap();
let other_project = tempfile::tempdir().unwrap();
for enabled in [false, true, false, true] {
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID,
enabled,
)
.unwrap();
let expected = enabled
&& cfg!(all(
windows,
target_arch = "x86_64",
feature = "godot-editor-execute"
));
assert_eq!(
native_runtime_function_name("godot.editor.execute").is_some(),
expected
);
let name = native_runtime_function_name_for_tool("godot.editor.execute");
assert_eq!(
build_agent_runtime_native_function_tools_for_project(
project.path(),
"__all_agents__"
)
.unwrap()
.iter()
.any(|tool| tool.name == name),
expected
);
assert!(!build_agent_runtime_native_function_tools_for_project(
other_project.path(),
"__all_agents__"
)
.unwrap()
.iter()
.any(|tool| tool.name == name));
}
}
#[test]
fn godot_native_schema_cannot_override_execution_identity() {
let schema = runtime_tool_input_schema("godot.editor.execute");
assert_eq!(schema["additionalProperties"], false);
assert_eq!(schema["required"], json!(["code"]));
assert_eq!(schema["properties"].as_object().unwrap().len(), 1);
}
#[test]
fn strict_native_function_schemas_match_openai_subset() {
let functions =
@@ -19,6 +19,8 @@ pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor";
pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute";
pub(crate) const AGC_UNITY_EDITOR_PLUGIN_ID: &str = "agc-unity-editor";
pub(crate) const AGC_UNITY_EDITOR_TOOL_NAME: &str = "unity.editor.execute";
pub(crate) const AGC_GODOT_EDITOR_PLUGIN_ID: &str = "agc-godot-editor";
pub(crate) const AGC_GODOT_EDITOR_TOOL_NAME: &str = "godot.editor.execute";
const STATE_FILE_NAME: &str = "builtin-plugins.json";
const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1";
@@ -27,6 +29,7 @@ const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1";
pub(crate) enum BuiltinPlugin {
CocosEditor,
UnityEditor,
GodotEditor,
}
impl BuiltinPlugin {
@@ -34,26 +37,30 @@ impl BuiltinPlugin {
match self {
Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID,
Self::UnityEditor => AGC_UNITY_EDITOR_PLUGIN_ID,
Self::GodotEditor => AGC_GODOT_EDITOR_PLUGIN_ID,
}
}
/// 未持久化任何开关时的默认状态。
fn default_enabled(self) -> bool {
match self {
Self::CocosEditor | Self::UnityEditor => true,
Self::CocosEditor | Self::UnityEditor | Self::GodotEditor => true,
}
}
/// 该插件是否向 Agent 暴露 Runtime 工具。
fn exposes_agent_tools(self) -> bool {
match self {
Self::CocosEditor | Self::UnityEditor => true,
Self::CocosEditor | Self::UnityEditor | Self::GodotEditor => true,
}
}
}
pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] =
&[BuiltinPlugin::CocosEditor, BuiltinPlugin::UnityEditor];
pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[
BuiltinPlugin::CocosEditor,
BuiltinPlugin::UnityEditor,
BuiltinPlugin::GodotEditor,
];
pub(crate) fn builtin_plugin(id: &str) -> Option<BuiltinPlugin> {
BUILTIN_PLUGINS
@@ -251,6 +258,13 @@ pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool {
feature = "unity-editor-execute"
)) && unity_editor_bridge::is_supported_platform()
}
BuiltinPlugin::GodotEditor => {
cfg!(all(
windows,
target_arch = "x86_64",
feature = "godot-editor-execute"
)) && godot_editor_bridge::is_supported_platform()
}
}
&& is_enabled(plugin.id())
}
@@ -284,6 +298,9 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> {
if unity_editor_agent_tool_available() {
available.push(AGC_UNITY_EDITOR_TOOL_NAME);
}
if godot_editor_agent_tool_available() {
available.push(AGC_GODOT_EDITOR_TOOL_NAME);
}
available
}
@@ -295,6 +312,8 @@ pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str
.filter(|tool| {
if *tool == AGC_UNITY_EDITOR_TOOL_NAME {
unity_editor_agent_tool_available_for_project(root)
} else if *tool == AGC_GODOT_EDITOR_TOOL_NAME {
godot_editor_agent_tool_available_for_project(root)
} else {
cocos_editor_agent_tool_available_for_project(root)
}
@@ -314,6 +333,18 @@ pub(crate) fn unity_editor_agent_tool_available_for_project(root: &Path) -> bool
.is_some()
}
pub(crate) fn godot_editor_agent_tool_available() -> bool {
agent_tool_available(BuiltinPlugin::GodotEditor)
}
pub(crate) fn godot_editor_agent_tool_available_for_project(root: &Path) -> bool {
godot_editor_agent_tool_available()
&& crate::project::discover_local_godot_project_root(root)
.ok()
.flatten()
.is_some()
}
#[cfg(test)]
pub(crate) use tests::test_lock;
@@ -329,6 +360,43 @@ mod tests {
.unwrap_or_else(|error| error.into_inner())
}
#[test]
fn godot_tools_follow_real_subproject_and_independent_toggle() {
let _guard = test_lock();
let config = tempdir().unwrap();
initialize(config.path()).unwrap();
let project = tempdir().unwrap();
fs::create_dir(project.path().join("game")).unwrap();
fs::write(
project.path().join("game/project.godot"),
"config_version=5\n",
)
.unwrap();
let supported = cfg!(all(
windows,
target_arch = "x86_64",
feature = "godot-editor-execute"
));
assert_eq!(
available_agent_tools_for_project(project.path()).contains(&AGC_GODOT_EDITOR_TOOL_NAME),
supported
);
set_enabled(AGC_GODOT_EDITOR_PLUGIN_ID, false).unwrap();
assert!(!available_agent_tools_for_project(project.path())
.contains(&AGC_GODOT_EDITOR_TOOL_NAME));
assert!(is_enabled(AGC_UNITY_EDITOR_PLUGIN_ID));
set_enabled(AGC_GODOT_EDITOR_PLUGIN_ID, true).unwrap();
fs::create_dir(project.path().join("other")).unwrap();
fs::write(
project.path().join("other/project.godot"),
"config_version=5\n",
)
.unwrap();
assert!(!godot_editor_agent_tool_available_for_project(
project.path()
));
}
#[test]
fn unity_tool_visibility_requires_project_platform_and_independent_toggle() {
let _guard = test_lock();
@@ -14,284 +14,24 @@ use crate::plugin_host::PluginHost;
use editor_adapter_api::{EditorAdapter, EditorConnectionInfo};
use serde_json::{json, Value};
use std::path::Path;
use std::sync::{Mutex, OnceLock};
struct UnityPendingDelivery {
id: String,
outcome_known: bool,
}
mod execution;
pub(crate) use execution::*;
fn unity_pending_delivery() -> &'static Mutex<Option<UnityPendingDelivery>> {
static PENDING: OnceLock<Mutex<Option<UnityPendingDelivery>>> = OnceLock::new();
PENDING.get_or_init(|| Mutex::new(None))
}
/// GUI 只转发已有 Runner RPC;每个引擎的连接和回执均归同一个 owner。
struct RunnerManagedEditorAdapter(ManagedEditor);
pub(crate) fn unity_execution_fence_path(config_dir: &Path) -> std::path::PathBuf {
config_dir.join("unity-editor-execution.pending")
}
pub(crate) fn unity_uncertain_fence_path(config_dir: &Path) -> std::path::PathBuf {
config_dir.join("unity-editor-execution.uncertain")
}
pub(crate) fn mark_unity_execution_uncertain_at(config_dir: &Path) -> Result<(), String> {
use std::io::Write;
let path = unity_uncertain_fence_path(config_dir);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(mut file) => file
.write_all(b"needs-reconciliation")
.and_then(|_| file.sync_all())
.map_err(|_| "无法持久记录 Unity 执行不确定状态".to_string()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(_) => Err("无法持久记录 Unity 执行不确定状态".to_string()),
}
}
pub(crate) fn mark_unity_execution_uncertain() -> Result<(), String> {
let config = crate::game_creator_runtime_config_dir_lock()
.lock()
.map_err(|_| "Unity 配置锁损坏")?
.clone()
.ok_or("Unity 执行宿主尚未初始化")?;
mark_unity_execution_uncertain_at(&config)
}
fn current_unity_execution_fence() -> Result<std::path::PathBuf, String> {
crate::game_creator_runtime_config_dir_lock()
.lock()
.map_err(|_| "Unity 配置锁损坏")?
.as_deref()
.map(unity_execution_fence_path)
.ok_or_else(|| "Unity 执行宿主尚未初始化".to_string())
}
fn remove_unity_execution_fence(path: &Path) -> Result<(), String> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err("无法清理 Unity 执行确认记录,继续阻断执行".to_string()),
}
}
/// 调用方必须同时独占 GUI 参与锁及 Runner 实例锁,保证这是全部宿主退出后的首次打开。
pub(crate) fn reset_unity_execution_fence_for_fresh_gui(config_dir: &Path) -> Result<(), String> {
remove_unity_execution_fence(&unity_execution_fence_path(config_dir))?;
remove_unity_execution_fence(&unity_uncertain_fence_path(config_dir))
}
pub(crate) fn unity_execute_receipt_is_valid(value: &Value) -> bool {
if value["retryAllowed"] != false {
return false;
}
let valid_error = value["error"]["code"]
.as_str()
.is_some_and(|code| !code.trim().is_empty())
&& value["error"]["message"]
.as_str()
.is_some_and(|message| !message.trim().is_empty());
match value["status"].as_str() {
Some("completed") => {
value["ok"] == true && value["dispatched"] == true && value.get("result").is_some()
}
Some("failed") => value["ok"] == false && value["dispatched"].is_boolean() && valid_error,
Some("needs-reconciliation") => {
value["ok"] == false && value["dispatched"] == true && valid_error
}
_ => false,
}
}
fn unity_reconciliation(message: &str) -> Value {
json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"needs-reconciliation","message":message}})
}
pub(crate) fn unity_not_dispatched(message: &str) -> Value {
json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,"error":{"code":"not-dispatched","message":message}})
}
/// 仅在长寿命 Runner 中触达 native serviceGUI / Runtime / DirectProject 共用此入口。
pub(crate) fn unity_editor_rpc(method: &str, params: Value) -> Result<Value, String> {
let method = method.strip_prefix("editor.").unwrap_or(method);
if crate::runner::external_agent_runner_is_server_process() {
unity_editor_rpc_owned(method, params, None)
} else {
crate::runner::call_external_unity_editor(method, params)
}
}
pub(crate) fn execute_unity_editor_code(root: &Path, code: &str) -> Result<Value, String> {
unity_editor_rpc(
"execute",
json!({"projectPath":root.to_string_lossy(),"code":code,"timeoutMs":60000}),
)
}
/// GUI 读不到执行回执时不会发送 ack;该门闩不能被插件、连接或项目生命周期清除。
pub(crate) fn unity_editor_rpc_owned(
method: &str,
params: Value,
delivery_id: Option<&str>,
) -> Result<Value, String> {
let method = method.strip_prefix("editor.").unwrap_or(method);
if !matches!(
method,
"detect" | "connect" | "status" | "execute" | "disconnect"
) {
return Err("Unity RPC 方法不受支持".to_string());
}
if method == "disconnect" {
unity_editor_bridge::disconnect_unity_editor();
return Ok(
json!({"adapter":"unity-editor","connected":false,"pid":null,"projectPath":params.get("projectPath"),"version":null}),
);
}
if method == "connect" {
unity_editor_bridge::disconnect_unity_editor();
}
let project = params
.get("projectPath")
.and_then(Value::as_str)
.ok_or("缺少 projectPath")?;
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(Path::new(project)) {
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
}
let mut delivery = if method == "execute" {
let mut pending = match unity_pending_delivery().try_lock() {
Ok(pending) => pending,
Err(std::sync::TryLockError::WouldBlock) => {
return Ok(unity_not_dispatched(
"Unity 编辑器已有请求正在执行,请等待回执",
))
}
Err(std::sync::TryLockError::Poisoned(_)) => {
return Ok(unity_reconciliation("Unity 执行状态异常,请人工核对"))
}
};
let fence = current_unity_execution_fence()?;
let uncertain_fence = fence.with_extension("uncertain");
if uncertain_fence.exists() {
return Ok(unity_reconciliation(
"Unity 执行回执未确认,请核对后退出全部宿主再重新打开",
));
}
if pending
.as_ref()
.is_some_and(|pending| pending.outcome_known)
{
return Ok(unity_not_dispatched(
"Unity 上一条执行正在等待客户端确认回执",
));
}
if pending.is_some() || fence.exists() {
return Ok(unity_reconciliation(
"先前 Unity 执行回执尚未确认,退出全部 AGC 和 Runner 后重新打开才可恢复",
));
}
let id = delivery_id
.map(str::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&fence)
.map_err(|_| "无法独占保存 Unity 执行确认记录,未发送请求")?;
file.write_all(id.as_bytes())
.and_then(|_| file.sync_all())
.map_err(|_| "无法持久保存 Unity 执行确认记录,未发送请求")?;
*pending = Some(UnityPendingDelivery {
id,
outcome_known: false,
});
Some(pending)
} else {
None
};
let result = unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).rpc(method, params);
if method == "execute" {
// native 的 Err 均为发送前失败;发送后的未知状态由结构化 result 携带并锁存。
let mut result = result.unwrap_or_else(|error| json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,"error":{"code":"not-dispatched","message":error}}));
if !unity_execute_receipt_is_valid(&result) {
result = unity_reconciliation("Unity 原生执行回执格式损坏,禁止自动重发");
}
let known = result["status"] != "needs-reconciliation";
// 与本次 pending 写入同一临界区决定确认,避免返回后再次抢锁造成误判。
if delivery_id.is_some() {
result["ackRequired"] = json!(known);
}
if let Some(pending) = delivery.as_mut() {
if let Some(pending) = pending.as_mut() {
pending.outcome_known = known;
}
if delivery_id.is_none() && known {
if current_unity_execution_fence()
.and_then(|path| remove_unity_execution_fence(&path))
.is_err()
{
return Ok(unity_reconciliation(
"Unity 执行已返回,但确认记录无法提交,请人工核对",
));
}
**pending = None;
}
}
return Ok(result);
}
result
}
pub(crate) fn acknowledge_unity_editor_delivery(request_id: &str) -> Result<(), String> {
let mut pending = unity_pending_delivery()
.try_lock()
.map_err(|_| "Unity 执行尚未结束")?;
if !pending
.as_ref()
.is_some_and(|pending| pending.id == request_id && pending.outcome_known)
{
return Err("Unity 回执确认身份不匹配或执行结果仍不确定".to_string());
}
remove_unity_execution_fence(&current_unity_execution_fence()?)?;
*pending = None;
Ok(())
}
#[cfg(test)]
pub(crate) fn unity_delivery_requires_ack(request_id: &str) -> bool {
unity_pending_delivery()
.try_lock()
.ok()
.is_some_and(|pending| {
pending
.as_ref()
.is_some_and(|pending| pending.id == request_id && pending.outcome_known)
})
}
pub(crate) fn disconnect_unity_editor_connection() {
if crate::runner::external_agent_runner_is_server_process() {
unity_editor_bridge::disconnect_unity_editor();
} else {
let _ = crate::runner::disconnect_external_unity_editor();
}
}
/// GUI 只代理已有 Runner RPC,不创建第二份 helper 或不确定门闩。
struct RunnerUnityEditorAdapter;
impl EditorAdapter for RunnerUnityEditorAdapter {
impl EditorAdapter for RunnerManagedEditorAdapter {
fn id(&self) -> &'static str {
"unity-editor"
self.0.adapter()
}
fn detect(&self, project_path: &Path) -> Result<EditorConnectionInfo, String> {
serde_json::from_value(unity_editor_rpc(
serde_json::from_value(managed_editor_rpc(
self.0,
"detect",
json!({"projectPath":project_path.to_string_lossy()}),
)?)
.map_err(|_| "Unity 探测回执格式无效".to_string())
.map_err(|_| "编辑器探测回执格式无效".to_string())
}
fn connect(
&mut self,
@@ -299,20 +39,26 @@ impl EditorAdapter for RunnerUnityEditorAdapter {
project_path: &Path,
_version: &str,
) -> Result<EditorConnectionInfo, String> {
serde_json::from_value(unity_editor_rpc(
serde_json::from_value(managed_editor_rpc(
self.0,
"connect",
json!({"processId":pid,"projectPath":project_path.to_string_lossy()}),
)?)
.map_err(|_| "Unity 连接回执格式无效".to_string())
.map_err(|_| "编辑器连接回执格式无效".to_string())
}
fn disconnect(&mut self) {
disconnect_unity_editor_connection();
let _ = disconnect_managed_editor_connection(self.0);
}
fn translate_rpc(&self, method: &str, params: Value) -> Result<Value, String> {
unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).translate_rpc(method, params)
match self.0 {
ManagedEditor::Unity => unity_editor_bridge::UnityEditorAdapter::new(Vec::new())
.translate_rpc(method, params),
ManagedEditor::Godot => godot_editor_bridge::GodotEditorAdapter::new(Vec::new())
.translate_rpc(method, params),
}
}
fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
unity_editor_rpc(method, params)
managed_editor_rpc(self.0, method, params)
}
}
@@ -344,6 +90,29 @@ pub(crate) fn configure_unity_helper_for_runtime() -> Result<(), String> {
unity_editor_bridge::configure_helper_candidates(candidates)
}
pub(crate) const GODOT_BRIDGE_PAYLOAD_RELATIVE: &str =
"plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll";
/// 安装包与开发构建使用同一插件资源布局,不将 DLL 复制进 Godot 工程。
pub(crate) fn configure_godot_payload_for_runtime(config_dir: &Path) -> Result<(), String> {
godot_editor_bridge::configure_runtime_cache_dir(config_dir.join("godot-editor-runtime"))?;
let mut candidates = Vec::new();
if let Ok(executable) = std::env::current_exe() {
if let Some(directory) = executable.parent() {
candidates.push(directory.join(GODOT_BRIDGE_PAYLOAD_RELATIVE));
}
}
#[cfg(debug_assertions)]
candidates.push(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.ok_or("插件工作区目录不可用")?
.join(GODOT_BRIDGE_PAYLOAD_RELATIVE),
);
godot_editor_bridge::configure_payload_candidates(candidates)
}
pub(crate) fn register_linked_editor_adapters(
app: &tauri::AppHandle,
host: &PluginHost,
@@ -360,7 +129,11 @@ pub(crate) fn register_linked_editor_adapters(
}
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
{
host.register_editor_adapter(Box::new(RunnerUnityEditorAdapter))?;
host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Unity)))?;
}
#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))]
{
host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Godot)))?;
}
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -46,6 +46,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[
"command.stdin",
"cocos.editor.execute",
"unity.editor.execute",
"godot.editor.execute",
"preview.start",
"agent.delegate",
"agent.spawn_isolated",
@@ -68,6 +69,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[
"command.stdin",
"cocos.editor.execute",
"unity.editor.execute",
"godot.editor.execute",
"preview.start",
"agent.delegate",
"agent.spawn_isolated",
@@ -1,5 +1,9 @@
#![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")]
#[cfg(test)]
#[path = "../build_support/godot_bundle.rs"]
mod godot_bundle;
use std::collections::BTreeMap;
use std::fs;
use std::fs::{File, OpenOptions};
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More