合并主分支
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 3m1s
Project CI / Native shell tests (pull_request) Successful in 13m12s

同步主分支资源卡依赖关系与类型分类预览等最新变更
保留并补齐画布快速编辑前后端正向白名单
拒绝图片编辑接口处理单个图标、角色动作、音频、视频及未知类型
修正快速编辑决策记录与现行编辑器文档
补充后端现役类型和未知类型表驱动测试
This commit is contained in:
2026-08-05 20:14:45 +08:00
76 changed files with 9334 additions and 1261 deletions
+3 -3
View File
@@ -161,9 +161,6 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Check server-rs boundaries
run: npm run check:server-rs-ddd
- name: Prepare server-rs Rust dependencies
shell: bash
run: |
@@ -181,6 +178,9 @@ jobs:
sleep $((attempt * 2))
done
- name: Check server-rs boundaries
run: npm run check:server-rs-ddd
- name: Run server-rs workspace tests
run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml
@@ -885,6 +885,10 @@ function readBrowserDom(url) {
function resolveChromeBin() {
for (const candidate of [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/opt/google/chrome/chrome',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
@@ -5708,7 +5708,7 @@ mod canvas_generation_tests {
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set request read timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
@@ -6702,7 +6702,7 @@ mod canvas_generation_tests {
#[tokio::test]
async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() {
let temporary = tempfile::tempdir().expect("create accepted scan project");
let temporary = crate::tests::canonical_test_tempdir("accepted-generation-scan-");
let root = temporary.path();
init_local_game_project_at(root, "accepted-scan", "恢复扫描测试")
.expect("init accepted scan project");
@@ -838,7 +838,7 @@ mod external_generation_state_tests {
#[test]
fn prepared_generation_state_reuses_identity_and_transitions_to_accepted() {
let temporary = tempfile::tempdir().expect("create generation ledger project");
let temporary = crate::tests::canonical_test_tempdir("external-generation-ledger-");
let root = temporary.path();
init_local_game_project_at(root, "generation-ledger", "生成账本测试")
.expect("init project");
@@ -955,7 +955,7 @@ mod external_generation_state_tests {
#[test]
fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() {
let temporary = tempfile::tempdir().expect("create legacy generation ledger project");
let temporary = crate::tests::canonical_test_tempdir("legacy-generation-ledger-");
let root = temporary.path();
init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试")
.expect("init project");
@@ -790,7 +790,7 @@ mod tests {
#[test]
fn generation_cleanup_failure_preserves_pending_identity_anchor() {
let temporary = tempfile::tempdir().expect("create pending cleanup project");
let temporary = crate::tests::canonical_test_tempdir("pending-generation-cleanup-");
let root = temporary.path();
let run_id = "generation-cleanup-order-run";
init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试")
@@ -463,7 +463,8 @@ mod tests {
root_source: &str,
suffix: &str,
) -> String {
let temporary = tempfile::tempdir().expect("temporary project root");
let temporary =
crate::tests::canonical_test_tempdir(&format!("provider-role-overlay-{suffix}-"));
let root = temporary.path().join("project");
init_local_game_project_at(&root, &format!("overlay-{suffix}"), "role overlay test")
.expect("project init");
@@ -549,7 +550,7 @@ mod tests {
const ORDINARY_NOTICE: &str = "除下方有界仓库启动上下文、当前 Session 未压缩对话尾部或历史压缩摘要外,项目记忆、资产和源码正文不会预加载";
const MEMORY_MARKER: &str = "supervisor-preloaded-context-marker";
let directory = tempfile::tempdir().expect("temp project directory");
let directory = crate::tests::canonical_test_tempdir("provider-request-project-");
let root = directory.path().join("project");
init_local_game_project_at(&root, "project-1", "项目总控预加载说明测试")
.expect("project init");
@@ -806,7 +807,7 @@ mod tests {
#[test]
fn planning_request_advertises_only_native_mcp_functions() {
let directory = tempfile::tempdir().expect("temp project directory");
let directory = crate::tests::canonical_test_tempdir("native-mcp-prompt-");
let root = directory.path().join("project");
init_local_game_project_at(&root, "project-mcp", "MCP 原生函数说明测试")
.expect("project init");
@@ -2,6 +2,12 @@ use super::*;
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
OnceLock::new();
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
> = OnceLock::new();
#[cfg(test)]
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
std::sync::Mutex::new(());
pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock<
std::sync::Mutex<std::collections::BTreeMap<String, bool>>,
> = OnceLock::new();
@@ -234,15 +240,21 @@ pub(in crate::agent) use recovery_scan::*;
pub(in crate::agent) use task_queue::*;
pub(in crate::agent) use task_start::*;
#[cfg(test)]
pub(crate) use entrypoints::acquire_game_creator_manifest_invalidation_event_sink_test_guard;
#[allow(unused_imports)]
pub(crate) use entrypoints::{
chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at,
chat_with_game_creator_role_agent_for_session_at, chat_with_game_creator_role_agent_runtime_at,
chat_with_game_creator_role_agent_runtime_for_session_at,
chat_with_game_creator_role_agent_stream_at,
chat_with_game_creator_role_agent_stream_for_session_at, generate_local_game_draft_at,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle,
chat_with_game_creator_role_agent_stream_for_session_at,
configure_game_creator_manifest_invalidation_event_sink,
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink,
};
#[cfg(test)]
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
@@ -1,30 +1,174 @@
use super::*;
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
fn lock_game_creator_manifest_invalidation_event_sink(
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) {
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
}
pub(in crate::agent) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) {
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
app: tauri::AppHandle,
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.map_err(|error| format!("绑定 manifest 失效事件接收端失败:{error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("读取 manifest 失效事件接收端失败:{error}"))?
.port();
let token = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let expected_token = token.clone();
thread::Builder::new()
.name("manifest-invalidation-event-sink".to_string())
.spawn(move || {
for incoming in listener.incoming() {
let Ok(mut stream) = incoming else {
continue;
};
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
let mut payload = Vec::new();
let mut limited =
(&mut stream).take(GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + 1);
if limited.read_to_end(&mut payload).is_err()
|| payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES
{
continue;
}
let Ok(envelope) = serde_json::from_slice::<
GameCreatorManifestInvalidationRelayEnvelope,
>(&payload) else {
continue;
};
if envelope.token != expected_token {
continue;
}
let _ = app.emit("game-creator-manifest-invalidated", envelope.event);
}
})
.map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?;
Ok(GameCreatorManifestInvalidationEventSink { port, token })
}
pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
port: u16,
token: &str,
) -> Result<(), String> {
if port == 0 {
return Err("manifest 失效事件接收端口无效".to_string());
}
let token = token.trim();
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("manifest 失效事件接收令牌无效".to_string());
}
*lock_game_creator_manifest_invalidation_event_sink() =
Some(GameCreatorManifestInvalidationEventSink {
port,
token: token.to_string(),
});
Ok(())
}
#[cfg(test)]
pub(crate) struct GameCreatorManifestInvalidationEventSinkTestGuard {
_isolation: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl GameCreatorManifestInvalidationEventSinkTestGuard {
pub(crate) fn configure(&self, port: u16, token: &str) -> Result<(), String> {
configure_game_creator_manifest_invalidation_event_sink(port, token)
}
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
lock_game_creator_manifest_invalidation_event_sink().clone()
}
}
#[cfg(test)]
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
fn drop(&mut self) {
*lock_game_creator_manifest_invalidation_event_sink() = None;
}
}
#[cfg(test)]
pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard(
) -> GameCreatorManifestInvalidationEventSinkTestGuard {
let isolation = GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
GameCreatorManifestInvalidationEventSinkTestGuard {
_isolation: isolation,
}
}
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
let Some(sink) = sink else {
return Ok(());
};
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
token: sink.token,
event: GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(),
},
};
let payload = serde_json::to_vec(&envelope)
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
if payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES {
return Err("manifest 失效事件超过大小上限".to_string());
}
let address = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, sink.port).into();
let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100))
.map_err(|error| format!("连接 manifest 失效事件接收端失败:{error}"))?;
stream
.set_write_timeout(Some(Duration::from_millis(100)))
.map_err(|error| format!("配置 manifest 失效事件发送超时失败:{error}"))?;
stream
.write_all(&payload)
.map_err(|error| format!("发送 manifest 失效事件失败:{error}"))
}
pub(crate) fn game_creator_agent_runtime_update_event(
root: &Path,
runtime: AgentRuntimeResult,
) -> GameCreatorAgentRuntimeUpdateEvent {
GameCreatorAgentRuntimeUpdateEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: runtime.state.agent_id.clone(),
run_id: runtime.state.run_id.clone(),
status: runtime.state.status.clone(),
phase: runtime.state.phase.clone(),
manifest_invalidated: true,
runtime,
}
}
pub(crate) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) {
if GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get().is_none() {
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
}
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else {
return;
};
let agent_id = runtime.state.agent_id.clone();
let run_id = runtime.state.run_id.clone();
let status = runtime.state.status.clone();
let phase = runtime.state.phase.clone();
let _ = app.emit(
"game-creator-agent-runtime-update",
GameCreatorAgentRuntimeUpdateEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id,
run_id,
status,
phase,
runtime,
},
game_creator_agent_runtime_update_event(root, runtime),
);
}
@@ -64,14 +64,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry()
#[tokio::test]
async fn game_chat_absolute_deadline_preserves_external_generation_for_same_action_resume() {
let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-reconciliation-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
.expect("project init");
bind_game_creator_agent_runtime_run_profile_at(
@@ -239,14 +233,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti
#[test]
fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-local-cleanup-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-local-cleanup-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试")
.expect("project init");
let mut runtime = start_game_creator_agent_runtime_task_at(
@@ -1207,7 +1207,7 @@ fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() {
fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() {
const RUN_ID: &str = "autonomous-manifest-waiting-parent";
const TASK: &str = "生成完整小游戏并完成项目任务图";
let temporary = tempfile::tempdir().expect("create manifest waiting root");
let temporary = crate::tests::canonical_test_tempdir("manifest-waiting-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "manifest-waiting-project", TASK)
.expect("init manifest waiting project");
@@ -1421,7 +1421,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
const TASK: &str = "生成一个可完成静态检查和双视口试玩的塔防游戏";
const TEST_KEY: &str = "autonomous-final-reply-fallback-key";
let temporary = tempfile::tempdir().expect("create autonomous fallback root");
let temporary = crate::tests::canonical_test_tempdir("autonomous-fallback-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "autonomous-fallback-project", TASK)
.expect("init autonomous fallback project");
@@ -13,7 +13,6 @@ where
.await
.expect("pending continuation task must exist")
}
async fn run_after_pending_stack_boundary<T>(
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
) -> T
@@ -150,7 +149,6 @@ fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
);
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
}
pub(crate) async fn continue_game_creator_agent_pending_tool_action(
root: PathBuf,
agent_id: String,
@@ -1332,7 +1332,7 @@ mod pending_recovery_tests {
#[test]
fn observed_unknown_canvas_generation_returns_to_same_approved_action() {
let temporary = tempfile::tempdir().expect("create prepared pending project");
let temporary = crate::tests::canonical_test_tempdir("prepared-pending-");
let root = temporary.path();
init_local_game_project_at(root, "prepared-pending", "原俄罗斯方块项目")
.expect("init prepared pending project");
@@ -1413,7 +1413,7 @@ mod pending_recovery_tests {
#[test]
fn legacy_executing_canvas_generation_returns_to_same_approved_action() {
let temporary = tempfile::tempdir().expect("create executing prepared project");
let temporary = crate::tests::canonical_test_tempdir("executing-prepared-");
let root = temporary.path();
init_local_game_project_at(root, "executing-prepared", "旧版俄罗斯方块项目")
.expect("init executing prepared project");
@@ -1490,7 +1490,7 @@ mod pending_recovery_tests {
#[test]
fn observed_postprocessing_failure_resumes_from_accepted_generation() {
let temporary = tempfile::tempdir().expect("create accepted recovery project");
let temporary = crate::tests::canonical_test_tempdir("accepted-recovery-");
let root = temporary.path();
init_local_game_project_at(root, "accepted-recovery", "俄罗斯方块素材后处理恢复")
.expect("init accepted recovery project");
@@ -1560,7 +1560,7 @@ mod pending_recovery_tests {
#[test]
fn canvas_reconciliation_keeps_the_context_plan_step_active() {
let temporary = tempfile::tempdir().expect("create reconciliation context project");
let temporary = crate::tests::canonical_test_tempdir("reconciliation-context-");
let root = temporary.path();
init_local_game_project_at(root, "reconciliation-context", "俄罗斯方块恢复上下文")
.expect("init reconciliation context project");
@@ -1221,7 +1221,7 @@ mod orphaned_external_generation_recovery_tests {
#[test]
fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() {
let temporary = tempfile::tempdir().expect("create orphan generation recovery project");
let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-");
let root = temporary.path();
let run_id = "orphan-generation-recovery-run";
init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试")
@@ -22,7 +22,7 @@ fn autonomous_fixture_with_source(
AgentRuntimeState,
AgentRuntimeAutonomousCompletionContract,
) {
let temporary = tempfile::tempdir().expect("create autonomous fixture root");
let temporary = crate::tests::canonical_test_tempdir("autonomous-fixture-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "autonomous-project", task).expect("init project");
let session_id = resolve_agent_conversation_session_id_at(
@@ -223,7 +223,7 @@ fn autonomous_fixture_with_setup(
AgentRuntimeState,
AgentRuntimeAutonomousCompletionContract,
) {
let temporary = tempfile::tempdir().expect("create autonomous fixture root");
let temporary = crate::tests::canonical_test_tempdir("autonomous-setup-fixture-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "autonomous-project", task).expect("init project");
setup(&root);
@@ -826,10 +826,11 @@ mod tests {
.duration_since(UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"genarrative-context-window-boundary-{}-{unique}",
let temporary = crate::tests::canonical_test_tempdir(&format!(
"context-window-boundary-{}-{unique}-",
std::process::id()
));
let root = temporary.path().join("project");
init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目")
.expect("project init");
let mut runtime = start_game_creator_agent_runtime_task_at(
@@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout(
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
}
#[tauri::command]
pub(crate) fn read_local_project_resource_graph(
project_path: String,
expected_project_id: String,
resources: Vec<ProjectResourceGraphNodeInput>,
) -> Result<ProjectResourceGraphReadModel, String> {
let root = validated_local_project_directory_path(project_path.trim())?;
enforce_project_auto_permission_policy(&root, "asset.list")?;
read_project_resource_graph_at(&root, expected_project_id.trim(), resources)
}
#[tauri::command]
pub(crate) fn update_local_project_resource_canvas_layout(
project_path: String,
@@ -1166,6 +1177,66 @@ pub(crate) fn read_local_project_image_preview(
load_local_project_image_preview(root, &normalized_path)
}
#[tauri::command]
pub(crate) fn read_local_project_text_preview(
project_path: String,
relative_path: String,
) -> Result<LocalProjectTextPreview, String> {
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
let is_registered_document = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
&& is_supported_project_text_resource(&asset.local_path, &asset.media_type)
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& is_supported_project_text_resource(&normalized_path, "")
});
if !is_registered_document {
return Err("只能读取当前项目已登记的文档资源".to_string());
}
load_local_project_text_preview(root, &normalized_path)
}
#[tauri::command]
pub(crate) fn read_local_project_media_preview(
project_path: String,
relative_path: String,
category: String,
) -> Result<LocalProjectMediaPreview, String> {
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
let kind = match category.trim() {
"art" => ProjectMediaPreviewKind::Art,
"audio" => ProjectMediaPreviewKind::Audio,
_ => return Err("媒体预览类别只支持 art 或 audio".to_string()),
};
let is_registered_media = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
&& match kind {
ProjectMediaPreviewKind::Art => {
is_supported_project_art_media_resource(&asset.local_path, &asset.media_type)
}
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
}
}
}) || (kind == ProjectMediaPreviewKind::Art
&& manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& is_supported_project_art_media_resource(&normalized_path, "")
}));
if !is_registered_media {
return Err("只能预览当前项目已登记的媒体资源".to_string());
}
load_local_project_media_preview(root, &normalized_path, kind)
}
#[tauri::command]
pub(crate) fn write_local_project_file(
project_path: String,
@@ -204,7 +204,10 @@ fn validate_agent_runtime_inspection_path(
Ok(())
}
fn validate_agent_runtime_inspection_ancestors(root: &Path, path: &Path) -> Result<(), String> {
pub(crate) fn validate_agent_runtime_inspection_ancestors(
root: &Path,
path: &Path,
) -> Result<(), String> {
let relative = path
.strip_prefix(root)
.map_err(|_| "image.inspect 图片路径超出项目目录".to_string())?;
@@ -450,7 +453,7 @@ fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
}
#[cfg(unix)]
fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
left.dev() == right.dev()
&& left.ino() == right.ino()
@@ -463,12 +466,12 @@ fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
}
#[cfg(not(unix))]
fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
left.len() == right.len() && left.modified().ok() == right.modified().ok()
}
#[cfg(unix)]
fn same_open_file_identity(
pub(crate) fn same_open_file_identity(
_left_file: &fs::File,
left: &fs::Metadata,
_right_file: &fs::File,
@@ -479,7 +482,7 @@ fn same_open_file_identity(
}
#[cfg(windows)]
fn same_open_file_identity(
pub(crate) fn same_open_file_identity(
left_file: &fs::File,
_left: &fs::Metadata,
right_file: &fs::File,
@@ -489,7 +492,7 @@ fn same_open_file_identity(
}
#[cfg(not(any(unix, windows)))]
fn same_open_file_identity(
pub(crate) fn same_open_file_identity(
_left_file: &fs::File,
left: &fs::Metadata,
_right_file: &fs::File,
@@ -23,8 +23,9 @@ use reqwest::header;
use serde::{Deserialize, Serialize};
use shared_contracts::game_creation_app::{
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
@@ -72,6 +73,7 @@ mod project;
mod provider_handoff;
mod provider_retry;
mod repository_context;
mod resource_inspect;
mod runner;
mod swarm_cli;
mod tool_plan_handoff;
@@ -101,6 +103,7 @@ use preview::*;
use process_session::*;
use project::*;
use repository_context::*;
use resource_inspect::*;
use runner::*;
use swarm_cli::*;
use user_input::*;
@@ -626,9 +629,30 @@ struct GameCreatorAgentRuntimeUpdateEvent {
run_id: String,
status: String,
phase: String,
manifest_invalidated: bool,
runtime: AgentRuntimeResult,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidatedEvent {
project_path: String,
agent_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidationRelayEnvelope {
token: String,
event: GameCreatorManifestInvalidatedEvent,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct GameCreatorManifestInvalidationEventSink {
port: u16,
token: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentProgressEvent {
@@ -1848,6 +1872,7 @@ mod game_chat_release_client_exit_tests {
}
}
#[cfg(not(test))]
fn main() {
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
#[cfg(target_os = "linux")]
@@ -2088,7 +2113,10 @@ fn main() {
format!("启动 Agent Runner 失败:{error}"),
)
})?;
attach_external_agent_runner_gui_owner()
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
let manifest_event_sink =
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
attach_external_agent_runner_gui_owner(&manifest_event_sink)
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
@@ -2109,7 +2137,6 @@ fn main() {
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete");
}
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
#[cfg(all(debug_assertions, not(test)))]
if game_chat_launch.is_none() {
open_developer_window(app.handle())?;
@@ -2170,6 +2197,8 @@ fn main() {
list_local_project_files,
read_local_project_file,
read_local_project_image_preview,
read_local_project_text_preview,
read_local_project_media_preview,
write_local_project_file,
delete_local_project_file,
read_local_game_memory,
@@ -2201,6 +2230,7 @@ fn main() {
stop_local_game_preview_if_matches,
get_local_game_preview_status,
read_local_project_resource_canvas_layout,
read_local_project_resource_graph,
update_local_project_resource_canvas_layout,
get_local_game_project_revision,
get_local_game_manifest
@@ -1980,7 +1980,10 @@ mod tests {
}
fn mcp_test_project(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
let temp_root = std::env::temp_dir()
.canonicalize()
.expect("canonicalize MCP test temp root");
let root = temp_root.join(format!(
"game-creator-mcp-{label}-{}-{}",
std::process::id(),
MCP_TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)
@@ -10,6 +10,7 @@ mod export;
mod filesystem;
mod manifest;
mod memory;
mod resource_dependency_graph;
mod resource_layout;
mod verification;
@@ -20,5 +21,6 @@ pub(crate) use export::*;
pub(crate) use filesystem::*;
pub(crate) use manifest::*;
pub(crate) use memory::*;
pub(crate) use resource_dependency_graph::*;
pub(crate) use resource_layout::*;
pub(crate) use verification::*;
@@ -1,5 +1,169 @@
use super::*;
const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500;
const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10;
static MANIFEST_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
#[derive(Debug)]
struct ManifestWriteLock {
_file: File,
}
fn manifest_lock_path(path: &Path) -> PathBuf {
path.with_file_name(format!(
".{}.lock",
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("manifest.json")
))
}
fn acquire_manifest_write_lock(path: &Path) -> Result<ManifestWriteLock, String> {
for attempt in 0..MANIFEST_LOCK_WAIT_ATTEMPTS {
if let Some(file) = try_open_manifest_write_lock_file(path)? {
return Ok(ManifestWriteLock { _file: file });
}
if attempt + 1 < MANIFEST_LOCK_WAIT_ATTEMPTS {
std::thread::sleep(Duration::from_millis(MANIFEST_LOCK_WAIT_MILLIS));
}
}
Err("manifest 正在被其他进程写入,请稍后重试".to_string())
}
#[cfg(unix)]
fn try_open_manifest_write_lock_file(path: &Path) -> Result<Option<File>, String> {
use std::os::fd::AsRawFd;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
let _open_guard = MANIFEST_LOCK_OPEN_GUARD
.get_or_init(|| Mutex::new(()))
.lock()
.map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?;
let lock_path = manifest_lock_path(path);
let mut options = fs::OpenOptions::new();
options
.create(true)
.read(true)
.write(true)
.mode(0o600)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
let file = options
.open(&lock_path)
.map_err(|error| format!("安全打开 manifest 锁失败:{}: {error}", lock_path.display()))?;
let metadata = file.metadata().map_err(|error| {
format!(
"读取 manifest 锁句柄元数据失败:{}: {error}",
lock_path.display()
)
})?;
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
let effective_user_id = unsafe { libc::geteuid() };
if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 {
return Err(format!(
"manifest 锁必须是当前用户持有的无硬链接普通文件:{}",
lock_path.display()
));
}
file.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|error| format!("收紧 manifest 锁权限失败:{}: {error}", lock_path.display()))?;
let path_metadata = fs::symlink_metadata(&lock_path)
.map_err(|error| format!("复核 manifest 锁路径失败:{}: {error}", lock_path.display()))?;
if path_metadata.file_type().is_symlink()
|| path_metadata.dev() != metadata.dev()
|| path_metadata.ino() != metadata.ino()
{
return Err(format!(
"manifest 锁路径在安全打开期间发生替换:{}",
lock_path.display()
));
}
let verified = file
.metadata()
.map_err(|error| format!("复核 manifest 锁句柄失败:{}: {error}", lock_path.display()))?;
if verified.uid() != effective_user_id
|| verified.nlink() != 1
|| verified.permissions().mode() & 0o777 != 0o600
{
return Err(format!(
"manifest 锁必须由当前用户持有且权限为 0600:{}",
lock_path.display()
));
}
// SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock.
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
return Ok(Some(file));
}
let error = std::io::Error::last_os_error();
if error.kind() == std::io::ErrorKind::WouldBlock {
Ok(None)
} else {
Err(format!(
"获取 manifest 系统文件锁失败:{}: {error}",
lock_path.display()
))
}
}
#[cfg(windows)]
fn try_open_manifest_write_lock_file(path: &Path) -> Result<Option<File>, String> {
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let _open_guard = MANIFEST_LOCK_OPEN_GUARD
.get_or_init(|| Mutex::new(()))
.lock()
.map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?;
let lock_path = manifest_lock_path(path);
if let Ok(metadata) = fs::symlink_metadata(&lock_path) {
if metadata.file_type().is_symlink()
|| !metadata.is_file()
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
{
return Err(format!(
"manifest 锁必须是普通文件且不能是 reparse point{}",
lock_path.display()
));
}
}
match fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.share_mode(0)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(&lock_path)
{
Ok(file) => {
validate_windows_regular_file_handle(&file, "manifest 锁")?;
crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, true)?;
Ok(Some(file))
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock
) =>
{
Ok(None)
}
Err(error) => Err(format!(
"获取 manifest 系统文件锁失败:{}: {error}",
lock_path.display()
)),
}
}
#[cfg(not(any(unix, windows)))]
fn try_open_manifest_write_lock_file(path: &Path) -> Result<Option<File>, String> {
Err(format!(
"当前平台不支持 manifest 系统文件锁:{}",
manifest_lock_path(path).display()
))
}
pub(crate) fn init_local_game_project_at(
root: &Path,
project_id: &str,
@@ -631,8 +795,11 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
.open(source_path)
.and_then(|mut file| file.read_to_string(&mut payload))
.map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?;
serde_json::from_str(&payload)
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))
let manifest: GameCreationAppManifest = serde_json::from_str(&payload)
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
validate_game_iteration_versions(&manifest.versions)
.map_err(|error| format!("校验 {label} 项目版本失败:{error}"))?;
Ok(manifest)
}
fn install_manifest_temp_with<F>(
@@ -709,12 +876,39 @@ pub(crate) fn write_manifest(
path: &Path,
manifest: &GameCreationAppManifest,
) -> Result<(), String> {
write_manifest_with_lock_hook(path, manifest, || {})
}
fn write_manifest_with_lock_hook<F>(
path: &Path,
manifest: &GameCreationAppManifest,
after_lock: F,
) -> Result<(), String>
where
F: FnOnce(),
{
validate_game_iteration_versions(&manifest.versions)
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
let payload = serde_json::to_string_pretty(manifest)
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?;
}
let _write_lock = acquire_manifest_write_lock(path)?;
after_lock();
if manifest_storage_exists(path)? {
let existing = read_manifest(path)?;
if existing.versions.len() > manifest.versions.len()
|| existing
.versions
.iter()
.zip(&manifest.versions)
.any(|(existing, candidate)| existing != candidate)
{
return Err("项目版本记录写入后不可修改、删除或重排".to_string());
}
}
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err("manifest 必须是普通文件".to_string());
@@ -745,7 +939,12 @@ pub(crate) fn write_manifest(
temp_path.display()
)
})?;
install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))
install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?;
let installed = read_manifest(path)?;
if installed != *manifest {
return Err("manifest 安装后回读与待写入内容不一致".to_string());
}
Ok(())
}
pub(crate) fn sanitize_file_name(file_name: &str) -> String {
@@ -1,4 +1,7 @@
use super::*;
use shared_contracts::game_creation_app::{
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
};
fn unique_manifest_test_root(test_name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
@@ -40,6 +43,126 @@ fn manifest_read_and_project_write_recover_previous_file() {
fs::remove_dir_all(root).ok();
}
fn version_fixture(
version_id: &str,
parent_version_id: Option<&str>,
project_revision: u64,
created_reason: GameIterationVersionCreatedReason,
) -> GameIterationVersion {
GameIterationVersion {
version_id: version_id.to_string(),
parent_version_id: parent_version_id.map(str::to_string),
project_revision,
resource_bindings: vec![GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
}],
created_reason,
created_at: project_revision,
}
}
#[test]
fn manifest_versions_are_append_only_at_the_storage_boundary() {
let root = unique_manifest_test_root("versions-append-only");
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = new_game_creation_app_manifest("project-versioned", "版本项目");
manifest.versions.push(version_fixture(
"version-root",
None,
1,
GameIterationVersionCreatedReason::Initial,
));
write_manifest(&manifest_path, &manifest).expect("write initial version");
manifest.versions.push(version_fixture(
"version-child",
Some("version-root"),
2,
GameIterationVersionCreatedReason::AgentRevision,
));
write_manifest(&manifest_path, &manifest).expect("append child version");
let stable_payload = fs::read(&manifest_path).expect("read stable manifest bytes");
manifest.versions[0].resource_bindings[0].resource_id = "asset-mutated".to_string();
let error =
write_manifest(&manifest_path, &manifest).expect_err("reject mutation of existing version");
assert!(error.contains("不可修改、删除或重排"), "{error}");
assert_eq!(
fs::read(&manifest_path).expect("read untouched manifest bytes"),
stable_payload
);
fs::remove_dir_all(root).ok();
}
#[test]
fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_snapshot() {
let root = unique_manifest_test_root("versions-concurrent-append-only");
let manifest_path = root.join(".agent/manifest.json");
let mut stale_manifest = new_game_creation_app_manifest("project-versioned", "并发版本项目");
stale_manifest.versions.push(version_fixture(
"version-root",
None,
1,
GameIterationVersionCreatedReason::Initial,
));
write_manifest(&manifest_path, &stale_manifest).expect("write initial version");
let mut newer_manifest = stale_manifest.clone();
newer_manifest.versions.push(version_fixture(
"version-child",
Some("version-root"),
2,
GameIterationVersionCreatedReason::AgentRevision,
));
let (newer_locked_tx, newer_locked_rx) = mpsc::channel();
let (release_newer_tx, release_newer_rx) = mpsc::channel();
let newer_path = manifest_path.clone();
let newer_writer = std::thread::spawn(move || {
write_manifest_with_lock_hook(&newer_path, &newer_manifest, || {
newer_locked_tx
.send(())
.expect("signal newer lock acquired");
release_newer_rx.recv().expect("release newer writer");
})
});
newer_locked_rx
.recv_timeout(Duration::from_secs(2))
.expect("newer writer acquires manifest lock");
let (stale_started_tx, stale_started_rx) = mpsc::channel();
let stale_path = manifest_path.clone();
let stale_writer = std::thread::spawn(move || {
stale_started_tx
.send(())
.expect("signal stale writer started");
write_manifest(&stale_path, &stale_manifest)
});
stale_started_rx
.recv_timeout(Duration::from_secs(2))
.expect("stale writer starts while newer writer holds lock");
release_newer_tx.send(()).expect("release newer writer");
newer_writer
.join()
.expect("join newer writer")
.expect("install newer manifest");
let stale_error = stale_writer
.join()
.expect("join stale writer")
.expect_err("reject stale manifest after newer version is installed");
assert!(
stale_error.contains("不可修改、删除或重排"),
"{stale_error}"
);
let installed = read_manifest(&manifest_path).expect("read final manifest");
assert_eq!(installed.versions.len(), 2);
assert_eq!(installed.versions[1].version_id, "version-child");
fs::remove_dir_all(root).ok();
}
#[test]
fn manifest_install_uses_previous_when_direct_replace_fails() {
let root = unique_manifest_test_root("replace-fallback");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,441 @@
use crate::image_inspect::{
same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors,
};
use crate::project::{
normalize_relative_path, open_project_snapshot_regular_file,
reject_sensitive_project_file_read, resolve_local_project_path,
};
use base64::Engine as _;
use serde::Serialize;
use std::io::Read;
use std::path::Path;
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectTextPreview {
pub(crate) path: String,
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) content: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectMediaPreview {
pub(crate) path: String,
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) data_url: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ProjectMediaPreviewKind {
Art,
Audio,
}
pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool {
let media_type = media_type.trim().to_ascii_lowercase();
matches!(
path_extension(path).as_deref(),
Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml")
) && (media_type.is_empty()
|| media_type.starts_with("text/")
|| media_type.contains("json")
|| media_type.contains("yaml")
|| matches!(
media_type.as_str(),
"项目文档" | "application/toml" | "application/mdx"
))
}
pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &str) -> bool {
let media_type = media_type.trim().to_ascii_lowercase();
matches!(
path_extension(path).as_deref(),
Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov")
) || media_type.starts_with("video/")
|| media_type == "image/svg+xml"
}
pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool {
let media_type = media_type.trim().to_ascii_lowercase();
matches!(
path_extension(path).as_deref(),
Some("mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus")
) || media_type.starts_with("audio/")
}
pub(crate) fn load_local_project_text_preview(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectTextPreview, String> {
let normalized = normalize_relative_path(relative_path.trim())?;
reject_sensitive_project_file_read(&normalized)?;
let media_type = project_text_media_type(&normalized)
.ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?;
let bytes = read_stable_project_resource(
root,
&normalized,
PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES,
"项目文档",
)?;
let content =
String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?;
Ok(LocalProjectTextPreview {
path: normalized,
media_type: media_type.to_string(),
byte_len: content.len() as u64,
content,
})
}
pub(crate) fn load_local_project_media_preview(
root: &Path,
relative_path: &str,
kind: ProjectMediaPreviewKind,
) -> Result<LocalProjectMediaPreview, String> {
let normalized = normalize_relative_path(relative_path.trim())?;
reject_sensitive_project_file_read(&normalized)?;
let bytes = read_stable_project_resource(
root,
&normalized,
PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES,
"项目媒体资源",
)?;
if bytes.is_empty() {
return Err("媒体文件为空,无法预览".to_string());
}
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
Ok(LocalProjectMediaPreview {
path: normalized,
media_type: media_type.to_string(),
byte_len: bytes.len() as u64,
data_url: format!(
"data:{media_type};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
),
})
}
fn read_stable_project_resource(
root: &Path,
normalized: &str,
max_bytes: u64,
label: &str,
) -> Result<Vec<u8>, String> {
let absolute = resolve_local_project_path(root, normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
if initial_metadata.len() > max_bytes {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
file.by_ref()
.take(max_bytes + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
if bytes.len() as u64 > max_bytes {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
let final_metadata = file
.metadata()
.map_err(|error| format!("复核{label}失败:{normalized}: {error}"))?;
if initial_metadata.len() != bytes.len() as u64
|| final_metadata.len() != bytes.len() as u64
|| !same_open_file_snapshot(&initial_metadata, &final_metadata)
{
return Err(format!("{label}读取期间发生漂移:{normalized}"));
}
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
return Err(format!("{label}路径读取期间发生替换:{normalized}"));
}
Ok(bytes)
}
fn project_text_media_type(path: &str) -> Option<&'static str> {
match path_extension(path).as_deref()? {
"md" | "markdown" | "mdx" => Some("text/markdown"),
"txt" => Some("text/plain"),
"json" => Some("application/json"),
"yaml" | "yml" => Some("application/yaml"),
"toml" => Some("application/toml"),
_ => None,
}
}
fn detect_project_media_type(
path: &str,
bytes: &[u8],
kind: ProjectMediaPreviewKind,
) -> Result<&'static str, String> {
if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") {
validate_safe_svg(bytes)?;
return Ok("image/svg+xml");
}
if kind == ProjectMediaPreviewKind::Art {
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return Ok("image/gif");
}
if bytes.starts_with(b"BM") {
return Ok("image/bmp");
}
if is_avif(bytes) {
return Ok("image/avif");
}
if is_iso_base_media(bytes) {
return Ok(if path_extension(path).as_deref() == Some("mov") {
"video/quicktime"
} else {
"video/mp4"
});
}
if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
return Ok("video/webm");
}
return Err("美术媒体预览只支持 GIF、安全 SVG、AVIF、BMP、MP4、WebM 或 MOV".to_string());
}
if looks_like_id3(bytes) || looks_like_mp3_frame(bytes) {
Ok("audio/mpeg")
} else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" {
Ok("audio/wav")
} else if bytes.starts_with(b"OggS") {
Ok("audio/ogg")
} else if bytes.starts_with(b"fLaC") {
Ok("audio/flac")
} else if is_avif(bytes) {
Err("音乐音效文件签名与登记类型不一致".to_string())
} else if is_iso_base_media(bytes) {
Ok("audio/mp4")
} else if looks_like_aac_adts(bytes) {
Ok("audio/aac")
} else {
Err("音乐音效预览只支持 MP3、WAV、OGG、M4A、AAC、FLAC 或 Opus".to_string())
}
}
fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> {
let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?;
let lower = text.to_ascii_lowercase();
if !lower.contains("<svg") {
return Err("SVG 内容无效,无法预览".to_string());
}
let forbidden = [
"<script",
"<style",
"<foreignobject",
"<image",
"<!doctype",
"<!entity",
"&#",
"javascript:",
"file:",
"@import",
];
let external_url_probe = lower
.replace("http://www.w3.org/2000/svg", "")
.replace("http://www.w3.org/1999/xlink", "");
if forbidden.iter().any(|value| lower.contains(value))
|| external_url_probe.contains("http://")
|| external_url_probe.contains("https://")
|| contains_svg_event_handler(&lower)
|| contains_unsafe_svg_href(&lower)
|| contains_unsafe_svg_url(&lower)
{
return Err("SVG 包含脚本或外部资源引用,无法安全预览".to_string());
}
Ok(())
}
fn contains_unsafe_svg_href(text: &str) -> bool {
let mut remaining = text;
while let Some(index) = remaining.find("href") {
let after_name = &remaining[index + 4..];
let Some(after_equals) = after_name.trim_start().strip_prefix('=') else {
remaining = after_name;
continue;
};
let value = after_equals.trim_start();
let value = value
.strip_prefix('\'')
.or_else(|| value.strip_prefix('"'))
.unwrap_or(value)
.trim_start();
if !value.starts_with('#') {
return true;
}
remaining = after_name;
}
false
}
fn contains_unsafe_svg_url(text: &str) -> bool {
let mut remaining = text;
while let Some(index) = remaining.find("url(") {
let value = remaining[index + 4..].trim_start();
let value = value
.strip_prefix('\'')
.or_else(|| value.strip_prefix('"'))
.unwrap_or(value)
.trim_start();
if !value.starts_with('#') {
return true;
}
remaining = &remaining[index + 4..];
}
false
}
fn contains_svg_event_handler(text: &str) -> bool {
let bytes = text.as_bytes();
let mut index = 0usize;
while index + 3 < bytes.len() {
if bytes[index].is_ascii_whitespace() && bytes[index + 1..].starts_with(b"on") {
let mut cursor = index + 3;
while cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() {
cursor += 1;
}
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor < bytes.len() && bytes[cursor] == b'=' {
return true;
}
}
index += 1;
}
false
}
fn is_iso_base_media(bytes: &[u8]) -> bool {
bytes.len() >= 12 && &bytes[4..8] == b"ftyp"
}
fn is_avif(bytes: &[u8]) -> bool {
is_iso_base_media(bytes)
&& (&bytes[8..12] == b"avif"
|| &bytes[8..12] == b"avis"
|| bytes[8..].windows(4).any(|brand| brand == b"avif"))
}
fn looks_like_mp3_frame(bytes: &[u8]) -> bool {
bytes.len() >= 4
&& bytes[0] == 0xff
&& bytes[1] & 0xe0 == 0xe0
&& bytes[1] & 0x06 != 0
&& bytes[2] & 0xf0 != 0xf0
&& bytes[2] & 0x0c != 0x0c
}
fn looks_like_id3(bytes: &[u8]) -> bool {
if bytes.len() < 10 || !bytes.starts_with(b"ID3") || bytes[3] == 0xff || bytes[4] == 0xff {
return false;
}
let size_bytes = &bytes[6..10];
if size_bytes.iter().any(|byte| byte & 0x80 != 0) {
return false;
}
let tag_size = size_bytes
.iter()
.fold(0usize, |size, byte| (size << 7) | usize::from(*byte));
10usize
.checked_add(tag_size)
.is_some_and(|required| required <= bytes.len())
}
fn looks_like_aac_adts(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xf6 == 0xf0
}
fn path_extension(path: &str) -> Option<String> {
Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn text_preview_requires_utf8_and_a_supported_extension() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("docs")).expect("docs dir");
fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown");
fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text");
fs::write(root.path().join("docs/page.html"), "<h1>unsafe</h1>").expect("html");
let preview =
load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown");
assert_eq!(preview.media_type, "text/markdown");
assert!(preview.content.contains("正文"));
assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err());
assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err());
}
#[test]
fn media_preview_accepts_safe_svg_and_rejects_active_svg() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets")).expect("assets dir");
fs::write(
root.path().join("assets/icon.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>",
)
.expect("svg");
fs::write(
root.path().join("assets/active.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\" onload=\"alert(1)\"/>",
)
.expect("active svg");
fs::write(
root.path().join("assets/external.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\"><use href = \"https://example.com/icon.svg#x\"/></svg>",
)
.expect("external svg");
let preview = load_local_project_media_preview(
root.path(),
"assets/icon.svg",
ProjectMediaPreviewKind::Art,
)
.expect("safe svg");
assert_eq!(preview.media_type, "image/svg+xml");
assert!(preview.data_url.starts_with("data:image/svg+xml;base64,"));
assert!(load_local_project_media_preview(
root.path(),
"assets/active.svg",
ProjectMediaPreviewKind::Art,
)
.is_err());
assert!(load_local_project_media_preview(
root.path(),
"assets/external.svg",
ProjectMediaPreviewKind::Art,
)
.is_err());
}
#[cfg(unix)]
#[test]
fn resource_preview_rejects_symlink_and_hardlink_files() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().expect("temp root");
let outside = tempfile::tempdir().expect("outside");
fs::create_dir_all(root.path().join("docs")).expect("docs dir");
let source = outside.path().join("source.md");
fs::write(&source, "secret").expect("source");
symlink(&source, root.path().join("docs/link.md")).expect("symlink");
fs::hard_link(&source, root.path().join("docs/hard.md")).expect("hardlink");
assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err());
assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err());
}
}
@@ -1,5 +1,8 @@
use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*};
use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog};
use crate::{
AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink,
GameCreatorMcpCatalog,
};
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use std::ffi::OsString;
@@ -982,7 +985,9 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
shutdown_external_agent_runner_at(&config_dir)
}
pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
pub(crate) fn attach_external_agent_runner_gui_owner(
event_sink: &GameCreatorManifestInvalidationEventSink,
) -> Result<(), String> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
.store(true, std::sync::atomic::Ordering::Release);
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
@@ -991,21 +996,33 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
register_external_agent_runner_gui_owner_attachment(
external_agent_runner_gui_owner_attachment_state(),
&config_dir,
ExternalAgentRunnerRequestParams::default(),
ExternalAgentRunnerRequestParams {
event_sink_port: Some(event_sink.port),
event_sink_token: Some(event_sink.token.clone()),
..ExternalAgentRunnerRequestParams::default()
},
);
ensure_external_agent_runner(&config_dir).map(|_| ())
}
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
result: &Value,
) -> Result<(), String> {
if result.get("attached").and_then(Value::as_bool) == Some(true)
&& result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true)
{
Ok(())
} else {
Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string())
}
}
fn attach_external_agent_runner_gui_owner_at(
endpoint: &ExternalAgentRunnerEndpoint,
params: ExternalAgentRunnerRequestParams,
) -> Result<(), String> {
let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?;
if result.get("attached").and_then(Value::as_bool) == Some(true) {
Ok(())
} else {
Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string())
}
validate_external_agent_runner_gui_owner_attachment_result(&result)
}
fn attach_registered_external_agent_runner_gui_owner_if_needed(
@@ -1276,6 +1293,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
run_id: run_id.map(str::to_string),
action_id: action_id.map(str::to_string),
steer_id: steer_id.map(str::to_string),
event_sink_port: None,
event_sink_token: None,
};
match stable_identity {
Some(stable_identity) => {

Some files were not shown because too many files have changed in this diff Show More