diff --git a/AGENTS.md b/AGENTS.md index 4ba512832..7b4ddcfdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ - 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。 - 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。 - 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。 +- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。 - 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”: 1. 不写历史兼容代码。 2. 不写用于维持退役行为的防御性兼容测试。 diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index 36c13586f..ca06149f2 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -736,7 +736,7 @@ function isAdminImageSequenceFrameUrlUsable( ): cached is AdminImageSequenceFrameCacheEntry { return Boolean( cached?.resolvedUrl && - (cached.expiresAtMs === null || cached.expiresAtMs > Date.now()), + (cached.expiresAtMs === null || cached.expiresAtMs > Date.now()), ); } diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index 8bf357203..ba8b9b3eb 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -26,8 +26,7 @@ const wrapperSuite = const configFileName = 'game-creator.config.json'; const configSentinelName = '.deterministic-provider-e2e.json'; const configSentinelSchema = 'genarrative-deterministic-provider-e2e-config.v1'; -const platformSessionFixtureEnv = - 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE'; +const platformSessionFixtureEnv = 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE'; const platformSessionFixtureName = '.deterministic-platform-session.json'; const platformSessionFixtureSchema = 'genarrative-agc-platform-session-fixture.v1'; diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs index 389fef24d..f283ecf8e 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs @@ -100,8 +100,7 @@ import { appendBounded, runProcess } from './process.mjs'; import { decodeUtf8Fatal, isIsolatedRunnerSuite } from './reporting.mjs'; import { killRunnerOnce, readRunnerStatus, runnerBootId } from './runtime.mjs'; -const platformSessionFixtureEnv = - 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE'; +const platformSessionFixtureEnv = 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE'; const platformSessionFixtureMaxBytes = 16 * 1024; const isolatedPlatformSessionFixtureName = '.deterministic-platform-session.json'; @@ -538,7 +537,9 @@ async function readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir) { ); let fixture; try { - fixture = JSON.parse(decodeUtf8Fatal(bytes, 'platform-session-fixture-invalid-utf8')); + fixture = JSON.parse( + decodeUtf8Fatal(bytes, 'platform-session-fixture-invalid-utf8'), + ); } catch (error) { throw codedError( 'supervisor-autonomous-playable-platform-session-fixture-invalid', @@ -577,8 +578,12 @@ async function installPlatformSessionFixtureIntoIsolatedAppData( appDataDir, ) { if (!isSupervisorAutonomousPlayableLaneDefenseSuite()) return; - const source = await readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir); - const isolatedPath = path.join(appDataDir, isolatedPlatformSessionFixtureName); + const source = + await readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir); + const isolatedPath = path.join( + appDataDir, + isolatedPlatformSessionFixtureName, + ); await fs.copyFile( source.sourcePath, isolatedPath, @@ -609,9 +614,7 @@ async function installPlatformSessionFixtureIntoIsolatedAppData( absolutePathVariants(source.sourcePath, isolatedPath), ); const previousLeakCount = state.transcriptScanner?.count ?? 0; - state.secrets = [ - ...new Set([...state.secrets, source.fixture.accessToken]), - ]; + state.secrets = [...new Set([...state.secrets, source.fixture.accessToken])]; rebuildSupervisorSwarmTranscriptScanner(); state.transcriptScanner.count = previousLeakCount; } diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index 8f81447ac..fed45aa02 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -990,7 +990,10 @@ export function createDeterministicLaneDefenseRouter({ } function recordReadyTaskRun(agentId, runId) { - if (!relaxedAutonomous && !deterministicManifestReadyAgentIds.includes(agentId)) { + if ( + !relaxedAutonomous && + !deterministicManifestReadyAgentIds.includes(agentId) + ) { throw providerError(`provider-ready-agent-unsupported:${agentId}`); } const existingRunId = readyTaskRunIdsByAgent.get(agentId); @@ -1405,14 +1408,10 @@ export function createDeterministicLaneDefenseRouter({ ]); } if (tools.has(runtimeFunction('task.list'))) { - const calls = [ - nativeAction('task.list', '查看并行任务当前状态', {}), - ]; + const calls = [nativeAction('task.list', '查看并行任务当前状态', {})]; if (tools.has(runtimeFunction('agent.run_status'))) { stats.runStatusCount += 1; - calls.push( - runStatusCall('读取并行任务的最新运行状态'), - ); + calls.push(runStatusCall('读取并行任务的最新运行状态')); } return callsResponse('project-supervisor', tools, calls); } @@ -1751,7 +1750,11 @@ export function createDeterministicLaneDefenseRouter({ stats.sourceWriteCount += 1; stats.manifestReadyTaskFileWriteCount += 1; return readyCallsResponse(agentId, runId, tools, [ - fileWriteCall(path, content, `重试写入 ${path} 并交给 Runtime 收束门验证`), + fileWriteCall( + path, + content, + `重试写入 ${path} 并交给 Runtime 收束门验证`, + ), ]); } // Runtime validates fixed owner artifacts after the owner responds. Once @@ -1769,12 +1772,7 @@ export function createDeterministicLaneDefenseRouter({ if (calls.some((call) => call.name === 'respond_to_user')) { recordReadyTaskCompletion(agentId, runId); } - return readyCallsResponse( - agentId, - runId, - tools, - calls, - ); + return readyCallsResponse(agentId, runId, tools, calls); } const recovery = runData.get(runId)?.writerRecovery ?? null; const observations = observationContext(context); @@ -2195,13 +2193,18 @@ export function createDeterministicLaneDefenseRouter({ if (finalReplyRuns.has(key)) { throw providerError('provider-duplicate-final-reply-request'); } - if (!relaxedAutonomous && + if ( + !relaxedAutonomous && !context.includes('给用户一个正常中文回复') && !context.includes('给开发者一个正常中文回复') ) { throw providerError('provider-unexpected-text-request'); } - if (!relaxedAutonomous && identity.agentId === 'project-supervisor' && parentStage !== 'done') { + if ( + !relaxedAutonomous && + identity.agentId === 'project-supervisor' && + parentStage !== 'done' + ) { throw providerError('provider-parent-final-reply-before-acceptance'); } finalReplyRuns.add(key); @@ -2586,8 +2589,7 @@ function createDeterministicCanvasFixture(apiKey) { } if ( request.method === 'POST' && - canonicalPath === - '/api/external/v1/editor/icon-spritesheets/generations' + canonicalPath === '/api/external/v1/editor/icon-spritesheets/generations' ) { const idempotencyKey = request.headers['idempotency-key']; if ( @@ -2906,7 +2908,9 @@ function createDeterministicCanvasFixture(apiKey) { if ( request.method === 'POST' && - canonicalPath?.match(/^\/api\/external\/v1\/editor\/projects\/[^/]+\/resources$/) + canonicalPath?.match( + /^\/api\/external\/v1\/editor\/projects\/[^/]+\/resources$/, + ) ) { const body = await readJsonBody(request); const objectKey = @@ -2959,7 +2963,11 @@ export async function startDeterministicLaneDefenseProvider({ relaxed = false, fallbackPorts = DEFAULT_FALLBACK_PORTS, } = {}) { - const router = createDeterministicLaneDefenseRouter({ apiKey, model, relaxed }); + const router = createDeterministicLaneDefenseRouter({ + apiKey, + model, + relaxed, + }); const canvasFixture = createDeterministicCanvasFixture(apiKey); const sockets = new Set(); let stopped = false; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 78d3e3ee0..bc2bedb87 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; mod codex_app_server; mod codex_cli; mod codex_provider_proxy; +mod direct_codex_attachments; +mod direct_codex_audit; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -34,6 +36,8 @@ pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; pub(crate) use codex_provider_proxy::*; +pub(crate) use direct_codex_attachments::*; +pub(crate) use direct_codex_audit::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 620379eac..304f6af6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1950,8 +1950,15 @@ impl CodexAppServerConnection { request: LlmRunRequest, on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, ) -> Result { - self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None) - .await + self.run_turn_with_direct_observer( + snapshot, + llm, + request, + on_agent_message_delta, + None, + None, + ) + .await } async fn run_turn_with_direct_observer( @@ -1961,6 +1968,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + mut audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let thread_lease = self.thread_for(snapshot, &request, llm).await?; @@ -2130,6 +2138,11 @@ impl CodexAppServerConnection { completed, ¶ms, ); + if completed { + if let Some(audit) = audit.as_mut() { + audit.observe_item(¶ms); + } + } } if item_type == "agentMessage" { if let Some(text) = item @@ -2841,8 +2854,14 @@ pub(crate) async fn direct_game_creator_codex_chat_at( system_prompt: String, user_prompt: String, ) -> Result { - direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None) - .await + direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt, + user_prompt, + None, + None, + ) + .await } pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( @@ -2856,6 +2875,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( system_prompt, user_prompt, Some(observer), + None, ) .await } @@ -2906,11 +2926,12 @@ fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) format!("{:x}", digest.finalize()) } -async fn direct_game_creator_codex_chat_at_with_optional_observer( +pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between @@ -2962,7 +2983,7 @@ async fn direct_game_creator_codex_chat_at_with_optional_observer( .await .map_err(|error| error.to_string())?; connection - .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer) + .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit) .await .map(|value| value.text) .map_err(|error| error.to_string()) @@ -4290,6 +4311,7 @@ while IFS= read -r line; do :; done tool_request(), Some(&mut on_delta), Some(&mut observer), + None, ) .await .expect("run fake app-server turn"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs new file mode 100644 index 000000000..8cf49e017 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs @@ -0,0 +1,431 @@ +//! Direct Codex 本轮附件 sidecar:Home 与 Project 共用同一 DTO 和渲染函数。 +//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。 + +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8; +const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160; +const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; +const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512; + +const HOME_ATTACHMENT_HEADER: &str = + "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]"; +const PROJECT_ATTACHMENT_HEADER: &str = + "[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]"; + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectCodexTurnAttachment { + pub(crate) name: String, + pub(crate) media_type: String, + #[serde(default)] + pub(crate) size: u64, + #[serde(default)] + pub(crate) local_path: Option, + #[serde(default)] + pub(crate) status: Option, +} + +pub(crate) fn sanitize_attachment_name(value: &str) -> String { + let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim(); + let sanitized = basename + .chars() + .filter(|character| !character.is_control()) + .take(MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS) + .collect::(); + if sanitized.is_empty() { + "未命名附件".to_string() + } else { + sanitized + } +} + +pub(crate) fn sanitize_attachment_media_type(value: &str) -> String { + let value = value.trim(); + if value.is_empty() + || value.chars().any(|character| { + !(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_')) + }) + { + "application/octet-stream".to_string() + } else { + value + .chars() + .take(MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS) + .collect() + } +} + +pub(crate) fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> { + match value.map(str::trim) { + Some("imported") => Some("imported"), + Some("failed") => Some("failed"), + _ => None, + } +} + +pub(crate) fn sanitize_attachment_local_path(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS + || trimmed.chars().any(char::is_control) + { + return None; + } + + let normalized = trimmed.replace('\\', "/"); + if normalized.starts_with('/') { + return None; + } + + let mut chars = normalized.chars(); + if let (Some(letter), Some(':')) = (chars.next(), chars.next()) { + if letter.is_ascii_alphabetic() { + return None; + } + } + + let mut segments = Vec::new(); + for segment in normalized.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return None; + } + segments.push(segment); + } + let first = segments.first()?; + if *first == ".agent" || *first == ".git" { + return None; + } + let path = segments.join("/"); + if path.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS { + return None; + } + Some(path) +} + +pub(crate) fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool { + attachments.iter().any(|attachment| { + attachment + .local_path + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || sanitize_attachment_status(attachment.status.as_deref()).is_some() + }) +} + +fn render_project_attachment_line(attachment: &DirectCodexTurnAttachment) -> String { + let name = sanitize_attachment_name(&attachment.name); + let media_type = sanitize_attachment_media_type(&attachment.media_type); + let raw_path = attachment + .local_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let sanitized_path = raw_path.and_then(sanitize_attachment_local_path); + let path_rejected = raw_path.is_some() && sanitized_path.is_none(); + let status = if path_rejected { + Some("failed") + } else { + sanitize_attachment_status(attachment.status.as_deref()) + }; + + let mut parts = vec![format!("原文件名:{name}")]; + if let Some(path) = sanitized_path { + parts.push(format!("项目路径:{path}")); + } + parts.push(format!("类型:{media_type}")); + parts.push(format!("大小:{} 字节", attachment.size)); + if let Some(status) = status { + parts.push(format!("状态:{status}")); + } + format!("- {}", parts.join(";")) +} + +pub(crate) fn render_direct_codex_user_prompt( + prompt: &str, + attachments: &[DirectCodexTurnAttachment], +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() && attachments.is_empty() { + return Err("聊天内容不能为空".to_string()); + } + if attachments.is_empty() { + return Ok(prompt.to_string()); + } + + let mut sections = Vec::new(); + if !prompt.is_empty() { + sections.push(prompt.to_string()); + sections.push(String::new()); + } + if attachments_use_project_mapping(attachments) { + sections.push(PROJECT_ATTACHMENT_HEADER.to_string()); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + sections.push(render_project_attachment_line(attachment)); + } + } else { + sections.push(HOME_ATTACHMENT_HEADER.to_string()); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + sections.push(format!( + "- {};类型:{};大小:{} 字节", + sanitize_attachment_name(&attachment.name), + sanitize_attachment_media_type(&attachment.media_type), + attachment.size, + )); + } + } + if attachments.len() > MAX_DIRECT_CODEX_ATTACHMENTS { + sections.push(format!( + "- 另有 {} 个附件未展开", + attachments.len() - MAX_DIRECT_CODEX_ATTACHMENTS + )); + } + Ok(sections.join("\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn home_attachment(name: &str, media_type: &str, size: u64) -> DirectCodexTurnAttachment { + DirectCodexTurnAttachment { + name: name.to_string(), + media_type: media_type.to_string(), + size, + local_path: None, + status: None, + } + } + + fn project_attachment( + name: &str, + media_type: &str, + size: u64, + local_path: Option<&str>, + status: Option<&str>, + ) -> DirectCodexTurnAttachment { + DirectCodexTurnAttachment { + name: name.to_string(), + media_type: media_type.to_string(), + size, + local_path: local_path.map(str::to_string), + status: status.map(str::to_string), + } + } + + #[test] + fn plain_prompt_is_trimmed_and_empty_prompt_without_attachments_is_rejected() { + assert_eq!( + render_direct_codex_user_prompt(" 你好 ", &[]).expect("plain prompt"), + "你好" + ); + assert_eq!( + render_direct_codex_user_prompt("", &[]).expect_err("empty prompt"), + "聊天内容不能为空" + ); + } + + #[test] + fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() { + let attachments = vec![home_attachment( + r"C:\Users\secret\角色参考.png", + "image/png\nBearer secret", + 3, + )]; + + let prompt = render_direct_codex_user_prompt(" 先看看这个附件 ", &attachments) + .expect("home prompt"); + + assert_eq!( + prompt, + "先看看这个附件\n\n[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]\n- 角色参考.png;类型:application/octet-stream;大小:3 字节" + ); + assert!(!prompt.contains("C:\\Users")); + assert!(!prompt.contains("\nBearer secret")); + } + + #[test] + fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() { + assert_eq!( + render_direct_codex_user_prompt("你好", &[]).expect("plain prompt"), + "你好" + ); + let attachments = (0..MAX_DIRECT_CODEX_ATTACHMENTS + 2) + .map(|index| home_attachment(&format!("asset-{index}.png"), "image/png", index as u64)) + .collect::>(); + let prompt = + render_direct_codex_user_prompt("看看素材", &attachments).expect("bounded attachments"); + assert!(prompt.contains("asset-7.png")); + assert!(!prompt.contains("asset-8.png")); + assert!(prompt.contains("另有 2 个附件未展开")); + assert!(render_direct_codex_user_prompt("", &attachments).is_ok()); + assert!(render_direct_codex_user_prompt("", &[]).is_err()); + } + + #[test] + fn home_json_without_path_or_status_still_deserializes() { + let attachment: DirectCodexTurnAttachment = + serde_json::from_str(r#"{"name":"a.png","mediaType":"image/png","size":3}"#) + .expect("home json"); + assert!(attachment.local_path.is_none()); + assert!(attachment.status.is_none()); + assert_eq!(attachment.size, 3); + } + + #[test] + fn project_prompt_keeps_user_text_and_maps_original_name_to_project_path() { + let attachments = vec![project_attachment( + "fast_gdd.md", + "text/markdown", + 7944, + Some("assets/uploads/upload-1788083777445-fast_gdd.md"), + Some("imported"), + )]; + let prompt = render_direct_codex_user_prompt("请根据附件做游戏", &attachments) + .expect("project prompt"); + + assert_eq!( + prompt, + "请根据附件做游戏\n\n[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]\n- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported" + ); + assert!(!prompt.contains("GDD")); + assert!(!prompt.contains("规格")); + assert!(!prompt.contains("权威")); + assert!(!prompt.contains("必须读取")); + } + + #[test] + fn project_png_and_markdown_share_the_same_line_shape() { + let attachments = vec![ + project_attachment( + "角色参考.png", + "image/png", + 12, + Some("assets/uploads/upload-1-角色参考.png"), + Some("imported"), + ), + project_attachment( + "notes.md", + "text/markdown", + 80, + Some("assets/uploads/upload-2-notes.md"), + Some("imported"), + ), + ]; + let prompt = + render_direct_codex_user_prompt("看这两个附件", &attachments).expect("mixed types"); + let lines: Vec<_> = prompt + .lines() + .filter(|line| line.starts_with("- 原文件名:")) + .collect(); + assert_eq!(lines.len(), 2); + for line in &lines { + assert!(line.contains(";项目路径:assets/uploads/")); + assert!(line.contains(";类型:")); + assert!(line.contains(";大小:")); + assert!(line.contains(";状态:imported")); + } + assert!(lines[0].contains("角色参考.png")); + assert!(lines[0].contains("image/png")); + assert!(lines[1].contains("notes.md")); + assert!(lines[1].contains("text/markdown")); + } + + #[test] + fn failed_attachment_without_path_has_status_and_no_error_body() { + let attachments = vec![project_attachment( + "lost.bin", + "application/octet-stream", + 2, + None, + Some("failed"), + )]; + let prompt = + render_direct_codex_user_prompt("附件失败了", &attachments).expect("failed prompt"); + assert!(prompt.contains(PROJECT_ATTACHMENT_HEADER)); + assert!(prompt.contains("原文件名:lost.bin")); + assert!(prompt.contains("状态:failed")); + assert!(!prompt.contains("项目路径:")); + assert!(!prompt.contains("error")); + assert!(!prompt.contains("失败原因")); + } + + #[test] + fn illegal_local_paths_are_omitted_and_marked_failed() { + let attachments = vec![ + project_attachment( + "up.md", + "text/markdown", + 1, + Some("../secret.md"), + Some("imported"), + ), + project_attachment( + "agent.md", + "text/markdown", + 1, + Some(".agent/conversations/x.md"), + Some("imported"), + ), + project_attachment( + "abs.md", + "text/markdown", + 1, + Some(r"C:\tmp\abs.md"), + Some("imported"), + ), + project_attachment( + "unix.md", + "text/markdown", + 1, + Some("/tmp/unix.md"), + Some("imported"), + ), + ]; + let prompt = + render_direct_codex_user_prompt("非法路径", &attachments).expect("illegal paths"); + assert!(!prompt.contains("../secret.md")); + assert!(!prompt.contains(".agent/conversations/x.md")); + assert!(!prompt.contains("C:\\tmp\\abs.md")); + assert!(!prompt.contains("/tmp/unix.md")); + assert!(!prompt.contains("项目路径:")); + assert_eq!(prompt.matches("状态:failed").count(), 4); + assert!(!prompt.contains("状态:imported")); + } + + #[test] + fn empty_prompt_with_project_attachments_still_renders() { + let attachments = vec![project_attachment( + "ref.png", + "image/png", + 4, + Some("assets/uploads/upload-1-ref.png"), + Some("imported"), + )]; + let prompt = render_direct_codex_user_prompt(" ", &attachments).expect("empty user text"); + assert!(prompt.starts_with(PROJECT_ATTACHMENT_HEADER)); + assert!(prompt.contains("项目路径:assets/uploads/upload-1-ref.png")); + } + + #[test] + fn unknown_error_field_is_not_forwarded_to_the_model() { + let attachment: DirectCodexTurnAttachment = serde_json::from_str( + r#"{"name":"a.md","mediaType":"text/markdown","size":1,"status":"failed","error":"secret boom"}"#, + ) + .expect("extra error field"); + let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render"); + assert!(!prompt.contains("secret boom")); + assert!(!prompt.contains("error")); + } + + #[test] + fn unknown_status_keeps_home_attachment_metadata_shape() { + let attachment = + project_attachment("pending.md", "text/markdown", 1, None, Some("pending")); + let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render"); + assert!(prompt.contains(HOME_ATTACHMENT_HEADER)); + assert!(!prompt.contains(PROJECT_ATTACHMENT_HEADER)); + assert!(!prompt.contains("状态:")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs new file mode 100644 index 000000000..bc34a6b4c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs @@ -0,0 +1,1458 @@ +//! Direct Codex GUI 回合行为账本:把 item/completed 抽成项目内有界时间线。 +//! 不灌附件正文、不落 stdout / patch / MCP result,不进入前端观察者。 + +use super::*; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DIRECT_CODEX_AUDIT_HASH_MAX_BYTES: u64 = 2 * 1024 * 1024; +const DIRECT_CODEX_AUDIT_MAX_ITEMS: usize = 256; +const DIRECT_CODEX_AUDIT_COMMAND_CHARS: usize = 240; +const DIRECT_CODEX_AUDIT_BRIEF_CHARS: usize = 4000; +const DIRECT_CODEX_AUDIT_PREVIEW_CHARS: usize = 240; +const DIRECT_CODEX_AUDIT_QUERY_CHARS: usize = 400; +const DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS: usize = 120; +const DIRECT_CODEX_AUDIT_ID_LIST_MAX: usize = 8; +const DIRECT_CODEX_AUDIT_TURN_LOG_DIR: &str = ".agent/runtime/direct-codex/turns"; + +const SKIPPED_ITEM_TYPES: &[&str] = &[ + "agentMessage", + "userMessage", + "plan", + "reasoning", + "contextCompaction", + "hookPrompt", +]; + +const DESIGN_MCP_TOOLS: &[&str] = &[ + "taonier_prepare_game_art", + "agc_generate_image", + "agc_edit_image", + "agc_create_or_derive_resource", +]; + +struct OfferedAttachment { + local_path: String, + content_sha256: Option, + read: bool, + content_sha256_match: Option, +} + +pub(crate) struct DirectCodexTurnAudit { + root: PathBuf, + client_turn_id: String, + log_path: PathBuf, + turn_log_relative: String, + sidecar_present: bool, + offered: Vec, + item_count: usize, + items_truncated: bool, + truncated_written: bool, + first_design: Option, + audit_write_failed: bool, + finished: bool, +} + +impl DirectCodexTurnAudit { + pub(crate) fn start( + root: &Path, + client_turn_id: &str, + original_prompt: &str, + attachments: &[DirectCodexTurnAttachment], + ) -> Self { + let turn_log_relative = format!("{DIRECT_CODEX_AUDIT_TURN_LOG_DIR}/{client_turn_id}.jsonl"); + let log_path = root.join(&turn_log_relative); + let sidecar_present = attachments_use_project_mapping(attachments); + let (attachment_values, offered) = project_audit_attachments(root, attachments); + let mut audit = Self { + root: root.to_path_buf(), + client_turn_id: client_turn_id.to_string(), + log_path, + turn_log_relative, + sidecar_present, + offered, + item_count: 0, + items_truncated: false, + truncated_written: false, + first_design: None, + audit_write_failed: false, + finished: false, + }; + let omitted = attachments + .len() + .saturating_sub(MAX_DIRECT_CODEX_ATTACHMENTS); + let mut record = json!({ + "recordType": "direct.codex.turn_start", + "clientTurnId": client_turn_id, + "sidecarPresent": sidecar_present, + "promptSha256": sha256_hex(original_prompt.as_bytes()), + "promptChars": original_prompt.chars().count(), + "attachments": attachment_values, + }); + if omitted > 0 { + record["attachmentsOmitted"] = json!(omitted); + } + audit.append_record(record); + audit + } + + pub(crate) fn observe_item(&mut self, params: &Value) { + if self.finished { + return; + } + let Some(item) = params.get("item") else { + return; + }; + let item_type = item + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if SKIPPED_ITEM_TYPES.contains(&item_type) { + return; + } + if self.item_count >= DIRECT_CODEX_AUDIT_MAX_ITEMS { + self.items_truncated = true; + if !self.truncated_written { + self.truncated_written = true; + self.append_record(json!({ + "recordType": "direct.codex.items_truncated", + "clientTurnId": self.client_turn_id, + "droppedAfter": DIRECT_CODEX_AUDIT_MAX_ITEMS, + })); + } + return; + } + + self.item_count = self.item_count.saturating_add(1); + let seq = self.item_count; + let mut record = json!({ + "recordType": "direct.codex.item", + "clientTurnId": self.client_turn_id, + "seq": seq, + "itemType": item_type, + }); + if let Some(item_id) = item + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + { + record["itemId"] = json!(item_id); + } + if let Some(status) = item.get("status").and_then(Value::as_str) { + record["status"] = json!(status); + } else { + record["status"] = json!("completed"); + } + + match item_type { + "commandExecution" => self.fill_command_execution(&mut record, item), + "mcpToolCall" => self.fill_mcp_tool_call(&mut record, item, seq), + "fileChange" => self.fill_file_change(&mut record, item, seq), + "imageView" => self.fill_image_view(&mut record, item), + "functionCallOutput" => fill_function_call_output(&mut record, item), + "webSearch" => fill_web_search(&mut record, item), + _ => {} + } + + self.append_record(record); + } + + pub(crate) fn finish(&mut self, completed: bool) { + if self.finished { + return; + } + self.finished = true; + let offered_read = self.offered_read_values(); + let record = json!({ + "recordType": "direct.codex.turn_end", + "clientTurnId": self.client_turn_id, + "completed": completed, + "itemCount": self.item_count, + "itemsTruncated": self.items_truncated, + "offeredRead": offered_read, + "firstDesign": self.first_design.clone(), + }); + self.append_record(record); + let summary = json!({ + "recordType": "direct.codex.turn", + "clientTurnId": self.client_turn_id, + "turnLog": self.turn_log_relative, + "sidecarPresent": self.sidecar_present, + "offeredCount": self.offered.len(), + "offeredRead": offered_read, + "firstDesign": self.first_design.clone(), + "itemCount": self.item_count, + "itemsTruncated": self.items_truncated, + "completed": completed, + "auditWriteFailed": self.audit_write_failed, + }); + if append_agent_db_record(&self.root, summary).is_err() { + self.audit_write_failed = true; + } + } + + fn fill_command_execution(&mut self, record: &mut Value, item: &Value) { + let mut path_rejected = false; + let mut actions = Vec::new(); + if let Some(raw_actions) = item.get("commandActions").and_then(Value::as_array) { + for action in raw_actions { + let action_type = action + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + match action_type { + "read" => { + let (entry, rejected) = self.read_action_entry(action); + path_rejected |= rejected; + actions.push(entry); + } + "listFiles" => { + let mut entry = json!({ "type": "listFiles" }); + match optional_action_path(&self.root, action) { + ActionPath::Missing => {} + ActionPath::Rejected => { + path_rejected = true; + entry["pathRejected"] = json!(true); + } + ActionPath::Ok(path) => entry["path"] = json!(path), + } + actions.push(entry); + } + "search" => { + let mut entry = json!({ "type": "search" }); + if let Some(query) = action.get("query").and_then(Value::as_str) { + entry["query"] = + json!(truncate_chars(query, DIRECT_CODEX_AUDIT_QUERY_CHARS)); + } + match optional_action_path(&self.root, action) { + ActionPath::Missing => {} + ActionPath::Rejected => { + path_rejected = true; + entry["pathRejected"] = json!(true); + } + ActionPath::Ok(path) => entry["path"] = json!(path), + } + actions.push(entry); + } + _ => actions.push(json!({ "type": "unknown" })), + } + } + } + record["actions"] = json!(actions); + if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { + record["exitCode"] = json!(exit_code); + } + if let Some(duration_ms) = item.get("durationMs").and_then(Value::as_i64) { + record["durationMs"] = json!(duration_ms); + } + let command = item.get("command").and_then(Value::as_str).unwrap_or(""); + if path_rejected || command_contains_host_absolute_path(command) { + record["commandRedacted"] = json!(true); + } else if !command.is_empty() { + record["command"] = json!(truncate_chars(command, DIRECT_CODEX_AUDIT_COMMAND_CHARS)); + } + } + + fn read_action_entry(&mut self, action: &Value) -> (Value, bool) { + let Some(raw_path) = action.get("path").and_then(Value::as_str) else { + return (json!({ "type": "read", "pathRejected": true }), true); + }; + match relativize_project_path(&self.root, raw_path) { + Some(path) => { + let (content_sha256, hash_skipped) = hash_project_file(&self.root, &path); + self.mark_offered_read(&path, content_sha256.as_deref()); + let mut entry = json!({ "type": "read", "path": path }); + insert_hash_fields(&mut entry, content_sha256, hash_skipped); + (entry, false) + } + None => (json!({ "type": "read", "pathRejected": true }), true), + } + } + + fn fill_mcp_tool_call(&mut self, record: &mut Value, item: &Value, seq: usize) { + let tool = item.get("tool").and_then(Value::as_str).unwrap_or(""); + record["tool"] = json!(tool); + if let Some(server) = item + .get("server") + .and_then(Value::as_str) + .filter(|server| !server.is_empty() && *server != "agc_tools") + { + record["server"] = json!(server); + } + if let Some(duration_ms) = item.get("durationMs").and_then(Value::as_i64) { + record["durationMs"] = json!(duration_ms); + } + if item.get("error").is_some() { + record["errorKind"] = json!(item + .pointer("/error/code") + .and_then(Value::as_str) + .or_else(|| item.pointer("/error/type").and_then(Value::as_str)) + .unwrap_or("error")); + } + let arguments = item.get("arguments").cloned().unwrap_or(Value::Null); + let extracted = extract_mcp_arguments(&self.root, tool, &arguments); + if let Some(path) = extracted + .get("path") + .and_then(Value::as_str) + .map(str::to_string) + { + self.mark_offered_read(&path, None); + } + if let Some(local_paths) = extracted.get("localPaths").and_then(Value::as_array) { + for path in local_paths { + if let Some(path) = path.as_str() { + self.mark_offered_read(path, None); + } + } + } + if extracted + .as_object() + .is_some_and(|object| !object.is_empty()) + { + record["arguments"] = extracted.clone(); + } + if self.first_design.is_none() { + if DESIGN_MCP_TOOLS.contains(&tool) { + let mut design = json!({ + "kind": format!("mcp:{tool}"), + "seq": seq, + "tool": tool, + }); + let preview = extracted + .get("brief") + .or_else(|| extracted.get("prompt")) + .and_then(Value::as_str) + .map(|text| truncate_chars(text, DIRECT_CODEX_AUDIT_PREVIEW_CHARS)); + if let Some(preview) = preview { + design["briefPreview"] = json!(preview); + } + self.first_design = Some(design); + } else if tool == "agc_write_file" { + if let Some(path) = extracted.get("path").and_then(Value::as_str) { + if is_design_write_path(path) { + self.first_design = Some(json!({ + "kind": format!("write:{path}"), + "seq": seq, + "path": path, + })); + } + } + } + } + } + + fn fill_file_change(&mut self, record: &mut Value, item: &Value, seq: usize) { + let mut changes = Vec::new(); + if let Some(raw_changes) = item.get("changes").and_then(Value::as_array) { + for change in raw_changes { + let kind = file_change_kind(change); + let mut entry = json!({ "kind": kind }); + match change.get("path").and_then(Value::as_str) { + Some(raw) => match relativize_project_path(&self.root, raw) { + Some(path) => { + if self.first_design.is_none() && is_design_write_path(&path) { + self.first_design = Some(json!({ + "kind": format!("fileChange:{path}"), + "seq": seq, + "path": path, + })); + } + entry["path"] = json!(path); + } + None => entry["pathRejected"] = json!(true), + }, + None => entry["pathRejected"] = json!(true), + } + changes.push(entry); + } + } + record["changes"] = json!(changes); + } + + fn fill_image_view(&mut self, record: &mut Value, item: &Value) { + match item.get("path").and_then(Value::as_str) { + Some(raw) => match relativize_project_path(&self.root, raw) { + Some(path) => { + let (content_sha256, hash_skipped) = hash_project_file(&self.root, &path); + self.mark_offered_read(&path, content_sha256.as_deref()); + record["path"] = json!(path); + insert_hash_fields(record, content_sha256, hash_skipped); + } + None => record["pathRejected"] = json!(true), + }, + None => record["pathRejected"] = json!(true), + } + } + + fn mark_offered_read(&mut self, path: &str, content_sha256: Option<&str>) { + for offered in &mut self.offered { + if offered.local_path != path { + continue; + } + offered.read = true; + match (offered.content_sha256.as_deref(), content_sha256) { + (Some(expected), Some(actual)) => { + let matches = expected == actual; + offered.content_sha256_match = + Some(offered.content_sha256_match.unwrap_or(true) && matches); + } + _ => {} + } + } + } + + fn offered_read_values(&self) -> Vec { + self.offered + .iter() + .map(|offered| { + let mut value = json!({ + "localPath": offered.local_path, + "read": offered.read, + }); + if let Some(matches) = offered.content_sha256_match { + value["contentSha256Match"] = json!(matches); + } + value + }) + .collect() + } + + fn append_record(&mut self, mut record: Value) { + #[cfg(test)] + if test_fail_audit_write(&self.root) { + self.audit_write_failed = true; + return; + } + if let Some(object) = record.as_object_mut() { + object.insert( + "recordedAtMs".to_string(), + json!(u64::try_from(unix_millis()).unwrap_or(u64::MAX)), + ); + } + let Ok(line) = serde_json::to_string(&record) else { + self.audit_write_failed = true; + return; + }; + if append_jsonl_line(&self.log_path, &line, "Direct 回合审计记录").is_err() { + self.audit_write_failed = true; + } + } +} + +impl Drop for DirectCodexTurnAudit { + fn drop(&mut self) { + if !self.finished { + self.finish(false); + } + } +} + +enum ActionPath { + Missing, + Rejected, + Ok(String), +} + +fn optional_action_path(root: &Path, action: &Value) -> ActionPath { + let Some(raw) = action.get("path").and_then(Value::as_str) else { + return ActionPath::Missing; + }; + if raw.trim().is_empty() { + return ActionPath::Missing; + } + match relativize_project_path(root, raw) { + Some(path) => ActionPath::Ok(path), + None => ActionPath::Rejected, + } +} + +fn project_audit_attachments( + root: &Path, + attachments: &[DirectCodexTurnAttachment], +) -> (Vec, Vec) { + let mut values = Vec::new(); + let mut offered = Vec::new(); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + let name = sanitize_attachment_name(&attachment.name); + let media_type = sanitize_attachment_media_type(&attachment.media_type); + let raw_path = attachment + .local_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let sanitized_path = raw_path.and_then(sanitize_attachment_local_path); + let path_rejected = raw_path.is_some() && sanitized_path.is_none(); + let status = if path_rejected { + Some("failed") + } else { + sanitize_attachment_status(attachment.status.as_deref()) + }; + let mut value = json!({ + "name": name, + "mediaType": media_type, + "size": attachment.size, + }); + if let Some(path) = sanitized_path { + value["localPath"] = json!(path.clone()); + let (content_sha256, hash_skipped) = hash_project_file(root, &path); + insert_hash_fields(&mut value, content_sha256.clone(), hash_skipped); + offered.push(OfferedAttachment { + local_path: path, + content_sha256, + read: false, + content_sha256_match: None, + }); + } + if let Some(status) = status { + value["status"] = json!(status); + } + values.push(value); + } + (values, offered) +} + +fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value { + let Some(object) = arguments.as_object() else { + return json!({}); + }; + let mut out = Map::new(); + match tool { + "agc_list_project_files" => { + copy_sanitized_path(root, object, "path", &mut out); + copy_truncated_string( + object, + "query", + DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS, + &mut out, + ); + copy_string(object, "kind", &mut out); + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_write_file" => { + copy_sanitized_path(root, object, "path", &mut out); + if let Some(content) = object.get("content").and_then(Value::as_str) { + out.insert("contentChars".to_string(), json!(content.chars().count())); + } + } + "taonier_prepare_game_art" => { + copy_string(object, "mode", &mut out); + copy_text_with_hash( + object, + "brief", + "brief", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_generate_image" => { + copy_string(object, "kind", &mut out); + copy_string(object, "aspectRatio", &mut out); + copy_string(object, "imageSize", &mut out); + copy_string(object, "assetName", &mut out); + copy_sanitized_path(root, object, "outputPath", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_edit_image" => { + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_create_or_derive_resource" => { + copy_string(object, "kind", &mut out); + copy_string(object, "mode", &mut out); + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_list_registered_assets" => { + copy_string(object, "kind", &mut out); + copy_string(object, "assetId", &mut out); + if let Some(flag) = object.get("includeSequenceFrames").and_then(Value::as_bool) { + out.insert("includeSequenceFrames".to_string(), json!(flag)); + } + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_list_account_assets" => { + copy_string(object, "folderId", &mut out); + copy_truncated_string( + object, + "query", + DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS, + &mut out, + ); + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_import_account_assets" => { + if let Some(ids) = object.get("assetIds").and_then(Value::as_array) { + let kept: Vec = ids + .iter() + .filter_map(Value::as_str) + .take(DIRECT_CODEX_AUDIT_ID_LIST_MAX) + .map(Value::from) + .collect(); + let omitted = ids.len().saturating_sub(kept.len()); + out.insert("assetIds".to_string(), json!(kept)); + if omitted > 0 { + out.insert("assetIdsOmitted".to_string(), json!(omitted)); + } + } + if let Some(paths) = object.get("localPaths").and_then(Value::as_array) { + let kept: Vec = paths + .iter() + .filter_map(Value::as_str) + .filter_map(|path| relativize_project_path(root, path)) + .take(DIRECT_CODEX_AUDIT_ID_LIST_MAX) + .map(Value::from) + .collect(); + out.insert("localPaths".to_string(), json!(kept)); + } + } + "agc_remove_background" => { + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + } + "agc_browser_playtest" => copy_number(object, "attempt", &mut out), + "agc_web_search" => { + copy_truncated_string(object, "query", DIRECT_CODEX_AUDIT_QUERY_CHARS, &mut out); + copy_number(object, "maxResults", &mut out); + } + "agc_read_skill_resource" => { + copy_string(object, "skillName", &mut out); + copy_string(object, "relativePath", &mut out); + } + _ => {} + } + Value::Object(out) +} + +fn fill_function_call_output(record: &mut Value, item: &Value) { + if let Some(name) = item.get("name").and_then(Value::as_str) { + record["name"] = json!(name); + } + if let Some(namespace) = item.get("namespace").and_then(Value::as_str) { + record["namespace"] = json!(namespace); + } +} + +fn fill_web_search(record: &mut Value, item: &Value) { + if let Some(query) = item.get("query").and_then(Value::as_str) { + record["query"] = json!(truncate_chars(query, DIRECT_CODEX_AUDIT_QUERY_CHARS)); + } +} + +fn copy_string(source: &Map, key: &str, out: &mut Map) { + if let Some(value) = source + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + out.insert(key.to_string(), json!(value)); + } +} + +fn copy_truncated_string( + source: &Map, + key: &str, + max_chars: usize, + out: &mut Map, +) { + if let Some(value) = source.get(key).and_then(Value::as_str) { + out.insert(key.to_string(), json!(truncate_chars(value, max_chars))); + } +} + +fn copy_number(source: &Map, key: &str, out: &mut Map) { + if let Some(value) = source.get(key).and_then(Value::as_i64) { + out.insert(key.to_string(), json!(value)); + } +} + +fn copy_sanitized_path( + root: &Path, + source: &Map, + key: &str, + out: &mut Map, +) { + let Some(raw) = source.get(key).and_then(Value::as_str) else { + return; + }; + match relativize_project_path(root, raw) { + Some(path) => { + out.insert(key.to_string(), json!(path)); + } + None => { + out.insert(format!("{key}Rejected"), json!(true)); + } + } +} + +fn copy_text_with_hash( + source: &Map, + source_key: &str, + dest_key: &str, + max_chars: usize, + out: &mut Map, +) { + let Some(text) = source.get(source_key).and_then(Value::as_str) else { + return; + }; + out.insert(format!("{dest_key}Chars"), json!(text.chars().count())); + out.insert( + format!("{dest_key}Sha256"), + json!(sha256_hex(text.as_bytes())), + ); + out.insert(dest_key.to_string(), json!(truncate_chars(text, max_chars))); +} + +fn file_change_kind(change: &Value) -> &'static str { + let kind = change.get("kind"); + let label = kind + .and_then(Value::as_str) + .or_else(|| { + kind.and_then(|value| value.get("type")) + .and_then(Value::as_str) + }) + .unwrap_or("update"); + match label { + "add" => "add", + "delete" => "delete", + _ => "update", + } +} + +fn is_design_write_path(path: &str) -> bool { + path == "index.html" + || path.starts_with("game/") + || path.rsplit('/').next() == Some("index.html") +} + +fn insert_hash_fields( + target: &mut Value, + content_sha256: Option, + hash_skipped: Option<&str>, +) { + if let Some(content_sha256) = content_sha256 { + target["contentSha256"] = json!(content_sha256); + } + if let Some(hash_skipped) = hash_skipped { + target["hashSkipped"] = json!(hash_skipped); + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + value.chars().take(max_chars).collect() +} + +fn hash_project_file(root: &Path, relative: &str) -> (Option, Option<&'static str>) { + if reject_agent_runtime_private_control_path(relative).is_err() + || reject_sensitive_project_file_read(relative).is_err() + { + return (None, Some("missing")); + } + let path = match resolve_local_project_path(root, relative) { + Ok(path) => path, + Err(_) => return (None, Some("missing")), + }; + let metadata = match fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => metadata, + _ => return (None, Some("missing")), + }; + if metadata.len() > DIRECT_CODEX_AUDIT_HASH_MAX_BYTES { + return (None, Some("too-large")); + } + match fs::read(&path) { + Ok(bytes) => (Some(sha256_hex(&bytes)), None), + Err(_) => (None, Some("missing")), + } +} + +fn posix_path_text(path: &Path) -> String { + let text = path.to_string_lossy(); + let text = text + .strip_prefix(r"\\?\") + .or_else(|| text.strip_prefix("//?/")) + .unwrap_or(&text); + text.replace('\\', "/").trim_end_matches('/').to_string() +} + +fn relativize_project_path(root: &Path, raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + if let Some(relative) = sanitize_attachment_local_path(trimmed) { + return accept_relative_path(&relative); + } + if let Some(relative) = strip_absolute_root_prefix(root, trimmed) { + return sanitize_attachment_local_path(&relative) + .and_then(|path| accept_relative_path(&path)); + } + None +} + +fn strip_absolute_root_prefix(root: &Path, raw: &str) -> Option { + if let (Ok(root_canon), Ok(raw_canon)) = (root.canonicalize(), Path::new(raw).canonicalize()) { + if let Ok(stripped) = raw_canon.strip_prefix(&root_canon) { + let relative = posix_path_text(stripped); + if !relative.is_empty() { + return Some(relative); + } + } + } + let root_text = posix_path_text(root); + let raw_text = posix_path_text(Path::new(raw)); + let rest = if cfg!(windows) { + let root_lower = root_text.to_ascii_lowercase(); + let raw_lower = raw_text.to_ascii_lowercase(); + let suffix = raw_lower.strip_prefix(&root_lower)?; + raw_text + .get(raw_text.len().saturating_sub(suffix.len())..) + .unwrap_or(suffix) + .to_string() + } else { + raw_text.strip_prefix(&root_text)?.to_string() + }; + let rest = rest.trim_start_matches('/').to_string(); + (!rest.is_empty()).then_some(rest) +} + +fn accept_relative_path(relative: &str) -> Option { + if reject_agent_runtime_private_control_path(relative).is_err() + || reject_sensitive_project_file_read(relative).is_err() + { + return None; + } + Some(relative.to_string()) +} + +fn command_contains_host_absolute_path(command: &str) -> bool { + if command.contains("\\\\") || command.contains("/Users/") || command.contains("/home/") { + return true; + } + let bytes = command.as_bytes(); + let mut index = 0; + while index + 2 < bytes.len() { + if bytes[index].is_ascii_alphabetic() + && bytes[index + 1] == b':' + && matches!(bytes[index + 2], b'\\' | b'/') + { + return true; + } + index += 1; + } + false +} + +#[cfg(test)] +fn test_fail_audit_write(root: &Path) -> bool { + root.join(".agent/runtime/test-fail-direct-codex-audit") + .is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_project(name: &str) -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temp project"); + init_local_game_project_at(directory.path(), name, "审计测试项目").expect("init project"); + directory + } + + fn attachment_json( + name: &str, + media_type: &str, + size: u64, + local_path: Option<&str>, + status: Option<&str>, + ) -> DirectCodexTurnAttachment { + let mut value = json!({ + "name": name, + "mediaType": media_type, + "size": size, + }); + if let Some(local_path) = local_path { + value["localPath"] = json!(local_path); + } + if let Some(status) = status { + value["status"] = json!(status); + } + serde_json::from_value(value).expect("attachment") + } + + fn read_turn_log(root: &Path, client_turn_id: &str) -> Vec { + let path = root + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join(format!("{client_turn_id}.jsonl")); + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("audit jsonl")) + .collect() + } + + fn read_agent_db(root: &Path) -> Vec { + fs::read_to_string(root.join(".agent/agent.db")) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect() + } + + fn start_audit( + root: &Path, + prompt: &str, + attachments: &[DirectCodexTurnAttachment], + ) -> DirectCodexTurnAudit { + DirectCodexTurnAudit::start(root, "turn-01", prompt, attachments) + } + + #[test] + fn turn_start_hashes_original_prompt_and_sanitizes_attachment_paths() { + let project = fixture_project("audit-start"); + let root = project.path(); + let upload = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads dir"); + fs::write(root.join(upload), "脉冲余烬").expect("write gdd"); + let attachments = vec![ + attachment_json( + "fast_gdd.md", + "text/markdown", + 12, + Some(upload), + Some("imported"), + ), + attachment_json( + "secret.md", + "text/markdown", + 1, + Some("../secret.md"), + Some("imported"), + ), + ]; + let mut audit = start_audit(root, "请根据附件做游戏", &attachments); + audit.finish(true); + let records = read_turn_log(root, "turn-01"); + let start = records + .iter() + .find(|record| record["recordType"] == "direct.codex.turn_start") + .expect("turn_start"); + assert_eq!( + start["promptSha256"], + json!(sha256_hex("请根据附件做游戏".as_bytes())) + ); + assert_eq!(start["sidecarPresent"], json!(true)); + let listed = start["attachments"].as_array().expect("attachments"); + assert_eq!(listed[0]["localPath"], json!(upload)); + assert_eq!( + listed[0]["contentSha256"], + json!(sha256_hex("脉冲余烬".as_bytes())) + ); + assert!(listed[1].get("localPath").is_none()); + assert_eq!(listed[1]["status"], json!("failed")); + assert!(!serde_json::to_string(start).expect("json").contains("..")); + } + + #[test] + fn turn_start_without_attachments_sets_sidecar_absent() { + let project = fixture_project("audit-empty"); + let mut audit = start_audit(project.path(), "继续改游戏", &[]); + audit.finish(true); + let start = &read_turn_log(project.path(), "turn-01")[0]; + assert_eq!(start["sidecarPresent"], json!(false)); + assert_eq!(start["attachments"], json!([])); + } + + #[test] + fn attachment_hash_skips_missing_and_too_large_files() { + let project = fixture_project("audit-hash"); + let root = project.path(); + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + let large_path = "assets/uploads/upload-1-big.bin"; + let missing_path = "assets/uploads/upload-1-missing.md"; + fs::write( + root.join(large_path), + vec![0_u8; (DIRECT_CODEX_AUDIT_HASH_MAX_BYTES as usize) + 1], + ) + .expect("large file"); + let attachments = vec![ + attachment_json( + "big.bin", + "application/octet-stream", + 3, + Some(large_path), + Some("imported"), + ), + attachment_json( + "missing.md", + "text/markdown", + 1, + Some(missing_path), + Some("imported"), + ), + ]; + let mut audit = start_audit(root, "x", &attachments); + audit.finish(true); + let start = &read_turn_log(root, "turn-01")[0]; + let listed = start["attachments"].as_array().expect("attachments"); + assert_eq!(listed[0]["hashSkipped"], json!("too-large")); + assert!(listed[0].get("contentSha256").is_none()); + assert_eq!(listed[1]["hashSkipped"], json!("missing")); + } + + #[test] + fn command_read_relativizes_absolute_path_and_drops_stdout() { + let project = fixture_project("audit-read"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "裂脉炮").expect("gdd"); + let absolute = root.join(relative); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 9, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "id": "item-read", + "type": "commandExecution", + "command": "type assets/uploads/upload-1-fast_gdd.md", + "status": "completed", + "exitCode": 0, + "aggregatedOutput": "裂脉炮 SECRET", + "commandActions": [{ + "type": "read", + "name": "fast_gdd.md", + "path": absolute.to_string_lossy(), + "command": format!("type {}", absolute.display()) + }] + } + })); + audit.finish(true); + let item = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + let dumped = serde_json::to_string(&item).expect("item json"); + assert!(!dumped.contains("aggregatedOutput")); + assert!(!dumped.contains("裂脉炮 SECRET")); + assert_eq!(item["actions"][0]["path"], json!(relative)); + assert_eq!( + item["actions"][0]["contentSha256"], + json!(sha256_hex("裂脉炮".as_bytes())) + ); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["offeredRead"][0]["read"], json!(true)); + assert_eq!(end["offeredRead"][0]["contentSha256Match"], json!(true)); + } + + #[test] + fn rejected_read_path_does_not_persist_host_absolute_path() { + let project = fixture_project("audit-reject"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": r"type C:\Users\secret\fast_gdd.md", + "aggregatedOutput": "nope", + "commandActions": [{ + "type": "read", + "path": r"C:\Users\secret\fast_gdd.md" + }] + } + })); + audit.finish(true); + let dumped = fs::read_to_string( + project + .path() + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join("turn-01.jsonl"), + ) + .expect("log"); + assert!(!dumped.contains(r"C:\Users")); + assert!(!dumped.contains("Users")); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["actions"][0]["pathRejected"], json!(true)); + assert_eq!(item["commandRedacted"], json!(true)); + assert!(item.get("command").is_none()); + assert!(item.get("aggregatedOutput").is_none()); + } + + #[test] + fn mcp_art_brief_is_kept_and_result_is_dropped() { + let project = fixture_project("audit-art"); + let mut audit = start_audit(project.path(), "做游戏", &[]); + audit.observe_item(&json!({ + "item": { + "id": "art-1", + "type": "mcpToolCall", + "server": "agc_tools", + "tool": "taonier_prepare_game_art", + "status": "completed", + "arguments": { "brief": "俯视角收集冒险小游戏", "mode": "reuse-or-create" }, + "result": { "secret": "do-not-store" }, + "error": null + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["arguments"]["brief"], json!("俯视角收集冒险小游戏")); + assert!(item.get("result").is_none()); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("do-not-store")); + let end = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!( + end["firstDesign"]["kind"], + json!("mcp:taonier_prepare_game_art") + ); + assert_eq!( + end["firstDesign"]["briefPreview"], + json!("俯视角收集冒险小游戏") + ); + } + + #[test] + fn agc_write_file_keeps_path_and_content_chars_not_body() { + let project = fixture_project("audit-write"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { + "path": "game/index.html", + "content": "秘密正文" + } + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["arguments"]["path"], json!("game/index.html")); + assert_eq!( + item["arguments"]["contentChars"], + json!("秘密正文".chars().count()) + ); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("秘密正文")); + assert!(item["arguments"].get("content").is_none()); + } + + #[test] + fn generate_image_prompt_is_truncated_with_hash() { + let project = fixture_project("audit-image"); + let prompt = "收".repeat(5000); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "agc_generate_image", + "arguments": { "prompt": prompt, "kind": "icon-spec" } + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + let stored = item["arguments"]["prompt"].as_str().expect("prompt"); + assert_eq!(stored.chars().count(), DIRECT_CODEX_AUDIT_BRIEF_CHARS); + assert_eq!(item["arguments"]["promptChars"], json!(5000)); + assert_eq!( + item["arguments"]["promptSha256"], + json!(sha256_hex("收".repeat(5000).as_bytes())) + ); + } + + #[test] + fn file_change_keeps_path_and_kind_without_diff() { + let project = fixture_project("audit-patch"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "fileChange", + "status": "completed", + "changes": [{ + "path": "game/index.html", + "kind": { "type": "add" }, + "diff": "*** SECRET PATCH" + }] + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["changes"][0]["path"], json!("game/index.html")); + assert_eq!(item["changes"][0]["kind"], json!("add")); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("SECRET PATCH")); + assert!(!dumped.contains("diff")); + } + + #[test] + fn list_or_search_does_not_count_as_reading_offered_attachment() { + let project = fixture_project("audit-list"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "x").expect("gdd"); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 1, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "rg fast_gdd assets", + "commandActions": [{ + "type": "search", + "query": "fast_gdd", + "path": "assets" + }] + } + })); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "ls assets/uploads", + "commandActions": [{ "type": "listFiles", "path": "assets/uploads" }] + } + })); + audit.finish(true); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["offeredRead"][0]["read"], json!(false)); + assert!(end["offeredRead"][0].get("contentSha256Match").is_none()); + assert!(end["firstDesign"].is_null()); + } + + #[test] + fn first_design_skips_reads_and_uses_later_art_item_seq() { + let project = fixture_project("audit-order"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "x").expect("gdd"); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 1, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "type assets/uploads/upload-1-fast_gdd.md", + "commandActions": [{ "type": "read", "path": relative }] + } + })); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "taonier_prepare_game_art", + "arguments": { "brief": "收集冒险" } + } + })); + audit.finish(true); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!( + end["firstDesign"]["kind"], + json!("mcp:taonier_prepare_game_art") + ); + assert_eq!(end["firstDesign"]["seq"], json!(2)); + assert_eq!(end["offeredRead"][0]["read"], json!(true)); + } + + #[test] + fn item_cap_writes_truncated_marker() { + let project = fixture_project("audit-cap"); + let mut audit = start_audit(project.path(), "x", &[]); + for index in 0..(DIRECT_CODEX_AUDIT_MAX_ITEMS + 2) { + audit.observe_item(&json!({ + "item": { + "id": format!("item-{index}"), + "type": "commandExecution", + "command": "ls", + "commandActions": [{ "type": "unknown" }] + } + })); + } + audit.finish(true); + let records = read_turn_log(project.path(), "turn-01"); + let items = records + .iter() + .filter(|record| record["recordType"] == "direct.codex.item") + .count(); + assert_eq!(items, DIRECT_CODEX_AUDIT_MAX_ITEMS); + assert!(records + .iter() + .any(|record| record["recordType"] == "direct.codex.items_truncated")); + let end = records + .iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["itemsTruncated"], json!(true)); + assert_eq!(end["itemCount"], json!(DIRECT_CODEX_AUDIT_MAX_ITEMS)); + } + + #[test] + fn agent_db_summary_points_at_relative_turn_log() { + let project = fixture_project("audit-db"); + let mut audit = start_audit(project.path(), "做游戏", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "taonier_prepare_game_art", + "arguments": { "brief": "俯视角收集冒险小游戏" } + } + })); + audit.finish(true); + let summary = read_agent_db(project.path()) + .into_iter() + .rev() + .find(|record| record["recordType"] == "direct.codex.turn") + .expect("summary"); + assert_eq!( + summary["turnLog"], + json!(format!("{DIRECT_CODEX_AUDIT_TURN_LOG_DIR}/turn-01.jsonl")) + ); + assert_eq!( + summary["firstDesign"]["briefPreview"], + json!("俯视角收集冒险小游戏") + ); + assert_eq!(summary["completed"], json!(true)); + assert!(!summary["turnLog"].as_str().expect("path").contains('\\')); + } + + #[test] + fn write_failure_does_not_panic_or_surface_through_finish() { + let project = fixture_project("audit-fail"); + fs::create_dir_all(project.path().join(".agent/runtime")).expect("runtime"); + fs::write( + project + .path() + .join(".agent/runtime/test-fail-direct-codex-audit"), + "1", + ) + .expect("marker"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { "type": "unknownTool" } + })); + audit.finish(true); + assert!(!project + .path() + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join("turn-01.jsonl") + .is_file()); + } + + #[test] + fn unknown_item_type_keeps_only_public_fields() { + let project = fixture_project("audit-unknown"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "id": "mystery", + "type": "secretNewItem", + "payload": { "token": "leak-me" }, + "aggregatedOutput": "nope" + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["itemType"], json!("secretNewItem")); + assert_eq!(item["itemId"], json!("mystery")); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("leak-me")); + assert!(!dumped.contains("payload")); + assert!(!dumped.contains("aggregatedOutput")); + } + + #[test] + fn agent_messages_are_skipped() { + let project = fixture_project("audit-skip"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { "type": "agentMessage", "text": "已按 GDD 完成" } + })); + audit.finish(true); + let items = read_turn_log(project.path(), "turn-01") + .into_iter() + .filter(|record| record["recordType"] == "direct.codex.item") + .count(); + assert_eq!(items, 0); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index c785cc490..34cb07418 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -7,9 +7,6 @@ use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; -const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8; -const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160; -const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; @@ -3766,14 +3763,6 @@ pub(crate) fn build_direct_codex_home_system_prompt() -> String { .join("\n") } -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexHomeAttachment { - name: String, - media_type: String, - size: u64, -} - #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectCodexHomeReply { @@ -3806,78 +3795,11 @@ fn parse_direct_codex_home_reply(reply: String) -> DirectCodexHomeReply { } } -fn direct_codex_home_attachment_name(value: &str) -> String { - let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim(); - let sanitized = basename - .chars() - .filter(|character| !character.is_control()) - .take(MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS) - .collect::(); - if sanitized.is_empty() { - "未命名附件".to_string() - } else { - sanitized - } -} - -fn direct_codex_home_attachment_media_type(value: &str) -> String { - let value = value.trim(); - if value.is_empty() - || value.chars().any(|character| { - !(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_')) - }) - { - "application/octet-stream".to_string() - } else { - value - .chars() - .take(MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS) - .collect() - } -} - -fn render_direct_codex_home_user_prompt( - prompt: &str, - attachments: &[DirectCodexHomeAttachment], -) -> Result { - let prompt = prompt.trim(); - if prompt.is_empty() && attachments.is_empty() { - return Err("聊天内容不能为空".to_string()); - } - if attachments.is_empty() { - return Ok(prompt.to_string()); - } - - let mut sections = Vec::new(); - if !prompt.is_empty() { - sections.push(prompt.to_string()); - sections.push(String::new()); - } - sections.push( - "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]".to_string(), - ); - for attachment in attachments.iter().take(MAX_DIRECT_HOME_ATTACHMENTS) { - sections.push(format!( - "- {};类型:{};大小:{} 字节", - direct_codex_home_attachment_name(&attachment.name), - direct_codex_home_attachment_media_type(&attachment.media_type), - attachment.size, - )); - } - if attachments.len() > MAX_DIRECT_HOME_ATTACHMENTS { - sections.push(format!( - "- 另有 {} 个附件未展开", - attachments.len() - MAX_DIRECT_HOME_ATTACHMENTS - )); - } - Ok(sections.join("\n")) -} - pub(crate) async fn run_direct_game_creator_home_turn( prompt: &str, - attachments: &[DirectCodexHomeAttachment], + attachments: &[DirectCodexTurnAttachment], ) -> Result { - let user_prompt = render_direct_codex_home_user_prompt(prompt, attachments)?; + let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?; direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt) .await .map(parse_direct_codex_home_reply) @@ -3907,6 +3829,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( prompt, creation_type, None, + None, ) .await } @@ -3916,6 +3839,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( prompt: &str, creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -3931,7 +3855,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None); } - match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await { + match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await + { Ok(reply) => Ok(reply), Err(failure) => { let error = record_direct_codex_turn_failure(root, failure); @@ -3948,6 +3873,7 @@ async fn run_direct_game_creator_turn_inner( prompt: &str, creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { @@ -3986,15 +3912,23 @@ async fn run_direct_game_creator_turn_inner( ); } }; - direct_game_creator_codex_chat_at_with_observer( + direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, prompt.to_string(), - &mut observer, + Some(&mut observer), + audit, ) .await } else { - direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await + direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt, + prompt.to_string(), + None, + audit, + ) + .await } .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; let visible_reply = project_direct_codex_visible_text(root, &reply).ok_or_else(|| { @@ -4331,6 +4265,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( prompt: String, creation_type: Option, client_turn_id: Option, + attachments: Option>, ) -> Result { let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; @@ -4339,21 +4274,47 @@ pub(crate) async fn chat_with_game_creator_direct_codex( redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) })?; let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let reply = run_direct_game_creator_turn_at_with_creation_type_and_emitter( + let mut audit = DirectCodexTurnAudit::start( root, + &turn_id, &prompt, + attachments.as_deref().unwrap_or_default(), + ); + let user_prompt = match render_direct_codex_user_prompt( + &prompt, + attachments.as_deref().unwrap_or_default(), + ) { + Ok(prompt) => prompt, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root, + &user_prompt, creation_type.as_deref(), Some(&turn_emitter), + Some(&mut audit), ) - .await?; - persist_direct_codex_assistant_reply_at(root, &turn_id, &reply).map_err(|error| { + .await + { + Ok(reply) => reply, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) { + audit.finish(false); turn_emitter.emit("failed", Some("none"), None); - redact_agent_runtime_error( + return Err(redact_agent_runtime_error( root, &format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"), 500, - ) - })?; + )); + } + audit.finish(true); turn_emitter.emit("completed", Some("none"), Some(reply.clone())); Ok(reply) } @@ -4361,7 +4322,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( #[tauri::command] pub(crate) async fn chat_with_game_creator_home_direct_codex( prompt: String, - attachments: Option>, + attachments: Option>, ) -> Result { run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await } @@ -4563,47 +4524,6 @@ mod tests { assert!(!prompt.contains("game/index.html")); } - #[test] - fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() { - let attachments = vec![DirectCodexHomeAttachment { - name: r"C:\Users\secret\角色参考.png".to_string(), - media_type: "image/png\nBearer secret".to_string(), - size: 3, - }]; - - let prompt = render_direct_codex_home_user_prompt(" 先看看这个附件 ", &attachments) - .expect("home prompt"); - - assert!(prompt.starts_with("先看看这个附件\n\n[首页附件说明")); - assert!(prompt.contains("角色参考.png")); - assert!(prompt.contains("类型:application/octet-stream")); - assert!(prompt.contains("大小:3 字节")); - assert!(!prompt.contains("C:\\Users")); - assert!(!prompt.contains("\nBearer secret")); - } - - #[test] - fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() { - assert_eq!( - render_direct_codex_home_user_prompt("你好", &[]).expect("plain prompt"), - "你好" - ); - let attachments = (0..MAX_DIRECT_HOME_ATTACHMENTS + 2) - .map(|index| DirectCodexHomeAttachment { - name: format!("asset-{index}.png"), - media_type: "image/png".to_string(), - size: index as u64, - }) - .collect::>(); - let prompt = render_direct_codex_home_user_prompt("看看素材", &attachments) - .expect("bounded attachments"); - assert!(prompt.contains("asset-7.png")); - assert!(!prompt.contains("asset-8.png")); - assert!(prompt.contains("另有 2 个附件未展开")); - assert!(render_direct_codex_home_user_prompt("", &attachments).is_ok()); - assert!(render_direct_codex_home_user_prompt("", &[]).is_err()); - } - #[test] fn home_create_marker_is_accepted_only_as_the_first_reply_token() { let requested = parse_direct_codex_home_reply(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 030b89231..51e452b0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -715,36 +715,6 @@ fn build_game_creator_agent_background_tool_plan_request_at( )) } -pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( - root: &Path, - agent_id: &str, - session_id: &str, - run_id: &str, - task: &str, - observations: &[AgentRuntimeToolObservation], - loop_index: usize, -) -> Result< - ( - GameCreatorLlmConfig, - String, - LlmRunRequest, - String, - AgentRuntimeToolPlanRequestSnapshot, - ), - String, -> { - build_game_creator_agent_background_tool_plan_request_at( - root, - None, - agent_id, - session_id, - run_id, - task, - observations, - loop_index, - ) -} - pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked( root: &Path, project_lock: &ProjectWriteLock, @@ -972,17 +942,20 @@ mod tests { use crate::{update_manifest_task_status_at, GameCreationAppTaskStatus}; use super::{ + acquire_game_creator_agent_provider_plan_project_write_lock_with_wait, agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at, build_game_creator_agent_background_final_reply_request, - build_game_creator_agent_background_tool_plan_request, + build_game_creator_agent_background_tool_plan_request_locked, game_creator_agent_context_preload_notice, game_creator_agent_runtime_run_profile_binding_path, game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, new_game_creation_app_seed_tasks, provider_command_exec_contract, - provider_command_start_contract, render_relaxed_autonomous_manifest_ready_task_background_prompt, + provider_command_start_contract, + render_relaxed_autonomous_manifest_ready_task_background_prompt, required_runtime_prompt_section, start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, + AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig, AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND, @@ -996,6 +969,40 @@ mod tests { RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, }; + fn build_game_creator_agent_background_tool_plan_request_for_test( + root: &std::path::Path, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, + observations: &[AgentRuntimeToolObservation], + loop_index: usize, + ) -> Result< + ( + GameCreatorLlmConfig, + String, + platform_llm::LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, + > { + let lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( + root, + "test.provider_request.build.tool_plan", + )?; + build_game_creator_agent_background_tool_plan_request_locked( + root, + &lock, + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + ) + } + fn native_input_required_fields( request: &platform_llm::LlmRunRequest, tool: &str, @@ -1066,7 +1073,7 @@ mod tests { summary: "结构化计划更新被 Runtime 拒绝".to_string(), detail: Some("计划状态回退".to_string()), }; - let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test( &root, &state.agent_id, &state.session_id, @@ -1155,16 +1162,17 @@ mod tests { // Relaxed orchestration does not convert an idle planning counter into // a tool-removal gate; the Provider remains free to choose its next // action. - let (_, _, baseline, _, _) = build_game_creator_agent_background_tool_plan_request( - &root, - &state.agent_id, - &state.session_id, - &state.run_id, - &state.current_task, - &[], - 1, - ) - .expect("build baseline request"); + let (_, _, baseline, _, _) = + build_game_creator_agent_background_tool_plan_request_for_test( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[], + 1, + ) + .expect("build baseline request"); assert!(baseline .function_tools .iter() @@ -1175,7 +1183,7 @@ mod tests { crate::agent::write_game_creator_agent_runtime_state(&root, &idle_state) .expect("persist idle rounds"); - let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test( &root, &state.agent_id, &state.session_id, @@ -1256,7 +1264,7 @@ mod tests { vec!["交付当前 manifest task".to_string()], ) .expect("start autonomous ready child task"); - let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test( &root, agent_id, &state.session_id, @@ -1506,7 +1514,7 @@ mod tests { vec!["冻结 Goal Contract".to_string()], ) .expect("start trusted root"); - let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test( &root, &state.agent_id, &state.session_id, @@ -1566,7 +1574,7 @@ mod tests { ) .expect("start plan root"); - let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request_for_test( &root, &state.agent_id, &state.session_id, @@ -1633,7 +1641,7 @@ mod tests { }, ) .expect("freeze plan contract"); - let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request( + let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request_for_test( &root, &state.agent_id, &state.session_id, @@ -1719,7 +1727,7 @@ mod tests { ) .expect("start supervisor runtime state"); let (_, _, supervisor_request, _, _) = - build_game_creator_agent_background_tool_plan_request( + build_game_creator_agent_background_tool_plan_request_for_test( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, &supervisor_state.session_id, @@ -1865,16 +1873,17 @@ mod tests { vec!["核对普通说明".to_string()], ) .expect("start ordinary runtime state"); - let (_, _, ordinary_request, _, _) = build_game_creator_agent_background_tool_plan_request( - &root, - "code-prototype", - &ordinary_state.session_id, - &ordinary_state.run_id, - &ordinary_state.current_task, - &[], - 0, - ) - .expect("build ordinary planning request"); + let (_, _, ordinary_request, _, _) = + build_game_creator_agent_background_tool_plan_request_for_test( + &root, + "code-prototype", + &ordinary_state.session_id, + &ordinary_state.run_id, + &ordinary_state.current_task, + &[], + 0, + ) + .expect("build ordinary planning request"); assert!(ordinary_request.messages[0] .content .contains("你正在使用 Genarrative AI 游戏创作多智能体 Runtime")); @@ -1989,16 +1998,17 @@ mod tests { vec!["读取需求并准备澄清".to_string()], ) .expect("start planning child"); - let (_, _, planning_request, _, _) = build_game_creator_agent_background_tool_plan_request( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &planning_state.session_id, - &planning_state.run_id, - &planning_state.current_task, - &[], - 0, - ) - .expect("build planning request"); + let (_, _, planning_request, _, _) = + build_game_creator_agent_background_tool_plan_request_for_test( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &planning_state.session_id, + &planning_state.run_id, + &planning_state.current_task, + &[], + 0, + ) + .expect("build planning request"); let planning_system_prompt = &planning_request.messages[0].content; let planning_brief_marker = "你是“立项策划 Agent”(`agentId=project-planning`)"; assert!(planning_system_prompt.contains(planning_brief_marker)); @@ -2074,7 +2084,7 @@ mod tests { ) .expect("start supervisor"); let (_, _, supervisor_request, _, _) = - build_game_creator_agent_background_tool_plan_request( + build_game_creator_agent_background_tool_plan_request_for_test( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, &supervisor_state.session_id, @@ -2183,7 +2193,7 @@ mod tests { }, ]; let (_, _, request, _, request_snapshot) = - build_game_creator_agent_background_tool_plan_request( + build_game_creator_agent_background_tool_plan_request_for_test( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, &state.session_id, @@ -2209,7 +2219,7 @@ mod tests { ), }); let (_, _, _, _, settled_request_snapshot) = - build_game_creator_agent_background_tool_plan_request( + build_game_creator_agent_background_tool_plan_request_for_test( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, &state.session_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 691ddb02d..5a8e66b50 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -216,23 +216,6 @@ pub(crate) fn pending_matches_receipt( /// Construct the independent planning pending projection after an external /// acceptance gate has succeeded. M1C-1 does not decide whether the gate /// passed; the caller must supply that fact and the exact GDD identity. -pub(crate) fn create_plan_gdd_approval_pending_at( - root: &Path, - gdd: &PlanGddV1, -) -> Result<(), PlanningStorageError> { - if !crate::config::game_creator_planning_capability_enabled() - .map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))? - { - return Err(approval_error( - "PLAN_CAPABILITY_DISABLED", - "立项策划能力当前已停用", - )); - } - let _lock = acquire_project_write_lock(root, "planning.approval-pending.create") - .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; - create_plan_gdd_approval_pending_locked(root, gdd) -} - pub(crate) fn create_plan_gdd_approval_pending_locked( root: &Path, gdd: &PlanGddV1, @@ -2357,13 +2340,6 @@ pub(crate) fn plan_gdd_typed_completion_blocker_at_locked( ), )); } - if session.phase == "recovery_required" { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "planning session 仍处于 recovery_required,不能收束任务", - format!("gddVersion={}", latest.version), - )); - } None } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index fba95f5d6..889d85f06 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -2408,8 +2408,7 @@ pub(crate) fn validate_plan_session_successor( && next.active_run_id.is_some() && next.last_run_id == next.active_run_id.clone().unwrap_or_default(); if next.run_profile_binding_fingerprint != previous.run_profile_binding_fingerprint - && (!active_run_changed - || !matches!(next.phase.as_str(), "collecting" | "revision_requested")) + && !active_run_changed { return Err(conflict( "session 只有在绑定新的 active planning child 时才能更换 Run Profile binding fingerprint", @@ -2712,18 +2711,6 @@ pub(crate) fn canonical_plan_submit_gdd_input_bytes( Ok(bytes) } -pub(crate) fn parse_plan_submit_gdd_input_bytes( - bytes: &[u8], -) -> Result { - let value = parse_strict_canonical::( - bytes, - "plan.submit_gdd input", - PLAN_GDD_MAX_BYTES, - )?; - validate_plan_submit_gdd_input(&value)?; - Ok(value) -} - pub(crate) fn validate_plan_gdd_chain(values: &[PlanGddV1]) -> Result<(), PlanningStorageError> { if values.len() > PLAN_MAX_VERSIONS as usize { return Err(PlanningStorageError::new( @@ -5067,22 +5054,23 @@ mod tests { fn submit_input_has_strict_canonical_parser_and_runtime_field_boundary() { let value = golden_submit_input(); let bytes = canonical_plan_submit_gdd_input_bytes(&value).expect("submit input bytes"); - assert_eq!( - parse_plan_submit_gdd_input_bytes(&bytes).expect("parse input"), - value - ); + let parse = |bytes: &[u8]| -> Result { + let value = parse_strict_canonical::( + bytes, + "plan.submit_gdd input", + PLAN_GDD_MAX_BYTES, + )?; + validate_plan_submit_gdd_input(&value)?; + Ok(value) + }; + assert_eq!(parse(&bytes).expect("parse input"), value); let mut newline = bytes.clone(); newline.push(b'\n'); - assert_eq!( - parse_plan_submit_gdd_input_bytes(&newline) - .unwrap_err() - .code(), - "PLAN_NON_CANONICAL_BYTES" - ); + assert_eq!(parse(&newline).unwrap_err().code(), "PLAN_NON_CANONICAL_BYTES"); let mut object = serde_json::from_slice::(&bytes).expect("input json"); object["projectId"] = serde_json::Value::String("forged-project".to_string()); let forged = serde_json::to_vec(&object).expect("forged input"); - assert!(parse_plan_submit_gdd_input_bytes(&forged).is_err()); + assert!(parse(&forged).is_err()); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index dfb10dbc6..94953a186 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1166,15 +1166,7 @@ fn validate_current_session_cas( "plan.submit_gdd 必须绑定当前活跃策划子 Run", )); } - // 这条 phase 判据实际只可能看到 `collecting`:上面的 activeRunId 判据要求 - // session 绑着当前策划子 run,而 schema 不变量禁止 `awaiting_user_input`、 - // `awaiting_gdd_approval`、`revision_requested`、`approved`、`rejected`、 - // `recovery_required` 保留 activeRunId(planning_storage.rs 的 - // 「session 进入审批/终态/recovery_required 后不得保留 activeRunId」)。 - // 因此 revise/reject 之后能不能重做,不由这条门决定,而由 M1C-2b 的 continuation - // 起点 writer 决定——它必须以新 activeRunId 写 revision+1 successor,phase 只能落回 - // `collecting`。这里保留 `revision_requested` 作为既有冗余,不再新增更多不可达分支。 - if !matches!(session.phase.as_str(), "collecting" | "revision_requested") { + if session.phase != "collecting" { return Err(submit_error( "PLAN_PENDING_GDD_EXISTS", "当前 planning session 仍有未决 GDD", @@ -1641,11 +1633,7 @@ fn project_submit_successors_locked( let source_session_matches_gdd = session_identity_matches_gdd && previous_session.session_revision == gdd.source_session_revision && previous_session.session_fingerprint == gdd.source_session_fingerprint - && previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str()) - && matches!( - previous_session.phase.as_str(), - "collecting" | "revision_requested" - ); + && previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str()); if !session_identity_matches_gdd { recovery_pending = true; } else if same_ref { @@ -1896,6 +1884,15 @@ mod tests { use std::fs; use std::path::PathBuf; + fn create_plan_gdd_approval_pending_for_test( + root: &std::path::Path, + gdd: &PlanGddV1, + ) -> Result<(), PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "test.planning.approval-pending.create") + .map_err(|error| PlanningStorageError::new("PLAN_DURABILITY_FAILED", error))?; + create_plan_gdd_approval_pending_locked(root, gdd) + } + fn valid_input() -> PlanSubmitGddInputV1 { PlanSubmitGddInputV1 { schema_version: PLAN_SUBMIT_INPUT_SCHEMA.to_string(), @@ -3628,7 +3625,7 @@ mod tests { .expect("read submitted GDD") .pop() .expect("GDD exists"); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); let decision_input = approval_input( &gdd, "approve", @@ -3654,13 +3651,13 @@ mod tests { .pop() .expect("GDD exists"); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); let pending = read_plan_gdd_approval_pending_locked(&root) .expect("read approval pending") .expect("pending exists"); assert_eq!(pending.status, "awaiting_decision"); // Recreating the exact card is an idempotent replay. - create_plan_gdd_approval_pending_at(&root, &gdd).expect("replay approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("replay approval pending"); let mut forged_next = gdd.clone(); forged_next.version = 2; @@ -3669,7 +3666,7 @@ mod tests { "gdd-approval-00000000-0000-4000-8000-000000000003".to_string(); forged_next.action_fingerprint = "4".repeat(64); forged_next.fingerprint = plan_gdd_fingerprint(&forged_next).expect("next fingerprint"); - let stale = create_plan_gdd_approval_pending_at(&root, &forged_next) + let stale = create_plan_gdd_approval_pending_for_test(&root, &forged_next) .expect_err("a non-latest GDD cannot receive an approval card"); assert_eq!(stale.code(), "PLAN_STALE_APPROVAL"); @@ -3684,7 +3681,7 @@ mod tests { ) .expect("commit approval receipt"); assert_eq!(decision.outcome, "committed"); - let after_receipt = create_plan_gdd_approval_pending_at(&root, &gdd) + let after_receipt = create_plan_gdd_approval_pending_for_test(&root, &gdd) .expect_err("a receipt must close awaiting_decision recreation"); assert_eq!(after_receipt.code(), "PLAN_STALE_APPROVAL"); cleanup_fixture(root); @@ -3698,7 +3695,7 @@ mod tests { .expect("read submitted GDD") .pop() .expect("GDD exists"); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); let task_path = game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); @@ -3738,7 +3735,7 @@ mod tests { .expect("read submitted GDD") .pop() .expect("GDD exists"); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); let _config_guard = crate::tests::write_test_local_config( r#"{"planning":{"capabilityEnabled":false}}"#.to_string(), ); @@ -3802,7 +3799,7 @@ mod tests { assert_eq!(blocker.status, "needs-reconciliation"); assert!(blocker.summary.contains("审批 pending")); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); let blocker = plan_gdd_completion_blocker_at_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -3955,7 +3952,7 @@ mod tests { .expect("read submitted GDD") .pop() .expect("GDD exists"); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); decide_plan_gdd_at( &root, &approval_input( @@ -4089,7 +4086,7 @@ mod tests { #[test] fn a_revision_comment_reaches_the_supervisor_conversation_once() { let (root, gdd, _root_runtime) = acceptance_gate_fixture(true); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); decide_plan_gdd_at( &root, &approval_input( @@ -4143,7 +4140,7 @@ mod tests { .expect("read submitted GDD") .pop() .expect("GDD exists"); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); decide_plan_gdd_at( &root, &approval_input( @@ -4230,7 +4227,8 @@ mod tests { .expect("read submitted GDD") .pop() .expect("GDD exists"); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd) + .expect("create approval pending"); let first_input = approval_input( &gdd, action, @@ -4461,7 +4459,7 @@ mod tests { .expect("read submitted GDD") .pop() .expect("GDD exists"); - create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); let decision_input = approval_input( &gdd, "approve", diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 6398725f7..2abccf8f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -136,23 +136,20 @@ fn normalize_external_editor_api_key(value: &str) -> Result { fn private_external_editor_api_credentials_from_file_at( path: &Path, ) -> Result, String> { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!("读取本机陶泥儿开发者 Key 配置失败:{error}")); - } - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err("本机陶泥儿开发者 Key 配置必须是普通文件".to_string()); + if !crate::prepare_game_creator_private_path_for_read(path, false, "本机陶泥儿开发者 Key 文件")? + { + return Ok(None); } + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?; if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES { return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string()); } - #[cfg(windows)] - secure_windows_game_creator_path_for_current_user(path, false, false)?; - let content = fs::read_to_string(path) - .map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?; + let content = crate::read_game_creator_private_file_to_string( + path, + "本机陶泥儿开发者 Key 配置", + PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES, + )?; let parsed = serde_json::from_str::(&content) .map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?; let api_key = normalize_external_editor_api_key(&parsed.api_key)?; @@ -162,6 +159,22 @@ fn private_external_editor_api_credentials_from_file_at( .as_deref() .unwrap_or(DEFAULT_CANVAS_SYNC_API_BASE_URL), )?; + let expected_fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes())); + let actual_fingerprint = path + .file_name() + .and_then(|value| value.to_str()) + .and_then(|value| { + value + .strip_prefix(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX) + .and_then(|value| value.strip_suffix(".json")) + }) + .filter(|value| value.len() == 16 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or_else(|| "本机陶泥儿开发者 Key 文件名身份无效,请重新登录客户端后重试".to_string())?; + if !actual_fingerprint.eq_ignore_ascii_case(&expected_fingerprint[..16]) { + return Err( + "本机陶泥儿开发者 Key 文件身份与服务器地址不一致,请重新登录客户端后重试".to_string(), + ); + } Ok(Some(ExternalEditorApiCredentials { api_base_url, api_key, @@ -171,18 +184,13 @@ fn private_external_editor_api_credentials_from_file_at( fn unique_private_external_editor_api_credentials_at( directory: &Path, ) -> Result, String> { - let metadata = match fs::symlink_metadata(directory) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!("读取本机陶泥儿开发者 Key 目录失败:{error}")); - } - }; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string()); + if !crate::prepare_game_creator_private_path_for_read( + directory, + true, + "本机陶泥儿开发者 Key 目录", + )? { + return Ok(None); } - #[cfg(windows)] - secure_windows_game_creator_path_for_current_user(directory, true, false)?; let mut candidates = fs::read_dir(directory) .map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))? .filter_map(Result::ok) @@ -219,35 +227,15 @@ fn ensure_plain_private_external_editor_directory( path: &Path, label: &str, ) -> Result { - match fs::symlink_metadata(path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); - } - Ok(false) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let created = match fs::create_dir(path) { - Ok(()) => true, - Err(create_error) if create_error.kind() == std::io::ErrorKind::AlreadyExists => { - false - } - Err(create_error) => { - return Err(format!( - "创建本机陶泥儿开发者凭据{label}失败:{create_error}" - )); - } - }; - let metadata = fs::symlink_metadata(path).map_err(|metadata_error| { - format!("读取本机陶泥儿开发者凭据{label}失败:{metadata_error}") - })?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); - } - Ok(created) - } - Err(error) => Err(format!("读取本机陶泥儿开发者凭据{label}失败:{error}")), + let label = format!("本机陶泥儿开发者凭据{label}"); + let created = crate::ensure_game_creator_private_directory_tree(path, &label)?; + #[cfg(windows)] + if !created { + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, true, true, + )?; } + Ok(created) } /// Prepares the exact private directory before a one-time remote developer key @@ -264,15 +252,11 @@ fn prepare_private_external_editor_api_credentials_parent_dir_at( .parent() .ok_or_else(|| "本机陶泥儿开发者凭据配置缺少上级目录".to_string())?; ensure_plain_private_external_editor_directory(container, "上级目录")?; - let parent_created = ensure_plain_private_external_editor_directory(parent, "目录")?; + ensure_plain_private_external_editor_directory(parent, "目录")?; #[cfg(windows)] - if parent_created { - initialize_windows_game_creator_directory_owner_for_current_user(parent)?; - } else { - secure_windows_game_creator_path_for_current_user(parent, true, true)?; - } + secure_windows_game_creator_path_for_current_user_with_auto_elevation(parent, true, true)?; #[cfg(unix)] - if parent_created { + { use std::os::unix::fs::PermissionsExt; fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) .map_err(|error| format!("收紧本机陶泥儿开发者凭据目录权限失败:{error}"))?; @@ -301,8 +285,19 @@ fn write_private_external_editor_api_credentials_at( if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string()); } - if path.exists() { - return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string()); + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err( + "本机陶泥儿开发者 Key 目标必须是普通文件,不能是链接或其他对象".to_string(), + ); + } + return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("读取本机陶泥儿开发者 Key 目标失败:{error}")); + } } let body = serde_json::to_string_pretty(&PrivateExternalEditorApiKeyFile { api_key: credentials.api_key.clone(), @@ -325,9 +320,19 @@ fn write_private_external_editor_api_credentials_at( use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&temporary) .map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?; + crate::harden_new_game_creator_private_path(&temporary, false, "本机陶泥儿开发者 Key 临时文件") + .map_err(|error| { + let _ = fs::remove_file(&temporary); + format!("初始化本机陶泥儿开发者 Key 临时文件安全权限失败:{error}") + })?; let write_result = file .write_all(format!("{body}\n").as_bytes()) .and_then(|_| file.sync_all()); @@ -336,8 +341,6 @@ fn write_private_external_editor_api_credentials_at( let _ = fs::remove_file(&temporary); return Err(format!("写入本机陶泥儿开发者 Key 临时文件失败:{error}")); } - #[cfg(windows)] - initialize_windows_game_creator_file_owner_for_current_user(&temporary)?; match fs::hard_link(&temporary, path) { Ok(()) => { let _ = fs::remove_file(&temporary); @@ -353,7 +356,14 @@ fn write_private_external_editor_api_credentials_at( } } #[cfg(windows)] - secure_windows_game_creator_path_for_current_user(path, false, true)?; + if let Err(error) = + secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true) + { + let _ = fs::remove_file(path); + return Err(format!( + "复核本机陶泥儿开发者 Key 文件安全权限失败:{error}" + )); + } Ok(()) } @@ -474,11 +484,10 @@ pub(crate) fn upload_local_asset_at( let relative_path = format!("assets/uploads/{asset_id}-{safe_name}"); let absolute_path = root.join(&relative_path); if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建上传目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "上传目录")?; + prepare_game_creator_private_path_for_read(parent, true, "上传目录")?; } - fs::write(&absolute_path, bytes) - .map_err(|error| format!("写入上传文件失败:{}: {error}", absolute_path.display()))?; + crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?; register_local_asset_entry( root, @@ -578,13 +587,11 @@ pub(crate) fn import_canvas_export_at( if !export_path.is_absolute() { return Err("画板导出 ZIP 路径必须是绝对路径".to_string()); } + crate::prepare_game_creator_user_selected_path_for_read(export_path, false, "画板导出 ZIP")?; let metadata = fs::symlink_metadata(export_path) .map_err(|error| format!("读取画板导出 ZIP 失败:{}: {error}", export_path.display()))?; - if metadata.file_type().is_symlink() { - return Err("画板导出 ZIP 不能是符号链接".to_string()); - } - if !metadata.is_file() { - return Err("画板导出路径必须是 ZIP 文件".to_string()); + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("画板导出路径必须是普通 ZIP 文件".to_string()); } init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; @@ -765,12 +772,10 @@ pub(crate) async fn sync_canvas_project_assets_at( ); let absolute_path = root.join(&local_path); if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建画板同步目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "画板同步目录")?; + prepare_game_creator_private_path_for_read(parent, true, "画板同步目录")?; } - fs::write(&absolute_path, &download.bytes).map_err(|error| { - format!("写入画板同步资产失败:{}: {error}", absolute_path.display()) - })?; + crate::write_game_creator_private_file(&absolute_path, &download.bytes, "画板同步资产")?; assets.push(register_local_asset_entry( root, &local_path, @@ -1523,13 +1528,18 @@ pub(crate) fn extract_canvas_export_zip_files( let local_relative_path = format!("{import_relative_root}/{normalized_relative}"); let target_path = resolve_local_project_path(root, &local_relative_path)?; if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建画板导入目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "画板导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "画板导入目录")?; } - let mut output = File::create(&target_path) - .map_err(|error| format!("写入画板导入文件失败:{}: {error}", target_path.display()))?; - std::io::copy(&mut entry, &mut output) + let entry_size = entry.size(); + let mut bytes = Vec::with_capacity(entry_size.min(MAX_CANVAS_EXPORT_BYTES as u64) as usize); + std::io::Read::take(&mut entry, entry_size + 1) + .read_to_end(&mut bytes) .map_err(|error| format!("解压画板导出文件失败:{}: {error}", target_path.display()))?; + if bytes.len() as u64 != entry_size { + return Err("画板导出 ZIP 条目读取长度不一致".to_string()); + } + crate::write_game_creator_private_file(&target_path, &bytes, "画板导入文件")?; copied_files.push(normalized_relative); } if copied_files.is_empty() { @@ -1807,11 +1817,16 @@ mod tests { { let root = tempfile::tempdir().expect("temp dir"); let directory = root.path().join("config").join("genarrative"); - let first_path = directory.join("external-editor-api-0000000000000001.json"); let first = ExternalEditorApiCredentials { api_base_url: "https://dev.genarrative.world".to_string(), api_key: "tnr_sk_headless_fixture_1".to_string(), }; + let first_path = directory.join( + private_external_editor_api_key_path_for_base_url(&first.api_base_url) + .expect("first credential path") + .file_name() + .expect("first credential filename"), + ); write_private_external_editor_api_credentials_at(&first_path, &first) .expect("write first private credential"); let recovered = unique_private_external_editor_api_credentials_at(&directory) @@ -1820,11 +1835,16 @@ mod tests { assert_eq!(recovered.api_base_url, first.api_base_url); assert_eq!(recovered.api_key, first.api_key); - let second_path = directory.join("external-editor-api-0000000000000002.json"); let second = ExternalEditorApiCredentials { api_base_url: "https://www.genarrative.world".to_string(), api_key: "tnr_sk_headless_fixture_2".to_string(), }; + let second_path = directory.join( + private_external_editor_api_key_path_for_base_url(&second.api_base_url) + .expect("second credential path") + .file_name() + .expect("second credential filename"), + ); write_private_external_editor_api_credentials_at(&second_path, &second) .expect("write second private credential"); let error = match unique_private_external_editor_api_credentials_at(&directory) { @@ -1856,11 +1876,17 @@ mod tests { #[test] fn newly_created_private_external_editor_credentials_directory_is_owned_by_token_user() { let root = tempfile::tempdir().expect("temp dir"); + let credential_file_name = + private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world") + .expect("credential path") + .file_name() + .expect("credential filename") + .to_owned(); let path = root .path() .join("config") .join("genarrative") - .join("external-editor-api-test.json"); + .join(credential_file_name); prepare_private_external_editor_api_credentials_parent_dir_at(&path) .expect("prepare private credential directory"); @@ -1889,7 +1915,13 @@ mod tests { "fixture must reproduce the inherited ACL rejection" ); - let path = parent.join("external-editor-api-test.json"); + let credential_file_name = + private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world") + .expect("credential path") + .file_name() + .expect("credential filename") + .to_owned(); + let path = parent.join(credential_file_name); prepare_private_external_editor_api_credentials_parent_dir_at(&path) .expect("current-user directory should be tightened locally before remote creation"); secure_windows_game_creator_path_for_current_user(&parent, true, false) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 73221b283..465d07443 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -165,8 +165,14 @@ fn validate_local_asset_import_requirements( } let mut total_size = 0u64; for source in source_paths { - let metadata = - fs::symlink_metadata(source.trim()).map_err(|_| "读取本地文件失败".to_string())?; + let path = Path::new(source.trim()); + // Run the explicit user-selection ACL preparation before any size/type + // preflight. On Windows, metadata traversal can itself fail with + // ERROR_ACCESS_DENIED; doing this only in the later import worker would + // leave the early validation path unable to reach the one-shot UAC + // repair entry. + crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地导入文件")?; + let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地文件失败".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通文件".to_string()); } @@ -282,12 +288,8 @@ pub(crate) fn create_automatic_local_game_project_at( if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { return Err("自动工作区根目录必须是绝对路径".to_string()); } - fs::create_dir_all(projects_root).map_err(|error| { - format!( - "创建自动工作区根目录失败:{}: {error}", - projects_root.display() - ) - })?; + ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?; + prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?; let metadata = fs::symlink_metadata(projects_root).map_err(|error| { format!( "读取自动工作区根目录失败:{}: {error}", @@ -306,6 +308,11 @@ pub(crate) fn create_automatic_local_game_project_at( match fs::create_dir(&project_root) { Ok(()) => { let result = (|| { + prepare_game_creator_private_path_for_read( + &project_root, + true, + "自动项目目录", + )?; enforce_project_permission_policy(&project_root, "project.create")?; let _lock = acquire_project_write_lock(&project_root, "project.create")?; init_local_game_project_at( @@ -377,6 +384,7 @@ pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Resu if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } + crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?; if !root.exists() { return Ok(false); } @@ -405,6 +413,15 @@ pub(crate) fn inspect_local_project_directory( if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } + match fs::symlink_metadata(root) { + Ok(metadata) if metadata.is_dir() => { + crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?; + } + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?; + } + _ => {} + } let recent_run_trace = recent_game_creator_run_trace(root); let godot_project_root = discover_local_godot_project_root(root)?; Ok(LocalProjectDirectoryStatus { @@ -464,7 +481,12 @@ pub(crate) fn game_creator_project_manifest_error(root: &Path) -> Option pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option { let trace_path = root.join(".agent/run.latest.json"); - let content = fs::read_to_string(trace_path).ok()?; + let content = crate::read_game_creator_private_file_to_string( + &trace_path, + "最近运行状态", + 256 * 1024, + ) + .ok()?; serde_json::from_str::(&content).ok() } @@ -500,9 +522,24 @@ pub(crate) async fn pick_local_project_directory( else { return Ok(None); }; - path.into_path() - .map(|path| Some(path.to_string_lossy().into_owned())) - .map_err(|error| format!("读取项目目录失败:{error}")) + let path = path + .into_path() + .map_err(|error| format!("读取项目目录失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, true); + // The native picker is the explicit user-selection boundary. Prepare the + // selected root before returning it so inspect/create/open never races the + // first ACL read. + if let Err(error) = crate::prepare_game_creator_project_root_for_read( + &path, + true, + "用户选择项目目录", + ) { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) } #[tauri::command] @@ -521,9 +558,21 @@ pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result Result<(), String> { + shutdown_game_creator_codex_app_servers()?; clear_external_agent_runner_platform_session(generation)?; clear_platform_session(generation); Ok(()) @@ -1901,14 +1952,30 @@ pub(crate) fn create_ui_design_resource( let relative_path = format!("ui/{resource_name}.json"); let absolute_path = resolve_local_project_path(root, &relative_path)?; if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 UI 资源目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?; + prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?; } - if absolute_path.exists() { + if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? { return Err("UI 设计资源路径已存在,拒绝覆盖".to_string()); } - fs::write(&absolute_path, "") + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&absolute_path) .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") { + drop(file); + let _ = fs::remove_file(&absolute_path); + return Err(error); + } + file.sync_all() + .map_err(|error| format!("同步 UI 资源失败:{}: {error}", absolute_path.display()))?; + drop(file); let asset = match register_local_asset_at( root, &relative_path, @@ -2123,6 +2190,7 @@ pub(crate) fn import_ui_editor_local_files( let mut total_size = 0u64; for source in source_paths { let path = Path::new(source.trim()); + crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地图片")?; let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通图片文件".to_string()); @@ -2203,6 +2271,7 @@ fn read_registered_ui_editor_font( return Err("项目资产不是受支持的字体候选".to_string()); } let target = resolve_local_project_path(root, &asset.local_path)?; + prepare_game_creator_private_path_for_read(&target, false, "项目字体")?; let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("项目字体必须是普通文件".to_string()); @@ -2279,6 +2348,7 @@ pub(crate) fn import_ui_editor_local_fonts( let mut input_hashes = std::collections::BTreeSet::new(); for source in source_paths { let path = Path::new(source.trim()); + crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?; let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通字体文件".to_string()); @@ -2337,7 +2407,8 @@ pub(crate) fn import_ui_editor_local_fonts( if !new_inputs.is_empty() { let font_root = root.join("assets/fonts"); - fs::create_dir_all(&font_root).map_err(|_| "创建项目字体目录失败".to_string())?; + ensure_game_creator_private_directory_tree(&font_root, "项目字体目录")?; + prepare_game_creator_private_path_for_read(&font_root, true, "项目字体目录")?; } // 字体批次同样是增量提交合同:已经复制并登记的字体在后续失败时保留。 let mut result = Vec::with_capacity(inputs.len()); @@ -2358,7 +2429,28 @@ pub(crate) fn import_ui_editor_local_fonts( validated.metadata.format.extension() ); let target = resolve_local_project_path(root, &relative_path)?; - fs::write(&target, &bytes).map_err(|_| "写入项目字体失败".to_string())?; + if prepare_game_creator_private_path_for_read(&target, false, "项目字体")? { + return Err(format!("项目字体目标已存在但未登记:{}", target.display())); + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&target) + .map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "项目字体") { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?; + drop(file); let registered = register_local_asset_entry( root, &relative_path, @@ -2598,7 +2690,7 @@ mod ui_editor_font_tests { assert_eq!( read_registered_ui_editor_font(root, &entry).expect_err("reject symlink"), - "项目文件路径不能包含符号链接" + "项目文件路径不能包含符号链接或 Windows reparse point" ); } } @@ -3380,6 +3472,7 @@ pub(crate) fn import_local_project_image_assets_for_agent( reject_sensitive_project_file_read(&normalized)?; reject_agent_local_image_source_path(&normalized)?; let source = resolve_local_project_path(root, &normalized)?; + prepare_game_creator_private_path_for_read(&source, false, "本地图片")?; let metadata = fs::symlink_metadata(&source).map_err(|_| format!("本地图片不存在:{normalized}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -3430,16 +3523,37 @@ pub(crate) fn import_local_project_image_assets_for_agent( }); continue; } - if target.exists() && source_path != local_path { + if target.exists() { + prepare_game_creator_private_path_for_read(&target, false, "目标图片")?; let existing_bytes = fs::read(&target).map_err(|_| "读取目标图片失败".to_string())?; if existing_bytes != bytes { return Err(format!("本地图片目标已存在且内容不同:{local_path}")); } } else { if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|_| "创建本地图片导入目录失败".to_string())?; + ensure_game_creator_private_directory_tree(parent, "本地图片导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "本地图片导入目录")?; } - fs::write(&target, &bytes).map_err(|_| "写入本地图片失败".to_string())?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&target) + .map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "目标图片") + { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?; + drop(file); } let registered = register_local_asset_entry( root, @@ -3588,12 +3702,33 @@ pub(crate) async fn import_account_editor_assets_for_agent( continue; } if target.exists() { + prepare_game_creator_private_path_for_read(&target, false, "账户图片目标")?; return Err(format!("账户图片目标已存在但尚未登记:{local_path}")); } if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|_| "创建账户图片导入目录失败".to_string())?; + ensure_game_creator_private_directory_tree(parent, "账户图片导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "账户图片导入目录")?; } - fs::write(&target, &bytes).map_err(|_| "写入账户图片失败".to_string())?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&target) + .map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "账户图片目标") + { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?; + drop(file); let (source_kind, canvas_project_id, generation_route) = match record.origin { AgentEditorAssetOrigin::AccountLibrary => ( GameCreationAppAssetSourceKind::Canvas, @@ -3695,9 +3830,31 @@ pub(crate) async fn import_ui_editor_remote_assets( for (asset, asset_id, media_type, local_path, bytes) in downloads { let target = resolve_local_project_path(root, &local_path)?; if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| format!("创建导入目录失败:{e}"))?; + ensure_game_creator_private_directory_tree(parent, "平台素材导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "平台素材导入目录")?; } - fs::write(&target, &bytes).map_err(|e| format!("写入平台素材失败:{e}"))?; + if prepare_game_creator_private_path_for_read(&target, false, "平台素材")? { + return Err(format!("平台素材目标已存在但尚未登记:{local_path}")); + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&target) + .map_err(|error| format!("写入平台素材失败:{e}", e = error))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "平台素材") { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("写入平台素材失败:{error}"))?; + drop(file); let registered = register_local_asset_entry( root, &local_path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 880382b98..d7abce6cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1,5 +1,96 @@ use super::*; +#[cfg(windows)] +use std::collections::HashMap; +#[cfg(windows)] +use std::time::Instant; + +#[cfg(windows)] +const GAME_CREATOR_USER_SELECTED_PATH_GRANT_TTL: Duration = Duration::from_secs(300); + +#[cfg(windows)] +#[derive(Clone, Copy)] +struct UserSelectedPathGrant { + is_directory: bool, + expires_at: Instant, +} + +#[cfg(windows)] +static GAME_CREATOR_USER_SELECTED_PATH_GRANTS: + OnceLock>> = OnceLock::new(); + +#[cfg(windows)] +fn user_selected_path_grants() -> &'static Mutex> { + GAME_CREATOR_USER_SELECTED_PATH_GRANTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(windows)] +fn normalize_user_selected_path_key(path: &Path) -> Option { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return None; + } + Some( + path.to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase(), + ) +} + +#[cfg(windows)] +pub(crate) fn register_game_creator_user_selected_path(path: &Path, is_directory: bool) { + let Some(key) = normalize_user_selected_path_key(path) else { + return; + }; + let mut grants = user_selected_path_grants() + .lock() + .expect("user-selected path grants lock"); + grants.retain(|_, grant| grant.expires_at > Instant::now()); + grants.insert( + key, + UserSelectedPathGrant { + is_directory, + expires_at: Instant::now() + GAME_CREATOR_USER_SELECTED_PATH_GRANT_TTL, + }, + ); +} + +#[cfg(windows)] +pub(crate) fn revoke_game_creator_user_selected_path(path: &Path) { + let Some(key) = normalize_user_selected_path_key(path) else { + return; + }; + user_selected_path_grants() + .lock() + .expect("user-selected path grants lock") + .remove(&key); +} + +#[cfg(windows)] +fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool { + let Some(key) = normalize_user_selected_path_key(path) else { + return false; + }; + let mut grants = user_selected_path_grants() + .lock() + .expect("user-selected path grants lock"); + let now = Instant::now(); + grants.retain(|_, grant| grant.expires_at > now); + grants.iter().any(|(granted_key, grant)| { + if granted_key == &key { + return grant.is_directory == is_directory; + } + grant.is_directory + && key + .strip_prefix(granted_key) + .is_some_and(|suffix| suffix.starts_with('\\')) + }) +} + pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1"; pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-5.6-sol"; @@ -678,6 +769,1068 @@ fn validate_game_creator_runtime_config_dir_metadata( Ok(()) } +/// Checks every already-existing path component without following links. +/// `canonicalize` alone is insufficient here because it resolves a junction +/// before the caller gets a chance to apply the owner/DACL policy. +pub(crate) fn validate_game_creator_private_path_ancestors( + path: &Path, + label: &str, +) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label}必须是绝对路径")); + } + for ancestor in path.ancestors().collect::>().into_iter().rev() { + match fs::symlink_metadata(ancestor) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(format!( + "{label} 路径不能包含符号链接:{}", + ancestor.display() + )); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 路径不能包含 Windows reparse point:{}", + ancestor.display() + )); + } + } + if ancestor != path && !metadata.is_dir() { + return Err(format!( + "{label} 父路径必须是普通目录:{}", + ancestor.display() + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 {label} 路径元数据失败:{}: {error}", + ancestor.display() + )); + } + } + } + Ok(()) +} + +/// Automatic ACL repair for managed paths is limited to objects AGC owns. A +/// separate, explicit user-selected scope below covers native picker/project +/// root results, including projects stored outside the current profile. +fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return false; + } + + let starts_with_path = |root: &Path| path == root || path.starts_with(root); + if game_creator_runtime_config_dir() + .as_deref() + .is_some_and(starts_with_path) + { + return true; + } + + if let Some(home) = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .filter(|candidate| candidate.is_absolute()) + { + let credentials_root = home.join(".config").join("genarrative"); + if starts_with_path(&credentials_root) { + return true; + } + + // The Tauri release uses this stable per-user AppData directory. The + // elevated helper runs in a fresh process, so the in-memory runtime + // config-dir override is unavailable there; recognize the packaged + // path from the user's profile as well. + let packaged_app_data = home + .join("AppData") + .join("Local") + .join("world.genarrative.ai-game-creator"); + if starts_with_path(&packaged_app_data) { + return true; + } + } + + for environment_name in ["LOCALAPPDATA", "APPDATA"] { + if let Some(root) = std::env::var_os(environment_name) + .map(PathBuf::from) + .filter(|candidate| candidate.is_absolute()) + { + if starts_with_path(&root.join("world.genarrative.ai-game-creator")) { + return true; + } + } + } + + // A bare `.agent` component is not enough to authorize ownership repair: + // an arbitrary user-selected path can contain a directory with that name. + // An existing AGC marker establishes the managed project root, after + // which every regular descendant (for example `game/index.html`) is + // covered by the same repair boundary. New projects use the explicit + // project-root preparation entry below until their marker is written. + // Nested or unrelated `.agent` directories remain outside the boundary. + let agent_components = path + .components() + .filter_map(|component| match component { + std::path::Component::Normal(name) + if name.to_string_lossy().eq_ignore_ascii_case(".agent") => + { + Some(name.to_os_string()) + } + _ => None, + }) + .collect::>(); + if agent_components.len() > 1 { + return false; + } + + for project_root in path.ancestors() { + let root_metadata = match fs::symlink_metadata(project_root) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + continue; + } + let agent_directory = project_root.join(".agent"); + let agent_metadata = match fs::symlink_metadata(&agent_directory) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + let manifest_path = agent_directory.join("manifest.json"); + let manifest_metadata = match fs::symlink_metadata(&manifest_path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if agent_metadata.file_type().is_symlink() + || !agent_metadata.is_dir() + || manifest_metadata.file_type().is_symlink() + || !manifest_metadata.is_file() + { + continue; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if root_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || agent_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || manifest_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + continue; + } + } + + if let Some(agent_name) = agent_components.first() { + let Some(actual_agent_directory) = path.ancestors().find(|candidate| { + candidate + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(".agent")) + }) else { + continue; + }; + if actual_agent_directory != &agent_directory + || actual_agent_directory + .file_name() + .map(|name| name != agent_name) + .unwrap_or(true) + { + continue; + } + } + return true; + } + false +} + +/// A file-picker/project-root result is an explicit native user action. Once +/// the caller has crossed that boundary, a regular object may be repaired by +/// the one-shot UAC helper even when it lives outside the current profile +/// (projects are commonly stored on another drive). The normal +/// reparse/regular-object/ancestor-type checks still run before this predicate +/// is consulted, so links, junctions and non-regular objects never become +/// elevation targets. +#[cfg(windows)] +fn game_creator_user_selected_path_allows_auto_elevation(path: &Path) -> bool { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return false; + } + // Never treat a drive/UNC root as a user file. This also prevents an + // unreadable ancestor walk from turning a picker selection into ownership + // repair of the entire volume root. A native picker can technically + // return Windows/Program Files paths, but taking ownership there would + // damage the operating system rather than repair an AGC user file. + if path.as_os_str().is_empty() || path.file_name().is_none() { + return false; + } + let normalized = path + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase(); + for variable in [ + "WINDIR", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMDATA", + "COMMONPROGRAMFILES", + "COMMONPROGRAMFILES(X86)", + ] { + let Some(root) = std::env::var_os(variable).map(PathBuf::from) else { + continue; + }; + let root = root + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase(); + if normalized == root || normalized.starts_with(&(root + "\\")) { + return false; + } + } + true +} + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WindowsAclRepairScope { + Managed, + UserSelected, +} + +#[cfg(windows)] +impl WindowsAclRepairScope { + fn wire_name(self) -> &'static str { + match self { + Self::Managed => "managed", + Self::UserSelected => "user-selected", + } + } + + fn allows_path(self, path: &Path) -> bool { + match self { + Self::Managed => game_creator_private_path_allows_auto_elevation(path), + Self::UserSelected => game_creator_user_selected_path_allows_auto_elevation(path), + } + } +} + +#[cfg(windows)] +pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result { + match value.trim() { + "managed" => Ok(WindowsAclRepairScope::Managed), + "user-selected" => Ok(WindowsAclRepairScope::UserSelected), + _ => Err("AGC ACL 修复 scope 无效".to_string()), + } +} + +#[cfg(windows)] +fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScope { + let is_builtin_root = |root: PathBuf| path == root || path.starts_with(root); + if let Some(home) = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .filter(|candidate| candidate.is_absolute()) + { + if is_builtin_root(home.join(".config").join("genarrative")) + || is_builtin_root( + home.join("AppData") + .join("Local") + .join("world.genarrative.ai-game-creator"), + ) + { + return WindowsAclRepairScope::Managed; + } + } + for environment_name in ["LOCALAPPDATA", "APPDATA"] { + if let Some(root) = std::env::var_os(environment_name) + .map(PathBuf::from) + .filter(|candidate| candidate.is_absolute()) + { + if is_builtin_root(root.join("world.genarrative.ai-game-creator")) { + return WindowsAclRepairScope::Managed; + } + } + } + WindowsAclRepairScope::UserSelected +} + +/// Prepares a caller-selected AGC project root. The root itself has no +/// `.agent/manifest.json` yet during first initialization, so it cannot use +/// the marker-based auto-elevation predicate above. This explicit entry is +/// only called by project initialization and the Runner, where the path has +/// already been accepted as the workspace root; descendants remain subject +/// to the marker-based managed-root check. +pub(crate) fn prepare_game_creator_project_root_for_read( + path: &Path, + is_directory: bool, + label: &str, +) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display()); + #[cfg(windows)] + { + // A selected project root may be unreadable before the leaf + // metadata can be inspected. Keep the same explicit + // user-selected scope and one-shot UAC path used after the + // metadata check, while rejecting all paths outside the + // current profile. + let scope = if game_creator_private_path_allows_auto_elevation(path) { + WindowsAclRepairScope::Managed + } else if user_selected_path_is_authorized(path, is_directory) + && game_creator_user_selected_path_allows_auto_elevation(path) + { + WindowsAclRepairScope::UserSelected + } else { + return Err(detail); + }; + if windows_acl_error_may_need_elevation(&detail) { + return secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + true, + scope, + ) + .map(|_| true) + .map_err(|repair_error| { + format!("{detail};自动提权修复未完成:{repair_error}") + }); + } + } + return Err(detail); + } + }; + if metadata.file_type().is_symlink() + || (is_directory && !metadata.is_dir()) + || (!is_directory && !metadata.is_file()) + { + return Err(format!( + "{label} 必须是普通{},不能是链接或其他对象:{}", + if is_directory { "目录" } else { "文件" }, + path.display() + )); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 不能是 Windows reparse point:{}", + path.display() + )); + } + // A project root is explicitly selected by the user before the AGC + // marker exists, so the managed-path predicate cannot identify it yet. + // Use the explicit user-selected scope for the root; this allows a + // project stored on another drive to be repaired while retaining the + // strict reparse/type checks above. + let scope = if game_creator_private_path_allows_auto_elevation(path) { + WindowsAclRepairScope::Managed + } else if user_selected_path_is_authorized(path, is_directory) + && game_creator_user_selected_path_allows_auto_elevation(path) + { + WindowsAclRepairScope::UserSelected + } else { + return secure_windows_game_creator_path_for_current_user(path, is_directory, true) + .map(|_| true); + }; + secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + true, + scope, + )?; + } + #[cfg(not(windows))] + { + validate_game_creator_private_path_ancestors(path, label)?; + } + Ok(true) +} + +#[cfg(windows)] +fn validate_game_creator_private_path_ancestors_with_auto_elevation( + path: &Path, + label: &str, +) -> Result<(), String> { + validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( + path, + label, + WindowsAclRepairScope::Managed, + ) +} + +#[cfg(windows)] +fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( + path: &Path, + label: &str, + scope: WindowsAclRepairScope, +) -> Result<(), String> { + #[cfg(test)] + { + return validate_game_creator_private_path_ancestors(path, label); + } + #[cfg(not(test))] + { + let target_user_sid = current_windows_token_user_sid_string()?; + let mut attempted_targets = Vec::::new(); + loop { + match validate_game_creator_private_path_ancestors(path, label) { + Ok(()) => return Ok(()), + Err(error) + if scope.allows_path(path) && windows_acl_error_may_need_elevation(&error) => + { + let repair_target = windows_acl_repair_target(path, scope); + if attempted_targets + .iter() + .any(|target| target == &repair_target) + { + return Err(format!( + "{error};自动提权修复重复命中同一目标,拒绝继续重试:{}", + repair_target.display() + )); + } + attempted_targets.push(repair_target); + attempt_elevated_windows_acl_repair(path, &target_user_sid, scope).map_err( + |repair_error| format!("{error};自动提权修复未完成:{repair_error}"), + )?; + } + Err(error) => return Err(error), + } + } + } +} + +/// Creates a private directory tree one component at a time. `create_dir_all` +/// can follow a junction that appears between components, so every existing +/// and newly-created component is checked before the next one is touched. +pub(crate) fn ensure_game_creator_private_directory_tree( + path: &Path, + label: &str, +) -> Result { + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation(path, label)?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, label)?; + let mut missing = Vec::new(); + let mut current = path.to_path_buf(); + loop { + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(format!("{label} 不能包含符号链接:{}", current.display())); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 不能包含 Windows reparse point:{}", + current.display() + )); + } + } + if !metadata.is_dir() { + return Err(format!( + "{label} 父路径必须是普通目录:{}", + current.display() + )); + } + break; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + missing.push(current.clone()); + current = current + .parent() + .ok_or_else(|| { + format!("{label} 没有可用于创建的现存父目录:{}", path.display()) + })? + .to_path_buf(); + } + Err(error) => { + return Err(format!( + "读取 {label} 元数据失败:{}: {error}", + current.display() + )); + } + } + } + + let created_target = missing.first().is_some_and(|created| created == path); + for directory in missing.into_iter().rev() { + let create_result = fs::create_dir(&directory); + match create_result { + Ok(()) => { + #[cfg(windows)] + if game_creator_private_path_allows_auto_elevation(&directory) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &directory, true, true, + )?; + } else { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + &directory, true, true, true, + )?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).map_err( + |error| format!("收紧 {label} 权限失败:{}: {error}", directory.display()), + )?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + // Another process won the create race. This object was not + // created by the current invocation, so it must go through + // the full existing-object owner/DACL gate before we touch + // any descendant. A type-only metadata check here would allow + // an attacker-created directory to become trusted. + prepare_game_creator_private_path_for_read(&directory, true, label)?; + } + #[cfg(windows)] + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) || matches!(error.raw_os_error(), Some(5)) => + { + let parent = directory.parent().ok_or_else(|| { + format!("创建 {label} 失败:{}: {error}", directory.display()) + })?; + if game_creator_private_path_allows_auto_elevation(parent) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + parent, true, true, + )?; + } else { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + parent, true, true, true, + )?; + } + fs::create_dir(&directory).map_err(|retry_error| { + format!("创建 {label} 失败:{}: {retry_error}", directory.display()) + })?; + if game_creator_private_path_allows_auto_elevation(&directory) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &directory, true, true, + )?; + } else { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + &directory, true, true, true, + )?; + } + } + Err(error) => { + return Err(format!( + "创建 {label} 失败:{}: {error}", + directory.display() + )); + } + } + let metadata = fs::symlink_metadata(&directory) + .map_err(|error| format!("复核 {label} 失败:{}: {error}", directory.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} 必须是普通目录:{}", directory.display())); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 不能是 Windows reparse point:{}", + directory.display() + )); + } + } + } + Ok(created_target) +} + +/// Hardens a directory or file that this invocation has just created. This +/// intentionally does not adopt an existing object: callers that discover an +/// existing path must go through `prepare_game_creator_private_path_for_read`, +/// which performs the strict owner/reparse/DACL checks and the controlled UAC +/// repair flow. Keeping the two paths separate prevents a race from turning +/// an attacker-owned object into an AGC-managed credential or sidecar file. +pub(crate) fn harden_new_game_creator_private_path( + path: &Path, + is_directory: bool, + label: &str, +) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label}必须是绝对路径")); + } + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取新建 {label} 元数据失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() + || (is_directory && !metadata.is_dir()) + || (!is_directory && !metadata.is_file()) + { + return Err(format!( + "新建 {label} 必须是普通{},不能是链接或其他对象:{}", + if is_directory { "目录" } else { "文件" }, + path.display() + )); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "新建 {label} 不能是 Windows reparse point:{}", + path.display() + )); + } + // A newly-created object normally inherits the creator's security + // descriptor. AGC-owned roots may request the one-shot UAC repair; + // a user-selected path is still hardened strictly after creation so + // a race cannot turn an attacker-owned object into a credential file. + if game_creator_private_path_allows_auto_elevation(path) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, + is_directory, + true, + )?; + } else { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + )?; + } + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + path, + fs::Permissions::from_mode(if is_directory { 0o700 } else { 0o600 }), + ) + .map_err(|error| format!("收紧新建 {label} 权限失败:{}: {error}", path.display()))?; + } + Ok(()) +} + +/// Writes a regular AGC-managed file only after its parent and any existing +/// target have passed the private-path policy. The post-write check also +/// hardens a newly-created file, so inherited Windows ACLs cannot remain on a +/// file that was created by this process. +pub(crate) fn write_game_creator_private_file( + path: &Path, + bytes: &[u8], + label: &str, +) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label}必须是绝对路径")); + } + let parent = path.parent().ok_or_else(|| format!("{label}缺少父目录"))?; + ensure_game_creator_private_directory_tree(parent, label)?; + prepare_game_creator_private_path_for_read(parent, true, label)?; + let existed = prepare_game_creator_private_path_for_read(path, false, label)?; + + // Never write directly through the checked pathname. A pathname can be + // replaced after the preflight by another process (or by a junction/link + // attack). Create and harden a sibling temporary inode first, then link + // it into place without replacement. Existing targets are moved to a + // unique backup only after a second strict check; if installation fails, + // the original target is restored. + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("{label}文件名无效"))?; + let temporary = (0..8) + .map(|attempt| { + path.with_file_name(format!( + ".{file_name}.tmp-{}-{}-{attempt}", + std::process::id(), + uuid::Uuid::new_v4().simple() + )) + }) + .find(|candidate| { + matches!( + fs::symlink_metadata(candidate), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) + }) + .ok_or_else(|| format!("创建 {label} 临时文件路径失败:目录中存在冲突残留"))?; + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options.open(&temporary).map_err(|error| { + format!( + "创建 {label} 临时文件失败:{}: {error}", + temporary.display() + ) + })?; + if let Err(error) = harden_new_game_creator_private_path(&temporary, false, label) { + drop(file); + let _ = fs::remove_file(&temporary); + return Err(error); + } + let write_result = std::io::Write::write_all(&mut file, bytes).and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = fs::remove_file(&temporary); + return Err(format!( + "写入 {label} 临时文件失败:{}: {error}", + temporary.display() + )); + } + let backup = path.with_file_name(format!( + ".{file_name}.previous-{}-{}", + std::process::id(), + uuid::Uuid::new_v4().simple() + )); + if existed { + // Re-check immediately before moving the old target. Links and + // reparse points remain fail-closed; only a regular managed file may + // enter the recoverable replacement path. + if let Err(error) = prepare_game_creator_private_path_for_read(path, false, label) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + if fs::symlink_metadata(&backup).is_ok() { + let _ = fs::remove_file(&temporary); + return Err(format!("{label}替换备份路径已存在,已拒绝覆盖")); + } + if let Err(error) = fs::rename(path, &backup) { + let _ = fs::remove_file(&temporary); + return Err(format!("准备替换 {label} 失败:{error}")); + } + } + + let install_result = fs::hard_link(&temporary, path).and_then(|_| fs::remove_file(&temporary)); + if let Err(error) = install_result { + let _ = fs::remove_file(&temporary); + if existed { + let _ = fs::rename(&backup, path); + } + return Err(format!("原子安装 {label} 失败:{error}")); + } + if let Err(error) = prepare_game_creator_private_path_for_read(path, false, label) { + if existed { + let _ = fs::remove_file(path); + let _ = fs::rename(&backup, path); + } else { + let _ = fs::remove_file(path); + } + return Err(format!("复核 {label} 失败:{error}")); + } + if existed { + fs::remove_file(&backup) + .map_err(|error| format!("回收 {label} 旧文件备份失败:{error}"))?; + } + Ok(()) +} + +/// Appends to a regular AGC-managed file while applying the same ACL policy +/// as replacement writes. This is used for human-readable logs and memory +/// journals whose append semantics are part of their existing contract. +pub(crate) fn append_game_creator_private_file( + path: &Path, + bytes: &[u8], + label: &str, +) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label}必须是绝对路径")); + } + let parent = path.parent().ok_or_else(|| format!("{label}缺少父目录"))?; + ensure_game_creator_private_directory_tree(parent, label)?; + prepare_game_creator_private_path_for_read(parent, true, label)?; + let existed = prepare_game_creator_private_path_for_read(path, false, label)?; + if existed { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取 {label} 元数据失败:{}: {error}", path.display()))?; + if !metadata.is_file() { + return Err(format!("{label} 必须是普通文件")); + } + } + let mut options = fs::OpenOptions::new(); + options.write(true).append(true).read(true); + if !existed { + options.create_new(true); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + options + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + } + let mut file = options + .open(path) + .map_err(|error| format!("写入 {label} 失败:{}: {error}", path.display()))?; + let opened_metadata = file.metadata().map_err(|error| { + format!( + "读取 {label} 文件句柄元数据失败:{}: {error}", + path.display() + ) + })?; + if !opened_metadata.is_file() { + drop(file); + return Err(format!("{label} 必须是普通文件")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if opened_metadata.nlink() != 1 { + drop(file); + return Err(format!("{label} 不能是硬链接")); + } + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("复核 {label} 路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != opened_metadata.dev() + || path_metadata.ino() != opened_metadata.ino() + { + drop(file); + return Err(format!("{label} 路径在安全打开期间发生替换")); + } + } + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, label)?; + if !existed { + if let Err(error) = harden_new_game_creator_private_path(path, false, label) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + } + use std::io::Write as _; + file.write_all(bytes) + .and_then(|_| file.sync_data()) + .map_err(|error| format!("写入 {label} 失败:{}: {error}", path.display()))?; + if existed { + prepare_game_creator_private_path_for_read(path, false, label)?; + } + Ok(()) +} + +/// Checks a private file/directory before the caller opens it. Windows ACL +/// failures must be handled before `OpenOptions::open`: an inherited DACL can +/// otherwise make the open fail before the strict verifier gets a chance to +/// request UAC repair. Missing leaves are returned as `false`; existing +/// objects are fully validated and, on Windows, repaired/re-validated through +/// the normal auto-elevation path. +pub(crate) fn prepare_game_creator_private_path_for_read( + path: &Path, + is_directory: bool, + label: &str, +) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display()); + #[cfg(windows)] + { + let result = if game_creator_private_path_allows_auto_elevation(path) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, + is_directory, + true, + ) + } else { + secure_windows_game_creator_path_for_current_user(path, is_directory, true) + }; + return result.map(|_| true).map_err(|repair_error| { + if game_creator_private_path_allows_auto_elevation(path) { + format!("{detail};自动提权修复未完成:{repair_error}") + } else { + format!("{detail};严格私有权限校验未通过:{repair_error}") + } + }); + } + #[cfg(not(windows))] + return Err(detail); + } + }; + if metadata.file_type().is_symlink() + || (is_directory && !metadata.is_dir()) + || (!is_directory && !metadata.is_file()) + { + return Err(format!( + "{label} 必须是普通{},不能是链接或其他对象:{}", + if is_directory { "目录" } else { "文件" }, + path.display() + )); + } + // Metadata on the leaf can be readable while traversal of an ancestor is + // blocked by a foreign owner or inherited ACL. Give the managed object a + // single explicit repair opportunity before surfacing a permission error; + // links/reparse points have already been rejected above and therefore stay + // fail-closed. + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation(path, label)?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, label)?; + #[cfg(windows)] + if game_creator_private_path_allows_auto_elevation(path) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, + is_directory, + true, + )?; + } else { + // User-selected external files are never silently adopted. Keep the + // strict owner/DACL check, but do not escalate an arbitrary path. + secure_windows_game_creator_path_for_current_user(path, is_directory, true)?; + } + Ok(true) +} + +/// Prepares a path returned by an explicit native file picker. On Windows, +/// owner/DACL failures on a regular selected object receive the same one-shot +/// UAC repair as AGC-managed files, including projects stored outside the +/// current profile. Reparse points, links, non-regular objects and ancestor +/// type conflicts are rejected before any repair attempt. +pub(crate) fn prepare_game_creator_user_selected_path_for_read( + path: &Path, + is_directory: bool, + label: &str, +) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display()); + #[cfg(windows)] + { + // Explicit native file-picker/project-root selections are + // bounded UAC targets even when the normal token cannot + // traverse far enough to read leaf metadata. The helper still + // re-validates owner, type and reparse state before changing + // anything. + if user_selected_path_is_authorized(path, is_directory) + && game_creator_user_selected_path_allows_auto_elevation(path) + && windows_acl_error_may_need_elevation(&detail) + { + return secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + true, + WindowsAclRepairScope::UserSelected, + ) + .map(|_| true) + .map_err(|repair_error| { + format!("{detail};自动提权修复未完成:{repair_error}") + }); + } + } + return Err(detail); + } + }; + if metadata.file_type().is_symlink() + || (is_directory && !metadata.is_dir()) + || (!is_directory && !metadata.is_file()) + { + return Err(format!( + "{label} 必须是普通{},不能是链接或其他对象:{}", + if is_directory { "目录" } else { "文件" }, + path.display() + )); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 不能是 Windows reparse point:{}", + path.display() + )); + } + if user_selected_path_is_authorized(path, is_directory) + && game_creator_user_selected_path_allows_auto_elevation(path) + { + validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( + path, + label, + WindowsAclRepairScope::UserSelected, + )?; + secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + true, + WindowsAclRepairScope::UserSelected, + )?; + } else { + validate_game_creator_private_path_ancestors(path, label)?; + secure_windows_game_creator_path_for_current_user(path, is_directory, true)?; + } + } + #[cfg(not(windows))] + { + validate_game_creator_private_path_ancestors(path, label)?; + } + Ok(true) +} + +/// Reads an AGC-managed private text file through a validated regular-file +/// handle. The pathname is used only for preflight/open identity checks; bytes +/// are read from the handle so a later rename cannot redirect the read. +pub(crate) fn read_game_creator_private_file_to_string( + path: &Path, + label: &str, + max_bytes: u64, +) -> Result { + let (mut file, metadata) = open_project_private_regular_file(path, label)?; + if metadata.len() > max_bytes { + return Err(format!("{label}过大,已拒绝读取:{}", path.display())); + } + let mut content = String::with_capacity(metadata.len() as usize); + file.read_to_string(&mut content) + .map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?; + let final_metadata = file + .metadata() + .map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?; + if final_metadata.len() != metadata.len() { + return Err(format!("{label}读取期间文件发生漂移:{}", path.display())); + } + Ok(content) +} + fn resolve_game_creator_runtime_config_dir( path: &Path, create_and_tighten: bool, @@ -685,18 +1838,24 @@ fn resolve_game_creator_runtime_config_dir( if !path.is_absolute() { return Err("客户端 AppData 配置目录必须是绝对路径".to_string()); } + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( + path, + "客户端 AppData 配置目录", + game_creator_runtime_config_repair_scope(path), + )?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, "客户端 AppData 配置目录")?; let mut created = false; if create_and_tighten { match fs::symlink_metadata(path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|create_error| { - format!( - "创建客户端 AppData 配置父目录失败:{}: {create_error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree( + parent, + "客户端 AppData 配置父目录", + )?; } match fs::create_dir(path) { Ok(()) => created = true, @@ -721,18 +1880,71 @@ fn resolve_game_creator_runtime_config_dir( } // canonicalize 会跟随目录链接,因此必须先检查用户给出的目录项本身。 validate_game_creator_runtime_config_dir_entry_type(path)?; - let canonical = fs::canonicalize(path).map_err(|error| { - format!( - "解析客户端 AppData 配置目录失败:{}: {error}", - path.display() - ) - })?; + let canonical = match fs::canonicalize(path) { + Ok(canonical) => canonical, + Err(error) => { + let detail = format!( + "解析客户端 AppData 配置目录失败:{}: {error}", + path.display() + ); + #[cfg(windows)] + if !cfg!(test) + && windows_acl_error_may_need_elevation(&detail) + && game_creator_private_path_allows_auto_elevation(path) + { + // canonicalize follows the directory and therefore can fail + // with access denied before the normal owner/DACL verifier is + // reached. Repair the managed target first, then canonicalize + // again so a denied config directory gets the same one-shot + // UAC treatment as an already-readable object. + let target_user_sid = current_windows_token_user_sid_string() + .map_err(|sid_error| format!("{detail};读取当前用户 SID 失败:{sid_error}"))?; + attempt_elevated_windows_acl_repair( + path, + &target_user_sid, + game_creator_runtime_config_repair_scope(path), + ) + .map_err(|repair_error| format!("{detail};自动提权修复未完成:{repair_error}"))?; + validate_game_creator_runtime_config_dir_metadata(path, true, false)?; + return fs::canonicalize(path).map_err(|canonicalize_error| { + format!( + "ACL 修复后解析客户端 AppData 配置目录失败:{}: {canonicalize_error}", + path.display() + ) + }); + } + return Err(detail); + } + }; match validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten, created) { Ok(()) => {} #[cfg(windows)] + Err(error) if !cfg!(test) && windows_acl_error_may_need_elevation(&error) => { + // Any existing private AppData directory that cannot be safely + // read is repaired through the one-shot elevated helper. This + // covers inherited ACLs and foreign owners while the earlier + // entry/reparse checks remain fail-closed. + let target_user_sid = current_windows_token_user_sid_string() + .map_err(|sid_error| format!("{error};读取当前用户 SID 失败:{sid_error}"))?; + attempt_elevated_windows_acl_repair( + path, + &target_user_sid, + game_creator_runtime_config_repair_scope(path), + ) + .map_err(|repair_error| format!("{error};自动提权修复未完成:{repair_error}"))?; + validate_game_creator_runtime_config_dir_metadata(path, true, false)?; + return fs::canonicalize(path).map_err(|canonicalize_error| { + format!( + "ACL 修复后解析客户端 AppData 配置目录失败:{}: {canonicalize_error}", + path.display() + ) + }); + } + #[cfg(windows)] Err(error) - if create_and_tighten + if cfg!(test) + && create_and_tighten && !created && error.starts_with("Windows 安全对象不属于当前用户:") => { @@ -860,14 +2072,102 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( is_directory: bool, tighten: bool, ) -> Result<(), String> { - secure_windows_game_creator_path_for_current_user_with_owner_policy( + secure_windows_game_creator_path_for_user_sid_with_owner_policy( path, is_directory, tighten, false, + None, ) } +/// Strictly validates a Windows private object and, when its owner/DACL cannot +/// be used by the current account, performs one explicit UAC repair before +/// validating again. Reparse points and non-regular objects remain +/// fail-closed; a repaired regular object is reassigned to the current token +/// user and receives a private non-inherited DACL. +#[cfg(windows)] +pub(crate) fn secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path: &Path, + is_directory: bool, + tighten: bool, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + tighten, + WindowsAclRepairScope::Managed, + ) +} + +/// Same strict verifier as the managed-path entry, but with an explicit scope +/// selected by the caller. This is used by file-picker imports only; the +/// scope is included in the one-shot UAC ticket and checked again by the +/// elevated child process. +#[cfg(windows)] +fn secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path: &Path, + is_directory: bool, + tighten: bool, + scope: WindowsAclRepairScope, +) -> Result<(), String> { + #[cfg(test)] + { + // Unit tests must not trigger an interactive UAC prompt. The strict + // verifier remains directly testable. For an owner-correct object + // whose only defect is an inherited DACL, emulate the formal prepare + // entry's local tightening in-process; foreign-owner fixtures still + // fail closed because they cannot be reassigned without elevation. + return match secure_windows_game_creator_path_for_current_user(path, is_directory, tighten) + { + Ok(()) => Ok(()), + Err(error) + if scope.allows_path(path) + && windows_acl_error_may_need_elevation(&error) + && !error.contains("安全对象不属于当前用户") => + { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + false, + ) + } + Err(error) => Err(error), + }; + } + #[cfg(not(test))] + { + let target_user_sid = current_windows_token_user_sid_string()?; + let mut attempted_targets = Vec::::new(); + loop { + match secure_windows_game_creator_path_for_current_user(path, is_directory, tighten) { + Ok(()) => return Ok(()), + Err(error) if windows_acl_error_may_need_elevation(&error) => { + if !scope.allows_path(path) { + return Err(error); + } + let repair_target = windows_acl_repair_target(path, scope); + if attempted_targets + .iter() + .any(|target| target == &repair_target) + { + return Err(format!( + "{error};自动提权修复重复命中同一目标,拒绝继续重试:{}", + repair_target.display() + )); + } + attempted_targets.push(repair_target); + attempt_elevated_windows_acl_repair(path, &target_user_sid, scope).map_err( + |repair_error| format!("{error};自动提权修复未完成:{repair_error}"), + )?; + } + Err(error) => return Err(error), + } + } + } +} + #[cfg(windows)] pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( path: &Path, @@ -885,6 +2185,451 @@ pub(crate) fn initialize_windows_game_creator_directory_owner_for_current_user( secure_windows_game_creator_path_for_current_user_with_owner_policy(path, true, true, true) } +/// Repairs an AGC-managed private object after an explicit UAC elevation. +/// Foreign-owned regular files/directories are deliberately reassigned to the +/// current token user here. The caller has already rejected links/reparse +/// points, and the final strict verification below is mandatory. +#[cfg(windows)] +pub(crate) fn repair_game_creator_private_acl_for_current_user(path: &Path) -> Result<(), String> { + let target_user_sid = current_windows_token_user_sid_string()?; + repair_game_creator_private_acl_for_user_sid(path, &target_user_sid) +} + +#[cfg(windows)] +fn current_windows_token_user_sid_string() -> Result { + use std::ffi::c_void; + + type Handle = *mut c_void; + type Sid = *mut c_void; + + #[repr(C)] + struct SidAndAttributes { + sid: Sid, + attributes: u32, + } + + #[repr(C)] + struct TokenUser { + user: SidAndAttributes, + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32; + fn GetTokenInformation( + token: Handle, + information_class: u32, + information: *mut c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + fn ConvertSidToStringSidW(sid: Sid, string_sid: *mut *mut u16) -> i32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Handle; + fn CloseHandle(handle: Handle) -> i32; + fn LocalFree(memory: *mut c_void) -> *mut c_void; + } + + const TOKEN_QUERY: u32 = 0x0000_0008; + const TOKEN_USER_CLASS: u32 = 1; + + let mut token = std::ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 + || token.is_null() + { + return Err(format!( + "读取 Windows 当前用户 token 失败:{}", + std::io::Error::last_os_error() + )); + } + let result = (|| { + let mut required = 0_u32; + unsafe { + GetTokenInformation( + token, + TOKEN_USER_CLASS, + std::ptr::null_mut(), + 0, + &mut required, + ); + } + if required == 0 { + return Err("读取 Windows 当前用户 SID 长度失败".to_string()); + } + let word_size = std::mem::size_of::(); + let mut token_buffer = vec![0_usize; (required as usize).div_ceil(word_size)]; + if unsafe { + GetTokenInformation( + token, + TOKEN_USER_CLASS, + token_buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(format!( + "读取 Windows 当前用户 SID 失败:{}", + std::io::Error::last_os_error() + )); + } + let sid = unsafe { (*(token_buffer.as_ptr().cast::())).user.sid }; + if sid.is_null() { + return Err("Windows 当前用户 SID 无效".to_string()); + } + let mut string_sid = std::ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } == 0 || string_sid.is_null() { + return Err(format!( + "转换 Windows 当前用户 SID 失败:{}", + std::io::Error::last_os_error() + )); + } + let mut length = 0_usize; + while unsafe { *string_sid.add(length) } != 0 { + length = length.saturating_add(1); + if length > 256 { + unsafe { LocalFree(string_sid.cast()) }; + return Err("Windows 当前用户 SID 长度无效".to_string()); + } + } + let value = String::from_utf16(unsafe { std::slice::from_raw_parts(string_sid, length) }) + .map_err(|_| "Windows 当前用户 SID 编码无效".to_string()); + unsafe { LocalFree(string_sid.cast()) }; + value + })(); + unsafe { CloseHandle(token) }; + result +} + +/// The elevated helper may run under administrator credentials that differ +/// from the original desktop user's token. Keep ownership and the private +/// DACL bound to the original TokenUser SID passed by the caller. +#[cfg(windows)] +pub(crate) fn repair_game_creator_private_acl_for_user_sid( + path: &Path, + target_user_sid: &str, +) -> Result<(), String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取待修复私有对象失败:{}: {error}", path.display()))?; + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || metadata.file_type().is_symlink() + { + return Err("待修复私有对象不能是 Windows reparse point 或链接".to_string()); + } + if !metadata.is_dir() && !metadata.is_file() { + return Err("待修复私有对象必须是普通文件或目录".to_string()); + } + secure_windows_game_creator_path_for_user_sid_with_owner_policy( + path, + metadata.is_dir(), + true, + true, + Some(target_user_sid), + ) +} + +#[cfg(windows)] +pub(crate) fn windows_acl_error_may_need_elevation(error: &str) -> bool { + (error.contains("DACL") + || error.contains("权限") + || error.contains("error 5") + || error.contains("安全对象不属于当前用户") + || error.contains("启用 Windows") + || error.contains("特权") + || error.contains("1300")) + && !error.contains("链接") + && !error.contains("reparse") +} + +#[cfg(windows)] +fn windows_acl_repair_target(path: &Path, scope: WindowsAclRepairScope) -> PathBuf { + // A user-selected object is the only trusted identity in that scope. Do + // not widen it to an unreadable parent (which could be another user's + // profile or a protected system directory); the helper will validate the + // selected regular object itself and fail closed if traversal remains + // impossible. Managed paths have an established AGC root and may repair + // the first blocked ancestor so inherited ACLs can be fixed in one pass. + if matches!(scope, WindowsAclRepairScope::UserSelected) { + return path.to_path_buf(); + } + // Walk from the filesystem root towards the leaf. If traversal is denied + // on an ancestor, repairing the leaf cannot help because the elevated + // helper will hit the same ancestor before it can inspect the leaf. + for ancestor in path.ancestors().collect::>().into_iter().rev() { + match fs::symlink_metadata(ancestor) { + Ok(_) => {} + Err(error) + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(5) => + { + return ancestor.to_path_buf(); + } + Err(_) => {} + } + } + path.to_path_buf() +} + +#[cfg(windows)] +const WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES: u64 = 4 * 1024; + +#[cfg(windows)] +struct WindowsAclRepairAuthorizationCleanup { + path: PathBuf, + armed: bool, +} + +#[cfg(windows)] +impl WindowsAclRepairAuthorizationCleanup { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +#[cfg(windows)] +impl Drop for WindowsAclRepairAuthorizationCleanup { + fn drop(&mut self) { + if self.armed { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(windows)] +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct WindowsAclRepairAuthorization { + path: String, + target_user_sid: String, + scope: String, + issued_at: u64, +} + +#[cfg(windows)] +fn windows_acl_repair_authorization_path(nonce: &str) -> Result { + let nonce = nonce.trim(); + if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("AGC ACL 修复授权票据无效".to_string()); + } + let temporary_directory = std::env::temp_dir(); + if !temporary_directory.is_absolute() { + return Err("AGC ACL 修复临时目录必须是绝对路径".to_string()); + } + Ok(temporary_directory.join(format!(".genarrative-acl-repair-{nonce}.json"))) +} + +#[cfg(windows)] +fn create_windows_acl_repair_authorization( + path: &Path, + target_user_sid: &str, + scope: WindowsAclRepairScope, +) -> Result { + let nonce = uuid::Uuid::new_v4().simple().to_string(); + let authorization_path = windows_acl_repair_authorization_path(&nonce)?; + let payload = serde_json::to_vec(&WindowsAclRepairAuthorization { + path: path.to_string_lossy().into_owned(), + target_user_sid: target_user_sid.to_string(), + scope: scope.wire_name().to_string(), + issued_at: unix_timestamp(), + }) + .map_err(|error| format!("创建 AGC ACL 修复授权票据失败:{error}"))?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options.open(&authorization_path).map_err(|error| { + format!( + "创建 AGC ACL 修复授权票据文件失败:{}: {error}", + authorization_path.display() + ) + })?; + // The ticket is created in the per-user TEMP directory while the normal + // process is not elevated. Do not call the auto-elevation wrapper here: + // that wrapper itself creates a ticket and would recurse indefinitely when + // TEMP inherits a broad DACL. The strict owner-policy path can still + // tighten an owner-correct inherited ACL and rejects a foreign TEMP owner. + if let Err(error) = secure_windows_game_creator_path_for_current_user_with_owner_policy( + &authorization_path, + false, + true, + true, + ) { + drop(file); + let _ = fs::remove_file(&authorization_path); + return Err(format!("初始化 AGC ACL 修复授权票据安全权限失败:{error}")); + } + if let Err(error) = file.write_all(&payload).and_then(|_| file.sync_all()) { + drop(file); + let _ = fs::remove_file(&authorization_path); + return Err(format!("写入 AGC ACL 修复授权票据失败:{error}")); + } + drop(file); + Ok(nonce) +} + +#[cfg(windows)] +pub(crate) fn consume_windows_acl_repair_authorization( + path: &Path, + target_user_sid: &str, + nonce: &str, + scope: WindowsAclRepairScope, +) -> Result<(), String> { + if !scope.allows_path(path) { + return Err(format!( + "AGC ACL 修复目标不在当前用户允许的 {} 范围内:{}", + scope.wire_name(), + path.display() + )); + } + let authorization_path = windows_acl_repair_authorization_path(nonce)?; + let mut cleanup = WindowsAclRepairAuthorizationCleanup::new(authorization_path.clone()); + + // Open the ticket before validating its contents. The security helper + // below uses GetNamedSecurityInfoW/SetNamedSecurityInfoW by pathname; an + // exclusive handle would make those calls fail with a sharing violation + // on Windows. Keep the ticket in the per-user TEMP directory with a + // hardened owner-only DACL, then validate the opened handle's metadata and + // exact payload before consuming the one-shot file. + use std::os::windows::fs::MetadataExt; + use std::os::windows::fs::OpenOptionsExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + use std::io::Read as _; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let mut file = fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&authorization_path) + .map_err(|error| format!("读取 AGC ACL 修复授权票据内容失败:{error}"))?; + crate::runner::validate_windows_regular_file_handle(&file, "AGC ACL 修复授权票据")?; + let opened_metadata = file + .metadata() + .map_err(|error| format!("读取 AGC ACL 修复授权票据句柄元数据失败:{error}"))?; + if opened_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || !opened_metadata.is_file() + { + return Err("AGC ACL 修复授权票据必须是普通文件".to_string()); + } + if opened_metadata.len() > WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES { + return Err("AGC ACL 修复授权票据过大".to_string()); + } + // The elevated helper runs under an administrator token, so validate the + // ticket against the original desktop user's SID rather than the helper's + // current SID. This also rejects a ticket placed in TEMP by another user. + // This check deliberately happens after opening the ticket handle so the + // payload is tied to a real regular file before it is authorized. + secure_windows_game_creator_path_for_user_sid_with_owner_policy( + &authorization_path, + false, + true, + false, + Some(target_user_sid), + )?; + + let mut content = String::new(); + file.read_to_string(&mut content) + .map_err(|error| format!("读取 AGC ACL 修复授权票据内容失败:{error}"))?; + let final_metadata = file + .metadata() + .map_err(|error| format!("复核 AGC ACL 修复授权票据句柄元数据失败:{error}"))?; + if final_metadata.len() != opened_metadata.len() { + return Err("AGC ACL 修复授权票据读取期间发生漂移".to_string()); + } + let authorization = serde_json::from_str::(&content) + .map_err(|error| format!("AGC ACL 修复授权票据格式无效:{error}"))?; + if unix_timestamp().saturating_sub(authorization.issued_at) > 300 { + return Err("AGC ACL 修复授权票据已过期".to_string()); + } + if authorization.path != path.to_string_lossy() + || authorization.target_user_sid != target_user_sid + || authorization.scope != scope.wire_name() + { + return Err("AGC ACL 修复授权票据与目标不匹配".to_string()); + } + + // Release the read handle before consuming the one-shot file. The cleanup + // guard retries removal on every failure path, while a successful removal + // disarms it to avoid a second delete attempt during unwinding. + drop(file); + fs::remove_file(&authorization_path) + .map_err(|error| format!("删除 AGC ACL 修复授权票据失败:{error}"))?; + cleanup.disarm(); + Ok(()) +} + +/// Starts a one-shot elevated copy of the current executable. The elevated +/// process performs only the allow-listed ACL repair command and exits with a +/// truthful status; UAC cancellation is never treated as success. +#[cfg(windows)] +fn attempt_elevated_windows_acl_repair( + path: &Path, + target_user_sid: &str, + scope: WindowsAclRepairScope, +) -> Result<(), String> { + if !scope.allows_path(path) { + return Err(format!( + "AGC ACL 提权目标不在当前用户允许的 {} 范围内:{}", + scope.wire_name(), + path.display() + )); + } + let executable = + std::env::current_exe().map_err(|error| format!("定位 AGC ACL 修复程序失败:{error}"))?; + if !executable.is_file() { + return Err("AGC ACL 修复程序不存在".to_string()); + } + let repair_path = windows_acl_repair_target(path, scope); + let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?; + let escaped_executable = executable.to_string_lossy().replace('\'', "''"); + let escaped_path = repair_path.to_string_lossy().replace('\'', "''"); + let escaped_target_user_sid = target_user_sid.replace('\'', "''"); + let escaped_nonce = nonce.replace('\'', "''"); + let script = format!( + "$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList @('--repair-private-acl','{escaped_path}','--target-user-sid','{escaped_target_user_sid}','--authorization','{escaped_nonce}','--scope','{}'); if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}", + scope.wire_name() + ); + use std::os::windows::process::CommandExt; + let status = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + script.as_str(), + ]) + .creation_flags(0x0800_0000) + .status() + .map_err(|error| format!("启动 AGC ACL 提权修复失败:{error}")); + let _ = windows_acl_repair_authorization_path(&nonce).and_then(|authorization_path| { + fs::remove_file(authorization_path).map_err(|error| error.to_string()) + }); + let status = status?; + if status.success() { + Ok(()) + } else { + Err(format!( + "AGC ACL 提权修复未成功(exit code {:?})", + status.code() + )) + } +} + #[cfg(windows)] pub(crate) fn windows_private_dacl_security_information( initialize_owner: bool, @@ -901,11 +2646,26 @@ pub(crate) fn windows_private_dacl_security_information( } else { 0 } - | if initialize_owner && !owner_matches { - OWNER_SECURITY_INFORMATION - } else { - 0 - } +} + +#[cfg(windows)] +fn windows_security_object_path(path: &Path) -> std::ffi::OsString { + use std::ffi::OsString; + + let raw = path.as_os_str().to_string_lossy(); + // GetNamedSecurityInfoW/SetNamedSecurityInfoW report ERROR_INVALID_NAME + // for ordinary absolute paths at the MAX_PATH boundary. Extended-length + // paths are accepted by these APIs and preserve the exact object identity. + // Do not add the prefix twice, and translate UNC paths to the documented + // \\?\UNC\server\share form. + if raw.starts_with("\\\\?\\") || raw.encode_utf16().count() < 260 { + return path.as_os_str().to_os_string(); + } + if let Some(unc) = raw.strip_prefix("\\\\") { + OsString::from(format!("\\\\?\\UNC\\{unc}")) + } else { + OsString::from(format!("\\\\?\\{raw}")) + } } #[cfg(windows)] @@ -914,10 +2674,57 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( is_directory: bool, tighten: bool, initialize_owner: bool, +) -> Result<(), String> { + secure_windows_game_creator_path_for_user_sid_with_owner_policy( + path, + is_directory, + tighten, + initialize_owner, + None, + ) +} + +#[cfg(windows)] +fn secure_windows_game_creator_path_for_user_sid_with_owner_policy( + path: &Path, + is_directory: bool, + tighten: bool, + initialize_owner: bool, + target_user_sid: Option<&str>, ) -> Result<(), String> { use std::ffi::c_void; use std::os::windows::ffi::OsStrExt; + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "读取 Windows 私有对象元数据失败:{}: {error}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Windows 私有对象不能是符号链接:{}", + path.display() + )); + } + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "Windows 私有对象不能是 reparse point:{}", + path.display() + )); + } + } + if (is_directory && !metadata.is_dir()) || (!is_directory && !metadata.is_file()) { + return Err(format!( + "Windows 私有对象类型不符合预期:{}", + path.display() + )); + } + validate_game_creator_private_path_ancestors(path, "Windows 私有对象")?; + type Handle = *mut c_void; type Sid = *mut c_void; @@ -972,6 +2779,24 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( sid_start: u32, } + #[repr(C)] + struct Luid { + low_part: u32, + high_part: i32, + } + + #[repr(C)] + struct LuidAndAttributes { + luid: Luid, + attributes: u32, + } + + #[repr(C)] + struct TokenPrivileges { + privilege_count: u32, + privileges: [LuidAndAttributes; 1], + } + #[link(name = "advapi32")] unsafe extern "system" { fn GetNamedSecurityInfoW( @@ -1000,6 +2825,16 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( new_acl: *mut *mut c_void, ) -> u32; fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32; + fn LookupPrivilegeValueW(system_name: *const u16, name: *const u16, luid: *mut Luid) + -> i32; + fn AdjustTokenPrivileges( + token: Handle, + disable_all_privileges: i32, + new_state: *mut TokenPrivileges, + buffer_length: u32, + previous_state: *mut TokenPrivileges, + return_length: *mut u32, + ) -> i32; fn GetTokenInformation( token: Handle, information_class: u32, @@ -1007,8 +2842,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( information_length: u32, return_length: *mut u32, ) -> i32; + fn ConvertStringSidToSidW(string_sid: *const u16, sid: *mut Sid) -> i32; fn EqualSid(first: Sid, second: Sid) -> i32; fn IsValidSid(sid: Sid) -> i32; + fn GetLengthSid(sid: Sid) -> u32; fn IsValidAcl(acl: *mut c_void) -> i32; fn GetAce(acl: *mut c_void, index: u32, ace: *mut *mut c_void) -> i32; fn GetSecurityDescriptorControl( @@ -1023,6 +2860,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( fn GetCurrentProcess() -> Handle; fn CloseHandle(handle: Handle) -> i32; fn LocalFree(memory: *mut c_void) -> *mut c_void; + fn GetLastError() -> u32; } const SE_FILE_OBJECT: u32 = 1; @@ -1030,7 +2868,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; const SE_DACL_PROTECTED: u16 = 0x1000; const TOKEN_QUERY: u32 = 0x0000_0008; + const TOKEN_ADJUST_PRIVILEGES: u32 = 0x0000_0020; const TOKEN_USER_CLASS: u32 = 1; + const SE_PRIVILEGE_ENABLED: u32 = 0x0000_0002; + const ERROR_NOT_ALL_ASSIGNED: u32 = 1300; const SET_ACCESS: i32 = 2; const TRUSTEE_IS_SID: i32 = 0; const TRUSTEE_IS_USER: i32 = 1; @@ -1041,7 +2882,13 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( let mut token = std::ptr::null_mut(); // SAFETY: GetCurrentProcess returns a valid pseudo handle and token is a valid output pointer. - if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 + if unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_ADJUST_PRIVILEGES, + &mut token, + ) + } == 0 || token.is_null() { return Err(format!( @@ -1050,6 +2897,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( )); } + let mut requested_user_sid = std::ptr::null_mut(); let result = (|| { let mut required = 0_u32; // SAFETY: the null query buffer is the documented size-probe call. @@ -1088,8 +2936,34 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( if current_user_sid.is_null() || unsafe { IsValidSid(current_user_sid) } == 0 { return Err("Windows 当前用户 SID 无效".to_string()); } + let target_user_sid = if let Some(target_user_sid) = target_user_sid { + let target_user_sid = target_user_sid.trim(); + if target_user_sid.is_empty() { + return Err("Windows ACL 修复目标 TokenUser SID 不能为空".to_string()); + } + let mut wide_target_user_sid = target_user_sid + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + if unsafe { + ConvertStringSidToSidW(wide_target_user_sid.as_mut_ptr(), &mut requested_user_sid) + } == 0 + || requested_user_sid.is_null() + || unsafe { IsValidSid(requested_user_sid) } == 0 + { + return Err("Windows ACL 修复目标 TokenUser SID 无效".to_string()); + } + requested_user_sid + } else { + current_user_sid + }; - let mut wide_path = path + // Security APIs otherwise reject a perfectly valid private sidecar at + // the MAX_PATH boundary with ERROR_INVALID_NAME. Use the extended + // length spelling only when needed; short paths retain the ordinary + // Win32 form for compatibility with older Windows builds. + let security_path = windows_security_object_path(path); + let mut wide_path = security_path .as_os_str() .encode_wide() .chain(std::iter::once(0)) @@ -1119,7 +2993,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( )); } let owner_matches = unsafe { IsValidSid(initial_owner) } != 0 - && unsafe { EqualSid(initial_owner, current_user_sid) } != 0; + && unsafe { EqualSid(initial_owner, target_user_sid) } != 0; unsafe { LocalFree(initial_descriptor) }; if !owner_matches { if !(initialize_owner && tighten) { @@ -1128,6 +3002,58 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( path.display() )); } + + // Assigning ownership to the current token user requires the + // take-ownership/restore privileges on an elevated process. + // Enabling them is attempted only for this allow-listed repair; + // a normal process will fail and the caller will request UAC + // elevation instead of weakening the verifier. + for privilege_name in ["SeTakeOwnershipPrivilege", "SeRestorePrivilege"] { + let mut privilege_name_wide = privilege_name + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut luid = Luid { + low_part: 0, + high_part: 0, + }; + if unsafe { + LookupPrivilegeValueW( + std::ptr::null(), + privilege_name_wide.as_mut_ptr(), + &mut luid, + ) + } == 0 + { + return Err(format!( + "启用 Windows {privilege_name} 失败:{}", + std::io::Error::last_os_error() + )); + } + let mut privileges = TokenPrivileges { + privilege_count: 1, + privileges: [LuidAndAttributes { + luid, + attributes: SE_PRIVILEGE_ENABLED, + }], + }; + let adjust_status = unsafe { + AdjustTokenPrivileges( + token, + 0, + &mut privileges, + std::mem::size_of::() as u32, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + let adjust_error = unsafe { GetLastError() }; + if adjust_status == 0 || adjust_error == ERROR_NOT_ALL_ASSIGNED { + return Err(format!( + "启用 Windows {privilege_name} 失败:error {adjust_error}" + )); + } + } } if tighten { let mut entry = ExplicitAccessW { @@ -1143,7 +3069,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( multiple_trustee_operation: 0, trustee_form: TRUSTEE_IS_SID, trustee_type: TRUSTEE_IS_USER, - name: current_user_sid.cast(), + name: target_user_sid.cast(), }, }; let mut private_dacl = std::ptr::null_mut(); @@ -1164,7 +3090,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( SE_FILE_OBJECT, windows_private_dacl_security_information(initialize_owner, owner_matches), if should_initialize_owner { - current_user_sid + target_user_sid } else { std::ptr::null_mut() }, @@ -1211,8 +3137,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( } let validation = (|| { - if unsafe { IsValidSid(owner) } == 0 - || unsafe { EqualSid(owner, current_user_sid) } == 0 + if unsafe { IsValidSid(owner) } == 0 || unsafe { EqualSid(owner, target_user_sid) } == 0 { return Err(format!( "Windows 安全对象不属于当前用户:{}", @@ -1269,18 +3194,23 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( } else { 0 }; - if allowed.header.ace_flags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) - != required_inheritance - { + if allowed.header.ace_flags != required_inheritance { return Err(format!("Windows DACL 继承边界无效:{}", path.display())); } let ace_sid = std::ptr::addr_of!(allowed.sid_start) .cast_mut() .cast::(); - if unsafe { IsValidSid(ace_sid) } == 0 - || unsafe { EqualSid(ace_sid, current_user_sid) } == 0 + let sid_length = unsafe { GetLengthSid(ace_sid) } as usize; + let sid_offset = std::mem::size_of::() + std::mem::size_of::(); + if sid_length == 0 + || sid_offset.saturating_add(sid_length) > usize::from(header.ace_size) + || unsafe { IsValidSid(ace_sid) } == 0 + || unsafe { EqualSid(ace_sid, target_user_sid) } == 0 { - return Err(format!("Windows DACL 含非当前用户 ACE:{}", path.display())); + return Err(format!( + "Windows DACL 当前用户 ACE 身份无效:{}", + path.display() + )); } Ok(()) })(); @@ -1290,6 +3220,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( })(); // SAFETY: token was opened successfully above. + if !requested_user_sid.is_null() { + // SAFETY: ConvertStringSidToSidW allocates this SID with LocalAlloc. + unsafe { LocalFree(requested_user_sid.cast()) }; + } unsafe { CloseHandle(token) }; result } @@ -1303,16 +3237,51 @@ pub(crate) fn configure_game_creator_runtime_config_dir( let config_dir = prepare_game_creator_runtime_config_dir(&requested_config_dir) .map_err(std::io::Error::other)?; let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); - if !config_path.exists() { + let config_exists = + validate_game_creator_config_file_entry(&config_path).map_err(std::io::Error::other)?; + if !config_exists { write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) .map_err(std::io::Error::other)?; - } else { - migrate_legacy_game_creator_agent_mode(&config_path).map_err(std::io::Error::other)?; + } + // Both the normal config and the optional local override are persisted + // inputs. A release build must scrub legacy provider credentials from + // either file before the next read can observe them again. + for path in [ + config_path, + config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), + ] { + migrate_legacy_game_creator_agent_mode(&path).map_err(std::io::Error::other)?; } set_game_creator_runtime_config_dir(config_dir); Ok(()) } +/// Validates a persisted AGC config file without following links. Existing +/// regular files are tightened through the same Windows owner/DACL gate used +/// by credential files before any read is allowed. +fn validate_game_creator_config_file_entry(path: &Path) -> Result { + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, "客户端配置文件")?; + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取客户端配置文件元数据失败:{}: {error}", + path.display() + )); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("客户端配置文件必须是普通文件,不能是链接或其他对象".to_string()); + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)?; + Ok(true) +} + pub(crate) fn legacy_game_creator_agent_mode( config: &GameCreatorAppConfigFile, ) -> Option<&'static str> { @@ -1451,6 +3420,9 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), )); } } + if game_creator_official_llm_route_locked() { + changed |= scrub_locked_game_creator_config_file(&mut config); + } if changed { let content = serde_json::to_string_pretty(&config) .map_err(|error| format!("序列化客户端配置失败:{error}"))?; @@ -1573,15 +3545,15 @@ pub(crate) fn merge_game_creator_config_file( path: &Path, ) -> Result<(), String> { let backup_path = game_creator_config_backup_path(path); - let read_path = if path.is_file() { + let path_exists = validate_game_creator_config_file_entry(path)?; + let read_path = if path_exists { path - } else if backup_path.is_file() { + } else if validate_game_creator_config_file_entry(&backup_path)? { backup_path.as_path() } else { return Ok(()); }; - let content = fs::read_to_string(read_path) - .map_err(|error| format!("读取客户端配置失败:{}: {error}", read_path.display()))?; + let content = read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?; let file_config = serde_json::from_str::(&content) .map_err(|error| format!("解析客户端配置失败:{}: {error}", read_path.display()))?; if let Some(agent_mode) = file_config.agent_mode { @@ -1624,11 +3596,19 @@ pub(crate) fn write_game_creator_config_atomically( path: &Path, content: &str, ) -> Result<(), String> { + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, "客户端配置文件")?; let parent = path .parent() .ok_or_else(|| "客户端配置缺少父目录".to_string())?; - fs::create_dir_all(parent) + ensure_game_creator_private_directory_tree(parent, "客户端配置目录") .map_err(|error| format!("创建客户端配置目录失败:{}: {error}", parent.display()))?; + // Never replace a symlink or another non-regular object. A regular + // existing config is repaired/tightened before it can be moved to the + // recoverable backup below. + let _initial_path_exists = validate_game_creator_config_file_entry(path)?; let temp_path = path.with_file_name(format!( ".{}.tmp.{}.{}", path.file_name() @@ -1647,12 +3627,24 @@ pub(crate) fn write_game_creator_config_atomically( use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options.open(&temp_path).map_err(|error| { format!( "创建客户端配置临时文件失败:{}: {error}", temp_path.display() ) })?; + if let Err(error) = + harden_new_game_creator_private_path(&temp_path, false, "客户端配置临时文件") + { + drop(file); + let _ = fs::remove_file(&temp_path); + return Err(error); + } let write_result = file .write_all(content.as_bytes()) .and_then(|_| file.sync_all()); @@ -1665,44 +3657,55 @@ pub(crate) fn write_game_creator_config_atomically( )); } + let backup_path = game_creator_config_backup_path(path); + // Re-check the destination immediately before changing either directory + // entry. A newly appeared regular target is moved aside; a link, + // reparse point, foreign object, or unsafe ACL is rejected by the same + // private-path policy used for reads. + let had_previous = validate_game_creator_config_file_entry(path)?; + if validate_game_creator_config_file_entry(&backup_path)? { + fs::remove_file(&backup_path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "清理旧客户端配置备份失败:{}: {error}", + backup_path.display() + ) + })?; + } + if had_previous { + if let Err(error) = fs::rename(path, &backup_path) { + let _ = fs::remove_file(&temp_path); + return Err(format!( + "准备替换客户端配置失败:{} -> {}: {error}", + path.display(), + backup_path.display() + )); + } + } match fs::rename(&temp_path, path) { Ok(()) => { - let _ = fs::remove_file(game_creator_config_backup_path(path)); + let _ = fs::remove_file(&backup_path); + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, false, true, + )?; Ok(()) } - Err(replace_error) => { - let backup_path = game_creator_config_backup_path(path); - if path.exists() { - let _ = fs::remove_file(&backup_path); - fs::rename(path, &backup_path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!( - "准备替换客户端配置失败:{} -> {}: {error}", - path.display(), - backup_path.display() - ) - })?; - } - match fs::rename(&temp_path, path) { - Ok(()) => { - let _ = fs::remove_file(&backup_path); - Ok(()) - } - Err(error) => { - let restore_error = if backup_path.exists() { - fs::rename(&backup_path, path).err() - } else { - None - }; - let _ = fs::remove_file(&temp_path); - let restore_detail = restore_error - .map(|error| format!(";恢复旧配置失败:{error}")) - .unwrap_or_default(); - Err(format!( - "替换客户端配置失败:{replace_error};重试失败:{error}{restore_detail}" - )) - } - } + Err(error) => { + let restore_error = if had_previous { + fs::rename(&backup_path, path).err() + } else { + None + }; + let _ = fs::remove_file(&temp_path); + let restore_detail = restore_error + .map(|error| format!(";恢复旧配置失败:{error}")) + .unwrap_or_default(); + Err(format!( + "替换客户端配置失败:{} -> {}: {error}{restore_detail}", + temp_path.display(), + path.display() + )) } } } @@ -2041,3 +4044,265 @@ mod anthropic_strict_capability_tests { } } } + +#[cfg(test)] +mod private_file_write_tests { + use super::*; + + #[test] + fn private_file_write_installs_via_hardened_sibling_and_replaces_regular_target() { + let root = tempfile::tempdir().expect("create private file fixture"); + let parent = root.path().join("private"); + let path = parent.join("state.json"); + + write_game_creator_private_file(&path, b"first\n", "测试私有文件") + .expect("install first private file"); + assert_eq!( + fs::read(&path).expect("read first private file"), + b"first\n" + ); + + write_game_creator_private_file(&path, b"second\n", "测试私有文件") + .expect("replace private file"); + assert_eq!( + fs::read(&path).expect("read replaced private file"), + b"second\n" + ); + + let leftovers = fs::read_dir(&parent) + .expect("read private file directory") + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".tmp-") || name.contains(".previous-")) + .collect::>(); + assert!( + leftovers.is_empty(), + "temporary/backup residue: {leftovers:?}" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let metadata = fs::symlink_metadata(&path).expect("metadata"); + assert_eq!(metadata.mode() & 0o777, 0o600); + } + } + + #[test] + fn private_file_append_creates_and_reuses_hardened_target() { + let root = tempfile::tempdir().expect("create append fixture"); + let path = root.path().join("private").join("journal.log"); + + append_game_creator_private_file(&path, b"first\n", "测试追加文件") + .expect("append first record"); + append_game_creator_private_file(&path, b"second\n", "测试追加文件") + .expect("append second record"); + assert_eq!( + fs::read(&path).expect("read append file"), + b"first\nsecond\n" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let metadata = fs::symlink_metadata(&path).expect("append metadata"); + assert_eq!(metadata.nlink(), 1); + assert_eq!(metadata.mode() & 0o777, 0o600); + } + } +} + +#[cfg(test)] +mod private_path_elevation_policy_tests { + use super::*; + + #[test] + fn automatic_elevation_covers_all_regular_project_descendants() { + let root = tempfile::tempdir().expect("create path policy fixture"); + let external_file = root.path().join("game").join("index.html"); + let nested_project_file = root.path().join("assets").join("sprites").join("hero.png"); + let managed_agent_file = root + .path() + .join(".agent") + .join("runtime") + .join("state.json"); + fs::create_dir_all(managed_agent_file.parent().expect("agent parent")) + .expect("create managed agent fixture"); + fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker"); + let traversal_path = root + .path() + .join(".agent") + .join("..") + .join("Windows") + .join("System32"); + + assert!(game_creator_private_path_allows_auto_elevation( + &external_file + )); + assert!(game_creator_private_path_allows_auto_elevation( + &nested_project_file + )); + assert!(game_creator_private_path_allows_auto_elevation( + &managed_agent_file + )); + assert!(!game_creator_private_path_allows_auto_elevation( + &traversal_path + )); + } + + #[test] + fn project_marker_does_not_authorize_unrelated_or_nested_agent_paths() { + let root = tempfile::tempdir().expect("create path policy fixture"); + fs::create_dir_all(root.path().join(".agent")).expect("create agent directory"); + fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker"); + + let unrelated = tempfile::tempdir().expect("create unrelated fixture"); + let unrelated_file = unrelated.path().join("game/index.html"); + let nested_agent = root.path().join("game").join(".agent").join("state.json"); + + assert!(!game_creator_private_path_allows_auto_elevation( + &unrelated_file + )); + assert!(!game_creator_private_path_allows_auto_elevation( + &nested_agent + )); + } + + #[cfg(windows)] + #[test] + fn explicit_user_selection_allows_repair_on_user_selected_non_system_path() { + let profile = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .expect("current user profile"); + let windir = std::env::var_os("WINDIR") + .map(PathBuf::from) + .expect("WINDIR"); + let program_files = std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .expect("ProgramFiles"); + let system_drive = std::env::var_os("SystemDrive") + .map(PathBuf::from) + .expect("SystemDrive"); + let selected = profile.join("Documents").join("fixture.png"); + let external_drive = PathBuf::from(r"D:\Genarrative\fixture.png"); + let system_path = windir.join("System32").join("fixture.png"); + let program_files_path = program_files.join("Genarrative").join("fixture.png"); + let drive_root = PathBuf::from(format!( + "{}\\", + system_drive.to_string_lossy().trim_end_matches(['\\', '/']) + )); + let unc_root = PathBuf::from(r"\\server\share\"); + let traversal_path = PathBuf::from(r"C:\Users\test\..\Windows\fixture.png"); + + assert!(game_creator_user_selected_path_allows_auto_elevation( + &selected + )); + assert!(game_creator_user_selected_path_allows_auto_elevation( + &external_drive + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &system_path + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &program_files_path + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &drive_root + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &unc_root + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &traversal_path + )); + } + + #[test] + fn arbitrary_agent_directory_without_project_marker_cannot_trigger_elevation() { + let root = tempfile::tempdir().expect("create unverified agent fixture"); + let path = root + .path() + .join(".agent") + .join("runtime") + .join("state.json"); + fs::create_dir_all(path.parent().expect("agent parent")).expect("create agent fixture"); + assert!(!game_creator_private_path_allows_auto_elevation(&path)); + } + + #[test] + fn nested_agent_components_cannot_trigger_elevation() { + let root = tempfile::tempdir().expect("create nested agent fixture"); + let path = root + .path() + .join(".agent") + .join("nested") + .join(".agent") + .join("state.json"); + fs::create_dir_all(path.parent().expect("nested agent parent")) + .expect("create nested agent"); + fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker"); + assert!(!game_creator_private_path_allows_auto_elevation(&path)); + } + + #[cfg(windows)] + #[test] + fn explicit_project_root_entry_can_tighten_owner_correct_inherited_acl() { + let root = tempfile::tempdir().expect("create project root fixture"); + assert!(prepare_game_creator_project_root_for_read(root.path(), true, "项目根").is_ok()); + } + + #[cfg(windows)] + #[test] + fn owner_flag_is_requested_only_when_owner_initialization_is_needed() { + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + assert_eq!( + windows_private_dacl_security_information(true, false) & OWNER_SECURITY_INFORMATION, + OWNER_SECURITY_INFORMATION + ); + assert_eq!( + windows_private_dacl_security_information(true, true) & OWNER_SECURITY_INFORMATION, + 0 + ); + } + + #[cfg(windows)] + #[test] + fn canonicalize_access_denied_is_classified_as_acl_repair_candidate() { + let detail = + "解析客户端 AppData 配置目录失败:C:\\Temp\\agc-config: 拒绝访问。 (os error 5)"; + assert!(windows_acl_error_may_need_elevation(detail)); + } + + #[cfg(windows)] + #[test] + fn custom_runtime_config_path_uses_explicit_user_selected_scope() { + let root = tempfile::tempdir().expect("create custom config fixture"); + assert_eq!( + game_creator_runtime_config_repair_scope(&root.path().join("config")), + WindowsAclRepairScope::UserSelected + ); + } + + #[cfg(windows)] + #[test] + fn picker_grant_is_required_and_directory_grant_covers_descendants() { + let root = tempfile::tempdir().expect("create picker grant fixture"); + let directory = root.path().join("selected"); + let child = directory.join("nested").join("file.png"); + let unapproved = root.path().join("other").join("file.png"); + + assert!(!user_selected_path_is_authorized(&directory, true)); + assert!(!user_selected_path_is_authorized(&unapproved, false)); + + register_game_creator_user_selected_path(&directory, true); + assert!(user_selected_path_is_authorized(&directory, true)); + assert!(user_selected_path_is_authorized(&child, false)); + assert!(!user_selected_path_is_authorized(&directory, false)); + assert!(!user_selected_path_is_authorized(&unapproved, false)); + + revoke_game_creator_user_selected_path(&directory); + assert!(!user_selected_path_is_authorized(&directory, true)); + assert!(!user_selected_path_is_authorized(&child, false)); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 5d6a651e7..6e97aebb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1903,6 +1903,61 @@ fn install_agent_runtime_async_runtime_with_deep_stack() { Box::leak(Box::new(runtime)); } +#[cfg(windows)] +fn run_windows_acl_repair_if_requested(args: &[String]) -> Option { + let [ + command, + path, + target_user_sid_flag, + target_user_sid, + authorization_flag, + nonce, + scope_flag, + scope_value, + ] = args + else { + if args.first().map(String::as_str) == Some("--repair-private-acl") { + eprintln!( + "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " + ); + return Some(1); + } + return None; + }; + if command != "--repair-private-acl" + || target_user_sid_flag != "--target-user-sid" + || authorization_flag != "--authorization" + || scope_flag != "--scope" + { + eprintln!( + "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " + ); + return Some(1); + } + let path = std::path::PathBuf::from(path); + let scope = match config::parse_windows_acl_repair_scope(scope_value) { + Ok(scope) => scope, + Err(error) => { + eprintln!("{error}"); + return Some(1); + } + }; + let result = config::consume_windows_acl_repair_authorization( + &path, + target_user_sid, + nonce, + scope, + ) + .and_then(|()| config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid)); + match result { + Ok(()) => Some(0), + Err(error) => { + eprintln!("AGC ACL 提权修复失败:{error}"); + Some(1) + } + } +} + #[cfg(test)] mod async_runtime_stack_tests { /// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。 @@ -1940,6 +1995,10 @@ fn main() { if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { std::process::exit(exit_code); } + #[cfg(windows)] + if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) { + std::process::exit(exit_code); + } #[cfg(target_os = "linux")] if command_sandbox_trampoline::is_trampoline_mode(&args) { match command_sandbox_trampoline::run_trampoline() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index b9e2f985e..bd2f3e18b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -349,8 +349,8 @@ fn open_agent_db_directory(root: &Path, create: bool) -> Result Result, String> { if create { - fs::create_dir_all(root) - .map_err(|error| format!("创建 Agent DB 项目目录失败:{}: {error}", root.display()))?; + ensure_game_creator_private_directory_tree(root, "Agent DB 项目目录")?; + } + if !prepare_game_creator_private_path_for_read(root, true, "Agent DB 项目目录")? { + return Ok(None); + } + let agent_path = root.join(".agent"); + if create { + ensure_game_creator_private_directory_tree(&agent_path, "项目 .agent 目录")?; + } + if !prepare_game_creator_private_path_for_read(&agent_path, true, "项目 .agent 目录")? { + return Ok(None); } let root_directory = match open_windows_agent_db_root(root, create) { Ok(file) => file, @@ -875,6 +884,21 @@ fn open_agent_db_storage( create: bool, ) -> Result, String> { verify_agent_db_directory_current(&directory)?; + let path = directory.path.join("agent.db"); + let existed = match fs::symlink_metadata(&path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => { + // Do not let an ACL-denied existing Agent DB fall through to + // NtCreateFile, which would otherwise be reported as a generic + // open failure without trying the approved repair path. + prepare_game_creator_private_path_for_read(&path, false, "Agent 本地索引")?; + true + } + }; + if existed { + prepare_game_creator_private_path_for_read(&path, false, "Agent 本地索引")?; + } let file = { let mut opened = None; for attempt in 0..100 { @@ -924,10 +948,16 @@ fn open_agent_db_storage( return Err("Agent 本地索引必须是普通文件".to_string()); } validate_windows_regular_file_handle(&file, "Agent 本地索引")?; + if existed { + secure_windows_game_creator_path_for_current_user_with_auto_elevation(&path, false, true)?; + } else { + initialize_windows_game_creator_file_owner_for_current_user(&path)?; + secure_windows_game_creator_path_for_current_user_with_auto_elevation(&path, false, false)?; + } verify_agent_db_directory_current(&directory)?; Ok(Some(AgentDbStorage { file, - path: directory.path.join("agent.db"), + path, root_path: directory.root_path, root_directory: directory.root_directory, agent_directory: directory.agent_directory, @@ -4369,13 +4399,10 @@ fn project_append_os_lock_path(path: &Path) -> Result { fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result { if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建{error_label}跨进程锁目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, error_label)?; + prepare_game_creator_private_path_for_read(parent, true, error_label)?; } + prepare_game_creator_private_path_for_read(path, false, error_label)?; for attempt in 0..100 { if let Some(file) = try_open_project_append_os_lock(path, error_label)? { return Ok(file); @@ -4391,12 +4418,58 @@ fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result Result, String> { use std::os::fd::AsRawFd; - let file = fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) + let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?; + let mut options = fs::OpenOptions::new(); + options.create(true).read(true).write(true); + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + let file = options .open(path) .map_err(|error| format!("打开{error_label}跨进程锁失败:{}: {error}", path.display()))?; + let metadata = file.metadata().map_err(|error| { + format!( + "读取{error_label}跨进程锁元数据失败:{}: {error}", + path.display() + ) + })?; + if !metadata.is_file() { + return Err(format!( + "{error_label}跨进程锁必须是普通文件:{}", + path.display() + )); + } + if !existed { + if let Err(error) = harden_new_game_creator_private_path(path, false, error_label) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + } + let path_metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "复核{error_label}跨进程锁路径失败:{}: {error}", + path.display() + ) + })?; + if path_metadata.file_type().is_symlink() { + return Err(format!( + "{error_label}跨进程锁不能是符号链接:{}", + path.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if path_metadata.nlink() != 1 + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "{error_label}跨进程锁路径在打开期间发生替换:{}", + path.display() + )); + } + } let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; if result == 0 { return Ok(Some(file)); @@ -4416,14 +4489,33 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result Result, String> { use std::os::windows::fs::OpenOptionsExt; - match fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .share_mode(0) - .open(path) - { - Ok(file) => Ok(Some(file)), + let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + match { + let mut options = fs::OpenOptions::new(); + options + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + options.open(path) + } { + Ok(file) => { + crate::runner::validate_windows_regular_file_handle(&file, error_label)?; + if !existed { + if let Err(error) = harden_new_game_creator_private_path(path, false, error_label) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + } + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, false, true, + )?; + crate::runner::validate_windows_regular_file_handle(&file, error_label)?; + Ok(Some(file)) + } Err(error) if matches!( error.kind(), @@ -4453,22 +4545,76 @@ pub(super) fn append_jsonl_line_unlocked( error_label: &str, ) -> Result<(), String> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建{error_label}目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, error_label)?; + prepare_game_creator_private_path_for_read(parent, true, error_label)?; } - let mut file = fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) + let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?; + let mut options = fs::OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + options + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + } + let mut file = options .open(path) .map_err(|error| format!("打开{error_label}失败:{}: {error}", path.display()))?; + let opened_metadata = file.metadata().map_err(|error| { + format!( + "读取{error_label}文件句柄元数据失败:{}: {error}", + path.display() + ) + })?; + if !opened_metadata.is_file() { + return Err(format!("{error_label}必须是普通文件:{}", path.display())); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if opened_metadata.nlink() != 1 { + return Err(format!("{error_label}不能是硬链接:{}", path.display())); + } + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("复核{error_label}路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != opened_metadata.dev() + || path_metadata.ino() != opened_metadata.ino() + { + return Err(format!( + "{error_label}路径在安全打开期间发生替换:{}", + path.display() + )); + } + } + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, error_label)?; + if !existed { + if let Err(error) = harden_new_game_creator_private_path(path, false, error_label) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + } repair_truncated_jsonl_tail_unlocked(&mut file, path, error_label)?; let framed = format!("{line}\n"); file.seek(SeekFrom::End(0)) .and_then(|_| file.write_all(framed.as_bytes())) .and_then(|_| file.flush()) .and_then(|_| file.sync_data()) - .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display())) + .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display()))?; + prepare_game_creator_private_path_for_read(path, false, error_label)?; + Ok(()) } const AGENT_DB_FINALIZATION_SLOT_PREPARED: u8 = 0; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 66b6b142b..9c99aaede 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -5,6 +5,54 @@ use super::filesystem::validate_portable_project_path_component; #[cfg(windows)] use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +#[cfg(windows)] +fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> { + use std::ffi::c_void; + use std::os::windows::io::AsRawHandle; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + + #[repr(C)] + struct ByHandleFileInformation { + file_attributes: u32, + creation_time: FileTime, + last_access_time: FileTime, + last_write_time: FileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandle( + file: *mut c_void, + information: *mut ByHandleFileInformation, + ) -> i32; + } + + // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: file owns a live kernel handle and information is a valid output pointer. + if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 { + return Err(format!( + "读取 {label} Windows 文件句柄身份失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(( + information.volume_serial_number, + (u64::from(information.file_index_high) << 32) | u64::from(information.file_index_low), + )) +} + pub(crate) fn create_local_project_checkpoint_at( root: &Path, ) -> Result { @@ -22,10 +70,10 @@ pub(crate) fn create_local_project_checkpoint_at( &checkpoint_file_relative_path(&checkpoint_id, &normalized_path), )?; if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!("创建 checkpoint 目录失败:{}: {error}", parent.display()) - })?; + ensure_game_creator_private_directory_tree(parent, "checkpoint 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "checkpoint 目录")?; } + prepare_game_creator_private_path_for_read(&target, false, "checkpoint 文件")?; fs::copy(&source, &target).map_err(|error| { format!( "写入 checkpoint 文件失败:{} -> {}: {error}", @@ -33,6 +81,7 @@ pub(crate) fn create_local_project_checkpoint_at( target.display() ) })?; + prepare_game_creator_private_path_for_read(&target, false, "checkpoint 文件")?; } let total_bytes = files.iter().map(|file| file.size).sum::(); let manifest = serde_json::json!({ @@ -42,20 +91,21 @@ pub(crate) fn create_local_project_checkpoint_at( }); let manifest_path = resolve_local_project_path(root, &checkpoint_manifest_relative_path(&checkpoint_id))?; - fs::write( + if let Some(parent) = manifest_path.parent() { + ensure_game_creator_private_directory_tree(parent, "checkpoint manifest 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "checkpoint manifest 目录")?; + } + prepare_game_creator_private_path_for_read(&manifest_path, false, "checkpoint manifest")?; + crate::write_game_creator_private_file( &manifest_path, format!( "{}\n", serde_json::to_string_pretty(&manifest) .map_err(|error| format!("序列化 checkpoint 失败:{error}"))? - ), - ) - .map_err(|error| { - format!( - "写入 checkpoint manifest 失败:{}: {error}", - manifest_path.display() ) - })?; + .as_bytes(), + "checkpoint manifest", + )?; append_agent_db_record( root, serde_json::json!({ @@ -147,13 +197,68 @@ pub(crate) fn open_project_snapshot_regular_file( } #[cfg(windows)] validate_windows_regular_file_handle(&file, label)?; + // The pathname was checked before opening, but another process can replace + // it between those two operations. Compare the opened handle identity to + // the current directory entry before any caller reads bytes; subsequent + // reads use the already-open handle and therefore are not pathname-based. + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("复核{label}路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { + return Err(format!( + "{label}路径在安全打开期间发生替换:{}", + path.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if path_metadata.dev() != metadata.dev() || path_metadata.ino() != metadata.ino() { + return Err(format!( + "{label}路径在安全打开期间发生替换:{}", + path.display() + )); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + let mut identity_options = fs::OpenOptions::new(); + identity_options + .read(true) + .custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + let identity_file = identity_options + .open(path) + .map_err(|error| format!("复核{label}路径失败:{}: {error}", path.display()))?; + validate_windows_regular_file_handle(&identity_file, label)?; + if windows_regular_file_handle_identity(&identity_file, label)? + != windows_regular_file_handle_identity(&file, label)? + { + return Err(format!( + "{label}路径在安全打开期间发生替换:{}", + path.display() + )); + } + } Ok((file, metadata)) } +/// Opens an AGC-managed project file after the private-path owner/DACL gate +/// has had a chance to repair an inherited or foreign ACL. Callers that read +/// arbitrary user-selected files must keep using +/// `open_project_snapshot_regular_file` so importing an external file never +/// silently changes its owner. +pub(crate) fn open_project_private_regular_file( + path: &Path, + label: &str, +) -> Result<(File, fs::Metadata), String> { + prepare_game_creator_private_path_for_read(path, false, label)?; + open_project_snapshot_regular_file(path, label) +} + fn read_local_project_content_diff_source( path: &Path, ) -> Result { - let (mut file, metadata) = open_project_snapshot_regular_file(path, "内容 diff 文件")?; + let (mut file, metadata) = open_project_private_regular_file(path, "内容 diff 文件")?; let mut hasher = Sha256::new(); let mut bytes = (metadata.len() <= PROJECT_CONTENT_DIFF_MAX_FILE_BYTES) .then(|| Vec::with_capacity(metadata.len() as usize)); @@ -518,10 +623,12 @@ pub(crate) fn restore_local_project_checkpoint_at( let restored_count = restore_plan.len(); for (source, target) in restore_plan { + prepare_game_creator_private_path_for_read(&source, false, "checkpoint 源文件")?; if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建恢复目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "恢复目录")?; + prepare_game_creator_private_path_for_read(parent, true, "恢复目录")?; } + prepare_game_creator_private_path_for_read(&target, false, "恢复目标文件")?; fs::copy(&source, &target).map_err(|error| { format!( "恢复 checkpoint 文件失败:{} -> {}: {error}", @@ -529,6 +636,7 @@ pub(crate) fn restore_local_project_checkpoint_at( target.display() ) })?; + prepare_game_creator_private_path_for_read(&target, false, "恢复目标文件")?; } let deleted_count = delete_plan.len(); for target in delete_plan { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index 12eb9f5d0..3ad3d43f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -68,6 +68,9 @@ fn file_modified_timestamp(path: &Path) -> u64 { } fn count_conversation_messages(path: &Path) -> Result { + if !prepare_game_creator_private_path_for_read(path, false, "对话记录")? { + return Ok(0); + } match File::open(path) { Ok(file) => { let mut count = 0_u64; @@ -118,6 +121,7 @@ fn read_agent_conversation_session_catalog_unlocked( validate_project_root(root)?; let agent_id = normalize_conversation_agent_id(agent_id)?; let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + prepare_game_creator_private_path_for_read(&catalog_path, false, "Agent Session 目录")?; let mut catalog = match fs::read_to_string(&catalog_path) { Ok(content) => serde_json::from_str::(&content) .map_err(|error| { @@ -305,37 +309,16 @@ fn write_agent_conversation_session_catalog_unlocked( ) -> Result<(), String> { let path = agent_conversation_session_catalog_path(root, &catalog.agent_id); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!("创建 Agent Session 目录失败:{}: {error}", parent.display()) - })?; + ensure_game_creator_private_directory_tree(parent, "Agent Session 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent Session 目录")?; } let content = serde_json::to_string_pretty(catalog) .map_err(|error| format!("序列化 Agent Session 目录失败:{error}"))?; - let temp_path = path.with_file_name(format!( - ".{}.tmp.{}.{}", - path.file_name() - .and_then(|value| value.to_str()) - .unwrap_or("sessions.json"), - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - fs::write(&temp_path, format!("{content}\n")).map_err(|error| { - format!( - "写入 Agent Session 临时目录失败:{}: {error}", - temp_path.display() - ) - })?; - fs::rename(&temp_path, &path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!( - "替换 Agent Session 目录失败:{} -> {}: {error}", - temp_path.display(), - path.display() - ) - }) + write_game_creator_private_file( + &path, + format!("{content}\n").as_bytes(), + "Agent Session 目录", + ) } pub(crate) fn ensure_agent_conversation_session_at( @@ -419,6 +402,7 @@ pub(crate) fn ensure_agent_session_has_no_live_tasks( let task_path = root .join(".agent/runtime/tasks") .join(format!("{agent_id}.jsonl")); + prepare_game_creator_private_path_for_read(&task_path, false, "Agent Runtime 任务")?; match File::open(&task_path) { Ok(file) => { for line in BufReader::new(file).lines() { @@ -497,6 +481,7 @@ pub(crate) fn ensure_agent_session_has_no_live_tasks( if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { continue; } + prepare_game_creator_private_path_for_read(&path, false, "Agent Runtime 任务")?; let mut delegated_latest_by_run = BTreeMap::::new(); let file = File::open(&path) .map_err(|error| format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()))?; @@ -558,19 +543,33 @@ pub(crate) fn create_game_creator_agent_session_at( let conversation_path = conversation_file_path_for_resolved_session(root, &agent_id, &session_id); if let Some(parent) = conversation_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "对话目录")?; + prepare_game_creator_private_path_for_read(parent, true, "对话目录")?; } - fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&conversation_path) - .map_err(|error| { - format!( - "创建 Agent Session 对话失败:{}: {error}", - conversation_path.display() - ) - })?; + prepare_game_creator_private_path_for_read(&conversation_path, false, "对话记录")?; + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options.open(&conversation_path).map_err(|error| { + format!( + "创建 Agent Session 对话失败:{}: {error}", + conversation_path.display() + ) + })?; + if let Err(error) = harden_new_game_creator_private_path( + &conversation_path, + false, + "对话记录", + ) { + drop(file); + let _ = fs::remove_file(&conversation_path); + return Err(error); + } + drop(file); catalog.sessions.push(AgentConversationSessionRecord { session_id: session_id.clone(), title, @@ -665,20 +664,28 @@ where let conversation_path = conversation_file_path_for_resolved_session(root, &agent_id, &session_id); if let Some(parent) = conversation_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "对话目录")?; + prepare_game_creator_private_path_for_read(parent, true, "对话目录")?; } + prepare_game_creator_private_path_for_read( + &conversation_path, + false, + "Agent Session 分叉对话", + )?; let write_result = (|| -> Result<(), String> { - let mut file = fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&conversation_path) - .map_err(|error| { - format!( - "创建 Agent Session 分叉对话失败:{}: {error}", - conversation_path.display() - ) - })?; + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options.open(&conversation_path).map_err(|error| { + format!( + "创建 Agent Session 分叉对话失败:{}: {error}", + conversation_path.display() + ) + })?; for record in &records { serde_json::to_writer(&mut file, record) .map_err(|error| format!("序列化 Agent Session 分叉消息失败:{error}"))?; @@ -700,6 +707,11 @@ where let _ = fs::remove_file(&conversation_path); return Err(error); } + prepare_game_creator_private_path_for_read( + &conversation_path, + false, + "Agent Session 分叉对话", + )?; let now = unix_timestamp(); let requested_title = title.trim(); @@ -938,6 +950,7 @@ fn read_persisted_local_conversation_records_unlocked( path: &Path, ) -> Result, String> { let mut records = Vec::new(); + prepare_game_creator_private_path_for_read(path, false, "对话记录")?; match File::open(path) { Ok(file) => { for line in BufReader::new(file).lines() { @@ -1431,23 +1444,22 @@ pub(crate) fn append_markdown_entry( error_label: &str, ) -> Result<(), String> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("{error_label}:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, error_label)?; + prepare_game_creator_private_path_for_read(parent, true, error_label)?; } + prepare_game_creator_private_path_for_read(path, false, error_label)?; let needs_header = fs::metadata(path) .map(|metadata| metadata.len() == 0) .unwrap_or(true); - let mut file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|error| format!("{error_label}:{}: {error}", path.display()))?; - if needs_header { - file.write_all(header.as_bytes()) - .map_err(|error| format!("{error_label}:{}: {error}", path.display()))?; - } - file.write_all(entry.as_bytes()) - .map_err(|error| format!("{error_label}:{}: {error}", path.display())) + let bytes = if needs_header { + let mut bytes = Vec::with_capacity(header.len() + entry.len()); + bytes.extend_from_slice(header.as_bytes()); + bytes.extend_from_slice(entry.as_bytes()); + bytes + } else { + entry.as_bytes().to_vec() + }; + append_game_creator_private_file(path, &bytes, error_label) } pub(crate) fn append_local_permission_log_at( @@ -1474,16 +1486,12 @@ pub(crate) fn append_local_permission_log_at( let log_path = root.join(".agent/logs/command.log"); if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "命令日志目录")?; + prepare_game_creator_private_path_for_read(parent, true, "命令日志目录")?; } + prepare_game_creator_private_path_for_read(&log_path, false, "命令日志")?; let line = format!("{} {event} {command_id}\n", unix_timestamp()); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .and_then(|mut file| file.write_all(line.as_bytes())) - .map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display())) + append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志") } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 0b1238987..d52959460 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -13,6 +13,7 @@ pub(crate) fn export_local_project_package_at( if !game_index_metadata.is_file() { return Err("导出试玩包前需要先生成 game/index.html".to_string()); } + prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; let game_index = fs::read_to_string(&game_index_path) .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; if game_index.trim().is_empty() { @@ -51,12 +52,26 @@ pub(crate) fn export_local_project_package_at( let package_relative_path = next_project_export_package_relative_path(root)?; let package_path = resolve_local_project_path(root, &package_relative_path)?; if let Some(parent) = package_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建导出目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "导出目录")?; + prepare_game_creator_private_path_for_read(parent, true, "导出目录")?; } + prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?; - let file = File::create(&package_path) + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options + .open(&package_path) .map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&package_path, false, "试玩包") { + drop(file); + let _ = fs::remove_file(&package_path); + return Err(error); + } let mut writer = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); @@ -64,6 +79,7 @@ pub(crate) fn export_local_project_package_at( writer .start_file(relative_path, options) .map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?; + prepare_game_creator_private_path_for_read(absolute_path, false, "导出文件")?; let bytes = fs::read(absolute_path) .map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?; writer @@ -73,13 +89,10 @@ pub(crate) fn export_local_project_package_at( writer .finish() .map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?; + prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?; let updated_at = unix_timestamp(); let log_path = root.join(".agent/logs/command.log"); - if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?; - } let output = format!( "导出试玩包:{},{} 个文件,{}B", package_relative_path, @@ -87,12 +100,7 @@ pub(crate) fn export_local_project_package_at( total_bytes ); let line = format!("{updated_at} project.export_package: {output}\n"); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .and_then(|mut file| file.write_all(line.as_bytes())) - .map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?; + append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?; record_command_run( root, GameCreationAppCommandRunState { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index c7f8f38e1..062a74e3c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,7 +1,7 @@ use super::*; #[cfg(windows)] -pub(super) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -135,6 +135,7 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { return false; }; if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) || !metadata.is_file() || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES { @@ -196,8 +197,8 @@ pub(crate) fn acquire_project_write_lock( validate_project_root(root)?; let mut path = resolve_project_write_lock_path(root)?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "项目锁目录")?; + prepare_game_creator_private_path_for_read(parent, true, "项目锁目录")?; } // Re-check the parent after creation so skipping metadata only for the final // create_new target cannot weaken the normal ancestor link/reparse checks. @@ -212,16 +213,26 @@ pub(crate) fn acquire_project_write_lock( .map_err(|error| format!("生成项目写锁失败:{error}"))?; let mut retried_after_reclaim = false; loop { - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + match options.open(&path) { Ok(mut file) => { + if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") + { + drop(file); + let _ = fs::remove_file(&path); + return Err(error); + } if let Err(error) = file.write_all(content.as_bytes()) { let _ = fs::remove_file(&path); return Err(format!("写入项目写锁失败:{}: {error}", path.display())); } + prepare_game_creator_private_path_for_read(&path, false, "项目写锁")?; return Ok(ProjectWriteLock { path, content: content.clone(), @@ -283,14 +294,13 @@ pub(crate) fn list_local_project_files_at( { let entry = entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?; - let file_type = entry.file_type().map_err(|error| { - format!("读取文件类型失败:{}: {error}", entry.path().display()) - })?; - if file_type.is_symlink() { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { continue; } - - let path = entry.path(); + let file_type = metadata.file_type(); let relative_path = relative_project_path(root, &path)?; if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) @@ -298,9 +308,6 @@ pub(crate) fn list_local_project_files_at( { continue; } - let metadata = entry.metadata().map_err(|error| { - format!("读取文件元数据失败:{}: {error}", entry.path().display()) - })?; let modified_at = metadata .modified() .ok() @@ -342,6 +349,7 @@ pub(crate) fn read_local_project_file_at( reject_agent_runtime_private_control_path(&normalized_path)?; reject_sensitive_project_file_read(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; + prepare_game_creator_private_path_for_read(&path, false, "项目文件")?; let metadata = fs::metadata(&path) .map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?; if !metadata.is_file() { @@ -483,12 +491,7 @@ pub(crate) fn write_local_project_file_at( if path.exists() && !path.is_file() { return Err("只能写入文件".to_string()); } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建项目目录失败:{}: {error}", parent.display()))?; - } - fs::write(&path, content) - .map_err(|error| format!("写入项目文件失败:{}: {error}", path.display()))?; + crate::write_game_creator_private_file(&path, content.as_bytes(), "项目文件")?; Ok(LocalProjectFileMutationResult { path: normalized_path, @@ -516,6 +519,7 @@ pub(crate) fn delete_local_project_file_at( if !path.is_file() { return Err("只能删除文件".to_string()); } + prepare_game_creator_private_path_for_read(&path, false, "项目文件")?; fs::remove_file(&path) .map_err(|error| format!("删除项目文件失败:{}: {error}", path.display()))?; @@ -537,24 +541,17 @@ pub(crate) fn build_local_project_index_at(root: &Path) -> Result { - return Err("项目文件路径不能包含符号链接".to_string()); + Ok(metadata) + if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) => + { + return Err("项目文件路径不能包含符号链接或 Windows reparse point".to_string()); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { should_check_metadata = false; } + Err(error) + if !acl_repair_attempted + && error.kind() == std::io::ErrorKind::PermissionDenied => + { + acl_repair_attempted = true; + #[cfg(windows)] + if crate::prepare_game_creator_private_path_for_read(&path, true, "项目路径") + .is_ok() + { + continue; + } + return Err(format!("读取路径失败:{}: {error}", path.display())); + } Err(error) => { return Err(format!("读取路径失败:{}: {error}", path.display())); } @@ -808,10 +822,18 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } + // Every project operation enters through this validator. On Windows the + // root may be a historical directory whose owner is still the elevated + // installer account or whose DACL is inherited. Reuse the formal prepare + // entry here so all downstream reads/writes get the same one-shot UAC + // repair and post-repair verification, instead of failing later at the + // first individual sidecar read. + #[cfg(windows)] + crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?; match fs::symlink_metadata(root) { Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err("项目目录不能是符号链接".to_string()); + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + return Err("项目目录不能是符号链接或 Windows reparse point".to_string()); } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} @@ -822,6 +844,20 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { Ok(()) } +fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + #[cfg(not(windows))] + { + let _ = metadata; + false + } +} + pub(crate) fn project_path_has_control_chars(root: &Path) -> bool { root.to_string_lossy().chars().any(char::is_control) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 5168849e5..8b2efaf66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -253,6 +253,8 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String .lock() .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; let lock_path = manifest_lock_path(path); + let existed = + crate::prepare_game_creator_private_path_for_read(&lock_path, false, "manifest 锁")?; let mut options = fs::OpenOptions::new(); options .create(true) @@ -277,6 +279,9 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String lock_path.display() )); } + if !existed { + crate::harden_new_game_creator_private_path(&lock_path, false, "manifest 锁")?; + } 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) @@ -340,6 +345,18 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String )); } } + let existed = match fs::symlink_metadata(&lock_path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => { + // An inherited/foreign ACL can hide an existing lock from the + // normal token. Prepare it through the formal elevation gate so + // the subsequent exclusive open does not misclassify access + // denial as a stale lock or a generic failure. + crate::prepare_game_creator_private_path_for_read(&lock_path, false, "manifest 锁")?; + true + } + }; match fs::OpenOptions::new() .create(true) .read(true) @@ -350,12 +367,21 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String { Ok(file) => { validate_windows_regular_file_handle(&file, "manifest 锁")?; - // 提升权限运行时,Windows 可能用 TokenOwner=Administrators 创建新文件。 - // 独占句柄与普通文件检查通过后,将这个固定锁文件收归当前 TokenUser, - // 再复核句柄并按既有规则验证 owner/DACL;不放宽旧文件的安全门禁。 - crate::initialize_windows_game_creator_file_owner_for_current_user(&lock_path)?; - validate_windows_regular_file_handle(&file, "manifest 锁")?; - crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)?; + if existed { + // Existing files may have a foreign owner or inherited DACL; + // let the strict verifier request one-shot UAC repair. + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &lock_path, false, true, + )?; + } else { + // Only this invocation's newly created lock may initialize its + // owner. It is still revalidated after initialization. + crate::initialize_windows_game_creator_file_owner_for_current_user(&lock_path)?; + validate_windows_regular_file_handle(&file, "manifest 锁")?; + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &lock_path, false, false, + )?; + } Ok(Some(file)) } Err(error) @@ -402,19 +428,20 @@ pub(crate) fn init_local_game_project_at( return Err("项目名称不能为空".to_string()); } + prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?; for relative in ["game", "assets", "memory", "memory/agents", "exports"] { - fs::create_dir_all(root.join(relative)).map_err(|error| { - format!( - "创建本地项目目录失败:{}: {error}", - root.join(relative).display() - ) - })?; + let path = root.join(relative); + ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; + prepare_game_creator_private_path_for_read(&path, true, "本地项目目录")?; } let index_path = root.join("game/index.html"); - if !index_path.exists() { - fs::write(&index_path, DEFAULT_GAME_INDEX_HTML) - .map_err(|error| format!("写入默认游戏入口失败:{}: {error}", index_path.display()))?; + if !prepare_game_creator_private_path_for_read(&index_path, false, "默认游戏入口")? { + crate::write_game_creator_private_file( + &index_path, + DEFAULT_GAME_INDEX_HTML.as_bytes(), + "默认游戏入口", + )?; } let agent_db_path = root.join(".agent/agent.db"); @@ -430,12 +457,9 @@ pub(crate) fn init_local_game_project_at( } for relative in [".agent/logs", ".agent/runtime"] { - fs::create_dir_all(root.join(relative)).map_err(|error| { - format!( - "创建本地项目目录失败:{}: {error}", - root.join(relative).display() - ) - })?; + let path = root.join(relative); + ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; + prepare_game_creator_private_path_for_read(&path, true, "本地项目目录")?; } let manifest_path = root.join(".agent/manifest.json"); @@ -466,8 +490,15 @@ pub(crate) fn import_local_godot_project_at( if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } - if !root.is_dir() { - return Err("Godot 工作区目录不存在或不是文件夹".to_string()); + // The user explicitly selected this workspace as a project root. Route it + // through the project-root ACL entry so an owner-correct inherited DACL (or + // a foreign owner that requires UAC) is repaired before discovery; once the + // AGC marker is written, descendants use the stricter managed-root policy. + prepare_game_creator_project_root_for_read(root, true, "Godot 工作区目录")?; + let root_metadata = fs::symlink_metadata(root) + .map_err(|error| format!("读取 Godot 工作区目录失败:{}: {error}", root.display()))?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err("Godot 工作区目录不存在或不是普通文件夹".to_string()); } let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() @@ -508,12 +539,9 @@ pub(crate) fn import_local_godot_project_at( } for relative in [".agent/logs", ".agent/runtime"] { - fs::create_dir_all(root.join(relative)).map_err(|error| { - format!( - "创建 Godot 项目 Agent 目录失败:{}: {error}", - root.join(relative).display() - ) - })?; + let path = root.join(relative); + ensure_game_creator_private_directory_tree(&path, "Godot 项目 Agent 目录")?; + prepare_game_creator_private_path_for_read(&path, true, "Godot 项目 Agent 目录")?; } let mut manifest = new_game_creation_app_manifest(project_id, name); @@ -1162,8 +1190,8 @@ pub(crate) fn mutate_manifest_at( } let manifest_path = root.join(".agent/manifest.json"); if let Some(parent) = manifest_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "manifest 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?; } let _write_lock = acquire_manifest_write_lock(&manifest_path)?; let (_, mut manifest) = read_or_create_manifest(root)?; @@ -1182,6 +1210,7 @@ fn manifest_backup_path(path: &Path) -> PathBuf { } pub(crate) fn manifest_storage_exists(path: &Path) -> Result { + let _ = prepare_game_creator_private_path_for_read(path, false, "manifest")?; match fs::symlink_metadata(path) { Ok(_) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { @@ -1219,6 +1248,7 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> { pub(crate) fn read_manifest(path: &Path) -> Result { let backup_path = manifest_backup_path(path); + let _ = prepare_game_creator_private_path_for_read(path, false, "manifest")?; let (source_path, metadata, is_backup) = match fs::symlink_metadata(path) { Ok(metadata) => (path, metadata, false), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { @@ -1255,6 +1285,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result Res .unwrap_or_default() .as_nanos() )); - fs::write(&temp_path, format!("{payload}\n")).map_err(|error| { - format!( - "写入 manifest 临时文件失败:{}: {error}", - temp_path.display() - ) - })?; + crate::write_game_creator_private_file( + &temp_path, + format!("{payload}\n").as_bytes(), + "manifest 临时文件", + )?; install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?; + prepare_game_creator_private_path_for_read(path, false, "manifest")?; let installed = read_manifest(path)?; if installed != *manifest { return Err("manifest 安装后回读与待写入内容不一致".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/memory.rs b/apps/ai-game-creator-shell/src-tauri/src/project/memory.rs index a8551c0f2..c18ff0b8c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/memory.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/memory.rs @@ -5,6 +5,15 @@ pub(crate) fn read_local_game_memory_at( scope: &str, ) -> Result { let (scope, path) = memory_file_path(root, scope)?; + let prepared = prepare_game_creator_private_path_for_read(&path, false, "游戏记忆")?; + if !prepared { + return Ok(LocalGameMemoryResult { + scope: scope.to_string(), + path: path.to_string_lossy().into_owned(), + content: String::new(), + exists: false, + }); + } match fs::read_to_string(&path) { Ok(content) => Ok(LocalGameMemoryResult { scope: scope.to_string(), @@ -28,6 +37,15 @@ pub(crate) fn read_local_agent_memory_at( ) -> Result { let relative_path = agent_role_memory_relative_path_for_task(task_id)?; let path = resolve_local_project_path(root, &relative_path)?; + let prepared = prepare_game_creator_private_path_for_read(&path, false, "Agent 记忆")?; + if !prepared { + return Ok(LocalAgentMemoryResult { + task_id: task_id.to_string(), + path: path.to_string_lossy().into_owned(), + content: String::new(), + exists: false, + }); + } match fs::read_to_string(&path) { Ok(content) => Ok(LocalAgentMemoryResult { task_id: task_id.to_string(), @@ -52,12 +70,7 @@ pub(crate) fn write_local_agent_memory_at( ) -> Result { let relative_path = agent_role_memory_relative_path_for_task(task_id)?; let path = resolve_local_project_path(root, &relative_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 Agent 记忆目录失败:{}: {error}", parent.display()))?; - } - fs::write(&path, content) - .map_err(|error| format!("写入 Agent 记忆失败:{}: {error}", path.display()))?; + crate::write_game_creator_private_file(&path, content.as_bytes(), "Agent 记忆")?; Ok(LocalAgentMemoryResult { task_id: task_id.to_string(), path: path.to_string_lossy().into_owned(), @@ -72,12 +85,7 @@ pub(crate) fn write_local_game_memory_at( content: &str, ) -> Result { let (scope, path) = memory_file_path(root, scope)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建记忆目录失败:{}: {error}", parent.display()))?; - } - fs::write(&path, content) - .map_err(|error| format!("写入记忆失败:{}: {error}", path.display()))?; + crate::write_game_creator_private_file(&path, content.as_bytes(), "游戏记忆")?; Ok(LocalGameMemoryResult { scope: scope.to_string(), path: path.to_string_lossy().into_owned(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index d9cefa8e4..bb8296f32 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1016,7 +1016,7 @@ fn read_stable_resource_edit_file( reject_sensitive_project_file_read(&normalized)?; 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)?; + let (mut file, initial_metadata) = open_project_private_regular_file(&absolute, label)?; if initial_metadata.len() > max_bytes as u64 { return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); } @@ -1527,9 +1527,10 @@ fn write_resource_edit_staging( ) -> Result<(), String> { let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建资源编辑 staging 目录失败:{error}"))?; + ensure_game_creator_private_directory_tree(parent, "资源编辑 staging 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "资源编辑 staging 目录")?; } + prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?; match fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err("资源编辑 staging 必须是普通文件".to_string()); @@ -1553,16 +1554,30 @@ fn write_resource_edit_staging( options.custom_flags(libc::O_NOFOLLOW); options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&path) .map_err(|error| format!("创建资源编辑 staging 失败:{error}"))?; + if let Err(error) = harden_new_game_creator_private_path(&path, false, "资源编辑 staging") { + drop(file); + let _ = fs::remove_file(&path); + return Err(error); + } file.write_all(bytes) .and_then(|_| file.sync_data()) - .map_err(|error| format!("写入资源编辑 staging 失败:{error}")) + .map_err(|error| { + let _ = fs::remove_file(&path); + format!("写入资源编辑 staging 失败:{error}") + }) } fn read_resource_edit_staging(root: &Path, operation_id: &str) -> Result, String> { let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?; + prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?; let metadata = fs::symlink_metadata(&path) .map_err(|error| format!("读取资源编辑 staging 失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -1576,6 +1591,10 @@ fn read_optional_resource_edit_staging( operation_id: &str, ) -> Result>, String> { let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?; + let prepared = prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?; + if !prepared { + return Ok(None); + } match fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { Err("资源编辑 staging 必须是普通文件".to_string()) @@ -2322,10 +2341,7 @@ fn resource_edit_remote_request( }, }); } - Ok(( - "/api/editor/character-animations/generations", - body, - )) + Ok(("/api/editor/character-animations/generations", body)) } LocalProjectResourceEditKind::Video => { let mut body = serde_json::json!({ @@ -2372,10 +2388,7 @@ fn resource_edit_remote_request( "placeholder": external_canvas_placeholder("1:1"), }); } - Ok(( - "/api/editor/audios/sound-effects/generations", - body, - )) + Ok(("/api/editor/audios/sound-effects/generations", body)) } LocalProjectResourceEditKind::BackgroundMusic => { let mut body = serde_json::json!({ @@ -2392,10 +2405,7 @@ fn resource_edit_remote_request( "placeholder": external_canvas_placeholder("1:1"), }); } - Ok(( - "/api/editor/audios/background-music/generations", - body, - )) + Ok(("/api/editor/audios/background-music/generations", body)) } _ => Err("当前资源类型不是远端媒体派生".to_string()), } @@ -3861,8 +3871,10 @@ fn install_resource_edit_final_media( ) -> Result<(), String> { let absolute_path = resolve_local_project_path(root, relative_path)?; if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent).map_err(|error| format!("创建派生资源目录失败:{error}"))?; + ensure_game_creator_private_directory_tree(parent, "派生资源目录")?; + prepare_game_creator_private_path_for_read(parent, true, "派生资源目录")?; } + prepare_game_creator_private_path_for_read(&absolute_path, false, "派生资源")?; let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -3871,12 +3883,26 @@ fn install_resource_edit_final_media( options.custom_flags(libc::O_NOFOLLOW); options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&absolute_path) .map_err(|error| format!("创建派生资源失败:{error}"))?; + if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "派生资源") + { + drop(file); + let _ = fs::remove_file(&absolute_path); + return Err(error); + } file.write_all(bytes) .and_then(|_| file.sync_data()) - .map_err(|error| format!("写入派生资源失败:{error}")) + .map_err(|error| { + let _ = fs::remove_file(&absolute_path); + format!("写入派生资源失败:{error}") + }) } fn commit_resource_edit_asset_internal( @@ -8273,10 +8299,7 @@ mod tests { Some(&canvas_context), ) .expect("build create video request"); - assert_eq!( - create_video_endpoint, - "/api/editor/videos/generations" - ); + assert_eq!(create_video_endpoint, "/api/editor/videos/generations"); assert!(create_video_body.get("referenceVideoSrcs").is_none()); assert_eq!( create_video_body["projectId"], @@ -8521,9 +8544,7 @@ mod tests { "assetObjectId": "source-video-object" }}}), ); - } else if request_line - .starts_with("POST /api/editor/videos/generations ") - { + } else if request_line.starts_with("POST /api/editor/videos/generations ") { assert!(request_lower .contains("authorization: bearer resource-editor-external-key")); assert!(request_lower.contains("idempotency-key:")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index f30dd9710..acc780b40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -15,6 +15,7 @@ pub(crate) fn run_limited_local_command_at( } let game_index_path = root.join("game/index.html"); + prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; let html = fs::read_to_string(&game_index_path) .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; if !html.contains("(&content) @@ -736,14 +721,13 @@ pub(crate) fn write_project_permission_policy_at( validate_project_root(root)?; let policy = normalize_project_permission_policy(policy)?; let path = root.join(PROJECT_PERMISSION_POLICY_PATH); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建项目权限策略目录失败:{}: {error}", parent.display()))?; - } let content = serde_json::to_string_pretty(&policy) .map_err(|error| format!("序列化项目权限策略失败:{error}"))?; - fs::write(&path, format!("{content}\n")) - .map_err(|error| format!("写入项目权限策略失败:{}: {error}", path.display()))?; + crate::write_game_creator_private_file( + &path, + format!("{content}\n").as_bytes(), + "项目权限策略", + )?; append_agent_db_record( root, serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 00a8521a5..1b1ecd5a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1495,7 +1495,7 @@ fn automatic_local_game_project_rejects_symlinked_projects_root() { let error = create_automatic_local_game_project_at(&projects_root) .expect_err("symlinked automatic workspace root must fail"); - assert!(error.contains("普通文件夹")); + assert!(error.contains("不能包含符号链接")); assert!(fs::read_dir(&target).expect("read target").next().is_none()); fs::remove_dir_all(container).ok(); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d7eb156ad..7df62abd1 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -60,6 +60,7 @@ import type { GenerateLocalGameDraftResult, ImportCanvasExportResult, InitLocalProjectResult, + LauncherImportedAttachment, LimitedLocalCommandResult, ListLocalProjectFilesResult, LocalAgentMemoryResult, @@ -127,6 +128,10 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; +import { + type DirectCodexTurnAttachment, + toDirectCodexTurnAttachments, +} from './features/app-shell/directCodexTurnAttachments'; import { isDeveloperMode, isTransientProjectOpenMessage, @@ -406,6 +411,7 @@ type AppProps = { supervisorChatOnly?: boolean; initialSupervisorMessage?: string; initialCreationType?: HomeCreationType | null; + initialAttachments?: LauncherImportedAttachment[]; playRequest?: ProjectSupervisorComponentProps['playRequest']; onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled']; onManifestChange?: ( @@ -418,6 +424,14 @@ type AppProps = { summaries: ProjectAgentRuntimeSummary[], ) => void; onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void; + onMakeGameFromApprovedGdd?: (projectPath: string) => Promise; +}; + +type ExecuteChatAgentReplyInput = { + prompt: string; + clientTurnId?: string; + creationType?: HomeCreationType | null; + attachments?: DirectCodexTurnAttachment[]; }; export function App({ @@ -430,12 +444,14 @@ export function App({ supervisorChatOnly = false, initialSupervisorMessage = '', initialCreationType = null, + initialAttachments = [], playRequest = null, onPlayRequestHandled, onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, + onMakeGameFromApprovedGdd, }: AppProps = {}) { const { setTitle: setWindowTitle } = useWindowChrome(); // 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于 @@ -492,6 +508,7 @@ export function App({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), creationType: initialCreationType, + attachments: toDirectCodexTurnAttachments(initialAttachments), }); const handledPlayRequestRef = useRef(null); @@ -987,11 +1004,7 @@ export function App({ | null >(null); const executeChatAgentReplyRef = useRef< - ( - prompt: string, - directConversationTurnId?: string, - creationType?: HomeCreationType | null, - ) => Promise + (input: ExecuteChatAgentReplyInput) => Promise >(async () => undefined); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); @@ -2750,10 +2763,10 @@ export function App({ return; } recoveredDirectCodexTurnClaimsRef.current.add(claimKey); - void executeChatAgentReply( - unansweredDirectTurn.prompt, - unansweredDirectTurn.turnId, - ); + void executeChatAgentReply({ + prompt: unansweredDirectTurn.prompt, + clientTurnId: unansweredDirectTurn.turnId, + }); }); } } catch (error) { @@ -5222,7 +5235,7 @@ export function App({ return; } - void executeChatAgentReply(prompt); + void executeChatAgentReply({ prompt }); } async function executeLlmConfigStatus() { @@ -5363,11 +5376,12 @@ export function App({ } } - async function executeChatAgentReply( - prompt: string, - directConversationTurnId?: string, - creationType?: HomeCreationType | null, - ) { + async function executeChatAgentReply({ + prompt, + clientTurnId: directConversationTurnId, + creationType, + attachments, + }: ExecuteChatAgentReplyInput) { // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. if (directCodexProductRuntime) { @@ -5477,6 +5491,7 @@ export function App({ prompt: string; clientTurnId: string; creationType?: HomeCreationType; + attachments?: DirectCodexTurnAttachment[]; } = { projectPath: directProjectPath, prompt, @@ -5485,6 +5500,9 @@ export function App({ if (creationType) { directTurnInput.creationType = creationType; } + if (attachments?.length) { + directTurnInput.attachments = attachments; + } const reply = await directInvoke( 'chat_with_game_creator_direct_codex', directTurnInput, @@ -5802,11 +5820,12 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReplyRef.current( - latch.prompt, - directConversationTurnId, - latch.creationType, - ); + void executeChatAgentReplyRef.current({ + prompt: latch.prompt, + clientTurnId: directConversationTurnId, + creationType: latch.creationType, + attachments: latch.attachments, + }); }, [ chatAgentBusy, directCodexProductRuntime, @@ -10817,7 +10836,10 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReply(prompt, directConversationTurnId); + void executeChatAgentReply({ + prompt, + clientTurnId: directConversationTurnId, + }); } const visibleProfessionalAgentCards = agentStatusCards.filter( @@ -10908,6 +10930,14 @@ export function App({ planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} + onMakeGameFromApprovedGdd={ + onMakeGameFromApprovedGdd + ? () => + onMakeGameFromApprovedGdd( + localProject?.projectPath ?? projectPath, + ) + : undefined + } runtime={projectSupervisorRuntime} error={projectSupervisorRuntimeError} runtimeByAgentId={agentRuntimeById} @@ -11000,12 +11030,6 @@ export function App({ projectSupervisorRuntime={projectSupervisorRuntime} projectSupervisorRuntimeError={projectSupervisorRuntimeError} projectSupervisorTransientReply={projectSupervisorTransientReply} - planGddState={planGddState} - planGddHydrateBusy={planGddHydrateBusy} - planGddDecisionBusy={planGddDecisionBusy} - planGddError={planGddError} - onPlanGddRefresh={() => void hydratePlanGddState()} - onPlanGddDecision={decidePlanGdd} queueAgentRunControlFromPanel={queueAgentRunControlFromPanel} queueOrExecuteProjectIndex={queueOrExecuteProjectIndex} queuePendingCommand={queuePendingCommand} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 934dd15bf..6b5794624 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -48,6 +48,7 @@ export type LauncherImportedAttachment = { localPath?: string; status: 'imported' | 'failed'; error?: string; + size?: number; }; export type LocalProjectKind = 'web' | 'godot'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 6d63481a4..f39ce5eb3 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -77,6 +77,7 @@ export function WorkspaceLauncherShell({ activeProjectAgentResults, setAgentResults: setActiveProjectAgentResults, resetLauncherHomeDraft, + startGameFromApprovedGdd, createHomeDraftAutomatically, openProject, } = homeProject; @@ -338,6 +339,7 @@ export function WorkspaceLauncherShell({ initialProjectKind={currentProjectContext.projectKind} initialSupervisorMessage={currentProjectContext.initialPrompt} initialCreationType={currentProjectContext.creationType} + initialAttachments={currentProjectContext.attachments} orchestrationMode="single-supervisor" projectSupervisorOnly planningStartMode={ @@ -351,6 +353,7 @@ export function WorkspaceLauncherShell({ setActiveProjectAgentRuntimeSummaries } onAgentResultsChange={setActiveProjectAgentResults} + onMakeGameFromApprovedGdd={startGameFromApprovedGdd} /> } /> diff --git a/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts b/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts new file mode 100644 index 000000000..ece47fd13 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts @@ -0,0 +1,38 @@ +import type { LauncherImportedAttachment } from '../../app/types'; + +export type DirectCodexTurnAttachment = { + name: string; + mediaType: string; + size?: number; + localPath?: string; + status?: 'imported' | 'failed'; +}; + +export function toDirectCodexTurnAttachments( + imported: readonly LauncherImportedAttachment[] | null | undefined, +): DirectCodexTurnAttachment[] { + if (!imported?.length) { + return []; + } + return imported.map((item) => { + const attachment: DirectCodexTurnAttachment = { + name: item.fileName, + mediaType: item.mediaType, + }; + if ( + typeof item.size === 'number' && + Number.isFinite(item.size) && + item.size >= 0 + ) { + attachment.size = Math.trunc(item.size); + } + const localPath = item.localPath?.trim(); + if (localPath) { + attachment.localPath = localPath; + } + if (item.status === 'imported' || item.status === 'failed') { + attachment.status = item.status; + } + return attachment; + }); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index f87101410..8d8283eba 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -5,7 +5,11 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import type { ChatMessage, LocalProjectDirectoryStatus } from '../../app/types'; +import type { + ChatMessage, + LauncherImportedAttachment, + LocalProjectDirectoryStatus, +} from '../../app/types'; import type { HomeCreationType } from '../../view/home'; import type { LauncherView } from '../../view/layout'; import type { @@ -32,6 +36,7 @@ export type ProjectSupervisorComponentProps = { initialProjectKind?: 'web' | 'godot'; initialSupervisorMessage?: string; initialCreationType?: HomeCreationType | null; + initialAttachments?: LauncherImportedAttachment[]; orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; planningStartMode?: boolean; @@ -50,6 +55,7 @@ export type ProjectSupervisorComponentProps = { summaries: ProjectAgentRuntimeSummary[], ) => void; onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void; + onMakeGameFromApprovedGdd?: (projectPath: string) => Promise; }; export type WorkspaceLauncherShellProps = WorkspaceLauncherProps & { diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index b5f9e13cd..df66b0f1b 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -19,6 +19,7 @@ import type { LauncherProjectContext, LocalGameProjectRevisionStatus, LocalProjectDirectoryStatus, + LocalProjectFileResult, PendingNonEmptyProject, ProjectStartMode, TauriInvoke, @@ -48,6 +49,27 @@ type UseHomeProjectCreationOptions = { rememberRecentWorkspace: (projectPath: string) => void; }; +const APPROVED_GDD_BUILD_PROMPT = [ + '请按照附件中的已批准 GDD 开始建造这款游戏。', + '', + '这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。', + '', + '请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。', +].join('\n'); + +function createTextAttachmentFile(content: string) { + const file = new File([content], 'fast_gdd.md', { + type: 'text/markdown', + lastModified: Date.now(), + }); + if (typeof file.arrayBuffer !== 'function') { + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new TextEncoder().encode(content).buffer, + }); + } + return file; +} + export function useHomeProjectCreation({ setStatus, setLauncherView, @@ -77,6 +99,7 @@ export function useHomeProjectCreation({ const resetLauncherHomeDraft = useLauncherHomeDraftStore( (state) => state.reset, ); + const approvedGddStartInFlightRef = useRef(false); function validateProjectPath(nextProjectPath: string) { const trimmedProjectPath = nextProjectPath.trim(); @@ -91,6 +114,50 @@ export function useHomeProjectCreation({ return trimmedProjectPath; } + async function startGameFromApprovedGdd(nextProjectPath: string) { + if (approvedGddStartInFlightRef.current) { + return; + } + approvedGddStartInFlightRef.current = true; + const invoke = resolveTauriInvoke(); + try { + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + const projectPath = nextProjectPath.trim(); + if (!projectPath) { + throw new Error('当前项目路径无效'); + } + + setStatus('正在读取已批准 GDD'); + const result = await invoke( + 'read_local_project_file', + { + projectPath, + relativePath: 'game/fast_gdd.md', + commandId: 'file.read', + }, + ); + const file = createTextAttachmentFile(result.content); + + await createHomeDraftAutomatically( + { + creationType: 'game', + prompt: APPROVED_GDD_BUILD_PROMPT, + attachments: [ + { + id: `approved-gdd-${Date.now().toString(36)}`, + file, + }, + ], + }, + 'direct-build', + ); + } finally { + approvedGddStartInFlightRef.current = false; + } + } + function enterProjectDevelopment(context: LauncherProjectContext) { setCurrentProjectContext(context); setActiveProjectPreview(context.manifest.preview ?? null); @@ -145,6 +212,7 @@ export function useHomeProjectCreation({ mediaType, localPath: result.localPath, status: 'imported', + size: attachment.file.size, }); } catch (error) { imported.push({ @@ -152,6 +220,7 @@ export function useHomeProjectCreation({ mediaType, status: 'failed', error: error instanceof Error ? error.message : String(error), + size: attachment.file.size, }); } } @@ -600,6 +669,7 @@ export function useHomeProjectCreation({ projectBusy: projectAction !== null, pendingNonEmptyProject, resetLauncherHomeDraft, + startGameFromApprovedGdd, createHomeDraft, createHomeDraftAutomatically, openProject, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 628f6f517..5a470dd39 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -42,6 +42,7 @@ type GddApprovalCardProps = { action: PlanGddDecisionAction, comment: string | null, ) => Promise; + onMakeGame?: () => Promise; }; const stateLabels: Record = { @@ -82,6 +83,7 @@ export function PlanGddSurface({ error, onRefresh, onDecision, + onMakeGame, }: GddApprovalCardProps & { active?: boolean; projectPath: string }) { const showProgress = stageProgressVisible(state, active); const showCard = approvalCardVisible(state); @@ -98,6 +100,7 @@ export function PlanGddSurface({ state={state} active={active} projectPath={projectPath} + onMakeGame={onMakeGame} /> ) : null} {showCard ? ( @@ -118,14 +121,18 @@ export function PlanGddStageProgress({ state, active = false, projectPath = '', + onMakeGame, }: { state: PlanGddStateViewV1 | null; active?: boolean; projectPath?: string; + onMakeGame?: () => Promise; }) { const [detailsOpen, setDetailsOpen] = useState(false); const [openError, setOpenError] = useState(''); const [opening, setOpening] = useState(false); + const [makingGame, setMakingGame] = useState(false); + const [makeGameError, setMakeGameError] = useState(''); if (!state || !stageProgressVisible(state, active)) { return null; } @@ -198,6 +205,25 @@ export function PlanGddStageProgress({ > {opening ? '正在打开' : '打开文件'} + {onMakeGame ? ( + + ) : null} {openError ? ( ) : null} + {makeGameError ? ( + + {makeGameError} + + ) : null} ) : null} {detailsOpen && deliveredGdd ? ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 1e22942f0..dba7534dc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -68,6 +68,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { action: PlanGddDecisionAction, comment: string | null, ) => Promise; + onMakeGameFromApprovedGdd?: () => Promise; }; export function ProjectSupervisorView({ @@ -99,6 +100,7 @@ export function ProjectSupervisorView({ planGddError, onPlanGddRefresh, onPlanGddDecision, + onMakeGameFromApprovedGdd, ...runtimePanelProps }: ProjectSupervisorViewProps) { const submitLabel = needsUserInput @@ -121,6 +123,7 @@ export function ProjectSupervisorView({ error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} + onMakeGame={onMakeGameFromApprovedGdd} />
void; - onPlanGddDecision: ( - action: PlanGddDecisionAction, - comment: string | null, - ) => Promise; queueAgentRunControlFromPanel: (action: 'kill' | 'retry' | 'resume') => void; queueOrExecuteProjectIndex: () => Promise; queuePendingCommand: (command: PendingCommand) => void; @@ -272,12 +258,6 @@ export function ProjectWorkspaceChatPane({ projectSupervisorRuntime, projectSupervisorRuntimeError, projectSupervisorTransientReply, - planGddState, - planGddHydrateBusy, - planGddDecisionBusy, - planGddError, - onPlanGddRefresh, - onPlanGddDecision, queueAgentRunControlFromPanel, queueOrExecuteProjectIndex, queuePendingCommand, @@ -367,16 +347,6 @@ export function ProjectWorkspaceChatPane({
-
- {isPlanningLaneRuntime(projectSupervisorRuntime) ? ( - - ) : ( - - )} + {pendingCommand ? (
diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 0c9984c75..250193272 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @source '../../../packages/shared/src/components'; +@import '@genarrative/shared/styles.css'; @import '../../../packages/shared/src/theme.css'; :root { @@ -820,11 +821,12 @@ textarea { } .launcher-projects-page { - align-content: start; - grid-template-rows: auto minmax(0, 1fr); + display: flex; + flex-direction: column; gap: 18px; width: calc(100vw - var(--launcher-sidebar-width)); max-width: none; + flex: 1 1 auto; height: 100dvh; min-height: 0; margin: 0; @@ -833,6 +835,8 @@ textarea { } .launcher-main:has(.launcher-projects-page) { + display: flex; + flex-direction: column; height: 100dvh; min-height: 0; padding-bottom: 0; @@ -1004,8 +1008,9 @@ textarea { } .launcher-project-list-shell { - display: grid; - grid-template-rows: 42px minmax(0, 1fr); + display: flex; + flex-direction: column; + flex: 1 1 auto; min-height: 0; overflow: hidden; border: 1px solid var(--platform-surface-border); @@ -1026,6 +1031,7 @@ textarea { .launcher-project-table-header { height: 42px; + flex-shrink: 0; padding: 0 14px; border-bottom: 1px solid var(--platform-surface-border); background: color-mix( @@ -1046,6 +1052,7 @@ textarea { .launcher-project-table { display: grid; align-content: start; + flex: 1 1 auto; min-height: 0; overflow-y: auto; } @@ -5457,6 +5464,31 @@ iframe.preview-frame { visibility: visible; } +.game-resource-card-type-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + display: inline-flex; + max-width: calc(100% - 16px); + align-items: center; + min-width: 0; + padding: 4px 8px; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 999px; + background: rgb(75 48 38 / 84%); + color: #fff; + font-size: 10px; + font-weight: 900; + line-height: 1; + letter-spacing: 0.02em; + pointer-events: none; + text-overflow: ellipsis; + white-space: nowrap; + box-shadow: 0 8px 18px rgb(96 62 47 / 20%); +} + .game-resource-card-open { position: absolute; z-index: 1; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 272916fa5..f539fc02f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -82,10 +82,10 @@ import { normalizeInfiniteResourceCanvasViewport, RESOURCE_CANVAS_DRAG_THRESHOLD, RESOURCE_CANVAS_FIT_PADDING, - RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, RESOURCE_CANVAS_SECTION_MIN_HEIGHT, RESOURCE_CANVAS_SECTION_MIN_WIDTH, RESOURCE_CANVAS_SECTION_ORDER, + RESOURCE_CANVAS_VISIBLE_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasCardSize, resourceCanvasContentBounds, @@ -123,6 +123,7 @@ import { type ProjectResource, type ProjectResourceCategory, projectResourcesFromReadModels, + projectResourceTypeLabel, } from './resourceProjectionModel'; import { clampProjectResourceSectionZoom, @@ -393,6 +394,7 @@ export type ProjectDevelopmentViewProps = { }; const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER; +const visibleCategoryOrder = RESOURCE_CANVAS_VISIBLE_SECTION_ORDER; const categoryLabels: Record = { code: '游戏代码', @@ -592,6 +594,7 @@ const ResourceCard = memo(function ResourceCard({ const [decodedIdentity, setDecodedIdentity] = useState(null); const Icon = categoryIcons[resource.category]; const kind = projectResourceCardPreviewKind(resource); + const resourceTypeLabel = projectResourceTypeLabel(resource); const isMedia = kind === 'video' || kind === 'audio'; const mediaActive = activeMediaIdentity === previewIdentity; const sourceUrl = @@ -797,6 +800,13 @@ const ResourceCard = memo(function ResourceCard({ + + {resourceTypeLabel} +
) : (
- {categoryOrder.map((category) => { + {visibleCategoryOrder.map((category) => { const Icon = categoryIcons[category]; const sectionHeight = resourceSectionHeights.sectionStates.get(category); diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts index ac8ac9143..d4133ab05 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts @@ -44,6 +44,14 @@ export const RESOURCE_CANVAS_FIT_PADDING = 16; export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] = ['document', 'art', 'audio', 'code', 'version']; +/** + * Ordinary resource-canvas navigation intentionally omits game code. The + * complete five-section order above remains the internal layout/sidecar + * contract so existing code-resource coordinates are not rewritten. + */ +export const RESOURCE_CANVAS_VISIBLE_SECTION_ORDER: readonly ProjectResourceCanvasSection[] = + ['document', 'art', 'audio', 'version']; + export type ResourceCanvasCardSize = { width: number; height: number; @@ -1345,7 +1353,7 @@ export function fitResourceCanvasViewportToContent({ bounds, canvasSize, padding = RESOURCE_CANVAS_FIT_PADDING, - maxScale = MAX_SCALE, + maxScale = RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, }: { bounds: { x: number; y: number; width: number; height: number }; canvasSize: { width: number; height: number }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts index 6151887d7..b43bf1352 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts @@ -55,6 +55,18 @@ export type ProjectResource = { imageSequenceDurationMs?: number | null; }; +export type ProjectResourceTypeLabel = + | '图片' + | 'SVG' + | '视频' + | '音频' + | '文档' + | '任务产物' + | 'Agent 回执' + | '项目版本' + | '游戏代码' + | '未知'; + const documentExtension = /\.(md|markdown|mdx|txt|json|ya?ml|toml|csv|ini|conf|xml)$/iu; const gameCodeExtension = @@ -116,6 +128,55 @@ export function classifyProjectedResource(input: { return null; } +/** + * Converts an already projected resource into short user-facing type text. + * This is deliberately display-only: it derives from existing projection + * fields and does not add a backend/read-model attribute. + */ +export function projectResourceTypeLabel( + resource: Pick< + ProjectResource, + 'category' | 'subtype' | 'path' | 'mediaType' + >, +): ProjectResourceTypeLabel { + const subtype = resource.subtype.trim().toLowerCase(); + const path = resource.path.trim().toLowerCase(); + const mediaType = resource.mediaType.trim().toLowerCase(); + + if (resource.category === 'version' || subtype === 'project-version') { + return '项目版本'; + } + if (subtype === 'agent-result') { + return 'Agent 回执'; + } + if (resource.category === 'code') { + return '游戏代码'; + } + if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) { + return 'SVG'; + } + if ( + resource.category === 'audio' || + mediaType.startsWith('audio/') || + audioExtension.test(path) + ) { + return '音频'; + } + if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) { + return '视频'; + } + if (resource.category === 'document' || mediaType.startsWith('text/')) { + return '文档'; + } + if (resource.category === 'art' || mediaType.startsWith('image/')) { + return '图片'; + } + if (subtype === 'task-artifact') { + return '任务产物'; + } + return '未知'; +} + function resourcePriority(resource: ProjectResource) { if (resource.manifestAssetId) { return 4; diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 4b2750da9..95e3cfb61 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1,6 +1,8 @@ import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../src/app/constants'; import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model'; +import { useHomeProjectCreation } from '../../src/features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; +import type { LauncherView } from '../../src/view/layout'; import { act, agentRuntimeUserInputRequest, @@ -27,6 +29,38 @@ import { within, } from './harness'; +function ApprovedGddStartHarness() { + const [, setLauncherView] = React.useState( + 'project-development', + ); + const [, setStatus] = React.useState(''); + const [, setAgentChatProjectPath] = React.useState(''); + const controller = useHomeProjectCreation({ + setStatus, + setLauncherView, + setAgentChatProjectPath, + rememberRecentWorkspace: () => undefined, + }); + + if (controller.currentProjectContext) { + return React.createElement( + 'p', + { 'aria-label': '已进入自动游戏项目' }, + controller.currentProjectContext.initialPrompt, + ); + } + + return React.createElement( + 'button', + { + type: 'button', + onClick: () => + void controller.startGameFromApprovedGdd('/tmp/planning-project'), + }, + '直接用已批准 GDD 开始建造', + ); +} + export function registerClientHomeTests() { it('anchors the empty home input placeholder to the editor while the page scrolls', () => { renderLauncherAt('/?launcher'); @@ -1495,11 +1529,111 @@ export function registerHomeProjectCreationTests() { prompt: '按这个角色做游戏', creationType: 'game', clientTurnId: expect.any(String), + attachments: [ + { + name: '角色参考.png', + mediaType: 'image/png', + size: attachment.size, + localPath: 'assets/uploads/reference.png', + status: 'imported', + }, + ], }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', expect.anything(), ); + + expect(await screen.findByText('附件已经进入当前项目。')).not.toBeNull(); + const followUpInput = screen.getByLabelText('陶泥儿对话内容'); + fireEvent.change(followUpInput, { target: { value: '再补一句玩法' } }); + fireEvent.submit(followUpInput.closest('form') as HTMLFormElement); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(2); + }); + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { + projectPath: automaticProjectPath, + prompt: '再补一句玩法', + clientTurnId: expect.any(String), + }); + const followUpPayload = invoke.mock.calls.find( + ([command, args]) => + command === 'chat_with_game_creator_direct_codex' && + (args as Record | undefined)?.prompt === + '再补一句玩法', + )?.[1] as Record | undefined; + expect(followUpPayload).not.toHaveProperty('attachments'); + }); + + it('starts an automatic game project from the approved GDD', async () => { + const automaticProjectPath = '/tmp/approved-gdd-game'; + const manifest = createGameCreationAppManifest( + 'approved-gdd-game', + '已批准 GDD 游戏', + ); + const gddContent = '# Fast GDD\n\n批准后的方案内容'; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_file') { + return { + path: 'game/fast_gdd.md', + absolutePath: '/tmp/planning-project/game/fast_gdd.md', + content: gddContent, + }; + } + if (command === 'create_automatic_local_game_project') { + return { + projectPath: automaticProjectPath, + manifestPath: `${automaticProjectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'upload_local_asset') { + return { + id: 'approved-gdd-asset', + localPath: 'assets/uploads/fast_gdd.md', + absolutePath: `${automaticProjectPath}/assets/uploads/fast_gdd.md`, + manifestPath: `${automaticProjectPath}/.agent/manifest.json`, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + render(React.createElement(ApprovedGddStartHarness)); + + fireEvent.click( + screen.getByRole('button', { name: '直接用已批准 GDD 开始建造' }), + ); + fireEvent.click( + screen.getByRole('button', { name: '直接用已批准 GDD 开始建造' }), + ); + + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'create_automatic_local_game_project', + ), + ).toHaveLength(1); + }); + expect(screen.getByLabelText('已进入自动游戏项目').textContent).toContain( + '请按照附件中的已批准 GDD 开始建造这款游戏。', + ); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/planning-project', + relativePath: 'game/fast_gdd.md', + commandId: 'file.read', + }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('upload_local_asset', { + projectPath: automaticProjectPath, + fileName: 'fast_gdd.md', + mediaType: 'text/markdown', + bytes: Array.from(new TextEncoder().encode(gddContent)), + }); + }); }); it('keeps the home composer out of chat mode while automatic project creation is pending', async () => { @@ -1629,6 +1763,18 @@ export function registerHomeProjectCreationTests() { } else { expect(startCall?.[1]).not.toHaveProperty('source'); } + expect(startCall?.[1]).not.toHaveProperty('attachments'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( + '本轮用户附件', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_agent', + expect.anything(), + ); expect( invoke.mock.calls.filter( ([command]) => command === 'create_automatic_local_game_project', @@ -1637,6 +1783,98 @@ export function registerHomeProjectCreationTests() { }, ); + it('keeps 做方案 first turn on Supervisor without a Direct attachment sidecar', async () => { + const projectPath = '/tmp/home-planning-attachment'; + const manifest = createGameCreationAppManifest( + 'home-planning-attachment', + '首页策划附件', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + expectedRunProfile: 'standard', + }); + const fileBytes = Array.from(new TextEncoder().encode('png')); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'upload_local_asset') { + return { + id: 'asset-upload-plan-1', + localPath: 'assets/uploads/reference.png', + absolutePath: `${projectPath}/assets/uploads/reference.png`, + manifestPath: `${projectPath}/.agent/manifest.json`, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherAt('/?launcher', 'home', true); + + fireEvent.click(screen.getByRole('button', { name: '做方案' })); + const fileInput = + document.querySelector('input[type="file"]'); + expect(fileInput).not.toBeNull(); + const attachment = new File(['png'], '角色参考.png', { + type: 'image/png', + lastModified: 1, + }); + Object.defineProperty(attachment, 'arrayBuffer', { + value: async () => new Uint8Array(fileBytes).buffer, + }); + fireEvent.change(fileInput!, { target: { files: [attachment] } }); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '整理一份可玩原型'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('整理一份可玩原型'); + expect(promptInput.textContent).toContain('角色参考.png'); + }); + fireEvent.click(screen.getByRole('button', { name: '进入立项策划' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ + projectPath, + source: PROJECT_SUPERVISOR_PLAN_SOURCE, + }), + ); + }); + expect(invoke).toHaveBeenCalledWith('upload_local_asset', { + projectPath, + fileName: '角色参考.png', + mediaType: 'image/png', + bytes: fileBytes, + }); + const startCall = invoke.mock.calls.find( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ); + expect(startCall?.[1]).not.toHaveProperty('attachments'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( + 'assets/uploads/reference.png', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_agent', + expect.anything(), + ); + }); + it('surfaces the planning clarification card after 做方案 creates the project from home', async () => { // 上面那条只断言到「run 起来了、source 对」。真实故障恰好落在它之后:plan 根 run // 停在 waiting-for-user-input 并带回澄清请求,而工作台一直停在前端本地的占位文案, diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index 6a4ca9104..e87da4da5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -1,20 +1,37 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard'; import { agentRuntimeUserInputRequest, + App, createPlanGddStateView, createProjectSupervisorRuntimeHarness, expect, fireEvent, it, - openMainProject, - renderAppAt, + React, + render, screen, + vi, waitFor, within, } from './harness'; +function mountFormalSupervisor( + harness: ReturnType, +) { + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); +} + async function mountApprovalCard( harness: ReturnType, ) { @@ -22,8 +39,7 @@ async function mountApprovalCard( core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - renderAppAt('/'); - await openMainProject(harness.projectPath); + mountFormalSupervisor(harness); return await screen.findByLabelText('GDD 审批卡'); } @@ -34,8 +50,7 @@ async function mountPlanningSurface( core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - renderAppAt('/'); - await openMainProject(harness.projectPath); + mountFormalSupervisor(harness); return await screen.findByLabelText('立项策划阶段进度'); } @@ -382,6 +397,24 @@ export function registerPlanGddApprovalTests() { expect(args).toEqual({ projectPath: harness.projectPath }); }); + it('offers to make an approved GDD into a game reference on the home entry', async () => { + const onMakeGame = vi.fn(async () => undefined); + render( + React.createElement(PlanGddStageProgress, { + state: approvedPlanGddState(), + active: true, + projectPath: '/tmp/approved-gdd-project', + onMakeGame, + }), + ); + + fireEvent.click(screen.getByRole('button', { name: '做成游戏' })); + + await waitFor(() => { + expect(onMakeGame).toHaveBeenCalledTimes(1); + }); + }); + it('keeps the delivery row hidden while the approval projection is still recovering', async () => { // 恢复态下权威投影还没收敛,磁盘上那份 Markdown 未必是用户批的那版。此时给出口 // 等于让用户读一份可能已经失效的交付物。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index e6fb313ba..39654209b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -6,6 +6,7 @@ import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, + RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, } from '../../src/view/project-development/resourceCanvasLayoutModel'; import { normalizeProjectResourceGraph } from '../../src/view/project-development/resourceDependencyGraphModel'; import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay'; @@ -429,6 +430,13 @@ export function registerProjectWorkbenchFoundationTests() { localPath: 'assets/section.mp3', source: { kind: 'generated', taskId: 'audio-asset-plan' }, }, + { + id: 'section-code', + kind: 'game-code', + mediaType: 'text/javascript', + localPath: 'game/section.js', + source: { kind: 'generated', taskId: 'code-prototype' }, + }, ]; manifest.versions = [ { @@ -448,9 +456,13 @@ export function registerProjectWorkbenchFoundationTests() { ); addSectionResources(manifest); let layoutRevision = 0; + let graphResourceIds: string[] = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { + graphResourceIds = ( + (args?.resources as Array<{ resourceId: string }> | undefined) ?? [] + ).map(({ resourceId }) => resourceId); return resourceGraphForInputs(args); } if (command === 'read_local_project_resource_canvas_layout') { @@ -529,7 +541,12 @@ export function registerProjectWorkbenchFoundationTests() { Array.from(outline.querySelectorAll('strong')).map( (item) => item.textContent, ), - ).toEqual(['设计文档', '美术资源', '音乐音效', '游戏代码', '项目版本']); + ).toEqual(['设计文档', '美术资源', '音乐音效', '项目版本']); + expect(graphResourceIds).toContain('asset:section-code'); + expect(screen.queryByRole('button', { name: /section\.js/ })).toBeNull(); + expect( + screen.queryByRole('region', { name: '游戏代码资源画布' }), + ).toBeNull(); expect(outline.querySelectorAll('small')).toHaveLength(0); expect( screen.getByRole('region', { name: '设计文档资源画布' }), @@ -541,11 +558,12 @@ export function registerProjectWorkbenchFoundationTests() { screen.getByRole('button', { name: /下一页\s*美术资源/ }), ).not.toBeNull(); - fireEvent.click(within(outline).getByRole('button', { name: /游戏代码/ })); expect( - screen.getByRole('region', { name: '游戏代码资源画布' }), - ).not.toBeNull(); - expect(screen.getByText('没有匹配资源')).not.toBeNull(); + within(outline).queryByRole('button', { name: /游戏代码/ }), + ).toBeNull(); + expect( + screen.queryByRole('region', { name: '游戏代码资源画布' }), + ).toBeNull(); fireEvent.click(within(outline).getByRole('button', { name: /文档/ })); const dispatchPageWheel = (target: Element, deltaY = 160) => { @@ -606,10 +624,12 @@ export function registerProjectWorkbenchFoundationTests() { .map(Number); const fittedViewport = readViewport(); const fittedBounds = readFitBounds(); - expect( - Math.abs(fittedBounds[2]! * fittedViewport[2]! - 468) < 1 || - Math.abs(fittedBounds[3]! * fittedViewport[2]! - 268) < 1, - ).toBe(true); + const expectedDocumentFitScale = Math.min( + RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, + 468 / fittedBounds[2]!, + 268 / fittedBounds[3]!, + ); + expect(fittedViewport[2]).toBeCloseTo(expectedDocumentFitScale, 8); const viewportBeforeZoom = fittedViewport; const zoomWheel = new WheelEvent('wheel', { @@ -626,6 +646,15 @@ export function registerProjectWorkbenchFoundationTests() { }); expect(zoomWheelResult).toBe(false); expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!); + const documentViewportAfterZoom = readViewport(); + fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); + expect( + screen.getByRole('region', { name: '美术资源资源画布' }), + ).not.toBeNull(); + fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ })); + await waitFor(() => + expect(readViewport()).toEqual(documentViewportAfterZoom), + ); expect( screen.getByRole('region', { name: '设计文档资源画布' }), ).not.toBeNull(); @@ -1051,10 +1080,16 @@ export function registerProjectWorkbenchFoundationTests() { }), ); await waitFor(() => { + expect(screen.queryByLabelText('资源栏目大纲')).toBeNull(); + expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4); expect( - within(screen.getByLabelText('资源栏目大纲')).queryByRole('button', { - name: /有新资源/, - }), + screen + .getByLabelText(/资源(?:依赖|类型)视图/) + .classList.contains('game-resource-canvas--paged'), + ).toBe(false); + expect(screen.queryByRole('button', { name: /game\.js/ })).toBeNull(); + expect( + screen.queryByRole('region', { name: '游戏代码资源画布' }), ).toBeNull(); }); }); @@ -1224,14 +1259,8 @@ export function registerProjectWorkbenchFoundationTests() { .getByLabelText(/资源(?:依赖|类型)视图/) .classList.contains('game-resource-canvas--dependency'), ).toBe(false); - expect(screen.getAllByText('暂无已登记资源')).toHaveLength(5); - for (const label of [ - '设计文档', - '美术资源', - '音乐音效', - '游戏代码', - '项目版本', - ]) { + expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4); + for (const label of ['设计文档', '美术资源', '音乐音效', '项目版本']) { expect(screen.getByRole('region', { name: label })).not.toBeNull(); } }; @@ -1394,6 +1423,9 @@ export function registerProjectWorkbenchFoundationTests() { expect(heroCard?.textContent).not.toContain('hero.png'); expect(heroCard?.textContent).not.toContain('assets/hero.png'); expect(heroCard?.textContent).not.toContain('Agent 生成'); + expect( + heroCard?.querySelector('[data-resource-type="图片"]')?.textContent, + ).toBe('图片'); expect( heroCard?.querySelector('.game-resource-card-open button'), ).toBeNull(); @@ -1411,7 +1443,12 @@ export function registerProjectWorkbenchFoundationTests() { }); showResourcePage('设计文档'); act(() => observer.triggerVisible()); - await screen.findByText(/这是安全的卡片正文摘要。/); + const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/); + expect( + documentSummary + .closest('.game-resource-card') + ?.querySelector('[data-resource-type="文档"]'), + ).not.toBeNull(); showResourcePage('美术资源'); expect( invoke.mock.calls.some( @@ -1425,6 +1462,9 @@ export function registerProjectWorkbenchFoundationTests() { name: '播放 intro.mp4', }); const videoCard = videoControl.closest('.game-resource-card'); + expect( + videoCard?.querySelector('[data-resource-type="视频"]'), + ).not.toBeNull(); const video = videoCard?.querySelector('video'); expect(video).not.toBeNull(); expect(video?.preload).toBe('auto'); @@ -1475,7 +1515,13 @@ export function registerProjectWorkbenchFoundationTests() { expect(document.activeElement).toBe(stableVideoControl); showResourcePage('音乐音效'); - fireEvent.click(screen.getByRole('button', { name: '播放 theme.mp3' })); + const audioControl = screen.getByRole('button', { name: '播放 theme.mp3' }); + expect( + audioControl + .closest('.game-resource-card') + ?.querySelector('[data-resource-type="音频"]'), + ).not.toBeNull(); + fireEvent.click(audioControl); await waitFor(() => { expect( invoke.mock.calls.some( @@ -2800,6 +2846,82 @@ export function registerProjectWorkbenchFoundationTests() { }); }); + it('hides dependency edges whose code endpoint is not visible while preserving visible edges', async () => { + const visibleEdgeId = 'asset-reference:["art-a","art-b"]'; + const hiddenEdgeId = 'asset-reference:["code","art-a"]'; + const graph = normalizeProjectResourceGraph({ + resourceIds: ['code', 'art-a', 'art-b'], + referenceEdges: [ + { + id: visibleEdgeId, + kind: 'asset-reference', + sourceResourceId: 'art-a', + targetResourceId: 'art-b', + cyclic: false, + }, + { + id: hiddenEdgeId, + kind: 'asset-reference', + sourceResourceId: 'code', + targetResourceId: 'art-a', + cyclic: false, + }, + ], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }); + const positions: ProjectResourceCanvasPosition[] = [ + { + resourceId: 'code', + section: 'code', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'art-a', + section: 'art', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'art-b', + section: 'art', + x: 0, + y: 144, + manuallyPlaced: false, + }, + ]; + + render( + React.createElement(ResourceDependencyOverlay, { + graph, + positions, + section: 'art', + visibleResourceIds: new Set(['art-a', 'art-b']), + }), + ); + + const overlay = await screen.findByTestId( + 'resource-dependency-overlay-art', + ); + await waitFor(() => { + const edgeById = (edgeId: string) => + Array.from( + overlay.querySelectorAll('[data-edge-id]'), + ).find((edge) => edge.getAttribute('data-edge-id') === edgeId); + expect(edgeById(visibleEdgeId)).not.toBeNull(); + expect(edgeById(hiddenEdgeId)).toBeUndefined(); + }); + }); + it('coalesces section scroll geometry, keeps partial endpoints stable, and cleans one dependency observer', async () => { const referenceId = 'asset-reference:["resource-a","resource-b"]'; const graph = normalizeProjectResourceGraph({ diff --git a/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts b/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts new file mode 100644 index 000000000..7ddc3a0c1 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { toDirectCodexTurnAttachments } from '../src/features/app-shell/directCodexTurnAttachments'; + +describe('toDirectCodexTurnAttachments', () => { + it('maps imported files and omits error plus empty localPath', () => { + expect( + toDirectCodexTurnAttachments([ + { + fileName: '角色参考.png', + mediaType: 'image/png', + localPath: 'assets/uploads/upload-1-角色参考.png', + status: 'imported', + size: 12, + }, + { + fileName: 'lost.bin', + mediaType: 'application/octet-stream', + status: 'failed', + error: '磁盘不可写', + size: 2, + }, + ]), + ).toEqual([ + { + name: '角色参考.png', + mediaType: 'image/png', + size: 12, + localPath: 'assets/uploads/upload-1-角色参考.png', + status: 'imported', + }, + { + name: 'lost.bin', + mediaType: 'application/octet-stream', + size: 2, + status: 'failed', + }, + ]); + }); + + it('returns an empty list when there is nothing to map', () => { + expect(toDirectCodexTurnAttachments(undefined)).toEqual([]); + expect(toDirectCodexTurnAttachments([])).toEqual([]); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index 92ef7f361..7ff71b3da 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; +import { + projectResourcesFromReadModels, + projectResourceTypeLabel, +} from '../src/view/project-development/resourceProjectionModel'; describe('项目资源投影', () => { it('只把明确资源投影到固定分类,未知任务产物不会伪装成项目版本', () => { @@ -225,4 +228,119 @@ describe('项目资源投影', () => { expect(versions[0]?.version?.childVersionIds).toEqual(['version-child']); expect(versions[1]?.version?.parentVersionId).toBe('version-root'); }); + + it('按稳定优先级推导资源卡类型标识并为未知类型兜底', () => { + expect( + projectResourceTypeLabel({ + category: 'version', + subtype: 'project-version', + path: 'versions/v1', + mediaType: '正式项目版本', + }), + ).toBe('项目版本'); + expect( + projectResourceTypeLabel({ + category: 'document', + subtype: 'agent-result', + path: '专业 Agent 文本回执', + mediaType: 'Agent 历史文本回执', + }), + ).toBe('Agent 回执'); + expect( + projectResourceTypeLabel({ + category: 'document', + subtype: 'task-artifact', + path: 'memory/plan.md', + mediaType: '项目文档', + }), + ).toBe('文档'); + expect( + projectResourceTypeLabel({ + category: 'audio', + subtype: 'task-artifact', + path: 'audio/theme.wav', + mediaType: '音乐音效产物', + }), + ).toBe('音频'); + expect( + projectResourceTypeLabel({ + category: 'art', + subtype: 'task-artifact', + path: 'assets/hero.svg', + mediaType: '美术产物', + }), + ).toBe('SVG'); + expect( + projectResourceTypeLabel({ + category: 'art', + subtype: 'task-artifact', + path: 'assets/unknown', + mediaType: '美术产物', + }), + ).toBe('图片'); + expect( + projectResourceTypeLabel({ + category: 'art', + subtype: 'character', + path: 'assets/hero.svg', + mediaType: 'image/svg+xml', + }), + ).toBe('SVG'); + expect( + projectResourceTypeLabel({ + category: 'art', + subtype: 'video', + path: 'assets/intro.mp4', + mediaType: 'video/mp4', + }), + ).toBe('视频'); + expect( + projectResourceTypeLabel({ + category: 'art', + subtype: 'character', + path: 'assets/hero.png', + mediaType: 'image/png', + }), + ).toBe('图片'); + expect( + projectResourceTypeLabel({ + category: 'document', + subtype: 'attachment', + path: 'docs/rules.yaml', + mediaType: 'application/yaml', + }), + ).toBe('文档'); + expect( + projectResourceTypeLabel({ + category: 'document', + subtype: 'unknown', + path: 'data/blob', + mediaType: 'application/octet-stream', + }), + ).toBe('文档'); + expect( + projectResourceTypeLabel({ + category: 'code', + subtype: 'source', + path: 'game/main.ts', + mediaType: 'text/typescript', + }), + ).toBe('游戏代码'); + expect( + projectResourceTypeLabel({ + category: 'art', + subtype: 'unknown', + path: 'assets/blob', + mediaType: 'application/octet-stream', + }), + ).toBe('图片'); + expect( + projectResourceTypeLabel({ + category: 'unsupported' as never, + subtype: 'unknown', + path: 'data/blob', + mediaType: 'application/octet-stream', + }), + ).toBe('未知'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index 959f7c5af..db6b2df22 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -18,6 +18,7 @@ import { RESOURCE_CANVAS_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, + RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, type ResourceCanvasCardSize, resourceCanvasContentBounds, resourceCanvasImageCardSize, @@ -831,15 +832,23 @@ describe('resource canvas variable card geometry', () => { expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8); }); - it('caps the initial fit without limiting explicit canvas zoom', () => { - const viewport = fitResourceCanvasViewportToContent({ - bounds: { x: 0, y: 0, width: 180, height: 128 }, - canvasSize: { width: 800, height: 600 }, - maxScale: 1.5, - }); + it.each(['document', 'art', 'audio', 'code', 'version'])( + 'caps the initial fit for every resource category (%s) while keeping explicit zoom overrides available', + () => { + const viewport = fitResourceCanvasViewportToContent({ + bounds: { x: 0, y: 0, width: 180, height: 128 }, + canvasSize: { width: 800, height: 600 }, + }); + const explicitViewport = fitResourceCanvasViewportToContent({ + bounds: { x: 0, y: 0, width: 180, height: 128 }, + canvasSize: { width: 800, height: 600 }, + maxScale: 2.5, + }); - expect(viewport.scale).toBe(1.5); - }); + expect(viewport.scale).toBe(RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE); + expect(explicitViewport.scale).toBe(2.5); + }, + ); it('accepts card sizes directly on resource items for hook integration', () => { const wide = { diff --git a/apps/ai-game-creator-shell/tsconfig.json b/apps/ai-game-creator-shell/tsconfig.json index 894821755..e18485909 100644 --- a/apps/ai-game-creator-shell/tsconfig.json +++ b/apps/ai-game-creator-shell/tsconfig.json @@ -22,7 +22,22 @@ ], "@genarrative/image-canvas-react": [ "packages/image-canvas-react/src/index.ts" - ] + ], + "@genarrative/shared": ["packages/shared/src/index.ts"], + "@genarrative/shared/components": [ + "packages/shared/src/components/index.ts" + ], + "@genarrative/shared/components/account": [ + "packages/shared/src/components/account.ts" + ], + "@genarrative/shared/components/ui": [ + "packages/shared/src/components/ui/index.ts" + ], + "@genarrative/shared/components/ui/*": [ + "packages/shared/src/components/ui/*" + ], + "@genarrative/shared/lib/*": ["packages/shared/src/lib/*"], + "@genarrative/shared/lib": ["packages/shared/src/lib/index.ts"] } }, "include": ["src", "vite.config.ts"] diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index 8f784f2a0..71b012d81 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -105,6 +105,46 @@ export default defineConfig({ 'packages/image-canvas-react/src/index.ts', ), }, + { + find: '@genarrative/shared/styles.css', + replacement: resolve( + repoRoot, + 'packages/shared/src/components/styles.css', + ), + }, + { + find: '@genarrative/shared/theme.css', + replacement: resolve(repoRoot, 'packages/shared/src/theme.css'), + }, + { + find: '@genarrative/shared/components/ui', + replacement: resolve( + repoRoot, + 'packages/shared/src/components/ui/index.ts', + ), + }, + { + find: '@genarrative/shared/lib', + replacement: resolve(repoRoot, 'packages/shared/src/lib'), + }, + { + find: '@genarrative/shared/components/account', + replacement: resolve( + repoRoot, + 'packages/shared/src/components/account.ts', + ), + }, + { + find: '@genarrative/shared/components', + replacement: resolve( + repoRoot, + 'packages/shared/src/components/index.ts', + ), + }, + { + find: '@genarrative/shared', + replacement: resolve(repoRoot, 'packages/shared/src/index.ts'), + }, ], dedupe: ['react', 'react-dom'], }, diff --git a/components.json b/components.json new file mode 100644 index 000000000..035330aed --- /dev/null +++ b/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@genarrative/shared/components", + "ui": "@genarrative/shared/components/ui", + "utils": "@genarrative/shared/lib/utils", + "lib": "@genarrative/shared/lib" + } +} diff --git a/docs/README.md b/docs/README.md index 7b24f1cda..40195c0ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,8 @@ ## AI 游戏创作与 Agent Runtime - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 +- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 - [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):当前策划入口、审批和恢复合同。 - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) @@ -30,6 +32,7 @@ ## 图片画布与媒体 +- [共享基础组件库与展示页](./technical/【前端架构】共享基础组件库与展示页-2026-08-26.md):网站与客户端复用的无业务 UI chrome、样式边界和 `/components` 展示页。 - [客户端素材创作无限画布阶段一合同](./technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md) - [图片画布结构化持久化与迁移回滚](./【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md) - [编辑器生成结果原子提交与幂等重放](./technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md) diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 5ff2dee1f..4ae5a4fc8 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -322,7 +322,7 @@ type UpdateProjectResourceCanvasLayoutResult = - type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的历史坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪、`dependencyDepth` 或资源拓扑身份签名(精确引用端点和聚合 task-flow 成员)变化后按最终拓扑确定性重算。签名以稳定资源 ID 的规范端点 / 成员序列生成固定大小摘要,不使用显示名称或浏览器测量值;自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。 - 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。 - 窗口尺寸变化只改变当前栏目的可视范围,不回写或裁切持久坐标,也不因资源 extent 或 resize 把已平移的 viewport 拉回内容边界。当前客户端继续以 `1280×800` 横屏合同验收。 -- 任一栏目出现资源后,资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本` 五栏目分页画布;每个栏目按 `projectId + mode + category` 保留独立 viewport。普通 wheel 切换栏目,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。 +- 任一可见资源出现后,普通用户资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 项目版本` 四栏目分页画布;游戏代码仍保留在内部资源、布局和依赖事实中,但不进入普通资源画布的导航、分页、卡片、搜索或详情入口。每个可见栏目按 `projectId + mode + category` 保留独立 viewport,普通 wheel 切换栏目,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。 - 首次载入项目中的既有资源不显示未读标识。当前会话内,非当前栏目出现稳定 ID 的新资源时,在对应栏目名称右上角显示红点;当前栏目新增资源不显示红点,用户通过点击、滚轮或程序跳转进入该栏目后立即清除。未读状态只属于当前前端会话,并按 `projectPath + projectId` 隔离,切换项目时清空,不写入 manifest、布局 sidecar 或后端。 - 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready` 或 `failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。 - 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 @@ -355,7 +355,7 @@ type UpdateProjectResourceCanvasLayoutResult = ### 5.3 资源类型与替换兼容性(P1) -实现状态(2026-08-23):当前资源投影与栏目页顺序收口为“设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本”。设计文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术资源接收图片、SVG、动画和视频类产物;音乐音效接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。 +实现状态(2026-08-29):内部资源投影仍识别“设计文档、美术资源、音乐音效、游戏代码、项目版本”五类事实,但普通用户资源画布只展示“设计文档 -> 美术资源 -> 音乐音效 -> 项目版本”四个栏目;游戏代码不进入画布导航、分页、卡片、搜索或详情入口。设计文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术资源接收图片、SVG、动画和视频类产物;音乐音效接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码继续接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript,底层文件、manifest 事实、生成/编辑/运行能力、项目版本引用与依赖关系不变。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。 资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。 @@ -533,12 +533,12 @@ type ProjectAgentMudPointAttribution = { ### 7.5 资源栏目分页无限画布验收 -1. 完全空项目继续显示全部栏目的分区展览;任一栏目出现资源后,dependency / type 都切换为固定五栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部栏目,空栏目也可打开空画布。 -2. 每个 `projectId + dependency|type + document|art|audio|code|version` 组合保留独立 viewport;切换栏目、模式、项目和打开 / 关闭详情后恢复对应平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport。 +1. 完全空项目继续显示四个可见栏目的分区展览;任一可见栏目出现资源后,dependency / type 都切换为固定四栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部可见栏目,空栏目也可打开空画布;游戏代码栏目和代码卡片不出现。 +2. 每个 `projectId + dependency|type + document|art|audio|version` 组合保留独立 viewport;隐藏代码资源的历史内部坐标和 sidecar 会话状态不被删除或重写,切换栏目、模式、项目和打开 / 关闭详情后恢复对应可见栏目平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport。 3. 当前栏目允许空白拖动无限平移;`Ctrl/Cmd + wheel` 以指针为锚点缩放,显式复位按包含负坐标资源在内的完整 bounds 适配内容。非空状态不显示分区高度、分区内部滚动或分区内容倍率操作。 4. 资源卡超过 `5px` 阈值后进入拖动,预览和 dependency 线同步移动;成功释放只提交一次 `manuallyPlaced=true` CAS,取消、移出释放、媒体控制点击和未超过阈值均不写布局。 5. dependency 引导线消费当前栏目的同类型精确引用,并与卡片共享同一 viewport transform;平移、缩放、拖动预览、搜索和 resize 后端点保持对齐,type 模式不渲染引导线。 -6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、视频、音频、文档、代码、版本卡片及非模态详情回归全部通过。 +6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本卡片及非模态详情回归全部通过,游戏代码仅保留内部事实而不进入普通资源画布。 ### 7.6 阶段七完整验收 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f7461e7dd..6fd963eb7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,4 +1,5 @@ # 决策记录 + > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 @@ -14,8 +15,32 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-08-30 批准 GDD 直接进入做游戏链路 + +- 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。 +- 决策:批准态 GDD 交付行提供“做成游戏”按钮。点击后读取当前项目的权威 `game/fast_gdd.md`,直接创建自动游戏工作区、导入 `text/markdown` 参考附件,并以固定建造指令自动启动 Direct Codex;不再回首页等待用户二次提交。该动作不复制原项目的 `approvedGddRef`、planning sidecar 或 approval receipt。 +- 影响范围:AGC 前端 GDD 交付行与现有自动建项/附件导入/Direct Codex 链路;移除首页 RichInputArea 的 GDD 一次性预填链路;不新增 HTTP API、SpacetimeDB schema、迁移、OpenAPI 或正式构建绑定。 +- 验证方式:批准态按钮直接创建工作区、导入附件、携带固定首条指令进入项目工作台且重复点击不重复创建的 appSurface 回归;类型检查、编码检查和 `git diff --check` 通过。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。 + --- +## 2026-08-31 Direct 回合把 Codex item 落成有界行为账本 + +- 背景:sidecar 已让模型看见本轮附件路径,但 native 读 / MCP / 写文件只存在于隔离 `CODEX_HOME` 的瞬时 stdout,回合结束即删。无法判断「没读附件」还是「读了仍走默认收集类」。 +- 决策:GUI DirectProject 每个 `clientTurnId` 追加 `.agent/runtime/direct-codex/turns/.jsonl`,并在 `agent.db` 写一条 `direct.codex.turn` 摘要。记 sidecar 提供的路径与文件 hash、`item/completed` 的 Read/List/Search/MCP/写文件(不含 stdout、patch、MCP result),以及 `offeredRead` / `firstDesign`。审计 fail-open,不阻断做游戏。Home、CLI、Supervisor 收据模型不接。不灌附件正文,不强制读取,不为 GDD 开特例。 +- 影响范围:`direct_codex_audit.rs`、Direct GUI command 边界、Codex collect 循环;前端 / jsonl 气泡 / sidecar 文案不变。 +- 验证方式:Rust fixture 覆盖 turn_start hash、绝对路径相对化、stdout/diff 不落盘、art brief 保留、list/search 不算已读、firstDesign 顺序、256 条截断、写盘失败不 panic;sidecar 渲染与 Direct 活动词测试保持通过。 +- 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、issue #212。 + +## 2026-08-31 Direct 本轮附件只映射路径,不灌正文、不区别 GDD + +- 背景:issue #212。首页附件已经复制到 `assets/uploads/` 并登记,但 Direct 首轮只把用户原文发给 Codex,原文件名不是磁盘路径,模型会另起一套玩法。 +- 决策:Home 与 Project 共用 `DirectCodexTurnAttachment`。有项目路径或导入状态时,只在发给 Codex 的 user prompt 末尾附有界 sidecar(原名 → 项目相对路径、类型、大小、状态);无路径且无状态时保持首页元数据文案。不灌正文、不强制读取、不按 GDD 开特例。做成游戏固定 prompt 不改,同一条 Direct 首轮附件链自动吃到 sidecar。jsonl 与工作台气泡仍只写用户原文。 +- 影响范围:`direct_codex_attachments.rs`、Direct command 边界、首页建项 latch、工作台首轮 invoke;Supervisor / 做方案首轮忽略附件 sidecar。 +- 验证方式:Rust 渲染测试(Home 逐字兼容、Project 映射、非法路径);home.suite 附件 Direct invoke 含 `localPath`;无附件不出现 `attachments` 键;做方案首轮仍走 Supervisor 且无 sidecar;后续手打消息不带 attachments。 +- 关联文档:`docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`、issue #212。 + ## 2026-08-26 运行中自主扩图提案留在编排层 - 背景:`agent-runtime-orchestration` 已能构造和调度动态 DAG,但 LLM 在执行中发现缺少步骤时没有通用的安全扩图合同。 @@ -30,6 +55,7 @@ - 产品边界:16 个游戏任务、六组角色、产物/验收条件、Evaluator Markdown 和中文语义路由继续留在 `platform-agent`;AGC 组合根使用公共层校验任务图与 `AgentCatalog`。Runtime store、Runner、Provider、权限、ToolHost、委派 journal、isolated write scope 和 `.agent/runtime/**` 不迁移、不双写。 - 验证方式:非游戏 conformance 覆盖并行分支、汇合、repair closure、AgentCatalog 和非法图失败关闭;`platform-agent` 锁定种子 DAG 与现役波次/返工顺序,并验证环拒绝和 catalog 注入。根检查脚本必须执行新 crate 测试。 - 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.54。 + ## 2026-08-27 `plan.submit_gdd` 拒绝无审批决定的 `user_revision` - 背景:结构校验允许 `round=0 + user_revision + confirmed`,提交闸原先只做结构、身份和 Session CAS。Provider 可在首次 collecting、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。 @@ -108,6 +134,7 @@ - 决策:三个工具调用的 arguments 统一限制为 `1 MiB`,先解析通用 JSON 并迭代检查,再进入递归业务类型。结构识别按每棵树独立限制 `512` 个 LLM 节点 / `32` 层,不跨树求和且不计 Rust 页面根;语义建议限制 `4` 节点 / `4` 层;合并计划限制 `512` 节点 / `32` 层。超限整次拒绝,不截断或交付部分结果,日志不记录 arguments 正文。 - 输入边界:`merge_ui` 继续直接接收 `State`,不修改 Tauri/frontend IPC 参数;进入 Rust 后、发起 LLM 前按每棵源树独立限制 `512` 节点 / `32` 层,不跨树求和,并限制 `2 MiB` 序列化投影。UI 设计参考图只设单张 `5 MiB` 上限,不设批次合计或像素数上限;元数据检查、有限读取和 base64 编码进入 blocking worker,不新增命令超时。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-08-19 M1 审查后四项可靠性修复 - **审批 stale 收口**:`status` 或 `phase` 为 `needs-reconciliation` 的策划根不再满足审批所需的 active 身份;审批命令返回 `PLAN_STALE_APPROVAL`,并且不得创建 approval receipt。 @@ -152,6 +179,7 @@ - **验证**:`appSurface.test.ts` **381 passed / 0 failed**(378 既有 + 3 新增);`agentTraceSummary` 与 `rememberCommand`(另两个 import `src/App` 的用例文件)13 passed;`agc:typecheck` 通过;6 个改动/新增文件 ESLint `--max-warnings 0` 通过;`check:encoding` 5409 文件通过。不改 Rust——三条全在前端,后端语义已经正确。 - **对既有记录的更正**:M1D-1 与 M1D-2 两条记录分别称「Shell TypeScript typecheck 仍被仓库既有依赖缺失阻断」「appSurface UI suite 受仓库现有缺失 Tauri plugin 依赖阻断,未把该基线失败归因于本包」,在原分支主工作树上都不成立:`agc:typecheck` 干净退出,appSurface 378 条全绿;两道门分别位于 CI 的 `check:native-shells`(且 typecheck 排在 cargo test 之前)与 Frontend tests 内,一直是活的。实际情况与记录相反——`bf2185fba` 改名 `taskGroupLabels.design` 后,appSurface 有 8 个用例文件的断言变红,随后由 `c6a08ef98` 修复;把该套件记为「基线阻断、不归因本包」正是让这条自带回归合入的原因。**隔离工作树的依赖缺失不能作为跳过门禁的依据,须回原工作树复跑后再下结论。** - **未修的审查发现(本次不并入,单列后续)**:① hydrate 在校验 GDD/session 的 projectId 与 manifest 一致之前,已执行 `reconcile_plan_gdd_approval_projections_at`、session previous 提升与 index 重建等落盘修复,违反第 18.3 节固定顺序,其中 `session.previous.json` 的提升+删除不可逆(触发需外部篡改 `.agent/`,App 自身流程造不出该分歧);② 项目写锁竞争时 `acquire_project_write_lock` 的错误原文内嵌项目绝对路径,被原样回传前端,违反第 18.3 节「返回值不包含绝对路径」,常态可达;③ design 组展示名只改了 `taskGroupLabels` 一本字典,`agentPresentation.ts` 的 `groupConfigs` 与 `view/project-development/index.tsx` 的 `summarizeAgent` 仍硬编码「策划 Agent」,与新阶段「立项策划」同屏共存,违反第 18.2 节;④ 阶段进度「轮次 X/3」直接透传 0-indexed 的 `clarificationRound` 未 +1(后端自己用的是 `current_round + 1`),最后一轮显示「轮次 2/3」,字面暗示还剩一轮。 + ## 2026-08-18 M1E 隔离工作树:Fast GDD submit 有界拒绝与覆盖审计 - **范围与结论**:在 `codex/genarrative-isolated` 上按 M1E 只接受具备完整触发链的缺陷。确认 Provider 连续输出不合法 `plan.submit_gdd` 时,Runtime 原有「rejected observation → 同 child run 续跑」链没有次数上限,模型可反复请求 tool-plan 并累积历史 observation;这是可达的 prompt 膨胀与资源消耗链。 @@ -288,7 +316,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 不做:改 walker、透明补边、裁切、横纵不同倍率、Lanczos / bilinear、另存逻辑图、前端框缩放、失败路径、新测试。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - - 背景:真实 `gpt-5.6-sol / max` 验收中,`art-director` 失败后已形成 `ready + needs-repair` delivery,但认领、合同读取、claim observation 和完成 blocker 均硬编码为 Supervisor-only;实际直属父 Run `code-prototype` 无法消费回执,随后又发起 29 次 Provider 请求。 - 验证方式:覆盖合法认领与合同精确读取、错误 Agent/Run/delegation 拒绝、delivery 身份篡改阻断、唯一安全默认返工、普通失败零后续 Provider lifecycle,以及新的真实 Provider 空项目轮次。顺带收紧 `validate_executable_inline_javascript_syntax`:正文游离 `<` 不再吞掉后续 `