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

3515 lines
130 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 std::sync::atomic::Ordering;
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,
pub(crate) cancel_flag: Option<Arc<AtomicBool>>,
#[cfg(target_os = "linux")]
pub(crate) gate: LaunchGate,
}
#[derive(Debug)]
pub(crate) struct EstablishedProjectCommand {
tree: ProjectCommandTree,
pub(crate) child: tokio::process::Child,
#[cfg(target_os = "linux")]
pub(crate) gate: LaunchGate,
}
#[derive(Debug)]
enum ProjectCommandTree {
#[cfg(windows)]
Job(crate::process_session::WindowsProcessJob),
#[cfg(not(windows))]
Group {
pid: u32,
start_identity: Option<String>,
},
}
impl ProjectCommandTree {
fn attach(child: &tokio::process::Child) -> Result<Self, String> {
#[cfg(windows)]
{
crate::process_session::WindowsProcessJob::assign_tokio(child).map(Self::Job)
}
#[cfg(not(windows))]
{
let pid = child.id().ok_or("受控命令缺少进程身份")?;
let start_identity = crate::runner::external_agent_runner_process_start_identity(pid)
.ok()
.flatten()
.filter(|identity| !identity.is_empty());
Ok(Self::Group {
pid,
start_identity,
})
}
}
#[cfg(not(windows))]
fn request_owned_group_termination(&self) -> Result<&'static str, String> {
let Self::Group {
pid,
start_identity,
} = self;
#[cfg(unix)]
{
let group = i32::try_from(*pid)
.ok()
.filter(|pid| *pid > 0)
.ok_or("受控命令进程组身份无效")?;
if unsafe { libc::kill(-group, 0) } != 0 {
return if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
Ok("受控进程组已不存在")
} else {
Err("受控进程组状态未确认,拒绝发送终止信号".into())
};
}
let observed = crate::runner::external_agent_runner_process_start_identity(*pid)
.map_err(|_| "受控进程组 leader 身份未确认,拒绝发送终止信号")?;
if !owned_project_command_group_identity_matches(
start_identity.as_deref(),
observed.as_deref(),
) {
return Err("受控进程组 leader 身份未确认,拒绝发送终止信号".into());
}
request_unix_project_command_process_group_termination(*pid)
}
#[cfg(not(unix))]
{
let _ = (pid, start_identity);
Err("当前平台不支持受控进程组身份核对".into())
}
}
async fn terminate(&self, child: &mut tokio::process::Child) -> Result<String, String> {
#[cfg(windows)]
{
let Self::Job(job) = self;
let requested = job.terminate();
if requested.is_err() {
let _ = child.start_kill();
}
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let waited = tokio::time::timeout_at(deadline, child.wait()).await;
requested?;
waited
.map_err(|_| "等待受控命令主进程退出超时")?
.map_err(|_| "受控命令主进程退出未确认")?;
while !job.is_empty()? {
if tokio::time::Instant::now() >= deadline {
return Err("受控命令 Windows Job 子树退出未确认".into());
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
Ok("已请求终止受控进程组并确认 Windows Job 全部退出".into())
}
#[cfg(not(windows))]
{
let requested = self.request_owned_group_termination();
let _ = child.start_kill();
let waited = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
requested?;
waited
.map_err(|_| "等待受控命令主进程退出超时")?
.map_err(|_| "受控命令主进程退出未确认")?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
}
}
async fn after_main_exit(&self, child: &mut tokio::process::Child) -> Result<(), String> {
#[cfg(windows)]
{
self.terminate(child).await.map(|_| ())
}
#[cfg(not(windows))]
{
let _ = child;
#[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
if !linux_project_command_group_has_live_members(*pid)? {
return Ok(());
}
}
self.request_owned_group_termination().map(|_| ())
}
}
}
#[cfg(target_os = "linux")]
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
let inspect = || -> std::io::Result<bool> {
let process_group = i32::try_from(group)
.ok()
.filter(|group| *group > 0)
.ok_or_else(|| std::io::Error::other("受控命令进程组身份无效"))?;
if unsafe { libc::kill(-process_group, 0) } != 0 {
let error = std::io::Error::last_os_error();
return if error.raw_os_error() == Some(libc::ESRCH) {
Ok(false)
} else {
Err(error)
};
}
for entry in fs::read_dir("/proc")? {
let entry = entry?;
if entry.file_name().to_string_lossy().parse::<u32>().is_err() {
continue;
}
let stat = match fs::read(entry.path().join("stat")) {
Ok(stat) => stat,
Err(error)
if error.kind() == std::io::ErrorKind::NotFound
|| error.raw_os_error() == Some(libc::ESRCH) =>
{
continue;
}
Err(error) => return Err(error),
};
let invalid_stat = || std::io::Error::other("无法解析 /proc 进程组状态");
// comm 可以包含括号和非 UTF-8 字节;只解析最后一个分隔符后的 ASCII 字段。
let end = stat
.windows(2)
.rposition(|pair| pair == b") ")
.ok_or_else(invalid_stat)?;
let tail = std::str::from_utf8(&stat[end + 2..]).map_err(|_| invalid_stat())?;
let mut fields = tail.split_whitespace();
let state = fields.next().ok_or_else(invalid_stat)?;
let process_group = fields
.nth(1)
.and_then(|value| value.parse::<u32>().ok())
.ok_or_else(invalid_stat)?;
if process_group == group && state != "Z" && state != "X" {
return Ok(true);
}
}
Ok(false)
};
inspect().map_err(|error| format!("读取受控进程组存活状态失败:{error}"))
}
#[cfg(any(unix, test))]
fn owned_project_command_group_identity_matches(
expected: Option<&str>,
observed: Option<&str>,
) -> bool {
matches!((expected, observed), (Some(expected), Some(observed)) if !expected.is_empty() && expected == observed)
}
async fn wait_project_command_cancelled(flag: Option<Arc<AtomicBool>>) {
let Some(flag) = flag else {
return std::future::pending::<()>().await;
};
while !flag.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
enum ProjectCommandWait {
Exited(std::io::Result<std::process::ExitStatus>),
TimedOut,
Cancelled,
}
#[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,
cancelled: bool,
output: String,
capture_truncated: bool,
}
/// 仅供宿主补丁入口使用的进程回执,不套用“源码未变化”的验证判据。
pub(crate) struct OwnedCodexPatchResult {
pub(crate) exit_code: Option<i32>,
pub(crate) timed_out: bool,
pub(crate) needs_reconciliation: bool,
pub(crate) output: String,
}
/// 调用方必须先绑定可信 Codex 身份、完整检查目标路径并持有项目写事务。
/// 此入口仍复用现有进程 Job/进程组、受限环境、取消与有界输出。
pub(crate) async fn run_owned_codex_patch_at<F>(
root: &Path,
executable: &Path,
patch: &str,
cancel_flag: Arc<AtomicBool>,
before_launch: F,
) -> Result<OwnedCodexPatchResult, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
let spec = ProjectCommandSpec {
program: "codex-apply-patch".into(),
executable: executable.to_path_buf(),
safe_path: OsString::new(),
arguments: vec!["--codex-run-as-apply-patch".into(), patch.into()],
cwd_relative: ".".into(),
cwd: root.to_path_buf(),
timeout_seconds: 15,
verification_eligible: false,
};
let launch = prepare_project_command_launch_spec(root, &spec)?;
let mut staged = stage_project_command_launch_spec(&spec, launch)?;
staged.cancel_flag = Some(cancel_flag);
let process = run_project_command_process(&spec, staged, before_launch).await?;
Ok(OwnedCodexPatchResult {
exit_code: process.exit_code,
timed_out: process.timed_out,
needs_reconciliation: process.cancelled || process.timed_out || process.exit_code.is_none(),
output: process.output,
})
}
#[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> {
if matches!(program, "node" | "npm") {
let runtime = crate::environment_check::resolve_node_runtime(root)?;
let executable = if program == "node" {
runtime.node.clone()
} else {
runtime
.node
.parent()
.ok_or("node-runtime-invalid")?
.join(if cfg!(windows) { "npm.cmd" } else { "npm" })
};
if !executable.is_file() {
return Err("npm-runtime-missing-launcher".into());
}
return Ok((executable, runtime.safe_path));
}
#[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,
cancel_flag: None,
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = spec;
Ok(StagedProjectCommandLaunchSpec {
launch,
cancel_flag: None,
})
}
}
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);
use std::os::windows::process::CommandExt;
// 挂入 Job 前不得运行目标程序;保留既有后台/新进程组标记。
command
.as_std_mut()
.creation_flags(0x0800_0000 | 0x0000_0200 | 0x0000_0004);
}
}
pub(crate) async fn spawn_staged_project_command<F>(
staged: StagedProjectCommandLaunchSpec,
durable_commit: F,
) -> Result<EstablishedProjectCommand, ProjectCommandError>
where
F: FnOnce() -> Result<(), String>,
{
if staged
.cancel_flag
.as_ref()
.is_some_and(|flag| flag.load(Ordering::Acquire))
{
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
"command.exec 执行许可已取消,未启动命令",
));
}
#[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);
if staged
.cancel_flag
.as_ref()
.is_some_and(|flag| flag.load(Ordering::Acquire))
{
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
"command.exec 执行许可已取消,未启动命令",
));
}
#[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 staged
.cancel_flag
.as_ref()
.is_some_and(|flag| flag.load(Ordering::Acquire))
{
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
project_command_launch_error_with_termination(
"command.exec 执行许可已取消,未派发目标程序",
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) => {
let tree = ProjectCommandTree::attach(&child).map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
})?;
Ok(EstablishedProjectCommand { tree, 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"))]
{
let mut child = child;
let tree = match ProjectCommandTree::attach(&child) {
Ok(tree) => tree,
Err(error) => {
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
format!("命令已启动但进程树归属未建立,结果不确定:{error}"),
));
}
};
#[cfg(windows)]
{
let ProjectCommandTree::Job(job) = &tree;
if staged
.cancel_flag
.as_ref()
.is_some_and(|flag| flag.load(Ordering::Acquire))
{
let termination = tree.terminate(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
project_command_launch_error_with_termination(
"command.exec 执行许可已取消,目标程序未恢复",
termination,
),
));
}
if let Err(error) = job.resume_suspended_tokio(&child) {
let termination = tree.terminate(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::LaunchUnknown,
project_command_launch_error_with_termination(error, termination),
));
}
}
Ok(EstablishedProjectCommand { tree, 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 cancel_flag = staged.cancel_flag.clone();
let established = spawn_staged_project_command(staged, durable_commit).await?;
let tree = established.tree;
let mut child = established.child;
#[cfg(target_os = "linux")]
let gate = established.gate;
let stdout = match child.stdout.take() {
Some(stdout) => stdout,
None => {
let termination = tree
.terminate(&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 = tree
.terminate(&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::select! {
biased;
_ = wait_project_command_cancelled(cancel_flag) => ProjectCommandWait::Cancelled,
status = child.wait() => ProjectCommandWait::Exited(status),
_ = tokio::time::sleep(Duration::from_secs(spec.timeout_seconds)) => ProjectCommandWait::TimedOut,
};
let cancelled = matches!(&wait, ProjectCommandWait::Cancelled);
let (exit_code, timed_out, termination_summary) = match wait {
ProjectCommandWait::Exited(Ok(status)) => {
#[cfg(target_os = "linux")]
let _terminal = wait_established_project_command_terminal(gate).await?;
if let Err(error) = tree.after_main_exit(&mut child).await {
stdout_task.abort();
stderr_task.abort();
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"),
));
}
(status.code(), false, None)
}
ProjectCommandWait::Exited(Err(error)) => {
let termination = tree
.terminate(&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}"),
));
}
ProjectCommandWait::TimedOut | ProjectCommandWait::Cancelled => {
let termination = match tree.terminate(&mut child).await {
Ok(termination) => termination,
Err(error) => {
stdout_task.abort();
stderr_task.abort();
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
format!("command.exec 超时后无法确认受控进程组终止,需要人工核对:{error}"),
));
}
};
(
None,
!cancelled,
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 cancelled {
sections.push(format!(
"command.exec 执行许可已取消;{}",
termination_summary.as_deref().unwrap_or("进程树回收未确认")
));
} else 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,
cancelled,
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 exited_group_accepts_orphan_zombies_but_rejects_live_members() {
use std::os::unix::process::CommandExt;
const TEST: &str =
"command_exec::tests::exited_group_accepts_orphan_zombies_but_rejects_live_members";
const FIXTURE: &str = "AGC_COMMAND_ORPHAN_FIXTURE";
if std::env::var_os(FIXTURE).is_none() {
// subreaper 只影响隔离夹具,避免接管并行测试的子进程。
let output = tokio::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", TEST, "--nocapture"])
.env(FIXTURE, "1")
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
return;
}
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
let mut command = tokio::process::Command::new("/bin/sh");
command
.args(["-c", "sleep 60 & echo $!; read release"])
.stdin(Stdio::piped())
.stdout(Stdio::piped());
command.as_std_mut().process_group(0);
let mut child = command.spawn().unwrap();
let tree = ProjectCommandTree::attach(&child).unwrap();
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
.await
.unwrap();
let descendant: i32 = line.trim().parse().unwrap();
drop(child.stdin.take());
child.wait().await.unwrap();
let live_result = tree.after_main_exit(&mut child).await;
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
assert_eq!(
unsafe {
libc::waitid(
libc::P_PID,
descendant as u32,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
},
0
);
let zombie_result = tree.after_main_exit(&mut child).await;
assert_eq!(
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) },
descendant
);
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理");
assert!(error.contains("leader 身份未确认"), "{error}");
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对");
tree.after_main_exit(&mut child).await.unwrap();
}
#[test]
fn owned_process_group_refuses_missing_or_reused_leader_identity() {
assert!(owned_project_command_group_identity_matches(
Some("start-1"),
Some("start-1")
));
for (expected, observed) in [
(Some("start-1"), Some("start-2")),
(Some("start-1"), None),
(None, Some("start-1")),
(None, None),
(Some(""), Some("")),
] {
assert!(!owned_project_command_group_identity_matches(
expected, observed
));
}
}
#[cfg(windows)]
const OWNED_FIXTURE: &str = "command_exec::tests::owned_command_process_fixture";
#[cfg(windows)]
#[test]
#[ignore = "owned command subprocess fixture"]
fn owned_command_process_fixture() {
let Ok(mode) = std::env::var("AGC_COMMAND_TREE_FIXTURE") else {
return;
};
let marker = std::env::var_os("AGC_COMMAND_TREE_MARKER").unwrap();
if mode == "leaf" {
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&marker)
.unwrap();
for sequence in 0u64.. {
writeln!(file, "{sequence}").unwrap();
std::thread::sleep(Duration::from_millis(20));
}
}
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
command
.args(["--exact", OWNED_FIXTURE, "--ignored", "--nocapture"])
.env("AGC_COMMAND_TREE_FIXTURE", "leaf")
.env("AGC_COMMAND_TREE_MARKER", &marker)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::configure_windows_background_std_command(&mut command, false);
let _child = command.spawn().unwrap();
while !Path::new(&marker).exists() {
std::thread::sleep(Duration::from_millis(10));
}
if mode == "parent-exit" {
return;
}
loop {
std::thread::sleep(Duration::from_secs(1));
}
}
#[cfg(windows)]
fn owned_command_launch(
root: &Path,
mode: &str,
cancel_flag: Option<Arc<AtomicBool>>,
) -> (ProjectCommandSpec, StagedProjectCommandLaunchSpec) {
let executable = std::env::current_exe().unwrap();
let arguments = vec![
"--exact".into(),
OWNED_FIXTURE.into(),
"--ignored".into(),
"--nocapture".into(),
];
let mut environment = vec![
(
OsString::from("AGC_COMMAND_TREE_FIXTURE"),
OsString::from(mode),
),
(
OsString::from("AGC_COMMAND_TREE_MARKER"),
root.join("writer.txt").into_os_string(),
),
];
for key in ["SystemRoot", "WINDIR", "TEMP", "TMP"] {
if let Some(value) = std::env::var_os(key) {
environment.push((key.into(), value));
}
}
let spec = ProjectCommandSpec {
program: "fixture".into(),
executable: executable.clone(),
safe_path: OsString::new(),
arguments,
cwd_relative: ".".into(),
cwd: root.to_path_buf(),
timeout_seconds: 20,
verification_eligible: false,
};
let staged = StagedProjectCommandLaunchSpec {
launch: ProjectCommandLaunchSpec {
executable,
arguments: spec.arguments.iter().map(OsString::from).collect(),
cwd: root.to_path_buf(),
environment,
sandbox_backend: "owned-fixture".into(),
sandbox_mode: "fixed-command".into(),
network_access: "disabled".into(),
sandbox_profile_version: "fixture-v1".into(),
},
cancel_flag,
};
(spec, staged)
}
#[cfg(windows)]
#[tokio::test]
async fn cancelled_before_spawn_has_no_commit_or_process_marker() {
let root = tempfile::tempdir().unwrap();
let cancelled = Arc::new(AtomicBool::new(true));
let committed = Arc::new(AtomicBool::new(false));
let (_, staged) = owned_command_launch(root.path(), "parent-wait", Some(cancelled));
let observed = Arc::clone(&committed);
let error = spawn_staged_project_command(staged, move || {
observed.store(true, Ordering::Release);
Ok(())
})
.await
.expect_err("cancelled command must not spawn");
assert_eq!(error.stage(), ProjectCommandErrorStage::Preflight);
assert!(!committed.load(Ordering::Acquire));
assert!(!root.path().join("writer.txt").exists());
}
#[cfg(windows)]
#[tokio::test]
async fn command_cancel_flag_stops_owned_descendants_and_retains_cancelled_result() {
let root = tempfile::tempdir().unwrap();
let marker = root.path().join("writer.txt");
let cancelled = Arc::new(AtomicBool::new(false));
let (spec, staged) =
owned_command_launch(root.path(), "parent-wait", Some(Arc::clone(&cancelled)));
let task =
tokio::spawn(
async move { run_project_command_process(&spec, staged, || Ok(())).await },
);
tokio::time::timeout(Duration::from_secs(5), async {
while !marker.exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
cancelled.store(true, Ordering::Release);
let result = tokio::time::timeout(Duration::from_secs(7), task)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(result.exit_code, None);
assert!(!result.timed_out);
assert!(result.output.contains("执行许可已取消"));
assert!(result.output.contains("Windows Job 全部退出"));
let stopped = fs::metadata(&marker).unwrap().len();
tokio::time::sleep(Duration::from_millis(150)).await;
assert_eq!(fs::metadata(marker).unwrap().len(), stopped);
}
#[cfg(windows)]
#[tokio::test]
async fn successful_main_exit_reaps_remaining_children_before_returning() {
let root = tempfile::tempdir().unwrap();
let marker = root.path().join("writer.txt");
let (spec, staged) = owned_command_launch(root.path(), "parent-exit", None);
let result = run_project_command_process(&spec, staged, || Ok(()))
.await
.unwrap();
assert_eq!(result.exit_code, Some(0));
let stopped = fs::metadata(&marker).unwrap().len();
tokio::time::sleep(Duration::from_millis(150)).await;
assert_eq!(fs::metadata(marker).unwrap().len(), stopped);
}
#[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"),
cancel_flag: None,
};
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);
}
}