修复内置插件跨进程开关与不确定执行阻断

让 Runner 和 CLI 查询持久化开关并即时感知 GUI 状态更新
让隔离 MCP 经现有工具桥获取可用工具,并将开关纳入会话缓存标识
在执行入口复查禁用状态,固定原生函数缓存构建使用的工具快照
修复损坏开关文件保存成功后仍保持关闭的状态
保留插件执行结果不确定的结构化回执和宿主适配器阻断,拒绝并发积压与自动重发
新增跨进程工具目录、热切换、坏文件恢复及不确定执行回归测试并同步文档
This commit is contained in:
2026-09-10 21:37:51 +08:00
parent dd36de3aa6
commit 02ff914bd3
14 changed files with 564 additions and 47 deletions
@@ -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 使用独立进程组,停止/卸载时回收自有进程。
+4
View File
@@ -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();
+37 -7
View File
@@ -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', {});
}
+107 -1
View File
@@ -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', {