Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/command_output.rs
T
AIGameCreator App 648170b224 新增命令输出分页回查能力
新增 command.output_read 并按当前 Agent 的持久动作身份分页读取命令输出
为 command.exec 写入受限 transcript sidecar 并保持审计与回执零正文
完善隔离 Agent 模板提示、失败结果收束和重复 spawn 去重
将真实 Provider E2E 改为无固定配方的结果导向验收
同步共享命令与能力契约并补齐 Rust 和 TypeScript 测试
更新 Runtime 技术方案、实施计划和共享决策记录
2026-07-14 00:22:02 +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());
}
}