合并主分支
同步主分支资源卡依赖关系与类型分类预览等最新变更 保留并补齐画布快速编辑前后端正向白名单 拒绝图片编辑接口处理单个图标、角色动作、音频、视频及未知类型 修正快速编辑决策记录与现行编辑器文档 补充后端现役类型和未知类型表驱动测试
This commit is contained in:
@@ -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");
|
||||
|
||||
+2
-2
@@ -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");
|
||||
|
||||
+1
-1
@@ -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", "生成账本清理顺序测试")
|
||||
|
||||
+4
-3
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-16
@@ -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", "孤儿生成账本恢复测试")
|
||||
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user