接入 Godot 编辑器插件与受控执行链路
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存 接入 AGC 插件开关、Runner、Agent 工具与权限审计 完善执行回执确认、不确定状态阻断及卸载恢复 补齐 Windows 分发资源、定向测试与实机验收文档
This commit is contained in:
@@ -302,6 +302,23 @@ jobs:
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Prepare Godot plugin Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo 'Godot plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Run AI game creator shell shared crate gates
|
||||
run: npm run check:native-shells:agc-rust-crates
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user