修复 Cocos 内置桥接与客户端项目打开链路

修复 Cocos 内置插件提示词,禁止回退到项目内 MCP 扩展

移除 agc_cocos_execute 对项目写锁的无关依赖

将项目对话读取移到 blocking worker,避免客户端窗口阻塞

为 Windows 开发与发行构建默认启用 Cocos 编辑器执行能力

补充 Cocos Inspector 引导、桥接协议和客户端回归验证
This commit is contained in:
2026-09-12 23:24:45 +08:00
parent fd904ea144
commit 1be6e6756c
29 changed files with 1127 additions and 103 deletions
+7
View File
@@ -7,6 +7,10 @@ RPC、权限和能力注册全部由通用宿主负责,只有“如何连接 C
“是否可用”,禁用后插件不能启动,`cocos.editor.execute` / `agc_cocos_execute` 也会
从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后立即恢复。
Cocos Creator 项目目录中的 `extensions/`、`package.json` 插件声明或第三方 MCP 包不属于
AGC Cocos 桥接来源。Agent 处理 Cocos 请求时只使用客户端登记的 `agc-cocos-editor`
内置插件;内置工具不可用时报告客户端插件状态,不扫描或要求用户启动项目内 MCP 扩展。
```text
plugin.json Agent Plugins 清单 + AGC Runtime 扩展
src/entry.mjs 运行时入口(注册命令 / 能力 / 面板,转发 host.rpc)
@@ -54,6 +58,8 @@ execute 不接受并发积压。结果不确定时返回 `needs-reconciliation`
- `windows-transport`:注入后通过 named pipe 提供 `ping/status/execute`,执行结果
不确定时返回 `ExecutionUncertain` 并禁止自动重放。
- `windows-injection`:随包 DLL 的 Windows 注入实现,默认关闭。
- `windows-bootstrap`:在首次 connect/execute 前经目标 PID 的 Node Inspector
安装内置 bootstrap,验证 pipe 握手并关闭本次开启的 Inspector;保留已有调试会话。
AGC 客户端当前在编译期链接本 crate(Cargo path 依赖),由通用宿主按 manifest 的
`adapter` 字段注册;宿主源码里没有 Cocos 进程名、注入或 Editor.Message 逻辑。
@@ -62,6 +68,7 @@ AGC 客户端当前在编译期链接本 crate(Cargo path 依赖),由通
```bash
cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml
cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml --features windows-bootstrap -- --include-ignored
node --test plugins/agc-cocos-editor/src/entry.test.mjs
```
@@ -33,6 +33,16 @@ windows-injection = [
"windows-sys/Win32_System_LibraryLoader",
"windows-sys/Win32_System_Memory",
]
windows-bootstrap = [
"windows-transport",
"dep:reqwest",
"dep:tungstenite",
"windows-sys/Win32_NetworkManagement_IpHelper",
"windows-sys/Win32_Networking_WinSock",
"windows-sys/Win32_System_Memory",
"windows-sys/Win32_System_ProcessStatus",
"windows-sys/Win32_System_SystemInformation",
]
[dependencies]
editor-adapter-api = { path = "../../../../server-rs/crates/editor-adapter-api" }
@@ -42,6 +52,8 @@ sha2 = { version = "0.10", optional = true }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", optional = true, default-features = false }
reqwest = { version = "0.12", optional = true, default-features = false, features = ["blocking", "json"] }
tungstenite = { version = "0.28", optional = true, default-features = false, features = ["handshake"] }
[build-dependencies]
cc = "1"
@@ -0,0 +1,34 @@
//! 对用户指定的已打开 Creator 项目执行只读冷启动/复用验证,不写项目文件。
use cocos_editor_bridge::{
execute_cocos_editor_code_for_project, ping_cocos_editor, CocosEditorAdapter,
};
use editor_adapter_api::EditorAdapter;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let project = std::env::args()
.nth(1)
.ok_or("需要显式提供已打开的 Cocos 项目目录")?;
let mut adapter = CocosEditorAdapter::default();
let target = adapter.detect(Path::new(&project))?;
let pid = target.pid.ok_or("没有匹配的 Creator")?;
println!("detected={}", serde_json::to_string(&target)?);
println!("before={:?}", ping_cocos_editor(pid, &project, 40));
for phase in ["first", "warm"] {
let response = execute_cocos_editor_code_for_project(&project,
"return {pid:process.pid,version:Editor.App.version,project:Editor.Project.path,inspectorOpen:!!require('node:inspector').url()};", 3000)?;
if !response.ok {
return Err(format!("{phase}: {:?}", response.error).into());
}
println!("{phase}={}", serde_json::to_string(&response)?);
}
let connected = adapter.connect(
pid,
Path::new(&project),
target.version.as_deref().unwrap_or(""),
)?;
assert!(connected.connected);
assert!(ping_cocos_editor(pid, &project, 500)?.ok);
println!("connected={}", serde_json::to_string(&connected)?);
Ok(())
}
@@ -17,7 +17,11 @@ function normalizeProjectPath(value) {
if (typeof value !== 'string' || !path.isAbsolute(value)) {
throw new Error('projectPath must be absolute');
}
const canonical = fs.realpathSync(value).replace(/^\\\\\?\\/, '');
// Rust canonicalize 的扩展路径必须先转回 Node 可解析的盘符/UNC 形式。
const ordinary = value
.replace(/^\\\\\?\\UNC\\/i, '\\\\')
.replace(/^\\\\\?\\/, '');
const canonical = fs.realpathSync(ordinary);
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
}
@@ -41,6 +41,13 @@ test('Creator bootstrap executes async Editor code and keeps a three-command sur
}
try {
assert.equal((await request({ op: 'ping' })).result.ready, true);
if (process.platform === 'win32') {
assert.equal(
(await request({ op: 'ping' }, { projectPath: `\\\\?\\${__dirname}` }))
.ok,
true,
);
}
assert.equal(
(await request({ op: 'status' })).result.creatorVersion,
'test',
@@ -120,6 +120,37 @@ impl CocosEditorAdapter {
}
}
fn connect_with(
&self,
process: crate::CocosEditorProcess,
handshake: impl FnOnce() -> Result<(), String>,
) -> Result<EditorConnectionInfo, String> {
let mut connection = self
.connection
.lock()
.map_err(|_| "Cocos 适配器连接状态锁已损坏".to_string())?;
*connection = None;
handshake()?;
let info = self.connected_info(&process);
*connection = Some(CocosEditorConnection {
process_id: process.process_id,
project_path: process
.project_path
.clone()
.ok_or_else(|| "目标缺少项目路径".to_string())?,
creator_version: process.creator_version.clone(),
});
Ok(info)
}
fn connect_target(&self, pid: u32, project_path: &str) -> Result<EditorConnectionInfo, String> {
let target =
validate_injection_target(pid, project_path).map_err(|error| error.to_string())?;
self.connect_with(target.clone(), || {
crate::ensure_target_ready(&target).map_err(|error| error.to_string())
})
}
/// 只允许加载插件包随包目录里的 payload,避免适配器变成任意 DLL 注入器。
fn payload_path(&self, requested: Option<&str>) -> Result<PathBuf, String> {
match requested {
@@ -286,22 +317,7 @@ impl EditorAdapter for CocosEditorAdapter {
project_path: &Path,
_version: &str,
) -> Result<EditorConnectionInfo, String> {
let process = validate_injection_target(pid, &project_path.to_string_lossy())
.map_err(|error| error.to_string())?;
let connection = CocosEditorConnection {
process_id: process.process_id,
project_path: process
.project_path
.clone()
.unwrap_or_else(|| project_path.to_string_lossy().into_owned()),
creator_version: process.creator_version.clone(),
};
let info = self.connected_info(&process);
*self
.connection
.lock()
.map_err(|_| "Cocos 适配器连接状态锁已损坏".to_string())? = Some(connection);
Ok(info)
self.connect_target(pid, &project_path.to_string_lossy())
}
fn disconnect(&mut self) {
@@ -328,21 +344,7 @@ impl EditorAdapter for CocosEditorAdapter {
}
"connect" => {
let project_path = params.project_path()?;
let process = validate_injection_target(params.process_id()?, &project_path)
.map_err(|error| error.to_string())?;
let connection = CocosEditorConnection {
process_id: process.process_id,
project_path: process
.project_path
.clone()
.unwrap_or_else(|| project_path.clone()),
creator_version: process.creator_version.clone(),
};
let info = self.connected_info(&process);
*self
.connection
.lock()
.map_err(|_| "Cocos 适配器连接状态锁已损坏".to_string())? = Some(connection);
let info = self.connect_target(params.process_id()?, &project_path)?;
serde_json::to_value(info).map_err(|error| error.to_string())
}
"disconnect" => {
@@ -375,6 +377,25 @@ impl EditorAdapter for CocosEditorAdapter {
mod tests {
use super::*;
#[test]
fn connection_is_not_published_before_a_successful_handshake() {
let adapter = CocosEditorAdapter::default();
let target = crate::CocosEditorProcess {
process_id: 42,
parent_process_id: 1,
executable_path: "CocosCreator.exe".into(),
command_line: None,
project_path: Some("C:/project".into()),
creator_version: Some("3.8.8".into()),
};
assert!(adapter
.connect_with(target.clone(), || Err("pipe unavailable".into()))
.is_err());
assert!(adapter.connection().unwrap().is_none());
assert!(adapter.connect_with(target, || Ok(())).unwrap().connected);
assert_eq!(adapter.connection().unwrap().unwrap().process_id, 42);
}
#[test]
fn uncertain_execute_blocks_subsequent_dispatch_even_after_disconnect() {
let mut adapter = CocosEditorAdapter::default();
File diff suppressed because it is too large Load Diff
@@ -12,9 +12,11 @@
//! DLL alone is never reported as a connected editor.
//! * The first command surface is intentionally small: `ping`, `status`, and a
//! bounded `execute` code request.
//! * `windows-bootstrap` initializes the bridge through the target's Node
//! Inspector before execute/connect, then closes only its own Inspector.
//!
//! Consumers should keep `default-features = false` and enable
//! `process-discovery`, `windows-transport`, or `windows-injection` only in a
//! `process-discovery`, `windows-transport`, or `windows-bootstrap` only in a
//! desktop adapter.
use serde::{Deserialize, Serialize};
@@ -22,6 +24,8 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
mod adapter;
#[cfg(all(windows, feature = "windows-bootstrap"))]
mod inspector;
pub use adapter::{CocosEditorAdapter, COCOS_EDITOR_ADAPTER_ID, COCOS_EDITOR_RPC_METHODS};
use std::fmt;
@@ -180,6 +184,7 @@ pub enum BridgeError {
PayloadRejected(String),
InjectionFailed(String),
Transport(String),
NotReady,
ExecutionUncertain(String),
}
@@ -198,6 +203,7 @@ impl fmt::Display for BridgeError {
Self::PayloadRejected(message) => write!(f, "Cocos bridge payload 被拒绝:{message}"),
Self::InjectionFailed(message) => write!(f, "Cocos bridge 注入失败:{message}"),
Self::Transport(message) => write!(f, "Cocos bridge 命令传输失败:{message}"),
Self::NotReady => f.write_str("Cocos bridge 尚未建立,未发送代码"),
Self::ExecutionUncertain(message) => {
write!(
f,
@@ -235,7 +241,7 @@ pub fn discover_cocos_editors() -> Result<CocosEditorDiscovery, BridgeError> {
"-NoProfile",
"-NonInteractive",
"-Command",
"$items = Get-CimInstance Win32_Process -Filter \"name='CocosCreator.exe'\" | Select-Object ProcessId,ParentProcessId,ExecutablePath,CommandLine; $items | ConvertTo-Json -Compress -Depth 3",
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $items = Get-CimInstance Win32_Process -Filter \"name='CocosCreator.exe'\" | Select-Object ProcessId,ParentProcessId,ExecutablePath,CommandLine; $items | ConvertTo-Json -Compress -Depth 3",
])
.stdin(Stdio::null())
.stderr(Stdio::piped())
@@ -509,6 +515,18 @@ pub fn validate_injection_target(
.into_iter()
.find(|process| process.process_id == process_id)
.ok_or(BridgeError::TargetNotFound(process_id))?;
validate_discovered_target(target, &project_path)
}
fn validate_discovered_target(
target: CocosEditorProcess,
project_path: &str,
) -> Result<CocosEditorProcess, BridgeError> {
if !Path::new(project_path).join("package.json").is_file() {
return Err(BridgeError::IdentityMismatch(
"目标目录缺少 Cocos package.json".to_string(),
));
}
let target_project = target.project_path.as_deref().ok_or_else(|| {
BridgeError::IdentityMismatch("目标进程没有可识别的 --project".to_string())
})?;
@@ -524,7 +542,7 @@ pub fn validate_injection_target(
}
Ok(CocosEditorProcess {
creator_version: read_creator_version(Path::new(&project_path))?,
project_path: Some(project_path),
project_path: Some(project_path.to_string()),
..target
})
}
@@ -701,6 +719,9 @@ fn send_command_over_pipe(
}
let error = unsafe { GetLastError() };
if Instant::now() >= deadline {
if error == 2 {
return Err(BridgeError::NotReady);
}
return Err(BridgeError::Transport(format!(
"打开 bridge pipe 超时(错误码 {error})"
)));
@@ -805,12 +826,35 @@ fn execute_cocos_editor_command(
if let CocosEditorCommand::Execute { code } = &command {
validate_execute_code(code)?;
}
validate_injection_target(process_id, project_path)?;
let target = validate_injection_target(process_id, project_path)?;
execute_target_command(&target, command, timeout_ms)
}
fn execute_target_command(
target: &CocosEditorProcess,
command: CocosEditorCommand,
timeout_ms: u32,
) -> Result<CocosEditorCommandResponse, BridgeError> {
if matches!(command, CocosEditorCommand::Execute { .. }) {
ensure_target_ready(target)?;
}
send_target_command(target, command, timeout_ms)
}
fn send_target_command(
target: &CocosEditorProcess,
command: CocosEditorCommand,
timeout_ms: u32,
) -> Result<CocosEditorCommandResponse, BridgeError> {
let process_id = target.process_id;
let envelope = CocosEditorCommandEnvelope {
schema_version: BRIDGE_SCHEMA_VERSION.to_string(),
request_id: new_command_request_id(process_id),
process_id,
project_path: normalize_existing_directory(Path::new(project_path))?,
project_path: target
.project_path
.clone()
.ok_or_else(|| BridgeError::IdentityMismatch("目标缺少项目路径".into()))?,
command,
};
#[cfg(all(feature = "windows-transport", windows))]
@@ -819,7 +863,7 @@ fn execute_cocos_editor_command(
}
#[cfg(not(all(feature = "windows-transport", windows)))]
{
let _ = envelope;
let _ = (envelope, timeout_ms);
Err(if cfg!(windows) {
BridgeError::FeatureDisabled
} else {
@@ -828,6 +872,43 @@ fn execute_cocos_editor_command(
}
}
fn ping_target(target: &CocosEditorProcess, timeout_ms: u32) -> Result<(), BridgeError> {
let response = send_target_command(target, CocosEditorCommand::Ping, timeout_ms)?;
if !response.ok
|| response
.result
.as_ref()
.and_then(|v| v.get("ready"))
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
return Err(BridgeError::Transport("Cocos bridge 未返回就绪握手".into()));
}
Ok(())
}
fn ensure_target_ready(target: &CocosEditorProcess) -> Result<(), BridgeError> {
match ping_target(target, 40) {
Ok(()) => Ok(()),
Err(BridgeError::NotReady) => {
#[cfg(all(windows, feature = "windows-bootstrap"))]
{
// 旧 Runtime 的同步工具入口也可能位于 Tokio worker;blocking HTTP
// 的私有 runtime 必须在普通线程中创建和销毁。
std::thread::scope(|scope| scope.spawn(|| inspector::bootstrap(target)).join())
.map_err(|_| {
BridgeError::InjectionFailed("Cocos 引导线程异常,未发送业务代码".into())
})?
}
#[cfg(not(all(windows, feature = "windows-bootstrap")))]
{
Err(BridgeError::NotReady)
}
}
Err(error) => Err(error),
}
}
pub fn ping_cocos_editor(
process_id: u32,
project_path: &str,
@@ -898,7 +979,14 @@ pub fn execute_cocos_editor_code_for_project(
if matches.next().is_some() {
return Err(BridgeError::TargetAmbiguous(normalized_project));
}
execute_cocos_editor_code(target.process_id, &normalized_project, code, timeout_ms)
let target = validate_discovered_target(target, &normalized_project)?;
execute_target_command(
&target,
CocosEditorCommand::Execute {
code: code.to_string(),
},
timeout_ms,
)
}
fn paths_equal(left: &Path, right: &Path) -> bool {
+2 -1
View File
@@ -30,7 +30,8 @@ export const COCOS_EDITOR_PANEL = Object.freeze({
});
export const PROJECT_CHANGED_EVENT = 'project.changed';
const RPC_TIMEOUT_MS = 10_000;
// 默认执行可先安装桥接;覆盖 15 秒引导、最长 60 秒命令和发现开销,早于宿主 90 秒截止。
const RPC_TIMEOUT_MS = 85_000;
export function createCocosEditorPlugin({
send,