ae0fc3322f
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- tests/mod.rs 新增 assert_observation_status,断言失败时输出 tool/status/summary/detail - command_runtime.rs 中 6 处 observation.status 断言改用该 helper,替换只输出 left/right 的裸 assert_eq - 目的:CI 上命令类用例失败时能直接看到真实 observation,而不是仅剩状态值差异
4169 lines
152 KiB
Rust
4169 lines
152 KiB
Rust
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"),
|
||
"<!doctype html><html><body>unverified replacement</body></html>",
|
||
)
|
||
.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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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_observation_status(&observation, "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_observation_status(&observation, "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_observation_status(&observation, "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_observation_status(&observation, "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_observation_status(&wrong_id_observation, "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_observation_status(&observation, "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::<Vec<_>>();
|
||
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("<output truncated:"));
|
||
assert!(sanitized.contains("VERIFY_FAILURE_TAIL"));
|
||
assert!(!sanitized.contains(secret));
|
||
assert!(!sanitized.contains(&github_secret));
|
||
assert!(sanitized.contains("[redacted-secret]"));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn project_verification_rejects_project_npmrc_script_shell_override() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "npmrc 验证项目").expect("project init");
|
||
let evil_shell = root.join("evil-script-shell.sh");
|
||
fs::write(
|
||
&evil_shell,
|
||
"#!/bin/sh\nprintf hijacked > 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#"<!doctype html>
|
||
<html lang="zh-CN">
|
||
<body>
|
||
<canvas id="game" width="320" height="180"></canvas>
|
||
<p>目标:点亮厨房。胜利 / 失败后按 R 重开。</p>
|
||
<script>
|
||
const canvas = document.getElementById('game');
|
||
const ctx = canvas.getContext('2d');
|
||
function frame() { requestAnimationFrame(frame); }
|
||
window.addEventListener('keydown', () => {});
|
||
requestAnimationFrame(frame);
|
||
</script>
|
||
</body>
|
||
</html>"#;
|
||
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#"<!doctype html>
|
||
<html lang="zh-CN">
|
||
<body>
|
||
<p>目标:完成订单。胜利或失败后可以重开。</p>
|
||
<script>
|
||
function draw() {} draw()const terminalPhases = ['won', 'lost'];
|
||
window.addEventListener('click', draw);
|
||
</script>
|
||
</body>
|
||
</html>"#;
|
||
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#"<!doctype html>
|
||
<html lang="zh-CN">
|
||
<body>
|
||
<canvas id="game" width="320" height="180"></canvas>
|
||
<p>目标:阻挡敌人。胜利或失败后可以 Restart。</p>
|
||
<script>
|
||
const canvas = document.getElementById('game');
|
||
const ctx = canvas.getContext('2d');
|
||
function frame() { ctx.fillRect(0, 0, 10, 10); requestAnimationFrame(frame); }
|
||
window.addEventListener('click', () => { ctx.fillStyle = '#fff'; });
|
||
requestAnimationFrame(frame);
|
||
ctx."#;
|
||
fs::write(root.join("game/index.html"), html).expect("write truncated game html");
|
||
|
||
let error = run_limited_local_command_at(&root, "game.static_smoke")
|
||
.expect_err("truncated script should fail smoke");
|
||
|
||
assert!(error.contains("<script> 代码块未闭合"), "{error}");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn limited_local_command_rejects_unknown_command() {
|
||
let root = unique_project_path();
|
||
let error = run_limited_local_command_at(&root, "npm.run.build")
|
||
.expect_err("unknown command should fail");
|
||
|
||
assert!(error.contains("不支持"));
|
||
}
|
||
|
||
#[test]
|
||
fn process_session_agent_runtime_exposes_all_persistent_command_tools() {
|
||
let tools = agent_runtime_executable_tools();
|
||
let persistent_tools = [
|
||
"command.start",
|
||
"command.poll",
|
||
"command.stdin",
|
||
"command.terminate",
|
||
];
|
||
|
||
for tool in persistent_tools {
|
||
assert!(tools.contains(&tool), "missing executable tool: {tool}");
|
||
}
|
||
let positions = persistent_tools
|
||
.iter()
|
||
.map(|tool| {
|
||
tools
|
||
.iter()
|
||
.position(|candidate| candidate == tool)
|
||
.expect("persistent command tool position")
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert!(positions.windows(2).all(|pair| pair[0] + 1 == pair[1]));
|
||
}
|
||
|
||
#[test]
|
||
fn process_session_legacy_empty_confirm_commands_keep_mutations_confirmed_and_poll_auto() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "旧权限策略项目").expect("project init");
|
||
fs::write(
|
||
root.join(PROJECT_PERMISSION_POLICY_PATH),
|
||
r#"{
|
||
"deniedCommands": [],
|
||
"confirmCommands": [],
|
||
"agentPolicies": {}
|
||
}
|
||
"#,
|
||
)
|
||
.expect("write legacy empty permission policy");
|
||
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"检查旧权限策略的持久进程默认值",
|
||
"code-process-policy-run",
|
||
"agent-background-task",
|
||
"读取持久进程工具策略",
|
||
vec!["核对默认确认边界".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
|
||
for tool in [
|
||
"project.git_commit",
|
||
"command.start",
|
||
"command.stdin",
|
||
"command.terminate",
|
||
] {
|
||
assert!(
|
||
runtime
|
||
.tool_policy
|
||
.confirm_tools
|
||
.iter()
|
||
.any(|item| item == tool),
|
||
"legacy policy must keep {tool} in confirm"
|
||
);
|
||
assert!(!runtime
|
||
.tool_policy
|
||
.auto_tools
|
||
.iter()
|
||
.any(|item| item == tool));
|
||
}
|
||
assert!(runtime
|
||
.tool_policy
|
||
.auto_tools
|
||
.contains(&"command.poll".to_string()));
|
||
assert!(!runtime
|
||
.tool_policy
|
||
.confirm_tools
|
||
.contains(&"command.poll".to_string()));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn process_session_planning_and_system_prompts_define_the_full_lifecycle() {
|
||
let system_prompt = game_creator_agent_runtime_tool_plan_system_prompt();
|
||
for token in [
|
||
"command.start",
|
||
"command.poll",
|
||
"command.stdin",
|
||
"command.terminate",
|
||
if cfg!(target_os = "linux") {
|
||
"workspace-write、network-disabled"
|
||
} else {
|
||
"固定 program/argv"
|
||
},
|
||
"processId/cursor",
|
||
"nextCursor",
|
||
"waitMs",
|
||
"禁止忙轮询",
|
||
"UTF-8",
|
||
"running/terminating",
|
||
"needs-reconciliation",
|
||
"禁止最终回复",
|
||
"不能签发验证凭证",
|
||
] {
|
||
assert!(
|
||
system_prompt.contains(token),
|
||
"system prompt missing {token}"
|
||
);
|
||
}
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "持久进程规划提示项目").expect("project init");
|
||
let (sender, receiver) = mpsc::channel();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||
vec![final_tool_plan_response("持久进程协议已核对。")],
|
||
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-process-prompt-run",
|
||
)
|
||
.expect("start background task");
|
||
let request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("planning request");
|
||
let request_json = mock_http_request_json(&request);
|
||
let planning_input = request_json["input"].to_string();
|
||
for token in [
|
||
"command.start 使用",
|
||
"program",
|
||
"args",
|
||
"cwd",
|
||
"timeoutSeconds",
|
||
"command.poll 使用",
|
||
"processId",
|
||
"cursor",
|
||
"maxChars",
|
||
"waitMs",
|
||
"command.stdin 使用",
|
||
"data",
|
||
"appendNewline",
|
||
"eof",
|
||
"command.terminate 使用",
|
||
"不要无等待忙轮询",
|
||
"poll 到可信终态",
|
||
"才能调用 respond_to_user 收束",
|
||
] {
|
||
assert!(
|
||
planning_input.contains(token),
|
||
"planning prompt missing {token}"
|
||
);
|
||
}
|
||
if cfg!(target_os = "linux") {
|
||
for token in [
|
||
"受信任 PATH 中的裸可执行名",
|
||
"workspace-write",
|
||
"network-disabled",
|
||
] {
|
||
assert!(
|
||
planning_input.contains(token),
|
||
"Linux planning prompt missing {token}"
|
||
);
|
||
}
|
||
}
|
||
let function_tools = request_json["tools"]
|
||
.as_array()
|
||
.expect("planning function tools");
|
||
for tool in [
|
||
"command.start",
|
||
"command.poll",
|
||
"command.stdin",
|
||
"command.terminate",
|
||
] {
|
||
let function_name = native_runtime_function_name(tool).expect("native command function");
|
||
assert!(function_tools
|
||
.iter()
|
||
.any(|candidate| candidate["name"] == function_name));
|
||
}
|
||
|
||
let runtime = wait_for_agent_runtime_idle(&root, "code-prototype");
|
||
assert_eq!(runtime.phase, "completed");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn process_session_command_stdin_input_summary_contains_only_safe_fields() {
|
||
let root = unique_project_path();
|
||
let process_id = "proc-0123456789abcdef0123456789abcdef";
|
||
let data = "PRIVATE_STDIN_BODY_MUST_NOT_LEAK";
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "command.stdin".to_string(),
|
||
reason: Some("向受控进程发送测试输入".to_string()),
|
||
input: serde_json::json!({
|
||
"processId": process_id,
|
||
"data": data,
|
||
"appendNewline": true,
|
||
"eof": false
|
||
}),
|
||
};
|
||
let bytes = format!("{data}\n");
|
||
let expected = format!(
|
||
"processId={process_id} · bytes={} · contentSha256={:x} · eof=false · appendNewline=true",
|
||
bytes.len(),
|
||
Sha256::digest(bytes.as_bytes())
|
||
);
|
||
|
||
assert_eq!(
|
||
agent_runtime_tool_action_input_summary(&root, &action),
|
||
Some(expected)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn process_session_final_response_redacts_private_poll_output() {
|
||
let challenge = "01234567-89ab-cdef-0123-456789abcdef";
|
||
let ready = format!("GENARRATIVE_PROCESS_READY challenge={challenge}");
|
||
let echo = format!("GENARRATIVE_PROCESS_ECHO {challenge}");
|
||
let stopped = "GENARRATIVE_PROCESS_STOPPED";
|
||
let observation = AgentRuntimeToolObservation {
|
||
tool: "command.poll".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "读取私有进程输出".to_string(),
|
||
detail: Some(
|
||
serde_json::json!({
|
||
"processId": "proc-0123456789abcdef0123456789abcdef",
|
||
"output": format!("{ready}\n{echo}\n{stopped}\n")
|
||
})
|
||
.to_string(),
|
||
),
|
||
};
|
||
|
||
let response = format!("已完成:{challenge};{ready};{echo};{stopped}");
|
||
let redacted = redact_agent_runtime_private_process_output_from_response(
|
||
&response,
|
||
std::slice::from_ref(&observation),
|
||
);
|
||
assert!(!redacted.contains(challenge));
|
||
assert!(!redacted.contains(&ready));
|
||
assert!(!redacted.contains(&echo));
|
||
assert!(!redacted.contains(stopped));
|
||
assert_eq!(redacted, "持久进程交互已完成,私有进程输出已省略。");
|
||
let short_private_observation = AgentRuntimeToolObservation {
|
||
tool: "command.poll".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "读取短私有进程输出".to_string(),
|
||
detail: Some(serde_json::json!({ "output": "PIN=1234\n" }).to_string()),
|
||
};
|
||
assert_eq!(
|
||
redact_agent_runtime_private_process_output_from_response(
|
||
"进程返回的 PIN 是 1234。",
|
||
&[short_private_observation],
|
||
),
|
||
"持久进程交互已完成,私有进程输出已省略。"
|
||
);
|
||
assert_eq!(
|
||
redact_agent_runtime_private_process_output_from_response(
|
||
"持久进程交互已完成。",
|
||
&[AgentRuntimeToolObservation {
|
||
tool: "command.poll".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "空输出".to_string(),
|
||
detail: Some(serde_json::json!({ "output": "" }).to_string()),
|
||
}],
|
||
),
|
||
"持久进程交互已完成。"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn process_session_start_only_detach_profile_does_not_pollute_command_exec_resolution() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "命令校验边界项目").expect("project init");
|
||
fs::write(
|
||
root.join("package.json"),
|
||
r#"{
|
||
"private": true,
|
||
"scripts": {
|
||
"test:detach-profile": "node --test test/safe.test.mjs # --daemon"
|
||
}
|
||
}
|
||
"#,
|
||
)
|
||
.expect("write detach profile package");
|
||
|
||
let spec = resolve_project_command_spec_at(
|
||
&root,
|
||
"npm",
|
||
&["run".to_string(), "test:detach-profile".to_string()],
|
||
".",
|
||
30,
|
||
)
|
||
.expect("command.exec resolution must ignore command.start-only detach profile");
|
||
let start_error = validate_process_session_command_spec(&spec)
|
||
.expect_err("the same spec must remain invalid for command.start");
|
||
assert!(start_error.contains("command.start"));
|
||
assert!(start_error.contains("脱离 Runner"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn process_session_command_start_rejects_detach_before_launch_or_revision_advance() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "非法脱离启动项目").expect("project init");
|
||
fs::write(
|
||
root.join("package.json"),
|
||
r#"{
|
||
"private": true,
|
||
"scripts": {
|
||
"dev:detached": "node process-fixture.mjs --daemon"
|
||
}
|
||
}
|
||
"#,
|
||
)
|
||
.expect("write detached start package");
|
||
fs::write(
|
||
root.join("process-fixture.mjs"),
|
||
"setInterval(() => {}, 1000);\n",
|
||
)
|
||
.expect("write process fixture");
|
||
fs::write(
|
||
root.join(PROJECT_PERMISSION_POLICY_PATH),
|
||
r#"{"deniedCommands":[],"confirmCommands":[],"agentPolicies":{}}"#,
|
||
)
|
||
.expect("write empty legacy policy");
|
||
let run_id = "code-process-detach-rejection-run";
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"拒绝脱离 Runner 的持久进程",
|
||
run_id,
|
||
"agent-background-task",
|
||
"校验 command.start",
|
||
vec!["拒绝非法启动".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
state.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "command.start".to_string(),
|
||
reason: Some("验证 start-only 脱离参数校验".to_string()),
|
||
input: serde_json::json!({
|
||
"program": "npm",
|
||
"args": ["run", "dev:detached"],
|
||
"cwd": ".",
|
||
"timeoutSeconds": 30
|
||
}),
|
||
};
|
||
let pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write approved detached start");
|
||
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 detached start task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write detached start state");
|
||
write_game_creator_agent_runtime_tool_confirmation(
|
||
&root,
|
||
"code-prototype",
|
||
run_id,
|
||
"command.start",
|
||
&pending.action_fingerprint,
|
||
"确认测试非法启动会被拒绝",
|
||
)
|
||
.expect("write detached start confirmation");
|
||
|
||
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.tool, "command.start");
|
||
assert_eq!(observation.status, "failed");
|
||
assert!(observation.summary.contains("脱离 Runner"));
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("revision after rejected start")
|
||
.revision,
|
||
0
|
||
);
|
||
assert!(
|
||
active_process_session_records_at(&root, Some("code-prototype"), Some(run_id))
|
||
.expect("active records after rejected start")
|
||
.is_empty()
|
||
);
|
||
assert!(!root.join(".agent/runtime/process-sessions").exists());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runner_restart_resume_projects_stale_process_session_to_reconciliation() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Runner 重启进程恢复项目")
|
||
.expect("project init");
|
||
let run_id = "code-process-runner-restart-reconciliation-run";
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"恢复旧 Runner 的持久进程",
|
||
run_id,
|
||
"agent-background-task",
|
||
"等待旧进程状态",
|
||
vec!["核对进程会话".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||
|
||
let process_id = "proc-0123456789abcdef0123456789abcdef";
|
||
let now = unix_timestamp();
|
||
let sandbox = command_sandbox_platform_metadata();
|
||
let record = ProcessSessionRecord {
|
||
schema_version: "3".to_string(),
|
||
project_id: "project-1".to_string(),
|
||
agent_id: "code-prototype".to_string(),
|
||
task_id: state.task_id.clone(),
|
||
conversation_session_id: state.session_id.clone(),
|
||
run_id: run_id.to_string(),
|
||
start_action_id: "action-0123456789abcdef01234567".to_string(),
|
||
start_action_fingerprint: "a".repeat(64),
|
||
process_id: process_id.to_string(),
|
||
owner_boot_id: format!("stale-{}", process_session_boot_id()),
|
||
command_id: "npm:process:fixture".to_string(),
|
||
program: "npm".to_string(),
|
||
cwd: ".".to_string(),
|
||
sandbox_backend: sandbox.backend.to_string(),
|
||
sandbox_mode: sandbox.mode.to_string(),
|
||
network_access: sandbox.network.to_string(),
|
||
sandbox_profile_version: sandbox.profile_version.to_string(),
|
||
sandbox_establishment: "established".to_string(),
|
||
target_exec: "established".to_string(),
|
||
launch_failure_kind: None,
|
||
sandbox_ready_at: Some(now),
|
||
exec_established_at: Some(now),
|
||
status: "running".to_string(),
|
||
exit_code: None,
|
||
signal: None,
|
||
stdin_open: true,
|
||
output_bytes: 0,
|
||
output_sha256: format!("{:x}", Sha256::digest([])),
|
||
output_ref: Some(format!(
|
||
".agent/runtime/process-sessions/{process_id}.output.json"
|
||
)),
|
||
source_fingerprint_before: "b".repeat(64),
|
||
source_fingerprint_after: None,
|
||
source_changed: None,
|
||
needs_reconciliation: false,
|
||
started_at: now,
|
||
terminal_at: None,
|
||
updated_at: now,
|
||
};
|
||
let record_directory = root.join(".agent/runtime/process-sessions");
|
||
fs::create_dir_all(&record_directory).expect("create process record directory");
|
||
fs::write(
|
||
record_directory.join(format!("{process_id}.json")),
|
||
serde_json::to_vec(&record).expect("serialize process record"),
|
||
)
|
||
.expect("write stale process record");
|
||
|
||
let resumed =
|
||
resume_game_creator_agent_background_tasks_at(&root).expect("resume stale process session");
|
||
assert!(resumed.iter().any(|runtime| {
|
||
runtime.state.agent_id == "code-prototype"
|
||
&& runtime.state.run_id == run_id
|
||
&& runtime.state.session_id == state.session_id
|
||
&& runtime.state.phase == "needs-reconciliation"
|
||
}));
|
||
let runtime = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||
.expect("read reconciled runtime")
|
||
.state;
|
||
assert_eq!(runtime.status, "failed");
|
||
assert_eq!(runtime.phase, "needs-reconciliation");
|
||
assert_eq!(runtime.run_id, run_id);
|
||
assert_eq!(runtime.session_id, state.session_id);
|
||
let records = active_process_session_records_at(&root, Some("code-prototype"), Some(run_id))
|
||
.expect("read reconciled process record");
|
||
assert_eq!(records.len(), 1);
|
||
assert_eq!(records[0].status, "needs-reconciliation");
|
||
assert!(records[0].needs_reconciliation);
|
||
|
||
let task_path = game_creator_agent_runtime_task_path(&root, "code-prototype");
|
||
remove_jsonl_records_for_test(&task_path, |task| {
|
||
task["runId"] == run_id && task["phase"] == "needs-reconciliation"
|
||
});
|
||
fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&task_path)
|
||
.expect("open task tail fixture")
|
||
.write_all(b"{\"phase\":\"needs-recon")
|
||
.expect("write truncated task tail");
|
||
fs::remove_file(root.join(".agent/runtime/agents/code-prototype.json"))
|
||
.expect("remove reconciliation runtime state");
|
||
let event_path = game_creator_agent_runtime_event_path(&root, "code-prototype");
|
||
remove_jsonl_records_for_test(&event_path, |event| {
|
||
event["runId"] == run_id
|
||
&& event["eventType"] == "process_session.reconciled_after_runner_restart"
|
||
});
|
||
fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&event_path)
|
||
.expect("open event tail fixture")
|
||
.write_all(b"{\"eventType\":\"process_session.reconciled")
|
||
.expect("write truncated event tail");
|
||
let agent_db_path = root.join(".agent/agent.db");
|
||
remove_jsonl_records_for_test(&agent_db_path, |audit| {
|
||
audit["runId"] == run_id
|
||
&& audit["recordType"]
|
||
== "agent.runtime.process_session.reconciled_after_runner_restart"
|
||
});
|
||
fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&agent_db_path)
|
||
.expect("open Agent DB tail fixture")
|
||
.write_all(b"{\"recordType\":\"agent.runtime.process_session")
|
||
.expect("write truncated Agent DB tail");
|
||
|
||
resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("partial reconciliation projections are repaired");
|
||
let repaired = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||
.expect("read repaired reconciliation runtime")
|
||
.state;
|
||
assert_eq!(repaired.status, "failed");
|
||
assert_eq!(repaired.phase, "needs-reconciliation");
|
||
assert_eq!(repaired.run_id, run_id);
|
||
assert_eq!(repaired.session_id, state.session_id);
|
||
let reconciliation_tasks = fs::read_to_string(game_creator_agent_runtime_task_path(
|
||
&root,
|
||
"code-prototype",
|
||
))
|
||
.expect("read reconciliation tasks")
|
||
.lines()
|
||
.filter(|line| !line.trim().is_empty())
|
||
.map(|line| serde_json::from_str::<Value>(line).expect("parse reconciliation task"))
|
||
.filter(|task| task["runId"] == run_id && task["phase"] == "needs-reconciliation")
|
||
.count();
|
||
assert_eq!(reconciliation_tasks, 1);
|
||
let reconciliation_events = fs::read_to_string(game_creator_agent_runtime_event_path(
|
||
&root,
|
||
"code-prototype",
|
||
))
|
||
.expect("read reconciliation events")
|
||
.lines()
|
||
.filter(|line| !line.trim().is_empty())
|
||
.map(|line| serde_json::from_str::<Value>(line).expect("parse reconciliation event"))
|
||
.filter(|event| {
|
||
event["runId"] == run_id
|
||
&& event["eventType"] == "process_session.reconciled_after_runner_restart"
|
||
})
|
||
.count();
|
||
assert_eq!(reconciliation_events, 1);
|
||
let audits = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["runId"] == run_id
|
||
&& record["recordType"]
|
||
== "agent.runtime.process_session.reconciled_after_runner_restart"
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(audits.len(), 1);
|
||
assert_eq!(audits[0]["runId"], run_id);
|
||
assert_eq!(audits[0]["processId"], process_id);
|
||
|
||
let mut conflicting_event_state = repaired.clone();
|
||
conflicting_event_state.session_id = "agent-session-conflicting".to_string();
|
||
let event_error = append_game_creator_agent_runtime_action_event(
|
||
&root,
|
||
&conflicting_event_state,
|
||
"process_session.reconciled_after_runner_restart",
|
||
"failed",
|
||
"needs-reconciliation",
|
||
"Runner 重启后发现旧 boot 的活跃进程会话,已停止自动恢复。",
|
||
conflicting_event_state.error.as_deref(),
|
||
&record.start_action_id,
|
||
)
|
||
.expect_err("conflicting reconciliation event must fail closed");
|
||
assert!(event_error.contains("幂等身份冲突"));
|
||
let audit_error = append_agent_db_process_reconciliation_if_missing_for_action(
|
||
&root,
|
||
"code-prototype",
|
||
run_id,
|
||
&record.start_action_id,
|
||
serde_json::json!({
|
||
"recordType": "agent.runtime.process_session.reconciled_after_runner_restart",
|
||
"agentId": "code-prototype",
|
||
"taskId": state.task_id,
|
||
"sessionId": state.session_id,
|
||
"runId": run_id,
|
||
"actionId": record.start_action_id,
|
||
"actionFingerprint": record.start_action_fingerprint,
|
||
"tool": "runtime.process_session",
|
||
"executionMode": "auto",
|
||
"status": "needs-reconciliation",
|
||
"summary": "Runner 重启后发现旧 boot 的活跃进程会话,已停止自动恢复。",
|
||
"safeDetail": null,
|
||
"detailUnavailable": true,
|
||
"processId": "proc-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||
"ownerBootId": record.owner_boot_id,
|
||
"processStatus": "needs-reconciliation",
|
||
"needsReconciliation": true,
|
||
}),
|
||
)
|
||
.expect_err("conflicting reconciliation audit must fail closed");
|
||
assert!(audit_error.contains("身份冲突"));
|
||
|
||
resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("complete reconciliation resume remains idempotent");
|
||
let final_reconciliation_tasks = fs::read_to_string(game_creator_agent_runtime_task_path(
|
||
&root,
|
||
"code-prototype",
|
||
))
|
||
.expect("read final reconciliation tasks")
|
||
.lines()
|
||
.filter(|line| !line.trim().is_empty())
|
||
.map(|line| serde_json::from_str::<Value>(line).expect("parse final reconciliation task"))
|
||
.filter(|task| task["runId"] == run_id && task["phase"] == "needs-reconciliation")
|
||
.count();
|
||
assert_eq!(final_reconciliation_tasks, 1);
|
||
let final_reconciliation_events = fs::read_to_string(game_creator_agent_runtime_event_path(
|
||
&root,
|
||
"code-prototype",
|
||
))
|
||
.expect("read final reconciliation events")
|
||
.lines()
|
||
.filter(|line| !line.trim().is_empty())
|
||
.map(|line| serde_json::from_str::<Value>(line).expect("parse final reconciliation event"))
|
||
.filter(|event| {
|
||
event["runId"] == run_id
|
||
&& event["eventType"] == "process_session.reconciled_after_runner_restart"
|
||
})
|
||
.count();
|
||
assert_eq!(final_reconciliation_events, 1);
|
||
assert_eq!(
|
||
read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["runId"] == run_id
|
||
&& record["recordType"]
|
||
== "agent.runtime.process_session.reconciled_after_runner_restart"
|
||
})
|
||
.count(),
|
||
1
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runner_restart_resume_rejects_process_session_task_identity_mismatch() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Runner 重启身份冲突项目")
|
||
.expect("project init");
|
||
let run_id = "code-process-runner-restart-identity-mismatch-run";
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"拒绝错误归属的旧 Runner 进程",
|
||
run_id,
|
||
"agent-background-task",
|
||
"等待旧进程状态",
|
||
vec!["核对进程会话身份".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||
|
||
let process_id = "proc-fedcba9876543210fedcba9876543210";
|
||
let now = unix_timestamp();
|
||
let sandbox = command_sandbox_platform_metadata();
|
||
let record = ProcessSessionRecord {
|
||
schema_version: "3".to_string(),
|
||
project_id: "project-1".to_string(),
|
||
agent_id: "code-prototype".to_string(),
|
||
task_id: "different-task".to_string(),
|
||
conversation_session_id: state.session_id.clone(),
|
||
run_id: run_id.to_string(),
|
||
start_action_id: "action-fedcba9876543210fedcba98".to_string(),
|
||
start_action_fingerprint: "c".repeat(64),
|
||
process_id: process_id.to_string(),
|
||
owner_boot_id: format!("stale-{}", process_session_boot_id()),
|
||
command_id: "npm:process:fixture".to_string(),
|
||
program: "npm".to_string(),
|
||
cwd: ".".to_string(),
|
||
sandbox_backend: sandbox.backend.to_string(),
|
||
sandbox_mode: sandbox.mode.to_string(),
|
||
network_access: sandbox.network.to_string(),
|
||
sandbox_profile_version: sandbox.profile_version.to_string(),
|
||
sandbox_establishment: "established".to_string(),
|
||
target_exec: "established".to_string(),
|
||
launch_failure_kind: None,
|
||
sandbox_ready_at: Some(now),
|
||
exec_established_at: Some(now),
|
||
status: "running".to_string(),
|
||
exit_code: None,
|
||
signal: None,
|
||
stdin_open: true,
|
||
output_bytes: 0,
|
||
output_sha256: format!("{:x}", Sha256::digest([])),
|
||
output_ref: Some(format!(
|
||
".agent/runtime/process-sessions/{process_id}.output.json"
|
||
)),
|
||
source_fingerprint_before: "d".repeat(64),
|
||
source_fingerprint_after: None,
|
||
source_changed: None,
|
||
needs_reconciliation: false,
|
||
started_at: now,
|
||
terminal_at: None,
|
||
updated_at: now,
|
||
};
|
||
let record_directory = root.join(".agent/runtime/process-sessions");
|
||
fs::create_dir_all(&record_directory).expect("create process record directory");
|
||
fs::write(
|
||
record_directory.join(format!("{process_id}.json")),
|
||
serde_json::to_vec(&record).expect("serialize process record"),
|
||
)
|
||
.expect("write stale process record");
|
||
|
||
let error = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect_err("identity mismatch must fail closed");
|
||
assert!(error.contains("身份不一致"));
|
||
let runtime = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||
.expect("read unchanged runtime")
|
||
.state;
|
||
assert_eq!(runtime.run_id, run_id);
|
||
assert_ne!(runtime.phase, "needs-reconciliation");
|
||
let reconciliation_events = fs::read_to_string(game_creator_agent_runtime_event_path(
|
||
&root,
|
||
"code-prototype",
|
||
))
|
||
.expect("read reconciliation events")
|
||
.lines()
|
||
.filter(|line| !line.trim().is_empty())
|
||
.map(|line| serde_json::from_str::<Value>(line).expect("parse reconciliation event"))
|
||
.filter(|event| {
|
||
event["runId"] == run_id
|
||
&& event["eventType"] == "process_session.reconciled_after_runner_restart"
|
||
})
|
||
.count();
|
||
assert_eq!(reconciliation_events, 0);
|
||
assert_eq!(
|
||
read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["agentId"] == "code-prototype"
|
||
&& record["runId"] == run_id
|
||
&& record["recordType"]
|
||
== "agent.runtime.process_session.reconciled_after_runner_restart"
|
||
})
|
||
.count(),
|
||
0
|
||
);
|
||
|
||
fs::remove_file(record_directory.join(format!("{process_id}.json")))
|
||
.expect("remove task mismatch process record");
|
||
let mut agent_mismatch = record;
|
||
agent_mismatch.agent_id = "design-director".to_string();
|
||
agent_mismatch.task_id = state.task_id.clone();
|
||
fs::write(
|
||
record_directory.join(format!("{process_id}.json")),
|
||
serde_json::to_vec(&agent_mismatch).expect("serialize Agent mismatch process record"),
|
||
)
|
||
.expect("write Agent mismatch process record");
|
||
let agent_error = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect_err("Agent identity mismatch must fail closed");
|
||
assert!(agent_error.contains("无法安全恢复"));
|
||
let unchanged = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||
.expect("read runtime after Agent mismatch")
|
||
.state;
|
||
assert_eq!(unchanged.run_id, run_id);
|
||
assert_ne!(unchanged.phase, "needs-reconciliation");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn process_session_command_start_is_a_revision_mutation_but_not_verification() {
|
||
let process_id = "proc-0123456789abcdef0123456789abcdef";
|
||
let observation = AgentRuntimeToolObservation {
|
||
tool: "command.start".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "持久进程已启动".to_string(),
|
||
detail: Some(
|
||
serde_json::json!({
|
||
"processId": process_id,
|
||
"status": "running",
|
||
"cursor": format!("v1:{process_id}:0"),
|
||
"nextCursor": format!("v1:{process_id}:0"),
|
||
"hasMore": false,
|
||
"stdinOpen": true,
|
||
"exitCode": null,
|
||
"signal": null,
|
||
"outputBytes": 0,
|
||
"outputSha256": format!("{:x}", Sha256::digest([])),
|
||
"sourceChanged": null,
|
||
"needsReconciliation": false,
|
||
"revisionAdvanced": true
|
||
})
|
||
.to_string(),
|
||
),
|
||
};
|
||
|
||
assert!(is_agent_runtime_project_mutation_observation(&observation));
|
||
assert!(agent_runtime_observation_advances_project_revision(
|
||
&observation
|
||
));
|
||
let blocker = project_verification_completion_blocker(&[observation])
|
||
.expect("command.start must not count as verification");
|
||
assert_eq!(blocker.tool, "runtime.verification");
|
||
assert!(blocker
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("command.start")));
|
||
}
|
||
|
||
#[test]
|
||
fn process_session_public_observations_and_receipts_exclude_pty_and_stdin_bodies() {
|
||
const OUTPUT_SENTINEL: &str = "PRIVATE_PTY_OUTPUT_SENTINEL";
|
||
const STDIN_SENTINEL: &str = "PRIVATE_STDIN_SENTINEL";
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "持久进程公共泄漏项目").expect("project init");
|
||
let run_id = "code-process-public-leak-run";
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"验证持久进程公共持久面不泄漏正文",
|
||
run_id,
|
||
"agent-background-task",
|
||
"记录安全动作元数据",
|
||
vec!["核对公共持久面".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
state.loop_iteration = 1;
|
||
let process_id = "proc-0123456789abcdef0123456789abcdef";
|
||
let output_sha256 = format!("{:x}", Sha256::digest(OUTPUT_SENTINEL.as_bytes()));
|
||
let poll_action = AgentRuntimeToolAction {
|
||
tool: "command.poll".to_string(),
|
||
reason: Some("读取一页私有 PTY 输出".to_string()),
|
||
input: serde_json::json!({
|
||
"processId": process_id,
|
||
"cursor": format!("v1:{process_id}:0"),
|
||
"maxChars": 8_000,
|
||
"waitMs": 1_000
|
||
}),
|
||
};
|
||
let mut poll_pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
poll_action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
poll_pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
let poll_observation = AgentRuntimeToolObservation {
|
||
tool: "command.poll".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "进程会话仍在运行,本页读取 27 字符".to_string(),
|
||
detail: Some(
|
||
serde_json::json!({
|
||
"processId": process_id,
|
||
"status": "running",
|
||
"cursor": format!("v1:{process_id}:0"),
|
||
"nextCursor": format!("v1:{process_id}:27"),
|
||
"hasMore": false,
|
||
"stdinOpen": true,
|
||
"exitCode": null,
|
||
"signal": null,
|
||
"outputBytes": OUTPUT_SENTINEL.len(),
|
||
"outputSha256": output_sha256,
|
||
"sourceChanged": null,
|
||
"needsReconciliation": false,
|
||
"revisionAdvanced": false,
|
||
"output": OUTPUT_SENTINEL
|
||
})
|
||
.to_string(),
|
||
),
|
||
};
|
||
let task = state.current_task.clone();
|
||
append_agent_runtime_tool_call_record(
|
||
&root,
|
||
&mut state,
|
||
&task,
|
||
&poll_action,
|
||
&poll_observation,
|
||
Some(&poll_pending.action_id),
|
||
Some(&poll_pending.action_fingerprint),
|
||
);
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&state,
|
||
&poll_pending.action_id,
|
||
&poll_pending.action_fingerprint,
|
||
"command.poll",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
poll_pending.input_summary.as_deref(),
|
||
&poll_observation,
|
||
)
|
||
.expect("append poll receipt");
|
||
|
||
let stdin_action = AgentRuntimeToolAction {
|
||
tool: "command.stdin".to_string(),
|
||
reason: Some("写入私有测试正文".to_string()),
|
||
input: serde_json::json!({
|
||
"processId": process_id,
|
||
"data": STDIN_SENTINEL,
|
||
"appendNewline": false,
|
||
"eof": false
|
||
}),
|
||
};
|
||
let stdin_pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
stdin_action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
let stdin_sha256 = format!("{:x}", Sha256::digest(STDIN_SENTINEL.as_bytes()));
|
||
let stdin_observation = AgentRuntimeToolObservation {
|
||
tool: "command.stdin".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: format!(
|
||
"已向进程会话 {process_id} 写入 {} 字节",
|
||
STDIN_SENTINEL.len()
|
||
),
|
||
detail: Some(
|
||
serde_json::json!({
|
||
"processId": process_id,
|
||
"bytesWritten": STDIN_SENTINEL.len(),
|
||
"contentSha256": stdin_sha256,
|
||
"stdinOpen": true,
|
||
"eof": false
|
||
})
|
||
.to_string(),
|
||
),
|
||
};
|
||
let task = state.current_task.clone();
|
||
append_agent_runtime_tool_call_record(
|
||
&root,
|
||
&mut state,
|
||
&task,
|
||
&stdin_action,
|
||
&stdin_observation,
|
||
Some(&stdin_pending.action_id),
|
||
Some(&stdin_pending.action_fingerprint),
|
||
);
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&state,
|
||
&stdin_pending.action_id,
|
||
&stdin_pending.action_fingerprint,
|
||
"command.stdin",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
|
||
stdin_pending.input_summary.as_deref(),
|
||
&stdin_observation,
|
||
)
|
||
.expect("append stdin receipt");
|
||
state.observations = vec![poll_observation.summary(), stdin_observation.summary()];
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append public task state");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write public runtime state");
|
||
|
||
assert!(state
|
||
.recent_tool_calls
|
||
.iter()
|
||
.filter(|call| matches!(call.tool.as_str(), "command.poll" | "command.stdin"))
|
||
.all(|call| call.detail.is_none()));
|
||
let records = read_agent_db_records_for_test(&root);
|
||
let records_json = serde_json::to_string(&records).expect("serialize Agent DB records");
|
||
assert!(!records_json.contains(OUTPUT_SENTINEL));
|
||
assert!(!records_json.contains(STDIN_SENTINEL));
|
||
let poll_receipt = records
|
||
.iter()
|
||
.find(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["tool"] == "command.poll"
|
||
})
|
||
.expect("poll receipt");
|
||
let poll_safe_detail: Value = serde_json::from_str(
|
||
poll_receipt["safeDetail"]
|
||
.as_str()
|
||
.expect("poll safe detail"),
|
||
)
|
||
.expect("parse poll safe detail");
|
||
assert!(poll_safe_detail.get("output").is_none());
|
||
let stdin_receipt = records
|
||
.iter()
|
||
.find(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["tool"] == "command.stdin"
|
||
})
|
||
.expect("stdin receipt");
|
||
let stdin_safe_detail: Value = serde_json::from_str(
|
||
stdin_receipt["safeDetail"]
|
||
.as_str()
|
||
.expect("stdin safe detail"),
|
||
)
|
||
.expect("parse stdin safe detail");
|
||
assert!(stdin_safe_detail.get("data").is_none());
|
||
assert_eq!(
|
||
stdin_safe_detail
|
||
.as_object()
|
||
.expect("stdin safe detail object")
|
||
.keys()
|
||
.map(String::as_str)
|
||
.collect::<std::collections::BTreeSet<_>>(),
|
||
std::collections::BTreeSet::from([
|
||
"bytesWritten",
|
||
"contentSha256",
|
||
"eof",
|
||
"processId",
|
||
"stdinOpen",
|
||
])
|
||
);
|
||
for path in [
|
||
root.join(".agent/agent.db"),
|
||
game_creator_agent_runtime_task_path(&root, "code-prototype"),
|
||
root.join(".agent/runtime/agents/code-prototype.json"),
|
||
] {
|
||
let public_content = fs::read_to_string(&path).expect("read public runtime surface");
|
||
assert!(
|
||
!public_content.contains(OUTPUT_SENTINEL),
|
||
"{}",
|
||
path.display()
|
||
);
|
||
assert!(
|
||
!public_content.contains(STDIN_SENTINEL),
|
||
"{}",
|
||
path.display()
|
||
);
|
||
}
|
||
let history = observe_agent_runtime_action_history(
|
||
&root,
|
||
"code-prototype",
|
||
run_id,
|
||
&serde_json::json!({ "limit": 10 }),
|
||
);
|
||
let history_json = serde_json::to_string(&history).expect("serialize action history");
|
||
assert!(!history_json.contains(OUTPUT_SENTINEL));
|
||
assert!(!history_json.contains(STDIN_SENTINEL));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn process_session_agent_runtime_confirmed_lifecycle_runs_in_isolated_registry() {
|
||
const CHILD_MARKER: &str = "GENARRATIVE_PROCESS_SESSION_AGENT_RUNTIME_CHILD";
|
||
if std::env::var_os(CHILD_MARKER).is_some() {
|
||
tauri::async_runtime::block_on(process_session_agent_runtime_confirmed_lifecycle_fixture());
|
||
return;
|
||
}
|
||
let status = std::process::Command::new(std::env::current_exe().expect("current test binary"))
|
||
.env(CHILD_MARKER, "1")
|
||
.args([
|
||
"--exact",
|
||
"tests::command_runtime::process_session_agent_runtime_confirmed_lifecycle_runs_in_isolated_registry",
|
||
"--nocapture",
|
||
"--test-threads=1",
|
||
])
|
||
.status()
|
||
.expect("spawn isolated process session integration test");
|
||
assert!(status.success(), "isolated lifecycle test failed: {status}");
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn process_session_supervisor_collaboration_blocks_stdin_but_allows_poll_and_terminate_in_isolated_registry(
|
||
) {
|
||
const CHILD_MARKER: &str =
|
||
"GENARRATIVE_PROCESS_SESSION_SUPERVISOR_COLLABORATION_GOVERNANCE_CHILD";
|
||
if std::env::var_os(CHILD_MARKER).is_some() {
|
||
tauri::async_runtime::block_on(
|
||
process_session_supervisor_collaboration_governance_fixture(),
|
||
);
|
||
return;
|
||
}
|
||
let status = std::process::Command::new(std::env::current_exe().expect("current test binary"))
|
||
.env(CHILD_MARKER, "1")
|
||
.args([
|
||
"--exact",
|
||
"tests::command_runtime::process_session_supervisor_collaboration_blocks_stdin_but_allows_poll_and_terminate_in_isolated_registry",
|
||
"--nocapture",
|
||
"--test-threads=1",
|
||
])
|
||
.status()
|
||
.expect("spawn isolated Supervisor process collaboration governance test");
|
||
assert!(
|
||
status.success(),
|
||
"isolated Supervisor process collaboration governance test failed: {status}"
|
||
);
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn process_session_agent_runtime_start_audit_failure_terminates_target_in_isolated_registry() {
|
||
const CHILD_MARKER: &str =
|
||
"GENARRATIVE_PROCESS_SESSION_AGENT_RUNTIME_START_AUDIT_FAILURE_CHILD";
|
||
if std::env::var_os(CHILD_MARKER).is_some() {
|
||
tauri::async_runtime::block_on(process_session_agent_runtime_start_audit_failure_fixture());
|
||
return;
|
||
}
|
||
let status = std::process::Command::new(std::env::current_exe().expect("current test binary"))
|
||
.env(CHILD_MARKER, "1")
|
||
.args([
|
||
"--exact",
|
||
"tests::command_runtime::process_session_agent_runtime_start_audit_failure_terminates_target_in_isolated_registry",
|
||
"--nocapture",
|
||
"--test-threads=1",
|
||
])
|
||
.status()
|
||
.expect("spawn isolated process session start audit failure test");
|
||
assert!(
|
||
status.success(),
|
||
"isolated start audit failure test failed: {status}"
|
||
);
|
||
}
|