Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/command_output.rs
T
suzmii 26e4eab6b6 对齐主线ACL文件边界
将AGC Rust源码中的ACL专用实现恢复为主线版本

移除本分支内的Windows ACL提权命令与私有路径调用

保留Router、联网搜索和流式传输改动在后续stash中继续处理
2026-08-31 13:09:29 +08:00

608 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use sha2::{Digest, Sha256};
pub(crate) const COMMAND_OUTPUT_TRANSCRIPT_SCHEMA_VERSION: &str = "game-creator-command-output.v1";
pub(crate) const COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES: usize = 256 * 1024;
pub(crate) const COMMAND_OUTPUT_READ_DEFAULT_LINES: usize = 160;
pub(crate) const COMMAND_OUTPUT_READ_MAX_LINES: usize = 240;
const COMMAND_OUTPUT_TRANSCRIPT_DIR: &str = ".agent/runtime/command-outputs";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct CommandOutputIdentity {
pub(crate) agent_id: String,
pub(crate) task_id: String,
pub(crate) session_id: String,
pub(crate) run_id: String,
pub(crate) action_id: String,
pub(crate) action_fingerprint: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct CommandOutputTranscript {
pub(crate) schema_version: String,
pub(crate) identity: CommandOutputIdentity,
pub(crate) output_ref: String,
pub(crate) command_id: String,
pub(crate) program: String,
pub(crate) args_sha256: String,
pub(crate) args_count: usize,
pub(crate) cwd: String,
pub(crate) exit_code: Option<i32>,
pub(crate) timed_out: bool,
pub(crate) duration_ms: u64,
pub(crate) source_changed: bool,
pub(crate) capture_truncated: bool,
pub(crate) output_sha256: String,
pub(crate) total_lines: usize,
pub(crate) output: String,
pub(crate) updated_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommandOutputPage {
pub(crate) source_action_id: String,
pub(crate) source_run_id: String,
pub(crate) source_action_fingerprint: String,
pub(crate) output_ref: String,
pub(crate) lines: String,
pub(crate) start_line: usize,
pub(crate) next_line: Option<usize>,
pub(crate) total_lines: usize,
pub(crate) has_more: bool,
pub(crate) capture_truncated: bool,
pub(crate) output_sha256: String,
pub(crate) exit_code: Option<i32>,
pub(crate) timed_out: bool,
pub(crate) source_changed: bool,
}
pub(crate) fn command_output_relative_path(identity: &CommandOutputIdentity) -> String {
let mut digest = Sha256::new();
for value in [
identity.agent_id.as_str(),
identity.task_id.as_str(),
identity.session_id.as_str(),
identity.run_id.as_str(),
identity.action_id.as_str(),
] {
digest.update(value.as_bytes());
digest.update([0]);
}
format!(
"{COMMAND_OUTPUT_TRANSCRIPT_DIR}/{:x}.json",
digest.finalize()
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_command_output_transcript(
identity: CommandOutputIdentity,
command_id: &str,
program: &str,
arguments: &[String],
cwd: &str,
exit_code: Option<i32>,
timed_out: bool,
duration_ms: u64,
source_changed: bool,
capture_truncated: bool,
output: &str,
updated_at: u64,
) -> Result<CommandOutputTranscript, String> {
validate_command_output_identity(&identity)?;
validate_command_output_safe_text(command_id, "commandId", 160)?;
validate_command_output_safe_text(program, "program", 40)?;
let cwd = if cwd == "." {
".".to_string()
} else {
normalize_relative_path(cwd)?
};
let output = sanitize_project_verification_output(output);
let output_sha256 = format!("{:x}", Sha256::digest(output.as_bytes()));
let total_lines = command_output_line_count(&output);
let args = serde_json::to_vec(arguments)
.map_err(|error| format!("序列化 command.exec argv 摘要失败:{error}"))?;
let transcript = CommandOutputTranscript {
schema_version: COMMAND_OUTPUT_TRANSCRIPT_SCHEMA_VERSION.to_string(),
output_ref: command_output_relative_path(&identity),
identity,
command_id: command_id.to_string(),
program: program.to_string(),
args_sha256: format!("{:x}", Sha256::digest(args)),
args_count: arguments.len(),
cwd,
exit_code,
timed_out,
duration_ms,
source_changed,
capture_truncated,
output_sha256,
total_lines,
output,
updated_at,
};
validate_command_output_transcript(&transcript)?;
Ok(transcript)
}
pub(crate) fn write_command_output_transcript_at(
root: &Path,
transcript: &CommandOutputTranscript,
) -> Result<(), String> {
validate_command_output_transcript(transcript)?;
let mut content = serde_json::to_vec_pretty(transcript)
.map_err(|error| format!("序列化 command.exec 输出 sidecar 失败:{error}"))?;
content.push(b'\n');
if content.len() > COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES {
return Err(format!(
"command.exec 输出 sidecar 超过 {} 字节上限",
COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES
));
}
let path = resolve_local_project_path(root, &transcript.output_ref)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"创建 command.exec 输出 sidecar 目录失败:{}: {error}",
parent.display()
)
})?;
}
let path = resolve_local_project_path(root, &transcript.output_ref)?;
match fs::symlink_metadata(&path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("command.exec 输出 sidecar 必须是普通文件".to_string());
}
let existing = read_command_output_transcript_file(&path)?;
if existing == *transcript {
return Ok(());
}
return Err("command.exec 输出 sidecar 已存在且身份或内容冲突".to_string());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取 command.exec 输出 sidecar 元数据失败:{}: {error}",
path.display()
));
}
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = match options.open(&path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let existing = read_command_output_transcript_file(&path)?;
if existing == *transcript {
return Ok(());
}
return Err("command.exec 输出 sidecar 并发创建后内容冲突".to_string());
}
Err(error) => {
return Err(format!(
"创建 command.exec 输出 sidecar 失败:{}: {error}",
path.display()
));
}
};
file.write_all(&content).map_err(|error| {
format!(
"写入 command.exec 输出 sidecar 失败:{}: {error}",
path.display()
)
})?;
file.sync_all().map_err(|error| {
format!(
"同步 command.exec 输出 sidecar 失败:{}: {error}",
path.display()
)
})?;
validate_command_output_file_handle(&file, &path)?;
drop(file);
let installed = read_command_output_transcript_file(&path)?;
if installed != *transcript {
return Err("command.exec 输出 sidecar 安装后内容不一致".to_string());
}
#[cfg(unix)]
if let Some(parent) = path.parent() {
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| {
format!(
"同步 command.exec 输出 sidecar 目录失败:{}: {error}",
parent.display()
)
})?;
}
Ok(())
}
pub(crate) fn read_command_output_page_at(
root: &Path,
expected: &CommandOutputIdentity,
start_line: usize,
max_lines: usize,
) -> Result<CommandOutputPage, String> {
validate_command_output_identity(expected)?;
if start_line == 0 {
return Err("command.output_read 的 startLine 必须从 1 开始".to_string());
}
if max_lines == 0 || max_lines > COMMAND_OUTPUT_READ_MAX_LINES {
return Err(format!(
"command.output_read 的 maxLines 必须在 1-{} 之间",
COMMAND_OUTPUT_READ_MAX_LINES
));
}
let output_ref = command_output_relative_path(expected);
let path = resolve_local_project_path(root, &output_ref)?;
let transcript = read_command_output_transcript_file(&path)?;
validate_command_output_transcript(&transcript)?;
if transcript.identity != *expected || transcript.output_ref != output_ref {
return Err("command.output_read 的 sidecar 身份与源动作不匹配".to_string());
}
if transcript.total_lines == 0 {
if start_line != 1 {
return Err("command.output_read 的 startLine 超出空输出范围".to_string());
}
return Ok(CommandOutputPage {
source_action_id: expected.action_id.clone(),
source_run_id: expected.run_id.clone(),
source_action_fingerprint: expected.action_fingerprint.clone(),
output_ref,
lines: String::new(),
start_line,
next_line: None,
total_lines: 0,
has_more: false,
capture_truncated: transcript.capture_truncated,
output_sha256: transcript.output_sha256,
exit_code: transcript.exit_code,
timed_out: transcript.timed_out,
source_changed: transcript.source_changed,
});
}
if start_line > transcript.total_lines {
return Err(format!(
"command.output_read 的 startLine 超出总行数 {}",
transcript.total_lines
));
}
let end_line = start_line
.saturating_add(max_lines)
.saturating_sub(1)
.min(transcript.total_lines);
let lines = transcript
.output
.split('\n')
.enumerate()
.skip(start_line - 1)
.take(end_line - start_line + 1)
.map(|(index, line)| format!("{}: {line}", index + 1))
.collect::<Vec<_>>()
.join("\n");
let has_more = end_line < transcript.total_lines;
Ok(CommandOutputPage {
source_action_id: expected.action_id.clone(),
source_run_id: expected.run_id.clone(),
source_action_fingerprint: expected.action_fingerprint.clone(),
output_ref,
lines,
start_line,
next_line: has_more.then_some(end_line + 1),
total_lines: transcript.total_lines,
has_more,
capture_truncated: transcript.capture_truncated,
output_sha256: transcript.output_sha256,
exit_code: transcript.exit_code,
timed_out: transcript.timed_out,
source_changed: transcript.source_changed,
})
}
fn read_command_output_transcript_file(path: &Path) -> Result<CommandOutputTranscript, String> {
let (mut file, metadata) =
open_project_snapshot_regular_file(path, "command.exec 输出 sidecar")?;
if metadata.len() > COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES as u64 {
return Err(format!(
"command.exec 输出 sidecar 超过 {} 字节上限",
COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES
));
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
std::io::Read::by_ref(&mut file)
.take((COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| {
format!(
"读取 command.exec 输出 sidecar 失败:{}: {error}",
path.display()
)
})?;
if bytes.len() > COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES {
return Err(format!(
"command.exec 输出 sidecar 超过 {} 字节上限",
COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES
));
}
let final_metadata = file.metadata().map_err(|error| {
format!(
"复核 command.exec 输出 sidecar 失败:{}: {error}",
path.display()
)
})?;
if final_metadata.len() != metadata.len() {
return Err("command.exec 输出 sidecar 在读取期间发生漂移".to_string());
}
validate_command_output_file_handle(&file, path)?;
serde_json::from_slice(&bytes).map_err(|error| {
format!(
"解析 command.exec 输出 sidecar 失败:{}: {error}",
path.display()
)
})
}
fn validate_command_output_transcript(transcript: &CommandOutputTranscript) -> Result<(), String> {
if transcript.schema_version != COMMAND_OUTPUT_TRANSCRIPT_SCHEMA_VERSION {
return Err(format!(
"不支持的 command.exec 输出 sidecar schema{}",
transcript.schema_version
));
}
validate_command_output_identity(&transcript.identity)?;
if transcript.output_ref != command_output_relative_path(&transcript.identity)
|| !transcript
.output_ref
.starts_with(&format!("{COMMAND_OUTPUT_TRANSCRIPT_DIR}/"))
|| !transcript.output_ref.ends_with(".json")
{
return Err("command.exec 输出 sidecar 的 outputRef 无效".to_string());
}
validate_command_output_safe_text(&transcript.command_id, "commandId", 160)?;
validate_command_output_safe_text(&transcript.program, "program", 40)?;
if transcript.cwd != "." {
normalize_relative_path(&transcript.cwd)?;
}
validate_sha256(&transcript.args_sha256, "argsSha256")?;
validate_sha256(&transcript.output_sha256, "outputSha256")?;
let sanitized = sanitize_project_verification_output(&transcript.output);
if sanitized != transcript.output
|| format!("{:x}", Sha256::digest(transcript.output.as_bytes())) != transcript.output_sha256
|| command_output_line_count(&transcript.output) != transcript.total_lines
{
return Err("command.exec 输出 sidecar 的正文摘要或行数无效".to_string());
}
Ok(())
}
fn validate_command_output_identity(identity: &CommandOutputIdentity) -> Result<(), String> {
validate_command_output_safe_text(&identity.agent_id, "agentId", 96)?;
validate_command_output_safe_text(&identity.task_id, "taskId", 96)?;
validate_command_output_safe_text(&identity.session_id, "sessionId", 160)?;
validate_command_output_safe_text(&identity.run_id, "runId", 160)?;
if !identity
.action_id
.strip_prefix("action-")
.is_some_and(|suffix| {
suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
})
{
return Err("command.exec 输出 sidecar 的 actionId 无效".to_string());
}
validate_sha256(&identity.action_fingerprint, "actionFingerprint")
}
fn validate_command_output_safe_text(
value: &str,
field: &str,
max_chars: usize,
) -> Result<(), String> {
if value.trim().is_empty()
|| value.chars().count() > max_chars
|| value.chars().any(char::is_control)
{
return Err(format!("command.exec 输出 sidecar 的 {field} 无效"));
}
Ok(())
}
fn validate_sha256(value: &str, field: &str) -> Result<(), String> {
if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
Ok(())
} else {
Err(format!("command.exec 输出 sidecar 的 {field} 无效"))
}
}
pub(crate) fn command_output_line_count(output: &str) -> usize {
if output.is_empty() {
0
} else {
output.split('\n').count()
}
}
fn validate_command_output_file_handle(file: &File, path: &Path) -> Result<(), String> {
let metadata = file.metadata().map_err(|error| {
format!(
"读取 command.exec 输出 sidecar 句柄失败:{}: {error}",
path.display()
)
})?;
if !metadata.is_file() {
return Err("command.exec 输出 sidecar 必须是普通文件".to_string());
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.nlink() != 1 {
return Err("command.exec 输出 sidecar 不能是硬链接".to_string());
}
}
#[cfg(windows)]
crate::runner::validate_windows_regular_file_handle(file, "command.exec 输出 sidecar")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn output_project(name: &str) -> tempfile::TempDir {
let dir = tempfile::Builder::new()
.prefix(&format!("command-output-{name}-"))
.tempdir()
.expect("command output tempdir");
init_local_game_project_at(dir.path(), "command-output-project", "命令输出测试")
.expect("init command output project");
dir
}
fn identity(seed: &str) -> CommandOutputIdentity {
CommandOutputIdentity {
agent_id: "code-prototype".to_string(),
task_id: "code-prototype".to_string(),
session_id: format!("session-{seed}"),
run_id: format!("run-{seed}"),
action_id: format!("action-{:024x}", seed.len()),
action_fingerprint: format!("{:064x}", seed.len()),
}
}
fn transcript(seed: &str, output: &str) -> CommandOutputTranscript {
build_command_output_transcript(
identity(seed),
"command.exec.node.--test",
"node",
&["--test".to_string(), "test/sample.test.mjs".to_string()],
".",
Some(1),
false,
25,
false,
true,
output,
1,
)
.expect("build transcript")
}
#[test]
fn command_output_paginates_unicode_with_one_based_lines() {
let dir = output_project("page");
let transcript = transcript("page", "第一行\nsecond\n第三行");
write_command_output_transcript_at(dir.path(), &transcript).expect("write transcript");
let first = read_command_output_page_at(dir.path(), &transcript.identity, 1, 2)
.expect("first page");
assert_eq!(first.lines, "1: 第一行\n2: second");
assert_eq!(first.next_line, Some(3));
assert!(first.has_more);
assert!(first.capture_truncated);
let last =
read_command_output_page_at(dir.path(), &transcript.identity, 3, 2).expect("last page");
assert_eq!(last.lines, "3: 第三行");
assert_eq!(last.next_line, None);
assert!(!last.has_more);
}
#[test]
fn command_output_write_is_immutable_and_idempotent() {
let dir = output_project("immutable");
let transcript = transcript("immutable", "stable output");
write_command_output_transcript_at(dir.path(), &transcript).expect("first write");
write_command_output_transcript_at(dir.path(), &transcript).expect("idempotent write");
let mut conflict = transcript.clone();
conflict.output = "changed".to_string();
conflict.output_sha256 = format!("{:x}", Sha256::digest(conflict.output.as_bytes()));
conflict.total_lines = 1;
assert!(write_command_output_transcript_at(dir.path(), &conflict)
.expect_err("reject conflict")
.contains("冲突"));
}
#[test]
fn command_output_rejects_identity_and_corrupt_content() {
let dir = output_project("identity");
let transcript = transcript("identity", "output");
write_command_output_transcript_at(dir.path(), &transcript).expect("write transcript");
let mut other = transcript.identity.clone();
other.run_id = "run-other".to_string();
assert!(read_command_output_page_at(dir.path(), &other, 1, 1).is_err());
fs::write(
resolve_local_project_path(dir.path(), &transcript.output_ref).expect("output path"),
b"{broken",
)
.expect("corrupt transcript");
assert!(read_command_output_page_at(dir.path(), &transcript.identity, 1, 1).is_err());
}
#[test]
fn command_output_handles_empty_output_and_rejects_page_limits() {
let dir = output_project("empty");
let transcript = transcript("empty", "");
write_command_output_transcript_at(dir.path(), &transcript)
.expect("write empty transcript");
let page = read_command_output_page_at(dir.path(), &transcript.identity, 1, 1)
.expect("read empty transcript");
assert!(page.lines.is_empty());
assert_eq!(page.total_lines, 0);
assert_eq!(page.next_line, None);
assert!(!page.has_more);
assert!(read_command_output_page_at(dir.path(), &transcript.identity, 0, 1).is_err());
assert!(read_command_output_page_at(
dir.path(),
&transcript.identity,
1,
COMMAND_OUTPUT_READ_MAX_LINES + 1,
)
.is_err());
let path = resolve_local_project_path(dir.path(), &transcript.output_ref).expect("path");
fs::write(&path, vec![b'x'; COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES + 1])
.expect("write oversized transcript");
assert!(
read_command_output_page_at(dir.path(), &transcript.identity, 1, 1)
.expect_err("reject oversized transcript")
.contains("字节上限")
);
}
#[cfg(unix)]
#[test]
fn command_output_rejects_symlink_and_hardlink_targets() {
use std::os::unix::fs::symlink;
let dir = output_project("links");
let transcript = transcript("links", "output");
let path = resolve_local_project_path(dir.path(), &transcript.output_ref).expect("path");
fs::create_dir_all(path.parent().expect("parent")).expect("create output dir");
let outside = dir.path().join("outside.json");
fs::write(&outside, b"{}\n").expect("write outside");
symlink(&outside, &path).expect("create symlink");
assert!(write_command_output_transcript_at(dir.path(), &transcript).is_err());
fs::remove_file(&path).expect("remove symlink");
fs::hard_link(&outside, &path).expect("create hardlink");
assert!(read_command_output_page_at(dir.path(), &transcript.identity, 1, 1).is_err());
fs::remove_file(&path).expect("remove hardlink");
symlink(dir.path().join("missing.json"), &path).expect("create dangling symlink");
assert!(write_command_output_transcript_at(dir.path(), &transcript).is_err());
}
}