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