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
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>
991 lines
35 KiB
Rust
991 lines
35 KiB
Rust
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(§ions.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());
|
||
}
|
||
}
|