From dcd8901246937c8f5a1aa8ab740f626c8ca6edc6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 31 Jul 2026 13:32:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E8=81=8A=E5=A4=A90.1.1=E5=B9=B6=E5=BC=BA?= =?UTF-8?q?=E5=8C=96=E8=AF=95=E7=8E=A9=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增只能打开游戏聊天页的0.1.1独立release构建配置。 内置Runner启动、诊断日志、Windows后台进程与退出收束。 修复自动预览启动、同页版本刷新和结构化验收状态展示。 强化试玩协议、窗口末端采样与固定失败识别,允许正常输赢但拒绝无法推进。 完善旧验收回执自愈和当前场景指纹完成门。 补齐Rust、前端、真实Chrome回归测试及项目文档。 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/build-game-chat-release.mjs | 39 + .../scripts/check-config.mjs | 142 +- .../src-tauri/Cargo.toml | 6 +- .../agent/generation/loop_orchestration.rs | 25 +- .../src-tauri/src/agent/runtime_actions.rs | 2 + .../src/agent/runtime_actions/action_audit.rs | 29 +- .../agent/runtime_driver/lifecycle_control.rs | 9 +- .../src/agent/runtime_driver/main_loop.rs | 20 +- .../agent/runtime_driver/main_loop_tests.rs | 12 +- .../agent/runtime_driver/provider_recovery.rs | 9 +- .../src/agent/runtime_driver/task_queue.rs | 2 +- .../runtime_protocol/autonomous_completion.rs | 34 +- .../autonomous_completion_contract_tests.rs | 240 ++- .../agent/runtime_protocol/provider_retry.rs | 252 +++- .../runtime_protocol/real_e2e_checkpoint.rs | 23 +- .../src-tauri/src/agent/runtime_state.rs | 10 +- .../src-tauri/src/browser/playtest/generic.rs | 540 ++++++- .../src-tauri/src/browser/playtest/mod.rs | 74 +- .../src-tauri/src/browser/tests.rs | 647 +++++++- .../src-tauri/src/command_exec.rs | 13 +- .../src-tauri/src/config.rs | 227 ++- .../src-tauri/src/git_inspect.rs | 1 + .../src-tauri/src/main.rs | 631 +++++++- .../src-tauri/src/mcp.rs | 1 + .../src-tauri/src/preview.rs | 93 +- .../src-tauri/src/project/verification.rs | 9 +- .../src-tauri/src/repository_context.rs | 1 + .../src-tauri/src/runner.rs | 6 +- .../src-tauri/src/runner/client.rs | 444 +++++- .../src-tauri/src/runner/dispatch.rs | 9 + .../src-tauri/src/runner/endpoint.rs | 78 +- .../src-tauri/src/runner/tests.rs | 322 +++- .../src-tauri/src/tests/configuration.rs | 202 +++ .../src-tauri/src/tests/mod.rs | 2 +- .../src-tauri/src/tests/project.rs | 155 ++ .../src-tauri/src/tests/provider.rs | 150 +- .../tests/runtime_actions/action_execution.rs | 59 + .../src/tests/runtime_actions/support.rs | 1 + .../src-tauri/src/tests/runtime_state.rs | 20 +- .../src-tauri/src/windows.rs | 346 ++++- .../tauri.game-chat-release.conf.json | 25 + apps/ai-game-creator-shell/src/App.tsx | 444 +++++- apps/ai-game-creator-shell/src/app/types.ts | 4 + .../src/features/agent-runtime/model.ts | 108 +- .../src/features/agent-runtime/panels.tsx | 20 +- .../ProjectWorkspaceChatPane.tsx | 6 +- .../SupervisorChatOnlyView.tsx | 100 +- apps/ai-game-creator-shell/src/main.tsx | 59 +- .../tests/agentRuntimeModel.test.ts | 180 +++ .../tests/appSurface/harness.ts | 13 +- .../appSurface/project-development.suite.ts | 1302 ++++++++++++++++- .../shared-memory/decision-log.md | 54 + .../shared-memory/development-workflow.md | 28 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 25 +- package.json | 1 + 56 files changed, 6891 insertions(+), 364 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/build-game-chat-release.mjs create mode 100644 apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json create mode 100644 apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index e99bc18c3..879b1dbab 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -9,6 +9,7 @@ "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", "build": "npm --prefix ../.. exec tauri -- build", + "build:game-chat-release": "npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", "chat": "node scripts/run-cli-with-config.mjs --swarm-chat", diff --git a/apps/ai-game-creator-shell/scripts/build-game-chat-release.mjs b/apps/ai-game-creator-shell/scripts/build-game-chat-release.mjs new file mode 100644 index 000000000..710fb5827 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/build-game-chat-release.mjs @@ -0,0 +1,39 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(appRoot, '../..'); +const npmCli = + process.env.npm_execpath ?? + resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'); + +function run(args, extraEnv = {}) { + const result = spawnSync(process.execPath, [npmCli, ...args], { + cwd: appRoot, + env: { ...process.env, ...extraEnv }, + stdio: 'inherit', + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +run(['--prefix', repoRoot, 'run', 'ai-game-creator-shell:typecheck']); +run( + [ + '--prefix', + repoRoot, + 'exec', + 'vite', + '--', + 'build', + '--config', + 'vite.config.ts', + ], + { VITE_AGC_GAME_CHAT_ONLY: 'true' }, +); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 32bc46933..44053cfe6 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -27,6 +27,20 @@ const tauriConfig = JSON.parse( 'utf8', ), ); +const gameChatReleaseTauriConfig = JSON.parse( + fs.readFileSync( + new URL('../src-tauri/tauri.game-chat-release.conf.json', import.meta.url), + 'utf8', + ), +); +const cargoManifestSource = fs.readFileSync( + new URL('../src-tauri/Cargo.toml', import.meta.url), + 'utf8', +); +const cargoPackageVersion = cargoManifestSource + .split(/\r?\n(?=\[)/u) + .find((section) => section.startsWith('[package]')) + ?.match(/^version\s*=\s*"([^"]+)"\s*$/mu)?.[1]; const eventCapabilityPath = new URL( '../src-tauri/capabilities/events.json', import.meta.url, @@ -57,6 +71,14 @@ const appEntrypointSource = fs.readFileSync( new URL('../src/main.tsx', import.meta.url), 'utf8', ); +const appModuleSource = fs.readFileSync( + new URL('../src/App.tsx', import.meta.url), + 'utf8', +); +const gameChatReleaseBuildSource = fs.readFileSync( + new URL('../scripts/build-game-chat-release.mjs', import.meta.url), + 'utf8', +); const tauriHandlerSource = fs.readFileSync( new URL('../src-tauri/src/main.rs', import.meta.url), 'utf8', @@ -916,6 +938,29 @@ if ( ); } +const gameChatReleaseAppIndex = appModuleSource.indexOf( + 'export function GameChatReleaseApp(', +); +const gameChatReleaseBranchIndex = appEntrypointSource.indexOf( + '{gameChatReleaseMode ? (', +); +const authenticatedClientIndex = appEntrypointSource.indexOf( + '', + gameChatReleaseBranchIndex, +); +if ( + gameChatReleaseAppIndex === -1 || + gameChatReleaseBranchIndex === -1 || + !appEntrypointSource + .slice(gameChatReleaseBranchIndex, authenticatedClientIndex) + .includes('gameChatApp') || + authenticatedClientIndex < gameChatReleaseBranchIndex +) { + throw new Error( + 'AI game creator game-chat release must render the local chat App before the platform authentication boundary', + ); +} + if ( packageConfig.scripts?.['agent-run'] !== 'node scripts/run-cli-with-config.mjs --agent-run' @@ -1271,8 +1316,6 @@ for (const requiredSnippet of [ 'fn apply_game_chat_initial_window_url(', '.find(|window| window.label == "client")', 'client.url = game_chat_window_url(', - '.build(tauri_context)', - 'app.run(|_, event| handle_game_creator_gui_run_event(&event))', ]) { if ( !`${tauriHandlerSource}\n${tauriWindowSource}`.includes(requiredSnippet) @@ -1283,6 +1326,15 @@ for (const requiredSnippet of [ } } +if ( + !tauriHandlerSource.includes('.build(tauri_context)') || + !tauriHandlerSource.includes('handle_game_creator_gui_run_event(&event)') +) { + throw new Error( + 'AI game creator Tauri runtime must build from the prepared Context and preserve the generic GUI exit hook', + ); +} + if ( !tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts') ) { @@ -1291,6 +1343,66 @@ if ( ); } +if ( + !appEntrypointSource.includes( + "import.meta.env.VITE_AGC_GAME_CHAT_ONLY === 'true'", + ) || + !appEntrypointSource.includes( + "import.meta.env.DEV && initialSearchParams.has('game-chat')", + ) +) { + throw new Error( + 'AI game creator game-chat release must be compile-time fixed while preserving the dev query entry', + ); +} + +if ( + packageConfig.scripts?.['build:game-chat-release'] !== + 'npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release' || + rootPackageConfig.scripts?.['agc:build:game-chat-release'] !== + 'npm --prefix apps/ai-game-creator-shell run build:game-chat-release --' +) { + throw new Error( + 'AI game creator game-chat release build commands must stay wired through the dedicated Tauri config', + ); +} + +if ( + gameChatReleaseTauriConfig.productName !== 'Genarrative Game Chat' || + gameChatReleaseTauriConfig.version !== '0.1.1' || + gameChatReleaseTauriConfig.identifier === tauriConfig.identifier || + gameChatReleaseTauriConfig.build?.beforeBuildCommand !== + 'node scripts/build-game-chat-release.mjs' || + !gameChatReleaseTauriConfig.bundle?.targets?.includes('nsis') +) { + throw new Error( + 'AI game creator game-chat release must keep version 0.1.1, its independent identity, frontend build, and NSIS target', + ); +} + +if ( + tauriConfig.version !== '0.1.0' || + packageConfig.version !== '0.1.0' || + cargoPackageVersion !== '0.1.0' +) { + throw new Error( + 'AI game creator standard release must remain version 0.1.0 while game-chat uses its dedicated version', + ); +} + +for (const requiredSnippet of [ + "'ai-game-creator-shell:typecheck'", + "VITE_AGC_GAME_CHAT_ONLY: 'true'", + "'--config'", + "'vite.config.ts'", +]) { + if (!gameChatReleaseBuildSource.includes(requiredSnippet)) { + throw new Error( + `AI game creator game-chat release build guardrail drifted: ${requiredSnippet}`, + ); + } +} + const devServerSource = fs.readFileSync( new URL('../scripts/start-dev-server.mjs', import.meta.url), 'utf8', @@ -1380,7 +1492,6 @@ for (const snippet of [ 'fn merge_game_creator_config_file(', '.join("apps")', '.join("ai-game-creator-shell")', - 'configure_game_creator_runtime_config_dir(app.handle())?', 'read_game_creator_app_config,', 'write_game_creator_app_config,', 'resolve_game_creator_llm_config_for_agent(app_config, "planner")', @@ -1400,6 +1511,31 @@ for (const snippet of [ } } +const runtimeConfigSetupStart = tauriHandlerSource.indexOf( + 'configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {', +); +const runtimeConfigSetupEnd = tauriHandlerSource.indexOf( + '})?;', + runtimeConfigSetupStart, +); +const runtimeConfigSetupSource = tauriHandlerSource.slice( + runtimeConfigSetupStart, + runtimeConfigSetupEnd, +); +if ( + runtimeConfigSetupStart === -1 || + runtimeConfigSetupEnd === -1 || + !runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') || + !runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') || + !runtimeConfigSetupSource.includes( + 'startup.appdata.configure.failed details={details}', + ) +) { + throw new Error( + 'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures', + ); +} + for (const snippet of [ 'import.meta.env.DEV', '#[cfg(all(debug_assertions, not(test)))]', diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 32c270eb8..5ef59f69a 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2021" publish = false +[features] +default = [] +game-chat-release = [] + [build-dependencies] tauri-build = { version = "2.6.2", features = [] } @@ -39,4 +43,4 @@ tauri-plugin-clipboard-manager = "2.3.2" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects"] } +windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index ecc223456..bcd4cc566 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -514,22 +514,25 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_llm_client( pub(crate) fn game_creator_agent_llm_error_public_summary( error: &platform_llm::LlmError, ) -> String { - let kind = match error { - platform_llm::LlmError::Timeout { .. } => "timeout".to_string(), - platform_llm::LlmError::Connectivity { .. } => "connectivity".to_string(), - platform_llm::LlmError::Transport(_) => "transport".to_string(), + let (kind, http_status) = match error { + platform_llm::LlmError::Timeout { .. } => ("timeout".to_string(), None), + platform_llm::LlmError::Connectivity { .. } => ("connectivity".to_string(), None), + platform_llm::LlmError::Transport(_) => ("transport".to_string(), None), platform_llm::LlmError::Upstream { status_code, .. } => { - format!("upstream-{status_code}") + (format!("upstream-{status_code}"), Some(*status_code)) } - platform_llm::LlmError::InvalidConfig(_) => "invalid-config".to_string(), - platform_llm::LlmError::InvalidRequest(_) => "invalid-request".to_string(), - platform_llm::LlmError::StreamUnavailable => "stream-unavailable".to_string(), - platform_llm::LlmError::EmptyResponse => "empty-response".to_string(), - platform_llm::LlmError::Deserialize(_) => "deserialize".to_string(), + platform_llm::LlmError::InvalidConfig(_) => ("invalid-config".to_string(), None), + platform_llm::LlmError::InvalidRequest(_) => ("invalid-request".to_string(), None), + platform_llm::LlmError::StreamUnavailable => ("stream-unavailable".to_string(), None), + platform_llm::LlmError::EmptyResponse => ("empty-response".to_string(), None), + platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None), }; let raw = error.to_string(); + let http_status = http_status + .map(|status| format!(" httpStatus={status}")) + .unwrap_or_default(); format!( - "kind={kind} fingerprint={:x} chars={}", + "kind={kind}{http_status} fingerprint={:x} chars={}", Sha256::digest(raw.as_bytes()), raw.chars().count() ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index fbb15dd19..5bf24efa5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -44,6 +44,8 @@ pub(in crate::agent) use run_status_observation::*; pub(in crate::agent) use structured_plan::*; pub(in crate::agent) use tool_plan_protocol::*; +#[cfg(test)] +pub(crate) use action_audit::agent_runtime_action_receipt_public_safe_detail_for_test; #[cfg(test)] pub(crate) use action_audit::agent_runtime_action_receipt_safe_detail_for_owner_for_test; pub(crate) use action_audit::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index cd1e96dd7..cd10cd51f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -194,6 +194,14 @@ pub(crate) fn agent_runtime_action_receipt_safe_detail_for_owner_for_test( ) } +#[cfg(test)] +pub(crate) fn agent_runtime_action_receipt_public_safe_detail_for_test( + root: &Path, + observation: &AgentRuntimeToolObservation, +) -> Option { + agent_runtime_action_receipt_safe_detail(root, observation) +} + fn agent_runtime_action_receipt_safe_detail_with_owner( root: &Path, receipt_owner: Option<(&str, &str)>, @@ -428,16 +436,27 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( { return None; } + let diagnostics_count = detail + .get("diagnostics") + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .unwrap_or(0); + if receipt_owner.is_none() { + return serde_json::to_string(&serde_json::json!({ + "passed": passed, + "revision": revision, + "diagnosticsCount": diagnostics_count, + "playtestPassed": playtest_passed, + "playtestScenario": playtest_scenario, + })) + .ok(); + } return serde_json::to_string(&serde_json::json!({ "passed": passed, "revision": revision, "reportPath": report_path, "screenshots": screenshots, - "diagnosticsCount": detail - .get("diagnostics") - .and_then(serde_json::Value::as_array) - .map(Vec::len) - .unwrap_or(0), + "diagnosticsCount": diagnostics_count, "playtestPassed": playtest_passed, "playtestScenario": playtest_scenario, })) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs index 26a309b6d..2770eb4de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs @@ -480,11 +480,10 @@ pub(crate) fn resume_game_creator_agent_runtime_for_goal_at( if let Some(retry) = waiting_provider_retry.as_ref() { state.status = "running".to_string(); state.phase = "waiting-for-provider-retry".to_string(); - state.current_action = format!( - "Goal 已恢复,继续等待 Provider 瞬态重试 {}/{}", - retry.next_attempt, retry.max_retries - ); - state.waiting_on = format!("Provider {} 瞬态故障退避到期", retry.error_kind); + let (current_action, waiting_on) = + game_creator_agent_runtime_provider_retry_waiting_presentation(retry); + state.current_action = format!("Goal 已恢复,{current_action}"); + state.waiting_on = waiting_on; state.next_step = "到期后恢复同一 Session/run/loop 和 retry attempt".to_string(); } else if pending_action.as_ref().is_some_and(|pending| { pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 0f40ca3d1..7de274d2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -629,7 +629,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), - content: format!("后台任务失败:{error}"), + content: game_creator_agent_runtime_failure_conversation_message( + &agent_id, &error, + ), agent_id: None, }, ); @@ -1817,7 +1819,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), - content: format!("后台任务失败:{error}"), + content: game_creator_agent_runtime_failure_conversation_message( + &agent_id, &error, + ), agent_id: None, }, ); @@ -1941,7 +1945,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), - content: format!("后台任务失败:{error}"), + content: game_creator_agent_runtime_failure_conversation_message( + &agent_id, &error, + ), agent_id: None, }, ); @@ -2201,7 +2207,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), - content: format!("后台任务失败:{error}"), + content: game_creator_agent_runtime_failure_conversation_message( + &agent_id, &error, + ), agent_id: None, }, ); @@ -3002,7 +3010,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c Some(&session_id), LocalConversationMessage { role: "assistant".to_string(), - content: format!("后台任务失败:{error}"), + content: game_creator_agent_runtime_failure_conversation_message( + &agent_id, &error, + ), agent_id: None, }, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index ac1183d0c..bb8a05d40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -190,10 +190,14 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState final_sequence: Some(8), final_phase: Some(BrowserPlaytestPhase::Playing), final_level: Some(2), - assertions: vec![BrowserPlaytestAssertion { - name: "fixture-passed".to_string(), - passed: true, - }], + assertions: scenario + .assertion_names() + .iter() + .map(|name| BrowserPlaytestAssertion { + name: (*name).to_string(), + passed: true, + }) + .collect(), diagnostics: Vec::new(), }), diagnostics: Vec::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 4bdb4c9dc..105feca1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -684,11 +684,10 @@ pub(in crate::agent) fn persist_waiting_provider_retry_context_at( } runtime.status = "running".to_string(); runtime.phase = "waiting-for-provider-retry".to_string(); - runtime.current_action = format!( - "等待 Provider 瞬态重试 {}/{}", - retry.next_attempt, retry.max_retries - ); - runtime.waiting_on = format!("Provider {} 瞬态故障退避到期", retry.error_kind); + let (current_action, waiting_on) = + game_creator_agent_runtime_provider_retry_waiting_presentation(retry); + runtime.current_action = current_action; + runtime.waiting_on = waiting_on; runtime.next_step = "到期后自动恢复同一 Session/run/loop,并重新检查控制状态".to_string(); runtime.error = None; runtime.updated_at = unix_timestamp(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs index bcd426420..38d5a9f5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs @@ -228,7 +228,7 @@ pub(in crate::agent) fn fail_game_creator_agent_background_context_at( Some(session_id), LocalConversationMessage { role: "assistant".to_string(), - content: format!("后台任务失败:{error}"), + content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error), agent_id: None, }, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 882cd47e5..584d8daa6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -664,8 +664,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt( BrowserPlaytestScenario::GenericV1 => { concat!( "完成合同要求 generic-v1 交互试玩。game/index.html 必须持续更新 + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Transient generic fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::GenericV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real generic browser validation"); + assert!( + !result.passed, + "transient playing state must fail validation" + ); + let playtest = result.playtest.expect("generic playtest result"); + assert!(!playtest.passed, "{:#?}", playtest.assertions); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Lost)); +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_accepts_stable_primary_action_flow() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let html = br#" + +Stable Generic Browser Fixture + +
Stable generic fixture
+ + + + + + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Stable generic fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::GenericV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real stable generic browser validation"); + assert!( + result.passed, + "diagnostics={:#?}\nviewports={:#?}", + result.diagnostics, result.viewport_results + ); + let playtest = result.playtest.expect("generic playtest result"); + assert!(playtest.passed, "{:#?}", playtest.diagnostics); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.final_sequence, Some(3)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_accepts_first_lost_when_retry_proves_non_loss_progression() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let html = br#" + +Recoverable Lost Generic Browser Fixture + +
Recoverable-lost generic fixture
+ + + + + + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Recoverable-lost generic fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::GenericV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real recoverable-lost generic browser validation"); + assert!( + result.passed, + "diagnostics={:#?}\nviewports={:#?}", + result.diagnostics, result.viewport_results + ); + let playtest = result.playtest.expect("generic playtest result"); + assert!(playtest.passed, "{:#?}", playtest.diagnostics); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.final_sequence, Some(5)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Playing)); + assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_rejects_fixed_lost_on_both_controlled_attempts() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let html = br#" + +Fixed Lost Generic Browser Fixture + +
Fixed-lost generic fixture
+ + + + + + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Fixed-lost generic fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::GenericV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real fixed-lost generic browser validation"); + assert!(!result.passed, "fixed lost must fail browser validation"); + let playtest = result.playtest.expect("generic playtest result"); + assert!(!playtest.passed, "{:#?}", playtest.assertions); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.final_sequence, Some(5)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Lost)); + assert!( + playtest + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("固定失败")), + "unexpected diagnostics: {:#?}", + playtest.diagnostics + ); + assert_eq!( + playtest + .assertions + .iter() + .find(|assertion| assertion.name == "non-loss-progression-observed") + .map(|assertion| assertion.passed), + Some(false) + ); +} + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] async fn real_chrome_lane_defense_playtest() { @@ -873,7 +1510,7 @@ async fn real_chrome_lane_defense_playtest() { listener.set_nonblocking(true).expect("nonblocking preview"); let html = br#" -Lane Defense Browser Fixture +Lane Defense Browser Fixture
Lane defense fixture
diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 936d1befb..adeec51c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -1316,11 +1316,7 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command } #[cfg(windows)] { - use std::os::windows::process::CommandExt; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - command - .as_std_mut() - .creation_flags(CREATE_NEW_PROCESS_GROUP); + crate::configure_windows_background_tokio_command(command, true); } } @@ -1501,13 +1497,16 @@ async fn request_project_command_process_group_termination( if !taskkill.is_absolute() || !taskkill.is_file() { return Err("请求终止受控进程组失败:taskkill.exe 不是绝对普通文件".to_string()); } - let status = tokio::process::Command::new(taskkill) + let mut command = tokio::process::Command::new(taskkill); + command .args(["/PID", &process_id.to_string(), "/T", "/F"]) .env_clear() .env("SystemRoot", &system_root) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::null()); + crate::configure_windows_background_tokio_command(&mut command, false); + let status = command .status() .await .map_err(|error| format!("请求终止受控进程组失败:启动 taskkill.exe 失败:{error}"))?; 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 ed9e40a67..19fded1e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -368,6 +368,7 @@ pub(crate) fn game_creator_llm_reasoning_effort_name( fn validate_game_creator_runtime_config_dir_metadata( path: &Path, tighten: bool, + initialize_windows_owner: bool, ) -> Result<(), String> { let metadata = fs::symlink_metadata(path).map_err(|error| { format!( @@ -382,6 +383,7 @@ fn validate_game_creator_runtime_config_dir_metadata( #[cfg(unix)] { use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let _ = initialize_windows_owner; // SAFETY: geteuid takes no arguments and has no memory safety preconditions. let effective_user_id = unsafe { libc::geteuid() }; @@ -418,7 +420,12 @@ fn validate_game_creator_runtime_config_dir_metadata( if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string()); } - secure_windows_game_creator_path_for_current_user(path, true, tighten)?; + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + true, + tighten, + initialize_windows_owner, + )?; } #[cfg(not(any(unix, windows)))] @@ -437,24 +444,155 @@ fn resolve_game_creator_runtime_config_dir( if !path.is_absolute() { return Err("客户端 AppData 配置目录必须是绝对路径".to_string()); } + let mut created = false; if create_and_tighten { - fs::create_dir_all(path).map_err(|error| { - format!( - "创建客户端 AppData 配置目录失败:{}: {error}", - path.display() - ) - })?; + 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() + ) + })?; + } + match fs::create_dir(path) { + Ok(()) => created = true, + // 与其他启动进程竞争时,不把对方创建的目录误判为本进程的新对象。 + Err(create_error) + if create_error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(create_error) => { + return Err(format!( + "创建客户端 AppData 配置目录失败:{}: {create_error}", + path.display() + )); + } + } + } + Err(error) => { + return Err(format!( + "检查客户端 AppData 配置目录失败:{}: {error}", + path.display() + )); + } + } } + // canonicalize 会跟随目录链接,因此必须先检查用户给出的目录项本身。 + validate_game_creator_runtime_config_dir_entry_type(path)?; let canonical = fs::canonicalize(path).map_err(|error| { format!( "解析客户端 AppData 配置目录失败:{}: {error}", path.display() ) })?; - validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten)?; + match validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten, created) + { + Ok(()) => {} + #[cfg(windows)] + Err(error) + if create_and_tighten + && !created + && error.starts_with("Windows 安全对象不属于当前用户:") => + { + let backup = migrate_windows_foreign_owner_config_dir(path)?; + fs::create_dir(path).map_err(|create_error| { + format!( + "旧 AppData 配置已安全保留在 {},但重新创建当前用户配置目录失败:{}: {create_error}", + backup.display(), + path.display() + ) + })?; + validate_game_creator_runtime_config_dir_metadata(path, true, true).map_err( + |validation_error| { + format!( + "旧 AppData 配置已安全保留在 {},但新配置目录安全初始化失败:{validation_error}", + backup.display() + ) + }, + )?; + return fs::canonicalize(path).map_err(|canonicalize_error| { + format!( + "旧 AppData 配置已安全保留在 {},但解析新配置目录失败:{}: {canonicalize_error}", + backup.display(), + path.display() + ) + }); + } + Err(error) => return Err(error), + } Ok(canonical) } +fn validate_game_creator_runtime_config_dir_entry_type(path: &Path) -> Result<(), String> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "读取客户端 AppData 配置目录元数据失败:{}: {error}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("客户端 AppData 配置目录必须是普通目录,不能是链接或其他文件".to_string()); + } + #[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("客户端 AppData 配置目录不能是 Windows reparse point".to_string()); + } + } + Ok(()) +} + +#[cfg(windows)] +fn migrate_windows_foreign_owner_config_dir(path: &Path) -> Result { + validate_game_creator_runtime_config_dir_entry_type(path)?; + let parent = path.parent().ok_or_else(|| { + format!( + "AppData 配置目录没有可用于安全迁移的父目录:{}", + path.display() + ) + })?; + let name = path + .file_name() + .ok_or_else(|| format!("AppData 配置目录名称无效,无法安全迁移:{}", path.display()))?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + for attempt in 0..100_u32 { + let backup = parent.join(format!( + "{}.owner-mismatch-backup-{timestamp}-{}-{attempt}", + name.to_string_lossy(), + std::process::id() + )); + match fs::symlink_metadata(&backup) { + Ok(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "检查旧 AppData 配置备份路径失败:{}: {error}", + backup.display() + )); + } + } + // 同一父目录内 rename 是原子目录项替换;目标已确认不存在,旧配置不会被覆盖。 + fs::rename(path, &backup).map_err(|error| { + format!( + "AppData 配置目录 owner 不匹配,无法安全迁移。请保留并手动恢复 {};计划备份路径为 {}:{error}", + path.display(), + backup.display() + ) + })?; + return Ok(backup); + } + Err(format!( + "AppData 配置目录 owner 不匹配,但无法找到不冲突的备份路径;请手动保留并恢复 {}", + path.display() + )) +} + pub(crate) fn prepare_game_creator_runtime_config_dir(path: &Path) -> Result { resolve_game_creator_runtime_config_dir(path, true) } @@ -480,6 +618,28 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( path: &Path, is_directory: bool, tighten: bool, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + tighten, + false, + ) +} + +#[cfg(windows)] +pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( + path: &Path, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true) +} + +#[cfg(windows)] +fn secure_windows_game_creator_path_for_current_user_with_owner_policy( + path: &Path, + is_directory: bool, + tighten: bool, + initialize_owner: bool, ) -> Result<(), String> { use std::ffi::c_void; use std::os::windows::ffi::OsStrExt; @@ -661,6 +821,41 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( .encode_wide() .chain(std::iter::once(0)) .collect::>(); + let mut initial_owner = std::ptr::null_mut(); + let mut initial_descriptor = std::ptr::null_mut(); + // 先验证 owner,再修改 DACL,避免对其他用户持有的旧配置做任何权限变更。 + let owner_status = unsafe { + GetNamedSecurityInfoW( + wide_path.as_mut_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut initial_owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut initial_descriptor, + ) + }; + if owner_status != 0 || initial_owner.is_null() || initial_descriptor.is_null() { + if !initial_descriptor.is_null() { + unsafe { LocalFree(initial_descriptor) }; + } + return Err(format!( + "读取 Windows owner 失败:{}: error {owner_status}", + path.display() + )); + } + let owner_matches = unsafe { IsValidSid(initial_owner) } != 0 + && unsafe { EqualSid(initial_owner, current_user_sid) } != 0; + unsafe { LocalFree(initial_descriptor) }; + if !owner_matches { + if !(initialize_owner && tighten) { + return Err(format!( + "Windows 安全对象不属于当前用户:{}", + path.display() + )); + } + } if tighten { let mut entry = ExplicitAccessW { access_permissions: FILE_ALL_ACCESS, @@ -693,8 +888,18 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( SetNamedSecurityInfoW( wide_path.as_mut_ptr(), SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), + DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + | if initialize_owner { + OWNER_SECURITY_INFORMATION + } else { + 0 + }, + if initialize_owner { + current_user_sid + } else { + std::ptr::null_mut() + }, std::ptr::null_mut(), private_dacl, std::ptr::null_mut(), @@ -704,7 +909,7 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( unsafe { LocalFree(private_dacl) }; if set_status != 0 { return Err(format!( - "收紧 Windows 当前用户私有 DACL 失败:{}: error {set_status}", + "初始化 Windows 当前用户 owner/私有 DACL 失败:{}: error {set_status}", path.display() )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs index 33ef15c67..74a7d4b92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs @@ -2263,6 +2263,7 @@ fn build_sandboxed_git_command( let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" }; let sandbox = context.sandbox.path(); let mut command = Command::new(&context.executable); + crate::configure_windows_background_std_command(&mut command, false); command.env_clear(); for key in ["SystemRoot", "WINDIR", "PATHEXT"] { if let Some(value) = std::env::var_os(key) { 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 d72097414..8f72f2bcc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2,10 +2,11 @@ use std::collections::BTreeMap; use std::fs; -use std::fs::File; -use std::io::{BufRead, BufReader, Read, Write}; +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::sync::{mpsc, Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -143,6 +144,12 @@ struct LocalPreviewStatus { root: Option, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalGameProjectRevisionStatus { + revision: u64, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GenerateLocalGameDraftResult { @@ -1580,6 +1587,292 @@ struct LlmAgentHandoff { next: String, } +const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024; +static DIAGNOSTIC_LOG_LOCK: OnceLock> = OnceLock::new(); +static STARTUP_PANIC_LOG_PATH: OnceLock = OnceLock::new(); +static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false); + +fn diagnostic_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn append_bounded_diagnostic_line_with_limit( + path: &Path, + line: &str, + max_bytes: u64, +) -> std::io::Result<()> { + let _guard = DIAGNOSTIC_LOG_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = open_secure_diagnostic_log(path)?; + if file.metadata()?.len() >= max_bytes { + file.seek(SeekFrom::Start(0)).map_err(|error| { + std::io::Error::new(error.kind(), format!("seek current log: {error}")) + })?; + let mut previous_content = Vec::new(); + std::io::Read::by_ref(&mut file) + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut previous_content) + .map_err(|error| { + std::io::Error::new(error.kind(), format!("read current log: {error}")) + })?; + let previous_path = path.with_extension("previous.log"); + let mut previous = open_secure_diagnostic_log(&previous_path)?; + previous.set_len(0).map_err(|error| { + std::io::Error::new(error.kind(), format!("truncate previous log: {error}")) + })?; + previous.write_all(&previous_content).map_err(|error| { + std::io::Error::new(error.kind(), format!("write previous log: {error}")) + })?; + previous.flush().map_err(|error| { + std::io::Error::new(error.kind(), format!("flush previous log: {error}")) + })?; + file.set_len(0).map_err(|error| { + std::io::Error::new(error.kind(), format!("truncate current log: {error}")) + })?; + } + file.seek(SeekFrom::End(0)) + .map_err(|error| std::io::Error::new(error.kind(), format!("seek log end: {error}")))?; + writeln!(file, "{} {line}", diagnostic_timestamp())?; + file.flush() +} + +fn open_secure_diagnostic_log(path: &Path) -> std::io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "diagnostic log must be a regular file", + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + #[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 file = options.open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "diagnostic log must be a regular file", + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "diagnostic log must not be a hardlink", + )); + } + } + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, "diagnostic log") + .map_err(std::io::Error::other)?; + Ok(file) +} + +pub(crate) fn append_bounded_diagnostic_line(path: &Path, line: &str) -> std::io::Result<()> { + append_bounded_diagnostic_line_with_limit(path, line, DIAGNOSTIC_LOG_MAX_BYTES) +} + +fn redact_windows_absolute_paths(value: &str) -> String { + let bytes = value.as_bytes(); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0; + while cursor < bytes.len() { + let previous_allows_drive_path = cursor == 0 || !bytes[cursor - 1].is_ascii_alphanumeric(); + let is_drive_path = previous_allows_drive_path + && cursor + 2 < bytes.len() + && bytes[cursor].is_ascii_alphabetic() + && bytes[cursor + 1] == b':' + && matches!(bytes[cursor + 2], b'\\' | b'/'); + if !is_drive_path { + let ch = value[cursor..] + .chars() + .next() + .expect("valid character boundary"); + output.push(ch); + cursor += ch.len_utf8(); + continue; + } + output.push_str(""); + cursor += 3; + while cursor < bytes.len() + && !bytes[cursor].is_ascii_whitespace() + && !matches!(bytes[cursor], b'\"' | b'\'' | b',' | b';') + { + cursor += 1; + } + } + output +} + +fn redact_unix_absolute_paths(value: &str) -> String { + let chars = value.chars().collect::>(); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0; + while cursor < chars.len() { + let previous_allows_path = cursor == 0 + || chars[cursor - 1].is_whitespace() + || matches!(chars[cursor - 1], '=' | '(' | ':' | ':'); + let is_url_separator = chars.get(cursor + 1) == Some(&'/'); + if chars[cursor] != '/' || !previous_allows_path || is_url_separator { + output.push(chars[cursor]); + cursor += 1; + continue; + } + output.push_str(""); + cursor += 1; + while cursor < chars.len() + && !chars[cursor].is_whitespace() + && !matches!(chars[cursor], '"' | '\'' | ',' | ';') + { + cursor += 1; + } + } + output +} + +pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Path>) -> String { + let mut sanitized = value.replace(['\r', '\n'], " "); + if let Some(root) = private_root { + let root = root.to_string_lossy(); + if !root.is_empty() { + sanitized = sanitized.replace(root.as_ref(), ""); + } + } + let lowercase = sanitized.to_ascii_lowercase(); + if [ + "authorization", + "bearer ", + "api_key", + "apikey", + "api key", + "x-api-key", + "token=", + "token:", + "credential", + ] + .iter() + .any(|marker| lowercase.contains(marker)) + { + return "".to_string(); + } + sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); + sanitized.chars().take(2_048).collect() +} + +fn initialize_game_chat_startup_log(identifier: &str) -> PathBuf { + let appdata_path = std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join(identifier) + .join("startup.log"); + if append_bounded_diagnostic_line(&appdata_path, "startup.begin").is_ok() { + return appdata_path; + } + let fallback_path = std::env::temp_dir() + .join("Genarrative-Game-Chat-Diagnostics") + .join("startup.log"); + let _ = append_bounded_diagnostic_line( + &fallback_path, + "startup.begin appdata-log-unavailable=true", + ); + fallback_path +} + +fn install_startup_panic_log(path: PathBuf) { + if STARTUP_PANIC_LOG_PATH.set(path).is_err() { + return; + } + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Some(path) = STARTUP_PANIC_LOG_PATH.get() { + let location = info + .location() + .map(|location| { + let file = Path::new(location.file()) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown"); + format!("{file}:{}:{}", location.line(), location.column()) + }) + .unwrap_or_else(|| "unknown".to_string()); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.panic location={location} details=redacted"), + ); + } + previous(info); + })); +} + +#[cfg(windows)] +fn show_startup_error_dialog(log_path: &Path) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, + }; + + if STARTUP_ERROR_DIALOG_SHOWN.swap(true, AtomicOrdering::AcqRel) { + return; + } + let title = std::ffi::OsStr::new("Genarrative Game Chat") + .encode_wide() + .chain(Some(0)) + .collect::>(); + let message_text = format!( + "应用启动失败。请将以下诊断日志发给开发人员:\n{}", + log_path.display() + ); + let message = std::ffi::OsStr::new(&message_text) + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: both UTF-16 buffers are NUL-terminated and live for the duration of the call. + unsafe { + MessageBoxW( + std::ptr::null_mut(), + message.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONERROR | MB_SETFOREGROUND, + ); + } +} + +#[cfg(not(windows))] +fn show_startup_error_dialog(log_path: &Path) { + if STARTUP_ERROR_DIALOG_SHOWN.swap(true, AtomicOrdering::AcqRel) { + return; + } + eprintln!( + "Genarrative Game Chat startup failed; see {}", + log_path.display() + ); +} + #[derive(Clone, Debug)] struct GameCreatorAgentLoopResult { run_id: String, @@ -1699,20 +1992,24 @@ fn main() { Err(_) => std::process::exit(125), } } - let game_chat_launch = match parse_game_chat_launch_args(&args) { + let explicit_game_chat_launch = match parse_game_chat_launch_args(&args) { + Ok(options) => options, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + let game_chat_launch = match select_game_chat_launch_options( + explicit_game_chat_launch, + cfg!(debug_assertions), + cfg!(feature = "game-chat-release"), + ) { Ok(options) => options, Err(error) => { eprintln!("{error}"); std::process::exit(1); } }; - #[cfg(not(debug_assertions))] - if game_chat_launch.is_some() { - eprintln!("--game-chat 仅在开发构建中可用"); - std::process::exit(1); - } - #[cfg(test)] - let _ = &game_chat_launch; let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { Ok(config_dir) => config_dir, Err(error) => { @@ -1784,35 +2081,106 @@ fn main() { } let mut tauri_context = tauri::generate_context!(); - #[cfg(debug_assertions)] + let startup_log = if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { + let path = initialize_game_chat_startup_log(&tauri_context.config().identifier); + install_startup_panic_log(path.clone()); + Some(path) + } else { + None + }; if let Some(options) = game_chat_launch.as_ref() { if let Err(error) = apply_game_chat_initial_window_url(tauri_context.config_mut(), options) { + if let Some(path) = startup_log.as_deref() { + let details = sanitize_diagnostic_message(error.as_str(), path.parent()); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.window-url.failed details={details}"), + ); + show_startup_error_dialog(path); + } eprintln!("{error}"); std::process::exit(1); } } + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.context.ready"); + } + let setup_log = startup_log.clone(); let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(game_creator_preview_registry()) .setup(move |app| { - configure_game_creator_runtime_config_dir(app.handle())?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.setup.begin"); + let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin"); + } + configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.appdata.configure.failed details={details}"), + ); + show_startup_error_dialog(path); + } + })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete"); + } let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { - std::io::Error::new( + let error = std::io::Error::new( std::io::ErrorKind::NotFound, "客户端 AppData 配置目录未初始化", - ) - })?; - configure_external_agent_runner(&config_dir).map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("配置 Agent Runner 失败:{error}"), - ) + ); + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.appdata.resolve.failed details=config-dir-uninitialized", + ); + show_startup_error_dialog(path); + } + error })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.begin"); + } + configure_external_agent_runner(&config_dir) + .inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = + sanitize_diagnostic_message(error, Some(config_dir.as_path())); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.runner.configure.failed details={details}"), + ); + show_startup_error_dialog(path); + } + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("配置 Agent Runner 失败:{error}"), + ) + })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.complete"); + } let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) + .inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = + sanitize_diagnostic_message(error, Some(config_dir.as_path())); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.runner.owner-lock.failed details={details}"), + ); + show_startup_error_dialog(path); + } + }) .map_err(|error| { std::io::Error::new( std::io::ErrorKind::AlreadyExists, @@ -1820,23 +2188,56 @@ fn main() { ) })?; app.manage(gui_owner_lock); - ensure_external_agent_runner_started_for_gui().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("启动 Agent Runner 失败:{error}"), - ) - })?; - attach_external_agent_runner_gui_owner().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("绑定 Agent Runner GUI owner 失败:{error}"), - ) - })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin"); + } + ensure_external_agent_runner_started_for_gui() + .inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = + sanitize_diagnostic_message(error, Some(config_dir.as_path())); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.runner.start.failed details={details}"), + ); + show_startup_error_dialog(path); + } + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("启动 Agent Runner 失败:{error}"), + ) + })?; + attach_external_agent_runner_gui_owner() + .inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = + sanitize_diagnostic_message(error, Some(config_dir.as_path())); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.runner.attach-owner.failed details={details}"), + ); + show_startup_error_dialog(path); + } + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("绑定 Agent Runner GUI owner 失败:{error}"), + ) + })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); + } set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] if game_chat_launch.is_none() { open_developer_window(app.handle())?; } + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.setup.complete"); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -1918,14 +2319,174 @@ fn main() { start_local_game_preview, activate_local_game_preview, stop_local_game_preview, + stop_local_game_preview_if_matches, get_local_game_preview_status, read_local_project_resource_canvas_layout, update_local_project_resource_canvas_layout, + get_local_game_project_revision, get_local_game_manifest ]) - .build(tauri_context) - .expect("failed to build Genarrative AI Game Creator shell"); - app.run(|_, event| handle_game_creator_gui_run_event(&event)); + .build(tauri_context); + let app = match app { + Ok(app) => { + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.build.complete"); + } + app + } + Err(error) => { + if let Some(path) = startup_log.as_deref() { + let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.build.failed details={details}"), + ); + show_startup_error_dialog(path); + } + eprintln!("failed to build Genarrative AI Game Creator shell: {error}"); + std::process::exit(1); + } + }; + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.run.begin"); + } + let shutdown_log = startup_log.clone(); + app.run(move |_, event| { + let game_chat_release = cfg!(all(not(debug_assertions), feature = "game-chat-release")); + if game_chat_release && should_shutdown_runner_on_tauri_event(true, &event) { + if let Some(path) = shutdown_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.runner.shutdown-for-client-exit.begin", + ); + } + match shutdown_external_agent_runner_for_client_exit() { + Ok(()) => { + if let Some(path) = shutdown_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.runner.shutdown-for-client-exit.complete", + ); + } + } + Err(error) => { + if let Some(path) = shutdown_log.as_deref() { + let details = sanitize_diagnostic_message(&error, path.parent()); + let _ = append_bounded_diagnostic_line( + path, + &format!( + "startup.runner.shutdown-for-client-exit.failed details={details}" + ), + ); + } + eprintln!("game-chat 客户端退出协议关闭 Agent Runner 失败:{error}") + } + } + } else if !game_chat_release { + handle_game_creator_gui_run_event(&event); + } + }); + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.run.complete"); + } +} + +#[cfg(test)] +mod diagnostic_log_tests { + use super::*; + + #[test] + fn bounded_diagnostic_log_rotates_and_keeps_only_one_previous_file() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let path = directory.path().join("startup.log"); + let first_record = "x".repeat(128); + append_bounded_diagnostic_line_with_limit(&path, &first_record, 64) + .expect("write first record"); + append_bounded_diagnostic_line_with_limit(&path, "second-record", 64) + .expect("rotate diagnostic log"); + + let current = fs::read_to_string(&path).expect("read current diagnostic log"); + let previous = fs::read_to_string(path.with_extension("previous.log")) + .expect("read previous diagnostic log"); + assert!(current.contains("second-record")); + assert!(previous.contains(&"x".repeat(32))); + } + + #[test] + fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { + assert_eq!( + sanitize_diagnostic_message("Authorization: Bearer secret", None), + "" + ); + assert_eq!( + sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None), + "failed at " + ); + assert_eq!( + sanitize_diagnostic_message("failed at /home/example/private/game.json", None), + "failed at " + ); + } + + #[test] + fn diagnostic_log_rejects_hardlink_targets_including_rotation_backup() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let outside = directory.path().join("outside.txt"); + fs::write(&outside, "outside-unchanged").expect("write outside target"); + let path = directory.path().join("startup.log"); + fs::hard_link(&outside, &path).expect("create diagnostic hardlink"); + assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err()); + assert_eq!( + fs::read_to_string(&outside).expect("read outside target"), + "outside-unchanged" + ); + + fs::remove_file(&path).expect("remove diagnostic hardlink"); + fs::write(&path, "rotate-me").expect("write diagnostic file"); + let previous = path.with_extension("previous.log"); + fs::hard_link(&outside, &previous).expect("create previous hardlink"); + assert!(append_bounded_diagnostic_line_with_limit(&path, "blocked", 1).is_err()); + assert_eq!( + fs::read_to_string(&outside).expect("read outside target after rotation"), + "outside-unchanged" + ); + } + + #[cfg(unix)] + #[test] + fn diagnostic_log_rejects_symlink_targets() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let outside = directory.path().join("outside.txt"); + fs::write(&outside, "outside-unchanged").expect("write outside target"); + let path = directory.path().join("startup.log"); + symlink(&outside, &path).expect("create diagnostic symlink"); + assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err()); + assert_eq!( + fs::read_to_string(&outside).expect("read outside target"), + "outside-unchanged" + ); + } + + #[cfg(windows)] + #[test] + fn diagnostic_log_rejects_windows_symlink_or_reparse_targets_when_supported() { + use std::os::windows::fs::symlink_file; + + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let outside = directory.path().join("outside.txt"); + fs::write(&outside, "outside-unchanged").expect("write outside target"); + let path = directory.path().join("startup.log"); + if symlink_file(&outside, &path).is_err() { + return; + } + assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err()); + assert_eq!( + fs::read_to_string(&outside).expect("read outside target"), + "outside-unchanged" + ); + } } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index 8ce881baf..066a6a37a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -631,6 +631,7 @@ fn apply_game_creator_mcp_platform_environment(command: &mut tokio::process::Com command.env(name, value); } } + crate::configure_windows_background_tokio_command(command, false); } #[cfg(not(windows))] diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 3060b666f..d36f60193 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -65,6 +65,21 @@ impl PreviewRegistry { let _ = server.stop.send(()); (stopped_preview_status(), true) } + + pub(crate) fn stop_if_matches(&self, expected: &LocalPreviewResult) -> bool { + let mut current = self.current.lock().expect("preview registry lock"); + if current + .as_ref() + .is_none_or(|server| server.preview != *expected) + { + return false; + } + let Some(server) = current.take() else { + return false; + }; + let _ = server.stop.send(()); + true + } } static GAME_CREATOR_PREVIEW_REGISTRY: OnceLock = OnceLock::new(); @@ -160,18 +175,35 @@ pub(crate) fn filter_preview_status_for_project( #[tauri::command] pub(crate) fn start_local_game_preview( project_path: String, + expected_revision: Option, registry: tauri::State<'_, PreviewRegistry>, ) -> Result { let root = Path::new(project_path.trim()); - start_local_game_preview_at(root, ®istry) + start_local_game_preview_at_revision(root, expected_revision, ®istry) } pub(crate) fn start_local_game_preview_at( root: &Path, registry: &PreviewRegistry, +) -> Result { + start_local_game_preview_at_revision(root, None, registry) +} + +pub(crate) fn start_local_game_preview_at_revision( + root: &Path, + expected_revision: Option, + registry: &PreviewRegistry, ) -> Result { enforce_project_permission_policy(root, "preview.start")?; let _lock = acquire_project_write_lock(root, "preview.start")?; + if let Some(expected_revision) = expected_revision { + let current_revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + if current_revision != expected_revision { + return Err(format!( + "本地游戏项目已在验证后发生变化(已验证 revision:{expected_revision},当前 revision:{current_revision})" + )); + } + } let (preview, stop) = start_local_game_preview_for_project(root)?; if let Err(error) = record_preview_state( root, @@ -231,6 +263,46 @@ pub(crate) fn stop_local_game_preview_for_root( Ok(status) } +#[tauri::command] +pub(crate) fn stop_local_game_preview_if_matches( + project_path: String, + expected_preview: LocalPreviewResult, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + stop_local_game_preview_if_matches_at( + Path::new(project_path.trim()), + &expected_preview, + ®istry, + ) +} + +pub(crate) fn stop_local_game_preview_if_matches_at( + root: &Path, + expected_preview: &LocalPreviewResult, + registry: &PreviewRegistry, +) -> Result { + let expected_status = local_preview_status_from_result(expected_preview); + ensure_preview_belongs_to_project(&expected_status, root)?; + if !registry.stop_if_matches(expected_preview) { + return Ok(false); + } + // This command is a compensating cleanup for a preview that became stale while an + // asynchronous start was in flight. Stop the exact registry identity before waiting + // for project persistence so a denied stop policy or a busy project lock cannot leak + // the loopback server. A newer preview for the same project owns the durable state. + let _lock = acquire_project_write_lock(root, "preview.stop")?; + let current_status = registry.status(); + if current_status.status == "running" + && ensure_preview_belongs_to_project(¤t_status, root).is_ok() + { + return Ok(true); + } + record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?; + append_preview_log(root, "stopped", None)?; + append_preview_stop_trace_step(root)?; + Ok(true) +} + #[tauri::command] pub(crate) fn get_local_game_preview_status( registry: tauri::State<'_, PreviewRegistry>, @@ -253,6 +325,23 @@ pub(crate) fn get_local_game_preview_status_at( )) } +#[tauri::command] +pub(crate) fn get_local_game_project_revision( + project_path: String, +) -> Result { + get_local_game_project_revision_at(Path::new(project_path.trim())) +} + +pub(crate) fn get_local_game_project_revision_at( + root: &Path, +) -> Result { + enforce_project_permission_policy(root, "preview.status")?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + Ok(LocalGameProjectRevisionStatus { + revision: revision.revision, + }) +} + #[tauri::command] pub(crate) fn activate_local_game_preview( registry: tauri::State<'_, PreviewRegistry>, @@ -478,7 +567,7 @@ pub(crate) fn content_type(path: &Path) -> &'static str { fn http_response(status: &str, content_type: &str, body: &[u8], content_length: usize) -> Vec { let header = format!( - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nPragma: no-cache\r\nExpires: 0\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", content_length ); let mut response = header.into_bytes(); 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 93d9879b0..30835b4d9 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 @@ -404,13 +404,14 @@ async fn terminate_project_verification_process_tree(child: &mut tokio::process: terminate_project_verification_process_group(process_id); #[cfg(windows)] { - let _ = tokio::process::Command::new("taskkill") + let mut command = tokio::process::Command::new("taskkill"); + command .args(["/PID", &process_id.to_string(), "/T", "/F"]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await; + .stderr(std::process::Stdio::null()); + crate::configure_windows_background_tokio_command(&mut command, false); + let _ = command.status().await; } } let _ = child.kill().await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs index 92461f3f1..ba92e21c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs @@ -1451,6 +1451,7 @@ fn isolated_git_command(root: &Path) -> Command { let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" }; let mut command = Command::new("git"); + crate::configure_windows_background_std_command(&mut command, false); command.env_clear(); for (key, value) in inherited_environment { command.env(key, value); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index f5c50af25..475f62ca9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -16,9 +16,9 @@ pub(crate) use client::{ read_external_agent_runner_mcp_catalog, read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, - shutdown_external_agent_runner, shutdown_external_agent_runner_if_idle, - steer_external_agent_runner, wake_external_agent_runner_pending, - wake_external_agent_runner_pending_for_run, + shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit, + shutdown_external_agent_runner_if_idle, steer_external_agent_runner, + wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, }; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 846c567ea..975968661 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -4,16 +4,193 @@ use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::ffi::OsString; use std::fs; -use std::io::{self, Write}; +use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; -pub(super) fn launch_external_agent_runner(config_dir: &Path) -> Result { +const AGENT_RUNNER_LOG_FILE_NAME: &str = "agent-runner.log"; +const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024; +const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024; +const AGENT_RUNNER_CLIENT_EXIT_TIMEOUT: Duration = Duration::from_secs(15); + +fn redact_url_queries(line: &str) -> String { + line.split_whitespace() + .map(|token| { + if (token.starts_with("http://") || token.starts_with("https://")) + && token.contains('?') + { + let base = token.split_once('?').map(|(base, _)| base).unwrap_or(token); + format!("{base}?") + } else { + token.to_string() + } + }) + .collect::>() + .join(" ") +} + +fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { + let lowercase = line.to_ascii_lowercase(); + if [ + "authorization", + "bearer ", + "api_key", + "apikey", + "api key", + "x-api-key", + "token=", + "token:", + "credential", + "password", + "cookie", + "set-cookie", + "secret", + "access_token", + "refresh_token", + "\"token\"", + "'token'", + ] + .iter() + .any(|marker| lowercase.contains(marker)) + { + return "".to_string(); + } + if lowercase.contains("panic") { + return "".to_string(); + } + let safe_internal_detail = + lowercase.starts_with("agent.runner.failed:") || lowercase.starts_with("runner."); + if !safe_internal_detail { + let summary = if ["error", "failed", "failure", "失败", "错误", "异常"] + .iter() + .any(|marker| lowercase.contains(marker)) + { + "" + } else if ["warning", "warn:"] + .iter() + .any(|marker| lowercase.contains(marker)) + { + "" + } else { + "" + }; + return summary.to_string(); + } + crate::sanitize_diagnostic_message(&redact_url_queries(line), Some(config_dir)) + .chars() + .take(AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS) + .collect() +} + +fn read_bounded_agent_runner_line( + reader: &mut R, +) -> io::Result> { + let mut content = Vec::new(); + let mut truncated = false; + let mut saw_bytes = false; + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return if saw_bytes { + Ok(Some(( + String::from_utf8_lossy(&content).into_owned(), + truncated, + ))) + } else { + Ok(None) + }; + } + saw_bytes = true; + let newline = available.iter().position(|byte| *byte == b'\n'); + let consumed = newline.map(|index| index + 1).unwrap_or(available.len()); + let payload_len = newline.unwrap_or(available.len()); + let remaining = AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES.saturating_sub(content.len()); + let copied = payload_len.min(remaining); + content.extend_from_slice(&available[..copied]); + if copied < payload_len { + truncated = true; + } + reader.consume(consumed); + if newline.is_some() { + return Ok(Some(( + String::from_utf8_lossy(&content).into_owned(), + truncated, + ))); + } + } +} + +fn spawn_agent_runner_log_pump( + stream: R, + stream_name: &'static str, + log_path: PathBuf, + config_dir: PathBuf, +) where + R: Read + Send + 'static, +{ + let _ = thread::Builder::new() + .name(format!("agent-runner-{stream_name}-log")) + .spawn(move || { + let mut reader = BufReader::new(stream); + loop { + match read_bounded_agent_runner_line(&mut reader) { + Ok(None) => break, + Ok(Some((line, truncated))) => { + let line = sanitize_agent_runner_output(line.trim(), &config_dir); + let _ = crate::append_bounded_diagnostic_line( + &log_path, + &format!( + "runner.{stream_name} truncated={} {line}", + if truncated { "true" } else { "false" } + ), + ); + } + Err(_) => { + let _ = crate::append_bounded_diagnostic_line( + &log_path, + &format!("runner.{stream_name}.read-failed details=redacted"), + ); + break; + } + } + } + }); +} + +pub(super) struct LaunchedExternalAgentRunner { + child: Child, + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + runner_job: crate::WindowsKillOnCloseJob, +} + +#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] +fn terminate_failed_external_agent_runner_launch(child: &mut Child, error: String) -> String { + let kill_error = child.kill().err(); + let wait_error = child.wait().err(); + match (kill_error, wait_error) { + (None, None) => error, + (kill_error, wait_error) => format!( + "{error};清理启动失败的 Agent Runner 时出错:kill={},wait={}", + kill_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "ok".to_string()), + wait_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "ok".to_string()) + ), + } +} + +pub(super) fn launch_external_agent_runner( + config_dir: &Path, +) -> Result { let executable = std::env::current_exe() .map_err(|error| format!("读取 Agent Runner 当前二进制失败:{error}"))?; + let runner_log_path = config_dir.join(AGENT_RUNNER_LOG_FILE_NAME); + let _ = crate::append_bounded_diagnostic_line(&runner_log_path, "runner.launch.begin"); let mut command = Command::new(executable); let gui_owner_required = EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT.load(std::sync::atomic::Ordering::Acquire); @@ -23,8 +200,8 @@ pub(super) fn launch_external_agent_runner(config_dir: &Path) -> Result Result job, + Err(error) => { + return Err(terminate_failed_external_agent_runner_launch( + &mut child, error, + )); + } + }; + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + if let Err(error) = runner_job.resume_suspended_runner(&child) { + drop(runner_job); + return Err(terminate_failed_external_agent_runner_launch( + &mut child, error, + )); + } + if let Some(stdout) = child.stdout.take() { + spawn_agent_runner_log_pump( + stdout, + "stdout", + runner_log_path.clone(), + config_dir.to_path_buf(), + ); + } + if let Some(stderr) = child.stderr.take() { + spawn_agent_runner_log_pump( + stderr, + "stderr", + runner_log_path.clone(), + config_dir.to_path_buf(), + ); + } + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + let _ = crate::append_bounded_diagnostic_line( + &runner_log_path, + "runner.launch.job.assigned-and-resumed", + ); + let _ = crate::append_bounded_diagnostic_line(&runner_log_path, "runner.launch.spawned"); + Ok(LaunchedExternalAgentRunner { + child, + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + runner_job, + }) } pub(super) fn external_agent_runner_launch_arguments( @@ -245,7 +464,22 @@ fn request_external_agent_runner_shutdown_if_idle_at( return Ok(false); } - let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; + wait_for_external_agent_runner_boot_exit( + endpoint_path, + endpoint, + EXTERNAL_AGENT_RUNNER_START_TIMEOUT, + "旧 Agent Runner 未在版本切换期限内退出", + )?; + Ok(true) +} + +fn wait_for_external_agent_runner_boot_exit( + endpoint_path: &Path, + endpoint: &ExternalAgentRunnerEndpoint, + timeout: Duration, + timeout_error: &str, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; let lock_path = endpoint_path .parent() .map(external_agent_runner_lock_path) @@ -253,23 +487,62 @@ fn request_external_agent_runner_shutdown_if_idle_at( loop { match read_external_agent_runner_endpoint(endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => {} - Ok(_) => return Ok(true), + Ok(_) => return Ok(()), Err(_) => { if let Some(lock) = try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? { drop(lock); - return Ok(true); + return Ok(()); } } } if Instant::now() >= deadline { - return Err("旧 Agent Runner 未在版本切换期限内退出".to_string()); + return Err(timeout_error.to_string()); } thread::sleep(Duration::from_millis(50)); } } +fn read_external_agent_runner_endpoint_for_shutdown( + config_dir: &Path, +) -> Result, String> { + let endpoint_path = external_agent_runner_endpoint_path(config_dir); + let lock_path = external_agent_runner_lock_path(config_dir); + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; + loop { + match fs::symlink_metadata(&endpoint_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("Agent Runner endpoint 不允许符号链接".to_string()); + } + Ok(_) => { + let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?; + return Ok(Some((endpoint_path, endpoint))); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if let Some(lock) = + try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? + { + drop(lock); + return Ok(None); + } + if Instant::now() >= deadline { + return Err( + "Agent Runner 启动锁仍被占用,但 endpoint 未在期限内就绪".to_string() + ); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(format!( + "读取 Agent Runner endpoint 元数据失败:{}: {error}", + endpoint_path.display() + )); + } + } + } +} + pub(super) fn shutdown_external_agent_runner_if_idle_at(config_dir: &Path) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let lock_path = external_agent_runner_lock_path(config_dir); @@ -656,6 +929,57 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { } } +pub(super) fn shutdown_external_agent_runner_for_client_exit_at( + config_dir: &Path, +) -> Result<(), String> { + let Some((endpoint_path, endpoint)) = + read_external_agent_runner_endpoint_for_shutdown(config_dir)? + else { + return Ok(()); + }; + let request_id = random_identifier(b"genarrative-agent-runner-client-exit-request-id")?; + let result = match send_external_agent_runner_request_with_protocol_and_id( + &endpoint, + endpoint.protocol_version, + request_id, + "runner.shutdown_for_client_exit", + ExternalAgentRunnerRequestParams::default(), + ) { + Ok(result) => result, + Err(error) => { + return match read_external_agent_runner_endpoint(&endpoint_path) { + Ok(current) if current.boot_id == endpoint.boot_id => Err(error), + _ => Ok(()), + }; + } + }; + let accepted = result + .get("accepted") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 accepted".to_string())?; + let will_shutdown = result + .get("willShutdown") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 willShutdown".to_string())?; + if !accepted || !will_shutdown { + return Err("Agent Runner 拒绝按客户端退出协议关闭".to_string()); + } + wait_for_external_agent_runner_boot_exit( + &endpoint_path, + &endpoint, + AGENT_RUNNER_CLIENT_EXIT_TIMEOUT, + "Agent Runner 未在客户端退出期限内停止", + ) +} + +pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result<(), String> { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(()); + }; + shutdown_external_agent_runner_for_client_exit_at(&config_dir) +} + pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, @@ -663,7 +987,6 @@ pub(super) fn wait_for_external_agent_runner( ) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; - let mut child_exit_status = None; loop { if let Some(endpoint) = read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint) @@ -672,17 +995,14 @@ pub(super) fn wait_for_external_agent_runner( return Ok(endpoint); } } - if child_exit_status.is_none() { - child_exit_status = child - .try_wait() - .map_err(|error| format!("检查外部 Agent Runner 子进程失败:{error}"))? - .map(|status| status.to_string()); + if let Some(status) = child + .try_wait() + .map_err(|error| format!("检查外部 Agent Runner 子进程失败:{error}"))? + { + return Err(format!("外部 Agent Runner 在就绪前退出:{status}")); } if Instant::now() >= deadline { - return Err(match child_exit_status { - Some(status) => format!("外部 Agent Runner 在就绪前退出:{status}"), - None => "外部 Agent Runner 未在启动期限内就绪".to_string(), - }); + return Err("外部 Agent Runner 未在启动期限内就绪".to_string()); } thread::sleep(Duration::from_millis(50)); } @@ -719,20 +1039,22 @@ pub(super) fn ensure_external_agent_runner( } } } - let mut child = launch_external_agent_runner(config_dir)?; - match wait_for_external_agent_runner(config_dir, &mut child, &executable_fingerprint) { + let mut launched = launch_external_agent_runner(config_dir)?; + match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) { Ok(endpoint) => { thread::Builder::new() .name("agent-runner-reaper".to_string()) .spawn(move || { - let _ = child.wait(); + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + let _runner_job = launched.runner_job; + let _ = launched.child.wait(); }) .map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?; Ok(endpoint) } Err(error) => { - let _ = child.kill(); - let _ = child.wait(); + let _ = launched.child.kill(); + let _ = launched.child.wait(); Err(error) } } @@ -1117,3 +1439,63 @@ pub(crate) fn read_external_agent_runner_status() -> ExternalAgentRunnerStatus { let config_dir = external_agent_runner_config_dir(); read_external_agent_runner_status_at(config_dir.as_deref()) } + +#[cfg(test)] +mod diagnostic_log_tests { + use super::*; + + #[test] + fn runner_log_output_redacts_config_paths_and_credentials() { + let config_dir = Path::new(r"C:\Users\example\AppData\Roaming\game-chat"); + assert_eq!( + sanitize_agent_runner_output( + r"agent.runner.failed: failed to open C:\Users\example\AppData\Roaming\game-chat\state.json", + config_dir, + ), + "agent.runner.failed: failed to open \\state.json" + ); + assert_eq!( + sanitize_agent_runner_output("Authorization: Bearer secret", config_dir), + "" + ); + assert_eq!( + sanitize_agent_runner_output( + r"agent.runner.failed: project C:\private\game\index.html failed", + config_dir, + ), + "agent.runner.failed: project failed" + ); + assert_eq!( + sanitize_agent_runner_output("normal model response body", config_dir), + "" + ); + assert_eq!( + sanitize_agent_runner_output( + "agent.runner.failed: request failed https://example.invalid/api?value=1", + config_dir, + ), + "agent.runner.failed: request failed https://example.invalid/api?" + ); + assert_eq!( + sanitize_agent_runner_output("error password=hunter2", config_dir), + "" + ); + } + + #[test] + fn runner_log_line_reader_caps_long_lines_and_drains_to_next_line() { + let mut input = vec![b'x'; AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES + 500]; + input.extend_from_slice(b"\nerror: second line\n"); + let mut reader = BufReader::new(std::io::Cursor::new(input)); + let (first, first_truncated) = read_bounded_agent_runner_line(&mut reader) + .expect("read first line") + .expect("first line exists"); + assert_eq!(first.len(), AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES); + assert!(first_truncated); + let (second, second_truncated) = read_bounded_agent_runner_line(&mut reader) + .expect("read second line") + .expect("second line exists"); + assert_eq!(second, "error: second line"); + assert!(!second_truncated); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 25547702e..f0be74db5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -617,6 +617,14 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( }), ) } + "runner.shutdown_for_client_exit" if cfg!(any(test, feature = "game-chat-release")) => { + state.draining.store(true, Ordering::Release); + state.shutdown_requested.store(true, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "willShutdown": true }), + ) + } "runner.shutdown_if_idle" | "shutdown_if_idle" => { if request.params.root.is_some() { match external_agent_runner_request_root(request) { @@ -784,6 +792,7 @@ pub(super) fn handle_external_agent_runner_request( | "runner.attach_gui_owner" | "runner.shutdown" | "shutdown" + | "runner.shutdown_for_client_exit" | "runner.shutdown_if_idle" | "shutdown_if_idle" => dispatch_external_agent_runner_runtime_request(&request, state), _ => ExternalAgentRunnerResponse::failure( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index 67b42b3c9..b4c91e3fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -331,7 +331,37 @@ pub(super) fn private_create_new_file(path: &Path) -> io::Result { .open(path) } - #[cfg(not(unix))] + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let file = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let secured = (|| { + validate_windows_regular_file_handle(&file, "新建私有临时文件") + .map_err(io::Error::other)?; + crate::initialize_windows_game_creator_file_owner_for_current_user(path) + .map_err(io::Error::other)?; + validate_windows_regular_file_handle(&file, "新建私有临时文件") + .map_err(io::Error::other)?; + crate::secure_windows_game_creator_path_for_current_user(path, false, false) + .map_err(io::Error::other) + })(); + if let Err(error) = secured { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(file) + } + + #[cfg(not(any(unix, windows)))] { OpenOptions::new().create_new(true).write(true).open(path) } @@ -444,6 +474,18 @@ pub(super) fn write_external_agent_runner_endpoint_atomic( let parent = path .parent() .ok_or_else(|| "Agent Runner endpoint 缺少父目录".to_string())?; + #[cfg(windows)] + { + let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?; + let expected_path = private_parent.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); + if path != expected_path { + return Err(format!( + "Agent Runner endpoint 必须位于已验证的私有 AppData 固定路径:{}", + expected_path.display() + )); + } + } + #[cfg(not(windows))] fs::create_dir_all(parent).map_err(|error| { format!( "创建 Agent Runner endpoint 目录失败:{}: {error}", @@ -750,6 +792,11 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(windows)] +pub(super) fn windows_external_agent_runner_lock_is_busy_error(error: &io::Error) -> bool { + matches!(error.raw_os_error(), Some(32 | 33)) +} + #[cfg(windows)] pub(super) fn try_open_external_agent_runner_lock( path: &Path, @@ -759,6 +806,18 @@ pub(super) fn try_open_external_agent_runner_lock( const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let parent = path + .parent() + .ok_or_else(|| format!("{label} 缺少 AppData 父目录:{}", path.display()))?; + let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?; + let expected_path = private_parent.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME); + if path != expected_path { + return Err(format!( + "{label} 必须位于已验证的私有 AppData 固定路径:{}", + expected_path.display() + )); + } + match OpenOptions::new() .create(true) .read(true) @@ -781,17 +840,16 @@ pub(super) fn try_open_external_agent_runner_lock( )); } validate_windows_regular_file_handle(&file, label)?; - crate::secure_windows_game_creator_path_for_current_user(path, false, true)?; + // share_mode(0) gives this process an exclusive handle. At this point the fixed + // lock path is known to be a stale, single-link, non-reparse regular file inside + // the current TokenUser's private AppData. Repairing its owner is therefore safe + // and is required when Windows creates it with TokenOwner=Administrators. + crate::initialize_windows_game_creator_file_owner_for_current_user(path)?; + validate_windows_regular_file_handle(&file, label)?; + crate::secure_windows_game_creator_path_for_current_user(path, false, false)?; Ok(Some(file)) } - Err(error) - if matches!( - error.kind(), - io::ErrorKind::PermissionDenied | io::ErrorKind::WouldBlock - ) => - { - Ok(None) - } + Err(error) if windows_external_agent_runner_lock_is_busy_error(&error) => Ok(None), Err(error) => Err(format!( "安全打开 {label} 失败:{}: {error}", path.display() diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index c45b9288c..e5b29e3f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -1236,6 +1236,156 @@ fn forced_shutdown_is_accepted_even_when_runtime_is_busy() { assert!(state.shutdown_requested.load(Ordering::Acquire)); } +#[test] +fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-client-exit.json"); + fs::create_dir_all(pending.parent().expect("pending parent")) + .expect("create pending directory"); + let durable_bytes = br#"{"durable":true}"#; + fs::write(&pending, durable_bytes).expect("write pending action"); + let token = "client-exit-private-token-client-exit-private-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "client-exit-boot-id", 32326), + ); + state.remember_root(&root); + + let unauthorized_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-unauthorized".to_string(), + token: "wrong-client-exit-private-token".to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(!unauthorized_response.ok); + assert_eq!( + unauthorized_response + .error + .as_ref() + .map(|error| error.code.as_str()), + Some("unauthorized") + ); + assert!(!state.shutdown_requested.load(Ordering::Acquire)); + assert!(!state.draining.load(Ordering::Acquire)); + assert_eq!( + fs::read(&pending).expect("read pending action after rejected shutdown"), + durable_bytes + ); + + let idle_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-idle-check".to_string(), + token: token.to_string(), + method: "runner.shutdown_if_idle".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(idle_response.ok); + assert_eq!( + idle_response + .result + .as_ref() + .and_then(|value| value["idle"].as_bool()), + Some(false) + ); + assert!(!state.shutdown_requested.load(Ordering::Acquire)); + assert!(!state.draining.load(Ordering::Acquire)); + + let shutdown_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-force-1".to_string(), + token: token.to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(shutdown_response.ok); + assert_eq!( + shutdown_response + .result + .as_ref() + .and_then(|value| value["accepted"].as_bool()), + Some(true) + ); + assert_eq!( + shutdown_response + .result + .as_ref() + .and_then(|value| value["willShutdown"].as_bool()), + Some(true) + ); + assert!(state.shutdown_requested.load(Ordering::Acquire)); + assert!(state.draining.load(Ordering::Acquire)); + assert_eq!( + fs::read(&pending).expect("read pending action"), + durable_bytes + ); + + let write_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-write-after-drain".to_string(), + token: token.to_string(), + method: "runtime.continue_action".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some("code-prototype".to_string()), + run_id: Some("run-client-exit".to_string()), + action_id: Some("action-client-exit".to_string()), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + assert!(!write_response.ok); + assert_eq!( + write_response + .error + .as_ref() + .map(|error| error.code.as_str()), + Some("runner-draining") + ); + + let repeated_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-force-2".to_string(), + token: token.to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(repeated_response.ok); + assert_eq!( + repeated_response + .result + .as_ref() + .and_then(|value| value["accepted"].as_bool()), + Some(true) + ); + assert_eq!( + repeated_response + .result + .as_ref() + .and_then(|value| value["willShutdown"].as_bool()), + Some(true) + ); + assert_eq!( + fs::read(&pending).expect("reread pending action"), + durable_bytes + ); +} + #[test] fn durable_tool_plan_handoff_prevents_shutdown_even_when_corrupt() { let directory = unique_test_directory(); @@ -1482,7 +1632,9 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() { #[test] fn stale_protocol_endpoint_does_not_override_instance_lock_arbitration() { let directory = unique_test_directory(); - let endpoint_path = directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let endpoint_path = config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); let mut stale = test_endpoint( "stale-private-token-stale-private-token", "stale-boot-id", @@ -1502,13 +1654,133 @@ fn stale_protocol_endpoint_does_not_override_instance_lock_arbitration() { .is_none()); let boot_id = "current-lock-owner"; let lock = acquire_external_agent_runner_instance_lock( - &external_agent_runner_lock_path(&directory.0), + &external_agent_runner_lock_path(&config_dir), boot_id, ) .expect("stale endpoint must not block the authoritative instance lock"); drop(lock); } +#[test] +fn active_runner_lock_is_not_repaired_or_truncated() { + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let lock_path = external_agent_runner_lock_path(&config_dir); + let first = acquire_external_agent_runner_instance_lock(&lock_path, "first-active-boot") + .expect("acquire first runner lock"); + + let error = match acquire_external_agent_runner_instance_lock(&lock_path, "second-boot") { + Ok(_) => panic!("active runner lock must reject a second owner"), + Err(error) => error, + }; + + assert!(error.contains("其他进程运行")); + drop(first); + let diagnostic: Value = serde_json::from_slice( + &fs::read(&lock_path).expect("read runner lock after rejected acquisition"), + ) + .expect("parse runner lock after rejected acquisition"); + assert_eq!(diagnostic["bootId"], "first-active-boot"); +} + +#[cfg(windows)] +#[test] +fn windows_stale_runner_lock_is_reowned_for_token_user() { + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let lock_path = external_agent_runner_lock_path(&config_dir); + fs::write(&lock_path, b"stale-lock-from-token-default-owner") + .expect("create stale runner lock"); + if !crate::tests::configuration::set_windows_test_path_owner_to_distinct_token_owner(&lock_path) + { + eprintln!("skip: 当前 Windows token 没有区别于 TokenUser 且可设置的默认 owner SID"); + return; + } + assert!( + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false).is_err(), + "fixture lock must start with a foreign owner" + ); + + let lock = acquire_external_agent_runner_instance_lock(&lock_path, "reowned-boot") + .expect("repair and acquire stale runner lock"); + + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) + .expect("runner lock owner must match TokenUser SID"); + drop(lock); + let diagnostic: Value = serde_json::from_slice( + &fs::read(&lock_path).expect("read repaired runner lock diagnostic"), + ) + .expect("parse repaired runner lock diagnostic"); + assert_eq!(diagnostic["bootId"], "reowned-boot"); +} + +#[cfg(windows)] +#[test] +fn windows_runner_lock_rejects_hard_link_without_touching_target() { + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let target = config_dir.join("lock-target.txt"); + let lock_path = external_agent_runner_lock_path(&config_dir); + fs::write(&target, b"do-not-truncate").expect("write lock target"); + fs::hard_link(&target, &lock_path).expect("create runner lock hard link"); + + let error = match acquire_external_agent_runner_instance_lock(&lock_path, "hard-link-boot") { + Ok(_) => panic!("runner lock hard link must be rejected"), + Err(error) => error, + }; + + assert!(error.contains("硬链接")); + assert_eq!( + fs::read(&target).expect("read untouched lock target"), + b"do-not-truncate" + ); +} + +#[cfg(windows)] +#[test] +fn windows_runner_lock_rejects_symlink_without_touching_target_when_supported() { + use std::os::windows::fs::symlink_file; + + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let target = config_dir.join("lock-symlink-target.txt"); + let lock_path = external_agent_runner_lock_path(&config_dir); + fs::write(&target, b"do-not-truncate").expect("write lock symlink target"); + if symlink_file(&target, &lock_path).is_err() { + eprintln!("skip: 当前 Windows 环境不允许创建文件符号链接"); + return; + } + + let error = match acquire_external_agent_runner_instance_lock(&lock_path, "symlink-boot") { + Ok(_) => panic!("runner lock symlink must be rejected"), + Err(error) => error, + }; + + assert!(error.contains("reparse point") || error.contains("普通文件")); + assert_eq!( + fs::read(&target).expect("read untouched lock symlink target"), + b"do-not-truncate" + ); +} + +#[cfg(windows)] +#[test] +fn windows_runner_lock_busy_error_classification_is_exact() { + assert!(windows_external_agent_runner_lock_is_busy_error( + &io::Error::from_raw_os_error(32) + )); + assert!(windows_external_agent_runner_lock_is_busy_error( + &io::Error::from_raw_os_error(33) + )); + assert!(!windows_external_agent_runner_lock_is_busy_error( + &io::Error::from_raw_os_error(5) + )); +} + #[cfg(unix)] #[test] fn runner_lock_rejects_symlink_without_touching_target() { @@ -1617,6 +1889,13 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { record.recovered_from_boot_id.as_deref(), Some("owner-boot-a") ); + #[cfg(windows)] + crate::secure_windows_game_creator_path_for_current_user( + &root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH), + false, + false, + ) + .expect("project owner diagnostic must match TokenUser SID"); } #[test] @@ -1962,7 +2241,9 @@ fn read_only_runner_configuration_does_not_chmod_appdata() { #[test] fn endpoint_write_is_atomic_and_private() { let directory = unique_test_directory(); - let path = directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let path = config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); let first = test_endpoint( "first-private-token-first-private-token", "boot-first", @@ -1981,7 +2262,7 @@ fn endpoint_write_is_atomic_and_private() { assert_eq!(persisted.boot_id, "boot-second"); assert_eq!(persisted.port, 20202); assert_eq!(persisted.token, "second-private-token-second-private-token"); - let names = fs::read_dir(&directory.0) + let names = fs::read_dir(&config_dir) .expect("list endpoint directory") .map(|entry| { entry @@ -2004,6 +2285,39 @@ fn endpoint_write_is_atomic_and_private() { & 0o777; assert_eq!(mode, 0o600); } + + #[cfg(windows)] + crate::secure_windows_game_creator_path_for_current_user(&path, false, false) + .expect("endpoint owner must match TokenUser SID"); +} + +#[test] +fn runner_child_exit_is_reported_without_waiting_for_start_timeout() { + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + #[cfg(windows)] + let mut child = std::process::Command::new("cmd.exe") + .args(["/D", "/C", "exit", "/B", "7"]) + .spawn() + .expect("spawn immediately failing child"); + #[cfg(unix)] + let mut child = std::process::Command::new("/bin/sh") + .args(["-c", "exit 7"]) + .spawn() + .expect("spawn immediately failing child"); + + let started = Instant::now(); + let error = match wait_for_external_agent_runner(&config_dir, &mut child, &"a".repeat(64)) { + Ok(_) => panic!("exited child must fail runner startup"), + Err(error) => error, + }; + + assert!(error.contains("在就绪前退出")); + assert!( + started.elapsed() < Duration::from_secs(2), + "exited child must not wait for the full startup deadline" + ); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index f91ddab26..a8c3ba393 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -978,3 +978,205 @@ fn appdata_config_dir_is_owned_privately() { fs::remove_dir_all(config_dir).ok(); } + +#[cfg(windows)] +#[test] +fn newly_created_windows_appdata_is_owned_by_token_user() { + let root = unique_project_path(); + let config_dir = root.join("appdata"); + + let prepared = prepare_game_creator_runtime_config_dir(&config_dir) + .expect("create and secure Windows AppData directory"); + + // TokenOwner 可能是 Administrators;安全边界必须以 TokenUser SID 为准。 + secure_windows_game_creator_path_for_current_user(&prepared, true, false) + .expect("prepared directory owner must match TokenUser SID"); + fs::remove_dir_all(root).ok(); +} + +#[cfg(windows)] +#[test] +fn windows_foreign_owner_prepare_preserves_backup_and_recreates_private_appdata() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create backup test root"); + let config_dir = root.join("appdata"); + fs::create_dir(&config_dir).expect("create old config directory"); + fs::write(config_dir.join("important.json"), b"preserve-me").expect("write old configuration"); + if !set_windows_test_path_owner_to_distinct_token_owner(&config_dir) { + eprintln!("skip: 当前 Windows token 没有区别于 TokenUser 且可设置的默认 owner SID"); + fs::remove_dir_all(root).ok(); + return; + } + assert!(secure_windows_game_creator_path_for_current_user(&config_dir, true, false).is_err()); + + let prepared = prepare_game_creator_runtime_config_dir(&config_dir) + .expect("prepare must isolate foreign-owner AppData and recreate it"); + + assert_eq!( + prepared, + fs::canonicalize(&config_dir).expect("canonical AppData") + ); + secure_windows_game_creator_path_for_current_user(&prepared, true, false) + .expect("new AppData must be owned privately by TokenUser SID"); + let backups = fs::read_dir(&root) + .expect("read backup parent") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name().is_some_and(|name| { + name.to_string_lossy() + .starts_with("appdata.owner-mismatch-backup-") + }) + }) + .collect::>(); + assert_eq!( + backups.len(), + 1, + "must create exactly one owner-mismatch backup" + ); + assert_eq!( + fs::read(backups[0].join("important.json")).expect("read preserved configuration"), + b"preserve-me" + ); + assert!(!config_dir.join("important.json").exists()); + fs::remove_dir_all(root).ok(); +} + +#[cfg(windows)] +pub(crate) fn set_windows_test_path_owner_to_distinct_token_owner(path: &Path) -> bool { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + + type Handle = *mut c_void; + type Sid = *mut c_void; + + #[repr(C)] + struct SidAndAttributes { + sid: Sid, + attributes: u32, + } + + #[repr(C)] + struct TokenUser { + user: SidAndAttributes, + } + + #[repr(C)] + struct TokenOwner { + owner: Sid, + } + + #[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 EqualSid(first: Sid, second: Sid) -> i32; + fn SetNamedSecurityInfoW( + object_name: *mut u16, + object_type: u32, + security_info: u32, + owner: Sid, + group: Sid, + dacl: *mut c_void, + sacl: *mut c_void, + ) -> u32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Handle; + fn CloseHandle(handle: Handle) -> i32; + } + + const TOKEN_QUERY: u32 = 0x0000_0008; + const TOKEN_USER_CLASS: u32 = 1; + const TOKEN_OWNER_CLASS: u32 = 4; + const SE_FILE_OBJECT: u32 = 1; + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + + unsafe fn token_information(token: Handle, class: u32) -> Option> { + let mut required = 0_u32; + unsafe { GetTokenInformation(token, class, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 { + return None; + } + let word_size = std::mem::size_of::(); + let mut buffer = vec![0_usize; (required as usize).div_ceil(word_size)]; + if unsafe { + GetTokenInformation( + token, + class, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return None; + } + Some(buffer) + } + + let mut token = std::ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 + || token.is_null() + { + return false; + } + let changed = (|| { + let user_buffer = unsafe { token_information(token, TOKEN_USER_CLASS) }?; + let owner_buffer = unsafe { token_information(token, TOKEN_OWNER_CLASS) }?; + let token_user = unsafe { (*(user_buffer.as_ptr().cast::())).user.sid }; + let token_owner = unsafe { (*(owner_buffer.as_ptr().cast::())).owner }; + if token_user.is_null() + || token_owner.is_null() + || unsafe { EqualSid(token_user, token_owner) } != 0 + { + return None; + } + let mut wide_path = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let status = unsafe { + SetNamedSecurityInfoW( + wide_path.as_mut_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + token_owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + (status == 0).then_some(()) + })() + .is_some(); + unsafe { CloseHandle(token) }; + changed +} + +#[cfg(windows)] +#[test] +fn windows_appdata_validation_does_not_follow_directory_links() { + let root = unique_project_path(); + let real = root.join("real-appdata"); + let link = root.join("linked-appdata"); + fs::create_dir_all(&real).expect("create real directory"); + if std::os::windows::fs::symlink_dir(&real, &link).is_err() { + fs::remove_dir_all(root).ok(); + return; + } + + let error = inspect_game_creator_runtime_config_dir(&link) + .expect_err("AppData directory link must be rejected before canonicalize"); + assert!(error.contains("链接") || error.contains("reparse point")); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index dc98e4f36..32e1692c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -5277,7 +5277,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { mod collaboration; mod command_runtime; -mod configuration; +pub(crate) mod configuration; mod goal; mod project; mod project_tools; 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 6b9d5a842..550350adc 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 @@ -2829,6 +2829,52 @@ fn game_chat_launch_args_are_strict_and_keep_normal_start_compatible() { } } +#[test] +fn game_chat_release_flavor_selects_only_its_fixed_page_without_changing_debug() { + let explicit = GameChatLaunchOptions { + project_path: Some("/tmp/game".to_string()), + initial_message: Some("继续".to_string()), + }; + + assert_eq!( + select_game_chat_launch_options(None, false, true) + .expect("game-chat release default launch") + .expect("game-chat release options"), + GameChatLaunchOptions::default() + ); + assert_eq!( + select_game_chat_launch_options(Some(explicit.clone()), true, false) + .expect("debug explicit launch"), + Some(explicit) + ); + assert_eq!( + select_game_chat_launch_options(None, true, true).expect("debug normal launch"), + None, + "enabling the packaging feature must not change debug startup" + ); + assert!( + select_game_chat_launch_options(Some(GameChatLaunchOptions::default()), false, false) + .expect_err("ordinary release must reject --game-chat") + .contains("--game-chat") + ); +} + +#[test] +fn game_chat_release_requests_dedicated_runner_shutdown_only_on_final_exit() { + assert!(should_shutdown_runner_on_tauri_event( + true, + &tauri::RunEvent::Exit + )); + assert!(!should_shutdown_runner_on_tauri_event( + true, + &tauri::RunEvent::Ready + )); + assert!(!should_shutdown_runner_on_tauri_event( + false, + &tauri::RunEvent::Exit + )); +} + #[test] fn game_chat_window_url_encodes_optional_project_path() { assert_eq!( @@ -3164,6 +3210,12 @@ fn local_preview_head_preserves_asset_content_length() { assert!(response.contains("200 OK"), "{response}"); assert!(response.contains("Content-Type: image/png"), "{response}"); + assert!( + response.contains("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"), + "{response}" + ); + assert!(response.contains("Pragma: no-cache"), "{response}"); + assert!(response.contains("Expires: 0"), "{response}"); assert!(response.contains("Content-Length: 7"), "{response}"); assert!(!response.contains("PNGDATA"), "{response}"); assert!(response.ends_with("\r\n\r\n"), "{response}"); @@ -3171,6 +3223,109 @@ fn local_preview_head_preserves_asset_content_length() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_preview_project_revision_reports_the_current_atomic_sidecar() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览 revision 状态测试").expect("project init"); + let revision = advance_project_revision_for_test( + &root, + "code-prototype", + "preview-revision-status-run", + "file.write", + ); + + let status = + get_local_game_project_revision_at(&root).expect("read local preview project revision"); + assert_eq!(status.revision, revision); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn local_preview_start_rejects_a_stale_validated_revision_atomically() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览 revision 启动门禁测试") + .expect("project init"); + let revision = advance_project_revision_for_test( + &root, + "code-prototype", + "preview-revision-start-run", + "file.write", + ); + let registry = PreviewRegistry::default(); + + let error = start_local_game_preview_at_revision(&root, Some(revision + 1), ®istry) + .expect_err("stale validated revision must not start a preview"); + assert!(error.contains("已验证 revision"), "{error}"); + assert!(error.contains(&revision.to_string()), "{error}"); + assert_eq!(registry.status(), stopped_preview_status()); + + let preview = start_local_game_preview_at_revision(&root, Some(revision), ®istry) + .expect("matching revision starts preview"); + assert_eq!(registry.status().url.as_deref(), Some(preview.url.as_str())); + let _ = registry.stop(); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn stale_preview_cleanup_does_not_stop_a_newer_matching_project_server() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览原子停止测试").expect("project init"); + let registry = PreviewRegistry::default(); + let (first, first_stop) = + start_local_game_preview_for_project(&root).expect("first preview start"); + registry.set_running(first.clone(), first_stop); + let (second, second_stop) = + start_local_game_preview_for_project(&root).expect("second preview start"); + registry.set_running(second.clone(), second_stop); + assert_ne!(first.url, second.url); + + assert!( + !stop_local_game_preview_if_matches_at(&root, &first, ®istry) + .expect("stale cleanup is a no-op") + ); + let running = registry.status(); + assert_eq!(running.status, "running"); + assert_eq!(running.url.as_deref(), Some(second.url.as_str())); + assert_eq!(running.port, Some(second.port)); + + assert!( + stop_local_game_preview_if_matches_at(&root, &second, ®istry) + .expect("matching cleanup stops current preview") + ); + assert_eq!(registry.status(), stopped_preview_status()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览补偿清理策略测试").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["preview.open".to_string(), "preview.stop".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny user preview open and stop commands"); + let registry = PreviewRegistry::default(); + let (preview, stop) = + start_local_game_preview_for_project(&root).expect("preview server start"); + registry.set_running(preview.clone(), stop); + + assert!( + stop_local_game_preview_if_matches_at(&root, &preview, ®istry) + .expect("stale compensating cleanup") + ); + assert_eq!(registry.status(), stopped_preview_status()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_preview_serves_generated_playable_game() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 33faee356..4f098396f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -1,5 +1,35 @@ use super::*; +fn spawn_mock_llm_http_failures( + failure_count: usize, + failed_status_line: &'static str, + failed_body: String, + request_notice_sender: Option>, +) -> String { + let listener = bind_test_tcp_listener("mock repeated HTTP Provider failure bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + for _ in 0..failure_count { + let (mut stream, _) = listener + .accept() + .expect("mock repeated HTTP Provider failure accept"); + drop(read_mock_http_request(&mut stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); + } + let response = format!( + "HTTP/1.1 {failed_status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + failed_body.len(), + failed_body + ); + stream + .write_all(response.as_bytes()) + .expect("mock repeated HTTP Provider failure response"); + } + }); + base_url +} + #[test] fn llm_context_budget_validation_rejects_invalid_combinations() { let mut llm = GameCreatorLlmConfig::default(); @@ -3543,7 +3573,7 @@ async fn provider_retry_http_and_deserialize_failures_recover_through_durable_si "server-error", "503 Service Unavailable", serde_json::json!({"error": {"message": "temporarily unavailable"}}).to_string(), - "upstream-5xx", + "upstream-503", ), ("deserialize", "200 OK", "{".to_string(), "deserialize"), ]; @@ -3892,8 +3922,9 @@ async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_side let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "Provider 持久等待测试").expect("project init"); let (request_notice_sender, request_notice_receiver) = mpsc::channel(); - let base_url = spawn_mock_llm_transport_failures_then_response( - 1, + let base_url = spawn_mock_llm_http_failure_then_response( + "503 Service Unavailable", + serde_json::json!({"error": {"message": "temporary upstream outage"}}).to_string(), final_tool_plan_response("持久等待到期后已完成"), Some(request_notice_sender), ); @@ -3930,6 +3961,12 @@ async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_side assert_eq!(waiting.status, "running"); assert_eq!(waiting.run_id, run_id); assert_eq!(waiting.session_id, started.state.session_id); + assert_eq!( + waiting.current_action, + "Provider 上游返回 HTTP 503,准备自动重试 1/1" + ); + assert!(waiting.waiting_on.starts_with("预计 ")); + assert!(waiting.waiting_on.ends_with(" 秒后重试")); let mut lane_released = false; for _ in 0..250 { lane_released = game_creator_agent_runtime_task_lock_is_available(&root, "design-director") @@ -3945,6 +3982,7 @@ async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_side .expect("persisted Provider retry exists"); assert_eq!(retry.next_attempt, 1); assert_eq!(retry.max_retries, 1); + assert_eq!(retry.error_kind, "upstream-503"); assert_eq!(retry.identity.request_kind, "tool-plan"); assert_eq!(retry.identity.base_request_slot, "loop-1-repair-0"); assert_eq!(retry.identity.request_fingerprint.len(), 64); @@ -4513,9 +4551,19 @@ async fn provider_retry_waiting_exhaustion_fails_and_removes_sidecar() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "Provider 等待耗尽测试").expect("project init"); let (request_notice_sender, request_notice_receiver) = mpsc::channel(); - let base_url = spawn_mock_llm_transport_failures_then_response( + let provider_secret = ["sk", "retry-exhaustion-secret"].join("-"); + let upstream_body = serde_json::json!({ + "error": { + "message": format!( + "private upstream body url=https://provider.example/private?api_key={provider_secret} path=C:\\private\\provider.txt" + ) + } + }) + .to_string(); + let base_url = spawn_mock_llm_http_failures( 2, - final_tool_plan_response("重试耗尽后不应收到此响应"), + "503 Service Unavailable", + upstream_body, Some(request_notice_sender), ); let _config_guard = write_test_local_config(format!( @@ -4556,10 +4604,73 @@ async fn provider_retry_waiting_exhaustion_fails_and_removes_sidecar() { .expect("last allowed physical Provider request"); let failed = wait_for_agent_runtime_idle(&root, "design-director"); assert_eq!(failed.phase, "failed"); - assert!(failed - .error - .as_deref() - .is_some_and(|error| error.contains("kind=transport"))); + let error = failed.error.as_deref().expect("exhausted Provider error"); + assert!(error.contains("kind=upstream-503 httpStatus=503 fingerprint=")); + assert!(error.contains(" retryAttempt=1 maxRetries=1 retryState=exhausted")); + for forbidden in [ + "private upstream body", + "provider.example", + "api_key=", + provider_secret.as_str(), + "C:\\private\\provider.txt", + ] { + assert!( + !error.contains(forbidden), + "exhausted Provider error leaked {forbidden}" + ); + } + let conversation = read_local_conversation_at(&root, Some("design-director")) + .expect("read exhausted Provider conversation"); + assert!(conversation.messages.iter().any(|message| { + message.role == "assistant" + && message.content == "专业 Agent 上游服务返回 HTTP 503;自动重试已耗尽(1/1)" + })); + let conversation_text = + serde_json::to_string(&conversation).expect("serialize exhausted Provider conversation"); + for forbidden in [ + "private upstream body", + "provider.example", + "api_key=", + provider_secret.as_str(), + "C:\\private\\provider.txt", + "fingerprint=", + "retryState=", + ] { + assert!( + !conversation_text.contains(forbidden), + "Provider conversation leaked {forbidden}" + ); + } + let projected = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read exhausted Provider failure projection"); + let failure_events = projected + .recent_events + .iter() + .filter(|event| { + event.run_id == run_id && matches!(event.event_type.as_str(), "error" | "turn.failed") + }) + .collect::>(); + assert_eq!(failure_events.len(), 2); + assert!(failure_events.iter().all(|event| { + event.detail.as_deref() == Some("专业 Agent 上游服务返回 HTTP 503;自动重试已耗尽(1/1)") + })); + let public_events = serde_json::to_string(&failure_events) + .expect("serialize exhausted Provider failure events"); + for forbidden in [ + "fingerprint=", + "chars=", + "retryAttempt=", + "retryState=", + "absolute-path", + "redacted-secret", + "provider.example", + provider_secret.as_str(), + ] { + assert!( + !public_events.contains(forbidden), + "Provider failure event leaked {forbidden}" + ); + } assert!( crate::provider_retry::read_for_run_at(&root, "design-director", run_id) .expect("read Provider retry after exhaustion") @@ -5424,7 +5535,7 @@ fn agent_llm_public_error_summary_never_copies_provider_error_text() { let raw = format!( "provider failed at https://provider-error.example/v1 for /tmp/provider-private/project and task PROVIDER_TASK_SENTINEL with secret {provider_secret}" ); - let error = platform_llm::LlmError::Transport(raw); + let error = platform_llm::LlmError::Transport(raw.clone()); let summary = game_creator_agent_llm_error_public_summary(&error); assert!(summary.starts_with("kind=transport fingerprint=")); assert!(summary.contains(" chars=")); @@ -5439,6 +5550,25 @@ fn agent_llm_public_error_summary_never_copies_provider_error_text() { "public summary leaked {forbidden}" ); } + + let upstream = platform_llm::LlmError::Upstream { + status_code: 503, + message: raw, + }; + let upstream_summary = game_creator_agent_llm_error_public_summary(&upstream); + assert!(upstream_summary.starts_with("kind=upstream-503 httpStatus=503 fingerprint=")); + assert!(upstream_summary.contains(" chars=")); + for forbidden in [ + "provider-error.example", + "/tmp/provider-private/project", + "PROVIDER_TASK_SENTINEL", + provider_secret.as_str(), + ] { + assert!( + !upstream_summary.contains(forbidden), + "upstream public summary leaked {forbidden}" + ); + } } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index e4cf0c63e..51c511cde 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1982,6 +1982,65 @@ fn image_inspect_safe_receipt_keeps_legacy_v1_audit_readable() { fs::remove_dir_all(root).ok(); } +#[test] +fn preview_validate_public_event_detail_stays_structured_below_event_limit() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览事件短投影测试").expect("project init"); + let agent_id = "preview-playtest"; + let run_id = "autonomous-ready-preview-playtest-0123456789abcdefabcd"; + let evidence_root = format!(".agent/runtime/browser-validations/{agent_id}/{run_id}/27"); + let observation = AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "ok".to_string(), + summary: "preview.validate:ok".to_string(), + detail: Some( + serde_json::json!({ + "passed": true, + "revision": 27, + "reportPath": format!("{evidence_root}/validation.json"), + "screenshots": [ + format!("{evidence_root}/desktop.png"), + format!("{evidence_root}/mobile.png"), + ], + "diagnostics": [], + "playtest": { + "passed": true, + "scenario": "lane-defense-v1", + }, + }) + .to_string(), + ), + }; + + let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation) + .expect("validated preview public event detail"); + assert!(public.chars().count() < 500, "{public}"); + let public = serde_json::from_str::(&public).expect("parse public preview detail"); + assert_eq!(public["passed"], true); + assert_eq!(public["revision"], 27); + assert_eq!(public["diagnosticsCount"], 0); + assert_eq!(public["playtestPassed"], true); + assert_eq!(public["playtestScenario"], "lane-defense-v1"); + assert!(public.get("reportPath").is_none()); + assert!(public.get("screenshots").is_none()); + + let receipt = agent_runtime_action_receipt_safe_detail_for_owner_for_test( + &root, + agent_id, + run_id, + &observation, + ) + .expect("validated preview durable receipt detail"); + let receipt = serde_json::from_str::(&receipt).expect("parse receipt detail"); + assert_eq!( + receipt["reportPath"], + format!("{evidence_root}/validation.json") + ); + assert_eq!(receipt["screenshots"].as_array().map(Vec::len), Some(2)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missing() { let _config_guard = crate::tests::write_test_local_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 7eb7bf8ae..4d31702f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -32,6 +32,7 @@ pub(super) use super::super::{ pub(super) use crate::{ advance_game_creator_agent_runtime_turn_at, + agent_runtime_action_receipt_public_safe_detail_for_test, agent_runtime_action_receipt_safe_detail_for_owner_for_test, agent_runtime_contains_secret_key_prefix, agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 97aca310a..8fb6c488e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -774,8 +774,7 @@ fn runtime_failure_public_audits_hash_private_delivery_diagnostics() { let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes())); let error_chars = private_error.chars().count(); - let expected_public_detail = - format!("errorSha256={error_sha256} · errorChars={error_chars}"); + let expected_public_detail = "专业 Agent 执行失败,请稍后重试"; let result = read_game_creator_agent_runtime_at(&root, agent_id) .expect("read failed runtime projection"); let public_failure_events = result @@ -797,7 +796,7 @@ fn runtime_failure_public_audits_hash_private_delivery_diagnostics() { .iter() .any(|event| event.event_type == terminal_event_type)); assert!(public_failure_events.iter().all(|event| { - event.detail.as_deref() == Some(expected_public_detail.as_str()) + event.detail.as_deref() == Some(expected_public_detail) && !event.summary.contains(private_error) })); let event_log = fs::read_to_string(game_creator_agent_runtime_event_path(&root, agent_id)) @@ -892,7 +891,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes())); let error_chars = private_error.chars().count(); - let expected_public_detail = format!("errorSha256={error_sha256} · errorChars={error_chars}"); + let expected_public_detail = "专业 Agent 服务请求失败,请稍后重试"; let result = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read public failure projections"); for event_type in ["error", "turn.failed"] { @@ -901,10 +900,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu .iter() .find(|event| event.run_id == run_id && event.event_type == event_type) .expect("public failure event"); - assert_eq!( - event.detail.as_deref(), - Some(expected_public_detail.as_str()) - ); + assert_eq!(event.detail.as_deref(), Some(expected_public_detail)); } let event_log = fs::read_to_string(game_creator_agent_runtime_event_path( &root, @@ -940,7 +936,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu ) .expect("read private failure conversation"); assert!(conversation.messages.iter().any(|message| { - message.role == "assistant" && message.content == format!("后台任务失败:{private_error}") + message.role == "assistant" && message.content == "专业 Agent 服务请求失败,请稍后重试" })); fs::remove_dir_all(root).ok(); @@ -1116,8 +1112,12 @@ fn structured_plan_state_write_failure_stops_before_context_and_audit() { message.role == "assistant" && message.content.contains("该回复不得落盘") })); assert!(conversation.messages.iter().any(|message| { - message.role == "assistant" && message.content.contains("后台任务失败") + message.role == "assistant" && message.content == "专业 Agent 执行失败,请稍后重试" })); + let conversation_text = + serde_json::to_string(&conversation).expect("serialize state write failure conversation"); + assert!(!conversation_text.contains("absolute-path")); + assert!(!conversation_text.contains("redacted sensitive context")); fs::remove_dir(&state_path).expect("remove sabotaged runtime state directory"); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index b0135a60f..b2bc08304 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -1,14 +1,349 @@ use super::*; +#[cfg(windows)] +fn configure_windows_background_std_command_with_suspension( + command: &mut std::process::Command, + create_process_group: bool, + create_suspended: bool, +) { + use std::os::windows::process::CommandExt; + + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_SUSPENDED: u32 = 0x0000_0004; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags( + CREATE_NO_WINDOW + | if create_process_group { + CREATE_NEW_PROCESS_GROUP + } else { + 0 + } + | if create_suspended { + CREATE_SUSPENDED + } else { + 0 + }, + ); +} + +#[cfg(windows)] +pub(crate) fn configure_windows_background_std_command( + command: &mut std::process::Command, + create_process_group: bool, +) { + configure_windows_background_std_command_with_suspension(command, create_process_group, false); +} + +#[cfg(not(windows))] +pub(crate) fn configure_windows_background_std_command( + _command: &mut std::process::Command, + _create_process_group: bool, +) { +} + +pub(crate) fn configure_windows_background_tokio_command( + command: &mut tokio::process::Command, + create_process_group: bool, +) { + configure_windows_background_std_command(command.as_std_mut(), create_process_group); +} + +#[cfg(all(windows, feature = "game-chat-release"))] +pub(crate) fn configure_windows_suspended_background_std_command( + command: &mut std::process::Command, + create_process_group: bool, +) { + configure_windows_background_std_command_with_suspension(command, create_process_group, true); +} + +#[cfg(all(windows, feature = "game-chat-release"))] +pub(crate) struct WindowsKillOnCloseJob { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(all(windows, feature = "game-chat-release"))] +unsafe impl Send for WindowsKillOnCloseJob {} + +#[cfg(all(windows, feature = "game-chat-release"))] +impl WindowsKillOnCloseJob { + pub(crate) fn assign_runner(child: &std::process::Child) -> Result { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(format!( + "创建 game-chat Agent Runner Windows Job Object 失败:{}", + std::io::Error::last_os_error() + )); + } + + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &information as *const _ as *const _, + size_of::() as u32, + ) + }; + if configured == 0 { + let error = std::io::Error::last_os_error(); + unsafe { + CloseHandle(handle); + } + return Err(format!( + "配置 game-chat Agent Runner Windows Job Object 失败:{error}" + )); + } + + let process = child.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE; + if process.is_null() || unsafe { AssignProcessToJobObject(handle, process) } == 0 { + let error = std::io::Error::last_os_error(); + unsafe { + CloseHandle(handle); + } + return Err(format!( + "将 game-chat Agent Runner 加入 Windows Job Object 失败:{error}" + )); + } + + Ok(Self { handle }) + } + + pub(crate) fn resume_suspended_runner( + &self, + child: &std::process::Child, + ) -> Result<(), String> { + use std::mem::size_of; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows_sys::Win32::System::Threading::{ + GetProcessIdOfThread, OpenThread, ResumeThread, THREAD_QUERY_LIMITED_INFORMATION, + THREAD_SUSPEND_RESUME, + }; + + const ERROR_NO_MORE_FILES: i32 = 18; + const RESUME_THREAD_FAILED: u32 = u32::MAX; + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE { + return Err(format!( + "枚举 game-chat Agent Runner 挂起线程失败:{}", + std::io::Error::last_os_error() + )); + } + + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut runner_thread_id = None; + if unsafe { Thread32First(snapshot, &mut entry) } == 0 { + let error = std::io::Error::last_os_error(); + unsafe { + CloseHandle(snapshot); + } + return Err(format!("读取 game-chat Agent Runner 挂起线程失败:{error}")); + } + loop { + if entry.th32OwnerProcessID == child.id() { + if runner_thread_id.replace(entry.th32ThreadID).is_some() { + unsafe { + CloseHandle(snapshot); + } + return Err( + "恢复 game-chat Agent Runner 失败:挂起进程存在多个线程".to_string() + ); + } + } + if unsafe { Thread32Next(snapshot, &mut entry) } != 0 { + continue; + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_NO_MORE_FILES) { + unsafe { + CloseHandle(snapshot); + } + return Err(format!( + "继续读取 game-chat Agent Runner 挂起线程失败:{error}" + )); + } + break; + } + unsafe { + CloseHandle(snapshot); + } + + let thread_id = runner_thread_id + .ok_or_else(|| "恢复 game-chat Agent Runner 失败:找不到挂起线程".to_string())?; + let thread = unsafe { + OpenThread( + THREAD_SUSPEND_RESUME | THREAD_QUERY_LIMITED_INFORMATION, + 0, + thread_id, + ) + }; + if thread.is_null() || thread == INVALID_HANDLE_VALUE { + return Err(format!( + "打开 game-chat Agent Runner 挂起线程失败:{}", + std::io::Error::last_os_error() + )); + } + if unsafe { GetProcessIdOfThread(thread) } != child.id() { + unsafe { + CloseHandle(thread); + } + return Err("恢复 game-chat Agent Runner 失败:挂起线程所属进程已发生变化".to_string()); + } + let previous_suspend_count = unsafe { ResumeThread(thread) }; + let resume_error = if previous_suspend_count == RESUME_THREAD_FAILED { + Some(format!( + "恢复 game-chat Agent Runner 挂起线程失败:{}", + std::io::Error::last_os_error() + )) + } else if previous_suspend_count != 1 { + Some(format!( + "恢复 game-chat Agent Runner 挂起线程失败:异常挂起计数 {previous_suspend_count}" + )) + } else { + None + }; + unsafe { + CloseHandle(thread); + } + if let Some(error) = resume_error { + return Err(error); + } + Ok(()) + } +} + +#[cfg(all(windows, feature = "game-chat-release"))] +impl Drop for WindowsKillOnCloseJob { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(self.handle); + } + } +} + +#[cfg(all(test, windows, feature = "game-chat-release"))] +mod windows_kill_on_close_job_tests { + use super::*; + use std::process::{Command, Stdio}; + use std::thread; + use std::time::{Duration, Instant}; + + #[test] + fn game_chat_runner_starts_suspended_then_job_kills_it_when_handle_closes() { + let directory = tempfile::tempdir().expect("create Windows Job test directory"); + let marker = directory.path().join("runner-started.txt"); + let mut command = Command::new("cmd.exe"); + command + .args([ + "/D", + "/S", + "/C", + "echo started>runner-started.txt & ping.exe -n 30 127.0.0.1 >NUL", + ]) + .current_dir(directory.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_windows_suspended_background_std_command(&mut command, true); + let mut child = command.spawn().expect("spawn Windows Job test child"); + let job = match WindowsKillOnCloseJob::assign_runner(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("assign Windows Job test child: {error}"); + } + }; + thread::sleep(Duration::from_millis(150)); + assert!( + !marker.exists(), + "CREATE_SUSPENDED child must not execute before ResumeThread" + ); + if let Err(error) = job.resume_suspended_runner(&child) { + drop(job); + let _ = child.kill(); + let _ = child.wait(); + panic!("resume Windows Job test child: {error}"); + } + + let started_deadline = Instant::now() + Duration::from_secs(3); + while !marker.exists() { + assert!( + Instant::now() < started_deadline, + "resumed Windows Job test child must execute" + ); + thread::sleep(Duration::from_millis(25)); + } + + drop(job); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if child + .try_wait() + .expect("poll Windows Job test child") + .is_some() + { + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("closing the kill-on-close Job must terminate its assigned process"); + } + thread::sleep(Duration::from_millis(25)); + } + } +} + const GAME_CHAT_LAUNCH_USAGE: &str = "用法:--game-chat [--project-path <本地项目绝对路径>] [--initial-message <首条消息>]"; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct GameChatLaunchOptions { pub(crate) project_path: Option, pub(crate) initial_message: Option, } +pub(crate) fn select_game_chat_launch_options( + explicit: Option, + debug_build: bool, + game_chat_release: bool, +) -> Result, String> { + if debug_build { + return Ok(explicit); + } + if game_chat_release { + return Ok(Some(explicit.unwrap_or_default())); + } + if explicit.is_some() { + return Err("--game-chat 仅在开发构建或 game-chat release 中可用".to_string()); + } + Ok(None) +} + +pub(crate) fn should_shutdown_runner_on_tauri_event( + game_chat_release: bool, + event: &tauri::RunEvent, +) -> bool { + game_chat_release && matches!(event, tauri::RunEvent::Exit) +} + pub(crate) fn parse_game_chat_launch_args( args: &[String], ) -> Result, String> { @@ -78,7 +413,6 @@ pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUr ))) } -#[cfg(any(debug_assertions, test))] pub(crate) fn game_chat_window_url( project_path: Option<&str>, initial_message: Option<&str>, @@ -87,7 +421,6 @@ pub(crate) fn game_chat_window_url( tauri::WebviewUrl::App(PathBuf::from(format!("index.html?{query}"))) } -#[cfg(any(debug_assertions, test))] pub(crate) fn apply_game_chat_initial_window_url( config: &mut tauri::Config, options: &GameChatLaunchOptions, @@ -105,7 +438,6 @@ pub(crate) fn apply_game_chat_initial_window_url( Ok(()) } -#[cfg(any(debug_assertions, test))] fn game_chat_window_query(project_path: Option<&str>, initial_message: Option<&str>) -> String { let mut query = "game-chat".to_string(); if let Some(project_path) = project_path { @@ -152,6 +484,9 @@ pub(crate) fn open_game_creator_workspace_window( window: tauri::Window, project_path: String, ) -> Result<(), String> { + if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { + return Err("game-chat 独立版只能打开游戏创作对话页面".to_string()); + } let project_path = validate_workspace_window_project_path(&project_path)?; if let Some(existing) = app.get_webview_window("main") { existing.close().map_err(|error| error.to_string())?; @@ -171,6 +506,9 @@ pub(crate) fn open_game_creator_launcher_window( app: tauri::AppHandle, window: tauri::Window, ) -> Result<(), String> { + if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { + return Err("game-chat 独立版只能打开游戏创作对话页面".to_string()); + } if let Some(existing) = app.get_webview_window("launcher") { existing.set_focus().map_err(|error| error.to_string())?; } else { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json new file mode 100644 index 000000000..5d1096ce0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Genarrative Game Chat", + "version": "0.1.1", + "identifier": "world.genarrative.ai-game-creator.game-chat", + "build": { + "beforeBuildCommand": "node scripts/build-game-chat-release.mjs" + }, + "app": { + "windows": [ + { + "label": "client", + "title": "Genarrative Game Chat", + "url": "index.html", + "width": 1280, + "height": 800, + "minWidth": 1280, + "minHeight": 800 + } + ] + }, + "bundle": { + "targets": ["nsis"] + } +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cec14b71b..0295facff 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -63,6 +63,7 @@ import type { LocalConversationMessageRecord, LocalConversationResult, LocalGameMemoryResult, + LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, LocalProjectCheckpointResult, @@ -236,16 +237,139 @@ import { } from './view/project-development'; const initialSupervisorMessageClaimsByPage = new WeakMap>(); -const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = +const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = 'genarrative.game-chat.auto-preview-authorization.v1'; +const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = + 'genarrative.game-chat.auto-preview-authorization.v2'; type GameChatAutoPreviewAuthorization = { + afterRevision: number; + afterValidatedAt: number; + authorizationId: string; projectPath: string; runId: string; }; +type GameChatPlayableRevision = { + runId: string; + revision: number; + validatedAt: number; +}; + +type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { + eventOrder: number; + playable: boolean; +}; + +function gameChatPlayableRevisionIsAfterAuthorization( + revision: GameChatPlayableRevision, + authorization: GameChatAutoPreviewAuthorization, +) { + return ( + revision.revision > authorization.afterRevision || + (revision.revision === authorization.afterRevision && + revision.validatedAt > authorization.afterValidatedAt) + ); +} + +export function latestGameChatPlayableRevision( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +): GameChatPlayableRevision | null { + if (!runtime?.runId) { + return null; + } + const playtestChildren = new Map(); + for (const child of Object.values(runtimeByAgentId)) { + if ( + child?.agentId !== 'preview-playtest' || + child.taskId !== 'preview-playtest' || + child.source !== 'agent-ready-task-scheduler' || + child.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || + child.parentRunId !== runtime.runId + ) { + continue; + } + playtestChildren.set( + `${child.agentId}\n${child.sessionId}\n${child.runId}`, + child, + ); + } + let latest: GameChatPreviewValidationCandidate | null = null; + let eventOrder = 0; + for (const child of playtestChildren.values()) { + for (const event of child.recentEvents ?? []) { + eventOrder += 1; + if ( + event.agentId !== child.agentId || + event.taskId !== child.taskId || + event.sessionId !== child.sessionId || + event.runId !== child.runId || + event.eventType !== 'observation' || + !Number.isSafeInteger(event.updatedAt) || + event.updatedAt < 0 || + !event.summary.startsWith('preview.validate:') || + !event.detail?.trim().startsWith('{') + ) { + continue; + } + try { + const detail = JSON.parse(event.detail) as { + passed?: unknown; + playtestPassed?: unknown; + revision?: unknown; + }; + const playable = + event.summary.startsWith('preview.validate:ok') && + detail.passed === true && + detail.playtestPassed === true; + if ( + typeof detail.passed !== 'boolean' || + typeof detail.revision !== 'number' || + !Number.isSafeInteger(detail.revision) || + detail.revision <= 0 + ) { + continue; + } + const shouldReplace = + !latest || + detail.revision > latest.revision || + (detail.revision === latest.revision && + event.updatedAt > latest.validatedAt) || + (detail.revision === latest.revision && + event.updatedAt === latest.validatedAt && + ((latest.playable && !playable) || + (latest.playable === playable && + eventOrder > latest.eventOrder))); + if (!shouldReplace) { + continue; + } + latest = { + eventOrder, + playable, + runId: runtime.runId, + revision: detail.revision, + validatedAt: event.updatedAt, + }; + } catch { + // Ignore malformed or truncated public evidence and wait for a valid revision. + } + } + } + return latest?.playable + ? { + runId: latest.runId, + revision: latest.revision, + validatedAt: latest.validatedAt, + } + : null; +} + function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthorization | null { try { + window.localStorage.removeItem( + LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + ); const raw = window.localStorage.getItem( GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, ); @@ -255,11 +379,22 @@ function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthor const parsed = JSON.parse(raw) as Partial; const projectPath = parsed.projectPath?.trim() ?? ''; const runId = parsed.runId?.trim() ?? ''; + const authorizationId = parsed.authorizationId?.trim() ?? ''; + const afterRevision = parsed.afterRevision; + const afterValidatedAt = parsed.afterValidatedAt; if ( !projectPath || !runId || + !authorizationId || + typeof afterRevision !== 'number' || + !Number.isSafeInteger(afterRevision) || + afterRevision < 0 || + typeof afterValidatedAt !== 'number' || + !Number.isSafeInteger(afterValidatedAt) || + afterValidatedAt < 0 || !isAbsoluteProjectPath(projectPath) || projectPathHasControlCharacter(projectPath) || + projectPathHasControlCharacter(authorizationId) || projectPathHasControlCharacter(runId) ) { window.localStorage.removeItem( @@ -267,7 +402,13 @@ function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthor ); return null; } - return { projectPath, runId }; + return { + afterRevision, + afterValidatedAt, + authorizationId, + projectPath, + runId, + }; } catch { return null; } @@ -342,6 +483,23 @@ export function WorkspaceLauncher(props: WorkspaceLauncherProps) { return ; } +export function GameChatReleaseApp({ + initialProjectPath = '', + initialSupervisorMessage = '', +}: { + initialProjectPath?: string; + initialSupervisorMessage?: string; +}) { + return ( + + ); +} + type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; @@ -399,9 +557,12 @@ export function App({ const [preview, setPreview] = useState(null); const gameChatPreviewRef = useRef(null); gameChatPreviewRef.current = preview; + const [gameChatPreviewRevision, setGameChatPreviewRevision] = useState< + number | null + >(null); + const gameChatPreviewRevisionRef = useRef(null); + gameChatPreviewRevisionRef.current = gameChatPreviewRevision; const [previewStatus, setPreviewStatus] = useState('未启动'); - const previewStatusRef = useRef(previewStatus); - previewStatusRef.current = previewStatus; const [gameChatProjectSelectionBusy, setGameChatProjectSelectionBusy] = useState(false); const gameChatProjectSelectionVersionRef = useRef(0); @@ -503,6 +664,8 @@ export function App({ const [agentRuntimeById, setAgentRuntimeById] = useState< Record >({}); + const agentRuntimeByIdRef = useRef(agentRuntimeById); + agentRuntimeByIdRef.current = agentRuntimeById; const [professionalAgentResultsById, setProfessionalAgentResultsById] = useState>({}); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); @@ -1163,6 +1326,55 @@ export function App({ } let disposed = false; let inFlight = false; + let attemptedRunId: string | null = null; + let attemptedAuthorizationId: string | null = null; + const authorizationMatches = ( + expected: GameChatAutoPreviewAuthorization, + ) => { + const current = gameChatAutoPreviewAuthorizationRef.current; + return ( + current?.authorizationId === expected.authorizationId && + current.projectPath === expected.projectPath && + current.runId === expected.runId + ); + }; + const attemptIsCurrent = ( + runId: string, + authorization: GameChatAutoPreviewAuthorization, + ) => + !disposed && + localProjectPathRef.current === nextProjectPath && + projectSupervisorRuntimeRef.current?.runId === runId && + authorizationMatches(authorization); + const clearMatchingAuthorization = ( + authorization: GameChatAutoPreviewAuthorization, + ) => { + if (authorizationMatches(authorization)) { + setGameChatAutoPreviewAuthorization(null); + } + }; + const readCurrentProjectRevision = async () => { + const result = await invoke( + 'get_local_game_project_revision', + { projectPath: nextProjectPath }, + ); + if (!Number.isSafeInteger(result.revision) || result.revision < 0) { + throw new Error('本地游戏项目 revision 无效'); + } + return result.revision; + }; + const stopStaleStartedPreview = async ( + startedPreview: LocalPreviewResult, + ) => { + try { + await invoke('stop_local_game_preview_if_matches', { + projectPath: nextProjectPath, + expectedPreview: startedPreview, + }); + } catch { + // A newer preview identity or a closed project already owns the visible state. + } + }; const syncPreview = async () => { if (disposed || inFlight) { return; @@ -1176,6 +1388,25 @@ export function App({ if (disposed || localProjectPathRef.current !== nextProjectPath) { return; } + const currentSupervisor = projectSupervisorRuntimeRef.current; + let playableRevision = latestGameChatPlayableRevision( + currentSupervisor, + agentRuntimeByIdRef.current, + ); + if (playableRevision) { + const currentRevision = await readCurrentProjectRevision(); + if ( + disposed || + localProjectPathRef.current !== nextProjectPath || + projectSupervisorRuntimeRef.current?.runId !== + playableRevision.runId + ) { + return; + } + if (currentRevision !== playableRevision.revision) { + playableRevision = null; + } + } const runningPreview = status.status === 'running' && status.url && @@ -1194,7 +1425,27 @@ export function App({ if (runningPreview) { updateClientPreview(runningPreview); setPreviewStatus(`运行中:127.0.0.1:${runningPreview.port}`); - setGameChatAutoPreviewAuthorization(null); + if ( + playableRevision && + playableRevision.revision > + (gameChatPreviewRevisionRef.current ?? 0) + ) { + gameChatPreviewRevisionRef.current = playableRevision.revision; + setGameChatPreviewRevision(playableRevision.revision); + } + const runningAuthorization = + gameChatAutoPreviewAuthorizationRef.current; + if ( + playableRevision && + runningAuthorization?.projectPath === nextProjectPath && + runningAuthorization.runId === playableRevision.runId && + gameChatPlayableRevisionIsAfterAuthorization( + playableRevision, + runningAuthorization, + ) + ) { + clearMatchingAuthorization(runningAuthorization); + } return; } const hadRunningPreview = Boolean(gameChatPreviewRef.current); @@ -1202,63 +1453,30 @@ export function App({ if (hadRunningPreview) { setPreviewStatus('未启动'); } - - const currentSupervisor = projectSupervisorRuntimeRef.current; - let authorization = gameChatAutoPreviewAuthorizationRef.current; + const authorization = gameChatAutoPreviewAuthorizationRef.current; if ( - !authorization && - currentSupervisor?.runId && - isAgentRuntimeTerminalState(currentSupervisor) && - previewStatusRef.current.startsWith( - '项目正在被其他写操作占用:', + !playableRevision || + authorization?.projectPath !== nextProjectPath || + authorization.runId !== playableRevision.runId || + !gameChatPlayableRevisionIsAfterAuthorization( + playableRevision, + authorization, ) - ) { - const interruptedAttemptKey = `${nextProjectPath}\n${currentSupervisor.runId}`; - if ( - gameChatObservedRunKeysRef.current.has(interruptedAttemptKey) && - gameChatAutoPreviewAttemptedRef.current.delete( - interruptedAttemptKey, - ) - ) { - authorization = { - projectPath: nextProjectPath, - runId: currentSupervisor.runId, - }; - setGameChatAutoPreviewAuthorization(authorization); - } - } - if ( - !authorization || - authorization.projectPath !== nextProjectPath || - currentSupervisor?.runId !== authorization.runId ) { return; } + const autoPreviewRunId = playableRevision.runId; + attemptedRunId = autoPreviewRunId; + attemptedAuthorizationId = authorization.authorizationId; const nextManifest = await invoke( 'get_local_game_manifest', { projectPath: nextProjectPath }, ); - if ( - disposed || - localProjectPathRef.current !== nextProjectPath || - projectSupervisorRuntimeRef.current?.runId !== authorization.runId - ) { + if (!attemptIsCurrent(autoPreviewRunId, authorization)) { return; } setManifest(nextManifest); - const firstPrototypeReady = nextManifest.tasks.some( - (task) => task.id === 'code-prototype' && task.status === 'completed', - ); - if (!firstPrototypeReady) { - if ( - currentSupervisor && - isAgentRuntimeTerminalState(currentSupervisor) - ) { - setGameChatAutoPreviewAuthorization(null); - } - return; - } - const attemptKey = `${nextProjectPath}\n${authorization.runId}`; + const attemptKey = `${nextProjectPath}\n${autoPreviewRunId}\nauthorization:${authorization.authorizationId}\nrevision:${playableRevision.revision}\nvalidatedAt:${playableRevision.validatedAt}`; if (gameChatAutoPreviewAttemptedRef.current.has(attemptKey)) { return; } @@ -1266,6 +1484,9 @@ export function App({ 'read_project_permission_policy', { projectPath: nextProjectPath }, ); + if (!attemptIsCurrent(autoPreviewRunId, authorization)) { + return; + } const previewDenied = policyView.policy.deniedCommands.includes('preview.start') || Object.values(policyView.policy.agentPolicies ?? {}).some((policy) => @@ -1273,7 +1494,7 @@ export function App({ ); if (previewDenied) { gameChatAutoPreviewAttemptedRef.current.add(attemptKey); - setGameChatAutoPreviewAuthorization(null); + clearMatchingAuthorization(authorization); const message = '项目权限策略拒绝执行:preview.start'; setCommandLog((current) => [ ...current, @@ -1282,6 +1503,13 @@ export function App({ setPreviewStatus(message); return; } + const revisionBeforeStart = await readCurrentProjectRevision(); + if ( + !attemptIsCurrent(autoPreviewRunId, authorization) || + revisionBeforeStart !== playableRevision.revision + ) { + return; + } appendLocalPermissionLog( nextProjectPath, 'permission.confirm', @@ -1291,26 +1519,60 @@ export function App({ try { startedPreview = await invoke( 'start_local_game_preview', - { projectPath: nextProjectPath }, + { + projectPath: nextProjectPath, + expectedRevision: playableRevision.revision, + }, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.startsWith('项目正在被其他写操作占用:')) { + const message = + error instanceof Error ? error.message : String(error); + const retryWithNewEvidence = + message.startsWith('项目正在被其他写操作占用:') || + message.startsWith('本地游戏项目已在验证后发生变化'); + if (!retryWithNewEvidence) { gameChatAutoPreviewAttemptedRef.current.add(attemptKey); - setGameChatAutoPreviewAuthorization(null); + clearMatchingAuthorization(authorization); } throw error; } - gameChatAutoPreviewAttemptedRef.current.add(attemptKey); - setGameChatAutoPreviewAuthorization(null); - if (disposed || localProjectPathRef.current !== nextProjectPath) { + let revisionAfterStart: number; + try { + revisionAfterStart = await readCurrentProjectRevision(); + } catch (error) { + await stopStaleStartedPreview(startedPreview); + throw error; + } + if ( + !attemptIsCurrent(autoPreviewRunId, authorization) || + revisionAfterStart !== playableRevision.revision + ) { + await stopStaleStartedPreview(startedPreview); return; } + gameChatAutoPreviewAttemptedRef.current.add(attemptKey); + clearMatchingAuthorization(authorization); updateClientPreview(startedPreview); + if ( + playableRevision && + playableRevision.revision > (gameChatPreviewRevisionRef.current ?? 0) + ) { + gameChatPreviewRevisionRef.current = playableRevision.revision; + setGameChatPreviewRevision(playableRevision.revision); + } setPreviewStatus(`运行中:127.0.0.1:${startedPreview.port}`); setCommandLog((current) => [...current, 'preview.start']); } catch (error) { - if (!disposed && localProjectPathRef.current === nextProjectPath) { + if ( + !disposed && + localProjectPathRef.current === nextProjectPath && + (!attemptedRunId || + projectSupervisorRuntimeRef.current?.runId === attemptedRunId) && + (!attemptedAuthorizationId || + !gameChatAutoPreviewAuthorizationRef.current || + gameChatAutoPreviewAuthorizationRef.current.authorizationId === + attemptedAuthorizationId) + ) { setPreviewStatus( error instanceof Error ? error.message : String(error), ); @@ -2402,6 +2664,8 @@ export function App({ setGameChatAutoPreviewAuthorization(null); } updateClientPreview(null); + gameChatPreviewRevisionRef.current = null; + setGameChatPreviewRevision(null); setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); setProjectStatus('正在初始化'); @@ -5046,16 +5310,62 @@ export function App({ if (!sessionId || localProjectPathRef.current !== nextProjectPath) { return; } + const submissionRunProfile = + supervisorChatOnly && !gameChatOnly + ? 'standard' + : 'autonomous-game-build'; + const runtimeAtSubmission = projectSupervisorRuntimeRef.current; + const steerRuntime = gameChatOnly + ? matchingAgentRuntimeForSteer( + [runtimeAtSubmission], + PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + submissionRunProfile, + ) + : null; + let autoPreviewAfterRevision = 0; + let autoPreviewAfterValidatedAt = 0; + if (steerRuntime) { + const playableAtSubmission = latestGameChatPlayableRevision( + runtimeAtSubmission, + agentRuntimeByIdRef.current, + ); + autoPreviewAfterRevision = Math.max( + gameChatPreviewRevisionRef.current ?? 0, + playableAtSubmission?.revision ?? 0, + ); + autoPreviewAfterValidatedAt = playableAtSubmission?.validatedAt ?? 0; + try { + const revisionStatus = await invoke( + 'get_local_game_project_revision', + { projectPath: nextProjectPath }, + ); + if ( + Number.isSafeInteger(revisionStatus.revision) && + revisionStatus.revision >= 0 + ) { + autoPreviewAfterRevision = Math.max( + autoPreviewAfterRevision, + revisionStatus.revision, + ); + } + } catch { + // The durable evidence cursor still prevents consuming an older validation. + } + if ( + localProjectPathRef.current !== nextProjectPath || + projectSupervisorSessionIdRef.current !== sessionId + ) { + return; + } + } const submission = await submitProjectSupervisorRuntimeTask({ invoke, projectPath: nextProjectPath, sessionId, prompt, - runtime: projectSupervisorRuntimeRef.current, - runProfile: - supervisorChatOnly && !gameChatOnly - ? 'standard' - : 'autonomous-game-build', + runtime: runtimeAtSubmission, + runProfile: submissionRunProfile, }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); @@ -5092,6 +5402,11 @@ export function App({ `${nextProjectPath}\n${acceptedRunId}`, ); setGameChatAutoPreviewAuthorization({ + afterRevision: + submission.mode === 'steer' ? autoPreviewAfterRevision : 0, + afterValidatedAt: + submission.mode === 'steer' ? autoPreviewAfterValidatedAt : 0, + authorizationId: createAgentChatRunId('game-chat-preview-auth'), projectPath: nextProjectPath, runId: acceptedRunId, }); @@ -10162,6 +10477,7 @@ export function App({ onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} preview={preview} + previewRevision={gameChatPreviewRevision} previewStatus={previewStatus} projectPath={gameChatProjectPath} projectReady={Boolean(localProject)} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 7acc280e5..4f6cfa6cc 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -110,6 +110,10 @@ export interface LocalPreviewStatus { root: string | null; } +export interface LocalGameProjectRevisionStatus { + revision: number; +} + export interface GenerateLocalGameDraftResult { projectPath: string; gameIndexPath: string; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index a2691184f..09b2219f3 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -836,6 +836,39 @@ export function agentRuntimeCancelStatus( : `已取消后台任务:${runId}`; } +function agentRuntimeProviderRetryStatus(runtime: AgentRuntimeState) { + if (runtime.phase !== 'waiting-for-provider-retry') { + return null; + } + const currentAction = runtime.currentAction?.trim(); + const waitingOn = runtime.waitingOn?.trim(); + const safeCurrentAction = + currentAction && + /^(?:Goal 已恢复,)?Provider (?:上游返回 HTTP \d{3}|瞬态故障),准备自动重试 \d+\/\d+$/.test( + currentAction, + ) + ? currentAction + : null; + const safeWaitingOn = + waitingOn && /^预计 \d+ 秒后重试$/.test(waitingOn) ? waitingOn : null; + if (safeCurrentAction && safeWaitingOn) { + return `${safeCurrentAction};${safeWaitingOn}`; + } + if (safeCurrentAction) { + return safeCurrentAction; + } + + const legacyAttempt = currentAction?.match( + /^(?:等待 Provider 瞬态重试|Goal 已恢复,继续等待 Provider 瞬态重试) (\d+)\/(\d+)$/, + ); + const retryProgress = + legacyAttempt?.[1] && legacyAttempt[2] + ? `,准备自动重试 ${legacyAttempt[1]}/${legacyAttempt[2]}` + : ',正在准备自动重试'; + const safeFallback = `Provider 上游服务暂时不可用${retryProgress}`; + return safeWaitingOn ? `${safeFallback};${safeWaitingOn}` : safeFallback; +} + export function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { if (isAgentRuntimeTerminalState(runtime)) { if (runtime.status === 'failed' || runtime.phase === 'failed') { @@ -869,6 +902,10 @@ export function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { ) { return '持久目标已暂停'; } + const providerRetryStatus = agentRuntimeProviderRetryStatus(runtime); + if (providerRetryStatus) { + return providerRetryStatus; + } const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase); return waitingOn ? `Agent 正在运行,等待${waitingOn}` : 'Agent 正在运行'; @@ -880,7 +917,9 @@ export function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) { } if (isAgentRuntimeTerminalState(runtime)) { if (runtime.status === 'failed' || runtime.phase === 'failed') { - return runtime.error || 'Agent 运行失败'; + return runtime.error + ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true) + : 'Agent 运行失败'; } if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') { return '本轮已取消'; @@ -891,9 +930,25 @@ export function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) { } export function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) { - const summary = event.summary || event.detail || event.runId; + const isFailureEvent = [ + 'error', + 'turn.failed', + 'turn.budget_exhausted', + ].includes(event.eventType); + const containsInternalFailureDiagnostics = Boolean( + event.detail && + /(?:errorSha256|errorChars|fingerprint|chars|retryAttempt|retryState)=|<(?:absolute-path|redacted-url)>|\[redacted(?:[- ]secret| sensitive context)\]/i.test( + event.detail, + ), + ); + const visibleDetail = + isFailureEvent && containsInternalFailureDiagnostics ? null : event.detail; + const summary = + event.summary || + visibleDetail || + (isFailureEvent ? 'Agent Runtime 本轮处理失败。' : event.runId); const detail = - event.detail && event.detail !== summary ? ` · ${event.detail}` : ''; + visibleDetail && visibleDetail !== summary ? ` · ${visibleDetail}` : ''; return `${event.eventType} · ${event.status} / ${event.phase} · ${summary}${detail}`; } @@ -1333,6 +1388,10 @@ export function projectRuntimePlanProgress(runtime: AgentRuntimeState) { } export function projectRuntimeVisibleCurrentWork(runtime: AgentRuntimeState) { + const providerRetryStatus = agentRuntimeProviderRetryStatus(runtime); + if (providerRetryStatus) { + return providerRetryStatus; + } const activePlanStep = agentRuntimeActivePlanStep(runtime); if (activePlanStep) { const stepText = agentRuntimePlanStepText(activePlanStep); @@ -1389,6 +1448,34 @@ export function projectRuntimeVisibleError( if (isRuntimeConfigMissingError(message)) { return '运行时配置未完成,请先打开配置'; } + const exhaustedUpstreamRetry = visibleMessage.match( + /(?:^|[\s::])kind=upstream-(\d{3}) httpStatus=(\d{3}) fingerprint=[0-9a-f]{64} chars=\d+ retryAttempt=(\d+) maxRetries=(\d+) retryState=exhausted\s*$/, + ); + if (exhaustedUpstreamRetry) { + const [, kindStatusText, httpStatusText, retryAttemptText, maxRetriesText] = + exhaustedUpstreamRetry; + if ( + !kindStatusText || + !httpStatusText || + !retryAttemptText || + !maxRetriesText + ) { + return `${subject} 执行失败,请稍后重试`; + } + const httpStatus = Number.parseInt(httpStatusText, 10); + const retryAttempt = Number.parseInt(retryAttemptText, 10); + const maxRetries = Number.parseInt(maxRetriesText, 10); + if ( + kindStatusText === httpStatusText && + httpStatus >= 500 && + httpStatus <= 599 && + retryAttempt === maxRetries && + maxRetries >= 0 && + maxRetries <= 4_294_967_295 + ) { + return `${subject} 上游服务返回 HTTP ${httpStatus};自动重试已耗尽(${retryAttempt}/${maxRetries})`; + } + } if ( normalized.includes('kind=transport') || normalized.includes('transport') || @@ -1443,6 +1530,21 @@ export function projectRuntimeVisibleError( return `${subject} 执行失败,请稍后重试`; } +export function projectSupervisorVisibleConversationText( + message: string, + role: ChatMessage['role'] = 'assistant', +) { + const failurePrefix = '后台任务失败:'; + if (role !== 'assistant' || !message.startsWith(failurePrefix)) { + return message; + } + return projectRuntimeVisibleError( + message.slice(failurePrefix.length), + '项目总控 Agent', + true, + ); +} + export function projectRuntimeVisibleToolSummary(summary: string) { return summary .split('·') diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index 492556ce3..e7db17e8b 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -634,8 +634,24 @@ export function AgentRuntimeStatusPanel({ ))} ) : null} - {runtime.error ?

{runtime.error}

: null} - {error ?

{error}

: null} + {runtime.error ? ( +

+ {projectRuntimeVisibleError( + runtime.error, + projectProfessionalAgentLabel(runtime.agentId), + true, + )} +

+ ) : null} + {error ? ( +

+ {projectRuntimeVisibleError( + error, + projectProfessionalAgentLabel(runtime.agentId), + true, + )} +

+ ) : null} )} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index 876c2b011..bb2dc5233 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -31,6 +31,7 @@ import { formatAgentRecentRuntimeTask, formatAgentRuntimeTaskQueue, ProjectSupervisorRuntimePanel, + projectSupervisorVisibleConversationText, } from '../agent-runtime'; import { formatAgentCardLlmStatus, @@ -806,7 +807,10 @@ export function ProjectWorkspaceChatPane({ {visibleMessages.map((message, index) => (

- {message.text} + {projectSupervisorVisibleConversationText( + message.text, + message.role, + )}

{message.draftCommand ? (