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