Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs
T
lhk229 ec3a187dd7
Project CI / AI game creator shell Rust crates (push) Successful in 1m11s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m34s
Project CI / Backend tests (push) Successful in 4m46s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m31s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m41s
Project CI / Frontend tests (push) Successful in 2m19s
Project CI / Native shell tests (push) Successful in 7m5s
Project CI / AI game creator shell web tests (push) Successful in 1m54s
Project CI / Repository checks (push) Successful in 2m36s
处理编译大量warning问题 (#503)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/503
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-24 13:01:31 +08:00

991 lines
35 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::*;
pub(crate) fn run_limited_local_command_at(
root: &Path,
command_id: &str,
) -> Result<LimitedLocalCommandResult, String> {
if command_id != "game.static_smoke" {
return Err("不支持的受限命令".to_string());
}
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
let (game_index_path, html) = validate_project_game_entry(root)?;
let output = format!(
"通过:{},{} 字节",
relative_project_path(root, &game_index_path)?,
html.len()
);
let log_path = root.join(".agent/logs/command.log");
let updated_at = unix_timestamp();
let line = format!("{updated_at} command.run_limited {command_id}: {output}\n");
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?;
record_command_run(
root,
GameCreationAppCommandRunState {
command_id: command_id.to_string(),
status: GameCreationAppCommandRunStatus::Completed,
output: output.clone(),
log_path: ".agent/logs/command.log".to_string(),
updated_at,
},
)?;
Ok(LimitedLocalCommandResult {
command_id: command_id.to_string(),
status: "completed".to_string(),
output,
log_path: ".agent/logs/command.log".to_string(),
updated_at,
})
}
pub(crate) fn validate_project_game_entry(root: &Path) -> Result<(PathBuf, String), String> {
let game_root = crate::preview::project_game_root(root);
let index = crate::preview::resolve_preview_path(root, "/")?;
prepare_game_creator_private_path_for_read(&index, false, "游戏入口")?;
let html = fs::read_to_string(&index).map_err(|error| format!("读取游戏入口失败:{error}"))?;
let lower = html.to_ascii_lowercase();
if !lower.contains("<html") && !lower.contains("<!doctype html") {
return Err("游戏入口不是 HTML 文档".to_string());
}
if game_root == root.join("dist") || game_root == root.join("game/dist") {
validate_built_game_references(root, &html)?;
} else {
validate_game_html_smoke(&html)?;
}
Ok((index, html))
}
fn validate_built_game_references(root: &Path, html: &str) -> Result<(), String> {
let tags = regex::Regex::new(r"(?is)<(?:script|link|img|audio|video|source)\b[^>]*>").unwrap();
let attributes =
regex::Regex::new(r#"(?is)\s+([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#)
.unwrap();
for tag in tags.find_iter(html) {
for attribute in attributes.captures_iter(tag.as_str()) {
let name = attribute.get(1).unwrap().as_str();
if !name.eq_ignore_ascii_case("src") && !name.eq_ignore_ascii_case("href") {
continue;
}
let value = attribute
.get(2)
.or_else(|| attribute.get(3))
.or_else(|| attribute.get(4))
.unwrap()
.as_str();
if value.is_empty()
|| value.starts_with('#')
|| value.starts_with("//")
|| value.contains(':')
{
continue;
}
let path = value.split(['?', '#']).next().unwrap_or(value);
let path = path.strip_prefix("./").unwrap_or(path);
crate::preview::resolve_preview_path(
root,
&format!("/{}", path.trim_start_matches('/')),
)
.map_err(|error| format!("构建入口引用不可用:{value}: {error}"))?;
}
}
Ok(())
}
pub(crate) const PROJECT_VERIFICATION_OUTPUT_MAX_BYTES: usize = 24 * 1024;
const PROJECT_VERIFICATION_PACKAGE_MAX_BYTES: u64 = 512 * 1024;
const PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS: u64 = 1;
const PROJECT_VERIFICATION_MAX_TIMEOUT_SECONDS: u64 = 300;
const PROJECT_VERIFICATION_SCRIPT_MAX_CHARS: usize = 160;
const PROJECT_VERIFICATION_NAMED_SCRIPT_PREFIXES: [&str; 7] = [
"check:",
"test:",
"lint:",
"typecheck:",
"build:",
"verify:",
"validate:",
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectVerificationSpec {
pub(crate) script: String,
pub(crate) expected_command: String,
pub(crate) package_manager: String,
pub(crate) program: String,
pub(crate) arguments: Vec<String>,
pub(crate) timeout_seconds: u64,
pub(crate) cwd_relative: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectVerificationResult {
pub(crate) command_id: String,
pub(crate) script: String,
pub(crate) expected_command: String,
pub(crate) package_manager: String,
pub(crate) cwd_relative: String,
pub(crate) status: String,
pub(crate) exit_code: Option<i32>,
pub(crate) timed_out: bool,
pub(crate) duration_ms: u64,
pub(crate) output: String,
pub(crate) sandbox_backend: String,
pub(crate) sandbox_mode: String,
pub(crate) network_access: String,
pub(crate) sandbox_profile_version: String,
pub(crate) sandbox_establishment: String,
pub(crate) target_exec: String,
pub(crate) launch_failure_kind: Option<String>,
pub(crate) log_path: String,
pub(crate) updated_at: u64,
}
#[derive(Debug)]
struct ProjectVerificationProcessResult {
exit_code: Option<i32>,
timed_out: bool,
output: String,
sandbox_backend: String,
sandbox_mode: String,
network_access: String,
sandbox_profile_version: String,
sandbox_establishment: String,
target_exec: String,
launch_failure_kind: Option<String>,
}
#[derive(Debug)]
struct BoundedProcessBytes {
head: Vec<u8>,
tail: std::collections::VecDeque<u8>,
total: usize,
max_bytes: usize,
}
impl BoundedProcessBytes {
fn new(max_bytes: usize) -> Self {
Self {
head: Vec::new(),
tail: std::collections::VecDeque::new(),
total: 0,
max_bytes,
}
}
fn push(&mut self, chunk: &[u8]) {
self.total = self.total.saturating_add(chunk.len());
let head_limit = self.max_bytes / 3;
let tail_limit = self.max_bytes.saturating_sub(head_limit);
let head_remaining = head_limit.saturating_sub(self.head.len());
let head_len = head_remaining.min(chunk.len());
self.head.extend_from_slice(&chunk[..head_len]);
self.tail.extend(&chunk[head_len..]);
while self.tail.len() > tail_limit {
self.tail.pop_front();
}
}
fn finish(self) -> String {
let tail = self.tail.into_iter().collect::<Vec<_>>();
if self.total <= self.max_bytes {
let mut bytes = self.head;
bytes.extend(tail);
return String::from_utf8_lossy(&bytes).into_owned();
}
let omitted = self.total.saturating_sub(self.head.len() + tail.len());
format!(
"{}\n...<{} output bytes omitted>...\n{}",
String::from_utf8_lossy(&self.head),
omitted,
String::from_utf8_lossy(&tail)
)
}
}
pub(crate) fn project_verification_npm_program() -> &'static str {
if cfg!(windows) {
"npm.cmd"
} else {
"npm"
}
}
fn project_verification_script_allowed(script: &str) -> bool {
matches!(script, "check" | "typecheck" | "test" | "lint" | "build")
|| PROJECT_VERIFICATION_NAMED_SCRIPT_PREFIXES
.iter()
.any(|prefix| {
script
.strip_prefix(prefix)
.is_some_and(project_verification_named_script_suffix_allowed)
})
}
fn project_verification_named_script_suffix_allowed(suffix: &str) -> bool {
suffix.split(':').all(|segment| {
let mut characters = segment.chars();
characters
.next()
.is_some_and(|character| character.is_ascii_alphanumeric())
&& characters.all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
})
})
}
fn ensure_project_verification_has_no_project_npmrc(root: &Path) -> Result<(), String> {
let path = root.join(".npmrc");
match fs::symlink_metadata(&path) {
Ok(_) => Err(
"project.verify 不允许项目级 .npmrc 改写 npm 执行语义;请移除后重新确认".to_string(),
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!(
"project.verify 检查项目级 .npmrc 失败:{}: {error}",
path.display()
)),
}
}
fn project_verification_package_manager_at(
root: &Path,
package: &serde_json::Value,
) -> Result<&'static str, String> {
let declared = package
.get("packageManager")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(declared) = declared {
let manager = declared.split('@').next().unwrap_or(declared);
if manager != "npm" {
return Err(format!(
"project.verify 当前只支持 npm 项目,packageManager 声明为 {manager}"
));
}
return Ok("npm");
}
for (lock_file, manager) in [
("pnpm-lock.yaml", "pnpm"),
("yarn.lock", "yarn"),
("bun.lock", "bun"),
("bun.lockb", "bun"),
] {
if root.join(lock_file).exists() {
return Err(format!(
"project.verify 当前只支持 npm 项目,检测到 {manager} 锁文件 {lock_file}"
));
}
}
Ok("npm")
}
#[cfg(test)]
pub(crate) fn resolve_project_verification_spec_at(
root: &Path,
script: &str,
expected_command: &str,
timeout_seconds: u64,
) -> Result<ProjectVerificationSpec, String> {
resolve_project_verification_spec_with_cwd_at(
root,
script,
expected_command,
timeout_seconds,
".",
)
}
pub(crate) fn resolve_project_verification_spec_with_cwd_at(
root: &Path,
script: &str,
expected_command: &str,
timeout_seconds: u64,
cwd_relative: &str,
) -> Result<ProjectVerificationSpec, String> {
validate_project_root(root)?;
let cwd_relative = if cwd_relative.trim().is_empty() || cwd_relative.trim() == "." {
".".to_string()
} else {
normalize_relative_path(cwd_relative)?
};
let package_root = if cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, &cwd_relative)?
};
if !package_root.is_dir() {
return Err("project.verify cwd 必须是项目内普通目录".to_string());
}
ensure_project_verification_has_no_project_npmrc(&package_root)?;
let script = script.trim();
if script.chars().count() > PROJECT_VERIFICATION_SCRIPT_MAX_CHARS {
return Err(format!(
"project.verify script 总长度不能超过 {PROJECT_VERIFICATION_SCRIPT_MAX_CHARS} 个字符"
));
}
if !project_verification_script_allowed(script) {
return Err(
"project.verify 只允许验证类脚本:check、typecheck、test、lint、build,或以 check:、test:、lint:、typecheck:、build:、verify:、validate: 开头的安全非空命名脚本"
.to_string(),
);
}
if expected_command.trim().is_empty() {
return Err("project.verify 缺少 expectedCommand".to_string());
}
if expected_command.chars().count() > 2_000
|| expected_command
.chars()
.any(|character| matches!(character, '\n' | '\r'))
{
return Err("project.verify expectedCommand 必须是最多 2,000 字符的单行脚本".to_string());
}
if !(PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS..=PROJECT_VERIFICATION_MAX_TIMEOUT_SECONDS)
.contains(&timeout_seconds)
{
return Err(format!(
"project.verify timeoutSeconds 必须在 {PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS}-{PROJECT_VERIFICATION_MAX_TIMEOUT_SECONDS} 之间"
));
}
let package_path = package_root.join("package.json");
let metadata = fs::symlink_metadata(&package_path).map_err(|error| {
format!(
"读取 package.json 失败:{}: {error}",
package_path.display()
)
})?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("project.verify 要求项目根 package.json 是普通文件".to_string());
}
if metadata.len() > PROJECT_VERIFICATION_PACKAGE_MAX_BYTES {
return Err(format!(
"project.verify package.json 超过 {} 字节上限",
PROJECT_VERIFICATION_PACKAGE_MAX_BYTES
));
}
prepare_game_creator_private_path_for_read(&package_path, false, "package.json")?;
let package_content = fs::read_to_string(&package_path).map_err(|error| {
format!(
"读取 package.json 失败:{}: {error}",
package_path.display()
)
})?;
let package: serde_json::Value = serde_json::from_str(&package_content)
.map_err(|error| format!("解析 package.json 失败:{error}"))?;
let package_manager = project_verification_package_manager_at(&package_root, &package)?;
let actual_command = package
.get("scripts")
.and_then(serde_json::Value::as_object)
.and_then(|scripts| scripts.get(script))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("package.json 未定义 {script} 脚本"))?;
if actual_command != expected_command {
return Err(format!(
"package.json 中的 {script} 脚本已变化,请重新读取后再确认执行"
));
}
if script == "build" && cwd_relative == "game" {
let modules_path = package_root.join("node_modules");
let modules_metadata = fs::symlink_metadata(&modules_path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
"project.verify build 前缺少 game/node_modules;请先执行 project.bootstrap"
.to_string()
} else {
format!("project.verify 检查 game/node_modules 失败:{error}")
}
})?;
if modules_metadata.file_type().is_symlink() || !modules_metadata.is_dir() {
return Err("project.verify build 前的 game/node_modules 不是普通目录;请重新执行 project.bootstrap".to_string());
}
}
Ok(ProjectVerificationSpec {
script: script.to_string(),
expected_command: expected_command.to_string(),
package_manager: package_manager.to_string(),
program: project_verification_npm_program().to_string(),
arguments: vec![
"run".to_string(),
"--silent".to_string(),
"--ignore-scripts".to_string(),
script.to_string(),
],
timeout_seconds,
cwd_relative,
})
}
fn truncate_project_verification_output(value: &str) -> String {
if value.len() <= PROJECT_VERIFICATION_OUTPUT_MAX_BYTES {
return value.trim().to_string();
}
let head_limit = PROJECT_VERIFICATION_OUTPUT_MAX_BYTES / 3;
let tail_limit = PROJECT_VERIFICATION_OUTPUT_MAX_BYTES - head_limit;
let mut head_end = head_limit.min(value.len());
while head_end > 0 && !value.is_char_boundary(head_end) {
head_end -= 1;
}
let mut tail_start = value.len().saturating_sub(tail_limit);
while tail_start < value.len() && !value.is_char_boundary(tail_start) {
tail_start += 1;
}
let omitted = tail_start.saturating_sub(head_end);
format!(
"{}\n...<output truncated: {omitted} bytes omitted>...\n{}",
&value[..head_end],
&value[tail_start..]
)
}
pub(crate) fn sanitize_project_verification_output(value: &str) -> String {
let printable = value
.chars()
.filter(|character| !character.is_control() || matches!(character, '\n' | '\t'))
.collect::<String>();
let sanitized = sanitize_prompt_context(&printable);
let output = redact_secret_tokens(&sanitized);
truncate_project_verification_output(&output)
}
async fn read_bounded_project_process_output<R>(
mut reader: R,
max_bytes: usize,
) -> Result<String, String>
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let mut output = BoundedProcessBytes::new(max_bytes);
let mut buffer = [0_u8; 4 * 1024];
loop {
let read = reader
.read(&mut buffer)
.await
.map_err(|error| format!("读取 project.verify 子进程输出失败:{error}"))?;
if read == 0 {
break;
}
output.push(&buffer[..read]);
}
Ok(output.finish())
}
#[cfg(unix)]
fn terminate_project_verification_process_group(process_id: u32) {
// npm may exit while a script leaves non-detached descendants behind.
unsafe {
libc::kill(-(process_id as i32), libc::SIGKILL);
}
}
async fn terminate_project_verification_process_tree(child: &mut tokio::process::Child) {
if let Some(process_id) = child.id() {
#[cfg(unix)]
terminate_project_verification_process_group(process_id);
#[cfg(windows)]
{
let mut command = tokio::process::Command::new("taskkill");
command
.args(["/PID", &process_id.to_string(), "/T", "/F"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
crate::configure_windows_background_tokio_command(&mut command, false);
let _ = command.status().await;
}
}
let _ = child.kill().await;
let _ = child.wait().await;
}
async fn collect_project_verification_output_task(
mut task: tokio::task::JoinHandle<Result<String, String>>,
stream_name: &str,
) -> Result<String, String> {
match tokio::time::timeout(Duration::from_secs(2), &mut task).await {
Ok(result) => {
result.map_err(|error| format!("收集 project.verify {stream_name} 失败:{error}"))?
}
Err(_) => {
task.abort();
Err(format!(
"project.verify {stream_name} 收集超时,验证结果不能判定为成功"
))
}
}
}
async fn run_project_verification_process<F>(
root: &Path,
spec: &ProjectVerificationSpec,
durable_commit: F,
) -> Result<ProjectVerificationProcessResult, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
let package_root = if spec.cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, &spec.cwd_relative)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?
};
ensure_project_verification_has_no_project_npmrc(&package_root)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let command_spec = resolve_project_command_spec_at(
root,
"npm",
&spec.arguments,
&spec.cwd_relative,
spec.timeout_seconds,
)?;
let launch = prepare_project_command_launch_spec(root, &command_spec)?;
let launch_metadata = launch.clone();
let staged = stage_project_command_launch_spec(&command_spec, launch)?;
let established = match spawn_staged_project_command(staged, durable_commit).await {
Ok(established) => established,
Err(error) if error.stage() == ProjectCommandErrorStage::TargetExec => {
return Ok(ProjectVerificationProcessResult {
exit_code: None,
timed_out: false,
output: sanitize_project_verification_output(error.message()),
sandbox_backend: launch_metadata.sandbox_backend,
sandbox_mode: launch_metadata.sandbox_mode,
network_access: launch_metadata.network_access,
sandbox_profile_version: launch_metadata.sandbox_profile_version,
sandbox_establishment: "established".to_string(),
target_exec: "failed".to_string(),
launch_failure_kind: Some("target-exec-failed".to_string()),
});
}
Err(error) => return Err(error),
};
let mut child = established.child;
#[cfg(target_os = "linux")]
let gate = established.gate;
#[cfg(unix)]
let process_id = child.id();
let stdout = child.stdout.take().ok_or_else(|| {
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
"读取 project.verify stdout 失败",
)
})?;
let stderr = child.stderr.take().ok_or_else(|| {
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
"读取 project.verify stderr 失败",
)
})?;
let stream_limit = PROJECT_VERIFICATION_OUTPUT_MAX_BYTES;
let stdout_task = tokio::spawn(read_bounded_project_process_output(stdout, stream_limit));
let stderr_task = tokio::spawn(read_bounded_project_process_output(stderr, stream_limit));
let timeout = Duration::from_secs(spec.timeout_seconds);
let wait = tokio::time::timeout(timeout, child.wait()).await;
let (exit_code, timed_out) = match wait {
Ok(Ok(status)) => {
#[cfg(target_os = "linux")]
let _terminal = wait_established_project_command_terminal(gate).await?;
#[cfg(unix)]
if let Some(process_id) = process_id {
terminate_project_verification_process_group(process_id);
}
(status.code(), false)
}
Ok(Err(error)) => {
terminate_project_verification_process_tree(&mut child).await;
stdout_task.abort();
stderr_task.abort();
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("等待 project.verify 子进程失败:{error}"),
));
}
Err(_) => {
terminate_project_verification_process_tree(&mut child).await;
stdout_task.abort();
stderr_task.abort();
(None, true)
}
};
let (stdout, stderr) = tokio::join!(
collect_project_verification_output_task(stdout_task, "stdout"),
collect_project_verification_output_task(stderr_task, "stderr"),
);
let stdout = match stdout {
Ok(output) => output,
Err(_) if timed_out => String::new(),
Err(error) => {
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
error,
));
}
};
let stderr = match stderr {
Ok(output) => output,
Err(_) if timed_out => String::new(),
Err(error) => {
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
error,
));
}
};
let mut sections = Vec::new();
if !stdout.trim().is_empty() {
sections.push(format!("stdout:\n{}", stdout.trim()));
}
if !stderr.trim().is_empty() {
sections.push(format!("stderr:\n{}", stderr.trim()));
}
if timed_out {
sections.push(format!(
"project.verify 在 {} 秒后超时,已终止进程树",
spec.timeout_seconds
));
} else if let Some(exit_code) = exit_code.filter(|code| *code != 0) {
sections.push(format!("project.verify 退出码:{exit_code}"));
}
if sections.is_empty() {
sections.push("project.verify 未产生输出".to_string());
}
Ok(ProjectVerificationProcessResult {
exit_code,
timed_out,
output: sanitize_project_verification_output(&sections.join("\n\n")),
sandbox_backend: launch_metadata.sandbox_backend,
sandbox_mode: launch_metadata.sandbox_mode,
network_access: launch_metadata.network_access,
sandbox_profile_version: launch_metadata.sandbox_profile_version,
sandbox_establishment: "established".to_string(),
target_exec: "established".to_string(),
launch_failure_kind: None,
})
}
#[cfg(test)]
pub(crate) async fn run_project_verification_at(
root: &Path,
script: &str,
expected_command: &str,
timeout_seconds: u64,
) -> Result<ProjectVerificationResult, String> {
run_project_verification_with_commit_at(
root,
script,
expected_command,
timeout_seconds,
".",
|| Ok(()),
)
.await
}
pub(crate) async fn run_project_verification_with_commit_at<F>(
root: &Path,
script: &str,
expected_command: &str,
timeout_seconds: u64,
cwd_relative: &str,
durable_commit: F,
) -> Result<ProjectVerificationResult, String>
where
F: FnOnce() -> Result<(), String>,
{
let spec = resolve_project_verification_spec_with_cwd_at(
root,
script,
expected_command,
timeout_seconds,
cwd_relative,
)?;
let started_at = std::time::Instant::now();
let mut process = match run_project_verification_process(root, &spec, durable_commit).await {
Ok(process) => process,
Err(error) if error.needs_reconciliation() => {
return Err(format!("project.verify 执行状态需要人工核对:{error}"));
}
Err(error) => ProjectVerificationProcessResult {
exit_code: None,
timed_out: false,
output: sanitize_project_verification_output(&error),
sandbox_backend: "unavailable".to_string(),
sandbox_mode: "not-established".to_string(),
network_access: "not-established".to_string(),
sandbox_profile_version: "none".to_string(),
sandbox_establishment: "not-established".to_string(),
target_exec: "not-attempted".to_string(),
launch_failure_kind: None,
},
};
let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let mut completed = !process.timed_out && process.exit_code == Some(0);
if completed && spec.script == "build" && spec.cwd_relative == "game" {
let dist_entry = root.join("game").join("dist").join("index.html");
completed = fs::symlink_metadata(&dist_entry)
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink());
if !completed {
process.output = format!(
"{}\nproject.verify build 成功但缺少 game/dist/index.html",
process.output
);
}
}
let status = if completed { "completed" } else { "failed" };
let command_id = format!("project.verify.{}", spec.script);
let updated_at = unix_timestamp();
let log_path = resolve_local_project_path(root, ".agent/logs/command.log")?;
let log_entry = format!(
"{updated_at} project.verify {} {status} manager={} exitCode={} timedOut={} durationMs={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={} sandboxEstablishment={} targetExec={} launchFailureKind={}\n{}\n",
spec.script,
spec.package_manager,
process
.exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "none".to_string()),
process.timed_out,
duration_ms,
process.sandbox_backend,
process.sandbox_mode,
process.network_access,
process.sandbox_profile_version,
process.sandbox_establishment,
process.target_exec,
process.launch_failure_kind.as_deref().unwrap_or("none"),
process.output
);
append_game_creator_private_file(&log_path, log_entry.as_bytes(), "命令日志")?;
record_command_run(
root,
GameCreationAppCommandRunState {
command_id: command_id.clone(),
status: if completed {
GameCreationAppCommandRunStatus::Completed
} else {
GameCreationAppCommandRunStatus::Failed
},
output: process.output.clone(),
log_path: log_path.to_string_lossy().into_owned(),
updated_at,
},
)?;
Ok(ProjectVerificationResult {
command_id,
script: spec.script,
expected_command: spec.expected_command,
package_manager: spec.package_manager,
cwd_relative: spec.cwd_relative,
status: status.to_string(),
exit_code: process.exit_code,
timed_out: process.timed_out,
duration_ms,
output: process.output,
sandbox_backend: process.sandbox_backend,
sandbox_mode: process.sandbox_mode,
network_access: process.network_access,
sandbox_profile_version: process.sandbox_profile_version,
sandbox_establishment: process.sandbox_establishment,
target_exec: process.target_exec,
launch_failure_kind: process.launch_failure_kind,
log_path: log_path.to_string_lossy().into_owned(),
updated_at,
})
}
impl Default for ProjectPermissionPolicy {
fn default() -> Self {
Self {
denied_commands: Vec::new(),
confirm_commands: GAME_CREATION_APP_COMMANDS
.iter()
.filter(|command| command.permission == GameCreationAppPermission::Confirm)
.map(|command| command.id.to_string())
.collect(),
agent_policies: BTreeMap::new(),
}
}
}
const PROJECT_PERMISSION_MANDATORY_CONFIRM_COMMANDS: &[&str] = &[
"project.git_commit",
"command.start",
"command.stdin",
"command.terminate",
];
pub(crate) fn read_project_permission_policy_at(
root: &Path,
) -> Result<ProjectPermissionPolicyView, String> {
validate_project_root(root)?;
let path = root.join(PROJECT_PERMISSION_POLICY_PATH);
if !path.exists() {
return Ok(ProjectPermissionPolicyView {
path: path.to_string_lossy().into_owned(),
policy: ProjectPermissionPolicy::default(),
});
}
prepare_game_creator_private_path_for_read(&path, false, "项目权限策略")?;
let content = fs::read_to_string(&path)
.map_err(|error| format!("读取项目权限策略失败:{}: {error}", path.display()))?;
let policy = serde_json::from_str::<ProjectPermissionPolicy>(&content)
.map_err(|error| format!("解析项目权限策略失败:{}: {error}", path.display()))?;
Ok(ProjectPermissionPolicyView {
path: path.to_string_lossy().into_owned(),
policy: normalize_project_permission_policy(policy)?,
})
}
pub(crate) fn write_project_permission_policy_at(
root: &Path,
policy: ProjectPermissionPolicy,
) -> Result<ProjectPermissionPolicyView, String> {
validate_project_root(root)?;
let policy = normalize_project_permission_policy(policy)?;
let path = root.join(PROJECT_PERMISSION_POLICY_PATH);
let content = serde_json::to_string_pretty(&policy)
.map_err(|error| format!("序列化项目权限策略失败:{error}"))?;
crate::write_game_creator_private_file(
&path,
format!("{content}\n").as_bytes(),
"项目权限策略",
)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.policy_write",
"deniedCommands": policy.denied_commands,
"confirmCommands": policy.confirm_commands,
"agentPolicies": policy.agent_policies,
}),
)?;
read_project_permission_policy_at(root)
}
pub(crate) fn normalize_project_permission_policy(
mut policy: ProjectPermissionPolicy,
) -> Result<ProjectPermissionPolicy, String> {
policy.denied_commands = normalize_policy_command_ids(policy.denied_commands)?;
policy.confirm_commands = normalize_policy_command_ids(policy.confirm_commands)?;
for command_id in PROJECT_PERMISSION_MANDATORY_CONFIRM_COMMANDS {
if !policy
.denied_commands
.iter()
.any(|command| command == command_id)
&& !policy
.confirm_commands
.iter()
.any(|command| command == command_id)
{
policy.confirm_commands.push((*command_id).to_string());
}
}
policy
.confirm_commands
.retain(|command| !policy.denied_commands.contains(command));
let mut agent_policies = BTreeMap::new();
for (agent_id, mut agent_policy) in policy.agent_policies {
let agent_id = normalize_game_creator_runtime_agent_id(&agent_id)?;
agent_policy.denied_commands = normalize_policy_command_ids(agent_policy.denied_commands)?;
agent_policy.confirm_commands =
normalize_policy_command_ids(agent_policy.confirm_commands)?;
agent_policy
.confirm_commands
.retain(|command| !agent_policy.denied_commands.contains(command));
agent_policies.insert(agent_id, agent_policy);
}
policy.agent_policies = agent_policies;
Ok(policy)
}
pub(crate) fn normalize_policy_command_ids(values: Vec<String>) -> Result<Vec<String>, String> {
let mut output = Vec::new();
for value in values {
let command_id = value.trim();
if command_id.is_empty() {
continue;
}
let supported_global_command = GAME_CREATION_APP_COMMANDS
.iter()
.any(|command| command.id == command_id);
if !supported_global_command {
return Err(format!("不支持的内置命令:{command_id}"));
}
if !output.iter().any(|existing| existing == command_id) {
output.push(command_id.to_string());
}
}
Ok(output)
}
pub(crate) fn enforce_project_permission_policy(
root: &Path,
command_id: &str,
) -> Result<(), String> {
let view = read_project_permission_policy_at(root)?;
if view
.policy
.denied_commands
.iter()
.any(|command| command == command_id)
{
return Err(format!("项目权限策略拒绝执行:{command_id}"));
}
Ok(())
}
pub(crate) fn enforce_project_auto_permission_policy(
root: &Path,
command_id: &str,
) -> Result<(), String> {
let view = read_project_permission_policy_at(root)?;
if view
.policy
.denied_commands
.iter()
.any(|command| command == command_id)
{
return Err(format!("项目权限策略拒绝执行:{command_id}"));
}
if view
.policy
.confirm_commands
.iter()
.any(|command| command == command_id)
{
return Err(format!("项目权限策略要求用户确认:{command_id}"));
}
Ok(())
}
#[cfg(test)]
mod npm_build_tests {
use super::*;
#[test]
fn built_smoke_checks_module_files_without_inline_canvas() {
let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp");
fs::create_dir_all(&base).unwrap();
let root = tempfile::tempdir_in(base).unwrap();
fs::create_dir_all(root.path().join("dist/assets")).unwrap();
fs::write(root.path().join("package.json"), "{}").unwrap();
let html = r#"<!doctype html><html><script type="module" src="./assets/main.js"></script><link href="./assets/main.css" rel="stylesheet"></html>"#;
fs::write(root.path().join("dist/index.html"), html).unwrap();
assert!(validate_built_game_references(root.path(), html).is_err());
fs::write(root.path().join("dist/assets/main.js"), "export {};").unwrap();
fs::write(root.path().join("dist/assets/main.css"), "body{}").unwrap();
assert!(validate_built_game_references(root.path(), html).is_ok());
let lazy_html = r#"<!doctype html><html><img data-src="later.png" data-href="missing.png" alt="src='not-a-reference.png'" src="./assets/main.js"></html>"#;
assert!(validate_built_game_references(root.path(), lazy_html).is_ok());
}
}