diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index dfd8dab81..ae1d6bfc4 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -4,6 +4,7 @@ "baseUrl": "https://api.openai.com/v1", "model": "gpt-4.1", "apiKind": "openai_responses", + "reasoningEffort": "high", "stream": false, "requestTimeoutMs": 180000, "maxRetries": 0, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index 9974d3275..c72fc9b24 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -18,6 +18,10 @@ const visibleText = 'GENARRATIVE_REAL_E2E_VISIBLE'; const patchedText = 'REAL_E2E_PATCHED'; const editorAssetPrompt = 'real e2e amber arcade token, transparent background'; const verificationCommand = 'node verify-e2e.mjs'; +const commandFailureMarker = 'real-e2e-command=failed'; +const commandPassedMarker = 'real-e2e-command=passed'; +const failedCommandArgs = ['test']; +const successfulCommandArgs = ['run', 'check:e2e']; const pollIntervalMs = 750; const runTimeoutMs = 30 * 60 * 1000; const commandOutputLimit = 4 * 1024 * 1024; @@ -401,7 +405,10 @@ async function seedDisposableProject() { { name: 'genarrative-agent-runtime-real-e2e-project', private: true, - scripts: { 'check:e2e': verificationCommand }, + scripts: { + test: verificationCommand, + 'check:e2e': verificationCommand, + }, }, null, 2, @@ -409,7 +416,7 @@ async function seedDisposableProject() { ), fs.writeFile( path.join(state.projectRoot, 'verify-e2e.mjs'), - `import fs from 'node:fs';\nconst html = fs.readFileSync('game/index.html', 'utf8');\nconst agents = fs.readFileSync('AGENTS.md', 'utf8');\nif (!html.includes('${patchedText}') || !html.includes(' auditPathEquals(execution.inputSummary, 'game/index.html'), ); assert(Boolean(mutationExecution), 'file-mutation-evidence-missing'); + const failedCommandArgsSha256 = createHash('sha256') + .update(JSON.stringify(failedCommandArgs)) + .digest('hex'); + const successfulCommandArgsSha256 = createHash('sha256') + .update(JSON.stringify(successfulCommandArgs)) + .digest('hex'); + const commandRecords = agentDb + .map((record, index) => ({ record, index })) + .filter( + ({ record }) => + record.recordType === 'agent.runtime.command.exec' && + record.agentId === mainAgentId && + record.runId === state.initialRunId, + ); + assert(commandRecords.length === 2, 'command-exec-record-count-invalid'); + const failedCommandRecord = commandRecords.find( + ({ record }) => record.status === 'failed', + ); + const successfulCommandRecord = commandRecords.find( + ({ record }) => record.status === 'completed', + ); + assert( + failedCommandRecord?.record.program === 'npm' && + failedCommandRecord.record.argsCount === failedCommandArgs.length && + failedCommandRecord.record.argsSha256 === failedCommandArgsSha256 && + failedCommandRecord.record.cwd === '.' && + Number.isInteger(failedCommandRecord.record.exitCode) && + failedCommandRecord.record.exitCode !== 0 && + failedCommandRecord.record.timedOut === false && + failedCommandRecord.record.sourceChanged === false && + failedCommandRecord.record.output?.includes(commandFailureMarker), + 'command-exec-failure-record-invalid', + ); + assert( + successfulCommandRecord?.record.program === 'npm' && + successfulCommandRecord.record.argsCount === + successfulCommandArgs.length && + successfulCommandRecord.record.argsSha256 === + successfulCommandArgsSha256 && + successfulCommandRecord.record.cwd === '.' && + successfulCommandRecord.record.exitCode === 0 && + successfulCommandRecord.record.timedOut === false && + successfulCommandRecord.record.sourceChanged === false && + successfulCommandRecord.record.output?.includes(commandPassedMarker), + 'command-exec-success-record-invalid', + ); + assert( + commandRecords.every( + ({ record }) => + !Object.hasOwn(record, 'args') && + !Object.hasOwn(record, 'arguments') && + /^[0-9a-f]{64}$/u.test(record.argsSha256) && + isNonEmptyString(record.actionId), + ), + 'command-exec-raw-argv-audit-leak', + ); + const failedCommandObservationIndex = agentDb.findIndex( + (record) => + record.recordType === 'agent.runtime.tool_observation' && + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.actionId === failedCommandRecord.record.actionId && + record.tool === 'command.exec' && + record.status === 'command-failed' && + record.decision === 'approved', + ); + assert( + failedCommandObservationIndex > failedCommandRecord.index, + 'command-exec-failure-observation-missing', + ); + const successfulCommandExecution = requireSuccessfulToolExecution( + agentDb, + 'command.exec', + state.initialRunId, + (execution) => + auditInputValue(execution.inputSummary, 'program') === 'npm' && + auditInputValue(execution.inputSummary, 'argsCount') === + String(successfulCommandArgs.length) && + auditInputValue(execution.inputSummary, 'argsSha256') === + successfulCommandArgsSha256 && + auditInputValue(execution.inputSummary, 'cwd') === '.' && + auditInputValue(execution.inputSummary, 'timeoutSeconds') === '120', + 'command-exec-success-action-invalid', + ); const verificationExecution = requireSuccessfulToolExecution( agentDb, 'project.verify', @@ -912,10 +1004,23 @@ async function validateLandedEvidence() { ), 'project-index-not-before-repository-reads', ); + assert( + failedCommandObservationIndex < checkpointExecution.startIndex, + 'checkpoint-not-after-failed-command-feedback', + ); assert( checkpointExecution.completionIndex < mutationExecution.startIndex, 'checkpoint-not-before-file-mutation', ); + assert( + mutationExecution.completionIndex < successfulCommandExecution.startIndex, + 'successful-command-not-after-file-mutation', + ); + assert( + successfulCommandExecution.completionIndex < + verificationExecution.startIndex, + 'project-verification-not-after-successful-command', + ); assert( mutationExecution.completionIndex < verificationExecution.startIndex, 'verification-not-after-file-mutation', @@ -1471,6 +1576,7 @@ async function validateLandedEvidence() { ...repositoryReadExecutions, checkpointExecution, mutationExecution, + successfulCommandExecution, verificationExecution, previewExecution, spawnExecution, @@ -1493,6 +1599,7 @@ async function validateLandedEvidence() { repositoryContextSourceCount: contextBundle.repositoryContextSourcePaths.length, checkpointFileCount: checkpointRecord.fileCount, + commandExecRunCount: commandRecords.length, editorApiAssetCount: editorAssetRecord ? 1 : 0, verificationPassed: true, browserValidationCount: browserReports.length, @@ -1639,6 +1746,7 @@ function emptyEvidence() { projectIndexExecutionCount: 0, repositoryContextSourceCount: 0, checkpointFileCount: 0, + commandExecRunCount: 0, editorApiAssetCount: 0, verificationPassed: false, browserValidationCount: 0, @@ -1828,7 +1936,8 @@ function validateConfirmedActionLifecycles(records) { const observed = lifecycle.filter( ({ record }) => record.recordType === 'agent.runtime.tool_observation' && - record.status === 'ok', + record.decision === 'approved' && + record.status !== 'waiting-for-confirmation', ); const required = lifecycle.filter( ({ record }) => @@ -1859,7 +1968,6 @@ function validateConfirmedActionLifecycles(records) { ) && waiting[0].record.tool === tool && observed[0].record.tool === tool && - observed[0].record.decision === 'approved' && approved[0].record.tool === tool && waiting[0].record.actionFingerprint === actionFingerprint && approved[0].record.actionFingerprint === actionFingerprint && diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 7f7e1d770..06aefc2a9 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -449,6 +449,32 @@ if (defaultAppConfig.llm?.apiKey !== '') { throw new Error('AI game creator shell default llm.apiKey must stay empty'); } +const allowedLlmReasoningEfforts = new Set([ + 'default', + 'low', + 'medium', + 'high', +]); + +if (defaultAppConfig.llm?.reasoningEffort !== 'high') { + throw new Error( + 'AI game creator shell default llm.reasoningEffort must stay high', + ); +} + +for (const [agentId, agentConfig] of Object.entries( + defaultAppConfig.agentLlm ?? {}, +)) { + if ( + agentConfig?.reasoningEffort !== undefined && + !allowedLlmReasoningEfforts.has(agentConfig.reasoningEffort) + ) { + throw new Error( + `AI game creator shell agentLlm.${agentId}.reasoningEffort is invalid`, + ); + } +} + if (defaultAppConfig.editorApi?.apiKey !== '') { throw new Error( 'AI game creator shell default editorApi.apiKey must stay empty', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 4d6e8fe79..71983f045 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -182,12 +182,15 @@ pub(crate) async fn chat_with_game_creator_agent_at( } else { format!("项目上下文如下。请只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}") }; - let request = LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_chat_agent_system_prompt()), - LlmMessage::user(user_prompt), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_chat_agent_system_prompt()), + LlmMessage::user(user_prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS), + &llm, + )?; let response = request_game_creator_llm_text(&client, &llm, request) .await .map_err(|error| format!("主聊天 Agent 调用 LLM 失败:{error}"))?; @@ -1881,6 +1884,7 @@ fn agent_runtime_tool_requires_repository_context_fingerprint_gate(tool: &str) - | "file.delete" | "task.create" | "task.update" + | "command.exec" | "command.run_limited" | "preview.start" | "preview.validate" @@ -3029,7 +3033,8 @@ async fn run_game_creator_agent_background_task_pass_with_context( let blocker_summary = blocker.summary(); runtime.current_action = "拒绝在验证未闭环时完成任务".to_string(); runtime.waiting_on = - "最后一次项目修改后的 project.verify 或 game.static_smoke".to_string(); + "最后一次项目修改后的 project.verify、可验证 command.exec 或 game.static_smoke" + .to_string(); runtime.next_step = "根据验证诊断继续修复,并在最后一次修改后重新验证".to_string(); runtime.observations.push(blocker_summary.clone()); runtime.updated_at = unix_timestamp(); @@ -4099,7 +4104,7 @@ fn agent_runtime_context_observation_fingerprint_detail( if observation.status == "ok" && matches!( observation.tool.as_str(), - "project.verify" | "command.run_limited" + "project.verify" | "command.run_limited" | "command.exec" ) { return None; @@ -4629,16 +4634,17 @@ fn validate_agent_runtime_verification_gate( if gate.last_mutation_tool.as_deref().is_some_and(|tool| { !matches!( tool, - "file.write" | "file.patch" | "file.delete" | "project.restore" + "file.write" | "file.patch" | "file.delete" | "project.restore" | "command.exec" ) }) { return Err("Agent Runtime verification gate 的修改工具无效".to_string()); } - if gate - .last_verification_tool - .as_deref() - .is_some_and(|tool| !matches!(tool, "project.verify" | "game.static_smoke")) - { + if gate.last_verification_tool.as_deref().is_some_and(|tool| { + !matches!( + tool, + "project.verify" | "game.static_smoke" | "command.exec" + ) + }) { return Err("Agent Runtime verification gate 的验证工具无效".to_string()); } if gate @@ -5886,19 +5892,48 @@ fn agent_runtime_revision_advance_failure_observation( } } -fn is_agent_runtime_project_mutation_observation( +pub(crate) fn is_agent_runtime_project_mutation_observation( observation: &AgentRuntimeToolObservation, ) -> bool { - matches!(observation.status.as_str(), "ok" | "verification-failed") + if observation.tool == "command.exec" { + return agent_runtime_command_exec_advanced_project_revision(observation); + } + observation.status == "ok" && matches!( observation.tool.as_str(), "file.write" | "file.patch" | "file.delete" | "project.restore" ) } -fn agent_runtime_observation_advances_project_revision( +fn agent_runtime_command_exec_advanced_project_revision( observation: &AgentRuntimeToolObservation, ) -> bool { + observation.tool == "command.exec" + && (matches!( + observation.status.as_str(), + "ok" | "command-failed" | AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ) || (observation.status == "verification-failed" + && observation + .detail + .as_deref() + .is_some_and(|detail| detail.starts_with("revisionAdvanced=true")))) +} + +fn agent_runtime_command_exec_is_verification_eligible( + observation: &AgentRuntimeToolObservation, +) -> bool { + observation.detail.as_deref().is_some_and(|detail| { + detail.starts_with("verificationEligible=true ·") + || detail.starts_with("revisionAdvanced=true · verificationEligible=true ·") + }) +} + +pub(crate) fn agent_runtime_observation_advances_project_revision( + observation: &AgentRuntimeToolObservation, +) -> bool { + if agent_runtime_command_exec_advanced_project_revision(observation) { + return true; + } if observation.status != "ok" { return false; } @@ -5929,7 +5964,10 @@ fn is_agent_runtime_static_smoke_observation(observation: &AgentRuntimeToolObser fn is_agent_runtime_project_verification_observation( observation: &AgentRuntimeToolObservation, ) -> bool { - observation.tool == "project.verify" || is_agent_runtime_static_smoke_observation(observation) + observation.tool == "project.verify" + || (observation.tool == "command.exec" + && agent_runtime_command_exec_is_verification_eligible(observation)) + || is_agent_runtime_static_smoke_observation(observation) } fn agent_runtime_project_verification_label( @@ -5937,6 +5975,8 @@ fn agent_runtime_project_verification_label( ) -> &'static str { if is_agent_runtime_static_smoke_observation(observation) { "game.static_smoke" + } else if observation.tool == "command.exec" { + "command.exec" } else { "project.verify" } @@ -5975,7 +6015,7 @@ pub(crate) fn project_verification_completion_blocker( status: "blocked".to_string(), summary: "项目在最近一次修改后尚未验证,不能把任务标记为完成".to_string(), detail: Some(format!( - "最后一次成功修改是 {};请执行并通过 project.verify 或 game.static_smoke。", + "最后一次成功修改是 {};请执行并通过 project.verify、可验证 command.exec 或 game.static_smoke。", mutation.tool )), }); @@ -5988,7 +6028,7 @@ pub(crate) fn project_verification_completion_blocker( status: "blocked".to_string(), summary: "项目在最近一次修改后尚未验证,不能把任务标记为完成".to_string(), detail: Some(format!( - "最后一次成功修改是 {},它发生在旧验证之后;请重新执行并通过 project.verify 或 game.static_smoke。", + "最后一次成功修改是 {},它发生在旧验证之后;请重新执行并通过 project.verify、可验证 command.exec 或 game.static_smoke。", mutation.tool )), }); @@ -6041,13 +6081,13 @@ fn evaluate_project_verification_completion_at_locked( if status == AGENT_RUNTIME_VERIFICATION_STATUS_RUNNING { return Ok(Some(agent_runtime_verification_blocker( "项目验证尚未形成可用结果,不能把任务标记为完成", - "请等待当前验证结束,或重新执行并通过 project.verify / game.static_smoke。", + "请等待当前验证结束,或重新执行并通过 project.verify / 可验证 command.exec / game.static_smoke。", ))); } if status == AGENT_RUNTIME_VERIFICATION_STATUS_FAILED { return Ok(Some(agent_runtime_verification_blocker( "最近一次项目验证未通过,不能把任务标记为完成", - "请根据验证诊断继续修复,并重新执行 project.verify / game.static_smoke。", + "请根据验证诊断继续修复,并重新执行 project.verify / 可验证 command.exec / game.static_smoke。", ))); } } @@ -6064,7 +6104,7 @@ fn evaluate_project_verification_completion_at_locked( return Ok(Some(agent_runtime_verification_blocker( "项目在当前 revision 上尚未通过验证,不能把任务标记为完成", format!( - "currentRevision={}, mutationRevision={}, verifiedRevision={};请重新执行并通过 project.verify 或 game.static_smoke。", + "currentRevision={}, mutationRevision={}, verifiedRevision={};请重新执行并通过 project.verify、可验证 command.exec 或 game.static_smoke。", revision.revision, gate.mutation_revision .map(|value| value.to_string()) @@ -6326,6 +6366,26 @@ pub(crate) fn agent_runtime_tool_action_input_summary( text(&["taskId", "task_id", "id"]), text(&["status"]) ), + "command.exec" => { + let arguments = input + .get("args") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let argument_bytes = serde_json::to_vec(&arguments).unwrap_or_default(); + format!( + "program={} · argsCount={} · argsSha256={:x} · cwd={} · timeoutSeconds={}", + text(&["program"]), + arguments.len(), + Sha256::digest(&argument_bytes), + relative_path(&["cwd"]), + input + .get("timeoutSeconds") + .or_else(|| input.get("timeout_seconds")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(120) + ) + } "command.run_limited" => format!( "commandId={}", text(&["commandId", "command_id", "id"]) @@ -6746,9 +6806,13 @@ fn build_game_creator_agent_background_tool_plan_request( .replace( "agent.delegate|agent.schedule_ready", "agent.delegate|agent.spawn_isolated|agent.schedule_ready", + ) + .replace( + "task.update|command.run_limited", + "task.update|command.exec|command.run_limited", ); let prompt = format!( - "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。每次成功执行 file.write、file.patch、file.delete 或 project.restore 都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。" + "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。command.exec 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120}},不接受 shell 字符串、管道、重定向、环境变量或项目外路径;该工具默认需要精确确认,适合运行定向测试、构建检查和只读诊断。只有 cargo check/test/clippy/fmt/build、npm test 或命名为 check/typecheck/test/lint/build/verify/validate 的验证脚本,以及精确 node --test 测试文件可签发验证凭证;git、rg、cargo metadata 和普通 npm run 只作为诊断结果。每次成功执行 file.write、file.patch、file.delete 或 project.restore,以及每次真正启动 command.exec,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。" ); let prompt = format!( "{prompt}\n\n新增工具输入:preview.validate 使用 {{\"viewports\":[\"desktop\",\"mobile\"],\"expectedText\":[\"可选可见文本\"],\"settleMs\":800,\"failOnConsoleError\":true}},不得提供 URL、脚本、Cookie 或请求头;agent.spawn_isolated 使用 {{\"children\":[{{\"templateAgentId\":\"规范 taskId\",\"task\":\"边界清晰的子任务\",\"acceptanceCriteria\":[\"可验证条件\"],\"expectedArtifacts\":[\"项目内路径\"],\"writeScopes\":[\"互不重叠的目录/**\"]}}],\"joinMode\":\"all\"}},一次最多 3 个子实例;spawn 后用 agent.run_status 的 scope=all 检查进度,当 observation 出现 readyIsolatedJoins 时表示 all-join 已完成,必须直接使用其中结果继续父 run,不得继续等待。" @@ -6763,13 +6827,13 @@ fn build_game_creator_agent_background_tool_plan_request( ]) .with_api_kind(api_kind) .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) - .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Low) .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low); if api_kind != platform_llm::LlmApiKind::Anthropic { request = request .with_function_tools(vec![game_creator_agent_tool_plan_function_tool()]) .with_tool_choice(platform_llm::LlmToolChoice::Required); } + request = apply_game_creator_llm_reasoning_effort(request, &llm)?; Ok((llm, config_path, request)) } @@ -6842,14 +6906,16 @@ fn build_game_creator_agent_background_final_reply_request( let prompt = format!( "运行上下文如下。请只依据后台任务、计划和已获准工具返回的 observation,给开发者一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{task}\n\n计划:\n{plan_json}\n\n工具观察:\n{observations_json}" ); - let request = LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_role_agent_chat_system_prompt()), - LlmMessage::user(prompt), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(AGENT_RUNTIME_FINAL_REPLY_MAX_OUTPUT_TOKENS) - .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Low) - .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low); + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_chat_system_prompt()), + LlmMessage::user(prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(AGENT_RUNTIME_FINAL_REPLY_MAX_OUTPUT_TOKENS) + .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low), + &llm, + )?; Ok((llm, config_path, request)) } @@ -7069,6 +7135,18 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ "task.list" => observe_agent_runtime_task_list(root), "task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input), "task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input), + "command.exec" => { + observe_agent_runtime_command_exec( + root, + agent_id, + run_id, + action_id, + &action_fingerprint, + pending_action, + &action.input, + ) + .await + } "command.run_limited" => { observe_agent_runtime_limited_command(root, agent_id, run_id, &action.input) } @@ -7125,6 +7203,7 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str "task.list" => Some("task.list"), "task.create" => Some("task.create"), "task.update" => Some("task.update"), + "command.exec" => Some("command.exec"), "command.run_limited" => Some("command.run_limited"), "preview.start" => Some("preview.start"), "preview.validate" => Some("preview.validate"), @@ -7651,6 +7730,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "task.list", "task.create", "task.update", + "command.exec", "command.run_limited", "preview.start", "preview.validate", @@ -7878,6 +7958,30 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_block_after_lock( } } +fn validate_agent_runtime_pending_action_after_lock( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: Option<&str>, + action_fingerprint: &str, + pending_action: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + validate_agent_runtime_pending_tool_action_record(root, pending_action)?; + if pending_action.agent_id != agent_id || pending_action.run_id != run_id { + return Err("Agent Runtime pending action 身份不匹配".to_string()); + } + if !pending_action.approved() { + return Err("Agent Runtime pending action 尚未获准执行".to_string()); + } + if pending_action.action.tool != "command.exec" + || pending_action.action_fingerprint != action_fingerprint + || action_id != Some(pending_action.action_id.as_str()) + { + return Err("Agent Runtime command.exec 的 actionId 或动作指纹已变化".to_string()); + } + Ok(()) +} + fn observe_agent_runtime_memory( root: &Path, agent_id: &str, @@ -9288,6 +9392,314 @@ fn agent_runtime_task_group_from_label(value: &str) -> Option, + #[serde(default = "default_agent_runtime_command_exec_cwd")] + cwd: String, + #[serde( + default = "default_agent_runtime_command_exec_timeout_seconds", + alias = "timeout_seconds" + )] + timeout_seconds: u64, +} + +fn default_agent_runtime_command_exec_cwd() -> String { + ".".to_string() +} + +fn default_agent_runtime_command_exec_timeout_seconds() -> u64 { + 120 +} + +async fn observe_agent_runtime_command_exec( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: Option<&str>, + action_fingerprint: &str, + pending_action: Option<&AgentRuntimePendingToolAction>, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let input = match serde_json::from_value::(input.clone()) { + Ok(input) => input, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "failed".to_string(), + summary: sanitize_agent_runtime_text( + &format!("command.exec 输入无效:{error}"), + 240, + ), + detail: None, + }; + } + }; + let command_spec = match resolve_project_command_spec_at( + root, + &input.program, + &input.args, + &input.cwd, + input.timeout_seconds, + ) { + Ok(spec) => spec, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let verification_eligible = command_spec.verification_eligible; + + let _lock = match acquire_project_write_lock(root, "command.exec") { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "failed".to_string(), + summary: "command.exec 无法取得项目执行锁".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 240)), + }; + } + }; + if let Some(blocked) = game_creator_agent_runtime_tool_policy_block_after_lock( + root, + agent_id, + "command.exec", + pending_action, + ) { + return agent_runtime_tool_policy_block_observation("command.exec", blocked); + } + if let Some(pending_action) = pending_action { + if let Err(error) = validate_agent_runtime_pending_action_after_lock( + root, + agent_id, + run_id, + action_id, + action_fingerprint, + pending_action, + ) { + return agent_runtime_mutation_gate_failure_observation(root, "command.exec", &error); + } + if let Err(error) = + validate_agent_runtime_pending_verification_gate_before(root, pending_action) + { + return agent_runtime_mutation_gate_failure_observation(root, "command.exec", &error); + } + } + if let Err(error) = + prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.exec") + { + return agent_runtime_mutation_gate_failure_observation(root, "command.exec", &error); + } + let (revision, gate) = match begin_agent_runtime_project_verification_locked( + root, + agent_id, + run_id, + "command.exec", + ) { + Ok(state) => state, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "verification-failed".to_string(), + summary: "command.exec 无法清除旧验证凭证,命令未执行".to_string(), + detail: Some(format!( + "revisionAdvanced=true · {}", + redact_agent_runtime_project_paths(root, &error, 500) + )), + }; + } + }; + + let result = run_project_command_at( + root, + &input.program, + &input.args, + &input.cwd, + input.timeout_seconds, + ) + .await; + let args_json = serde_json::to_vec(&input.args).unwrap_or_default(); + let args_sha256 = format!("{:x}", Sha256::digest(&args_json)); + let audit_result = match &result { + Ok(command) => append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.command.exec", + "agentId": agent_id, + "runId": run_id, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + "commandId": command.command_id, + "program": command.program, + "argsSha256": args_sha256, + "argsCount": input.args.len(), + "cwd": command.cwd_relative, + "status": command.status, + "exitCode": command.exit_code, + "timedOut": command.timed_out, + "durationMs": command.duration_ms, + "sourceChanged": command.source_changed, + "verificationEligible": command.verification_eligible, + "logPath": command.log_path, + "output": truncate_agent_runtime_text_preserving_tail( + &sanitize_prompt_context(&command.output), + 4_000, + ), + }), + ), + Err(error) => append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.command.exec", + "agentId": agent_id, + "runId": run_id, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + "program": input.program, + "argsSha256": args_sha256, + "argsCount": input.args.len(), + "cwd": input.cwd, + "verificationEligible": verification_eligible, + "status": if error.execution_started() { + "execution-unknown" + } else { + "failed-before-execution" + }, + "errorStage": error.stage().as_str(), + "error": redact_agent_runtime_project_paths(root, error.message(), 1_000), + }), + ), + }; + let passed = result.as_ref().is_ok_and(|command| { + command.verification_eligible + && command.status == "completed" + && command.exit_code == Some(0) + && !command.timed_out + && !command.source_changed + }) && audit_result.is_ok(); + let execution_started = result + .as_ref() + .map(|_| true) + .unwrap_or_else(|error| error.execution_started()); + let gate_result = + finish_agent_runtime_project_verification_locked(root, &revision, gate, passed); + + if let Err(error) = audit_result { + let gate_error = gate_result.err().map(|gate_error| { + format!( + " · verificationGateError={}", + redact_agent_runtime_project_paths(root, &gate_error, 300) + ) + }); + return AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: if execution_started { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "failed" + } + .to_string(), + summary: if execution_started { + "command.exec 已返回,但执行审计无法完整落盘".to_string() + } else { + "command.exec 未启动,失败诊断也无法写入 Agent DB".to_string() + }, + detail: Some(format!( + "revisionAdvanced=true · verificationEligible={verification_eligible} · {}{}", + redact_agent_runtime_project_paths(root, &error, 500), + gate_error.unwrap_or_default(), + )), + }; + } + if let Err(error) = gate_result { + return AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "command.exec 已返回,但验证凭证无法完整落盘".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + + match result { + Ok(command) => { + let detail = redact_agent_runtime_project_paths_preserving_tail( + root, + &command.output, + AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, + ); + let detail = format!( + "verificationEligible={} · {detail}", + command.verification_eligible + ); + if command.source_changed { + AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "verification-failed".to_string(), + summary: format!( + "{} 执行期间修改了受保护源码,验证结果无效", + command.command_id + ), + detail: Some(format!("revisionAdvanced=true · {detail}")), + } + } else if command.status == "completed" { + AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "ok".to_string(), + summary: if command.verification_eligible { + format!("{} 已通过", command.command_id) + } else { + format!("{} 已完成,只作为诊断结果", command.command_id) + }, + detail: Some(detail), + } + } else { + let reason = if command.timed_out { + format!("{} 执行超时", command.command_id) + } else if let Some(exit_code) = command.exit_code { + format!("{} 执行失败,退出码 {exit_code}", command.command_id) + } else { + format!("{} 启动失败", command.command_id) + }; + AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "command-failed".to_string(), + summary: reason, + detail: Some(detail), + } + } + } + Err(error) => { + let needs_reconciliation = error.needs_reconciliation(); + AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: if needs_reconciliation { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "verification-failed" + } + .to_string(), + summary: if needs_reconciliation { + "command.exec 执行结果不完整,需要人工核对".to_string() + } else { + "command.exec 未启动".to_string() + }, + detail: Some(format!( + "revisionAdvanced=true · verificationEligible={verification_eligible} · {}", + redact_agent_runtime_project_paths(root, error.message(), 500) + )), + } + } + } +} + pub(crate) fn observe_agent_runtime_limited_command( root: &Path, agent_id: &str, @@ -14602,12 +15014,15 @@ pub(crate) fn build_game_creator_role_agent_chat_request_for_session( } else { format!("项目上下文如下。请只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}") }; - let request = LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_role_agent_chat_system_prompt()), - LlmMessage::user(user_prompt), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_chat_system_prompt()), + LlmMessage::user(user_prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS), + &llm, + )?; Ok((llm, config_path, request)) } @@ -14698,6 +15113,14 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { "agent.delegate、agent.schedule_ready", "agent.delegate、agent.spawn_isolated、agent.schedule_ready", ) + .replace( + "task.update、command.run_limited", + "task.update、command.exec、command.run_limited", + ) + .replace( + "每次成功执行 file.write、file.patch、file.delete 或 project.restore 都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify,或成功执行 command.run_limited 的 game.static_smoke", + "每次成功执行 file.write、file.patch、file.delete 或 project.restore,以及每次真正启动 command.exec,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke", + ) } pub(crate) fn game_creator_agent_role_definition( @@ -15267,17 +15690,20 @@ pub(crate) async fn request_planner_spec_with_client( long_memory: &str, project_blackboard: &str, ) -> Result { - let request = LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_planner_system_prompt()), - LlmMessage::user(game_creator_planner_user_prompt( - prompt, - short_memory, - long_memory, - project_blackboard, - )), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS); + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_planner_system_prompt()), + LlmMessage::user(game_creator_planner_user_prompt( + prompt, + short_memory, + long_memory, + project_blackboard, + )), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS), + llm, + )?; let response = request_game_creator_llm_text(client, llm, request) .await .map_err(|error| format!("Planner 生成失败:{error}"))?; @@ -15319,12 +15745,15 @@ pub(crate) async fn request_generator_game_draft_with_client( const MAX_EMPTY_RETRIES: u32 = 3; let mut empty_retries = 0u32; let response = loop { - let request = LlmRunRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt.clone()), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt.clone()), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS), + llm, + )?; match request_game_creator_llm_text(client, llm, request).await { Ok(response) => break response, Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { @@ -15810,15 +16239,18 @@ pub(crate) async fn request_agent_role_brief_with_config( let llm = resolve_game_creator_llm_config_for_agent(config, agent_id); let config_path = format!("agentLlm.{agent_id}"); let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; - let request = LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_role_agent_system_prompt()), - LlmMessage::user(format!( - "请基于下面的本地上下文生成本角色的 Markdown brief。只返回 brief 正文,不要代码块。\n\n{}", - truncate_prompt_context(local_markdown) - )), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS); + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_system_prompt()), + LlmMessage::user(format!( + "请基于下面的本地上下文生成本角色的 Markdown brief。只返回 brief 正文,不要代码块。\n\n{}", + truncate_prompt_context(local_markdown) + )), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS), + &llm, + )?; let response = request_game_creator_llm_text(&client, &llm, request) .await .map_err(|error| format!("{config_path} 生成角色 brief 失败:{error}"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 09afd7dec..59888c67f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -172,6 +172,7 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) ), format!("llm.model={}", status.model.as_deref().unwrap_or_default()), format!("llm.apiKind={}", status.api_kind), + format!("llm.reasoningEffort={}", status.reasoning_effort), format!("llm.stream={}", status.stream), ]; for agent in &status.agents { @@ -197,6 +198,10 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) "llm.agent.{}.apiKind={}", agent.agent_id, agent.api_kind )); + lines.push(format!( + "llm.agent.{}.reasoningEffort={}", + agent.agent_id, agent.reasoning_effort + )); lines.push(format!( "llm.agent.{}.stream={}", agent.agent_id, agent.stream 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 new file mode 100644 index 000000000..57e4a1821 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -0,0 +1,2047 @@ +use super::*; +use sha2::{Digest, Sha256}; +use std::collections::{HashSet, VecDeque}; +use std::ffi::{OsStr, OsString}; +use std::process::Stdio; +use tokio::io::AsyncReadExt; + +const PROJECT_COMMAND_MAX_ARGUMENTS: usize = 64; +const PROJECT_COMMAND_MAX_ARGUMENT_CHARS: usize = 512; +const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024; +const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1; +const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300; +const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024; +const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000; +const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000; +const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024; +const PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS: &[&str] = &[ + ":(exclude).agent/**", + ":(exclude)**/.agent/**", + ":(exclude).git/**", + ":(exclude)**/.git/**", + ":(exclude).env", + ":(exclude).env.*", + ":(exclude)**/.env", + ":(exclude)**/.env.*", + ":(exclude)key", + ":(exclude)key.*", + ":(exclude)**/key", + ":(exclude)**/key.*", + ":(exclude)**/*.key", + ":(exclude)config", + ":(exclude)config.*", + ":(exclude)**/config", + ":(exclude)**/config.*", + ":(exclude)**/secrets/**", + ":(exclude)**/credentials/**", + ":(exclude)**/*.pem", + ":(exclude)**/*.p12", + ":(exclude)**/*.pfx", + ":(exclude)**/*.kdbx", + ":(exclude)**/.npmrc", + ":(exclude)**/.pypirc", + ":(exclude)**/.netrc", + ":(exclude)**/.git-credentials", + ":(exclude)**/credentials.json", + ":(exclude)**/auth.json", + ":(exclude)**/secrets.json", + ":(exclude)**/cookies.json", + ":(exclude)**/game-creator.config*.json", +]; +const PROJECT_COMMAND_SENSITIVE_RG_GLOBS: &[&str] = &[ + "!.agent/**", + "!**/.agent/**", + "!.git/**", + "!**/.git/**", + "!.hg/**", + "!**/.hg/**", + "!.svn/**", + "!**/.svn/**", + "!.env", + "!.env.*", + "!**/.env", + "!**/.env.*", + "!key", + "!key.*", + "!**/key", + "!**/key.*", + "!**/*.key", + "!config", + "!config.*", + "!**/config", + "!**/config.*", + "!**/secrets/**", + "!**/credentials/**", + "!**/*.pem", + "!**/*.p12", + "!**/*.pfx", + "!**/*.kdbx", + "!**/.npmrc", + "!**/.pypirc", + "!**/.netrc", + "!**/.git-credentials", + "!**/credentials.json", + "!**/auth.json", + "!**/secrets.json", + "!**/cookies.json", + "!**/game-creator.config*.json", +]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectCommandSpec { + pub(crate) program: String, + pub(crate) executable: PathBuf, + pub(crate) safe_path: OsString, + pub(crate) arguments: Vec, + pub(crate) cwd_relative: String, + pub(crate) cwd: PathBuf, + pub(crate) timeout_seconds: u64, + pub(crate) verification_eligible: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectCommandResult { + pub(crate) command_id: String, + pub(crate) program: String, + pub(crate) arguments: Vec, + pub(crate) cwd_relative: String, + pub(crate) status: String, + pub(crate) exit_code: Option, + pub(crate) timed_out: bool, + pub(crate) duration_ms: u64, + pub(crate) output: String, + pub(crate) source_fingerprint_before: String, + pub(crate) source_fingerprint_after: String, + pub(crate) source_changed: bool, + pub(crate) verification_eligible: bool, + pub(crate) log_path: String, + pub(crate) updated_at: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectCommandErrorStage { + Validation, + Preflight, + Spawn, + Execution, + PostExecutionFingerprint, + AuditLog, + ManifestProjection, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectCommandError { + stage: ProjectCommandErrorStage, + message: String, +} + +impl ProjectCommandError { + fn new(stage: ProjectCommandErrorStage, message: impl Into) -> Self { + Self { + stage, + message: sanitize_project_verification_output(&message.into()), + } + } + + pub(crate) fn stage(&self) -> ProjectCommandErrorStage { + self.stage + } + + pub(crate) fn message(&self) -> &str { + &self.message + } + + pub(crate) fn execution_started(&self) -> bool { + matches!( + self.stage, + ProjectCommandErrorStage::Execution + | ProjectCommandErrorStage::PostExecutionFingerprint + | ProjectCommandErrorStage::AuditLog + | ProjectCommandErrorStage::ManifestProjection + ) + } + + pub(crate) fn needs_reconciliation(&self) -> bool { + self.execution_started() + } +} + +impl ProjectCommandErrorStage { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Validation => "validation", + Self::Preflight => "preflight", + Self::Spawn => "spawn", + Self::Execution => "execution", + Self::PostExecutionFingerprint => "post-execution-fingerprint", + Self::AuditLog => "audit-log", + Self::ManifestProjection => "manifest-projection", + } + } +} + +impl std::fmt::Display for ProjectCommandError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ProjectCommandError {} + +impl std::ops::Deref for ProjectCommandError { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.message() + } +} + +#[derive(Debug)] +struct ProjectCommandProcessResult { + exit_code: Option, + timed_out: bool, + output: String, +} + +#[derive(Debug)] +struct BoundedCommandBytes { + head: Vec, + tail: VecDeque, + total: usize, + max_bytes: usize, +} + +impl BoundedCommandBytes { + fn new(max_bytes: usize) -> Self { + Self { + head: Vec::new(), + tail: VecDeque::new(), + total: 0, + max_bytes, + } + } + + fn push(&mut self, chunk: &[u8]) { + self.total = self.total.saturating_add(chunk.len()); + let head_limit = self.max_bytes / 3; + let tail_limit = self.max_bytes.saturating_sub(head_limit); + let head_len = head_limit.saturating_sub(self.head.len()).min(chunk.len()); + self.head.extend_from_slice(&chunk[..head_len]); + self.tail.extend(&chunk[head_len..]); + while self.tail.len() > tail_limit { + self.tail.pop_front(); + } + } + + fn finish(self) -> String { + let tail = self.tail.into_iter().collect::>(); + if self.total <= self.max_bytes { + let mut bytes = self.head; + bytes.extend(tail); + return String::from_utf8_lossy(&bytes).into_owned(); + } + let omitted = self.total.saturating_sub(self.head.len() + tail.len()); + format!( + "{}\n...<{} output bytes omitted>...\n{}", + String::from_utf8_lossy(&self.head), + omitted, + String::from_utf8_lossy(&tail) + ) + } +} + +pub(crate) fn resolve_project_command_spec_at( + root: &Path, + program: &str, + arguments: &[String], + cwd: &str, + timeout_seconds: u64, +) -> Result { + resolve_project_command_spec_inner(root, program, arguments, cwd, timeout_seconds) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error)) +} + +fn resolve_project_command_spec_inner( + root: &Path, + program: &str, + arguments: &[String], + cwd: &str, + timeout_seconds: u64, +) -> Result { + validate_project_root(root)?; + if !root.is_dir() { + return Err("command.exec 要求项目目录已存在".to_string()); + } + if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS) + .contains(&timeout_seconds) + { + return Err(format!( + "command.exec timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间" + )); + } + let program = program.trim().to_ascii_lowercase(); + if !matches!(program.as_str(), "cargo" | "npm" | "node" | "git" | "rg") { + return Err("command.exec program 只允许 cargo、npm、node、git 或 rg".to_string()); + } + validate_project_command_arguments(&program, arguments)?; + let cwd_relative = normalize_project_command_cwd(cwd)?; + let cwd = if cwd_relative == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, &cwd_relative)? + }; + validate_project_command_cwd_components(root, &cwd_relative)?; + if program == "node" { + validate_project_command_node_test_files(&cwd, arguments)?; + } + let (executable, safe_path) = resolve_project_command_executable(root, &program)?; + let verification_eligible = project_command_verification_eligible(&program, arguments); + Ok(ProjectCommandSpec { + program, + executable, + safe_path, + arguments: arguments.to_vec(), + cwd_relative, + cwd, + timeout_seconds, + verification_eligible, + }) +} + +fn validate_project_command_cwd_components(root: &Path, relative_path: &str) -> Result<(), String> { + let mut current = root.to_path_buf(); + validate_project_command_cwd_component(¤t)?; + if relative_path != "." { + for component in relative_path.split('/') { + current.push(component); + validate_project_command_cwd_component(¤t)?; + } + } + if !current.is_dir() { + return Err("command.exec cwd 必须是项目内普通目录".to_string()); + } + Ok(()) +} + +fn validate_project_command_cwd_component(path: &Path) -> Result<(), String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("command.exec cwd 不可用:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || project_command_metadata_is_reparse_point(&metadata) { + return Err("command.exec cwd 的任一层级都不能是符号链接或 reparse point".to_string()); + } + if !metadata.is_dir() { + return Err("command.exec cwd 必须是项目内普通目录".to_string()); + } + Ok(()) +} + +#[cfg(windows)] +fn project_command_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn project_command_metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn validate_project_command_node_test_files( + cwd: &Path, + arguments: &[String], +) -> Result<(), String> { + let files = arguments + .get(1..) + .filter(|files| !files.is_empty()) + .ok_or_else(|| "command.exec node --test 至少需要一个项目内测试文件".to_string())?; + for relative_path in files { + if relative_path.starts_with('-') + || relative_path.contains(['*', '?', '[', ']', '{', '}']) + || relative_path.contains('\\') + { + return Err( + "command.exec node --test 只接受精确的项目内测试文件路径,不接受额外 Node 选项或 glob" + .to_string(), + ); + } + let normalized = normalize_relative_path(relative_path)?; + if normalized != *relative_path { + return Err("command.exec node --test 文件路径必须是规范相对路径".to_string()); + } + let extension = Path::new(&normalized) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if !matches!( + extension.as_str(), + "js" | "mjs" | "cjs" | "ts" | "mts" | "cts" + ) { + return Err( + "command.exec node --test 只接受 JavaScript / TypeScript 测试文件".to_string(), + ); + } + let mut current = cwd.to_path_buf(); + for component in normalized.split('/') { + current.push(component); + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + format!( + "command.exec node 测试文件不可用:{}: {error}", + current.display() + ) + })?; + if metadata.file_type().is_symlink() + || project_command_metadata_is_reparse_point(&metadata) + { + return Err( + "command.exec node 测试文件的任一层级都不能是符号链接或 reparse point" + .to_string(), + ); + } + } + if !current.is_file() { + return Err("command.exec node --test 目标必须是项目内普通文件".to_string()); + } + } + Ok(()) +} + +fn project_command_verification_eligible(program: &str, arguments: &[String]) -> bool { + match program { + "cargo" => matches!( + arguments.first().map(String::as_str), + Some("check" | "test" | "clippy" | "fmt" | "build") + ), + "npm" => match arguments.first().map(String::as_str) { + Some("test") => true, + Some("run") => arguments + .get(1) + .is_some_and(|script| project_command_npm_verification_script_allowed(script)), + _ => false, + }, + "node" => arguments + .first() + .is_some_and(|argument| argument == "--test"), + "git" | "rg" => false, + _ => false, + } +} + +fn project_command_npm_verification_script_allowed(script: &str) -> bool { + if matches!(script, "check" | "typecheck" | "test" | "lint" | "build") { + return true; + } + [ + "check:", + "typecheck:", + "test:", + "lint:", + "build:", + "verify:", + "validate:", + ] + .iter() + .any(|prefix| { + script + .strip_prefix(prefix) + .is_some_and(project_command_npm_verification_script_suffix_allowed) + }) +} + +fn project_command_npm_verification_script_suffix_allowed(suffix: &str) -> bool { + suffix.split(':').all(|segment| { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + }) +} + +fn resolve_project_command_executable( + root: &Path, + program: &str, +) -> Result<(PathBuf, OsString), String> { + let path = std::env::var_os("PATH").ok_or_else(|| "command.exec 缺少 PATH".to_string())?; + resolve_project_command_executable_from_path(root, program, &path) +} + +fn resolve_project_command_executable_from_path( + root: &Path, + program: &str, + path: &OsStr, +) -> Result<(PathBuf, OsString), String> { + let root = fs::canonicalize(root) + .map_err(|error| format!("解析 command.exec 项目根目录失败:{error}"))?; + let mut safe_directories = Vec::new(); + let mut seen_directories = HashSet::new(); + let mut executable = None; + for directory in std::env::split_paths(path) { + if !directory.is_absolute() { + continue; + } + let canonical_directory = match fs::canonicalize(&directory) { + Ok(directory) + if directory.is_absolute() + && directory.is_dir() + && !directory.starts_with(&root) => + { + directory + } + _ => continue, + }; + if !seen_directories.insert(canonical_directory.clone()) { + continue; + } + safe_directories.push(canonical_directory.clone()); + if executable.is_some() { + continue; + } + for name in project_command_executable_names(program) { + let candidate = canonical_directory.join(name); + if !candidate.is_absolute() || candidate.starts_with(&root) { + continue; + } + let canonical_candidate = match fs::canonicalize(&candidate) { + Ok(candidate) + if candidate.is_absolute() + && candidate.is_file() + && !candidate.starts_with(&root) => + { + candidate + } + _ => continue, + }; + let metadata = match fs::metadata(&canonical_candidate) { + Ok(metadata) if metadata.is_file() => metadata, + _ => continue, + }; + if !project_command_file_is_executable(&metadata) { + continue; + } + let canonical_parent = canonical_candidate.parent().unwrap_or(&canonical_directory); + if canonical_parent.starts_with(&root) { + continue; + } + // Preserve argv[0] proxy semantics (notably rustup's cargo proxy) while + // requiring both the absolute entry and its resolved target to stay + // outside the project. + executable = Some(candidate); + break; + } + } + let executable = + executable.ok_or_else(|| format!("command.exec 找不到受信任的 {program} 可执行文件"))?; + let safe_path = std::env::join_paths(safe_directories) + .map_err(|error| format!("构造 command.exec 安全 PATH 失败:{error}"))?; + Ok((executable, safe_path)) +} + +#[cfg(windows)] +fn project_command_executable_names(program: &str) -> Vec { + match program { + "npm" => vec!["npm.cmd".to_string()], + _ => vec![format!("{program}.exe")], + } +} + +#[cfg(not(windows))] +fn project_command_executable_names(program: &str) -> Vec { + vec![program.to_string()] +} + +#[cfg(unix)] +fn project_command_file_is_executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn project_command_file_is_executable(_metadata: &fs::Metadata) -> bool { + true +} + +fn normalize_project_command_cwd(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value == "." { + return Ok(".".to_string()); + } + if project_command_argument_is_external_path(value) + || project_command_argument_contains_sensitive_path(value) + { + return Err("command.exec cwd 必须是项目内非敏感相对目录".to_string()); + } + let normalized = value.replace('\\', "/"); + if normalized != value || normalized.ends_with('/') || normalized.contains("//") { + return Err("command.exec cwd 必须使用规范正斜杠相对路径".to_string()); + } + Ok(normalized) +} + +fn validate_project_command_arguments(program: &str, arguments: &[String]) -> Result<(), String> { + if arguments.is_empty() { + return Err("command.exec args 不能为空".to_string()); + } + if arguments.len() > PROJECT_COMMAND_MAX_ARGUMENTS { + return Err(format!( + "command.exec args 不能超过 {PROJECT_COMMAND_MAX_ARGUMENTS} 项" + )); + } + let mut total_bytes = 0usize; + for argument in arguments { + if argument.is_empty() + || argument.chars().count() > PROJECT_COMMAND_MAX_ARGUMENT_CHARS + || argument.chars().any(char::is_control) + { + return Err(format!( + "command.exec 每个 argv 必须是 1-{PROJECT_COMMAND_MAX_ARGUMENT_CHARS} 个无控制字符的字符" + )); + } + total_bytes = total_bytes.saturating_add(argument.len()); + if project_command_argument_is_external_path(argument) + || project_command_argument_contains_sensitive_path(argument) + { + return Err("command.exec argv 不能引用项目外或敏感路径".to_string()); + } + } + if total_bytes > PROJECT_COMMAND_MAX_ARGUMENT_BYTES { + return Err(format!( + "command.exec args 总长度不能超过 {PROJECT_COMMAND_MAX_ARGUMENT_BYTES} 字节" + )); + } + match program { + "cargo" => validate_cargo_arguments(arguments), + "npm" => validate_npm_arguments(arguments), + "node" => validate_node_arguments(arguments), + "git" => validate_git_arguments(arguments), + "rg" => validate_rg_arguments(arguments), + _ => unreachable!("program whitelist checked above"), + } +} + +fn project_command_argument_is_external_path(value: &str) -> bool { + if Path::new(value).is_absolute() + || value.starts_with("//") + || value.starts_with("\\\\") + || value.split(['/', '\\']).any(|component| component == "..") + { + return true; + } + let bytes = value.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn project_command_argument_contains_sensitive_path(value: &str) -> bool { + let normalized = value.replace('\\', "/").to_ascii_lowercase(); + let components = normalized.split('/').collect::>(); + if components.iter().any(|component| { + matches!( + *component, + ".agent" + | ".git" + | ".hg" + | ".svn" + | ".ssh" + | ".aws" + | ".azure" + | ".gnupg" + | ".kube" + | ".docker" + | ".gcloud" + | ".password-store" + | ".secrets" + | "secrets" + | "credentials" + ) + }) { + return true; + } + let file_name = components.last().copied().unwrap_or_default(); + file_name == ".env" + || file_name.starts_with(".env.") + || matches!( + file_name, + ".npmrc" + | ".pypirc" + | ".netrc" + | ".git-credentials" + | "credentials.json" + | "auth.json" + | "secrets.json" + | "cookies.json" + | "game-creator.config.json" + | "game-creator.config.local.json" + ) + || [ + ".pem", ".p12", ".pfx", ".key", ".kdbx", ".sqlite", ".sqlite3", ".dump", + ] + .iter() + .any(|suffix| file_name.ends_with(suffix)) +} + +fn argument_matches_option(argument: &str, option: &str) -> bool { + argument == option || argument.starts_with(&format!("{option}=")) +} + +fn validate_cargo_arguments(arguments: &[String]) -> Result<(), String> { + let subcommand = arguments.first().map(String::as_str).unwrap_or_default(); + if !matches!( + subcommand, + "check" | "test" | "clippy" | "fmt" | "build" | "metadata" + ) { + return Err( + "command.exec cargo 只允许 check、test、clippy、fmt、build 或 metadata".to_string(), + ); + } + if arguments.iter().any(|argument| { + [ + "--config", + "--manifest-path", + "--target-dir", + "--registry", + "--index", + ] + .iter() + .any(|option| argument_matches_option(argument, option)) + }) { + return Err("command.exec cargo 禁止覆盖配置、manifest、target 或 registry".to_string()); + } + if subcommand == "fmt" && !arguments.iter().any(|argument| argument == "--check") { + return Err("command.exec cargo fmt 必须包含 --check".to_string()); + } + Ok(()) +} + +fn validate_npm_arguments(arguments: &[String]) -> Result<(), String> { + let subcommand = arguments.first().map(String::as_str).unwrap_or_default(); + if !matches!(subcommand, "test" | "run") { + return Err("command.exec npm 只允许 test 或 run".to_string()); + } + if subcommand == "run" + && arguments + .get(1) + .map(String::as_str) + .filter(|value| !value.starts_with('-')) + .is_none() + { + return Err("command.exec npm run 缺少脚本名".to_string()); + } + if arguments.iter().any(|argument| { + [ + "--prefix", + "--userconfig", + "--script-shell", + "--registry", + "--global", + "--location", + "--cache", + ] + .iter() + .any(|option| argument_matches_option(argument, option)) + }) { + return Err("command.exec npm 禁止覆盖 prefix、配置、shell、registry 或 cache".to_string()); + } + if arguments.iter().skip(1).any(|argument| { + argument != "--" + && (argument.chars().any(|character| { + character.is_whitespace() + || matches!( + character, + ';' | '&' | '|' | '<' | '>' | '`' | '$' | '(' | ')' | '{' | '}' + ) + }) || argument.contains("\\n") + || argument.contains("\\r")) + }) { + return Err("command.exec npm argv 不能包含 shell 元字符或空白".to_string()); + } + Ok(()) +} + +fn validate_node_arguments(arguments: &[String]) -> Result<(), String> { + if arguments.first().map(String::as_str) != Some("--test") || arguments.len() < 2 { + return Err("command.exec node 只允许精确的 node --test <项目内测试文件...>".to_string()); + } + if arguments.iter().skip(1).any(|argument| { + argument.starts_with('-') + || argument.contains(['*', '?', '[', ']', '{', '}']) + || argument.contains('\\') + }) { + return Err( + "command.exec node --test 只接受项目内普通测试文件,不接受额外 Node 选项或 glob" + .to_string(), + ); + } + Ok(()) +} + +fn validate_git_arguments(arguments: &[String]) -> Result<(), String> { + let subcommand = arguments.first().map(String::as_str).unwrap_or_default(); + if !matches!( + subcommand, + "status" | "diff" | "log" | "show" | "grep" | "ls-files" | "rev-parse" + ) { + return Err("command.exec git 只允许只读审阅子命令".to_string()); + } + if arguments.iter().any(|argument| { + [ + "-c", + "--config-env", + "--git-dir", + "--work-tree", + "--exec-path", + "--paginate", + "--pager", + "--ext-diff", + "--textconv", + "--output", + "--pathspec-from-file", + "--pathspec-file-nul", + "--exclude-from", + "--exclude-per-directory", + "--show-signature", + "--verify-signatures", + "--use-mailmap", + "--mailmap", + "--alternate-refs", + "--no-index", + "--recurse-submodules", + "--literal-pathspecs", + "--glob-pathspecs", + "--noglob-pathspecs", + "--icase-pathspecs", + ] + .iter() + .any(|option| argument_matches_option(argument, option)) + || argument_matches_short_option(argument, 'O') + || (subcommand == "grep" && argument_matches_short_option(argument, 'f')) + || argument_matches_option(argument, "--open-files-in-pager") + || argument.starts_with(':') + || project_command_git_argument_contains_sensitive_object_path(argument) + }) { + return Err( + "command.exec git 禁止间接读取、外部执行、pager、危险 pathspec 或敏感对象路径" + .to_string(), + ); + } + let mut pathspecs_started = false; + for argument in arguments.iter().skip(1) { + if argument == "--" { + pathspecs_started = true; + continue; + } + if pathspecs_started && project_command_git_path_is_sensitive(argument) { + return Err("command.exec git 禁止读取敏感 pathspec".to_string()); + } + } + Ok(()) +} + +fn validate_rg_arguments(arguments: &[String]) -> Result<(), String> { + if arguments.iter().any(|argument| { + [ + "--pre", + "--pre-glob", + "--hostname-bin", + "--ignore-file", + "--files-from", + "--file", + "--follow", + "--hidden", + "--search-zip", + "--glob", + "--iglob", + "--type", + "--type-not", + "--type-add", + "--type-clear", + ] + .iter() + .any(|option| argument_matches_option(argument, option)) + || argument.starts_with("--glob-") + || argument == "--no-ignore" + || argument.starts_with("--no-ignore=") + || argument.starts_with("--no-ignore-") + || ['L', 'u', 'z', 'g', 'f', 't', 'T', '.'] + .iter() + .any(|option| argument_matches_short_option(argument, *option)) + }) { + return Err( + "command.exec rg 禁止跟随链接、隐藏/忽略绕过、压缩包、预处理器、外部文件或自定义 glob" + .to_string(), + ); + } + Ok(()) +} + +fn argument_matches_short_option(argument: &str, option: char) -> bool { + argument.starts_with('-') + && !argument.starts_with("--") + && argument.chars().skip(1).any(|value| value == option) +} + +fn project_command_git_argument_contains_sensitive_object_path(argument: &str) -> bool { + let Some((_, object_path)) = argument.split_once(':') else { + return false; + }; + if object_path.is_empty() { + return false; + } + project_command_argument_is_external_path(object_path) + || project_command_git_path_is_sensitive(object_path) +} + +fn project_command_git_path_is_sensitive(value: &str) -> bool { + let normalized = value + .replace('\\', "/") + .trim_start_matches("./") + .trim_start_matches('/') + .to_ascii_lowercase(); + if project_command_argument_contains_sensitive_path(&normalized) { + return true; + } + let components = normalized + .split('/') + .filter(|component| !component.is_empty()) + .collect::>(); + if components.iter().any(|component| { + matches!( + *component, + ".git" | ".env" | "key" | "keys" | "config" | "secrets" | "credentials" + ) + }) { + return true; + } + let file_name = components.last().copied().unwrap_or_default(); + file_name.starts_with(".env.") + || file_name.starts_with("key.") + || file_name.starts_with("config.") + || file_name.ends_with(".key") +} + +fn project_command_actual_arguments(spec: &ProjectCommandSpec) -> Vec { + match spec.program.as_str() { + "npm" => std::iter::once("--ignore-scripts".to_string()) + .chain(spec.arguments.iter().cloned()) + .collect(), + "git" => { + let mut arguments = vec![ + "-c".to_string(), + "core.pager=cat".to_string(), + "-c".to_string(), + "diff.external=".to_string(), + "-c".to_string(), + "core.fsmonitor=false".to_string(), + "-c".to_string(), + "log.showSignature=false".to_string(), + "-c".to_string(), + "submodule.recurse=false".to_string(), + "--no-pager".to_string(), + ]; + let subcommand = spec + .arguments + .first() + .expect("validated git subcommand") + .clone(); + arguments.push(subcommand); + if matches!( + spec.arguments.first().map(String::as_str), + Some("diff" | "log" | "show") + ) { + arguments.push("--no-ext-diff".to_string()); + arguments.push("--no-textconv".to_string()); + } + arguments.extend(spec.arguments.iter().skip(1).cloned()); + if matches!( + spec.arguments.first().map(String::as_str), + Some("diff" | "log" | "show" | "grep") + ) { + if !spec.arguments.iter().any(|argument| argument == "--") { + arguments.push("--".to_string()); + } + arguments.extend( + PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS + .iter() + .map(|pathspec| (*pathspec).to_string()), + ); + } + arguments + } + "rg" => { + let mut arguments = vec![ + "--no-config".to_string(), + "--no-follow".to_string(), + "--no-hidden".to_string(), + ]; + let separator = spec.arguments.iter().position(|argument| argument == "--"); + let user_options_end = separator.unwrap_or(spec.arguments.len()); + arguments.extend(spec.arguments[..user_options_end].iter().cloned()); + for glob in PROJECT_COMMAND_SENSITIVE_RG_GLOBS { + arguments.push("--glob".to_string()); + arguments.push((*glob).to_string()); + } + if let Some(separator) = separator { + arguments.extend(spec.arguments[separator..].iter().cloned()); + } + arguments + } + _ => spec.arguments.clone(), + } +} + +fn configure_project_command_process_group(command: &mut tokio::process::Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + #[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); + } +} + +async fn request_project_command_process_group_termination( + process_id: u32, +) -> Result<&'static str, String> { + #[cfg(unix)] + { + let result = unsafe { libc::kill(-(process_id as i32), libc::SIGKILL) }; + if result == 0 { + return Ok("已请求终止受控进程组"); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + return Ok("受控进程组已不存在"); + } + return Err(format!("请求终止受控进程组失败:{error}")); + } + #[cfg(windows)] + { + let system_root = std::env::var_os("SystemRoot") + .ok_or_else(|| "请求终止受控进程组失败:缺少 SystemRoot".to_string())?; + let taskkill = fs::canonicalize(PathBuf::from(&system_root).join("System32/taskkill.exe")) + .map_err(|error| format!("请求终止受控进程组失败:定位 taskkill.exe 失败:{error}"))?; + if !taskkill.is_absolute() || !taskkill.is_file() { + return Err("请求终止受控进程组失败:taskkill.exe 不是绝对普通文件".to_string()); + } + let status = tokio::process::Command::new(taskkill) + .args(["/PID", &process_id.to_string(), "/T", "/F"]) + .env_clear() + .env("SystemRoot", &system_root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map_err(|error| format!("请求终止受控进程组失败:启动 taskkill.exe 失败:{error}"))?; + if !status.success() { + return Err(format!( + "请求终止受控进程组失败:taskkill.exe 退出码 {}", + status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_string()) + )); + } + return Ok("已请求终止受控进程组"); + } + #[cfg(not(any(unix, windows)))] + { + let _ = process_id; + Err("请求终止受控进程组失败:当前平台不支持受控进程组终止".to_string()) + } +} + +async fn terminate_project_command_process_group( + child: &mut tokio::process::Child, +) -> Result { + let process_id = child + .id() + .ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?; + let group_result = request_project_command_process_group_termination(process_id).await; + let child_kill_error = child.start_kill().err(); + let wait_result = child.wait().await; + if let Err(error) = &group_result { + let fallback = match (&child_kill_error, &wait_result) { + (_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(), + (Some(kill_error), Err(wait_error)) => { + format!("主进程兜底终止失败:{kill_error};等待失败:{wait_error}") + } + (None, Err(wait_error)) => format!("等待主进程退出失败:{wait_error}"), + }; + return Err(format!("{error};{fallback}")); + } + wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?; + Ok(format!( + "{}并完成主进程回收", + group_result.expect("group termination result checked") + )) +} + +async fn read_bounded_project_command_output(mut reader: R) -> Result +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut output = BoundedCommandBytes::new(PROJECT_COMMAND_OUTPUT_MAX_BYTES); + let mut buffer = [0_u8; 4 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .await + .map_err(|error| format!("读取 command.exec 子进程输出失败:{error}"))?; + if read == 0 { + break; + } + output.push(&buffer[..read]); + } + Ok(output.finish()) +} + +async fn collect_project_command_output_task( + mut task: tokio::task::JoinHandle>, + stream_name: &str, +) -> Result { + match tokio::time::timeout(Duration::from_secs(2), &mut task).await { + Ok(result) => { + result.map_err(|error| format!("收集 command.exec {stream_name} 失败:{error}"))? + } + Err(_) => { + task.abort(); + Err(format!( + "command.exec {stream_name} 收集超时,执行结果需要人工核对" + )) + } + } +} + +fn project_command_source_fingerprint(root: &Path) -> Result { + validate_project_root(root)?; + let mut entries_seen = 0usize; + let mut files_seen = 0usize; + let mut total_bytes = 0u64; + let mut files = Vec::new(); + let mut dirs = vec![root.to_path_buf()]; + while let Some(dir) = dirs.pop() { + for entry in fs::read_dir(&dir).map_err(|error| { + format!("读取 command.exec 指纹目录失败:{}: {error}", dir.display()) + })? { + entries_seen = entries_seen.saturating_add(1); + if entries_seen > PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES { + return Err(format!( + "command.exec 源码指纹超过目录项预算 {PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES}" + )); + } + let entry = entry.map_err(|error| { + format!("读取 command.exec 指纹条目失败:{}: {error}", dir.display()) + })?; + let file_type = entry.file_type().map_err(|error| { + format!( + "读取 command.exec 指纹文件类型失败:{}: {error}", + entry.path().display() + ) + })?; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + let relative_path = relative_project_path(root, &path)?; + if should_skip_project_index_path(&relative_path) { + continue; + } + if file_type.is_dir() { + dirs.push(path); + continue; + } + if !file_type.is_file() { + continue; + } + files_seen = files_seen.saturating_add(1); + if files_seen > PROJECT_COMMAND_FINGERPRINT_MAX_FILES { + return Err(format!( + "command.exec 源码指纹超过文件预算 {PROJECT_COMMAND_FINGERPRINT_MAX_FILES}" + )); + } + let size = entry + .metadata() + .map_err(|error| { + format!( + "读取 command.exec 指纹元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + total_bytes = total_bytes.saturating_add(size); + if total_bytes > PROJECT_COMMAND_FINGERPRINT_MAX_BYTES { + return Err(format!( + "command.exec 源码指纹超过字节预算 {PROJECT_COMMAND_FINGERPRINT_MAX_BYTES}" + )); + } + let mut file = fs::File::open(&path).map_err(|error| { + format!( + "读取 command.exec 指纹文件失败:{}: {error}", + path.display() + ) + })?; + let mut file_digest = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|error| { + format!( + "读取 command.exec 指纹文件失败:{}: {error}", + path.display() + ) + })?; + if read == 0 { + break; + } + file_digest.update(&buffer[..read]); + } + files.push((relative_path, size, format!("{:x}", file_digest.finalize()))); + } + } + files.sort_by(|left, right| left.0.cmp(&right.0)); + let mut digest = Sha256::new(); + for (path, size, checksum) in files { + digest.update(path.as_bytes()); + digest.update([0]); + digest.update(size.to_le_bytes()); + digest.update([0]); + digest.update(checksum.as_bytes()); + digest.update([b'\n']); + } + Ok(format!("{:x}", digest.finalize())) +} + +async fn run_project_command_process( + root: &Path, + spec: &ProjectCommandSpec, +) -> Result { + let isolated_home = resolve_local_project_path(root, ".agent/runtime/command-env/home") + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + let isolated_tmp = resolve_local_project_path(root, ".agent/runtime/command-env/tmp") + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + let isolated_cache = resolve_local_project_path(root, ".agent/runtime/command-env/cache") + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + fs::create_dir_all(&isolated_home) + .and_then(|()| fs::create_dir_all(&isolated_tmp)) + .and_then(|()| fs::create_dir_all(&isolated_cache)) + .map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("创建 command.exec 隔离目录失败:{error}"), + ) + })?; + + let mut command = tokio::process::Command::new(&spec.executable); + command + .args(project_command_actual_arguments(spec)) + .current_dir(&spec.cwd) + .env_clear() + .env("CI", "1") + .env("NO_COLOR", "1") + .env("FORCE_COLOR", "0") + .env("HOME", &isolated_home) + .env("USERPROFILE", &isolated_home) + .env("TMPDIR", &isolated_tmp) + .env("TEMP", &isolated_tmp) + .env("TMP", &isolated_tmp) + .env("CARGO_HOME", isolated_cache.join("cargo")) + .env("CARGO_NET_OFFLINE", "true") + .env("CARGO_TERM_COLOR", "never") + .env("npm_config_audit", "false") + .env("npm_config_fund", "false") + .env("npm_config_ignore_scripts", "true") + .env("npm_config_offline", "true") + .env("npm_config_update_notifier", "false") + .env("npm_config_cache", isolated_cache.join("npm")) + .env( + "npm_config_userconfig", + isolated_home.join("empty-user.npmrc"), + ) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", isolated_home.join("empty-gitconfig")) + .env("GIT_PAGER", "cat") + .env("GIT_EXTERNAL_DIFF", "") + .env("PAGER", "cat") + .env("HTTP_PROXY", "http://127.0.0.1:9") + .env("HTTPS_PROXY", "http://127.0.0.1:9") + .env("ALL_PROXY", "http://127.0.0.1:9") + .env("NO_PROXY", "") + .env("PATH", &spec.safe_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + for name in ["SystemRoot", "ComSpec", "PATHEXT", "RUSTUP_HOME"] { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + configure_project_command_process_group(&mut command); + + let mut child = command.spawn().map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Spawn, + format!("启动 command.exec {} 失败:{error}", spec.program), + ) + })?; + let process_id = child.id(); + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + let termination = terminate_project_command_process_group(&mut child) + .await + .unwrap_or_else(|error| error); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!("读取 command.exec stdout 失败;{termination}"), + )); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + let termination = terminate_project_command_process_group(&mut child) + .await + .unwrap_or_else(|error| error); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!("读取 command.exec stderr 失败;{termination}"), + )); + } + }; + let stdout_task = tokio::spawn(read_bounded_project_command_output(stdout)); + let stderr_task = tokio::spawn(read_bounded_project_command_output(stderr)); + let wait = tokio::time::timeout(Duration::from_secs(spec.timeout_seconds), child.wait()).await; + let (exit_code, timed_out, termination_summary) = match wait { + Ok(Ok(status)) => { + #[cfg(unix)] + if let Some(process_id) = process_id { + if let Err(error) = + request_project_command_process_group_termination(process_id).await + { + stdout_task.abort(); + stderr_task.abort(); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!( + "command.exec 主进程退出后请求终止受控进程组失败,需要人工核对:{error}" + ), + )); + } + } + (status.code(), false, None) + } + Ok(Err(error)) => { + let termination = terminate_project_command_process_group(&mut child) + .await + .unwrap_or_else(|termination_error| termination_error); + stdout_task.abort(); + stderr_task.abort(); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!("等待 command.exec 子进程失败:{error};{termination}"), + )); + } + Err(_) => { + let termination = match terminate_project_command_process_group(&mut child).await { + Ok(termination) => termination, + Err(error) => { + stdout_task.abort(); + stderr_task.abort(); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!("command.exec 超时后无法确认受控进程组终止,需要人工核对:{error}"), + )); + } + }; + ( + None, + true, + Some(format!("{termination};该终止请求不等同完整 OS sandbox")), + ) + } + }; + let (stdout, stderr) = tokio::join!( + collect_project_command_output_task(stdout_task, "stdout"), + collect_project_command_output_task(stderr_task, "stderr"), + ); + let stdout = stdout + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; + let stderr = stderr + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; + let mut sections = Vec::new(); + if !stdout.trim().is_empty() { + sections.push(format!("stdout:\n{}", stdout.trim())); + } + if !stderr.trim().is_empty() { + sections.push(format!("stderr:\n{}", stderr.trim())); + } + if timed_out { + sections.push(format!( + "command.exec 在 {} 秒后超时;{}", + spec.timeout_seconds, + termination_summary + .as_deref() + .unwrap_or("已请求终止受控进程组") + )); + } else if let Some(exit_code) = exit_code.filter(|code| *code != 0) { + sections.push(format!("command.exec 退出码:{exit_code}")); + } + if sections.is_empty() { + sections.push("command.exec 未产生输出".to_string()); + } + Ok(ProjectCommandProcessResult { + exit_code, + timed_out, + output: sanitize_project_verification_output(§ions.join("\n\n")), + }) +} + +fn project_command_id(spec: &ProjectCommandSpec) -> String { + let subcommand = spec + .arguments + .first() + .map(String::as_str) + .unwrap_or("run") + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character + } else { + '-' + } + }) + .collect::(); + format!("command.exec.{}.{}", spec.program, subcommand) +} + +pub(crate) async fn run_project_command_at( + root: &Path, + program: &str, + arguments: &[String], + cwd: &str, + timeout_seconds: u64, +) -> Result { + let spec = resolve_project_command_spec_at(root, program, arguments, cwd, timeout_seconds)?; + let source_fingerprint_before = project_command_source_fingerprint(root) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + let started_at = std::time::Instant::now(); + let process = run_project_command_process(root, &spec).await?; + let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + let source_fingerprint_after = project_command_source_fingerprint(root).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::PostExecutionFingerprint, + format!("command.exec 执行后无法复核项目源码指纹,需要人工核对:{error}"), + ) + })?; + let source_changed = source_fingerprint_before != source_fingerprint_after; + let completed = !process.timed_out && process.exit_code == Some(0) && !source_changed; + let status = if completed { "completed" } else { "failed" }; + let command_id = project_command_id(&spec); + let updated_at = unix_timestamp(); + let log_path = resolve_local_project_path(root, ".agent/logs/command.log") + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::AuditLog, + format!("创建 command.exec 日志目录失败:{error}"), + ) + })?; + } + let argument_bytes = serde_json::to_vec(&spec.arguments).unwrap_or_default(); + let log_entry = format!( + "{updated_at} command.exec program={} argsSha256={:x} argsCount={} cwd={} {status} exitCode={} timedOut={} durationMs={} sourceChanged={} verificationEligible={}\n{}\n", + spec.program, + Sha256::digest(&argument_bytes), + spec.arguments.len(), + spec.cwd_relative, + process + .exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_string()), + process.timed_out, + duration_ms, + source_changed, + spec.verification_eligible, + process.output, + ); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|mut file| file.write_all(log_entry.as_bytes())) + .map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::AuditLog, + format!("command.exec 执行后写入命令日志失败,需要人工核对:{error}"), + ) + })?; + record_command_run( + root, + GameCreationAppCommandRunState { + command_id: command_id.clone(), + status: if completed { + GameCreationAppCommandRunStatus::Completed + } else { + GameCreationAppCommandRunStatus::Failed + }, + output: process.output.clone(), + log_path: log_path.to_string_lossy().into_owned(), + updated_at, + }, + ) + .map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::ManifestProjection, + format!("command.exec 执行后写入命令投影失败,需要人工核对:{error}"), + ) + })?; + + Ok(ProjectCommandResult { + command_id, + program: spec.program, + arguments: spec.arguments, + cwd_relative: spec.cwd_relative, + status: status.to_string(), + exit_code: process.exit_code, + timed_out: process.timed_out, + duration_ms, + output: process.output, + source_fingerprint_before, + source_fingerprint_after, + source_changed, + verification_eligible: spec.verification_eligible, + log_path: log_path.to_string_lossy().into_owned(), + updated_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn command_project(name: &str) -> tempfile::TempDir { + let dir = tempfile::Builder::new() + .prefix(&format!("game-creator-command-{name}-")) + .tempdir() + .expect("command tempdir"); + init_local_game_project_at(dir.path(), "command-project", "命令测试") + .expect("init command project"); + dir + } + + fn command_args(arguments: &[&str]) -> Vec { + arguments + .iter() + .map(|argument| (*argument).to_string()) + .collect() + } + + #[cfg(unix)] + fn write_fake_command_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + fs::create_dir_all(path.parent().expect("fake executable parent")) + .expect("create fake executable parent"); + fs::write(path, "#!/bin/sh\nexit 0\n").expect("write fake executable"); + let mut permissions = fs::metadata(path) + .expect("fake executable metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("make fake executable executable"); + } + + #[test] + fn project_command_rejects_shells_paths_and_dangerous_options() { + let dir = command_project("validation"); + let root = dir.path(); + for (program, args) in [ + ("bash", vec!["-lc", "echo nope"]), + ("cargo", vec!["test", "--manifest-path", "../Cargo.toml"]), + ("npm", vec!["exec", "vite"]), + ("node", vec!["-e", "process.exit(0)"]), + ("git", vec!["commit", "-m", "nope"]), + ("rg", vec!["--pre", "sh", "needle"]), + ] { + let args = args.into_iter().map(str::to_string).collect::>(); + assert!( + resolve_project_command_spec_at(root, program, &args, ".", 30).is_err(), + "expected rejection for {program} {args:?}" + ); + } + let absolute = vec!["test".to_string(), "/tmp/outside.rs".to_string()]; + assert!(resolve_project_command_spec_at(root, "cargo", &absolute, ".", 30).is_err()); + let sensitive = vec!["status".to_string(), ".agent/agent.db".to_string()]; + assert!(resolve_project_command_spec_at(root, "git", &sensitive, ".", 30).is_err()); + } + + #[cfg(unix)] + #[test] + fn project_command_rejects_symlinked_cwd_ancestors() { + use std::os::unix::fs::symlink; + + let dir = command_project("cwd-symlink"); + let outside = tempfile::tempdir().expect("outside command cwd"); + fs::create_dir_all(outside.path().join("nested")).expect("outside nested cwd"); + symlink(outside.path(), dir.path().join("linked-cwd")).expect("symlink command cwd"); + let error = resolve_project_command_spec_at( + dir.path(), + "node", + &["--test".to_string(), "sample.test.mjs".to_string()], + "linked-cwd/nested", + 30, + ) + .expect_err("reject symlinked cwd ancestor"); + assert_eq!(error.stage(), ProjectCommandErrorStage::Validation); + assert!(error.message().contains("符号链接")); + } + + #[test] + fn project_command_accepts_targeted_developer_commands() { + let dir = command_project("allowed"); + let root = dir.path(); + fs::create_dir_all(root.join("tests")).expect("allowed tests dir"); + fs::write(root.join("tests/sample.test.mjs"), "// test fixture\n") + .expect("allowed node test fixture"); + for (program, args) in [ + ("cargo", vec!["test", "-p", "example", "specific_test"]), + ("npm", vec!["run", "test:unit", "--", "sample"]), + ("node", vec!["--test", "tests/sample.test.mjs"]), + ("git", vec!["diff", "--stat"]), + ("rg", vec!["-n", "needle"]), + ] { + let args = args.into_iter().map(str::to_string).collect::>(); + let spec = resolve_project_command_spec_at(root, program, &args, ".", 30) + .unwrap_or_else(|error| panic!("expected {program} to pass: {error}")); + assert_eq!(spec.program, program); + } + } + + #[test] + fn project_command_node_requires_exact_regular_test_files() { + for arguments in [ + command_args(&["--test"]), + command_args(&["--test=tests/sample.test.mjs"]), + command_args(&["--test", "--test-reporter=spec", "tests/sample.test.mjs"]), + command_args(&[ + "--test", + "tests/sample.test.mjs", + "--test-name-pattern=unit", + ]), + command_args(&["--test", "tests/*.test.mjs"]), + command_args(&["--test", "tests/{one,two}.test.mjs"]), + ] { + assert!( + validate_node_arguments(&arguments).is_err(), + "expected strict node rejection for {arguments:?}" + ); + } + assert!(validate_node_arguments(&command_args(&[ + "--test", + "tests/one.test.mjs", + "tests/two.test.ts", + ])) + .is_ok()); + } + + #[cfg(unix)] + #[test] + fn project_command_node_rejects_symlinked_test_files_and_ancestors() { + use std::os::unix::fs::symlink; + + let dir = command_project("node-test-symlink"); + let root = dir.path(); + fs::create_dir_all(root.join("tests/real")).expect("real tests dir"); + fs::write(root.join("tests/real/sample.test.mjs"), "// real test\n") + .expect("real node test"); + symlink( + root.join("tests/real/sample.test.mjs"), + root.join("tests/linked.test.mjs"), + ) + .expect("linked node test"); + symlink(root.join("tests/real"), root.join("tests/linked-dir")) + .expect("linked node test dir"); + + for test_path in ["tests/linked.test.mjs", "tests/linked-dir/sample.test.mjs"] { + let error = resolve_project_command_spec_at( + root, + "node", + &command_args(&["--test", test_path]), + ".", + 30, + ) + .expect_err("reject linked node test path"); + assert_eq!(error.stage(), ProjectCommandErrorStage::Validation); + assert!(error.message().contains("符号链接")); + } + } + + #[test] + fn project_command_git_rejects_indirect_execution_and_sensitive_objects() { + for arguments in [ + command_args(&["grep", "-O", "less", "needle"]), + command_args(&["grep", "-Oless", "needle"]), + command_args(&["grep", "--open-files-in-pager=less", "needle"]), + command_args(&["grep", "-fpatterns.txt"]), + command_args(&["log", "--paginate"]), + command_args(&["show", "--show-signature", "HEAD"]), + command_args(&["status", "--pathspec-from-file=paths.txt"]), + command_args(&["status", "--pathspec-file-nul"]), + command_args(&["ls-files", "--exclude-from=paths.txt"]), + command_args(&["diff", "--no-index", "left", "right"]), + command_args(&["show", "HEAD:.env"]), + command_args(&["show", "HEAD:.git/config"]), + command_args(&["show", "HEAD:keys/private.key"]), + command_args(&["show", "HEAD:config"]), + command_args(&["grep", "needle", "--", ":(glob)**/.env"]), + ] { + assert!( + validate_git_arguments(&arguments).is_err(), + "expected strict git rejection for {arguments:?}" + ); + } + assert!(validate_git_arguments(&command_args(&["grep", "-n", "needle"])).is_ok()); + assert!(validate_git_arguments(&command_args(&["show", "HEAD:package.json"])).is_ok()); + } + + #[test] + fn project_command_rg_rejects_visibility_archive_and_glob_bypasses() { + for arguments in [ + command_args(&["-L", "needle"]), + command_args(&["--follow", "needle"]), + command_args(&["--hidden", "needle"]), + command_args(&["-u", "needle"]), + command_args(&["--no-ignore-vcs", "needle"]), + command_args(&["-z", "needle"]), + command_args(&["--search-zip", "needle"]), + command_args(&["--pre=sh", "needle"]), + command_args(&["--pre-glob=*.js", "needle"]), + command_args(&["--hostname-bin=sh", "needle"]), + command_args(&["--glob=**/.env", "needle"]), + command_args(&["-g*.rs", "needle"]), + command_args(&["--iglob", "*.rs", "needle"]), + command_args(&["-fpatterns.txt"]), + command_args(&["--type-add=secret:*.env", "-tsecret", "needle"]), + command_args(&["--type", "rust", "needle"]), + command_args(&["-trust", "needle"]), + ] { + assert!( + validate_rg_arguments(&arguments).is_err(), + "expected strict rg rejection for {arguments:?}" + ); + } + assert!(validate_rg_arguments(&command_args(&["-n", "needle", "src"])).is_ok()); + } + + #[test] + fn project_command_verification_eligibility_is_conservative() { + for (program, arguments) in [ + ("cargo", command_args(&["check"])), + ("cargo", command_args(&["test", "specific_test"])), + ("cargo", command_args(&["clippy"])), + ("cargo", command_args(&["fmt", "--check"])), + ("cargo", command_args(&["build"])), + ("npm", command_args(&["test"])), + ("npm", command_args(&["run", "check:encoding"])), + ("npm", command_args(&["run", "typecheck"])), + ("node", command_args(&["--test", "tests/sample.test.mjs"])), + ] { + assert!( + project_command_verification_eligible(program, &arguments), + "expected verification eligible: {program} {arguments:?}" + ); + } + for (program, arguments) in [ + ("cargo", command_args(&["metadata"])), + ("npm", command_args(&["run", "dev"])), + ("npm", command_args(&["run", "start"])), + ("git", command_args(&["status"])), + ("rg", command_args(&["needle"])), + ] { + assert!( + !project_command_verification_eligible(program, &arguments), + "expected verification ineligible: {program} {arguments:?}" + ); + } + } + + #[cfg(unix)] + #[test] + fn project_command_executable_resolution_ignores_project_and_relative_path_poisoning() { + let dir = command_project("path-poisoning"); + let root = dir.path(); + let outside = tempfile::tempdir().expect("outside executable dir"); + let executable_name = project_command_executable_names("node") + .into_iter() + .next() + .expect("node executable name"); + let project_bin = root.join("tools/bin"); + let project_executable = project_bin.join(&executable_name); + let outside_bin = outside.path().join("bin"); + let outside_executable = outside_bin.join(&executable_name); + write_fake_command_executable(&project_executable); + write_fake_command_executable(&outside_executable); + + let poisoned_path = std::env::join_paths([ + PathBuf::from("relative-bin"), + project_bin.clone(), + outside_bin.clone(), + ]) + .expect("poisoned PATH"); + let (executable, safe_path) = + resolve_project_command_executable_from_path(root, "node", &poisoned_path) + .expect("resolve outside executable"); + assert_eq!( + executable, + fs::canonicalize(&outside_executable).expect("canonical outside executable") + ); + let canonical_root = fs::canonicalize(root).expect("canonical command root"); + let safe_directories = std::env::split_paths(&safe_path).collect::>(); + assert!(!safe_directories.is_empty()); + assert!(safe_directories + .iter() + .all(|directory| directory.is_absolute() && !directory.starts_with(&canonical_root))); + assert!(!safe_directories + .iter() + .any(|directory| directory == &fs::canonicalize(&project_bin).unwrap())); + + let project_only_path = + std::env::join_paths([project_bin]).expect("project-only poisoned PATH"); + assert!( + resolve_project_command_executable_from_path(root, "node", &project_only_path).is_err() + ); + } + + #[test] + fn project_command_injects_git_safety_options_before_pathspec_separator() { + let dir = command_project("git-arguments"); + let spec = resolve_project_command_spec_at( + dir.path(), + "git", + &[ + "diff".to_string(), + "--".to_string(), + "game/index.html".to_string(), + ], + ".", + 30, + ) + .expect("resolve git diff"); + let arguments = project_command_actual_arguments(&spec); + let separator = arguments + .iter() + .position(|argument| argument == "--") + .expect("pathspec separator"); + let no_ext_diff = arguments + .iter() + .position(|argument| argument == "--no-ext-diff") + .expect("safe external diff option"); + let no_textconv = arguments + .iter() + .position(|argument| argument == "--no-textconv") + .expect("safe textconv option"); + assert!(no_ext_diff < separator); + assert!(no_textconv < separator); + assert_eq!(arguments[separator + 1], "game/index.html"); + for pathspec in PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS { + let position = arguments + .iter() + .position(|argument| argument == pathspec) + .unwrap_or_else(|| panic!("missing protected Git pathspec {pathspec}")); + assert!(position > separator); + } + } + + #[test] + fn project_command_injects_non_overridable_rg_sensitive_exclusions() { + let dir = command_project("rg-arguments"); + let spec = resolve_project_command_spec_at( + dir.path(), + "rg", + &command_args(&["-n", "needle"]), + ".", + 30, + ) + .expect("resolve rg search"); + let arguments = project_command_actual_arguments(&spec); + assert_eq!(arguments.first().map(String::as_str), Some("--no-config")); + assert!(arguments.iter().any(|argument| argument == "--no-follow")); + assert!(arguments.iter().any(|argument| argument == "--no-hidden")); + for glob in PROJECT_COMMAND_SENSITIVE_RG_GLOBS { + let position = arguments + .windows(2) + .position(|pair| pair[0] == "--glob" && pair[1] == *glob); + assert!(position.is_some(), "missing protected rg glob {glob}"); + } + let last_user_argument = arguments + .iter() + .position(|argument| argument == "needle") + .expect("user rg pattern"); + let first_protected_glob = arguments + .windows(2) + .position(|pair| { + pair[0] == "--glob" && pair[1] == PROJECT_COMMAND_SENSITIVE_RG_GLOBS[0] + }) + .expect("first protected rg glob"); + assert!(first_protected_glob > last_user_argument); + assert!( + validate_rg_arguments(&command_args(&["-n", "needle", "--glob", "**/.env",])).is_err() + ); + } + + #[test] + fn project_command_npm_rejects_shell_metacharacters_in_forwarded_arguments() { + for arguments in [ + command_args(&["run", "test:unit", "--", ";touch-outside"]), + command_args(&["run", "test:unit", "--", "$(id)"]), + command_args(&["test", "--", "name with spaces"]), + command_args(&["run", "test:unit", "--", "value|other"]), + ] { + assert!( + validate_npm_arguments(&arguments).is_err(), + "expected npm shell metacharacter rejection for {arguments:?}" + ); + } + assert!( + validate_npm_arguments(&command_args(&["run", "test:unit", "--", "sample.test",])) + .is_ok() + ); + } + + #[tokio::test] + async fn project_command_reports_success_failure_timeout_redaction_and_source_changes() { + let dir = command_project("execution"); + let root = dir.path(); + fs::create_dir_all(root.join("tests")).expect("tests dir"); + fs::write( + root.join("tests/pass.test.mjs"), + "import test from 'node:test';\nimport assert from 'node:assert/strict';\ntest('pass', () => { console.log('PASS_MARKER'); assert.equal(1, 1); });\n", + ) + .expect("write passing test"); + let success = run_project_command_at( + root, + "node", + &["--test".to_string(), "tests/pass.test.mjs".to_string()], + ".", + 30, + ) + .await + .expect("run passing test"); + assert_eq!(success.status, "completed"); + assert_eq!(success.exit_code, Some(0)); + assert!(!success.source_changed); + assert!(success.verification_eligible); + assert!(success.output.contains("PASS_MARKER")); + + let secret = ["s", "k-command-", "secret-123456789"].concat(); + fs::write( + root.join("tests/fail.test.mjs"), + format!( + "import test from 'node:test';\nimport assert from 'node:assert/strict';\ntest('fail', () => {{ console.log({secret:?}); assert.equal(1, 2); }});\n" + ), + ) + .expect("write failing test"); + let failed = run_project_command_at( + root, + "node", + &["--test".to_string(), "tests/fail.test.mjs".to_string()], + ".", + 30, + ) + .await + .expect("run failing test"); + assert_eq!(failed.status, "failed"); + assert_ne!(failed.exit_code, Some(0)); + assert!(!failed.output.contains(&secret)); + + fs::write( + root.join("tests/change.test.mjs"), + "import test from 'node:test';\nimport fs from 'node:fs';\ntest('change', () => { fs.writeFileSync('changed.txt', 'changed\\n'); });\n", + ) + .expect("write changing test"); + let changed = run_project_command_at( + root, + "node", + &["--test".to_string(), "tests/change.test.mjs".to_string()], + ".", + 30, + ) + .await + .expect("run changing test"); + assert!(changed.source_changed); + assert_eq!(changed.status, "failed"); + + fs::write( + root.join("tests/timeout.test.mjs"), + "import test from 'node:test';\ntest('wait', async () => { await new Promise(resolve => setTimeout(resolve, 5000)); });\n", + ) + .expect("write timeout test"); + let timed_out = run_project_command_at( + root, + "node", + &["--test".to_string(), "tests/timeout.test.mjs".to_string()], + ".", + 1, + ) + .await + .expect("run timeout test"); + assert!(timed_out.timed_out); + assert_eq!(timed_out.status, "failed"); + assert!(timed_out.output.contains("请求终止受控进程组")); + assert!(timed_out.output.contains("不等同完整 OS sandbox")); + + let command_log = root.join(".agent/logs/command.log"); + fs::remove_file(&command_log).expect("remove command log before audit failure"); + fs::create_dir(&command_log).expect("replace command log with directory"); + let audit_error = run_project_command_at( + root, + "node", + &["--test".to_string(), "tests/pass.test.mjs".to_string()], + ".", + 30, + ) + .await + .expect_err("audit log failure after execution"); + assert_eq!(audit_error.stage(), ProjectCommandErrorStage::AuditLog); + assert!(audit_error.execution_started()); + assert!(audit_error.needs_reconciliation()); + } + + #[test] + fn project_command_errors_distinguish_validation_from_started_execution() { + let dir = command_project("error-stage"); + let error = resolve_project_command_spec_at( + dir.path(), + "bash", + &["-lc".to_string(), "echo no".to_string()], + ".", + 30, + ) + .expect_err("reject shell"); + assert_eq!(error.stage(), ProjectCommandErrorStage::Validation); + assert_eq!(error.stage().as_str(), "validation"); + assert!(!error.execution_started()); + assert!(!error.needs_reconciliation()); + } + + #[test] + fn bounded_command_output_keeps_head_and_tail() { + let mut output = BoundedCommandBytes::new(30); + output.push(b"HEAD-0123456789-MIDDLE-abcdefghij-TAIL"); + let output = output.finish(); + assert!(output.contains("HEAD")); + assert!(output.contains("TAIL")); + assert!(output.contains("omitted")); + } +} 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 863f7a694..f6c51831b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -47,6 +47,32 @@ pub(crate) fn parse_game_creator_llm_api_kind(value: &str) -> Result Result, String> { + match value.trim().to_ascii_lowercase().as_str() { + "default" => Ok(None), + "low" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Low)), + "medium" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium)), + "high" => Ok(Some(platform_llm::LlmResponseReasoningEffort::High)), + value => Err(format!( + "LLM reasoning_effort 无效:{value},请使用 default、low、medium 或 high" + )), + } +} + +pub(crate) fn apply_game_creator_llm_reasoning_effort( + request: LlmRunRequest, + llm: &GameCreatorLlmConfig, +) -> Result { + Ok( + match parse_game_creator_llm_reasoning_effort(&llm.reasoning_effort)? { + Some(effort) => request.with_response_reasoning_effort(effort), + None => request, + }, + ) +} + pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus { let app_config = match load_game_creator_app_config() { Ok(config) => config, @@ -57,6 +83,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi base_url: None, model: None, api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), + reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(), stream: false, error: Some(error), agents: Vec::new(), @@ -150,6 +177,7 @@ pub(crate) fn check_game_creator_llm_config_values( base_url, model, api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), + reasoning_effort: config.reasoning_effort.clone(), stream: config.stream, error, agents: Vec::new(), @@ -184,6 +212,7 @@ pub(crate) fn check_game_creator_agent_llm_config_values( base_url: status.base_url, model: status.model, api_kind: status.api_kind, + reasoning_effort: config.reasoning_effort.clone(), stream: config.stream, error: status.error, } @@ -201,6 +230,8 @@ pub(crate) fn validate_game_creator_llm_timing_config( if config.retry_backoff_ms == 0 { return Err(format!("配置项 {config_path}.retryBackoffMs 必须大于 0")); } + parse_game_creator_llm_reasoning_effort(&config.reasoning_effort) + .map_err(|error| format!("配置项 {config_path}.reasoningEffort 无效:{error}"))?; Ok(()) } @@ -213,6 +244,16 @@ pub(crate) fn game_creator_llm_api_kind_name(api_kind: LlmApiKind) -> String { .to_string() } +pub(crate) fn game_creator_llm_reasoning_effort_name( + value: &str, + config_path: &str, +) -> Result { + let normalized = value.trim().to_ascii_lowercase(); + parse_game_creator_llm_reasoning_effort(&normalized) + .map_err(|error| format!("配置项 {config_path} 无效:{error}"))?; + Ok(normalized) +} + fn validate_game_creator_runtime_config_dir_metadata( path: &Path, tighten: bool, @@ -926,6 +967,9 @@ pub(crate) fn merge_game_creator_llm_config( if let Some(value) = patch.api_kind { config.api_kind = value; } + if let Some(value) = patch.reasoning_effort { + config.reasoning_effort = value; + } if let Some(value) = patch.stream { config.stream = value; } @@ -956,6 +1000,9 @@ pub(crate) fn merge_game_creator_llm_patch( if let Some(value) = patch.api_kind { config.api_kind = Some(value); } + if let Some(value) = patch.reasoning_effort { + config.reasoning_effort = Some(value); + } if let Some(value) = patch.stream { config.stream = Some(value); } @@ -1012,6 +1059,10 @@ pub(crate) fn normalize_game_creator_app_config( trim_config_string(&config.llm.model).ok_or_else(|| llm_model_config_error("llm"))?; config.llm.api_kind = game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind(&config.llm.api_kind)?); + config.llm.reasoning_effort = game_creator_llm_reasoning_effort_name( + &config.llm.reasoning_effort, + "llm.reasoningEffort", + )?; validate_game_creator_llm_timing_config(&config.llm, "llm")?; let mut agent_llm = BTreeMap::new(); for (agent_id, patch) in config.agent_llm { @@ -1045,6 +1096,13 @@ pub(crate) fn normalize_game_creator_llm_patch_config( )), None => None, }; + patch.reasoning_effort = match patch.reasoning_effort { + Some(value) => Some(game_creator_llm_reasoning_effort_name( + &value, + &format!("agentLlm.{agent_id}.reasoningEffort"), + )?), + None => None, + }; if patch .request_timeout_ms .is_some_and(|value| value < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS) @@ -1066,6 +1124,7 @@ pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile) && patch.base_url.is_none() && patch.model.is_none() && patch.api_kind.is_none() + && patch.reasoning_effort.is_none() && patch.stream.is_none() && patch.request_timeout_ms.is_none() && patch.max_retries.is_none() 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 39b127921..5076a0c53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -44,6 +44,7 @@ mod agent; mod assets; mod browser; mod cli; +mod command_exec; mod commands; mod config; #[cfg(all(debug_assertions, not(test)))] @@ -59,6 +60,7 @@ use agent::*; use assets::*; use browser::*; use cli::*; +use command_exec::*; use commands::*; use config::*; use isolated_agent::*; @@ -431,6 +433,7 @@ struct GameCreatorLlmConfigStatus { base_url: Option, model: Option, api_kind: String, + reasoning_effort: String, stream: bool, error: Option, agents: Vec, @@ -446,6 +449,7 @@ struct GameCreatorAgentLlmConfigStatus { base_url: Option, model: Option, api_kind: String, + reasoning_effort: String, stream: bool, error: Option, } @@ -470,6 +474,8 @@ struct GameCreatorLlmConfigFile { #[serde(skip_serializing_if = "Option::is_none")] api_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] stream: Option, #[serde(skip_serializing_if = "Option::is_none")] request_timeout_ms: Option, @@ -502,6 +508,7 @@ struct GameCreatorLlmConfig { base_url: String, model: String, api_kind: String, + reasoning_effort: String, stream: bool, request_timeout_ms: u64, max_retries: u32, @@ -834,6 +841,7 @@ const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.jso const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1"; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; +const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082"; const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json"); const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; @@ -897,6 +905,7 @@ impl Default for GameCreatorLlmConfig { base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(), model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), + reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(), stream: false, request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, max_retries: 0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 3e1367421..4a9645094 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -831,6 +831,7 @@ fn config_file_overrides_defaults_without_env() { "baseUrl": "https://example.test/v1", "model": "model-from-file", "apiKind": "openai_chat", + "reasoningEffort": "medium", "stream": true, "requestTimeoutMs": 42000, "maxRetries": 2, @@ -840,6 +841,7 @@ fn config_file_overrides_defaults_without_env() { "planner": { "model": "planner-model", "apiKind": "anthropic", + "reasoningEffort": "default", "stream": false }, "generator": { @@ -863,6 +865,7 @@ fn config_file_overrides_defaults_without_env() { assert_eq!(config.llm.base_url, "https://example.test/v1"); assert_eq!(config.llm.model, "model-from-file"); assert_eq!(config.llm.api_kind, "openai_chat"); + assert_eq!(config.llm.reasoning_effort, "medium"); assert!(config.llm.stream); assert_eq!(config.llm.request_timeout_ms, 42_000); assert_eq!(config.llm.max_retries, 2); @@ -872,6 +875,7 @@ fn config_file_overrides_defaults_without_env() { assert_eq!(planner_llm.base_url, "https://example.test/v1"); assert_eq!(planner_llm.model, "planner-model"); assert_eq!(planner_llm.api_kind, "anthropic"); + assert_eq!(planner_llm.reasoning_effort, "default"); assert!(!planner_llm.stream); let generator_llm = resolve_game_creator_llm_config_for_agent(&config, "generator"); assert_eq!(generator_llm.api_key, "file-key"); @@ -935,6 +939,10 @@ fn runtime_config_read_returns_defaults_when_file_is_missing() { result.config.llm.api_kind, DEFAULT_GAME_CREATOR_LLM_API_KIND ); + assert_eq!( + result.config.llm.reasoning_effort, + DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT + ); assert!(!result.config.llm.stream); assert_eq!( result.config.llm.request_timeout_ms, @@ -962,6 +970,7 @@ fn app_config_commands_write_runtime_config_file() { base_url: Some(" https://planner.example.test/v1 ".to_string()), model: Some(" planner-model ".to_string()), api_kind: Some("anthropic".to_string()), + reasoning_effort: Some(" low ".to_string()), stream: Some(true), request_timeout_ms: Some(15_000), max_retries: Some(1), @@ -976,6 +985,7 @@ fn app_config_commands_write_runtime_config_file() { base_url: " https://runtime.example.test/v1 ".to_string(), model: " runtime-model ".to_string(), api_kind: "openai_chat".to_string(), + reasoning_effort: " high ".to_string(), stream: true, request_timeout_ms: 42_000, max_retries: 2, @@ -998,6 +1008,7 @@ fn app_config_commands_write_runtime_config_file() { assert_eq!(saved.config.llm.api_key, "unit-test-key"); assert_eq!(saved.config.llm.base_url, "https://runtime.example.test/v1"); assert_eq!(saved.config.llm.api_kind, "openai_chat"); + assert_eq!(saved.config.llm.reasoning_effort, "high"); assert_eq!( saved .config @@ -1021,6 +1032,14 @@ fn app_config_commands_write_runtime_config_file() { .and_then(|llm| llm.api_kind.as_deref()), Some("anthropic") ); + assert_eq!( + read_back + .config + .agent_llm + .get("planner") + .and_then(|llm| llm.reasoning_effort.as_deref()), + Some("low") + ); fs::remove_dir_all(root).expect("cleanup runtime config dir"); } @@ -1047,6 +1066,28 @@ fn app_config_write_rejects_invalid_api_kind() { fs::remove_dir_all(root).expect("cleanup runtime config dir"); } +#[test] +fn app_config_write_rejects_invalid_reasoning_effort() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let result = write_game_creator_app_config(GameCreatorAppConfig { + llm: GameCreatorLlmConfig { + reasoning_effort: "maximum".to_string(), + ..GameCreatorLlmConfig::default() + }, + editor_api: GameCreatorEditorApiConfig::default(), + agent_llm: BTreeMap::new(), + }); + + assert!(result + .expect_err("invalid reasoning effort") + .contains("reasoningEffort")); + assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists()); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); +} + #[test] fn app_config_write_rejects_too_small_request_timeout() { let root = unique_project_path(); @@ -4514,6 +4555,7 @@ fn agent_runtime_default_allowed_tools_match_executable_whitelist() { assert_eq!(default_game_creator_agent_runtime_allowed_tools(), expected); assert!(expected.contains(&"project.index".to_string())); assert!(expected.contains(&"file.delete".to_string())); + assert!(expected.contains(&"command.exec".to_string())); assert!(expected.contains(&"preview.validate".to_string())); assert!(expected.contains(&"agent.spawn_isolated".to_string())); assert!(!expected.contains(&"conversation.write".to_string())); @@ -9379,6 +9421,914 @@ async fn background_agent_runtime_confirms_project_verify_and_replans_with_outpu fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_command_exec_requires_confirmation_by_default() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "受控命令确认项目").expect("project init"); + fs::create_dir_all(root.join("test")).expect("create test dir"); + fs::write( + root.join("test/confirmation.test.mjs"), + "console.log('COMMAND_EXEC_SHOULD_WAIT');\n", + ) + .expect("write command fixture"); + assert!(ProjectPermissionPolicy::default() + .confirm_commands + .contains(&"command.exec".to_string())); + + let plan = serde_json::json!({ + "thinkingSummary": "运行定向测试前等待开发者确认", + "plan": ["运行 node 定向测试"], + "actions": [{ + "tool": "command.exec", + "reason": "取得真实测试输出", + "input": { + "program": "node", + "args": ["--test", "test/confirmation.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + } + }], + "response": "" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan], Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "code-key", + "baseUrl": {base_url:?}, + "model": "code-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "code-prototype", + "运行定向测试并读取真实输出", + "code-command-exec-confirm-default-run", + ) + .expect("start command task"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("command plan request"); + let runtime = wait_for_agent_runtime_confirmation(&root, "code-prototype"); + assert_eq!(runtime.status, "waiting-for-confirmation"); + let pending = runtime.pending_tool_action.expect("pending command action"); + assert_eq!(pending.tool, "command.exec"); + assert!(pending + .input_summary + .as_deref() + .is_some_and(|summary| summary.contains("program=node") && summary.contains("cwd=."))); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before confirmation") + .revision, + 0 + ); + assert!(!root.join(".agent/logs/command.log").exists()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_command_exec_repairs_failure_and_finishes_once() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "受控命令修复项目").expect("project init"); + fs::create_dir_all(root.join("src")).expect("create source dir"); + fs::create_dir_all(root.join("test")).expect("create test dir"); + fs::write(root.join("src/answer.mjs"), "export const answer = 1;\n") + .expect("write broken source"); + fs::write( + root.join("test/answer.test.mjs"), + concat!( + "import { answer } from '../src/answer.mjs';\n", + "if (answer !== 2) throw new Error('COMMAND_EXEC_REPAIR_REQUIRED');\n", + "console.log('COMMAND_EXEC_RECOVERY_PASS');\n", + ), + ) + .expect("write command test"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["command.exec".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require command confirmation only"); + + let command_action = || { + serde_json::json!({ + "tool": "command.exec", + "reason": "运行定向失败测试并取得真实输出", + "input": { + "program": "node", + "args": ["--test", "test/answer.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + } + }) + }; + let failed_command_plan = serde_json::json!({ + "thinkingSummary": "先复现未知位置的测试失败", + "plan": ["运行定向测试", "依据 stderr 定位并修复", "重新运行测试"], + "actions": [command_action()], + "response": "" + }) + .to_string(); + let patch_plan = serde_json::json!({ + "thinkingSummary": "失败输出已定位 answer 值错误", + "plan": ["精确修复源码", "重新运行同一测试"], + "actions": [{ + "tool": "file.patch", + "reason": "修正失败测试暴露的错误值", + "input": { + "path": "src/answer.mjs", + "oldText": "export const answer = 1;\n", + "newText": "export const answer = 2;\n", + "expectedReplacements": 1 + } + }], + "response": "" + }) + .to_string(); + let passing_command_plan = serde_json::json!({ + "thinkingSummary": "源码已修复,重新执行同一测试", + "plan": ["重新运行定向测试"], + "actions": [command_action()], + "response": "" + }) + .to_string(); + let response = "已根据失败输出修复 answer,并以 COMMAND_EXEC_RECOVERY_PASS 完成验证。"; + let final_plan = final_tool_plan_response(response); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + failed_command_plan, + patch_plan, + passing_command_plan, + final_plan, + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "code-key", + "baseUrl": {base_url:?}, + "model": "code-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let run_id = "code-command-exec-repair-run"; + start_game_creator_agent_background_task_at( + &root, + "code-prototype", + "定位并修复失败测试,最后重新验证", + run_id, + ) + .expect("start command repair task"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial command plan request"); + let first_waiting = wait_for_agent_runtime_confirmation(&root, "code-prototype"); + let first_pending = first_waiting + .pending_tool_action + .as_ref() + .expect("first command confirmation"); + assert_eq!(first_pending.tool, "command.exec"); + confirm_game_creator_agent_runtime_task( + root.to_string_lossy().into_owned(), + "code-prototype".to_string(), + run_id.to_string(), + first_pending.action_id.clone(), + "允许运行失败测试".to_string(), + ) + .expect("confirm failed command"); + + let repair_request = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("repair plan request"); + assert!(repair_request.contains("COMMAND_EXEC_REPAIR_REQUIRED")); + let retry_request = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("retry command plan request"); + assert!(retry_request.contains("file.patch")); + assert!(retry_request.contains("已局部修改 src/answer.mjs")); + let second_waiting = wait_for_agent_runtime_confirmation(&root, "code-prototype"); + let second_pending = second_waiting + .pending_tool_action + .as_ref() + .expect("second command confirmation"); + assert_eq!(second_pending.tool, "command.exec"); + assert_ne!(second_pending.action_id, first_pending.action_id); + confirm_game_creator_agent_runtime_task( + root.to_string_lossy().into_owned(), + "code-prototype".to_string(), + run_id.to_string(), + second_pending.action_id.clone(), + "允许重新运行测试".to_string(), + ) + .expect("confirm passing command"); + + let final_request = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("final plan request"); + assert!(final_request.contains("COMMAND_EXEC_RECOVERY_PASS")); + let runtime = wait_for_agent_runtime_idle(&root, "code-prototype"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.last_response.as_deref(), Some(response)); + assert_eq!( + fs::read_to_string(root.join("src/answer.mjs")).expect("read repaired source"), + "export const answer = 2;\n" + ); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("command.exec:command-failed"))); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("file.patch:ok"))); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("command.exec:ok"))); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read command repair revision"); + assert_eq!(revision.revision, 3); + let gate = read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", run_id) + .expect("read command repair gate"); + assert_eq!(gate.verified_revision, Some(3)); + assert_eq!(gate.last_verification_status.as_deref(), Some("passed")); + + let records = read_agent_db_records_for_test(&root); + let command_records = records + .iter() + .filter(|record| record["recordType"] == "agent.runtime.command.exec") + .collect::>(); + assert_eq!(command_records.len(), 2); + assert!(command_records.iter().all(|record| { + record.get("args").is_none() + && record["argsCount"] == 2 + && record["verificationEligible"] == true + && record["argsSha256"] + .as_str() + .is_some_and(|value| value.len() == 64) + })); + assert!(records + .iter() + .filter(|record| { + matches!( + record["recordType"].as_str(), + Some( + "agent.runtime.tool_confirmation_required" + | "agent.runtime.tool_confirmation.approved" + ) + ) && record["tool"] == "command.exec" + }) + .all(|record| { + record["inputSummary"].as_str().is_some_and(|summary| { + summary.contains("argsSha256=") && !summary.contains("answer.test") + }) + })); + let conversation = read_local_conversation_for_session_at( + &root, + Some("code-prototype"), + Some(&runtime.session_id), + ) + .expect("read command repair conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_command_exec_revalidates_revision_after_acquiring_project_lock() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "命令锁内复核项目").expect("project init"); + fs::create_dir_all(root.join("test")).expect("create test dir"); + fs::write( + root.join("test/stale-command.test.mjs"), + "console.log('STALE_COMMAND_MUST_NOT_RUN');\n", + ) + .expect("write stale command fixture"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow command fixture"); + let run_id = "code-command-lock-revalidation-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "运行等待执行的定向测试", + run_id, + "agent-background-task", + "准备执行 command.exec", + vec!["运行定向测试".to_string()], + ) + .expect("start command runtime state"); + let action = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("运行定向测试".to_string()), + input: serde_json::json!({ + "program": "node", + "args": ["--test", "test/stale-command.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + }), + }; + let pending = pending_tool_action_for_test( + &root, + &state, + action.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + assert_eq!( + advance_project_revision_for_test( + &root, + "art-director", + "art-concurrent-command-revalidation-run", + "file.write", + ), + 1 + ); + + let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( + &root, + "code-prototype", + run_id, + &state.current_task, + &action, + Some(&pending.action_id), + Some(&pending), + ) + .await; + + assert_eq!(observation.status, "verification-failed"); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("项目 revision 已变化"))); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read unchanged command revision") + .revision, + 1 + ); + assert!(!root.join(".agent/logs/command.log").exists()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_command_exec_requires_reconciliation_after_audit_failure() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "命令审计失败项目").expect("project init"); + fs::create_dir_all(root.join("test")).expect("create test dir"); + fs::write( + root.join("test/audit.test.mjs"), + "console.log('COMMAND_EXEC_AUDIT_RAN');\n", + ) + .expect("write audit command fixture"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow command fixture"); + let command_log = root.join(".agent/logs/command.log"); + fs::create_dir_all(command_log.parent().expect("command log parent")) + .expect("create command log parent"); + fs::create_dir(&command_log).expect("replace command log with directory"); + let run_id = "code-command-audit-reconciliation-run"; + let action = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("运行命令并模拟执行后审计失败".to_string()), + input: serde_json::json!({ + "program": "node", + "args": ["--test", "test/audit.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + }), + }; + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "code-prototype", + run_id, + "验证命令执行后审计失败语义", + &action, + ) + .await; + + assert_eq!(observation.status, "needs-reconciliation"); + assert!(observation.summary.contains("执行结果不完整")); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("写入命令日志失败"))); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read audit failure revision") + .revision, + 1 + ); + let gate = read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", run_id) + .expect("read audit failure gate"); + assert_eq!(gate.last_verification_status.as_deref(), Some("failed")); + let records = read_agent_db_records_for_test(&root); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.command.exec" + && record["status"] == "execution-unknown" + && record["errorStage"] == "audit-log" + })); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_command_exec_recovery_keeps_started_revision_without_replay() { + 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::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow command fixture"); + let run_id = "code-command-started-recovery-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "恢复执行中断的命令", + run_id, + "agent-background-task", + "模拟 command.exec 已开始", + vec!["运行定向测试".to_string()], + ) + .expect("start command recovery state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("模拟执行中的命令".to_string()), + input: serde_json::json!({ + "program": "node", + "args": ["--test", "test/interrupted.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + }), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write executing command action"); + { + let _lock = acquire_project_write_lock(&root, "test.command.exec.started") + .expect("acquire command mutation lock"); + assert_eq!( + prepare_agent_runtime_project_mutation_locked( + &root, + "code-prototype", + run_id, + "command.exec", + ) + .expect("prepare command mutation"), + 1 + ); + let (revision, gate) = begin_agent_runtime_project_verification_locked( + &root, + "code-prototype", + run_id, + "command.exec", + ) + .expect("begin command verification"); + assert_eq!(revision.revision, 1); + assert_eq!(gate.last_verification_status.as_deref(), Some("running")); + } + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append executing command task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write executing command state"); + + resume_game_creator_agent_background_tasks_at(&root).expect("resume interrupted command"); + let runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read reconciled command runtime") + .state; + assert_eq!(runtime.status, "failed"); + assert_eq!(runtime.phase, "needs-reconciliation"); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read preserved command revision") + .revision, + 1 + ); + let gate = read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", run_id) + .expect("read preserved command gate"); + assert!(gate.requires_verification); + assert_eq!(gate.mutation_revision, Some(1)); + assert_eq!(gate.verified_revision, None); + assert_eq!(gate.last_verification_status.as_deref(), Some("running")); + assert!(!root.join(".agent/logs/command.log").exists()); + resume_game_creator_agent_background_tasks_at(&root) + .expect("repeat resume remains reconciliation"); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read command revision after repeat resume") + .revision, + 1 + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_command_exec_revision_accounting_only_counts_started_actions() { + let before_start = AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "verification-failed".to_string(), + summary: "旧确认已失效".to_string(), + detail: Some("项目 revision 已变化".to_string()), + }; + assert!(!agent_runtime_observation_advances_project_revision( + &before_start + )); + + let after_start = AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "verification-failed".to_string(), + summary: "命令启动后验证失败".to_string(), + detail: Some("revisionAdvanced=true · 源码发生漂移".to_string()), + }; + assert!(agent_runtime_observation_advances_project_revision( + &after_start + )); + assert!(is_agent_runtime_project_mutation_observation(&after_start)); + + let command_failed = AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "command-failed".to_string(), + summary: "测试退出码为 1".to_string(), + detail: None, + }; + assert!(agent_runtime_observation_advances_project_revision( + &command_failed + )); + assert!(is_agent_runtime_project_mutation_observation( + &command_failed + )); + + let needs_reconciliation = AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "needs-reconciliation".to_string(), + summary: "命令已返回但审计失败".to_string(), + detail: Some( + "revisionAdvanced=true · verificationEligible=true · Agent DB 写入失败".to_string(), + ), + }; + assert!(agent_runtime_observation_advances_project_revision( + &needs_reconciliation + )); + + let spoofed_eligibility = AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "ok".to_string(), + summary: "诊断命令已完成".to_string(), + detail: Some("verificationEligible=false · stdout: verificationEligible=true".to_string()), + }; + assert!(project_verification_completion_blocker(&[spoofed_eligibility]).is_some()); +} + +#[tokio::test] +async fn agent_runtime_command_exec_diagnostic_command_cannot_pass_verification_gate() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "命令验证资格项目").expect("project init"); + fs::create_dir_all(root.join("src")).expect("create cargo src"); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"command-verification-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .expect("write cargo manifest"); + fs::write(root.join("src/lib.rs"), "pub fn ready() -> bool { true }\n") + .expect("write cargo source"); + fs::write( + root.join("Cargo.lock"), + concat!( + "# This file is automatically @generated by Cargo.\n", + "# It is not intended for manual editing.\n", + "version = 4\n\n", + "[[package]]\n", + "name = \"command-verification-fixture\"\n", + "version = \"0.1.0\"\n", + ), + ) + .expect("write stable cargo lock"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow diagnostic command"); + let run_id = "code-command-diagnostic-run"; + let action = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("读取 Cargo 元数据".to_string()), + input: serde_json::json!({ + "program": "cargo", + "args": ["metadata"], + "cwd": ".", + "timeoutSeconds": 30 + }), + }; + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "code-prototype", + run_id, + "确认诊断命令不能替代验证", + &action, + ) + .await; + + assert_eq!(observation.status, "ok"); + assert!(observation.summary.contains("只作为诊断结果")); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("verificationEligible=false"))); + let gate = read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", run_id) + .expect("read diagnostic command gate"); + assert_eq!(gate.last_verification_status.as_deref(), Some("failed")); + assert_eq!(gate.verified_revision, None); + assert!(project_verification_completion_blocker_at( + &root, + "code-prototype", + run_id, + std::slice::from_ref(&observation), + ) + .is_some()); + assert!(read_agent_db_records_for_test(&root).iter().any(|record| { + record["recordType"] == "agent.runtime.command.exec" + && record["verificationEligible"] == false + })); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_command_exec_agent_db_audit_failure_keeps_gate_failed() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "命令数据库审计失败项目").expect("project init"); + fs::create_dir_all(root.join("test")).expect("create test dir"); + fs::write( + root.join("test/agent-db-audit.test.mjs"), + "console.log('AGENT_DB_AUDIT_COMMAND_RAN');\n", + ) + .expect("write command fixture"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow command fixture"); + let agent_db = root.join(".agent/agent.db"); + if agent_db.exists() { + fs::remove_file(&agent_db).expect("remove agent db file"); + } + fs::create_dir(&agent_db).expect("replace agent db with directory"); + let run_id = "code-command-agent-db-audit-run"; + let action = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("模拟 Agent DB 审计失败".to_string()), + input: serde_json::json!({ + "program": "node", + "args": ["--test", "test/agent-db-audit.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + }), + }; + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "code-prototype", + run_id, + "验证 Agent DB 审计失败时门禁关闭", + &action, + ) + .await; + + assert_eq!(observation.status, "needs-reconciliation"); + assert!(observation.summary.contains("执行审计无法完整落盘")); + let gate = read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", run_id) + .expect("read failed audit gate"); + assert_eq!(gate.last_verification_status.as_deref(), Some("failed")); + assert_eq!(gate.verified_revision, None); + assert!(root.join(".agent/logs/command.log").is_file()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_command_exec_rejects_changed_pending_action_identity_after_lock() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "命令动作身份复核项目").expect("project init"); + fs::create_dir_all(root.join("test")).expect("create test dir"); + fs::write( + root.join("test/original.test.mjs"), + "console.log('ORIGINAL_COMMAND');\n", + ) + .expect("write original command"); + fs::write( + root.join("test/changed.test.mjs"), + "console.log('CHANGED_COMMAND_MUST_NOT_RUN');\n", + ) + .expect("write changed command"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow command fixture"); + let run_id = "code-command-pending-identity-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "运行已确认的原始测试", + run_id, + "agent-background-task", + "准备执行原始命令", + vec!["运行原始测试".to_string()], + ) + .expect("start command runtime state"); + let original = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("运行原始测试".to_string()), + input: serde_json::json!({ + "program": "node", + "args": ["--test", "test/original.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + }), + }; + let pending = pending_tool_action_for_test( + &root, + &state, + original.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + let changed = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("替换为未确认测试".to_string()), + input: serde_json::json!({ + "program": "node", + "args": ["--test", "test/changed.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + }), + }; + + let wrong_id_observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( + &root, + "code-prototype", + run_id, + &state.current_task, + &original, + Some("command-action-wrong-id"), + Some(&pending), + ) + .await; + assert_eq!(wrong_id_observation.status, "verification-failed"); + assert!(wrong_id_observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("actionId 或动作指纹已变化"))); + + let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( + &root, + "code-prototype", + run_id, + &state.current_task, + &changed, + Some(&pending.action_id), + Some(&pending), + ) + .await; + + assert_eq!(observation.status, "verification-failed"); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("actionId 或动作指纹已变化"))); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read unchanged revision") + .revision, + 0 + ); + assert!(!root.join(".agent/logs/command.log").exists()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_command_exec_rechecks_deny_policy_after_project_lock() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "命令策略锁内复核项目").expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "运行已确认测试", + "code-command-policy-recheck-run", + "agent-background-task", + "准备执行测试", + vec!["运行测试".to_string()], + ) + .expect("runtime state"); + let action = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("运行测试".to_string()), + input: serde_json::json!({ + "program": "node", + "args": ["--test", "test/policy.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + }), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["command.exec".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny command while confirmed action waits for lock"); + + assert!(matches!( + game_creator_agent_runtime_tool_policy_block_after_lock( + &root, + "code-prototype", + "command.exec", + Some(&pending), + ), + Some(AgentRuntimeToolPolicyBlock::Denied(_)) + )); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_repairs_failed_verification_before_completing() { let root = unique_project_path(); @@ -12247,6 +13197,8 @@ fn agent_runtime_tool_plan_prompt_explains_named_verification_scripts_and_contex assert!(prompt.contains("file.write")); assert!(prompt.contains("project.restore")); assert!(prompt.contains("game.static_smoke")); + assert!(prompt.contains("command.exec")); + assert!(prompt.contains("每次真正启动 command.exec")); assert!(prompt.contains("空 actions")); assert!(prompt.contains("上下文压缩窗口")); assert!(prompt.contains("同一 run")); @@ -12734,10 +13686,10 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { }, ) .expect("allow interrupted action fixture"); - let target = root.join(if tool == "file.delete" { - "game/interrupted-delete-target.txt" - } else { - "game/notes.txt" + let target = root.join(match tool { + "file.delete" => "game/interrupted-delete-target.txt", + "command.exec" => ".agent/logs/command.log", + _ => "game/notes.txt", }); if tool == "file.delete" { fs::write(&target, "中断恢复不得自动删除\n").expect("write interrupted delete target"); @@ -12757,23 +13709,32 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { ) .expect("start runtime state"); state.loop_iteration = 1; - let action = if tool == "file.delete" { - AgentRuntimeToolAction { + let action = match tool { + "file.delete" => AgentRuntimeToolAction { tool: tool.to_string(), reason: Some("删除可能产生副作用的文件".to_string()), input: serde_json::json!({ "path": "game/interrupted-delete-target.txt" }), - } - } else { - AgentRuntimeToolAction { + }, + "command.exec" => AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("运行可能产生副作用的受控命令".to_string()), + input: serde_json::json!({ + "program": "node", + "args": ["--test", "test/interrupted.test.mjs"], + "cwd": ".", + "timeoutSeconds": 15 + }), + }, + _ => AgentRuntimeToolAction { tool: tool.to_string(), reason: Some("写入可能产生副作用的内容".to_string()), input: serde_json::json!({ "path": "game/notes.txt", "content": "不应被自动重放" }), - } + }, }; let mut pending = pending_tool_action_for_test( &root, @@ -12914,6 +13875,10 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { record["recordType"] == "agent.runtime.file.delete" && record["path"] == "game/interrupted-delete-target.txt" })); + } else if tool == "command.exec" { + assert!(!records + .iter() + .any(|record| record["recordType"] == "agent.runtime.command.exec")); } cancel_game_creator_agent_runtime_task_at(&root, "design-director", run_id) .expect("cancel reconciled task"); @@ -12928,6 +13893,11 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { "design-delete-executing-recovery-run", false, ); + assert_interrupted_action_is_not_replayed( + "command.exec", + "design-command-executing-recovery-run", + false, + ); } #[test] @@ -15413,7 +16383,7 @@ async fn background_agent_runtime_can_search_patch_and_read_in_sequence() { assert!(plan_request.contains("startLine")); assert!(plan_request.contains("expectedReplacements")); assert!(plan_request.contains("\"max_output_tokens\":4000")); - assert!(plan_request.contains("\"reasoning\":{\"effort\":\"low\"}")); + assert!(plan_request.contains("\"reasoning\":{\"effort\":\"high\"}")); let verification_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("verification llm request"); @@ -15642,7 +16612,7 @@ async fn background_agent_runtime_retries_empty_plan_and_final_responses() { } else { assert!(request.contains("\"max_tokens\":2400")); } - assert!(request.contains("\"reasoning_effort\":\"low\"")); + assert!(request.contains("\"reasoning_effort\":\"high\"")); } assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); @@ -17824,6 +18794,7 @@ async fn agent_loop_uses_per_agent_llm_overrides() { base_url: Some(planner_base_url), model: Some("planner-model".to_string()), api_kind: Some("openai_responses".to_string()), + reasoning_effort: None, stream: Some(false), request_timeout_ms: None, max_retries: None, @@ -17837,6 +18808,7 @@ async fn agent_loop_uses_per_agent_llm_overrides() { base_url: Some(generator_base_url), model: Some("generator-model".to_string()), api_kind: Some("openai_responses".to_string()), + reasoning_effort: None, stream: Some(false), request_timeout_ms: None, max_retries: None, @@ -17850,6 +18822,7 @@ async fn agent_loop_uses_per_agent_llm_overrides() { base_url: Some(art_base_url), model: Some("art-model".to_string()), api_kind: Some("openai_responses".to_string()), + reasoning_effort: None, stream: Some(false), request_timeout_ms: None, max_retries: None, @@ -18180,6 +19153,7 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { base_url: Some("https://global.example.test/v1".to_string()), model: Some("global-model".to_string()), api_kind: "openai_responses".to_string(), + reasoning_effort: "high".to_string(), stream: false, error: Some("Generator:缺少 API Key".to_string()), agents: vec![GameCreatorAgentLlmConfigStatus { @@ -18190,6 +19164,7 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { base_url: Some("https://generator.example.test/v1".to_string()), model: Some("generator-model".to_string()), api_kind: "openai_chat".to_string(), + reasoning_effort: "medium".to_string(), stream: true, error: Some("LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string()), }], @@ -18199,6 +19174,8 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { assert!(lines.contains("llm.agent.generator.error=LLM 未配置")); assert!(lines.contains("llm.agent.generator.stream=true")); + assert!(lines.contains("llm.reasoningEffort=high")); + assert!(lines.contains("llm.agent.generator.reasoningEffort=medium")); assert!(lines.contains("llm.error=Generator:缺少 API Key")); assert!(!lines.contains("sk-")); assert!(!lines.contains("secret")); @@ -18225,6 +19202,40 @@ fn llm_api_kind_parses_canonical_names() { assert!(parse_game_creator_llm_api_kind("legacy").is_err()); } +#[test] +fn llm_reasoning_effort_supports_provider_default_and_explicit_levels() { + assert_eq!(parse_game_creator_llm_reasoning_effort("default"), Ok(None)); + assert_eq!( + parse_game_creator_llm_reasoning_effort("low"), + Ok(Some(platform_llm::LlmResponseReasoningEffort::Low)) + ); + assert_eq!( + parse_game_creator_llm_reasoning_effort("medium"), + Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium)) + ); + assert_eq!( + parse_game_creator_llm_reasoning_effort("high"), + Ok(Some(platform_llm::LlmResponseReasoningEffort::High)) + ); + assert!(parse_game_creator_llm_reasoning_effort("maximum").is_err()); + + let mut llm = GameCreatorLlmConfig::default(); + llm.reasoning_effort = "default".to_string(); + let request = + apply_game_creator_llm_reasoning_effort(LlmRunRequest::single_turn("system", "user"), &llm) + .expect("provider default request"); + assert_eq!(request.response_reasoning_effort, None); + + llm.reasoning_effort = "high".to_string(); + let request = + apply_game_creator_llm_reasoning_effort(LlmRunRequest::single_turn("system", "user"), &llm) + .expect("high reasoning request"); + assert_eq!( + request.response_reasoning_effort, + Some(platform_llm::LlmResponseReasoningEffort::High) + ); +} + #[tokio::test] async fn agent_loop_writes_spec_findings_and_retries_generator() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 728c932d7..7687fab7b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -384,12 +384,22 @@ interface GameCreatorAgentRuntimeUpdateEvent { runtime: AgentRuntimeResult; } +const gameCreatorLlmReasoningEfforts = [ + 'default', + 'low', + 'medium', + 'high', +] as const; +type GameCreatorLlmReasoningEffort = + (typeof gameCreatorLlmReasoningEfforts)[number]; + interface GameCreatorLlmConfigStatus { configured: boolean; apiKeyPresent: boolean; baseUrl: string | null; model: string | null; apiKind: string; + reasoningEffort: GameCreatorLlmReasoningEffort; stream: boolean; error: string | null; agents?: GameCreatorAgentLlmConfigStatus[]; @@ -403,6 +413,7 @@ interface GameCreatorAgentLlmConfigStatus { baseUrl: string | null; model: string | null; apiKind: string; + reasoningEffort: GameCreatorLlmReasoningEffort; stream: boolean; error: string | null; } @@ -421,6 +432,7 @@ interface GameCreatorLlmConfig { baseUrl: string; model: string; apiKind: GameCreatorLlmApiKind; + reasoningEffort: GameCreatorLlmReasoningEffort; stream: boolean; requestTimeoutMs: number; maxRetries: number; @@ -1611,6 +1623,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1', apiKind: 'openai_responses', + reasoningEffort: 'high', stream: false, requestTimeoutMs: 180000, maxRetries: 0, @@ -1714,6 +1727,12 @@ function clampRuntimeConfigNumber(value: number, minimum: number) { : minimum; } +function isGameCreatorLlmReasoningEffort( + value: unknown, +): value is GameCreatorLlmReasoningEffort { + return gameCreatorLlmReasoningEfforts.some((effort) => effort === value); +} + function normalizeRuntimeAgentLlmConfig( config: GameCreatorAgentLlmConfig | undefined, ): GameCreatorAgentLlmConfig { @@ -1736,6 +1755,9 @@ function normalizeRuntimeAgentLlmConfig( ) { normalized.apiKind = config.apiKind; } + if (isGameCreatorLlmReasoningEffort(config.reasoningEffort)) { + normalized.reasoningEffort = config.reasoningEffort; + } if (typeof config.stream === 'boolean') { normalized.stream = config.stream; } @@ -1767,6 +1789,11 @@ function normalizeRuntimeConfigDraft( ].includes(config.llm.apiKind) ? config.llm.apiKind : defaultRuntimeConfigDraft.llm.apiKind; + const reasoningEffort = isGameCreatorLlmReasoningEffort( + config.llm.reasoningEffort, + ) + ? config.llm.reasoningEffort + : defaultRuntimeConfigDraft.llm.reasoningEffort; const agentLlm: Record = {}; for (const [agentId, agentConfig] of Object.entries(config.agentLlm ?? {})) { const normalized = normalizeRuntimeAgentLlmConfig(agentConfig); @@ -1779,6 +1806,7 @@ function normalizeRuntimeConfigDraft( llm: { ...config.llm, apiKind, + reasoningEffort, requestTimeoutMs: clampRuntimeConfigNumber( config.llm.requestTimeoutMs, 1000, @@ -2751,6 +2779,25 @@ function RuntimeConfigDialog({ + +