use super::*; #[test] fn static_smoke_finish_rejects_same_revision_entry_rewrite_before_credential_binding() { let root = unique_project_path(); init_local_game_project_at( &root, "project-static-smoke-toctou", "静态验证入口摘要时序测试", ) .expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, ) .expect("write initially valid game entry"); let run_id = "static-smoke-toctou-run"; let _lock = acquire_project_write_lock(&root, "test.static-smoke.toctou") .expect("acquire project write lock"); prepare_agent_runtime_project_mutation_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, "file.write", ) .expect("prepare project mutation"); let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, "game.static_smoke", ) .expect("begin static smoke"); fs::write( root.join("game/index.html"), "unverified replacement", ) .expect("replace entry without advancing revision"); let error = finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) .expect_err("replacement bytes must be smoke-validated before credential binding"); assert!(error.contains("game.static_smoke"), "{error}"); let gate = read_game_creator_agent_runtime_verification_gate( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) .expect("read failed static smoke gate"); assert_eq!(gate.verified_revision, None); assert_eq!(gate.static_smoke_verified_revision, None); assert_eq!(gate.static_smoke_verified_game_index_sha256, None); assert_eq!( gate.last_verification_status.as_deref(), Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED) ); drop(_lock); fs::remove_dir_all(root).ok(); } #[test] fn failed_autonomous_preview_invalidates_static_smoke_verification_gate() { let root = unique_project_path(); init_local_game_project_at(&root, "project-preview-gate", "试玩失败凭证失效测试") .expect("project init"); write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) .expect("write default collaboration policy"); let run_id = "preview-gate-failure-run"; fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, ) .expect("write smoke-valid game entry"); let revision = prepare_agent_runtime_project_mutation_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, "file.write", ) .expect("prepare project mutation"); let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, "game.static_smoke", ) .expect("begin static smoke"); finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) .expect("finish static smoke"); invalidate_agent_runtime_project_verification_after_preview_failure_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, revision, ) .expect("invalidate failed browser playtest"); let gate = read_game_creator_agent_runtime_verification_gate( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) .expect("read invalidated gate"); assert!(gate.requires_verification); assert_eq!(gate.mutation_revision, Some(revision)); assert_eq!(gate.verified_revision, None); assert_eq!( gate.last_verification_tool.as_deref(), Some("preview.validate") ); assert_eq!(gate.last_verification_status.as_deref(), Some("failed")); assert_eq!(gate.failed_playtest_revision, Some(revision)); // A later static smoke pass must not erase the durable interactive failure. let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, "game.static_smoke", ) .expect("begin static smoke after failed playtest"); finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) .expect("finish static smoke after failed playtest"); let gate = read_game_creator_agent_runtime_verification_gate( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) .expect("read gate after static smoke"); assert_eq!(gate.failed_playtest_revision, Some(revision)); fs::remove_dir_all(root).ok(); } #[test] fn agent_runtime_context_bundle_preserves_project_verification_gate_evidence() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "验证凭证恢复项目").expect("project init"); let mut state = start_game_creator_agent_runtime_task_at( &root, "design-director", "验证修改和凭证跨上下文恢复", "design-verification-context-run", "agent-background-task", "验证凭证恢复测试", vec!["保留当前 revision 验证凭证".to_string()], ) .expect("start verification context runtime"); advance_project_revision_for_test( &root, "design-director", "design-verification-context-run", "file.patch", ); persist_project_verification_for_test( &root, "design-director", "design-verification-context-run", "project.verify", true, ); let mut observations = vec![ verification_gate_observation("file.patch", "ok", "已更新 game/main.js"), verification_gate_observation("project.verify", "ok", "test 已通过"), ]; observations.extend((0..20).map(|index| { verification_gate_observation("file.read", "ok", &format!("已回读第 {index} 个文件片段")) })); let mut tracker = AgentRuntimeContextWindowTracker::default(); assert_eq!( tracker.complete_loop(1), AgentRuntimeContextCheckpoint::Continue ); let bundle = build_game_creator_agent_runtime_context_bundle( &root, &state, &state.current_task, &AgentRuntimeToolPlan::default(), &observations, 1, &tracker, ) .expect("build verification context bundle"); assert!(bundle .observations .iter() .any(|observation| observation.tool == "file.patch")); assert!(bundle .observations .iter() .any(|observation| observation.tool == "project.verify")); assert_eq!(bundle.verification_gate.mutation_revision, Some(1)); assert_eq!(bundle.verification_gate.verified_revision, Some(1)); write_game_creator_agent_runtime_context_bundle(&root, &bundle) .expect("write verification context bundle"); state.loop_iteration = 1; let mut loaded = read_game_creator_agent_runtime_context_bundle(&root, &state) .expect("read verification context bundle") .expect("verification context bundle exists"); assert!(project_verification_completion_blocker(&loaded.observations).is_none()); assert!(project_verification_completion_blocker_at( &root, "design-director", "design-verification-context-run", &loaded.observations, ) .is_none()); advance_project_revision_for_test(&root, "code-prototype", "code-run", "file.write"); loaded.observations.extend((0..20).map(|index| { verification_gate_observation( "file.read", "ok", &format!("恢复后回读第 {index} 个文件片段"), ) })); let retained = sanitize_game_creator_agent_runtime_context_observations_for_storage( &root, &loaded.observations, ); assert!(project_verification_completion_blocker(&retained).is_none()); assert!(project_verification_completion_blocker_at( &root, "design-director", "design-verification-context-run", &retained, ) .is_none()); fs::remove_dir_all(root).ok(); } #[tokio::test] async fn background_agent_runtime_project_verify_uses_independent_permission_policy() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let check_command = r#"node -e "process.stdout.write('SHOULD_NOT_RUN')""#; fs::write( root.join("package.json"), serde_json::to_string_pretty(&serde_json::json!({ "name": "project-verify-policy-fixture", "private": true, "scripts": { "check": check_command } })) .expect("serialize package json"), ) .expect("write package json"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { denied_commands: Vec::new(), confirm_commands: vec!["project.verify".to_string()], agent_policies: BTreeMap::new(), }, ) .expect("require verification confirmation"); let (sender, receiver) = mpsc::channel(); let plan_json = serde_json::json!({ "thinkingSummary": "尝试运行项目检查", "plan": ["运行 check"], "actions": [{ "tool": "project.verify", "reason": "测试确认策略", "input": { "script": "check", "expectedCommand": check_command, "timeoutSeconds": 15 } }], "response": "" }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], 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-project-verify-policy-run", ) .expect("start background task"); receiver .recv_timeout(Duration::from_secs(2)) .expect("plan llm request"); let runtime = wait_for_agent_runtime_confirmation(&root, "code-prototype"); assert_eq!(runtime.status, "waiting-for-confirmation"); assert!(runtime.observations.iter().any(|item| item.contains( "project.verify:waiting-for-confirmation · 项目权限策略要求用户确认:project.verify" ))); let pending = runtime.pending_tool_action.expect("pending verify action"); assert_eq!(pending.tool, "project.verify"); assert!(pending .input_summary .as_deref() .is_some_and(|summary| summary.contains("script=check"))); assert!(!root.join(".agent/logs/command.log").exists()); fs::remove_dir_all(root).ok(); } #[tokio::test] async fn background_agent_runtime_confirms_project_verify_and_replans_with_output() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let check_command = r#"node -e "process.stdout.write('PROJECT_VERIFY_CONFIRMED')""#; fs::write( root.join("package.json"), serde_json::to_string_pretty(&serde_json::json!({ "name": "project-verify-confirm-fixture", "private": true, "scripts": { "check": check_command } })) .expect("serialize package json"), ) .expect("write package json"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { denied_commands: Vec::new(), confirm_commands: vec!["project.verify".to_string(), "agent.resume".to_string()], agent_policies: BTreeMap::new(), }, ) .expect("require verification confirmation"); let (sender, receiver) = mpsc::channel(); let verify_plan = serde_json::json!({ "thinkingSummary": "修改完成后运行项目检查", "plan": ["运行 check", "根据真实输出收束"], "actions": [{ "tool": "project.verify", "reason": "确认当前修改通过项目检查", "input": { "script": "check", "expectedCommand": check_command, "timeoutSeconds": 15 } }], "response": "" }) .to_string(); let final_plan = serde_json::json!({ "thinkingSummary": "项目检查已经通过", "plan": [], "actions": [], "response": "已根据 project.verify 的真实输出确认 check 通过。" }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture(vec![verify_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" }} }} }}"# )); start_game_creator_agent_background_task_at( &root, "code-prototype", "运行项目检查并依据结果回复", "code-project-verify-confirm-run", ) .expect("start background task"); receiver .recv_timeout(Duration::from_secs(2)) .expect("initial plan request"); let waiting_runtime = wait_for_agent_runtime_confirmation(&root, "code-prototype"); let pending_action = waiting_runtime .pending_tool_action .as_ref() .expect("pending project verify action"); assert_eq!(pending_action.tool, "project.verify"); assert!(!root.join(".agent/logs/command.log").exists()); let confirmed = confirm_game_creator_agent_runtime_task( root.to_string_lossy().into_owned(), "code-prototype".to_string(), "code-project-verify-confirm-run".to_string(), pending_action.action_id.clone(), "允许执行项目 check".to_string(), ) .expect("confirm project verify"); assert_eq!(confirmed.state.status, "running"); let continued_request = receiver .recv_timeout(Duration::from_secs(4)) .expect("continued plan request"); assert!(continued_request.contains("project.verify")); assert!(continued_request.contains("check")); let runtime = wait_for_agent_runtime_idle(&root, "code-prototype"); assert_eq!(runtime.status, "idle"); assert_eq!( runtime.last_response.as_deref(), Some("已根据 project.verify 的真实输出确认 check 通过。") ); assert!(runtime .observations .iter() .any(|item| item.contains("project.verify:ok · check 已通过"))); let command_log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); assert!(command_log.contains("PROJECT_VERIFY_CONFIRMED")); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_confirmation.approved\"")); assert!(agent_db.contains("\"recordType\":\"agent.runtime.project.verify\"")); 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")); assert!(repair_request.contains("command-failed") || repair_request.contains("failed")); 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 background_agent_runtime_reads_long_command_output_without_leaking_lines_to_audit() { const ROOT_MARKER: &str = "ROOT_CAUSE_中段标记_Ω"; 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"); let mut script = "import test from 'node:test';\ntest('long output', () => {\n".to_string(); for line in 1..=240 { let payload = if line == 140 { ROOT_MARKER.to_string() } else { format!("TRACE_LINE_{line:03}_{}", "x".repeat(24)) }; script.push_str(&format!( " console.log({});\n", serde_json::to_string(&payload).expect("serialize output line") )); } script.push_str("});\n"); fs::write(root.join("test/long-output.test.mjs"), script).expect("write long output 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_plan = serde_json::json!({ "thinkingSummary": "先运行长输出定向测试", "plan": ["执行测试", "分页读取中部输出", "继续规划"], "actions": [{ "tool": "command.exec", "reason": "取得完整测试输出 sidecar", "input": { "program": "node", "args": ["--test", "test/long-output.test.mjs"], "cwd": ".", "timeoutSeconds": 15 } }], "response": "" }) .to_string(); let (request_sender, request_receiver) = mpsc::channel(); let (response_sender, response_receiver) = mpsc::channel(); let base_url = spawn_interactive_mock_llm_server_with_capture(3, request_sender, response_receiver); 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-output-read-background-run"; start_game_creator_agent_background_task_at( &root, "code-prototype", "从长命令输出中读取中部诊断信息", run_id, ) .expect("start long output background task"); request_receiver .recv_timeout(Duration::from_secs(2)) .expect("initial command plan request"); response_sender .send(command_plan) .expect("release command plan response"); let waiting = wait_for_agent_runtime_confirmation(&root, "code-prototype"); let source_action_id = if let Some(command_pending) = waiting.pending_tool_action.as_ref() { assert_eq!(command_pending.tool, "command.exec"); let action_id = command_pending.action_id.clone(); confirm_game_creator_agent_runtime_task( root.to_string_lossy().into_owned(), "code-prototype".to_string(), run_id.to_string(), action_id.clone(), "允许运行长输出测试".to_string(), ) .expect("confirm long output command"); action_id } else { read_agent_db_records_for_test(&root) .iter() .find(|record| { record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE && record["runId"] == run_id && record["tool"] == "command.exec" }) .and_then(|record| record["actionId"].as_str()) .map(str::to_string) .expect("auto-executed long output command receipt") }; let short_observation_request = request_receiver .recv_timeout(Duration::from_secs(5)) .expect("followup request after command exec"); assert!(short_observation_request.contains("outputRef")); assert!(short_observation_request.contains("totalLines")); assert!(short_observation_request.contains(&format!("sourceActionId={source_action_id}"))); assert!(!short_observation_request.contains("TRACE_LINE_001")); assert!(!short_observation_request.contains(ROOT_MARKER)); assert!(!short_observation_request.contains("TRACE_LINE_139")); let source_records = read_agent_db_records_for_test(&root); let source_receipt = source_records .iter() .find(|record| { record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE && record["actionId"] == source_action_id && record["tool"] == "command.exec" }) .expect("source command terminal receipt"); assert_eq!(source_receipt["status"], "ok"); let source_safe_detail: Value = serde_json::from_str( source_receipt["safeDetail"] .as_str() .expect("source command safe detail"), ) .expect("parse source command safe detail"); assert!( source_safe_detail["totalLines"] .as_u64() .unwrap_or_default() >= 220 ); let output_ref = source_safe_detail["outputRef"] .as_str() .expect("source output ref"); let output_sidecar = fs::read_to_string(root.join(output_ref)).expect("read output sidecar"); assert!(output_sidecar.contains(ROOT_MARKER)); let output_read_plan = serde_json::json!({ "thinkingSummary": "短 observation 不含中部根因,分页读取源输出", "plan": ["读取第 100-199 行", "依据中部标记继续规划"], "actions": [{ "tool": "command.output_read", "reason": "读取短 observation 之外的中部输出", "input": { "actionId": source_action_id, "startLine": 100, "maxLines": 100 } }], "response": "" }) .to_string(); response_sender .send(output_read_plan) .expect("release output read plan response"); let output_read_request = request_receiver .recv_timeout(Duration::from_secs(5)) .expect("followup request after output read"); assert!(output_read_request.contains(ROOT_MARKER)); assert!(output_read_request.contains("TRACE_LINE_139")); assert!(output_read_request.contains("nextLine")); assert!(output_read_request.contains("totalLines")); let bundle_path = game_creator_agent_runtime_context_bundle_path(&root, "code-prototype", run_id); let bundle: AgentRuntimeContextBundle = serde_json::from_str( &fs::read_to_string(bundle_path).expect("read output read context bundle"), ) .expect("parse output read context bundle"); let output_read_observation = bundle .observations .iter() .find(|observation| observation.tool == "command.output_read") .expect("output read observation in context bundle"); let source_command_observation = bundle .observations .iter() .find(|observation| observation.tool == "command.exec") .expect("source command observation in context bundle"); assert!(source_command_observation .detail .as_deref() .is_some_and(|detail| detail.contains(&format!("sourceActionId={source_action_id}")))); assert_eq!(output_read_observation.status, "ok"); let output_page: Value = serde_json::from_str( output_read_observation .detail .as_deref() .expect("output read page detail"), ) .expect("parse output read page"); assert!(output_page["lines"] .as_str() .is_some_and(|lines| lines.contains(ROOT_MARKER) && lines.contains("TRACE_LINE_139"))); assert_eq!(output_page["nextLine"], 200); assert!(output_page["totalLines"].as_u64().unwrap_or_default() >= 220); let event_log = fs::read_to_string(game_creator_agent_runtime_event_path( &root, "code-prototype", )) .expect("read runtime events"); assert!(!event_log.contains(ROOT_MARKER)); assert!(!event_log.contains("TRACE_LINE_139")); let records = read_agent_db_records_for_test(&root); let output_read_audits = records .iter() .filter(|record| record["recordType"] == "agent.runtime.command.output_read") .collect::>(); assert_eq!(output_read_audits.len(), 1); assert!(output_read_audits[0].get("lines").is_none()); let output_read_receipts = records .iter() .filter(|record| { record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE && record["tool"] == "command.output_read" }) .collect::>(); assert_eq!(output_read_receipts.len(), 1); let receipt_safe_detail: Value = serde_json::from_str( output_read_receipts[0]["safeDetail"] .as_str() .expect("output read receipt safe detail"), ) .expect("parse output read receipt safe detail"); assert!(receipt_safe_detail.get("lines").is_none()); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db"); assert!(!agent_db.contains(ROOT_MARKER)); assert!(!agent_db.contains("TRACE_LINE_139")); assert_eq!( records .iter() .filter(|record| record["recordType"] == "agent.runtime.command.exec") .count(), 1 ); let command_log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("read command log"); assert_eq!(command_log.matches(ROOT_MARKER).count(), 1); response_sender .send(final_tool_plan_response( "已通过 command.output_read 读取中部 Unicode 根因并继续完成规划。", )) .expect("release final response"); 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("已通过 command.output_read 读取中部 Unicode 根因并继续完成规划。") ); fs::remove_dir_all(root).ok(); } #[tokio::test] async fn command_exec_output_sidecar_failure_runs_once_and_requires_reconciliation() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "命令输出 sidecar 失败项目") .expect("project init"); fs::create_dir_all(root.join("test")).expect("create test dir"); fs::write( root.join("test/output-sidecar-failure.test.mjs"), "import fs from 'node:fs';\nfs.appendFileSync('command-execution-count.txt', 'run\\n');\n", ) .expect("write sidecar failure 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-output-sidecar-failure-run"; let mut state = start_game_creator_agent_runtime_task_at( &root, "code-prototype", "验证输出 sidecar 失败不重放命令", run_id, "agent-background-task", "准备执行命令", vec!["执行一次并保持失败门禁".to_string()], ) .expect("start sidecar failure runtime"); state.loop_iteration = 1; let action = AgentRuntimeToolAction { tool: "command.exec".to_string(), reason: Some("执行后模拟 transcript sidecar 写入失败".to_string()), input: serde_json::json!({ "program": "node", "args": ["--test", "test/output-sidecar-failure.test.mjs"], "cwd": ".", "timeoutSeconds": 15 }), }; let mut pending = pending_tool_action_for_test( &root, &state, action.clone(), 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 command pending"); 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 command task"); write_game_creator_agent_runtime_state(&root, &state).expect("write command state"); fs::write(root.join(".agent/runtime/command-outputs"), b"blocked") .expect("block command output directory"); 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, "needs-reconciliation"); assert!(observation .detail .as_deref() .is_some_and(|detail| detail.contains("输出 sidecar"))); assert_eq!( fs::read_to_string(root.join("command-execution-count.txt")) .expect("read command execution count"), "run\n" ); assert!(!root.join(".agent/logs/command.log").exists()); assert_eq!( read_game_creator_agent_runtime_project_revision(&root) .expect("read command revision") .revision, 1 ); let gate = read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", run_id) .expect("read command gate"); assert_eq!(gate.last_verification_status.as_deref(), Some("failed")); let records = read_agent_db_records_for_test(&root); assert_eq!( records .iter() .filter(|record| record["recordType"] == "agent.runtime.command.exec") .count(), 1 ); assert!(records.iter().any(|record| { record["recordType"] == "agent.runtime.command.exec" && record["status"] == "execution-unknown" && record["errorStage"] == "output-sidecar" })); let persisted = read_game_creator_agent_runtime_pending_tool_action(&root, "code-prototype", run_id) .expect("read command pending"); assert_eq!( persisted.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING ); fs::remove_dir_all(root).ok(); } #[tokio::test] async fn command_output_read_allows_same_agent_history_and_rejects_cross_agent_action_id() { const HISTORY_MARKER: &str = "HISTORICAL_OUTPUT_历史读取"; 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/history-output.test.mjs"), format!("console.log({HISTORY_MARKER:?});\n"), ) .expect("write history command fixture"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { denied_commands: Vec::new(), confirm_commands: Vec::new(), agent_policies: BTreeMap::new(), }, ) .expect("allow command output tools"); let source_run_id = "code-command-output-history-source-run"; let source_plan = serde_json::json!({ "thinkingSummary": "生成可供后续 run 读取的命令输出", "plan": ["运行定向测试"], "actions": [{ "tool": "command.exec", "reason": "生成历史输出 sidecar", "input": { "program": "node", "args": ["--test", "test/history-output.test.mjs"], "cwd": ".", "timeoutSeconds": 15 } }], "response": "" }) .to_string(); let base_url = spawn_mock_llm_server_responses(vec![ source_plan, final_tool_plan_response("历史命令输出已持久化。"), ]); 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", "生成历史命令输出", source_run_id, ) .expect("start source command run"); let source_runtime = wait_for_agent_runtime_idle(&root, "code-prototype"); assert!(matches!( source_runtime.phase.as_str(), "completed" | "planning" )); let source_records = read_agent_db_records_for_test(&root); let source_receipt = source_records .iter() .find(|record| { record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE && record["runId"] == source_run_id && record["tool"] == "command.exec" }) .expect("source command receipt"); let source_action_id = source_receipt["actionId"] .as_str() .expect("source action id") .to_string(); let mut same_agent_state = start_game_creator_agent_runtime_task_at( &root, "code-prototype", "读取上一 run 的命令输出", "code-command-output-history-reader-run", "agent-background-task", "准备读取历史输出", vec!["读取历史 sidecar".to_string()], ) .expect("start same agent reader state"); same_agent_state.loop_iteration = 1; let same_agent_action = AgentRuntimeToolAction { tool: "command.output_read".to_string(), reason: Some("读取同 Agent 历史 run 输出".to_string()), input: serde_json::json!({ "actionId": source_action_id, "startLine": 1, "maxLines": 20 }), }; let mut same_agent_pending = pending_tool_action_for_test( &root, &same_agent_state, same_agent_action.clone(), AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, None, ); same_agent_pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); write_game_creator_agent_runtime_pending_tool_action(&root, &same_agent_pending) .expect("write same agent output read pending"); same_agent_state.status = "running".to_string(); same_agent_state.phase = "action".to_string(); same_agent_state.pending_tool_action = Some(same_agent_pending.summary()); append_game_creator_agent_runtime_task(&root, &same_agent_state) .expect("append same agent reader task"); write_game_creator_agent_runtime_state(&root, &same_agent_state) .expect("write same agent reader state"); let same_agent_observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( &root, "code-prototype", &same_agent_state.run_id, &same_agent_state.current_task, &same_agent_action, Some(&same_agent_pending.action_id), Some(&same_agent_pending), ) .await; assert_eq!(same_agent_observation.status, "ok"); let same_agent_page: Value = serde_json::from_str( same_agent_observation .detail .as_deref() .expect("same agent output page"), ) .expect("parse same agent output page"); assert_eq!(same_agent_page["sourceRunId"], source_run_id); assert!(same_agent_page["lines"] .as_str() .is_some_and(|lines| lines.contains(HISTORY_MARKER))); let mut cross_agent_state = start_game_creator_agent_runtime_task_at( &root, "art-director", "尝试读取其他 Agent 的命令输出", "art-command-output-cross-agent-run", "agent-background-task", "准备验证跨 Agent 门禁", vec!["拒绝跨 Agent actionId".to_string()], ) .expect("start cross agent reader state"); cross_agent_state.loop_iteration = 1; let cross_agent_action = AgentRuntimeToolAction { tool: "command.output_read".to_string(), reason: Some("验证跨 Agent actionId 不可读".to_string()), input: serde_json::json!({ "actionId": source_action_id, "startLine": 1, "maxLines": 20 }), }; let mut cross_agent_pending = pending_tool_action_for_test( &root, &cross_agent_state, cross_agent_action.clone(), AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, None, ); cross_agent_pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); write_game_creator_agent_runtime_pending_tool_action(&root, &cross_agent_pending) .expect("write cross agent output read pending"); cross_agent_state.status = "running".to_string(); cross_agent_state.phase = "action".to_string(); cross_agent_state.pending_tool_action = Some(cross_agent_pending.summary()); append_game_creator_agent_runtime_task(&root, &cross_agent_state) .expect("append cross agent reader task"); write_game_creator_agent_runtime_state(&root, &cross_agent_state) .expect("write cross agent reader state"); let cross_agent_observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( &root, "art-director", &cross_agent_state.run_id, &cross_agent_state.current_task, &cross_agent_action, Some(&cross_agent_pending.action_id), Some(&cross_agent_pending), ) .await; assert_eq!(cross_agent_observation.status, "failed"); assert_eq!( cross_agent_observation.summary, "command.output_read 无法验证源命令身份" ); assert!(cross_agent_observation .detail .as_deref() .is_some_and(|detail| detail.contains("未找到当前 Agent 的源动作回执"))); fs::remove_dir_all(root).ok(); } #[test] fn isolated_child_command_output_read_rechecks_template_deny_policy_after_project_lock() { use platform_agent::game_creation::{ GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentJoinMode, GameCreationIsolatedAgentSpawnRequest, }; let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "隔离子 Agent 输出策略复核项目") .expect("project init"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { denied_commands: Vec::new(), confirm_commands: Vec::new(), agent_policies: BTreeMap::new(), }, ) .expect("allow output read before lock wait"); let request = GameCreationIsolatedAgentSpawnRequest { children: vec![GameCreationIsolatedAgentChildSpec { template_agent_id: "code-prototype".to_string(), task: "读取受控命令输出".to_string(), acceptance_criteria: vec!["只读取本实例获准输出".to_string()], expected_artifacts: vec!["game/feature-a/output.txt".to_string()], write_scopes: vec!["game/feature-a/**".to_string()], }], join_mode: GameCreationIsolatedAgentJoinMode::All, }; let group = create_or_read_isolated_group_at( &root, "design-director", "isolated-output-policy-parent-run", "isolated-output-policy-parent-session", "isolated-output-policy-parent-action", &request, ) .expect("create isolated output policy group"); let instance = resolve_isolated_agent_instance_at(&root, &group.instance_ids[0]) .expect("resolve isolated output reader"); ensure_agent_conversation_session_at( &root, &instance.instance_id, &instance.session_id, "隔离输出读取", ) .expect("ensure isolated reader session"); let mut state = start_game_creator_agent_runtime_task_for_session_at( &root, &instance.instance_id, Some(&instance.session_id), &instance.task, &instance.run_id, AGENT_RUNTIME_ISOLATED_CHILD_SOURCE, "等待项目锁后读取命令输出", vec!["锁内复核模板策略".to_string()], ) .expect("start isolated output reader state"); state.loop_iteration = 1; let action = AgentRuntimeToolAction { tool: "command.output_read".to_string(), reason: Some("验证模板 deny 在锁内生效".to_string()), input: serde_json::json!({ "actionId": "action-000000000000000000000001", "startLine": 1, "maxLines": 1 }), }; let mut pending = pending_tool_action_for_test( &root, &state, action.clone(), 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 isolated output read pending"); 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 isolated output reader task"); write_game_creator_agent_runtime_state(&root, &state) .expect("write isolated output reader state"); let project_lock = acquire_project_write_lock(&root, "test.isolated-output-policy-writer") .expect("acquire project writer lock"); let thread_root = root.clone(); let thread_state = state.clone(); let thread_action = action.clone(); let thread_pending = pending.clone(); let (started_sender, started_receiver) = mpsc::channel(); let (finished_sender, finished_receiver) = mpsc::channel(); let reader = std::thread::spawn(move || { started_sender.send(()).expect("signal child reader start"); let observation = tauri::async_runtime::block_on( execute_game_creator_agent_runtime_tool_action_with_pending_action( &thread_root, &thread_state.agent_id, &thread_state.run_id, &thread_state.current_task, &thread_action, Some(&thread_pending.action_id), Some(&thread_pending), ), ); finished_sender .send(()) .expect("signal child reader completion"); observation }); started_receiver .recv_timeout(Duration::from_secs(1)) .expect("isolated output reader starts"); assert!( finished_receiver .recv_timeout(Duration::from_millis(80)) .is_err(), "child command.output_read must wait for the project consistency lock" ); let mut agent_policies = BTreeMap::new(); agent_policies.insert( "code-prototype".to_string(), ProjectAgentPermissionPolicy { denied_commands: vec!["command.output_read".to_string()], confirm_commands: Vec::new(), }, ); write_project_permission_policy_at( &root, ProjectPermissionPolicy { denied_commands: Vec::new(), confirm_commands: Vec::new(), agent_policies, }, ) .expect("deny output read through child template policy while locked"); drop(project_lock); let observation = reader.join().expect("join isolated output reader"); assert_eq!(observation.status, "blocked"); assert_eq!( observation.summary, "Agent 权限策略拒绝执行:code-prototype / command.output_read" ); assert!(!read_agent_db_records_for_test(&root) .iter() .any(|record| record["recordType"] == "agent.runtime.command.output_read")); 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(); } #[test] fn project_verification_gate_scopes_credential_to_exact_agent_run_and_revision() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "验证凭证隔离项目").expect("project init"); assert_eq!( advance_project_revision_for_test(&root, "agent-a", "run-a", "file.write"), 1 ); persist_project_verification_for_test(&root, "agent-b", "run-b", "project.verify", true); assert!(project_verification_completion_blocker_at(&root, "agent-a", "run-a", &[]).is_some()); persist_project_verification_for_test(&root, "agent-a", "run-a", "project.verify", true); assert!(project_verification_completion_blocker_at(&root, "agent-a", "run-a", &[]).is_none()); assert_eq!( advance_project_revision_for_test(&root, "agent-b", "run-b", "file.patch"), 2 ); persist_project_verification_for_test(&root, "agent-b", "run-b", "project.verify", true); let agent_a_gate = read_game_creator_agent_runtime_verification_gate(&root, "agent-a", "run-a") .expect("read agent A gate"); let agent_b_gate = read_game_creator_agent_runtime_verification_gate(&root, "agent-b", "run-b") .expect("read agent B gate"); assert_eq!(agent_a_gate.verified_revision, Some(1)); assert_eq!(agent_b_gate.verified_revision, Some(2)); assert!(project_verification_completion_blocker_at(&root, "agent-a", "run-a", &[]).is_none()); assert!(project_verification_completion_blocker_at(&root, "agent-b", "run-b", &[]).is_none()); fs::remove_dir_all(root).ok(); } #[test] fn project_verification_gate_allows_read_only_run_at_nonzero_revision() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "只读验证门禁项目").expect("project init"); advance_project_revision_for_test(&root, "writer", "writer-run", "file.write"); let observations = vec![verification_gate_observation( "file.read", "ok", "已读取 package.json", )]; assert!(project_verification_completion_blocker_at( &root, "reader", "reader-run", &observations, ) .is_none()); assert!( !game_creator_agent_runtime_verification_gate_path(&root, "reader", "reader-run").exists() ); assert_eq!( read_game_creator_agent_runtime_project_revision(&root) .expect("read revision") .revision, 1 ); assert!(game_creator_agent_runtime_project_revision_path(&root).is_file()); fs::remove_dir_all(root).ok(); } #[test] fn failed_project_verification_clears_previous_run_credential() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "失败验证清凭证项目").expect("project init"); advance_project_revision_for_test(&root, "agent-a", "run-a", "file.patch"); persist_project_verification_for_test(&root, "agent-a", "run-a", "project.verify", true); assert_eq!( read_game_creator_agent_runtime_verification_gate(&root, "agent-a", "run-a") .expect("read passed gate") .verified_revision, Some(1) ); persist_project_verification_for_test(&root, "agent-a", "run-a", "project.verify", false); let gate = read_game_creator_agent_runtime_verification_gate(&root, "agent-a", "run-a") .expect("read failed gate"); assert_eq!(gate.verified_revision, None); assert_eq!( gate.last_verification_status.as_deref(), Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED) ); assert!(project_verification_completion_blocker_at(&root, "agent-a", "run-a", &[]).is_some()); fs::remove_dir_all(root).ok(); } #[tokio::test] async fn invalid_project_verification_preserves_previous_run_credential() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "无效验证保留凭证项目").expect("project init"); advance_project_revision_for_test(&root, "agent-a", "run-a", "file.patch"); persist_project_verification_for_test(&root, "agent-a", "run-a", "project.verify", true); let observation = observe_agent_runtime_project_verify( &root, "agent-a", "run-a", Some("action-invalid-verify"), "fingerprint-invalid-verify", &serde_json::json!({ "script": "check" }), ) .await; assert_eq!(observation.status, "failed"); let gate = read_game_creator_agent_runtime_verification_gate(&root, "agent-a", "run-a") .expect("read preserved gate"); assert_eq!(gate.verified_revision, Some(1)); assert_eq!(gate.last_verification_status.as_deref(), Some("passed")); assert_eq!( read_game_creator_agent_runtime_project_revision(&root) .expect("read revision") .revision, 1 ); fs::remove_dir_all(root).ok(); } #[tokio::test] async fn relaxed_autonomous_validation_tools_skip_without_side_effects() { let root = unique_project_path(); init_local_game_project_at(&root, "relaxed-validation-skip", "自主构建跳过验证工具测试") .expect("project init"); let run_id = "relaxed-validation-skip-run"; bind_game_creator_agent_runtime_run_profile_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), None, ) .expect("bind relaxed autonomous run profile"); let agent_db_path = root.join(".agent/agent.db"); let agent_db_before = fs::read(&agent_db_path).expect("read initial agent db"); let verification_gate_path = game_creator_agent_runtime_verification_gate_path( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ); assert!(!verification_gate_path.exists()); let project_verify = observe_agent_runtime_project_verify( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, None, "relaxed-project-verify", &serde_json::json!({}), ) .await; let limited_command = observe_agent_runtime_limited_command( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, &serde_json::json!({}), ); let preview_start = observe_agent_runtime_preview_start( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ); let preview_validate = observe_agent_runtime_preview_validate( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, None, "relaxed-preview-validate", &serde_json::json!({}), ) .await; for observation in [ &project_verify, &limited_command, &preview_start, &preview_validate, ] { assert_eq!(observation.status, "ok", "{observation:?}"); assert!(observation.summary.contains("跳过"), "{observation:?}"); assert!(observation .detail .as_deref() .is_some_and(|detail| detail.contains("relaxedAutonomous=true"))); } assert_eq!( fs::read(&agent_db_path).expect("read unchanged agent db"), agent_db_before ); assert!(!verification_gate_path.exists()); assert!(!root.join(".agent/logs/command.log").exists()); fs::remove_dir_all(root).ok(); } #[cfg(unix)] #[tokio::test] async fn executed_project_verification_audit_failure_requires_reconciliation() { use std::os::unix::fs::symlink; let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "验证审计失败项目").expect("project init"); let check_command = r#"node -e "require('fs').writeFileSync('verify-ran.txt','once')""#; fs::write( root.join("package.json"), serde_json::to_string_pretty(&serde_json::json!({ "name": "verify-audit-failure", "private": true, "scripts": { "check": check_command } })) .expect("serialize package json"), ) .expect("write package json"); fs::remove_file(root.join(".agent/agent.db")).expect("remove initial agent db"); let outside = root.parent().expect("project parent").join(format!( "verify-audit-outside-{}", TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed) )); fs::write(&outside, "outside").expect("write outside agent db target"); symlink(&outside, root.join(".agent/agent.db")).expect("symlink agent db"); let observation = observe_agent_runtime_project_verify( &root, "agent-a", "run-a", Some("action-verify-audit-failure"), "fingerprint-verify-audit-failure", &serde_json::json!({ "script": "check", "expectedCommand": check_command, "timeoutSeconds": 15 }), ) .await; assert_eq!( observation.status, AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION ); assert_eq!( fs::read_to_string(root.join("verify-ran.txt")).expect("verification marker"), "once" ); let gate = read_game_creator_agent_runtime_verification_gate(&root, "agent-a", "run-a") .expect("read failed gate"); assert_eq!(gate.verified_revision, None); assert_eq!(gate.last_verification_status.as_deref(), Some("failed")); fs::remove_file(root.join(".agent/agent.db")).ok(); fs::remove_file(outside).ok(); fs::remove_dir_all(root).ok(); } #[test] fn project_verification_gate_accepts_only_exact_game_static_smoke() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "精确静态验证项目").expect("project init"); advance_project_revision_for_test(&root, "playtest", "playtest-run", "file.write"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, ) .expect("write playable game"); let near_match = observe_agent_runtime_limited_command( &root, "playtest", "playtest-run", &serde_json::json!({ "commandId": "game.static_smoke.backup" }), ); assert_eq!(near_match.status, "failed"); let observations = vec![ verification_gate_observation("file.write", "ok", "已写入 game/index.html"), verification_gate_observation( "command.run_limited", "ok", "game.static_smoke backup 已完成", ), ]; assert!(project_verification_completion_blocker(&observations).is_some()); assert_eq!( read_game_creator_agent_runtime_verification_gate(&root, "playtest", "playtest-run") .expect("read gate before exact smoke") .verified_revision, None ); let exact = observe_agent_runtime_limited_command( &root, "playtest", "playtest-run", &serde_json::json!({ "commandId": "game.static_smoke" }), ); assert_eq!(exact.status, "ok", "{}", exact.summary); let gate = read_game_creator_agent_runtime_verification_gate(&root, "playtest", "playtest-run") .expect("read exact smoke gate"); assert_eq!( gate.last_verification_tool.as_deref(), Some("game.static_smoke") ); assert_eq!(gate.verified_revision, Some(1)); assert!(project_verification_completion_blocker_at( &root, "playtest", "playtest-run", &[exact], ) .is_none()); fs::remove_dir_all(root).ok(); } #[test] fn successful_canvas_generation_verifies_prior_visual_replacement() { let observations = vec![ verification_gate_observation("file.delete", "ok", "已删除 assets/ui-prototype.png"), verification_gate_observation( "canvas.asset_generate", "ok", "已生成美术素材:assets/ui-prototype.png", ), ]; assert!(project_verification_completion_blocker(&observations).is_none()); } #[test] fn project_verification_sidecar_failure_blocks_completion() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "验证门禁失败关闭项目").expect("project init"); let mut contradictory = read_game_creator_agent_runtime_verification_gate(&root, "agent-a", "run-a") .expect("read default gate"); contradictory.mutation_revision = Some(1); contradictory.last_mutation_tool = Some("file.write".to_string()); assert!( write_game_creator_agent_runtime_verification_gate(&root, &contradictory) .expect_err("requiresVerification=false cannot retain mutation evidence") .contains("requiresVerification=false") ); let gate_path = game_creator_agent_runtime_verification_gate_path(&root, "agent-a", "run-a"); fs::create_dir_all(&gate_path).expect("replace verification sidecar with directory"); let error = { let _lock = acquire_project_write_lock(&root, "test.sidecar.failure") .expect("acquire sidecar failure lock"); prepare_agent_runtime_project_mutation_locked(&root, "agent-a", "run-a", "file.write") .expect_err("invalid gate sidecar must stop mutation") }; assert!(error.contains("普通文件")); let blocker = project_verification_completion_blocker_at(&root, "agent-a", "run-a", &[]) .expect("unreadable sidecar must fail completion closed"); assert!(blocker.summary.contains("verification gate")); fs::remove_dir_all(root).ok(); } #[tokio::test] async fn agent_runtime_file_delete_removes_file_and_advances_verification_gate() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let target = root.join("game/obsolete-notes.txt"); fs::write(&target, "这份旧笔记应被删除\n").expect("write delete target"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { denied_commands: Vec::new(), confirm_commands: Vec::new(), agent_policies: BTreeMap::new(), }, ) .expect("allow file delete"); let observation = execute_agent_runtime_file_delete_for_test( &root, "file-delete-success-run", Some("game/obsolete-notes.txt"), ) .await; assert_eq!(observation.tool, "file.delete"); assert_eq!(observation.status, "ok"); assert_eq!(observation.summary, "已删除 game/obsolete-notes.txt"); assert_eq!(observation.detail.as_deref(), Some("deleted=true")); assert!(!target.exists()); let delete_records = read_agent_db_records_for_test(&root) .into_iter() .filter(|record| record["recordType"] == "agent.runtime.file.delete") .collect::>(); assert_eq!(delete_records.len(), 1); assert_eq!(delete_records[0]["agentId"], "design-director"); assert_eq!(delete_records[0]["path"], "game/obsolete-notes.txt"); assert_eq!(delete_records[0]["deleted"], true); assert_eq!( read_game_creator_agent_runtime_project_revision(&root) .expect("read delete revision") .revision, 1 ); let gate = read_game_creator_agent_runtime_verification_gate( &root, "design-director", "file-delete-success-run", ) .expect("read delete verification gate"); assert!(gate.requires_verification); assert_eq!(gate.mutation_revision, Some(1)); assert_eq!(gate.verified_revision, None); assert_eq!(gate.last_mutation_tool.as_deref(), Some("file.delete")); assert!(project_verification_completion_blocker_at( &root, "design-director", "file-delete-success-run", std::slice::from_ref(&observation), ) .is_some()); fs::remove_dir_all(root).ok(); } #[test] fn agent_runtime_project_verify_summary_hashes_command_without_head_or_tail() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "验证命令公开摘要项目").expect("project init"); let command = format!( "curl https://user:password@verify.example/private --password SUPER_SECRET --data GOAL_VERIFY_CANARY {}", root.join("private/verify-target").display() ); let action = AgentRuntimeToolAction { tool: "project.verify".to_string(), reason: Some("执行项目验证".to_string()), input: serde_json::json!({ "script": "test", "expectedCommand": command, "timeoutSeconds": 120 }), }; let summary = agent_runtime_tool_action_input_summary(&root, &action).expect("verify input summary"); assert!(summary.contains("script=test")); assert!(summary.contains("expectedCommandSha256=")); assert!(summary.contains("expectedCommandChars=")); assert!(!summary.contains("head=")); assert!(!summary.contains("tail=")); for forbidden in [ "verify.example", "password", "SUPER_SECRET", "GOAL_VERIFY_CANARY", root.to_string_lossy().as_ref(), ] { assert!( !summary.contains(forbidden), "verify summary leaked {forbidden}" ); } fs::remove_dir_all(root).ok(); } #[test] fn limited_local_command_runs_static_game_smoke_and_writes_log() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, ) .expect("write playable game html"); let result = run_limited_local_command_at(&root, "game.static_smoke").expect("static smoke"); assert_eq!(result.command_id, "game.static_smoke"); assert_eq!(result.status, "completed", "{}", result.output); assert!(result.output.contains("game/index.html")); assert_eq!(result.log_path, ".agent/logs/command.log"); assert!(!result.output.contains(root.to_string_lossy().as_ref())); let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); assert!(log.contains("command.run_limited game.static_smoke")); assert!(!log.contains(root.to_string_lossy().as_ref())); let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); assert_eq!(manifest["commandRuns"][0]["commandId"], "game.static_smoke"); assert_eq!(manifest["commandRuns"][0]["status"], "completed"); assert_eq!( manifest["commandRuns"][0]["logPath"], ".agent/logs/command.log" ); assert!(!manifest["commandRuns"][0]["output"] .as_str() .unwrap_or_default() .contains(root.to_string_lossy().as_ref())); fs::remove_dir_all(root).ok(); } #[test] fn project_verification_resolves_npm_script_and_rejects_command_drift() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "代码验证项目").expect("project init"); let check_command = "node scripts/check.mjs"; fs::write( root.join("package.json"), serde_json::to_string_pretty(&serde_json::json!({ "name": "verify-contract-fixture", "private": true, "packageManager": "npm@10.0.0", "scripts": { "check": check_command, "check:ci": "node scripts/check-ci.mjs", "test:unit": "node --test", "lint:strict": "eslint . --max-warnings 0", "typecheck:app": "tsc --noEmit", "build:preview": "vite build --mode preview", "verify:contracts": "node scripts/verify-contracts.mjs", "validate:schema": "node scripts/validate-schema.mjs", "deploy": "node scripts/deploy.mjs" } })) .expect("serialize package json"), ) .expect("write package json"); let spec = resolve_project_verification_spec_at(&root, "check", check_command, 30) .expect("resolve check script"); assert_eq!(spec.script, "check"); assert_eq!(spec.expected_command, check_command); assert_eq!(spec.program, project_verification_npm_program()); assert_eq!( spec.arguments, vec!["run", "--silent", "--ignore-scripts", "check"] ); assert_eq!(spec.timeout_seconds, 30); let named_test = resolve_project_verification_spec_at(&root, "test:unit", "node --test", 30) .expect("resolve named unit test script"); assert_eq!( named_test.arguments, vec!["run", "--silent", "--ignore-scripts", "test:unit"] ); for (script, command) in [ ("check:ci", "node scripts/check-ci.mjs"), ("test:unit", "node --test"), ("lint:strict", "eslint . --max-warnings 0"), ("typecheck:app", "tsc --noEmit"), ("build:preview", "vite build --mode preview"), ("verify:contracts", "node scripts/verify-contracts.mjs"), ("validate:schema", "node scripts/validate-schema.mjs"), ] { resolve_project_verification_spec_at(&root, script, command, 30) .unwrap_or_else(|error| panic!("resolve named verification script {script}: {error}")); } let drift = resolve_project_verification_spec_at(&root, "check", "node stale.mjs", 30) .expect_err("changed package script should fail closed"); assert!(drift.contains("package.json 中的 check 脚本已变化")); let unsupported = resolve_project_verification_spec_at(&root, "deploy", "node scripts/deploy.mjs", 30) .expect_err("deploy is outside verification allowlist"); assert!(unsupported.contains("只允许验证类脚本")); let empty_suffix = resolve_project_verification_spec_at(&root, "test:", "node --test", 30) .expect_err("named script suffix must not be empty"); assert!(empty_suffix.contains("只允许验证类脚本")); let deploy_family = resolve_project_verification_spec_at( &root, "deploy:production", "node scripts/deploy.mjs", 30, ) .expect_err("deploy family stays outside verification allowlist"); assert!(deploy_family.contains("只允许验证类脚本")); for unsafe_script in [ "test::unit", "test:unit fast", "test:../unit", "pretest:unit", "posttest:unit", ] { let error = resolve_project_verification_spec_at(&root, unsafe_script, "node --test", 30) .expect_err("unsafe or lifecycle-like script name must fail closed"); assert!(error.contains("只允许验证类脚本")); } let overlong_script = format!("test:{}", "a".repeat(161)); let error = resolve_project_verification_spec_at(&root, &overlong_script, "node --test", 30) .expect_err("overlong named script must fail closed"); assert!(error.contains("总长度不能超过")); fs::remove_dir_all(root).ok(); } #[test] fn project_verification_output_redacts_secrets_and_keeps_failure_tail() { let secret = concat!("sk-", "1234567890abcdefghijkl"); let github_secret = format!("ghp_{}", "a".repeat(36)); let output = format!( "head\n{}\nprovider-error({secret});\ngithub={github_secret}\nVERIFY_FAILURE_TAIL", "x".repeat(PROJECT_VERIFICATION_OUTPUT_MAX_BYTES * 2), ); let sanitized = sanitize_project_verification_output(&output); assert!(sanitized.len() <= PROJECT_VERIFICATION_OUTPUT_MAX_BYTES + 256); assert!(sanitized.contains(" npmrc-script-shell-ran.txt\nexit 0\n", ) .expect("write malicious script shell"); let mut permissions = fs::metadata(&evil_shell) .expect("malicious script shell metadata") .permissions(); permissions.set_mode(0o755); fs::set_permissions(&evil_shell, permissions).expect("make malicious script shell executable"); fs::write( root.join(".npmrc"), format!("script-shell={}\n", evil_shell.display()), ) .expect("write project npmrc"); let check_command = r#"node -e "require('fs').writeFileSync('expected-script-ran.txt','ok')""#; fs::write( root.join("package.json"), serde_json::to_string_pretty(&serde_json::json!({ "name": "verify-script-shell-fixture", "private": true, "packageManager": "npm@10.0.0", "scripts": { "check": check_command } })) .expect("serialize npmrc package json"), ) .expect("write npmrc package json"); let error = run_project_verification_at(&root, "check", check_command, 15) .await .expect_err("project npmrc must fail closed before verification"); assert!(error.contains("不允许项目级 .npmrc")); assert!(!root.join("expected-script-ran.txt").exists()); assert!(!root.join("npmrc-script-shell-ran.txt").exists()); fs::remove_dir_all(root).ok(); } #[cfg(unix)] #[tokio::test] async fn project_verification_cleans_up_residual_process_group() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "代码验证项目").expect("project init"); fs::write( root.join("spawn-background.cjs"), r#"const { spawn } = require('child_process'); const child = spawn(process.execPath, [ '-e', "setTimeout(() => require('fs').writeFileSync('verify-background-leak.txt', 'leaked'), 1200)", ], { stdio: 'ignore' }); child.unref(); "#, ) .expect("write background process fixture"); let check_command = "node spawn-background.cjs"; fs::write( root.join("package.json"), serde_json::to_string_pretty(&serde_json::json!({ "name": "verify-process-group-fixture", "private": true, "scripts": { "check": check_command } })) .expect("serialize package json"), ) .expect("write package json"); let result = run_project_verification_at(&root, "check", check_command, 15) .await .expect("run check script"); assert_eq!(result.status, "completed"); std::thread::sleep(Duration::from_millis(1_500)); assert!(!root.join("verify-background-leak.txt").exists()); fs::remove_dir_all(root).ok(); } #[tokio::test] async fn project_verification_runs_without_prepost_and_records_failure_and_timeout() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "代码验证项目").expect("project init"); let check_command = r#"node -e "process.stdout.write('VERIFY_PROCESS_OK')""#; let precheck_command = r#"node -e "require('fs').writeFileSync('precheck-ran.txt','unexpected')""#; let named_test_command = r#"node -e "process.stdout.write('VERIFY_NAMED_TEST_OK')""#; let prenamed_test_command = r#"node -e "require('fs').writeFileSync('prenamed-test-ran.txt','unexpected')""#; let postnamed_test_command = r#"node -e "require('fs').writeFileSync('postnamed-test-ran.txt','unexpected')""#; let test_command = r#"node -e "console.error('VERIFY_EXIT_7');process.exit(7)""#; let lint_command = r#"node -e "const {spawn}=require('child_process');spawn(process.execPath,['-e','setTimeout(()=>{},5000)'],{stdio:'inherit'});setTimeout(()=>{},5000)""#; fs::write( root.join("package.json"), serde_json::to_string_pretty(&serde_json::json!({ "name": "verify-process-fixture", "private": true, "scripts": { "precheck": precheck_command, "check": check_command, "pretest:unit": prenamed_test_command, "test:unit": named_test_command, "posttest:unit": postnamed_test_command, "test": test_command, "lint": lint_command } })) .expect("serialize package json"), ) .expect("write package json"); let completed = run_project_verification_at(&root, "check", check_command, 15) .await .expect("run check script"); assert_eq!(completed.status, "completed", "{}", completed.output); assert_eq!(completed.exit_code, Some(0)); assert!(!completed.timed_out); assert!(completed.output.contains("VERIFY_PROCESS_OK")); #[cfg(target_os = "linux")] { assert_eq!(completed.sandbox_backend, "bubblewrap"); assert_eq!(completed.sandbox_mode, "workspace-write"); assert_eq!(completed.network_access, "disabled"); assert_eq!(completed.sandbox_profile_version, "workspace-v1"); } assert!(!root.join("precheck-ran.txt").exists()); let named = run_project_verification_at(&root, "test:unit", named_test_command, 15) .await .expect("run named unit test script"); assert_eq!(named.status, "completed", "{}", named.output); assert!(named.output.contains("VERIFY_NAMED_TEST_OK")); assert!(!root.join("prenamed-test-ran.txt").exists()); assert!(!root.join("postnamed-test-ran.txt").exists()); let failed = run_project_verification_at(&root, "test", test_command, 15) .await .expect("record failed test script"); assert_eq!(failed.status, "failed"); assert_eq!(failed.exit_code, Some(7)); assert!(!failed.timed_out); assert!(failed.output.contains("VERIFY_EXIT_7")); #[cfg(target_os = "linux")] assert_eq!(failed.sandbox_backend, "bubblewrap"); let timed_out = run_project_verification_at(&root, "lint", lint_command, 1) .await .expect("record timed out lint script"); assert_eq!(timed_out.status, "failed"); assert!(timed_out.timed_out); assert!(timed_out.output.contains("1 秒后超时")); #[cfg(target_os = "linux")] assert_eq!(timed_out.sandbox_backend, "bubblewrap"); let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); assert!(log.contains("project.verify check completed")); assert!(log.contains("project.verify test:unit completed")); assert!(log.contains("project.verify test failed")); assert!(log.contains("project.verify lint failed")); let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); assert!(manifest["commandRuns"] .as_array() .is_some_and(|runs| runs.iter().any(|run| { run["commandId"] == "project.verify.check" && run["status"] == "completed" }))); assert!(manifest["commandRuns"].as_array().is_some_and(|runs| runs .iter() .any(|run| { run["commandId"] == "project.verify.test" && run["status"] == "failed" }))); fs::remove_dir_all(root).ok(); } #[test] fn limited_local_command_appends_playtest_to_existing_trace() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, ) .expect("write playable game html"); write_agent_run_trace( &root, "run-1", "像素风横版动作", "passed", 1, &[agent_trace_step( 1, "Generator", "completed", &[".agent/spec.md"], &["game/index.html"], "生成可运行原型", "llm.chat.generator", )], None, ) .expect("run trace"); let result = run_limited_local_command( root.to_string_lossy().into_owned(), "game.static_smoke".to_string(), ) .expect("static smoke"); assert_eq!(result.status, "completed"); let trace: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) .expect("run trace json"); assert_eq!(trace["status"], "passed"); assert!(trace["steps"].as_array().unwrap().iter().any(|step| { step["agent"] == "Playtest" && step["phase"] == "playtest" && step["taskId"] == "preview-readiness" && step["toolCalls"][0]["toolId"] == "game.static_smoke" })); fs::remove_dir_all(root).ok(); } #[test] fn limited_local_command_rejects_placeholder_game_smoke() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let error = run_limited_local_command_at(&root, "game.static_smoke") .expect_err("placeholder game should fail smoke"); assert!(error.contains("可渲染画布")); fs::remove_dir_all(root).ok(); } #[test] fn limited_local_command_rejects_forbidden_runtime_apis() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let html = fake_llm_game_draft() .game_html .replace("const marker =", "fetch('/secret');\n const marker ="); fs::write(root.join("game/index.html"), html).expect("write game html"); let error = run_limited_local_command_at(&root, "game.static_smoke") .expect_err("fetch should fail smoke"); assert!(error.contains("fetch(")); fs::remove_dir_all(root).ok(); } #[test] fn limited_local_command_rejects_blank_canvas_game_smoke() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let html = r#"

目标:点亮厨房。胜利 / 失败后按 R 重开。

"#; fs::write(root.join("game/index.html"), html).expect("write game html"); let error = run_limited_local_command_at(&root, "game.static_smoke") .expect_err("blank canvas should fail smoke"); assert!(error.contains("canvas 上绘制画面")); fs::remove_dir_all(root).ok(); } #[test] fn limited_local_command_rejects_invalid_javascript_before_surface_checks() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "脚本语法优先验证测试") .expect("project init"); let html = r#"

目标:完成订单。胜利或失败后可以重开。

"#; fs::write(root.join("game/index.html"), html).expect("write invalid JavaScript game html"); let error = run_limited_local_command_at(&root, "game.static_smoke") .expect_err("invalid JavaScript must fail before canvas keywords"); assert!(error.contains("不是有效 JavaScript"), "{error}"); assert!(!error.contains("可渲染画布"), "{error}"); fs::remove_dir_all(root).ok(); } #[test] fn limited_local_command_rejects_token_rich_truncated_script() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "截断游戏入口测试").expect("project init"); let html = r#"

目标:阻挡敌人。胜利或失败后可以 Restart。