Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs
T
kdletters 0f829cd252
Project CI / Repository checks (push) Failing after 2m44s
Project CI / Frontend tests (push) Failing after 2m53s
Project CI / Native shell tests (push) Failing after 6m39s
Project CI / Backend tests (push) Successful in 8m7s
完成 DirectProject Phaser 迁移闭环 (#353)
## 变更

单 HTML → Phaser 4 + Vite 的迁移链路现在有明确且可执行的工程合同:Phaser/npm 目标固定走 DirectProject,旧 JSON Generator 继续只处理单文件 HTML。

- 新增受控 `project.bootstrap`,仅允许项目内 `game` 目录执行无参数 `npm install`,校验 package/lock、路径、npm 管理器并记录依赖指纹。
- `project.verify` 支持相对 `cwd`,构建前诊断缺失依赖,`cwd=game` 的 build 必须产出 `game/dist/index.html`。
- npm bootstrap 使用受控网络沙箱;通用 `command.exec npm install` 仍被拒绝。
- 同步 Runtime 工具广告、执行分发、审计、权限策略、DirectProject/Generator 提示、前端权限面板、TypeScript/Rust 契约和项目文档。
- Phaser 迁移提示覆盖 Scene、输入、敌人/守卫、波次、胜负、重开和桌面/移动双视口验收。

## 验证

- `npm run ai-game-creator-shell:typecheck`
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
- `cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app`
- 定向 `project.verify` Rust 测试
- `npm run check:encoding`
- `npm run check:doc-index`
- `git diff --check`
- 真实 DirectProject:`npm install`、`npm run build` 成功,`game/dist/index.html` 存在。
- 浏览器试玩:桌面视口画布非空;移动视口触摸开火、键盘换道和重开逻辑均已观察确认。

真实项目没有纳入仓库提交;PR 只包含客户端合同、Runtime 能力和验证门禁。

Reviewed-on: #353
2026-09-14 13:57:24 +08:00

2920 lines
107 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::*;
use sha2::{Digest, Sha256};
use std::collections::{HashSet, VecDeque};
use std::ffi::{OsStr, OsString};
use std::process::Stdio;
use tokio::io::AsyncReadExt;
#[cfg(target_os = "linux")]
use crate::command_sandbox_trampoline::{LaunchGate, TargetExecState, TargetTerminalState};
const PROJECT_COMMAND_MAX_ARGUMENTS: usize = 64;
const PROJECT_COMMAND_MAX_ARGUMENT_CHARS: usize = 512;
const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024;
const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1;
const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300;
const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024;
const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024;
const PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS: &[&str] = &[
":(exclude).agent/**",
":(exclude)**/.agent/**",
":(exclude).git/**",
":(exclude)**/.git/**",
":(exclude).env",
":(exclude).env.*",
":(exclude)**/.env",
":(exclude)**/.env.*",
":(exclude)key",
":(exclude)key.*",
":(exclude)**/key",
":(exclude)**/key.*",
":(exclude)**/*.key",
":(exclude)config",
":(exclude)config.*",
":(exclude)**/config",
":(exclude)**/config.*",
":(exclude)**/secrets/**",
":(exclude)**/credentials/**",
":(exclude)**/*.pem",
":(exclude)**/*.p12",
":(exclude)**/*.pfx",
":(exclude)**/*.kdbx",
":(exclude)**/.npmrc",
":(exclude)**/.pypirc",
":(exclude)**/.netrc",
":(exclude)**/.git-credentials",
":(exclude)**/credentials.json",
":(exclude)**/auth.json",
":(exclude)**/secrets.json",
":(exclude)**/cookies.json",
":(exclude)**/game-creator.config*.json",
];
const PROJECT_COMMAND_SENSITIVE_RG_GLOBS: &[&str] = &[
"!.agent/**",
"!**/.agent/**",
"!.git/**",
"!**/.git/**",
"!.hg/**",
"!**/.hg/**",
"!.svn/**",
"!**/.svn/**",
"!.env",
"!.env.*",
"!**/.env",
"!**/.env.*",
"!key",
"!key.*",
"!**/key",
"!**/key.*",
"!**/*.key",
"!config",
"!config.*",
"!**/config",
"!**/config.*",
"!**/secrets/**",
"!**/credentials/**",
"!**/*.pem",
"!**/*.p12",
"!**/*.pfx",
"!**/*.kdbx",
"!**/.npmrc",
"!**/.pypirc",
"!**/.netrc",
"!**/.git-credentials",
"!**/credentials.json",
"!**/auth.json",
"!**/secrets.json",
"!**/cookies.json",
"!**/game-creator.config*.json",
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectCommandSpec {
pub(crate) program: String,
pub(crate) executable: PathBuf,
pub(crate) safe_path: OsString,
pub(crate) arguments: Vec<String>,
pub(crate) cwd_relative: String,
pub(crate) cwd: PathBuf,
pub(crate) timeout_seconds: u64,
pub(crate) verification_eligible: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectCommandLaunchSpec {
pub(crate) executable: PathBuf,
pub(crate) arguments: Vec<OsString>,
pub(crate) cwd: PathBuf,
pub(crate) environment: Vec<(OsString, OsString)>,
pub(crate) sandbox_backend: String,
pub(crate) sandbox_mode: String,
pub(crate) network_access: String,
pub(crate) sandbox_profile_version: String,
}
#[derive(Debug)]
pub(crate) struct StagedProjectCommandLaunchSpec {
pub(crate) launch: ProjectCommandLaunchSpec,
#[cfg(target_os = "linux")]
pub(crate) gate: LaunchGate,
}
#[derive(Debug)]
pub(crate) struct EstablishedProjectCommand {
pub(crate) child: tokio::process::Child,
#[cfg(target_os = "linux")]
pub(crate) gate: LaunchGate,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectCommandResult {
pub(crate) command_id: String,
pub(crate) program: String,
pub(crate) arguments: Vec<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) capture_truncated: bool,
pub(crate) output_ref: Option<String>,
pub(crate) output_sha256: String,
pub(crate) total_lines: usize,
pub(crate) source_fingerprint_before: String,
pub(crate) source_fingerprint_after: String,
pub(crate) source_changed: bool,
pub(crate) verification_eligible: bool,
pub(crate) sandbox_backend: String,
pub(crate) sandbox_mode: String,
pub(crate) network_access: String,
pub(crate) sandbox_profile_version: String,
pub(crate) log_path: String,
pub(crate) updated_at: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ProjectCommandErrorStage {
Validation,
Preflight,
Spawn,
DurableCommit,
TargetExec,
LaunchUnknown,
Execution,
PostExecutionFingerprint,
OutputSidecar,
AuditLog,
ManifestProjection,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectCommandError {
stage: ProjectCommandErrorStage,
message: String,
}
impl ProjectCommandError {
pub(crate) fn new(stage: ProjectCommandErrorStage, message: impl Into<String>) -> Self {
Self {
stage,
message: sanitize_project_verification_output(&message.into()),
}
}
pub(crate) fn stage(&self) -> ProjectCommandErrorStage {
self.stage
}
pub(crate) fn message(&self) -> &str {
&self.message
}
pub(crate) fn execution_started(&self) -> bool {
matches!(
self.stage,
ProjectCommandErrorStage::LaunchUnknown
| ProjectCommandErrorStage::Execution
| ProjectCommandErrorStage::PostExecutionFingerprint
| ProjectCommandErrorStage::OutputSidecar
| ProjectCommandErrorStage::AuditLog
| ProjectCommandErrorStage::ManifestProjection
)
}
pub(crate) fn needs_reconciliation(&self) -> bool {
self.execution_started()
}
}
impl ProjectCommandErrorStage {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Validation => "validation",
Self::Preflight => "preflight",
Self::Spawn => "spawn",
Self::DurableCommit => "durable-commit",
Self::TargetExec => "target-exec",
Self::LaunchUnknown => "launch-unknown",
Self::Execution => "execution",
Self::PostExecutionFingerprint => "post-execution-fingerprint",
Self::OutputSidecar => "output-sidecar",
Self::AuditLog => "audit-log",
Self::ManifestProjection => "manifest-projection",
}
}
}
impl std::fmt::Display for ProjectCommandError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ProjectCommandError {}
impl std::ops::Deref for ProjectCommandError {
type Target = str;
fn deref(&self) -> &Self::Target {
self.message()
}
}
#[derive(Debug)]
struct ProjectCommandProcessResult {
exit_code: Option<i32>,
timed_out: bool,
output: String,
capture_truncated: bool,
}
#[derive(Debug)]
struct BoundedCommandOutput {
text: String,
truncated: bool,
}
#[derive(Debug)]
struct BoundedCommandBytes {
head: Vec<u8>,
tail: VecDeque<u8>,
total: usize,
max_bytes: usize,
}
impl BoundedCommandBytes {
fn new(max_bytes: usize) -> Self {
Self {
head: Vec::new(),
tail: 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_len = head_limit.saturating_sub(self.head.len()).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) -> BoundedCommandOutput {
let tail = self.tail.into_iter().collect::<Vec<_>>();
if self.total <= self.max_bytes {
let mut bytes = self.head;
bytes.extend(tail);
return BoundedCommandOutput {
text: String::from_utf8_lossy(&bytes).into_owned(),
truncated: false,
};
}
let omitted = self.total.saturating_sub(self.head.len() + tail.len());
BoundedCommandOutput {
text: format!(
"{}\n...<{} output bytes omitted>...\n{}",
String::from_utf8_lossy(&self.head),
omitted,
String::from_utf8_lossy(&tail)
),
truncated: true,
}
}
}
pub(crate) fn resolve_project_command_spec_at(
root: &Path,
program: &str,
arguments: &[String],
cwd: &str,
timeout_seconds: u64,
) -> Result<ProjectCommandSpec, ProjectCommandError> {
resolve_project_command_spec_inner(root, program, arguments, cwd, timeout_seconds)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))
}
/// Resolves the one privileged npm operation used to hydrate a DirectProject.
/// It intentionally bypasses the general command.exec npm allow-list: callers
/// must use the dedicated `project.bootstrap` action, which only accepts the
/// literal `npm install` in the project's `game` directory.
pub(crate) fn resolve_project_bootstrap_spec_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectCommandSpec, ProjectCommandError> {
validate_project_root(root)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
let cwd_relative = "game".to_string();
let cwd = resolve_local_project_path(root, &cwd_relative)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
validate_project_command_cwd_components(root, &cwd_relative)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS)
.contains(&timeout_seconds)
{
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Validation,
format!("project.bootstrap timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间"),
));
}
let program = "npm".to_string();
let (executable, safe_path) = resolve_project_command_executable(root, &program)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
Ok(ProjectCommandSpec {
program,
executable,
safe_path,
arguments: vec!["install".to_string()],
cwd_relative,
cwd,
timeout_seconds,
verification_eligible: false,
})
}
fn resolve_project_command_spec_inner(
root: &Path,
program: &str,
arguments: &[String],
cwd: &str,
timeout_seconds: u64,
) -> Result<ProjectCommandSpec, String> {
validate_project_root(root)?;
if !root.is_dir() {
return Err("command.exec 要求项目目录已存在".to_string());
}
if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS)
.contains(&timeout_seconds)
{
return Err(format!(
"command.exec timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间"
));
}
let program = normalize_project_command_program(program)?;
validate_project_command_arguments(&program, arguments)?;
let cwd_relative = normalize_project_command_cwd(cwd)?;
let cwd = if cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, &cwd_relative)?
};
validate_project_command_cwd_components(root, &cwd_relative)?;
if program == "node" {
validate_project_command_node_test_files(&cwd, arguments)?;
}
let (executable, safe_path) = resolve_project_command_executable(root, &program)?;
let verification_eligible = project_command_verification_eligible(&program, arguments);
Ok(ProjectCommandSpec {
program,
executable,
safe_path,
arguments: arguments.to_vec(),
cwd_relative,
cwd,
timeout_seconds,
verification_eligible,
})
}
fn normalize_project_command_program(value: &str) -> Result<String, String> {
let value = value.trim();
#[cfg(target_os = "linux")]
{
if value.is_empty()
|| value.len() > 64
|| !value.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '+')
})
{
return Err(
"command.exec program 必须是 1-64 个 ASCII 字母、数字、点、下划线、加号或连字符组成的裸可执行名"
.to_string(),
);
}
Ok(value.to_string())
}
#[cfg(not(target_os = "linux"))]
{
let value = value.to_ascii_lowercase();
if !matches!(value.as_str(), "cargo" | "npm" | "node" | "git" | "rg") {
return Err("command.exec program 只允许 cargo、npm、node、git 或 rg".to_string());
}
Ok(value)
}
}
fn validate_project_command_cwd_components(root: &Path, relative_path: &str) -> Result<(), String> {
let mut current = root.to_path_buf();
validate_project_command_cwd_component(&current)?;
if relative_path != "." {
for component in relative_path.split('/') {
current.push(component);
validate_project_command_cwd_component(&current)?;
}
}
if !current.is_dir() {
return Err("command.exec cwd 必须是项目内普通目录".to_string());
}
Ok(())
}
fn validate_project_command_cwd_component(path: &Path) -> Result<(), String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("command.exec cwd 不可用:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() || project_command_metadata_is_reparse_point(&metadata) {
return Err("command.exec cwd 的任一层级都不能是符号链接或 reparse point".to_string());
}
if !metadata.is_dir() {
return Err("command.exec cwd 必须是项目内普通目录".to_string());
}
Ok(())
}
#[cfg(windows)]
fn project_command_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn project_command_metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
fn validate_project_command_node_test_files(
cwd: &Path,
arguments: &[String],
) -> Result<(), String> {
let files = arguments
.get(1..)
.filter(|files| !files.is_empty())
.ok_or_else(|| "command.exec node --test 至少需要一个项目内测试文件".to_string())?;
for relative_path in files {
if relative_path.starts_with('-')
|| relative_path.contains(['*', '?', '[', ']', '{', '}'])
|| relative_path.contains('\\')
{
return Err(
"command.exec node --test 只接受精确的项目内测试文件路径,不接受额外 Node 选项或 glob"
.to_string(),
);
}
let normalized = normalize_relative_path(relative_path)?;
if normalized != *relative_path {
return Err("command.exec node --test 文件路径必须是规范相对路径".to_string());
}
let extension = Path::new(&normalized)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if !matches!(
extension.as_str(),
"js" | "mjs" | "cjs" | "ts" | "mts" | "cts"
) {
return Err(
"command.exec node --test 只接受 JavaScript / TypeScript 测试文件".to_string(),
);
}
let mut current = cwd.to_path_buf();
for component in normalized.split('/') {
current.push(component);
let metadata = fs::symlink_metadata(&current).map_err(|error| {
format!(
"command.exec node 测试文件不可用:{}: {error}",
current.display()
)
})?;
if metadata.file_type().is_symlink()
|| project_command_metadata_is_reparse_point(&metadata)
{
return Err(
"command.exec node 测试文件的任一层级都不能是符号链接或 reparse point"
.to_string(),
);
}
}
if !current.is_file() {
return Err("command.exec node --test 目标必须是项目内普通文件".to_string());
}
}
Ok(())
}
fn project_command_verification_eligible(program: &str, arguments: &[String]) -> bool {
match program {
"cargo" => matches!(
arguments.first().map(String::as_str),
Some("check" | "test" | "clippy" | "fmt" | "build")
),
"npm" => match arguments.first().map(String::as_str) {
Some("test") => true,
Some("run") => arguments
.get(1)
.is_some_and(|script| project_command_npm_verification_script_allowed(script)),
_ => false,
},
"node" => arguments
.first()
.is_some_and(|argument| argument == "--test"),
"git" | "rg" => false,
_ => false,
}
}
fn project_command_npm_verification_script_allowed(script: &str) -> bool {
if matches!(script, "check" | "typecheck" | "test" | "lint" | "build") {
return true;
}
[
"check:",
"typecheck:",
"test:",
"lint:",
"build:",
"verify:",
"validate:",
]
.iter()
.any(|prefix| {
script
.strip_prefix(prefix)
.is_some_and(project_command_npm_verification_script_suffix_allowed)
})
}
fn project_command_npm_verification_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 resolve_project_command_executable(
root: &Path,
program: &str,
) -> Result<(PathBuf, OsString), String> {
#[cfg(target_os = "linux")]
let path = std::env::join_paths([
PathBuf::from("/usr/local/sbin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/sbin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/sbin"),
PathBuf::from("/bin"),
])
.map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?;
#[cfg(not(target_os = "linux"))]
let path = std::env::var_os("PATH").ok_or_else(|| "command.exec 缺少 PATH".to_string())?;
resolve_project_command_executable_from_path(root, program, &path)
}
fn resolve_project_command_executable_from_path(
root: &Path,
program: &str,
path: &OsStr,
) -> Result<(PathBuf, OsString), String> {
let root = fs::canonicalize(root)
.map_err(|error| format!("解析 command.exec 项目根目录失败:{error}"))?;
let mut safe_directories = Vec::new();
let mut seen_directories = HashSet::new();
let mut executable = None;
for directory in std::env::split_paths(path) {
if !directory.is_absolute() {
continue;
}
let canonical_directory = match fs::canonicalize(&directory) {
Ok(directory)
if directory.is_absolute()
&& directory.is_dir()
&& !directory.starts_with(&root) =>
{
directory
}
_ => continue,
};
if !seen_directories.insert(canonical_directory.clone()) {
continue;
}
safe_directories.push(canonical_directory.clone());
if executable.is_some() {
continue;
}
for name in project_command_executable_names(program) {
let candidate = canonical_directory.join(name);
if !candidate.is_absolute() || candidate.starts_with(&root) {
continue;
}
let canonical_candidate = match fs::canonicalize(&candidate) {
Ok(candidate)
if candidate.is_absolute()
&& candidate.is_file()
&& !candidate.starts_with(&root) =>
{
candidate
}
_ => continue,
};
let metadata = match fs::metadata(&canonical_candidate) {
Ok(metadata) if metadata.is_file() => metadata,
_ => continue,
};
if !project_command_file_is_executable(&metadata) {
continue;
}
let canonical_parent = canonical_candidate.parent().unwrap_or(&canonical_directory);
if canonical_parent.starts_with(&root) {
continue;
}
// Preserve argv[0] proxy semantics (notably rustup's cargo proxy) while
// requiring both the absolute entry and its resolved target to stay
// outside the project.
executable = Some(candidate);
break;
}
}
let executable =
executable.ok_or_else(|| format!("command.exec 找不到受信任的 {program} 可执行文件"))?;
#[cfg(windows)]
let safe_directories = safe_directories
.into_iter()
.map(|directory| {
let directory = directory.to_string_lossy();
PathBuf::from(directory.strip_prefix(r"\\?\").unwrap_or(&directory))
})
.collect::<Vec<_>>();
let safe_path = std::env::join_paths(safe_directories)
.map_err(|error| format!("构造 command.exec 安全 PATH 失败:{error}"))?;
Ok((executable, safe_path))
}
#[cfg(windows)]
fn project_command_executable_names(program: &str) -> Vec<String> {
match program {
"npm" => vec!["npm.cmd".to_string()],
_ => vec![format!("{program}.exe")],
}
}
#[cfg(not(windows))]
fn project_command_executable_names(program: &str) -> Vec<String> {
vec![program.to_string()]
}
#[cfg(unix)]
fn project_command_file_is_executable(metadata: &fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn project_command_file_is_executable(_metadata: &fs::Metadata) -> bool {
true
}
fn normalize_project_command_cwd(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value == "." {
return Ok(".".to_string());
}
if project_command_argument_is_external_path(value)
|| project_command_argument_contains_sensitive_path(value)
{
return Err("command.exec cwd 必须是项目内非敏感相对目录".to_string());
}
let normalized = value.replace('\\', "/");
if normalized != value || normalized.ends_with('/') || normalized.contains("//") {
return Err("command.exec cwd 必须使用规范正斜杠相对路径".to_string());
}
Ok(normalized)
}
fn validate_project_command_arguments(program: &str, arguments: &[String]) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
if arguments.is_empty() {
return Err("command.exec args 不能为空".to_string());
}
if arguments.len() > PROJECT_COMMAND_MAX_ARGUMENTS {
return Err(format!(
"command.exec args 不能超过 {PROJECT_COMMAND_MAX_ARGUMENTS} 项"
));
}
let mut total_bytes = 0usize;
for argument in arguments {
if argument.is_empty()
|| argument.chars().count() > PROJECT_COMMAND_MAX_ARGUMENT_CHARS
|| argument.chars().any(char::is_control)
{
return Err(format!(
"command.exec 每个 argv 必须是 1-{PROJECT_COMMAND_MAX_ARGUMENT_CHARS} 个无控制字符的字符"
));
}
total_bytes = total_bytes.saturating_add(argument.len());
#[cfg(not(target_os = "linux"))]
if project_command_argument_is_external_path(argument)
|| project_command_argument_contains_sensitive_path(argument)
{
return Err("command.exec argv 不能引用项目外或敏感路径".to_string());
}
}
if total_bytes > PROJECT_COMMAND_MAX_ARGUMENT_BYTES {
return Err(format!(
"command.exec args 总长度不能超过 {PROJECT_COMMAND_MAX_ARGUMENT_BYTES} 字节"
));
}
#[cfg(target_os = "linux")]
{
let _ = program;
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
match program {
"cargo" => validate_cargo_arguments(arguments),
"npm" => validate_npm_arguments(arguments),
"node" => validate_node_arguments(arguments),
"git" => validate_git_arguments(arguments),
"rg" => validate_rg_arguments(arguments),
_ => unreachable!("program whitelist checked above"),
}
}
}
fn project_command_argument_is_external_path(value: &str) -> bool {
if Path::new(value).is_absolute()
|| value.starts_with("//")
|| value.starts_with("\\\\")
|| value.split(['/', '\\']).any(|component| component == "..")
{
return true;
}
let bytes = value.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
fn project_command_argument_contains_sensitive_path(value: &str) -> bool {
let normalized = value.replace('\\', "/").to_ascii_lowercase();
let components = normalized.split('/').collect::<Vec<_>>();
if components.iter().any(|component| {
matches!(
*component,
".agent"
| ".git"
| ".agents"
| ".codex"
| ".hg"
| ".svn"
| ".ssh"
| ".aws"
| ".azure"
| ".gnupg"
| ".kube"
| ".docker"
| ".gcloud"
| ".password-store"
| ".secrets"
| "secrets"
| "credentials"
)
}) {
return true;
}
let file_name = components.last().copied().unwrap_or_default();
file_name == ".env"
|| file_name.starts_with(".env.")
|| matches!(
file_name,
".npmrc"
| ".pypirc"
| ".netrc"
| ".git-credentials"
| "credentials.json"
| "auth.json"
| "secrets.json"
| "cookies.json"
| "game-creator.config.json"
| "game-creator.config.local.json"
)
|| [
".pem", ".p12", ".pfx", ".key", ".kdbx", ".sqlite", ".sqlite3", ".dump",
]
.iter()
.any(|suffix| file_name.ends_with(suffix))
}
fn argument_matches_option(argument: &str, option: &str) -> bool {
argument == option || argument.starts_with(&format!("{option}="))
}
fn validate_cargo_arguments(arguments: &[String]) -> Result<(), String> {
let subcommand = arguments.first().map(String::as_str).unwrap_or_default();
if !matches!(
subcommand,
"check" | "test" | "clippy" | "fmt" | "build" | "metadata"
) {
return Err(
"command.exec cargo 只允许 check、test、clippy、fmt、build 或 metadata".to_string(),
);
}
if arguments.iter().any(|argument| {
[
"--config",
"--manifest-path",
"--target-dir",
"--registry",
"--index",
]
.iter()
.any(|option| argument_matches_option(argument, option))
}) {
return Err("command.exec cargo 禁止覆盖配置、manifest、target 或 registry".to_string());
}
if subcommand == "fmt" && !arguments.iter().any(|argument| argument == "--check") {
return Err("command.exec cargo fmt 必须包含 --check".to_string());
}
Ok(())
}
fn validate_npm_arguments(arguments: &[String]) -> Result<(), String> {
let subcommand = arguments.first().map(String::as_str).unwrap_or_default();
if !matches!(subcommand, "test" | "run") {
return Err("command.exec npm 只允许 test 或 run".to_string());
}
if subcommand == "run"
&& arguments
.iter()
.skip(1)
.map(String::as_str)
.find(|value| !value.starts_with('-'))
.is_none()
{
return Err("command.exec npm run 缺少脚本名".to_string());
}
if arguments.iter().any(|argument| {
[
"--prefix",
"--userconfig",
"--script-shell",
"--registry",
"--global",
"--location",
"--cache",
]
.iter()
.any(|option| argument_matches_option(argument, option))
}) {
return Err("command.exec npm 禁止覆盖 prefix、配置、shell、registry 或 cache".to_string());
}
if arguments.iter().skip(1).any(|argument| {
argument != "--"
&& (argument.chars().any(|character| {
character.is_whitespace()
|| matches!(
character,
';' | '&' | '|' | '<' | '>' | '`' | '$' | '(' | ')' | '{' | '}'
)
}) || argument.contains("\\n")
|| argument.contains("\\r"))
}) {
return Err("command.exec npm argv 不能包含 shell 元字符或空白".to_string());
}
Ok(())
}
fn validate_node_arguments(arguments: &[String]) -> Result<(), String> {
if arguments.first().map(String::as_str) != Some("--test") || arguments.len() < 2 {
return Err("command.exec node 只允许精确的 node --test <项目内测试文件...>".to_string());
}
if arguments.iter().skip(1).any(|argument| {
argument.starts_with('-')
|| argument.contains(['*', '?', '[', ']', '{', '}'])
|| argument.contains('\\')
}) {
return Err(
"command.exec node --test 只接受项目内普通测试文件,不接受额外 Node 选项或 glob"
.to_string(),
);
}
Ok(())
}
fn validate_git_arguments(arguments: &[String]) -> Result<(), String> {
let subcommand = arguments.first().map(String::as_str).unwrap_or_default();
if !matches!(
subcommand,
"status" | "diff" | "log" | "show" | "grep" | "ls-files" | "rev-parse"
) {
return Err("command.exec git 只允许只读审阅子命令".to_string());
}
if arguments.iter().any(|argument| {
[
"-c",
"--config-env",
"--git-dir",
"--work-tree",
"--exec-path",
"--paginate",
"--pager",
"--ext-diff",
"--textconv",
"--output",
"--pathspec-from-file",
"--pathspec-file-nul",
"--exclude-from",
"--exclude-per-directory",
"--show-signature",
"--verify-signatures",
"--use-mailmap",
"--mailmap",
"--alternate-refs",
"--no-index",
"--recurse-submodules",
"--literal-pathspecs",
"--glob-pathspecs",
"--noglob-pathspecs",
"--icase-pathspecs",
]
.iter()
.any(|option| argument_matches_option(argument, option))
|| argument_matches_short_option(argument, 'O')
|| (subcommand == "grep" && argument_matches_short_option(argument, 'f'))
|| argument_matches_option(argument, "--open-files-in-pager")
|| argument.starts_with(':')
|| project_command_git_argument_contains_sensitive_object_path(argument)
}) {
return Err(
"command.exec git 禁止间接读取、外部执行、pager、危险 pathspec 或敏感对象路径"
.to_string(),
);
}
let mut pathspecs_started = false;
for argument in arguments.iter().skip(1) {
if argument == "--" {
pathspecs_started = true;
continue;
}
if pathspecs_started && project_command_git_path_is_sensitive(argument) {
return Err("command.exec git 禁止读取敏感 pathspec".to_string());
}
}
Ok(())
}
fn validate_rg_arguments(arguments: &[String]) -> Result<(), String> {
if arguments.iter().any(|argument| {
[
"--pre",
"--pre-glob",
"--hostname-bin",
"--ignore-file",
"--files-from",
"--file",
"--follow",
"--hidden",
"--search-zip",
"--glob",
"--iglob",
"--type",
"--type-not",
"--type-add",
"--type-clear",
]
.iter()
.any(|option| argument_matches_option(argument, option))
|| argument.starts_with("--glob-")
|| argument == "--no-ignore"
|| argument.starts_with("--no-ignore=")
|| argument.starts_with("--no-ignore-")
|| ['L', 'u', 'z', 'g', 'f', 't', 'T', '.']
.iter()
.any(|option| argument_matches_short_option(argument, *option))
}) {
return Err(
"command.exec rg 禁止跟随链接、隐藏/忽略绕过、压缩包、预处理器、外部文件或自定义 glob"
.to_string(),
);
}
Ok(())
}
fn argument_matches_short_option(argument: &str, option: char) -> bool {
argument.starts_with('-')
&& !argument.starts_with("--")
&& argument.chars().skip(1).any(|value| value == option)
}
fn project_command_git_argument_contains_sensitive_object_path(argument: &str) -> bool {
let Some((_, object_path)) = argument.split_once(':') else {
return false;
};
if object_path.is_empty() {
return false;
}
project_command_argument_is_external_path(object_path)
|| project_command_git_path_is_sensitive(object_path)
}
fn project_command_git_path_is_sensitive(value: &str) -> bool {
let normalized = value
.replace('\\', "/")
.trim_start_matches("./")
.trim_start_matches('/')
.to_ascii_lowercase();
if project_command_argument_contains_sensitive_path(&normalized) {
return true;
}
let components = normalized
.split('/')
.filter(|component| !component.is_empty())
.collect::<Vec<_>>();
if components.iter().any(|component| {
matches!(
*component,
".git" | ".env" | "key" | "keys" | "config" | "secrets" | "credentials"
)
}) {
return true;
}
let file_name = components.last().copied().unwrap_or_default();
file_name.starts_with(".env.")
|| file_name.starts_with("key.")
|| file_name.starts_with("config.")
|| file_name.ends_with(".key")
}
pub(crate) fn project_command_actual_arguments(spec: &ProjectCommandSpec) -> Vec<String> {
#[cfg(target_os = "linux")]
{
return spec.arguments.clone();
}
#[cfg(not(target_os = "linux"))]
match spec.program.as_str() {
"npm" => std::iter::once("--ignore-scripts".to_string())
.chain(spec.arguments.iter().cloned())
.collect(),
"git" => {
let mut arguments = vec![
"-c".to_string(),
"core.pager=cat".to_string(),
"-c".to_string(),
"diff.external=".to_string(),
"-c".to_string(),
"core.fsmonitor=false".to_string(),
"-c".to_string(),
"log.showSignature=false".to_string(),
"-c".to_string(),
"submodule.recurse=false".to_string(),
"--no-pager".to_string(),
];
let subcommand = spec
.arguments
.first()
.expect("validated git subcommand")
.clone();
arguments.push(subcommand);
if matches!(
spec.arguments.first().map(String::as_str),
Some("diff" | "log" | "show")
) {
arguments.push("--no-ext-diff".to_string());
arguments.push("--no-textconv".to_string());
}
arguments.extend(spec.arguments.iter().skip(1).cloned());
if matches!(
spec.arguments.first().map(String::as_str),
Some("diff" | "log" | "show" | "grep")
) {
if !spec.arguments.iter().any(|argument| argument == "--") {
arguments.push("--".to_string());
}
arguments.extend(
PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS
.iter()
.map(|pathspec| (*pathspec).to_string()),
);
}
arguments
}
"rg" => {
let mut arguments = vec![
"--no-config".to_string(),
"--no-follow".to_string(),
"--no-hidden".to_string(),
];
let separator = spec.arguments.iter().position(|argument| argument == "--");
let user_options_end = separator.unwrap_or(spec.arguments.len());
arguments.extend(spec.arguments[..user_options_end].iter().cloned());
for glob in PROJECT_COMMAND_SENSITIVE_RG_GLOBS {
arguments.push("--glob".to_string());
arguments.push((*glob).to_string());
}
if let Some(separator) = separator {
arguments.extend(spec.arguments[separator..].iter().cloned());
}
arguments
}
_ => spec.arguments.clone(),
}
}
pub(crate) fn prepare_project_command_launch_spec(
root: &Path,
spec: &ProjectCommandSpec,
) -> Result<ProjectCommandLaunchSpec, ProjectCommandError> {
let isolated_home = resolve_local_project_path(root, ".agent/runtime/command-env/home")
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let isolated_tmp = resolve_local_project_path(root, ".agent/runtime/command-env/tmp")
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let isolated_cache = resolve_local_project_path(root, ".agent/runtime/command-env/cache")
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
fs::create_dir_all(&isolated_home)
.and_then(|()| fs::create_dir_all(&isolated_tmp))
.and_then(|()| fs::create_dir_all(&isolated_cache))
.map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("创建 command.exec 隔离目录失败:{error}"),
)
})?;
let mut environment = vec![
(OsString::from("CI"), OsString::from("1")),
(OsString::from("NO_COLOR"), OsString::from("1")),
(OsString::from("FORCE_COLOR"), OsString::from("0")),
(OsString::from("TERM"), OsString::from("dumb")),
(OsString::from("HOME"), isolated_home.as_os_str().to_owned()),
(
OsString::from("USERPROFILE"),
isolated_home.as_os_str().to_owned(),
),
(
OsString::from("TMPDIR"),
isolated_tmp.as_os_str().to_owned(),
),
(OsString::from("TEMP"), isolated_tmp.as_os_str().to_owned()),
(OsString::from("TMP"), isolated_tmp.as_os_str().to_owned()),
(
OsString::from("CARGO_HOME"),
isolated_cache.join("cargo").into_os_string(),
),
(OsString::from("CARGO_NET_OFFLINE"), OsString::from("true")),
(OsString::from("CARGO_TERM_COLOR"), OsString::from("never")),
// 受控命令不得继承用户级 Cargo rustc-wrapper(例如 sccache);
// 隔离 HOME/CARGO_HOME 下这类包装器既不可复现,也可能无法启动。
(OsString::from("RUSTC_WRAPPER"), OsString::new()),
(OsString::from("RUSTC_WORKSPACE_WRAPPER"), OsString::new()),
(OsString::from("npm_config_audit"), OsString::from("false")),
(OsString::from("npm_config_fund"), OsString::from("false")),
(
OsString::from("npm_config_ignore_scripts"),
OsString::from("true"),
),
(OsString::from("npm_config_offline"), OsString::from("true")),
(
OsString::from("npm_config_update_notifier"),
OsString::from("false"),
),
(
OsString::from("npm_config_cache"),
isolated_cache.join("npm").into_os_string(),
),
(
OsString::from("npm_config_userconfig"),
isolated_home.join("empty-user.npmrc").into_os_string(),
),
(OsString::from("GIT_CONFIG_NOSYSTEM"), OsString::from("1")),
(
OsString::from("GIT_CONFIG_GLOBAL"),
isolated_home.join("empty-gitconfig").into_os_string(),
),
(OsString::from("GIT_PAGER"), OsString::from("cat")),
(OsString::from("GIT_EXTERNAL_DIFF"), OsString::new()),
(OsString::from("PAGER"), OsString::from("cat")),
(
OsString::from("HTTP_PROXY"),
OsString::from("http://127.0.0.1:9"),
),
(
OsString::from("HTTPS_PROXY"),
OsString::from("http://127.0.0.1:9"),
),
(
OsString::from("ALL_PROXY"),
OsString::from("http://127.0.0.1:9"),
),
(OsString::from("NO_PROXY"), OsString::new()),
(OsString::from("PATH"), spec.safe_path.clone()),
];
if spec
.arguments
.first()
.is_some_and(|argument| argument == "install")
{
for (name, value) in &mut environment {
match name.to_string_lossy().as_ref() {
"npm_config_offline" => *value = OsString::from("false"),
"HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" => *value = OsString::new(),
_ => {}
}
}
}
for name in ["SystemRoot", "PATHEXT", "RUSTUP_HOME"] {
if let Some(value) = std::env::var_os(name) {
environment.push((OsString::from(name), value));
}
}
#[cfg(target_os = "linux")]
if !environment
.iter()
.any(|(name, _)| name == OsStr::new("RUSTUP_HOME"))
{
if let Some(home) = std::env::var_os("HOME") {
let rustup_home = PathBuf::from(home).join(".rustup");
if rustup_home.is_dir() {
environment.push((OsString::from("RUSTUP_HOME"), rustup_home.into_os_string()));
}
}
}
#[cfg(windows)]
if let Some(system_root) = std::env::var_os("SystemRoot") {
environment.push((
OsString::from("ComSpec"),
PathBuf::from(system_root)
.join("System32")
.join("cmd.exe")
.into_os_string(),
));
}
#[cfg(windows)]
for (_, value) in &mut environment {
let rendered = value.to_string_lossy();
if let Some(without_prefix) = rendered.strip_prefix(r"\\?\") {
*value = OsString::from(without_prefix);
}
}
let arguments = project_command_actual_arguments(spec);
#[cfg(target_os = "linux")]
{
let sandbox = prepare_command_sandbox_launch(
root,
&spec.executable,
&arguments,
&spec.cwd,
&environment,
)
.map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("command.exec sandbox unavailable{error}"),
)
})?;
Ok(ProjectCommandLaunchSpec {
executable: sandbox.executable,
arguments: sandbox.arguments,
cwd: sandbox.cwd,
environment: sandbox.environment,
sandbox_backend: sandbox.metadata.backend.to_string(),
sandbox_mode: sandbox.metadata.mode.to_string(),
network_access: sandbox.metadata.network.to_string(),
sandbox_profile_version: sandbox.metadata.profile_version.to_string(),
})
}
#[cfg(not(target_os = "linux"))]
{
#[cfg(windows)]
let (executable, arguments, cwd) = {
fn without_windows_verbatim_prefix(path: PathBuf) -> PathBuf {
let value = path.to_string_lossy();
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
}
let is_npm_batch = spec
.executable
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd"));
if is_npm_batch {
let npm_directory = spec.executable.parent().ok_or_else(|| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
"command.exec 无法定位 Windows npm 安装目录",
)
})?;
let node_executable = npm_directory.join("node.exe");
let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js");
if !node_executable.is_file() || !npm_cli.is_file() {
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
"command.exec Windows npm 安装缺少 node.exe 或 npm-cli.js",
));
}
let node_executable = fs::canonicalize(node_executable).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("command.exec 定位 Windows node.exe 失败:{error}"),
)
})?;
let npm_cli = fs::canonicalize(npm_cli).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("command.exec 定位 Windows npm-cli.js 失败:{error}"),
)
})?;
let node_executable = without_windows_verbatim_prefix(node_executable);
let npm_cli = without_windows_verbatim_prefix(npm_cli);
let mut node_arguments = vec![npm_cli.into_os_string()];
node_arguments.extend(arguments.into_iter().map(OsString::from));
(
node_executable,
node_arguments,
without_windows_verbatim_prefix(spec.cwd.clone()),
)
} else {
(
without_windows_verbatim_prefix(spec.executable.clone()),
arguments.into_iter().map(OsString::from).collect(),
without_windows_verbatim_prefix(spec.cwd.clone()),
)
}
};
#[cfg(not(windows))]
let (executable, arguments, cwd) = (
spec.executable.clone(),
arguments.into_iter().map(OsString::from).collect(),
spec.cwd.clone(),
);
Ok(ProjectCommandLaunchSpec {
executable,
arguments,
cwd,
environment,
sandbox_backend: "legacy-host-restricted".to_string(),
sandbox_mode: "fixed-command".to_string(),
network_access: "proxy-only".to_string(),
sandbox_profile_version: "legacy-v1".to_string(),
})
}
}
pub(crate) fn stage_project_command_launch_spec(
spec: &ProjectCommandSpec,
launch: ProjectCommandLaunchSpec,
) -> Result<StagedProjectCommandLaunchSpec, ProjectCommandError> {
#[cfg(target_os = "linux")]
{
let target_arguments = project_command_actual_arguments(spec)
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>();
let staged = stage_command_sandbox_launch(
CommandSandboxLaunch {
executable: launch.executable,
arguments: launch.arguments,
cwd: launch.cwd,
environment: launch.environment,
metadata: command_sandbox_platform_metadata(),
},
&spec.executable,
&target_arguments,
)
.map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("command.exec sandbox staged launch 失败:{error}"),
)
})?;
Ok(StagedProjectCommandLaunchSpec {
launch: ProjectCommandLaunchSpec {
executable: staged.launch.executable,
arguments: staged.launch.arguments,
cwd: staged.launch.cwd,
environment: staged.launch.environment,
sandbox_backend: staged.launch.metadata.backend.to_string(),
sandbox_mode: staged.launch.metadata.mode.to_string(),
network_access: staged.launch.metadata.network.to_string(),
sandbox_profile_version: staged.launch.metadata.profile_version.to_string(),
},
gate: staged.gate,
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = spec;
Ok(StagedProjectCommandLaunchSpec { launch })
}
}
fn configure_project_command_process_group(
command: &mut tokio::process::Command,
launch: &ProjectCommandLaunchSpec,
) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.as_std_mut().process_group(0);
}
#[cfg(windows)]
{
let npm_cli_host = launch
.arguments
.first()
.and_then(|argument| Path::new(argument).file_name())
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"));
let _ = npm_cli_host;
crate::configure_windows_background_tokio_command(command, true);
}
}
pub(crate) async fn spawn_staged_project_command<F>(
staged: StagedProjectCommandLaunchSpec,
durable_commit: F,
) -> Result<EstablishedProjectCommand, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
#[cfg(not(target_os = "linux"))]
durable_commit().map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::DurableCommit, error)
})?;
#[cfg(target_os = "linux")]
let mut staged = staged;
let mut command = tokio::process::Command::new(&staged.launch.executable);
command
.args(&staged.launch.arguments)
.current_dir(&staged.launch.cwd)
.env_clear()
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
for (name, value) in &staged.launch.environment {
command.env(name, value);
}
configure_project_command_process_group(&mut command, &staged.launch);
#[cfg(target_os = "linux")]
staged
.gate
.install_on_command(command.as_std_mut())
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let child = match command.spawn() {
Ok(child) => child,
Err(error) => {
#[cfg(target_os = "linux")]
staged.gate.spawn_failed();
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Spawn,
format!("启动 staged project command 失败:{error}"),
));
}
};
#[cfg(target_os = "linux")]
{
let mut child = child;
let ready_task = tokio::task::spawn_blocking(move || {
let mut gate = staged.gate;
let ready = gate
.child_created()
.and_then(|()| gate.wait_sandbox_ready(Duration::from_secs(3)));
(gate, ready)
})
.await;
let (mut gate, ready) = match ready_task {
Ok(result) => result,
Err(error) => {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
project_command_launch_error_with_termination(
format!("等待 sandbox-ready 任务失败:{error}"),
termination,
),
));
}
};
if let Err(error) = ready {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
project_command_launch_error_with_termination(error, termination),
));
}
if let Err(error) = durable_commit() {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::DurableCommit,
project_command_launch_error_with_termination(error, termination),
));
}
if let Err(error) = gate.commit_exec() {
let termination = terminate_project_command_process_group_after_commit(&mut child);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
project_command_launch_error_with_termination(error, termination),
));
}
// No await is allowed between durable commit and the exec verdict. A
// cancelled future must not erase the launch-unknown decision window.
let exec = gate.wait_target_exec(Duration::from_secs(3));
match exec {
Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { child, gate }),
Ok(TargetExecState::Failed { errno }) => {
let termination = terminate_project_command_process_group_after_commit(&mut child);
Err(ProjectCommandError::new(
ProjectCommandErrorStage::TargetExec,
project_command_launch_error_with_termination(
format!("command target exec 失败:errno={errno}"),
termination,
),
))
}
Err(error) => {
let termination = terminate_project_command_process_group_after_commit(&mut child);
Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
project_command_launch_error_with_termination(error, termination),
))
}
}
}
#[cfg(not(target_os = "linux"))]
{
Ok(EstablishedProjectCommand { child })
}
}
fn project_command_launch_error_with_termination(
message: impl Into<String>,
termination: Result<String, String>,
) -> String {
match termination {
Ok(summary) => format!("{}{summary}", message.into()),
Err(error) => format!("{};进程终止与回收未确认:{error}", message.into()),
}
}
#[cfg(target_os = "linux")]
pub(crate) async fn wait_established_project_command_terminal(
mut gate: LaunchGate,
) -> Result<TargetTerminalState, ProjectCommandError> {
tokio::task::spawn_blocking(move || gate.wait_terminal(Duration::from_secs(2)))
.await
.map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("等待 command target terminal 任务失败:{error}"),
)
})?
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))
}
#[cfg(unix)]
fn request_unix_project_command_process_group_termination(
process_id: u32,
) -> Result<&'static str, String> {
let result = unsafe { libc::kill(-(process_id as i32), libc::SIGKILL) };
if result == 0 {
return Ok("已请求终止受控进程组");
}
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) {
return Ok("受控进程组已不存在");
}
Err(format!("请求终止受控进程组失败:{error}"))
}
async fn request_project_command_process_group_termination(
process_id: u32,
) -> Result<&'static str, String> {
#[cfg(unix)]
{
return request_unix_project_command_process_group_termination(process_id);
}
#[cfg(windows)]
{
let system_root = std::env::var_os("SystemRoot")
.ok_or_else(|| "请求终止受控进程组失败:缺少 SystemRoot".to_string())?;
let taskkill = fs::canonicalize(PathBuf::from(&system_root).join("System32/taskkill.exe"))
.map_err(|error| format!("请求终止受控进程组失败:定位 taskkill.exe 失败:{error}"))?;
if !taskkill.is_absolute() || !taskkill.is_file() {
return Err("请求终止受控进程组失败:taskkill.exe 不是绝对普通文件".to_string());
}
let mut command = tokio::process::Command::new(taskkill);
command
.args(["/PID", &process_id.to_string(), "/T", "/F"])
.env_clear()
.env("SystemRoot", &system_root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::configure_windows_background_tokio_command(&mut command, false);
let status = command
.status()
.await
.map_err(|error| format!("请求终止受控进程组失败:启动 taskkill.exe 失败:{error}"))?;
if !status.success() {
return Err(format!(
"请求终止受控进程组失败:taskkill.exe 退出码 {}",
status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "none".to_string())
));
}
return Ok("已请求终止受控进程组");
}
#[cfg(not(any(unix, windows)))]
{
let _ = process_id;
Err("请求终止受控进程组失败:当前平台不支持受控进程组终止".to_string())
}
}
async fn terminate_project_command_process_group(
child: &mut tokio::process::Child,
) -> Result<String, String> {
let process_id = child
.id()
.ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?;
let group_result = request_project_command_process_group_termination(process_id).await;
let child_kill_error = child.start_kill().err();
let wait_result = child.wait().await;
if let Err(error) = &group_result {
let fallback = match (&child_kill_error, &wait_result) {
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
(Some(kill_error), Err(wait_error)) => {
format!("主进程兜底终止失败:{kill_error};等待失败:{wait_error}")
}
(None, Err(wait_error)) => format!("等待主进程退出失败:{wait_error}"),
};
return Err(format!("{error}{fallback}"));
}
wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?;
Ok(format!(
"{}并完成主进程回收",
group_result.expect("group termination result checked")
))
}
#[cfg(target_os = "linux")]
fn terminate_project_command_process_group_after_commit(
child: &mut tokio::process::Child,
) -> Result<String, String> {
let process_id = child
.id()
.ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?;
let group_result = request_unix_project_command_process_group_termination(process_id);
let child_kill_error = child.start_kill().err();
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let wait_result = loop {
match child.try_wait() {
Ok(Some(status)) => break Ok(status),
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(5));
}
Ok(None) => break Err("同步等待主进程退出超时".to_string()),
Err(error) => break Err(format!("同步等待主进程退出失败:{error}")),
}
};
if let Err(error) = &group_result {
let fallback = match (&child_kill_error, &wait_result) {
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
(Some(kill_error), Err(wait_error)) => {
format!("主进程兜底终止失败:{kill_error}{wait_error}")
}
(None, Err(wait_error)) => wait_error.clone(),
};
return Err(format!("{error}{fallback}"));
}
wait_result?;
Ok(format!(
"{}并完成主进程回收",
group_result.expect("group termination result checked")
))
}
async fn read_bounded_project_command_output<R>(
mut reader: R,
) -> Result<BoundedCommandOutput, String>
where
R: tokio::io::AsyncRead + Unpin,
{
let mut output = BoundedCommandBytes::new(PROJECT_COMMAND_OUTPUT_MAX_BYTES);
let mut buffer = [0_u8; 4 * 1024];
loop {
let read = reader
.read(&mut buffer)
.await
.map_err(|error| format!("读取 command.exec 子进程输出失败:{error}"))?;
if read == 0 {
break;
}
output.push(&buffer[..read]);
}
Ok(output.finish())
}
async fn collect_project_command_output_task(
mut task: tokio::task::JoinHandle<Result<BoundedCommandOutput, String>>,
stream_name: &str,
) -> Result<BoundedCommandOutput, String> {
match tokio::time::timeout(Duration::from_secs(2), &mut task).await {
Ok(result) => {
result.map_err(|error| format!("收集 command.exec {stream_name} 失败:{error}"))?
}
Err(_) => {
task.abort();
Err(format!(
"command.exec {stream_name} 收集超时,执行结果需要人工核对"
))
}
}
}
pub(crate) fn project_command_source_fingerprint(root: &Path) -> Result<String, String> {
validate_project_root(root)?;
let mut entries_seen = 0usize;
let mut files_seen = 0usize;
let mut total_bytes = 0u64;
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).map_err(|error| {
format!("读取 command.exec 指纹目录失败:{}: {error}", dir.display())
})? {
entries_seen = entries_seen.saturating_add(1);
if entries_seen > PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES {
return Err(format!(
"command.exec 源码指纹超过目录项预算 {PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES}"
));
}
let entry = entry.map_err(|error| {
format!("读取 command.exec 指纹条目失败:{}: {error}", dir.display())
})?;
let file_type = entry.file_type().map_err(|error| {
format!(
"读取 command.exec 指纹文件类型失败:{}: {error}",
entry.path().display()
)
})?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
let relative_path = relative_project_path(root, &path)?;
if should_skip_project_index_path(&relative_path) {
continue;
}
if file_type.is_dir() {
dirs.push(path);
continue;
}
if !file_type.is_file() {
continue;
}
files_seen = files_seen.saturating_add(1);
if files_seen > PROJECT_COMMAND_FINGERPRINT_MAX_FILES {
return Err(format!(
"command.exec 源码指纹超过文件预算 {PROJECT_COMMAND_FINGERPRINT_MAX_FILES}"
));
}
let size = entry
.metadata()
.map_err(|error| {
format!(
"读取 command.exec 指纹元数据失败:{}: {error}",
path.display()
)
})?
.len();
total_bytes = total_bytes.saturating_add(size);
if total_bytes > PROJECT_COMMAND_FINGERPRINT_MAX_BYTES {
return Err(format!(
"command.exec 源码指纹超过字节预算 {PROJECT_COMMAND_FINGERPRINT_MAX_BYTES}"
));
}
let mut file = fs::File::open(&path).map_err(|error| {
format!(
"读取 command.exec 指纹文件失败:{}: {error}",
path.display()
)
})?;
let mut file_digest = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).map_err(|error| {
format!(
"读取 command.exec 指纹文件失败:{}: {error}",
path.display()
)
})?;
if read == 0 {
break;
}
file_digest.update(&buffer[..read]);
}
files.push((relative_path, size, format!("{:x}", file_digest.finalize())));
}
}
files.sort_by(|left, right| left.0.cmp(&right.0));
let mut digest = Sha256::new();
for (path, size, checksum) in files {
digest.update(path.as_bytes());
digest.update([0]);
digest.update(size.to_le_bytes());
digest.update([0]);
digest.update(checksum.as_bytes());
digest.update([b'\n']);
}
Ok(format!("{:x}", digest.finalize()))
}
async fn run_project_command_process<F>(
spec: &ProjectCommandSpec,
staged: StagedProjectCommandLaunchSpec,
durable_commit: F,
) -> Result<ProjectCommandProcessResult, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
let established = spawn_staged_project_command(staged, durable_commit).await?;
let mut child = established.child;
#[cfg(target_os = "linux")]
let gate = established.gate;
#[cfg(unix)]
let process_id = child.id();
let stdout = match child.stdout.take() {
Some(stdout) => stdout,
None => {
let termination = terminate_project_command_process_group(&mut child)
.await
.unwrap_or_else(|error| error);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("读取 command.exec stdout 失败;{termination}"),
));
}
};
let stderr = match child.stderr.take() {
Some(stderr) => stderr,
None => {
let termination = terminate_project_command_process_group(&mut child)
.await
.unwrap_or_else(|error| error);
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("读取 command.exec stderr 失败;{termination}"),
));
}
};
let stdout_task = tokio::spawn(read_bounded_project_command_output(stdout));
let stderr_task = tokio::spawn(read_bounded_project_command_output(stderr));
let wait = tokio::time::timeout(Duration::from_secs(spec.timeout_seconds), child.wait()).await;
let (exit_code, timed_out, termination_summary) = 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 {
if let Err(error) =
request_project_command_process_group_termination(process_id).await
{
stdout_task.abort();
stderr_task.abort();
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!(
"command.exec 主进程退出后请求终止受控进程组失败,需要人工核对:{error}"
),
));
}
}
(status.code(), false, None)
}
Ok(Err(error)) => {
let termination = terminate_project_command_process_group(&mut child)
.await
.unwrap_or_else(|termination_error| termination_error);
stdout_task.abort();
stderr_task.abort();
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("等待 command.exec 子进程失败:{error}{termination}"),
));
}
Err(_) => {
let termination = match terminate_project_command_process_group(&mut child).await {
Ok(termination) => termination,
#[cfg(windows)]
Err(error) if child.try_wait().ok().flatten().is_some() => {
format!(
"请求终止受控进程组后主进程已回收(taskkill 未找到已退出进程:{error}"
)
}
Err(error) => {
stdout_task.abort();
stderr_task.abort();
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("command.exec 超时后无法确认受控进程组终止,需要人工核对:{error}"),
));
}
};
(
None,
true,
Some(format!("{termination};该终止请求不等同完整 OS sandbox")),
)
}
};
let (stdout, stderr) = tokio::join!(
collect_project_command_output_task(stdout_task, "stdout"),
collect_project_command_output_task(stderr_task, "stderr"),
);
let stdout = stdout
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
let stderr = stderr
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
let mut sections = Vec::new();
if !stdout.text.trim().is_empty() {
sections.push(format!("stdout:\n{}", stdout.text.trim()));
}
if !stderr.text.trim().is_empty() {
sections.push(format!("stderr:\n{}", stderr.text.trim()));
}
if timed_out {
sections.push(format!(
"command.exec 在 {} 秒后超时;{}",
spec.timeout_seconds,
termination_summary
.as_deref()
.unwrap_or("已请求终止受控进程组")
));
} else if let Some(exit_code) = exit_code.filter(|code| *code != 0) {
sections.push(format!("command.exec 退出码:{exit_code}"));
}
if sections.is_empty() {
sections.push("command.exec 未产生输出".to_string());
}
let output = sanitize_project_verification_output(&sections.join("\n\n"));
let capture_truncated =
stdout.truncated || stderr.truncated || output.contains("...<output truncated:");
Ok(ProjectCommandProcessResult {
exit_code,
timed_out,
output,
capture_truncated,
})
}
fn project_command_id(spec: &ProjectCommandSpec) -> String {
let subcommand = spec
.arguments
.first()
.map(String::as_str)
.unwrap_or("run")
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
character
} else {
'-'
}
})
.collect::<String>();
format!("command.exec.{}.{}", spec.program, subcommand)
}
pub(crate) async fn run_project_command_at(
root: &Path,
program: &str,
arguments: &[String],
cwd: &str,
timeout_seconds: u64,
) -> Result<ProjectCommandResult, ProjectCommandError> {
run_project_command_with_output_at(root, program, arguments, cwd, timeout_seconds, None).await
}
pub(crate) async fn run_project_command_with_output_at(
root: &Path,
program: &str,
arguments: &[String],
cwd: &str,
timeout_seconds: u64,
output_identity: Option<CommandOutputIdentity>,
) -> Result<ProjectCommandResult, ProjectCommandError> {
let spec = resolve_project_command_spec_at(root, program, arguments, cwd, timeout_seconds)?;
let launch = prepare_project_command_launch_spec(root, &spec)?;
let staged = stage_project_command_launch_spec(&spec, launch)?;
run_prepared_project_command_with_output_at(root, &spec, staged, output_identity, || Ok(()))
.await
}
pub(crate) async fn run_project_bootstrap_command_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectCommandResult, ProjectCommandError> {
let spec = resolve_project_bootstrap_spec_at(root, timeout_seconds)?;
let launch = prepare_project_command_launch_spec(root, &spec)?;
let staged = stage_project_command_launch_spec(&spec, launch)?;
run_prepared_project_command_with_output_at(root, &spec, staged, None, || Ok(())).await
}
pub(crate) async fn run_prepared_project_command_with_output_at<F>(
root: &Path,
spec: &ProjectCommandSpec,
staged: StagedProjectCommandLaunchSpec,
output_identity: Option<CommandOutputIdentity>,
durable_commit: F,
) -> Result<ProjectCommandResult, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
let launch_metadata = staged.launch.clone();
let source_fingerprint_before = project_command_source_fingerprint(root)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?;
let started_at = std::time::Instant::now();
let process = run_project_command_process(spec, staged, durable_commit).await?;
let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let source_fingerprint_after = project_command_source_fingerprint(root).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::PostExecutionFingerprint,
format!("command.exec 执行后无法复核项目源码指纹,需要人工核对:{error}"),
)
})?;
let source_changed = source_fingerprint_before != source_fingerprint_after;
let completed = !process.timed_out && process.exit_code == Some(0) && !source_changed;
let status = if completed { "completed" } else { "failed" };
let command_id = project_command_id(&spec);
let updated_at = unix_timestamp();
let default_output_sha256 = format!("{:x}", Sha256::digest(process.output.as_bytes()));
let default_total_lines = command_output_line_count(&process.output);
let (output_ref, output_sha256, total_lines) = if let Some(identity) = output_identity {
let transcript = build_command_output_transcript(
identity,
&command_id,
&spec.program,
&spec.arguments,
&spec.cwd_relative,
process.exit_code,
process.timed_out,
duration_ms,
source_changed,
process.capture_truncated,
&process.output,
updated_at,
)
.map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::OutputSidecar,
format!("command.exec 执行后构建输出 sidecar 失败,需要人工核对:{error}"),
)
})?;
write_command_output_transcript_at(root, &transcript).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::OutputSidecar,
format!("command.exec 执行后写入输出 sidecar 失败,需要人工核对:{error}"),
)
})?;
(
Some(transcript.output_ref),
transcript.output_sha256,
transcript.total_lines,
)
} else {
(None, default_output_sha256, default_total_lines)
};
let log_path = resolve_local_project_path(root, ".agent/logs/command.log")
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?;
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::AuditLog,
format!("创建 command.exec 日志目录失败:{error}"),
)
})?;
}
let argument_bytes = serde_json::to_vec(&spec.arguments).unwrap_or_default();
let log_entry = format!(
"{updated_at} command.exec program={} argsSha256={:x} argsCount={} cwd={} {status} exitCode={} timedOut={} durationMs={} sourceChanged={} verificationEligible={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={}\n{}\n",
spec.program,
Sha256::digest(&argument_bytes),
spec.arguments.len(),
spec.cwd_relative,
process
.exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "none".to_string()),
process.timed_out,
duration_ms,
source_changed,
spec.verification_eligible,
launch_metadata.sandbox_backend,
launch_metadata.sandbox_mode,
launch_metadata.network_access,
launch_metadata.sandbox_profile_version,
process.output,
);
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| file.write_all(log_entry.as_bytes()))
.map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::AuditLog,
format!("command.exec 执行后写入命令日志失败,需要人工核对:{error}"),
)
})?;
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,
},
)
.map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::ManifestProjection,
format!("command.exec 执行后写入命令投影失败,需要人工核对:{error}"),
)
})?;
Ok(ProjectCommandResult {
command_id,
program: spec.program.clone(),
arguments: spec.arguments.clone(),
cwd_relative: spec.cwd_relative.clone(),
status: status.to_string(),
exit_code: process.exit_code,
timed_out: process.timed_out,
duration_ms,
output: process.output,
capture_truncated: process.capture_truncated,
output_ref,
output_sha256,
total_lines,
source_fingerprint_before,
source_fingerprint_after,
source_changed,
verification_eligible: spec.verification_eligible,
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,
log_path: log_path.to_string_lossy().into_owned(),
updated_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[tokio::test]
async fn staged_launcher_never_commits_before_sandbox_ready() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let committed = Arc::new(AtomicBool::new(false));
let committed_for_callback = Arc::clone(&committed);
let staged = StagedProjectCommandLaunchSpec {
launch: ProjectCommandLaunchSpec {
executable: PathBuf::from("/usr/bin/false"),
arguments: Vec::new(),
cwd: PathBuf::from("/"),
environment: Vec::new(),
sandbox_backend: "bubblewrap".to_string(),
sandbox_mode: "workspace-write".to_string(),
network_access: "disabled".to_string(),
sandbox_profile_version: "workspace-v1".to_string(),
},
gate: LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/true"), &[])
.expect("create staged launch gate"),
};
let error = spawn_staged_project_command(staged, move || {
committed_for_callback.store(true, Ordering::SeqCst);
Ok(())
})
.await
.expect_err("launcher without bwrap status must fail before ready");
assert_eq!(error.stage(), ProjectCommandErrorStage::Preflight);
assert!(!committed.load(Ordering::SeqCst));
}
fn command_project(name: &str) -> tempfile::TempDir {
let dir = tempfile::Builder::new()
.prefix(&format!("game-creator-command-{name}-"))
.tempdir()
.expect("command tempdir");
init_local_game_project_at(dir.path(), "command-project", "命令测试")
.expect("init command project");
dir
}
fn command_args(arguments: &[&str]) -> Vec<String> {
arguments
.iter()
.map(|argument| (*argument).to_string())
.collect()
}
#[cfg(unix)]
fn write_fake_command_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
fs::create_dir_all(path.parent().expect("fake executable parent"))
.expect("create fake executable parent");
fs::write(path, "#!/bin/sh\nexit 0\n").expect("write fake executable");
let mut permissions = fs::metadata(path)
.expect("fake executable metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).expect("make fake executable executable");
}
#[cfg(not(target_os = "linux"))]
#[test]
fn project_command_rejects_shells_paths_and_dangerous_options() {
let dir = command_project("validation");
let root = dir.path();
for (program, args) in [
("bash", vec!["-lc", "echo nope"]),
("cargo", vec!["test", "--manifest-path", "../Cargo.toml"]),
("npm", vec!["exec", "vite"]),
("node", vec!["-e", "process.exit(0)"]),
("git", vec!["commit", "-m", "nope"]),
("rg", vec!["--pre", "sh", "needle"]),
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
assert!(
resolve_project_command_spec_at(root, program, &args, ".", 30).is_err(),
"expected rejection for {program} {args:?}"
);
}
let absolute = vec![
"test".to_string(),
std::env::temp_dir()
.join("outside.rs")
.to_string_lossy()
.into_owned(),
];
assert!(resolve_project_command_spec_at(root, "cargo", &absolute, ".", 30).is_err());
let sensitive = vec!["status".to_string(), ".agent/agent.db".to_string()];
assert!(resolve_project_command_spec_at(root, "git", &sensitive, ".", 30).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn project_command_accepts_general_programs_only_as_bare_names() {
let dir = command_project("general-programs");
let root = dir.path();
let shell = resolve_project_command_spec_at(
root,
"bash",
&command_args(&["-lc", "printf general-command"]),
".",
30,
)
.expect("resolve sandboxed shell");
assert_eq!(shell.program, "bash");
for program in ["/bin/bash", "../bash", "tool/name", "bad name", ""] {
assert!(
resolve_project_command_spec_at(root, program, &[], ".", 30).is_err(),
"expected bare program rejection for {program:?}"
);
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn project_command_workspace_sandbox_blocks_host_controls_and_network() {
let dir = command_project("workspace-sandbox");
let root = dir.path();
for name in [".git", ".agents", ".codex"] {
fs::create_dir_all(root.join(name)).expect("create protected directory");
fs::write(root.join(name).join("marker"), name).expect("write protected marker");
}
let outside = root
.parent()
.expect("sandbox project parent")
.join(format!("outside-{}", std::process::id()));
fs::write(&outside, "HOST_SECRET").expect("write outside sentinel");
let script = format!(
r#"set -eu
printf WORKSPACE_OK > workspace-write.txt
test ! -r {outside:?}
! printf NO > {outside:?}
for control in .git .agents .codex; do
test -r "$control/marker"
! touch "$control/blocked-write"
done
! test -r .agent/manifest.json
bash -c 'test ! -r {outside:?}; ! touch .git/child-blocked'
python3 -c 'import socket; s=socket.socket(); s.settimeout(0.2); code=0
try: s.connect(("1.1.1.1", 53)); code=1
except OSError: pass
finally: s.close()
raise SystemExit(code)'
"#,
outside = outside.to_string_lossy(),
);
fs::write(root.join("sandbox-check.sh"), script).expect("write sandbox check script");
let result =
run_project_command_at(root, "bash", &["sandbox-check.sh".to_string()], ".", 30)
.await
.expect("run workspace sandbox command");
assert_eq!(result.exit_code, Some(0), "{}", result.output);
assert_eq!(result.sandbox_backend, "bubblewrap");
assert_eq!(result.sandbox_mode, "workspace-write");
assert_eq!(result.network_access, "disabled");
assert_eq!(
fs::read_to_string(root.join("workspace-write.txt")).expect("workspace write"),
"WORKSPACE_OK"
);
assert_eq!(
fs::read_to_string(&outside).expect("outside sentinel"),
"HOST_SECRET"
);
for name in [".git", ".agents", ".codex"] {
assert!(!root.join(name).join("blocked-write").exists());
}
fs::remove_file(outside).ok();
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn project_command_runs_project_build_and_git_read_inside_sandbox() {
let dir = command_project("sandbox-build-git");
let root = dir.path();
fs::create_dir_all(root.join("src")).expect("create Rust source directory");
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"sandbox-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("write Cargo.toml");
fs::write(
root.join("Cargo.lock"),
"# This file is automatically @generated by Cargo.\n# It is not intended for manual editing.\nversion = 4\n\n[[package]]\nname = \"sandbox-fixture\"\nversion = \"0.1.0\"\n",
)
.expect("write Cargo.lock");
fs::write(root.join("src/lib.rs"), "pub fn answer() -> u32 { 42 }\n")
.expect("write Rust source");
let git_status = std::process::Command::new("git")
.args(["init", "--quiet"])
.current_dir(root)
.status()
.expect("initialize Git repository");
assert!(git_status.success());
let build = run_project_command_at(
root,
"cargo",
&command_args(&["check", "--quiet"]),
".",
120,
)
.await
.expect("run cargo check in sandbox");
assert_eq!(build.status, "completed", "{}", build.output);
assert_eq!(build.sandbox_mode, "workspace-write");
assert_eq!(build.network_access, "disabled");
let git =
run_project_command_at(root, "git", &command_args(&["status", "--short"]), ".", 30)
.await
.expect("run git status in sandbox");
assert_eq!(git.exit_code, Some(0), "{}", git.output);
assert_eq!(git.sandbox_backend, "bubblewrap");
}
#[cfg(unix)]
#[test]
fn project_command_rejects_symlinked_cwd_ancestors() {
use std::os::unix::fs::symlink;
let dir = command_project("cwd-symlink");
let outside = tempfile::tempdir().expect("outside command cwd");
fs::create_dir_all(outside.path().join("nested")).expect("outside nested cwd");
symlink(outside.path(), dir.path().join("linked-cwd")).expect("symlink command cwd");
let error = resolve_project_command_spec_at(
dir.path(),
"node",
&["--test".to_string(), "sample.test.mjs".to_string()],
"linked-cwd/nested",
30,
)
.expect_err("reject symlinked cwd ancestor");
assert_eq!(error.stage(), ProjectCommandErrorStage::Validation);
assert!(error.message().contains("符号链接"));
}
#[test]
fn project_command_accepts_targeted_developer_commands() {
let dir = command_project("allowed");
let root = dir.path();
fs::create_dir_all(root.join("tests")).expect("allowed tests dir");
fs::write(root.join("tests/sample.test.mjs"), "// test fixture\n")
.expect("allowed node test fixture");
for (program, args) in [
("cargo", vec!["test", "-p", "example", "specific_test"]),
("npm", vec!["run", "test:unit", "--", "sample"]),
("node", vec!["--test", "tests/sample.test.mjs"]),
("git", vec!["diff", "--stat"]),
("rg", vec!["-n", "needle"]),
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
let spec = resolve_project_command_spec_at(root, program, &args, ".", 30)
.unwrap_or_else(|error| panic!("expected {program} to pass: {error}"));
assert_eq!(spec.program, program);
}
}
#[test]
fn project_command_node_requires_exact_regular_test_files() {
for arguments in [
command_args(&["--test"]),
command_args(&["--test=tests/sample.test.mjs"]),
command_args(&["--test", "--test-reporter=spec", "tests/sample.test.mjs"]),
command_args(&[
"--test",
"tests/sample.test.mjs",
"--test-name-pattern=unit",
]),
command_args(&["--test", "tests/*.test.mjs"]),
command_args(&["--test", "tests/{one,two}.test.mjs"]),
] {
assert!(
validate_node_arguments(&arguments).is_err(),
"expected strict node rejection for {arguments:?}"
);
}
assert!(validate_node_arguments(&command_args(&[
"--test",
"tests/one.test.mjs",
"tests/two.test.ts",
]))
.is_ok());
}
#[cfg(unix)]
#[test]
fn project_command_node_rejects_symlinked_test_files_and_ancestors() {
use std::os::unix::fs::symlink;
let dir = command_project("node-test-symlink");
let root = dir.path();
fs::create_dir_all(root.join("tests/real")).expect("real tests dir");
fs::write(root.join("tests/real/sample.test.mjs"), "// real test\n")
.expect("real node test");
symlink(
root.join("tests/real/sample.test.mjs"),
root.join("tests/linked.test.mjs"),
)
.expect("linked node test");
symlink(root.join("tests/real"), root.join("tests/linked-dir"))
.expect("linked node test dir");
for test_path in ["tests/linked.test.mjs", "tests/linked-dir/sample.test.mjs"] {
let error = resolve_project_command_spec_at(
root,
"node",
&command_args(&["--test", test_path]),
".",
30,
)
.expect_err("reject linked node test path");
assert_eq!(error.stage(), ProjectCommandErrorStage::Validation);
assert!(error.message().contains("符号链接"));
}
}
#[test]
fn project_command_git_rejects_indirect_execution_and_sensitive_objects() {
for arguments in [
command_args(&["grep", "-O", "less", "needle"]),
command_args(&["grep", "-Oless", "needle"]),
command_args(&["grep", "--open-files-in-pager=less", "needle"]),
command_args(&["grep", "-fpatterns.txt"]),
command_args(&["log", "--paginate"]),
command_args(&["show", "--show-signature", "HEAD"]),
command_args(&["status", "--pathspec-from-file=paths.txt"]),
command_args(&["status", "--pathspec-file-nul"]),
command_args(&["ls-files", "--exclude-from=paths.txt"]),
command_args(&["diff", "--no-index", "left", "right"]),
command_args(&["show", "HEAD:.env"]),
command_args(&["show", "HEAD:.git/config"]),
command_args(&["show", "HEAD:keys/private.key"]),
command_args(&["show", "HEAD:config"]),
command_args(&["grep", "needle", "--", ":(glob)**/.env"]),
] {
assert!(
validate_git_arguments(&arguments).is_err(),
"expected strict git rejection for {arguments:?}"
);
}
assert!(validate_git_arguments(&command_args(&["grep", "-n", "needle"])).is_ok());
assert!(validate_git_arguments(&command_args(&["show", "HEAD:package.json"])).is_ok());
}
#[test]
fn project_command_rg_rejects_visibility_archive_and_glob_bypasses() {
for arguments in [
command_args(&["-L", "needle"]),
command_args(&["--follow", "needle"]),
command_args(&["--hidden", "needle"]),
command_args(&["-u", "needle"]),
command_args(&["--no-ignore-vcs", "needle"]),
command_args(&["-z", "needle"]),
command_args(&["--search-zip", "needle"]),
command_args(&["--pre=sh", "needle"]),
command_args(&["--pre-glob=*.js", "needle"]),
command_args(&["--hostname-bin=sh", "needle"]),
command_args(&["--glob=**/.env", "needle"]),
command_args(&["-g*.rs", "needle"]),
command_args(&["--iglob", "*.rs", "needle"]),
command_args(&["-fpatterns.txt"]),
command_args(&["--type-add=secret:*.env", "-tsecret", "needle"]),
command_args(&["--type", "rust", "needle"]),
command_args(&["-trust", "needle"]),
] {
assert!(
validate_rg_arguments(&arguments).is_err(),
"expected strict rg rejection for {arguments:?}"
);
}
assert!(validate_rg_arguments(&command_args(&["-n", "needle", "src"])).is_ok());
}
#[test]
fn project_command_verification_eligibility_is_conservative() {
for (program, arguments) in [
("cargo", command_args(&["check"])),
("cargo", command_args(&["test", "specific_test"])),
("cargo", command_args(&["clippy"])),
("cargo", command_args(&["fmt", "--check"])),
("cargo", command_args(&["build"])),
("npm", command_args(&["test"])),
("npm", command_args(&["run", "check:encoding"])),
("npm", command_args(&["run", "typecheck"])),
("node", command_args(&["--test", "tests/sample.test.mjs"])),
] {
assert!(
project_command_verification_eligible(program, &arguments),
"expected verification eligible: {program} {arguments:?}"
);
}
for (program, arguments) in [
("cargo", command_args(&["metadata"])),
("npm", command_args(&["run", "dev"])),
("npm", command_args(&["run", "start"])),
("git", command_args(&["status"])),
("rg", command_args(&["needle"])),
] {
assert!(
!project_command_verification_eligible(program, &arguments),
"expected verification ineligible: {program} {arguments:?}"
);
}
}
#[cfg(unix)]
#[test]
fn project_command_executable_resolution_ignores_project_and_relative_path_poisoning() {
let dir = command_project("path-poisoning");
let root = dir.path();
let outside = tempfile::tempdir().expect("outside executable dir");
let executable_name = project_command_executable_names("node")
.into_iter()
.next()
.expect("node executable name");
let project_bin = root.join("tools/bin");
let project_executable = project_bin.join(&executable_name);
let outside_bin = outside.path().join("bin");
let outside_executable = outside_bin.join(&executable_name);
write_fake_command_executable(&project_executable);
write_fake_command_executable(&outside_executable);
let poisoned_path = std::env::join_paths([
PathBuf::from("relative-bin"),
project_bin.clone(),
outside_bin.clone(),
])
.expect("poisoned PATH");
let (executable, safe_path) =
resolve_project_command_executable_from_path(root, "node", &poisoned_path)
.expect("resolve outside executable");
assert_eq!(
executable,
fs::canonicalize(&outside_executable).expect("canonical outside executable")
);
let canonical_root = fs::canonicalize(root).expect("canonical command root");
let safe_directories = std::env::split_paths(&safe_path).collect::<Vec<_>>();
assert!(!safe_directories.is_empty());
assert!(safe_directories
.iter()
.all(|directory| directory.is_absolute() && !directory.starts_with(&canonical_root)));
assert!(!safe_directories
.iter()
.any(|directory| directory == &fs::canonicalize(&project_bin).unwrap()));
let project_only_path =
std::env::join_paths([project_bin]).expect("project-only poisoned PATH");
assert!(
resolve_project_command_executable_from_path(root, "node", &project_only_path).is_err()
);
}
#[cfg(windows)]
#[test]
fn project_command_safe_path_uses_win32_compatible_directories() {
let dir = command_project("windows-safe-path");
let raw_path = std::env::var_os("PATH").expect("PATH");
let (_, safe_path) =
resolve_project_command_executable_from_path(dir.path(), "node", &raw_path)
.expect("resolve node executable");
assert!(std::env::split_paths(&safe_path)
.all(|directory| { !directory.as_os_str().to_string_lossy().starts_with(r"\\?\") }));
}
#[cfg(not(target_os = "linux"))]
#[test]
fn project_command_injects_git_safety_options_before_pathspec_separator() {
let dir = command_project("git-arguments");
let spec = resolve_project_command_spec_at(
dir.path(),
"git",
&[
"diff".to_string(),
"--".to_string(),
"game/index.html".to_string(),
],
".",
30,
)
.expect("resolve git diff");
let arguments = project_command_actual_arguments(&spec);
let separator = arguments
.iter()
.position(|argument| argument == "--")
.expect("pathspec separator");
let no_ext_diff = arguments
.iter()
.position(|argument| argument == "--no-ext-diff")
.expect("safe external diff option");
let no_textconv = arguments
.iter()
.position(|argument| argument == "--no-textconv")
.expect("safe textconv option");
assert!(no_ext_diff < separator);
assert!(no_textconv < separator);
assert_eq!(arguments[separator + 1], "game/index.html");
for pathspec in PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS {
let position = arguments
.iter()
.position(|argument| argument == pathspec)
.unwrap_or_else(|| panic!("missing protected Git pathspec {pathspec}"));
assert!(position > separator);
}
}
#[cfg(not(target_os = "linux"))]
#[test]
fn project_command_injects_non_overridable_rg_sensitive_exclusions() {
let dir = command_project("rg-arguments");
let spec = resolve_project_command_spec_at(
dir.path(),
"rg",
&command_args(&["-n", "needle"]),
".",
30,
)
.expect("resolve rg search");
let arguments = project_command_actual_arguments(&spec);
assert_eq!(arguments.first().map(String::as_str), Some("--no-config"));
assert!(arguments.iter().any(|argument| argument == "--no-follow"));
assert!(arguments.iter().any(|argument| argument == "--no-hidden"));
for glob in PROJECT_COMMAND_SENSITIVE_RG_GLOBS {
let position = arguments
.windows(2)
.position(|pair| pair[0] == "--glob" && pair[1] == *glob);
assert!(position.is_some(), "missing protected rg glob {glob}");
}
let last_user_argument = arguments
.iter()
.position(|argument| argument == "needle")
.expect("user rg pattern");
let first_protected_glob = arguments
.windows(2)
.position(|pair| {
pair[0] == "--glob" && pair[1] == PROJECT_COMMAND_SENSITIVE_RG_GLOBS[0]
})
.expect("first protected rg glob");
assert!(first_protected_glob > last_user_argument);
assert!(
validate_rg_arguments(&command_args(&["-n", "needle", "--glob", "**/.env",])).is_err()
);
}
#[test]
fn project_command_npm_rejects_shell_metacharacters_in_forwarded_arguments() {
for arguments in [
command_args(&["run", "test:unit", "--", ";touch-outside"]),
command_args(&["run", "test:unit", "--", "$(id)"]),
command_args(&["test", "--", "name with spaces"]),
command_args(&["run", "test:unit", "--", "value|other"]),
] {
assert!(
validate_npm_arguments(&arguments).is_err(),
"expected npm shell metacharacter rejection for {arguments:?}"
);
}
assert!(
validate_npm_arguments(&command_args(&["run", "test:unit", "--", "sample.test",]))
.is_ok()
);
assert!(validate_npm_arguments(&command_args(&[
"run",
"--silent",
"--ignore-scripts",
"test:unit",
]))
.is_ok());
assert!(
validate_npm_arguments(&command_args(&["run", "--silent", "--ignore-scripts",]))
.is_err()
);
}
#[tokio::test]
async fn project_command_reports_success_failure_timeout_redaction_and_source_changes() {
let dir = command_project("execution");
let root = dir.path();
fs::create_dir_all(root.join("tests")).expect("tests dir");
fs::write(
root.join("tests/pass.test.mjs"),
"import test from 'node:test';\nimport assert from 'node:assert/strict';\ntest('pass', () => { console.log('PASS_MARKER'); assert.equal(1, 1); });\n",
)
.expect("write passing test");
let success = run_project_command_at(
root,
"node",
&["--test".to_string(), "tests/pass.test.mjs".to_string()],
".",
30,
)
.await
.expect("run passing test");
assert_eq!(success.status, "completed");
assert_eq!(success.exit_code, Some(0));
assert!(!success.source_changed);
assert!(success.verification_eligible);
assert!(success.output.contains("PASS_MARKER"));
let secret = ["s", "k-command-", "secret-123456789"].concat();
fs::write(
root.join("tests/fail.test.mjs"),
format!(
"import test from 'node:test';\nimport assert from 'node:assert/strict';\ntest('fail', () => {{ console.log({secret:?}); assert.equal(1, 2); }});\n"
),
)
.expect("write failing test");
let failed = run_project_command_at(
root,
"node",
&["--test".to_string(), "tests/fail.test.mjs".to_string()],
".",
30,
)
.await
.expect("run failing test");
assert_eq!(failed.status, "failed");
assert_ne!(failed.exit_code, Some(0));
assert!(!failed.output.contains(&secret));
fs::write(
root.join("tests/change.test.mjs"),
"import test from 'node:test';\nimport fs from 'node:fs';\ntest('change', () => { fs.writeFileSync('changed.txt', 'changed\\n'); });\n",
)
.expect("write changing test");
let changed = run_project_command_at(
root,
"node",
&["--test".to_string(), "tests/change.test.mjs".to_string()],
".",
30,
)
.await
.expect("run changing test");
assert!(changed.source_changed);
assert_eq!(changed.status, "failed");
fs::write(
root.join("tests/timeout.test.mjs"),
"import test from 'node:test';\ntest('wait', async () => { await new Promise(resolve => setTimeout(resolve, 5000)); });\n",
)
.expect("write timeout test");
let timed_out = run_project_command_at(
root,
"node",
&["--test".to_string(), "tests/timeout.test.mjs".to_string()],
".",
1,
)
.await
.expect("run timeout test");
assert!(timed_out.timed_out);
assert_eq!(timed_out.status, "failed");
assert!(timed_out.output.contains("请求终止受控进程组"));
assert!(timed_out.output.contains("不等同完整 OS sandbox"));
let command_log = root.join(".agent/logs/command.log");
fs::remove_file(&command_log).expect("remove command log before audit failure");
fs::create_dir(&command_log).expect("replace command log with directory");
let audit_error = run_project_command_at(
root,
"node",
&["--test".to_string(), "tests/pass.test.mjs".to_string()],
".",
30,
)
.await
.expect_err("audit log failure after execution");
assert_eq!(audit_error.stage(), ProjectCommandErrorStage::AuditLog);
assert!(audit_error.execution_started());
assert!(audit_error.needs_reconciliation());
}
#[test]
fn project_command_errors_distinguish_validation_from_started_execution() {
let dir = command_project("error-stage");
let error = resolve_project_command_spec_at(dir.path(), "../bash", &[], ".", 30)
.expect_err("reject executable path");
assert_eq!(error.stage(), ProjectCommandErrorStage::Validation);
assert_eq!(error.stage().as_str(), "validation");
assert!(!error.execution_started());
assert!(!error.needs_reconciliation());
}
#[test]
fn bounded_command_output_keeps_head_and_tail() {
let mut output = BoundedCommandBytes::new(30);
output.push(b"HEAD-0123456789-MIDDLE-abcdefghij-TAIL");
let output = output.finish();
assert!(output.text.contains("HEAD"));
assert!(output.text.contains("TAIL"));
assert!(output.text.contains("omitted"));
assert!(output.truncated);
}
}