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, 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, 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, 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, timed_out: bool, duration_ms: u64, source_changed: bool, capture_truncated: bool, output: &str, updated_at: u64, ) -> Result { 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 { 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::>() .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 { 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()); } }