1a27e40758
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
绑定逐视口摘要中的状态、阻断原因与首个未通过断言 验证成功和失败结果均完整进入最终 MCP 回包 新增真实点击后未进入 playing 的浏览器场景并对照持久报告 收紧视觉未执行断言并补充长期验收要求
999 lines
38 KiB
Rust
999 lines
38 KiB
Rust
//! Direct 验证工具与输入指纹。预算和可信回执统一交宿主执行状态持久化。
|
|
use super::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::Read;
|
|
use std::path::Path;
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub(crate) struct DirectValidationConfig {
|
|
pub(crate) max_runs: u32,
|
|
#[serde(default = "default_execution_seconds")]
|
|
pub(crate) max_execution_seconds: u64,
|
|
#[serde(default = "default_turn_seconds")]
|
|
pub(crate) max_turn_seconds: u64,
|
|
}
|
|
|
|
fn default_execution_seconds() -> u64 {
|
|
900
|
|
}
|
|
fn default_turn_seconds() -> u64 {
|
|
1800
|
|
}
|
|
|
|
impl Default for DirectValidationConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_runs: 3,
|
|
max_execution_seconds: default_execution_seconds(),
|
|
max_turn_seconds: default_turn_seconds(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DirectValidationConfig {
|
|
pub(crate) fn validate(&self) -> Result<(), String> {
|
|
if self.max_runs == 0 {
|
|
return Err("validation.maxRuns 必须是正整数".into());
|
|
}
|
|
if self.max_execution_seconds == 0
|
|
|| self.max_turn_seconds == 0
|
|
|| self.max_execution_seconds > u64::MAX / 1000
|
|
|| self.max_turn_seconds > u64::MAX / 1000
|
|
{
|
|
return Err(
|
|
"validation.maxExecutionSeconds 与 maxTurnSeconds 必须是有效的正整数秒数".into(),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(super) struct Reservation {
|
|
session: std::sync::Arc<super::direct_execution::ExecutionSession>,
|
|
lease: std::sync::Mutex<Option<super::direct_execution::ExecutionLease>>,
|
|
turn_id: String,
|
|
sequence: u32,
|
|
key: String,
|
|
fingerprint: String,
|
|
source_fingerprint: String,
|
|
build: bool,
|
|
}
|
|
|
|
pub(super) enum ValidationStart {
|
|
Run(Reservation),
|
|
Reused(Value),
|
|
}
|
|
impl Reservation {
|
|
pub(super) fn sequence(&self) -> usize {
|
|
self.sequence as usize
|
|
}
|
|
}
|
|
|
|
fn budget_result(
|
|
mut result: Value,
|
|
session: &super::direct_execution::ExecutionSession,
|
|
sequence: u32,
|
|
fingerprint: &str,
|
|
reused: bool,
|
|
) -> Result<Value, String> {
|
|
let state = session.snapshot()?;
|
|
result["validation"] = json!({"sequence":sequence,"fingerprint":fingerprint,"reused":reused,
|
|
"usedRuns":state.used_passes,"maxRuns":state.max_runs,"remainingRuns":state.max_runs.saturating_sub(state.used_passes),
|
|
"usedExecutionMs":state.used_execution_ms,"maxExecutionMs":state.max_execution_ms});
|
|
Ok(result)
|
|
}
|
|
|
|
fn evidence_hashes(root: &Path, result: &Value) -> Result<Value, String> {
|
|
let mut hashes = serde_json::Map::new();
|
|
if !matches!(result["mode"].as_str(), Some("visual" | "gameplay")) {
|
|
return Ok(Value::Object(hashes));
|
|
}
|
|
let report = result["reportPath"]
|
|
.as_str()
|
|
.ok_or("validation-evidence: 缺少报告")?;
|
|
let shots = result["screenshots"]
|
|
.as_array()
|
|
.filter(|shots| shots.len() == 2)
|
|
.ok_or("validation-evidence: 缺少双端截图")?;
|
|
for relative in std::iter::once(Some(report)).chain(shots.iter().map(Value::as_str)) {
|
|
let relative = relative.ok_or("validation-evidence: 证据路径无效")?;
|
|
if !relative.starts_with(".agent/runtime/direct-validation-evidence/") {
|
|
return Err("validation-evidence: 证据不属于受控验证目录".into());
|
|
}
|
|
let path = resolve_local_project_path(root, relative)?;
|
|
let meta =
|
|
std::fs::symlink_metadata(&path).map_err(|_| "validation-evidence: 证据文件丢失")?;
|
|
if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 16 * 1024 * 1024 {
|
|
return Err("validation-evidence: 证据类型或大小无效".into());
|
|
}
|
|
let mut bytes = Vec::new();
|
|
std::fs::File::open(path)
|
|
.map_err(|_| "validation-evidence: 无法读取证据")?
|
|
.take(16 * 1024 * 1024 + 1)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|_| "validation-evidence: 无法读取证据")?;
|
|
if bytes.len() > 16 * 1024 * 1024 {
|
|
return Err("validation-evidence: 证据文件在读取时超限".into());
|
|
}
|
|
hashes.insert(
|
|
relative.to_string(),
|
|
json!(format!("{:x}", Sha256::digest(bytes))),
|
|
);
|
|
}
|
|
Ok(Value::Object(hashes))
|
|
}
|
|
|
|
fn cached_evidence_available(root: &Path, result: &Value) -> bool {
|
|
if !matches!(result["mode"].as_str(), Some("visual" | "gameplay")) {
|
|
return true;
|
|
}
|
|
evidence_hashes(root, result).is_ok_and(|hashes| hashes == result["evidenceHashes"])
|
|
}
|
|
|
|
fn reserve(
|
|
root: &Path,
|
|
session: std::sync::Arc<super::direct_execution::ExecutionSession>,
|
|
key: &str,
|
|
fingerprint: &str,
|
|
source: &str,
|
|
build: bool,
|
|
) -> Result<ValidationStart, String> {
|
|
session.tick()?;
|
|
let state = session.snapshot()?;
|
|
if state.phase.is_terminal() || state.phase == super::direct_execution::ExecutionPhase::Sealing
|
|
{
|
|
return Err("direct-execution-closed: 本轮已关闭验证".into());
|
|
}
|
|
if let Some(previous) = state.evidence.get(key) {
|
|
if previous.fingerprint == fingerprint
|
|
&& previous.source_fingerprint == source
|
|
&& previous.result["passed"] == true
|
|
&& cached_evidence_available(root, &previous.result)
|
|
{
|
|
return Ok(ValidationStart::Reused(budget_result(
|
|
previous.result.clone(),
|
|
&session,
|
|
0,
|
|
fingerprint,
|
|
true,
|
|
)?));
|
|
}
|
|
}
|
|
let lease = session.admit_validation(key, source)?;
|
|
let sequence = lease.sequence();
|
|
Ok(ValidationStart::Run(Reservation {
|
|
session,
|
|
lease: std::sync::Mutex::new(Some(lease)),
|
|
turn_id: state.client_turn_id,
|
|
sequence,
|
|
key: key.into(),
|
|
fingerprint: fingerprint.into(),
|
|
source_fingerprint: source.into(),
|
|
build,
|
|
}))
|
|
}
|
|
|
|
fn finish(
|
|
root: &Path,
|
|
reservation: &Reservation,
|
|
mut result: Value,
|
|
passed: bool,
|
|
) -> Result<Value, String> {
|
|
let source = source_input_fingerprint(root)?;
|
|
let output = source_fingerprint(root)?;
|
|
let unchanged = source == reservation.source_fingerprint
|
|
&& (reservation.build || output == reservation.fingerprint);
|
|
result["passed"] = json!(
|
|
passed && unchanged && result["needsReconciliation"] != true && result["timedOut"] != true
|
|
);
|
|
result["sourceChanged"] = json!(!unchanged);
|
|
result["outputFingerprint"] = json!(output);
|
|
if passed && unchanged {
|
|
result["evidenceHashes"] = evidence_hashes(root, &result)?;
|
|
}
|
|
let evidence = super::direct_execution::ExecutionEvidence {
|
|
key: reservation.key.clone(),
|
|
fingerprint: output.clone(),
|
|
source_fingerprint: source,
|
|
result: result.clone(),
|
|
};
|
|
let lease = reservation
|
|
.lease
|
|
.lock()
|
|
.map_err(|_| "validation-receipt: 租约不可用")?
|
|
.take()
|
|
.ok_or("validation-receipt: 租约已结算")?;
|
|
lease.finish(passed, !unchanged, Some(evidence))?;
|
|
budget_result(
|
|
result,
|
|
&reservation.session,
|
|
reservation.sequence,
|
|
&output,
|
|
false,
|
|
)
|
|
}
|
|
|
|
/// 只指纹运行输入,排除文档、运行日志与依赖缓存;包括构建产物与二进制素材。
|
|
/// 有界读取失败关闭,不把截断目录的摘要作为有效缓存键。
|
|
pub(super) fn source_fingerprint(root: &Path) -> Result<String, String> {
|
|
fingerprint(root, true)
|
|
}
|
|
|
|
/// npm 工程已声明的 dist 是输出;源码与运行产物分别绑定,正常构建不算返修。
|
|
pub(super) fn source_input_fingerprint(root: &Path) -> Result<String, String> {
|
|
fingerprint(root, false)
|
|
}
|
|
|
|
fn fingerprint(root: &Path, include_outputs: bool) -> Result<String, String> {
|
|
validate_project_root(root)?;
|
|
let mut directories = vec![root.to_path_buf()];
|
|
let mut files = Vec::new();
|
|
let mut bytes = 0u64;
|
|
let mut visited = 0usize;
|
|
while let Some(directory) = directories.pop() {
|
|
for entry in
|
|
std::fs::read_dir(&directory).map_err(|_| "validation-fingerprint: 读取项目失败")?
|
|
{
|
|
visited += 1;
|
|
if visited > 20_000 {
|
|
return Err("validation-fingerprint: 项目文件数超限".into());
|
|
}
|
|
let entry = entry.map_err(|_| "validation-fingerprint: 读取目录项失败")?;
|
|
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
|
|
if matches!(
|
|
name.as_str(),
|
|
".agent"
|
|
| ".git"
|
|
| ".hg"
|
|
| ".svn"
|
|
| ".codex"
|
|
| ".agents"
|
|
| ".npm-cache"
|
|
| ".cache"
|
|
| "node_modules"
|
|
| "target"
|
|
| "exports"
|
|
| "logs"
|
|
| "coverage"
|
|
) || name.starts_with(".edge-profile")
|
|
{
|
|
continue;
|
|
}
|
|
let ty = entry
|
|
.file_type()
|
|
.map_err(|_| "validation-fingerprint: 读取类型失败")?;
|
|
let path = entry.path();
|
|
if !include_outputs
|
|
&& name == "dist"
|
|
&& ty.is_dir()
|
|
&& (directory == root || directory == root.join("game"))
|
|
&& directory.join("package.json").is_file()
|
|
{
|
|
continue;
|
|
}
|
|
if ty.is_symlink() {
|
|
return Err("validation-fingerprint: 不复用含符号链接输入的验证".into());
|
|
}
|
|
if ty.is_dir() {
|
|
if directory == root && matches!(name.as_str(), "docs" | "memory") {
|
|
continue;
|
|
}
|
|
directories.push(path);
|
|
continue;
|
|
}
|
|
if !ty.is_file() {
|
|
continue;
|
|
}
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or_default()
|
|
.to_ascii_lowercase();
|
|
// 文档目录和根说明不影响运行;public/assets/data 内的 Markdown 或文本
|
|
// 可能就是剧情/关卡输入,不能仅凭扩展名排除。
|
|
if directory == root && matches!(ext.as_str(), "md" | "log") {
|
|
continue;
|
|
}
|
|
if name.starts_with("game-creator.config") {
|
|
continue;
|
|
}
|
|
let mut file =
|
|
std::fs::File::open(&path).map_err(|_| "validation-fingerprint: 打开输入失败")?;
|
|
let mut digest = Sha256::new();
|
|
let mut buffer = [0u8; 64 * 1024];
|
|
loop {
|
|
let read = file
|
|
.read(&mut buffer)
|
|
.map_err(|_| "validation-fingerprint: 读取输入失败")?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
bytes = bytes.saturating_add(read as u64);
|
|
if bytes > 512 * 1024 * 1024 {
|
|
return Err("validation-fingerprint: 项目输入超过大小预算".into());
|
|
}
|
|
digest.update(&buffer[..read]);
|
|
}
|
|
let relative = path
|
|
.strip_prefix(root)
|
|
.map_err(|_| "validation-fingerprint: 路径越界")?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
files.push((relative, format!("{:x}", digest.finalize())));
|
|
}
|
|
}
|
|
files.sort();
|
|
let mut digest = Sha256::new();
|
|
for (path, hash) in files {
|
|
digest.update(path);
|
|
digest.update([0]);
|
|
digest.update(hash);
|
|
digest.update([0]);
|
|
}
|
|
Ok(format!("{:x}", digest.finalize()))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct BrowserRequest {
|
|
#[serde(default)]
|
|
attempt: Option<u64>,
|
|
#[serde(default = "visual_mode")]
|
|
mode: String,
|
|
#[serde(default)]
|
|
scenario: Option<crate::browser::BrowserPlaytestScenario>,
|
|
}
|
|
|
|
fn visual_mode() -> String {
|
|
"visual".into()
|
|
}
|
|
|
|
fn browser_request(arguments: &Value) -> Result<BrowserRequest, String> {
|
|
let input: BrowserRequest = serde_json::from_value(arguments.clone())
|
|
.map_err(|_| "工具参数:浏览器验证只接受 attempt、mode、scenario".to_string())?;
|
|
if input.attempt == Some(0) || !matches!(input.mode.as_str(), "visual" | "gameplay") {
|
|
return Err("工具参数:mode 必须为 visual 或 gameplay,旧 attempt 必须为正整数".into());
|
|
}
|
|
if input.mode == "visual" && input.scenario.is_some() {
|
|
return Err("工具参数:visual 不执行玩法场景,请使用 gameplay".into());
|
|
}
|
|
Ok(input)
|
|
}
|
|
|
|
async fn start_for_current_turn(
|
|
root: &Path,
|
|
key: String,
|
|
build: bool,
|
|
) -> Result<ValidationStart, String> {
|
|
let turn_id = active_turn_id_at(root)?;
|
|
let root = root.to_path_buf();
|
|
tokio::task::spawn_blocking(move || {
|
|
if active_turn_id_at(&root)? != turn_id {
|
|
return Err("validation-turn-changed: 当前验证所属回合已结束".into());
|
|
}
|
|
let session = super::direct_execution::current(&root)?;
|
|
let fingerprint = source_fingerprint(&root)?;
|
|
let source = source_input_fingerprint(&root)?;
|
|
reserve(&root, session, &key, &fingerprint, &source, build)
|
|
})
|
|
.await
|
|
.map_err(|_| "验证预约任务退出".to_string())?
|
|
}
|
|
|
|
async fn finish_async(
|
|
root: &Path,
|
|
reservation: Reservation,
|
|
result: Value,
|
|
passed: bool,
|
|
) -> Result<Value, String> {
|
|
let root = root.to_path_buf();
|
|
tokio::task::spawn_blocking(move || finish(&root, &reservation, result, passed))
|
|
.await
|
|
.map_err(|_| "验证回执任务退出".to_string())?
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct GameplayViewportEvidence {
|
|
viewport: crate::browser::BrowserValidationViewport,
|
|
passed: bool,
|
|
diagnostics: Vec<String>,
|
|
assertions: Vec<crate::browser::BrowserPlaytestAssertion>,
|
|
initial_phase: Option<crate::browser::BrowserPlaytestPhase>,
|
|
initial_sequence: Option<u64>,
|
|
initial_level: Option<u64>,
|
|
final_phase: Option<crate::browser::BrowserPlaytestPhase>,
|
|
final_sequence: Option<u64>,
|
|
final_level: Option<u64>,
|
|
}
|
|
|
|
/// 只投影模型修复所需的玩法事实;完整报告继续留在宿主私有目录。
|
|
pub(crate) fn browser_tool_result(
|
|
root: &Path,
|
|
evidence: &crate::browser::BrowserValidationResult,
|
|
scenario: Option<crate::browser::BrowserPlaytestScenario>,
|
|
sequence: usize,
|
|
) -> Value {
|
|
use crate::browser::BrowserValidationViewport::{Desktop, Mobile};
|
|
|
|
let gameplay_results = if scenario.is_some() {
|
|
[Desktop, Mobile]
|
|
.into_iter()
|
|
.map(|viewport| {
|
|
let result = evidence
|
|
.viewport_playtests
|
|
.iter()
|
|
.find(|entry| entry.viewport == viewport)
|
|
.map(|entry| &entry.result);
|
|
GameplayViewportEvidence {
|
|
viewport,
|
|
passed: result.is_some_and(|result| result.passed),
|
|
diagnostics: result
|
|
.map(|result| {
|
|
result
|
|
.diagnostics
|
|
.iter()
|
|
.take(4)
|
|
.map(|message| truncate_agent_runtime_text(message, 511))
|
|
.collect()
|
|
})
|
|
.unwrap_or_else(|| {
|
|
vec![prompt_text!("direct.browser.gameplayMissing").into()]
|
|
}),
|
|
assertions: result
|
|
.map(|result| result.assertions.clone())
|
|
.unwrap_or_default(),
|
|
initial_phase: result.and_then(|result| result.initial_phase),
|
|
initial_sequence: result.and_then(|result| result.initial_sequence),
|
|
initial_level: result.and_then(|result| result.initial_level),
|
|
final_phase: result.and_then(|result| result.final_phase),
|
|
final_sequence: result.and_then(|result| result.final_sequence),
|
|
final_level: result.and_then(|result| result.final_level),
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let gameplay_summary = if scenario.is_none() {
|
|
prompt_text!("direct.browser.gameplayNotRun").to_string()
|
|
} else {
|
|
let lines = gameplay_results
|
|
.iter()
|
|
.map(|result| {
|
|
format!(
|
|
prompt_text!("direct.browser.gameplayViewport"),
|
|
viewport = match result.viewport {
|
|
Desktop => "desktop",
|
|
Mobile => "mobile",
|
|
},
|
|
passed = result.passed,
|
|
diagnostic = result
|
|
.diagnostics
|
|
.first()
|
|
.map(String::as_str)
|
|
.unwrap_or(prompt_text!("direct.browser.gameplayNoDiagnostic")),
|
|
assertion = result
|
|
.assertions
|
|
.iter()
|
|
.find(|assertion| !assertion.passed)
|
|
.map(|assertion| assertion.name.as_str())
|
|
.unwrap_or(prompt_text!("direct.browser.gameplayNoUnmetAssertion")),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
format!(
|
|
prompt_text!("direct.browser.gameplayEvidence"),
|
|
lines.join("\n")
|
|
)
|
|
};
|
|
let summary = format!(
|
|
"{}\n{}",
|
|
render_direct_browser_tool_evidence(root, evidence, sequence),
|
|
gameplay_summary
|
|
);
|
|
let screenshots: Vec<_> = evidence
|
|
.viewport_results
|
|
.iter()
|
|
.filter_map(|viewport| {
|
|
viewport
|
|
.screenshot_path
|
|
.strip_prefix(root)
|
|
.ok()
|
|
.map(|path| path.to_string_lossy().replace('\\', "/"))
|
|
})
|
|
.collect();
|
|
let passed = evidence.passed
|
|
&& scenario.is_none_or(|scenario| {
|
|
crate::browser::required_viewport_playtests_passed(
|
|
scenario,
|
|
&evidence.viewport_playtests,
|
|
)
|
|
});
|
|
json!({
|
|
"kind":"browser", "mode":if scenario.is_some() {"gameplay"} else {"visual"},
|
|
"scenario":scenario, "passed":passed,
|
|
"scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint),
|
|
"summary":summary, "screenshots":screenshots,
|
|
"reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|path|path.to_string_lossy().replace('\\', "/")),
|
|
"visualViewports":["desktop","mobile"],
|
|
"gameplayViewports":evidence.viewport_playtests.iter().map(|entry|entry.viewport).collect::<Vec<_>>(),
|
|
"gameplayResults":gameplay_results,
|
|
"coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"}
|
|
})
|
|
}
|
|
|
|
pub(super) async fn run_browser(root: &Path, arguments: &Value) -> Result<Value, String> {
|
|
run_browser_with_budget(root, arguments, true).await
|
|
}
|
|
|
|
/// 独立客户端 MCP 没有 Direct user turn;显式保持其既有浏览器能力,不能借用另一回合账本。
|
|
pub(super) async fn run_external_browser(root: &Path, arguments: &Value) -> Result<Value, String> {
|
|
let mut arguments = arguments.clone();
|
|
if arguments.get("mode").is_none() {
|
|
arguments["mode"] = json!("gameplay");
|
|
}
|
|
run_browser_with_budget(root, &arguments, false).await
|
|
}
|
|
|
|
async fn run_browser_with_budget(
|
|
root: &Path,
|
|
arguments: &Value,
|
|
direct_turn: bool,
|
|
) -> Result<Value, String> {
|
|
enforce_project_permission_policy(root, "game.run_local")?;
|
|
let input = browser_request(arguments)?;
|
|
let scenario = if input.mode == "gameplay" {
|
|
Some(
|
|
input
|
|
.scenario
|
|
.unwrap_or(crate::browser::BrowserPlaytestScenario::GenericV1),
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let key = format!(
|
|
"browser:dual-viewport:{}:{}:{}",
|
|
input.mode,
|
|
serde_json::to_string(&scenario).map_err(|_| "验证场景序列化失败")?,
|
|
scenario
|
|
.map(crate::browser::browser_playtest_scenario_fingerprint)
|
|
.unwrap_or_default()
|
|
);
|
|
let reservation = if direct_turn {
|
|
Some(match start_for_current_turn(root, key, false).await? {
|
|
ValidationStart::Reused(result) => return Ok(result),
|
|
ValidationStart::Run(reservation) => reservation,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
let turn_id = reservation
|
|
.as_ref()
|
|
.map(|r| r.turn_id.clone())
|
|
.unwrap_or_else(|| format!("external-{}", uuid::Uuid::new_v4()));
|
|
let sequence = reservation.as_ref().map(|r| r.sequence()).unwrap_or(1);
|
|
let evidence_root = resolve_local_project_path(
|
|
root,
|
|
&format!(
|
|
".agent/runtime/direct-validation-evidence/{:x}/{}",
|
|
Sha256::digest(turn_id.as_bytes()),
|
|
sequence,
|
|
),
|
|
)?;
|
|
let evidence = super::direct_runtime::run_direct_browser_evidence_with_analytics_at(
|
|
root,
|
|
evidence_root,
|
|
scenario,
|
|
false,
|
|
reservation.as_ref().map(|r| r.session.cancel_flag()),
|
|
reservation
|
|
.as_ref()
|
|
.and_then(|r| r.session.analytics_capture()),
|
|
)
|
|
.await;
|
|
let (result, passed) = match evidence {
|
|
Ok(evidence) => {
|
|
let result = browser_tool_result(root, &evidence, scenario, sequence);
|
|
let passed = result["passed"] == true;
|
|
(result, passed)
|
|
}
|
|
Err(error) => (
|
|
json!({"mode":input.mode,"needsReconciliation":error.starts_with("browser-cleanup-unconfirmed:"),"error":truncate_agent_runtime_text(&error, 1800)}),
|
|
false,
|
|
),
|
|
};
|
|
if let Some(reservation) = reservation {
|
|
finish_async(root, reservation, result, passed).await
|
|
} else {
|
|
let mut result = result;
|
|
result["passed"] = json!(passed);
|
|
result["budgetScope"] = json!("external-client");
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct CommandRequest {
|
|
program: String,
|
|
arguments: Vec<String>,
|
|
#[serde(default = "root_cwd")]
|
|
cwd: String,
|
|
#[serde(default = "command_timeout")]
|
|
timeout_seconds: u64,
|
|
#[serde(default = "test_purpose")]
|
|
purpose: String,
|
|
}
|
|
fn test_purpose() -> String {
|
|
"test".into()
|
|
}
|
|
|
|
fn root_cwd() -> String {
|
|
".".into()
|
|
}
|
|
fn command_timeout() -> u64 {
|
|
300
|
|
}
|
|
|
|
pub(super) async fn run_command(root: &Path, arguments: &Value) -> Result<Value, String> {
|
|
enforce_project_permission_policy(root, "game.run_local")?;
|
|
enforce_project_permission_policy(root, "command.exec")?;
|
|
let input: CommandRequest = serde_json::from_value(arguments.clone()).map_err(|_| {
|
|
"工具参数:验证命令需要 program、arguments,可选 cwd、timeoutSeconds".to_string()
|
|
})?;
|
|
if !matches!(input.program.as_str(), "node" | "npm") {
|
|
return Err("工具参数:托管验证仅允许 node --test 或 npm 测试脚本".into());
|
|
}
|
|
if !matches!(input.purpose.as_str(), "build" | "test")
|
|
|| (input.purpose == "build"
|
|
&& !(input.program == "npm" && input.arguments == ["run", "build"]))
|
|
{
|
|
return Err("工具参数:purpose 必须为 test 或 build,构建使用 npm run build".into());
|
|
}
|
|
// 沿用 command.exec 的路径、参数、环境隔离、输出与超时边界。
|
|
let spec = crate::command_exec::resolve_project_command_spec_at(
|
|
root,
|
|
&input.program,
|
|
&input.arguments,
|
|
&input.cwd,
|
|
input.timeout_seconds,
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
if !spec.verification_eligible {
|
|
return Err("工具参数:命令不是验证脚本".into());
|
|
}
|
|
let launch = crate::command_exec::prepare_project_command_launch_spec(root, &spec)
|
|
.map_err(|e| e.to_string())?;
|
|
let mut staged = crate::command_exec::stage_project_command_launch_spec(&spec, launch)
|
|
.map_err(|e| e.to_string())?;
|
|
let key = format!("command:{:x}", Sha256::digest(serde_json::to_vec(&json!({
|
|
"program":input.program,"arguments":input.arguments,"cwd":input.cwd,"purpose":input.purpose
|
|
})).map_err(|_| "验证命令身份序列化失败")?));
|
|
let reservation = match start_for_current_turn(root, key, input.purpose == "build").await? {
|
|
ValidationStart::Reused(result) => return Ok(result),
|
|
ValidationStart::Run(reservation) => reservation,
|
|
};
|
|
staged.cancel_flag = Some(reservation.session.cancel_flag());
|
|
let execution = crate::command_exec::run_prepared_project_command_with_output_at(
|
|
root,
|
|
&spec,
|
|
staged,
|
|
None,
|
|
|| Ok(()),
|
|
)
|
|
.await;
|
|
let (result, passed) = match execution {
|
|
Ok(result) => {
|
|
let passed = result.status == "completed";
|
|
(
|
|
json!({"kind":"command", "mode":"command", "program":input.program,"args":input.arguments,"cwd":input.cwd,"purpose":input.purpose,
|
|
"exitCode":result.exit_code,"timedOut":result.timed_out,
|
|
"durationMs":result.duration_ms,"output":truncate_agent_runtime_text(&result.output,12_000),
|
|
"outputTruncated":result.capture_truncated,"coverage":"command-exit-status"}),
|
|
passed,
|
|
)
|
|
}
|
|
Err(error) => (
|
|
json!({"mode":"command","needsReconciliation":error.needs_reconciliation(),"error":truncate_agent_runtime_text(&error.to_string(),1800)}),
|
|
false,
|
|
),
|
|
};
|
|
finish_async(root, reservation, result, passed).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) mod tests {
|
|
use super::*;
|
|
|
|
pub(crate) fn browser_evidence_fixture(root: &Path) -> crate::browser::BrowserValidationResult {
|
|
let mut viewports = Vec::new();
|
|
let mut playtests = Vec::new();
|
|
for viewport in ["desktop", "mobile"] {
|
|
viewports.push(json!({
|
|
"viewport":viewport, "width":1280, "height":720,
|
|
"finalUrl":"http://127.0.0.1/", "title":"fixture", "readyState":"complete",
|
|
"visibleTextSummary":"", "visibleTextCharacterCount":0, "domCharacterCount":0,
|
|
"expectedText":[], "consoleErrors":[], "consoleWarnings":[], "exceptions":[],
|
|
"failedRequests":[], "canvases":[], "diagnostics":[], "passed":true,
|
|
"blockedPopupCount":0, "blockedDialogCount":0, "blockedDownloadCount":0,
|
|
"blockedPermissionCount":0, "blockedServiceWorkerCount":0,
|
|
"screenshotPath":root.join(format!("{viewport}.png"))
|
|
}));
|
|
playtests.push(json!({"viewport":viewport, "result":{
|
|
"scenario":"generic-v1", "scenarioFingerprint":"fixture", "passed":true,
|
|
"initialSequence":0, "initialPhase":"ready", "initialLevel":1,
|
|
"finalSequence":3, "finalPhase":"ready", "finalLevel":1,
|
|
"assertions":[
|
|
{"name":"start-control-clicked", "passed":true},
|
|
{"name":"start-phase-playing", "passed":true},
|
|
{"name":"restart-control-clicked", "passed":true}
|
|
], "diagnostics":[]
|
|
}}));
|
|
}
|
|
serde_json::from_value(json!({
|
|
"schemaVersion":"browser-validation.v1", "url":"http://127.0.0.1/",
|
|
"browser":{"kind":"chrome", "product":"fixture", "protocolVersion":"1.3"},
|
|
"passed":true, "viewportResults":viewports, "viewportPlaytests":playtests,
|
|
"diagnostics":[], "completedAtUnixMs":1,
|
|
"evidence":{"root":root, "reportPath":root.join("validation.json")}
|
|
}))
|
|
.unwrap()
|
|
}
|
|
|
|
pub(crate) fn failed_browser_evidence_fixture(
|
|
root: &Path,
|
|
) -> crate::browser::BrowserValidationResult {
|
|
let mut evidence = browser_evidence_fixture(root);
|
|
evidence.passed = false;
|
|
for (index, entry) in evidence.viewport_playtests.iter_mut().enumerate() {
|
|
entry.result.passed = false;
|
|
entry.result.final_phase = Some(crate::browser::BrowserPlaytestPhase::Ready);
|
|
entry.result.final_sequence = Some(index as u64);
|
|
entry.result.diagnostics = vec![if index == 0 {
|
|
"固定试玩控件 start 处于 disabled 状态".into()
|
|
} else {
|
|
"generic-v1 start 后必须进入 playing".into()
|
|
}];
|
|
for (assertion_index, assertion) in entry.result.assertions.iter_mut().enumerate() {
|
|
assertion.passed = index == 1 && assertion_index == 0;
|
|
}
|
|
}
|
|
evidence
|
|
}
|
|
|
|
#[test]
|
|
fn browser_tool_projection_distinguishes_gameplay_success_visual_only_and_missing_viewport() {
|
|
use crate::browser::BrowserPlaytestScenario::GenericV1;
|
|
let root = tempfile::tempdir().unwrap();
|
|
for mode in ["gameplay", "visual", "missing-mobile"] {
|
|
let mut evidence = browser_evidence_fixture(root.path());
|
|
let scenario = if mode == "visual" {
|
|
evidence.viewport_playtests.clear();
|
|
None
|
|
} else {
|
|
Some(GenericV1)
|
|
};
|
|
if mode == "missing-mobile" {
|
|
evidence.viewport_playtests.pop();
|
|
evidence.passed = false;
|
|
}
|
|
let receipt = browser_tool_result(root.path(), &evidence, scenario, 1);
|
|
let summary = receipt["summary"].as_str().unwrap();
|
|
assert_eq!(receipt["passed"], mode != "missing-mobile");
|
|
if mode == "visual" {
|
|
assert_eq!(receipt["gameplayResults"], json!([]));
|
|
assert!(summary
|
|
.lines()
|
|
.any(|line| line.starts_with("玩法检查:未执行")));
|
|
assert_eq!(receipt["coverage"], "visual-only");
|
|
continue;
|
|
}
|
|
for (index, viewport) in ["desktop", "mobile"].iter().enumerate() {
|
|
let result = &receipt["gameplayResults"][index];
|
|
assert_eq!(result["viewport"], *viewport);
|
|
if mode == "missing-mobile" && index == 1 {
|
|
assert_eq!(result["passed"], false);
|
|
assert!(result["diagnostics"][0].as_str().unwrap().contains("缺失"));
|
|
assert!(summary.contains("mobile: gameplayPassed=false"));
|
|
assert_eq!(result["assertions"], json!([]));
|
|
assert_eq!(result["finalPhase"], Value::Null);
|
|
} else {
|
|
assert_eq!(result["passed"], true);
|
|
assert_eq!(result["diagnostics"], json!([]));
|
|
assert!(result["assertions"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|a| a["passed"] == true));
|
|
assert!(summary.contains(&format!("{viewport}: gameplayPassed=true")));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn browser_tool_projection_bounds_diagnostics_without_losing_either_blocker() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let mut evidence = failed_browser_evidence_fixture(root.path());
|
|
for viewport in &mut evidence.viewport_playtests {
|
|
viewport
|
|
.result
|
|
.diagnostics
|
|
.extend((0..8).map(|_| "额外诊断".repeat(200)));
|
|
}
|
|
let receipt = browser_tool_result(
|
|
root.path(),
|
|
&evidence,
|
|
Some(crate::browser::BrowserPlaytestScenario::GenericV1),
|
|
1,
|
|
);
|
|
for (index, entry) in evidence.viewport_playtests.iter().enumerate() {
|
|
let diagnostics = receipt["gameplayResults"][index]["diagnostics"]
|
|
.as_array()
|
|
.unwrap();
|
|
assert_eq!(diagnostics.len(), 4);
|
|
assert_eq!(diagnostics[0], entry.result.diagnostics[0]);
|
|
assert!(diagnostics
|
|
.iter()
|
|
.all(|d| d.as_str().unwrap().chars().count() <= 512));
|
|
assert!(receipt["summary"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains(&entry.result.diagnostics[0]));
|
|
}
|
|
}
|
|
|
|
fn project() -> (tempfile::TempDir, std::path::PathBuf) {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let root = temp.path().join("project");
|
|
init_local_game_project_at(&root, "validation-budget", "验证预算").unwrap();
|
|
(temp, root)
|
|
}
|
|
|
|
fn session(
|
|
temp: &tempfile::TempDir,
|
|
root: &Path,
|
|
) -> std::sync::Arc<super::super::direct_execution::ExecutionSession> {
|
|
let session = super::super::direct_execution::open_at(
|
|
&temp.path().join("host"),
|
|
root,
|
|
"validation-turn",
|
|
&format!("{:x}", Sha256::digest(b"request")),
|
|
false,
|
|
&Default::default(),
|
|
)
|
|
.unwrap();
|
|
session
|
|
.freeze_contract(json!({"requirements":[{"id":"build"}]}))
|
|
.unwrap();
|
|
session
|
|
}
|
|
|
|
#[test]
|
|
fn successful_build_binds_source_and_new_output_without_false_drift() {
|
|
let (temp, root) = project();
|
|
let session = session(&temp, &root);
|
|
let source = source_input_fingerprint(&root).unwrap();
|
|
let before = source_fingerprint(&root).unwrap();
|
|
let ValidationStart::Run(reservation) =
|
|
reserve(&root, session.clone(), "build", &before, &source, true).unwrap()
|
|
else {
|
|
panic!("initial build")
|
|
};
|
|
std::fs::create_dir_all(root.join("game/dist")).unwrap();
|
|
std::fs::write(root.join("game/dist/index.html"), "built game").unwrap();
|
|
assert_eq!(source, source_input_fingerprint(&root).unwrap());
|
|
let result = finish(
|
|
&root,
|
|
&reservation,
|
|
json!({"kind":"command","mode":"command","purpose":"build","exitCode":0}),
|
|
true,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(result["passed"], true);
|
|
assert_eq!(result["sourceChanged"], false);
|
|
let output = source_fingerprint(&root).unwrap();
|
|
assert_ne!(before, output);
|
|
assert_eq!(
|
|
session.snapshot().unwrap().evidence["build"].fingerprint,
|
|
output
|
|
);
|
|
assert!(matches!(
|
|
reserve(&root, session.clone(), "build", &output, &source, true).unwrap(),
|
|
ValidationStart::Reused(_)
|
|
));
|
|
assert_eq!(session.snapshot().unwrap().used_passes, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_source_write_cannot_turn_a_test_into_current_success() {
|
|
let (temp, root) = project();
|
|
let session = session(&temp, &root);
|
|
let fp = source_fingerprint(&root).unwrap();
|
|
let source = source_input_fingerprint(&root).unwrap();
|
|
let ValidationStart::Run(reservation) =
|
|
reserve(&root, session.clone(), "test", &fp, &source, false).unwrap()
|
|
else {
|
|
panic!("initial test")
|
|
};
|
|
std::fs::write(root.join("game/game.js"), "changed actual source").unwrap();
|
|
let result = finish(&root, &reservation, json!({"mode":"command"}), true).unwrap();
|
|
assert_eq!(result["passed"], false);
|
|
assert_eq!(result["sourceChanged"], true);
|
|
assert_eq!(
|
|
session.snapshot().unwrap().phase,
|
|
super::super::direct_execution::ExecutionPhase::Draining
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn browser_reuse_requires_the_original_report_and_both_screenshot_hashes() {
|
|
let (_temp, root) = project();
|
|
let relative = ".agent/runtime/direct-validation-evidence/test/1";
|
|
std::fs::create_dir_all(root.join(relative)).unwrap();
|
|
for name in ["report.json", "desktop.png", "mobile.png"] {
|
|
std::fs::write(root.join(relative).join(name), name).unwrap();
|
|
}
|
|
let mut result = json!({"mode":"visual","reportPath":format!("{relative}/report.json"),"screenshots":[format!("{relative}/desktop.png"),format!("{relative}/mobile.png")]});
|
|
result["evidenceHashes"] = evidence_hashes(&root, &result).unwrap();
|
|
assert!(cached_evidence_available(&root, &result));
|
|
std::fs::write(root.join(relative).join("mobile.png"), "forged").unwrap();
|
|
assert!(!cached_evidence_available(&root, &result));
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_text_and_hidden_public_assets_are_part_of_cache_identity() {
|
|
let (_temp, root) = project();
|
|
let initial = source_fingerprint(&root).unwrap();
|
|
std::fs::create_dir_all(root.join("game/public/.well-known")).unwrap();
|
|
std::fs::write(root.join("game/public/levels.txt"), "level data").unwrap();
|
|
let text = source_fingerprint(&root).unwrap();
|
|
assert_ne!(initial, text);
|
|
std::fs::write(root.join("game/public/.well-known/runtime.json"), "{}").unwrap();
|
|
let hidden = source_fingerprint(&root).unwrap();
|
|
assert_ne!(text, hidden);
|
|
std::fs::write(root.join("game/public/story.md"), "runtime story").unwrap();
|
|
assert_ne!(hidden, source_fingerprint(&root).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn vite_environment_inputs_invalidate_build_evidence_without_returning_contents() {
|
|
let (_temp, root) = project();
|
|
std::fs::write(root.join("game/.env.production"), "VITE_SPEED=1\n").unwrap();
|
|
let source = source_input_fingerprint(&root).unwrap();
|
|
let runtime = source_fingerprint(&root).unwrap();
|
|
std::fs::write(root.join("game/.env.production"), "VITE_SPEED=2\n").unwrap();
|
|
let changed = source_input_fingerprint(&root).unwrap();
|
|
assert_ne!(source, changed);
|
|
assert_ne!(runtime, source_fingerprint(&root).unwrap());
|
|
assert_eq!(changed.len(), 64);
|
|
assert!(!changed.contains("VITE_SPEED"));
|
|
}
|
|
|
|
#[test]
|
|
fn layer_selection_and_legacy_attempt_cannot_override_client_budget() {
|
|
assert_eq!(
|
|
browser_request(&json!({"attempt":999})).unwrap().mode,
|
|
"visual"
|
|
);
|
|
assert!(browser_request(&json!({"mode":"visual","scenario":"generic-v1"})).is_err());
|
|
assert!(browser_request(&json!({"mode":"gameplay","scenario":"generic-v1"})).is_ok());
|
|
assert!(DirectValidationConfig {
|
|
max_runs: 0,
|
|
..Default::default()
|
|
}
|
|
.validate()
|
|
.is_err());
|
|
assert_eq!(
|
|
serde_json::from_value::<DirectValidationConfig>(json!({"maxRuns":99}))
|
|
.unwrap()
|
|
.max_runs,
|
|
99
|
|
);
|
|
}
|
|
}
|