实现游戏工作台 P0/P1 可运行版本能力 #132

Closed
menghao wants to merge 29 commits from codex/game-agent-run into master
63 changed files with 8872 additions and 1230 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',
@@ -2057,7 +2057,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");
@@ -736,7 +736,7 @@ mod external_generation_state_tests {
#[test]
fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() {
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");
@@ -846,7 +846,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", "生成账本清理顺序测试")
@@ -425,7 +425,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");
@@ -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_reconciliation() {
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(
@@ -271,14 +265,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_reconciliatio
#[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(
@@ -767,7 +767,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");
@@ -981,7 +981,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,
@@ -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", "孤儿生成账本恢复测试")
@@ -1249,6 +1249,88 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
}),
)?;
}
if status == GameCreationAppTaskStatus::Completed && state.agent_id == "preview-playtest" {
let manifest_before_completion = read_manifest_for_project(root)?;
let required_tasks = autonomous_manifest_seed_tasks_for_source(&root_parent_binding.source);
let required_by_id = required_tasks
.iter()
.map(|task| (task.id.as_str(), task))
.collect::<std::collections::HashMap<_, _>>();
let mut prerequisite_ids = std::collections::BTreeSet::new();
let mut pending_ids = vec![state.agent_id.as_str()];
while let Some(task_id) = pending_ids.pop() {
if !prerequisite_ids.insert(task_id) {
continue;
}
let task = required_by_id
.get(task_id)
.ok_or_else(|| format!("可运行版本项目完整性合同缺少任务:{task_id}"))?;
pending_ids.extend(task.dependencies.iter().map(String::as_str));
}
let incomplete = required_tasks
.iter()
.filter(|required| prerequisite_ids.contains(required.id.as_str()))
.filter(|required| required.id != state.agent_id)
.filter(|required| {
manifest_before_completion
.tasks
.iter()
.find(|task| task.id == required.id)
.is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed)
})
.map(|task| task.id.as_str())
.collect::<Vec<_>>();
if !incomplete.is_empty() {
return Err(format!(
"可运行版本项目完整性检查未通过:{}",
incomplete.join("")
));
}
let readiness_records =
latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(
&game_creator_agent_runtime_task_path(root, "preview-readiness"),
)?);
let readiness = readiness_records
.iter()
.rev()
.find(|record| {
record.parent_agent_id.as_deref() == Some(parent_agent_id.as_str())
&& record.parent_run_id.as_deref() == Some(parent_run_id.as_str())
&& record.status == "completed"
})
.ok_or_else(|| "可运行版本缺少 preview-readiness 完成回执".to_string())?;
let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
let readiness_gate = read_game_creator_agent_runtime_verification_gate(
root,
&readiness.agent_id,
&readiness.run_id,
)?;
if readiness_gate.last_verification_tool.as_deref() != Some("game.static_smoke")
|| readiness_gate.last_verification_status.as_deref()
!= Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|| readiness_gate.verified_revision != Some(current_revision.revision)
{
return Err("可运行版本缺少当前 revision 的 game.static_smoke 通过凭证".to_string());
}
let contract = autonomous_playtest_completion_contract_for_state_at(root, state)?
.ok_or_else(|| "可运行版本缺少 preview.validate 完成合同".to_string())?;
let receipt = read_autonomous_playtest_receipt(root, &contract)?
.ok_or_else(|| "可运行版本缺少 preview.validate 成功回执".to_string())?;
verify_autonomous_playtest_evidence_files_at(root, &receipt)?;
if receipt.revision != current_revision.revision {
return Err(format!(
"可运行版本 revision 不一致:receipt={} current={}",
receipt.revision, current_revision.revision
));
}
register_current_runnable_game_version_at(
root,
current_revision.revision,
&receipt.agent_id,
&receipt.run_id,
&receipt.report.path,
)?;
}
update_manifest_task_status_at(root, &state.agent_id, status.clone())?;
append_agent_db_record(
root,
@@ -698,6 +698,138 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() {
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none());
}
#[test]
fn preview_playtest_terminal_registers_a_runnable_snapshot_before_downstream_publish_tasks_complete(
) {
let (_temporary, root, parent_state, contract) =
autonomous_fixture("做一个完整小游戏", "autonomous-runnable-version-parent");
for task_id in ["publish-strategy", "publish-package"] {
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending)
.unwrap_or_else(|error| panic!("leave downstream {task_id} pending: {error}"));
}
let task_ids = read_manifest_for_project(&root)
.expect("read autonomous manifest")
.tasks
.into_iter()
.map(|task| task.id)
.collect::<Vec<_>>();
for task_id in task_ids {
if !matches!(
task_id.as_str(),
"preview-readiness" | "preview-playtest" | "publish-strategy" | "publish-package"
) {
update_manifest_task_status_at(&root, &task_id, GameCreationAppTaskStatus::Completed)
.unwrap_or_else(|error| panic!("complete prerequisite {task_id}: {error}"));
}
}
update_manifest_task_status_at(
&root,
"preview-readiness",
GameCreationAppTaskStatus::Running,
)
.expect("mark preview readiness running");
let readiness_child =
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness");
let revision = advance_game_index_revision(
&root,
&parent_state,
"<!doctype html><title>可运行版本</title><canvas></canvas>",
);
let readiness_state = agent_runtime_state_from_task_record(&readiness_child);
mark_verification_passed(&root, &readiness_state, "game.static_smoke");
let readiness_terminal = AgentRuntimeTaskRecord {
status: "completed".to_string(),
phase: "completed".to_string(),
updated_at: unix_timestamp(),
..readiness_child
};
append_game_creator_agent_runtime_task_record(&root, &readiness_terminal)
.expect("persist completed preview readiness record");
project_autonomous_manifest_ready_task_terminal_at(
&root,
&agent_runtime_state_from_task_record(&readiness_terminal),
)
.expect("project preview readiness completion");
update_manifest_task_status_at(
&root,
"preview-playtest",
GameCreationAppTaskStatus::Running,
)
.expect("mark preview playtest running");
let playtest_child =
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest");
let playtest_state = agent_runtime_state_from_task_record(&playtest_child);
let result = browser_result_fixture(
&root,
&parent_state,
revision,
BrowserPlaytestScenario::GenericV1,
);
let action = AgentRuntimeToolAction {
tool: "preview.validate".to_string(),
reason: Some("验证可运行版本".to_string()),
input: serde_json::json!({}),
};
let action_fingerprint =
agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task);
let action_id =
agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint);
write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect("persist runnable playtest receipt");
let playtest_terminal = AgentRuntimeTaskRecord {
status: "completed".to_string(),
phase: "completed".to_string(),
updated_at: unix_timestamp(),
..playtest_child
};
append_game_creator_agent_runtime_task_record(&root, &playtest_terminal)
.expect("persist completed preview playtest record");
project_autonomous_manifest_ready_task_terminal_at(
&root,
&agent_runtime_state_from_task_record(&playtest_terminal),
)
.expect("project preview playtest and register runnable version");
let manifest = read_manifest_for_project(&root).expect("read runnable manifest");
assert_eq!(manifest.runnable_versions.len(), 1);
let version = &manifest.runnable_versions[0];
assert_eq!(version.project_revision, revision);
assert_eq!(
version.created_reason,
RunnableGameVersionCreatedReason::Initial
);
assert_eq!(
manifest.current_runnable_version_id.as_deref(),
Some(version.version_id.as_str())
);
assert!(root
.join(&version.artifact_path)
.join("game/index.html")
.is_file());
for task_id in ["publish-strategy", "publish-package"] {
let status = manifest
.tasks
.iter()
.find(|task| task.id == task_id)
.map(|task| &task.status)
.unwrap_or_else(|| panic!("missing downstream task {task_id}"));
assert_ne!(
status,
&GameCreationAppTaskStatus::Completed,
"runnable registration must not wait for downstream {task_id} completion"
);
}
}
#[test]
fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() {
let baseline_bytes =
@@ -827,10 +827,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,6 +23,7 @@ use reqwest::header;
use serde::{Deserialize, Serialize};
use shared_contracts::game_creation_app::{
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
validate_game_iteration_versions, validate_runnable_game_versions,
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
@@ -31,11 +32,14 @@ use shared_contracts::game_creation_app::{
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
GameIterationVersionResourceBinding, ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, RunnableGameVersion,
RunnableGameVersionCreatedReason, RunnableGameVersionValidation,
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
RUNNABLE_GAME_VERSION_SCHEMA_VERSION,
};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
@@ -72,6 +76,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 +106,7 @@ use preview::*;
use process_session::*;
use project::*;
use repository_context::*;
use resource_inspect::*;
use runner::*;
use swarm_cli::*;
use user_input::*;
@@ -144,6 +150,14 @@ struct LocalPreviewStatus {
root: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct RunnableGameVersionLaunchResult {
manifest: GameCreationAppManifest,
version: RunnableGameVersion,
preview: LocalPreviewResult,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalGameProjectRevisionStatus {
@@ -2026,6 +2040,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")]
@@ -2348,6 +2363,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,
@@ -2374,11 +2391,13 @@ fn main() {
open_game_creator_launcher_window,
open_project_supervisor_chat_window,
start_local_game_preview,
launch_local_game_runnable_version,
activate_local_game_preview,
stop_local_game_preview,
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
@@ -1931,7 +1931,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)
@@ -382,6 +382,14 @@ pub(crate) fn activate_local_game_preview(
pub(crate) fn start_local_game_preview_for_project(
root: &Path,
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
start_local_game_preview_for_served_root(root, root)
}
pub(crate) fn start_local_game_preview_for_served_root(
project_root: &Path,
served_root: &Path,
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
let root = project_root;
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
@@ -389,7 +397,10 @@ pub(crate) fn start_local_game_preview_for_project(
return Err("项目目录必须是绝对路径".to_string());
}
let game_root = root.join("game");
if served_root.as_os_str().is_empty() || !served_root.is_absolute() {
return Err("预览产物目录无效".to_string());
}
let game_root = served_root.join("game");
if !game_root.is_dir() {
return Err(format!("游戏目录不存在:{}", game_root.display()));
}
@@ -409,7 +420,7 @@ pub(crate) fn start_local_game_preview_for_project(
listener
.set_nonblocking(true)
.map_err(|error| format!("设置预览监听失败:{error}"))?;
let served_root = root.to_path_buf();
let served_root = served_root.to_path_buf();
let (stop_sender, stop_receiver) = mpsc::channel();
thread::spawn(move || loop {
@@ -443,6 +454,80 @@ pub(crate) fn start_local_game_preview_for_project(
))
}
#[tauri::command]
pub(crate) fn launch_local_game_runnable_version(
project_path: String,
expected_project_id: String,
version_id: Option<String>,
registry: tauri::State<'_, PreviewRegistry>,
) -> Result<RunnableGameVersionLaunchResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "preview.start")?;
let _lock = acquire_project_write_lock(root, "preview.start")?;
let current_manifest = read_existing_manifest_for_project(root)?;
if current_manifest.project_id != expected_project_id.trim() {
return Err("可运行版本项目身份不一致".to_string());
}
let version_id = version_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or(current_manifest.current_runnable_version_id.clone())
.ok_or_else(|| "当前无可运行版本".to_string())?;
let _ = registry.stop_for_project(Some(root));
let (_, version, artifact_root) =
resolve_runnable_game_version_at(root, expected_project_id.trim(), &version_id)?;
let (preview, stop) = match start_local_game_preview_for_served_root(root, &artifact_root) {
Ok(result) => result,
Err(error) => {
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
return Err(format!("可运行版本预览启动失败:{error}"));
}
};
if let Err(error) = record_preview_state(
root,
GameCreationAppPreviewStatus::Running,
Some(preview.url.clone()),
Some(preview.port),
) {
let _ = stop.send(());
return Err(error);
}
if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) {
let _ = stop.send(());
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
return Err(error);
}
let (preview, previous_preview) = registry.set_running(preview, stop);
if let Some(previous_preview) = previous_preview.as_ref() {
record_replaced_preview_stop(previous_preview);
}
if let Err(error) = append_preview_start_trace_step(root, &preview) {
let _ = registry.stop();
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
return Err(error);
}
let (manifest, selected_version, _) = match select_current_runnable_game_version_at(
root,
expected_project_id.trim(),
&version.version_id,
) {
Ok(selected) => selected,
Err(error) => {
let _ = registry.stop();
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
return Err(error);
}
};
Ok(RunnableGameVersionLaunchResult {
manifest,
version: selected_version,
preview,
})
}
fn handle_preview_stream(mut stream: TcpStream, root: &Path) {
// The listener is nonblocking so its accept loop can observe the stop channel. Windows may
// inherit that mode on accepted sockets; switch each connection back to blocking mode before
@@ -10,7 +10,9 @@ mod export;
mod filesystem;
mod manifest;
mod memory;
mod resource_dependency_graph;
mod resource_layout;
mod runnable_versions;
mod verification;
pub(crate) use agent_db::*;
@@ -20,5 +22,7 @@ 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 runnable_versions::*;
pub(crate) use verification::*;
@@ -631,8 +631,17 @@ 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}"))?;
validate_runnable_game_versions(
&manifest.project_id,
&manifest.runnable_versions,
manifest.current_runnable_version_id.as_deref(),
)
.map_err(|error| format!("校验 {label} 可运行版本失败:{error}"))?;
Ok(manifest)
}
fn install_manifest_temp_with<F>(
@@ -709,6 +718,35 @@ pub(crate) fn write_manifest(
path: &Path,
manifest: &GameCreationAppManifest,
) -> Result<(), String> {
validate_game_iteration_versions(&manifest.versions)
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
validate_runnable_game_versions(
&manifest.project_id,
&manifest.runnable_versions,
manifest.current_runnable_version_id.as_deref(),
)
.map_err(|error| format!("校验 manifest 可运行版本失败:{error}"))?;
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());
}
if existing.runnable_versions.len() > manifest.runnable_versions.len()
|| existing
.runnable_versions
.iter()
.zip(&manifest.runnable_versions)
.any(|(existing, candidate)| existing != candidate)
{
return Err("可运行版本记录写入后不可修改、删除或重排".to_string());
}
}
let payload = serde_json::to_string_pretty(manifest)
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
if let Some(parent) = path.parent() {
@@ -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,59 @@ 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 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
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());
}
}
@@ -1606,5 +1606,12 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser
.expect("read barrier after rejecting reserved delivery")
.is_clear());
let released = wait_for_agent_runtime_lane_release_async(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
)
.await;
assert_eq!(released.state.run_id, parent_run_id);
fs::remove_dir_all(root).ok();
}

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