Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs
T
lhk229 afd5b8d91f
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m39s
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (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
修复 Linux 命令执行将孤儿僵尸误判为未回收进程
命令主进程退出后区分存活成员与僵尸成员,保留进程组身份校验和失败关闭语义
处理 procfs 扫描期间进程退出及非 UTF-8 进程名,空进程组直接返回
新增隔离 subreaper 回归测试并补全命令 observation 断言诊断
同步 Runtime 技术方案与共享排障记录
2026-09-21 10:30:41 +00:00

4169 lines
152 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.verifywaiting-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.verifyok · 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(30))
.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(30))
.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(30))
.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.execcommand-failed")));
assert!(runtime
.observations
.iter()
.any(|item| item.contains("file.patchok")));
assert!(runtime
.observations
.iter()
.any(|item| item.contains("command.execok")));
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(30))
.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(30))
.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_observation_status(&observation, "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}"
);
}