接入 Godot 编辑器插件与受控执行链路
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存 接入 AGC 插件开关、Runner、Agent 工具与权限审计 完善执行回执确认、不确定状态阻断及卸载恢复 补齐 Windows 分发资源、定向测试与实机验收文档
This commit is contained in:
@@ -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
@@ -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"),
|
||||
|
||||
+12
@@ -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 = ®ISTRIES[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 service,GUI / 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(¤t_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
Reference in New Issue
Block a user