1use super::{AtpEvidenceLedger, CrashpackError};
29use crate::lab::oracle::evidence::EvidenceEntry;
30use serde::{Deserialize, Serialize};
31use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33use thiserror::Error;
34
35pub const AGENT_PROOF_BUNDLE_SCHEMA_VERSION: u32 = 1;
37
38const MAX_COMMAND_OUTPUT_BYTES: usize = 16 * 1024;
40
41const MAX_PATH_LENGTH: usize = 512;
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AgentTaskProofBundle {
47 pub schema_version: u32,
49
50 pub objective: String,
52
53 pub agent_id: String,
55
56 pub bead_ids: Vec<String>,
58
59 pub file_reservations: Vec<FileReservationRecord>,
61
62 pub touched_paths: Vec<PathBuf>,
64
65 pub commands: Vec<CommandRecord>,
67
68 pub rch_details: Option<RchRecord>,
70
71 pub commit_ids: CommitRecord,
73
74 pub validation_frontier: ValidationFrontierRecord,
76
77 pub mail_thread_ids: Vec<String>,
79
80 pub artifact_paths: Vec<PathBuf>,
82
83 pub first_blocker: Option<BlockerRecord>,
85
86 pub replay_instructions: ReplayInstructions,
88
89 pub evidence_ledger: AtpEvidenceLedger,
91
92 pub metadata: BTreeMap<String, String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct FileReservationRecord {
99 pub path: PathBuf,
101 pub holder: String,
103 pub reservation_id: String,
105 pub released_cleanly: bool,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct CommandRecord {
112 pub command: String,
114 pub working_dir: PathBuf,
116 pub exit_status: i32,
118 pub stdout: String,
120 pub stderr: String,
122 pub outputs_redacted: bool,
124 pub duration_ms: u64,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RchRecord {
131 pub worker_id: Option<String>,
133 pub admitted: bool,
135 pub refusal_reason: Option<String>,
137 pub queue_position: Option<u32>,
139 pub wait_time_ms: Option<u64>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct CommitRecord {
146 pub before_commit: String,
148 pub after_commit: Option<String>,
150 pub dirty_tree_before: bool,
152 pub dirty_tree_after: bool,
154 pub changed_files: Vec<PathBuf>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ValidationFrontierRecord {
161 pub main_commit: String,
163 pub compile_failures: Vec<String>,
165 pub test_failures: Vec<String>,
167 pub production_lib_green: bool,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct BlockerRecord {
174 pub blocker_type: String,
176 pub description: String,
178 pub source_location: Option<String>,
180 pub concurrent_change: bool,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ReplayInstructions {
187 pub safety_level: ReplaySafetyLevel,
189 pub safe_commands: Vec<String>,
191 pub remote_required_commands: Vec<String>,
193 pub approval_required_commands: Vec<String>,
195 pub environment_variables: BTreeMap<String, String>,
197 pub required_file_state: Vec<PathBuf>,
199 pub manual_setup_instructions: Vec<String>,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205pub enum ReplaySafetyLevel {
206 Safe,
208 RemoteRequired,
210 EnvironmentDependent,
212 ApprovalRequired,
214}
215
216#[derive(Debug, Default)]
218pub struct AgentTaskProofBundleBuilder {
219 objective: Option<String>,
220 agent_id: Option<String>,
221 bead_ids: Vec<String>,
222 file_reservations: Vec<FileReservationRecord>,
223 touched_paths: Vec<PathBuf>,
224 commands: Vec<CommandRecord>,
225 rch_details: Option<RchRecord>,
226 commit_ids: Option<CommitRecord>,
227 validation_frontier: Option<ValidationFrontierRecord>,
228 mail_thread_ids: Vec<String>,
229 artifact_paths: Vec<PathBuf>,
230 first_blocker: Option<BlockerRecord>,
231 replay_instructions: Option<ReplayInstructions>,
232 evidence_ledger: AtpEvidenceLedger,
233 metadata: BTreeMap<String, String>,
234}
235
236impl AgentTaskProofBundleBuilder {
237 pub fn new() -> Self {
239 Self::default()
240 }
241
242 pub fn with_objective(mut self, objective: impl Into<String>) -> Self {
244 self.objective = Some(objective.into());
245 self
246 }
247
248 pub fn with_agent_id(mut self, agent_id: impl Into<String>) -> Self {
250 self.agent_id = Some(agent_id.into());
251 self
252 }
253
254 pub fn with_bead_id(mut self, bead_id: impl Into<String>) -> Self {
256 self.bead_ids.push(bead_id.into());
257 self
258 }
259
260 pub fn with_file_reservation(mut self, reservation: FileReservationRecord) -> Self {
262 self.file_reservations.push(reservation);
263 self
264 }
265
266 pub fn with_touched_path(mut self, path: impl Into<PathBuf>) -> Self {
268 let path = path.into();
269 if !self.touched_paths.contains(&path) {
270 self.touched_paths.push(path); }
272 self
273 }
274
275 pub fn with_command(mut self, command: impl Into<String>, exit_status: i32) -> Self {
277 let record = CommandRecord {
278 command: command.into(),
279 working_dir: std::env::current_dir().unwrap_or_default(),
280 exit_status,
281 stdout: String::new(),
282 stderr: String::new(),
283 outputs_redacted: false,
284 duration_ms: 0,
285 };
286 self.commands.push(record);
287 self
288 }
289
290 pub fn with_command_record(mut self, record: CommandRecord) -> Self {
292 self.commands.push(record);
293 self
294 }
295
296 pub fn with_rch_details(mut self, rch: RchRecord) -> Self {
298 self.rch_details = Some(rch);
299 self
300 }
301
302 pub fn with_rch_worker(mut self, worker_id: impl Into<String>) -> Self {
304 self.rch_details = Some(RchRecord {
305 worker_id: Some(worker_id.into()),
306 admitted: true,
307 refusal_reason: None,
308 queue_position: None,
309 wait_time_ms: None,
310 });
311 self
312 }
313
314 pub fn with_commit_record(mut self, commits: CommitRecord) -> Self {
316 self.commit_ids = Some(commits);
317 self
318 }
319
320 pub fn with_commit_id(mut self, commit_id: impl Into<String>) -> Self {
322 self.commit_ids = Some(CommitRecord {
323 before_commit: commit_id.into(),
324 after_commit: None,
325 dirty_tree_before: false,
326 dirty_tree_after: false,
327 changed_files: Vec::new(),
328 });
329 self
330 }
331
332 pub fn with_validation_frontier(mut self, frontier: ValidationFrontierRecord) -> Self {
334 self.validation_frontier = Some(frontier);
335 self
336 }
337
338 pub fn with_mail_thread_id(mut self, thread_id: impl Into<String>) -> Self {
340 self.mail_thread_ids.push(thread_id.into());
341 self
342 }
343
344 pub fn with_artifact_path(mut self, path: impl Into<PathBuf>) -> Self {
346 let path = path.into();
347 if !self.artifact_paths.contains(&path) {
348 self.artifact_paths.push(path); }
350 self
351 }
352
353 pub fn with_first_blocker(mut self, blocker: BlockerRecord) -> Self {
355 self.first_blocker = Some(blocker);
356 self
357 }
358
359 pub fn with_replay_instructions(mut self, instructions: ReplayInstructions) -> Self {
361 self.replay_instructions = Some(instructions);
362 self
363 }
364
365 pub fn with_evidence(
367 mut self,
368 oracle_name: impl Into<String>,
369 evidence: EvidenceEntry,
370 ) -> Self {
371 self.evidence_ledger
372 .record_oracle_result(oracle_name, evidence, None);
373 self
374 }
375
376 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
378 self.metadata.insert(key.into(), value.into());
379 self
380 }
381
382 pub fn build(self) -> Result<AgentTaskProofBundle, AgentProofError> {
384 let objective = self
385 .objective
386 .ok_or(AgentProofError::MissingField("objective"))?;
387 let agent_id = self
388 .agent_id
389 .ok_or(AgentProofError::MissingField("agent_id"))?;
390
391 let commit_ids = self.commit_ids.unwrap_or_else(|| CommitRecord {
393 before_commit: "unknown".to_string(),
394 after_commit: None,
395 dirty_tree_before: false,
396 dirty_tree_after: false,
397 changed_files: Vec::new(),
398 });
399
400 let validation_frontier =
401 self.validation_frontier
402 .unwrap_or_else(|| ValidationFrontierRecord {
403 main_commit: "unknown".to_string(),
404 compile_failures: Vec::new(),
405 test_failures: Vec::new(),
406 production_lib_green: false,
407 });
408
409 let replay_instructions = self
410 .replay_instructions
411 .unwrap_or_else(|| ReplayInstructions {
412 safety_level: ReplaySafetyLevel::ApprovalRequired,
413 safe_commands: Vec::new(),
414 remote_required_commands: Vec::new(),
415 approval_required_commands: self
416 .commands
417 .iter()
418 .map(|c| c.command.clone())
419 .collect(),
420 environment_variables: BTreeMap::new(),
421 required_file_state: Vec::new(),
422 manual_setup_instructions: vec![
423 "Review commands before replay".to_string(),
424 "Ensure clean working directory".to_string(),
425 ],
426 });
427
428 let bundle = AgentTaskProofBundle {
429 schema_version: AGENT_PROOF_BUNDLE_SCHEMA_VERSION,
430 objective,
431 agent_id,
432 bead_ids: self.bead_ids,
433 file_reservations: self.file_reservations,
434 touched_paths: self.touched_paths,
435 commands: apply_redaction_policy(self.commands),
436 rch_details: self.rch_details,
437 commit_ids,
438 validation_frontier,
439 mail_thread_ids: self.mail_thread_ids,
440 artifact_paths: self.artifact_paths,
441 first_blocker: self.first_blocker,
442 replay_instructions,
443 evidence_ledger: self.evidence_ledger,
444 metadata: self.metadata,
445 };
446
447 validate_bundle(&bundle)?;
448 Ok(bundle)
449 }
450}
451
452impl AgentTaskProofBundle {
453 pub fn emit_proof_artifacts(
455 &self,
456 output_dir: impl AsRef<Path>,
457 ) -> Result<(), AgentProofError> {
458 let output_dir = output_dir.as_ref();
459 std::fs::create_dir_all(output_dir)?;
460
461 let bundle_path = output_dir.join("agent_proof_bundle.json");
463 let bundle_json = serde_json::to_string_pretty(self)?;
464 std::fs::write(&bundle_path, bundle_json)?;
465
466 let evidence_path = output_dir.join("evidence_ledger.json");
468 std::fs::write(&evidence_path, self.evidence_ledger.export_json()?)?;
469
470 let replay_script = self.generate_replay_script()?;
472 let replay_path = output_dir.join("replay.sh");
473 std::fs::write(&replay_path, replay_script)?;
474
475 #[cfg(unix)]
477 {
478 use std::os::unix::fs::PermissionsExt;
479 let mut perms = std::fs::metadata(&replay_path)?.permissions();
480 perms.set_mode(0o755);
481 std::fs::set_permissions(&replay_path, perms)?;
482 }
483
484 let command_summary = self.generate_command_summary();
486 let commands_path = output_dir.join("commands.txt");
487 std::fs::write(&commands_path, command_summary)?;
488
489 if let Some(ref blocker) = self.first_blocker {
491 let blocker_report = self.generate_blocker_report(blocker);
492 let blocker_path = output_dir.join("blocker_report.txt");
493 std::fs::write(&blocker_path, blocker_report)?;
494 }
495
496 Ok(())
497 }
498
499 fn generate_replay_script(&self) -> Result<String, AgentProofError> {
500 let mut script = String::from("#!/bin/bash\n");
501 script.push_str("# Agent Task Proof Bundle Replay Script\n");
502 script.push_str(&format!("# Generated for agent: {}\n", self.agent_id));
503 script.push_str(&format!("# Objective: {}\n", self.objective));
504 script.push_str(&format!(
505 "# Safety level: {:?}\n",
506 self.replay_instructions.safety_level
507 ));
508 script.push('\n');
509
510 script.push_str("set -euo pipefail\n\n");
511
512 script.push_str("echo \"WARNING: This replay script contains agent task commands.\"\n");
514 script.push_str(&format!(
515 "echo \"Safety level: {:?}\"\n",
516 self.replay_instructions.safety_level
517 ));
518
519 match self.replay_instructions.safety_level {
520 ReplaySafetyLevel::Safe => {
521 script.push_str("echo \"All commands are safe to run.\"\n");
522 }
523 ReplaySafetyLevel::RemoteRequired => {
524 script.push_str("echo \"Some commands require remote worker.\"\n");
525 }
526 ReplaySafetyLevel::EnvironmentDependent => {
527 script.push_str("echo \"Commands depend on specific environment setup.\"\n");
528 }
529 ReplaySafetyLevel::ApprovalRequired => {
530 script.push_str("echo \"Commands require explicit approval before execution.\"\n");
531 script.push_str("read -p \"Continue? (y/N) \" -n 1 -r\n");
532 script.push_str("echo\n");
533 script.push_str("if [[ ! $REPLY =~ ^[Yy]$ ]]; then exit 1; fi\n");
534 }
535 }
536 script.push('\n');
537
538 if !self.replay_instructions.environment_variables.is_empty() {
540 script.push_str("# Environment variables\n");
541 for (key, value) in &self.replay_instructions.environment_variables {
542 script.push_str(&format!("export {}={}\n", key, shell_escape(value)));
543 }
544 script.push('\n');
545 }
546
547 if !self
549 .replay_instructions
550 .manual_setup_instructions
551 .is_empty()
552 {
553 script.push_str("# Manual setup required:\n");
554 for instruction in &self.replay_instructions.manual_setup_instructions {
555 script.push_str(&format!("# - {}\n", instruction));
556 }
557 script.push('\n');
558 }
559
560 if !self.replay_instructions.safe_commands.is_empty() {
562 script.push_str("# Safe commands\n");
563 for cmd in &self.replay_instructions.safe_commands {
564 script.push_str(&format!("echo \"Running: {}\"\n", cmd));
565 script.push_str(&format!("{}\n", cmd));
566 }
567 script.push('\n');
568 }
569
570 if !self.replay_instructions.remote_required_commands.is_empty() {
571 script.push_str("# Remote worker required commands\n");
572 for cmd in &self.replay_instructions.remote_required_commands {
573 script.push_str(&format!("echo \"Remote required: {}\"\n", cmd));
574 script.push_str(&format!("# {}\n", cmd));
575 }
576 script.push('\n');
577 }
578
579 if !self
580 .replay_instructions
581 .approval_required_commands
582 .is_empty()
583 {
584 script.push_str("# Approval required commands\n");
585 for cmd in &self.replay_instructions.approval_required_commands {
586 script.push_str(&format!("read -p \"Execute '{}'? (y/N) \" -n 1 -r\n", cmd));
587 script.push_str("echo\n");
588 script.push_str("if [[ $REPLY =~ ^[Yy]$ ]]; then\n");
589 script.push_str(&format!(" {}\n", cmd));
590 script.push_str("else\n");
591 script.push_str(&format!(" echo \"Skipped: {}\"\n", cmd));
592 script.push_str("fi\n");
593 }
594 }
595
596 script.push_str("\necho \"Replay complete.\"\n");
597 Ok(script)
598 }
599
600 fn generate_command_summary(&self) -> String {
601 let mut summary = "Agent Task Command Summary\n".to_string();
602 summary.push_str(&format!("Agent: {}\n", self.agent_id));
603 summary.push_str(&format!("Objective: {}\n", self.objective));
604 summary.push_str(&format!("Commands executed: {}\n\n", self.commands.len()));
605
606 for (i, cmd) in self.commands.iter().enumerate() {
607 summary.push_str(&format!("Command {}: {}\n", i + 1, cmd.command));
608 summary.push_str(&format!(" Exit status: {}\n", cmd.exit_status));
609 summary.push_str(&format!(" Duration: {}ms\n", cmd.duration_ms));
610 summary.push_str(&format!(" Working dir: {}\n", cmd.working_dir.display()));
611
612 if cmd.outputs_redacted {
613 summary.push_str(" Outputs: [REDACTED]\n");
614 } else {
615 if !cmd.stdout.is_empty() {
616 summary.push_str(&format!(
617 " Stdout: {}\n",
618 truncate_output(&cmd.stdout, 200)
619 ));
620 }
621 if !cmd.stderr.is_empty() {
622 summary.push_str(&format!(
623 " Stderr: {}\n",
624 truncate_output(&cmd.stderr, 200)
625 ));
626 }
627 }
628 summary.push('\n');
629 }
630
631 summary
632 }
633
634 fn generate_blocker_report(&self, blocker: &BlockerRecord) -> String {
635 let mut report = String::from("Agent Task Blocker Report\n");
636 report.push_str("========================\n\n");
637 report.push_str(&format!("Blocker Type: {}\n", blocker.blocker_type));
638 report.push_str(&format!("Description: {}\n", blocker.description));
639
640 if let Some(ref location) = blocker.source_location {
641 report.push_str(&format!("Source: {}\n", location));
642 }
643
644 report.push_str(&format!(
645 "Concurrent Change: {}\n",
646 blocker.concurrent_change
647 ));
648 report.push('\n');
649
650 if blocker.concurrent_change {
651 report.push_str(
652 "This blocker was likely caused by concurrent changes to the codebase.\n",
653 );
654 report.push_str("Consider retrying the task after syncing with the latest changes.\n");
655 }
656
657 report
658 }
659}
660
661fn apply_redaction_policy(commands: Vec<CommandRecord>) -> Vec<CommandRecord> {
663 commands
664 .into_iter()
665 .map(|mut cmd| {
666 let stdout_redacted = should_redact_output(&cmd.stdout);
667 let stderr_redacted = should_redact_output(&cmd.stderr);
668
669 if stdout_redacted || stderr_redacted {
670 cmd.outputs_redacted = true;
671
672 if stdout_redacted {
673 cmd.stdout =
674 "[REDACTED - output too long or contains sensitive content]".to_string();
675 }
676
677 if stderr_redacted {
678 cmd.stderr =
679 "[REDACTED - output too long or contains sensitive content]".to_string();
680 }
681 }
682
683 cmd.command = redact_sensitive_patterns(cmd.command);
685
686 cmd
687 })
688 .collect()
689}
690
691fn should_redact_output(output: &str) -> bool {
693 if output.len() > MAX_COMMAND_OUTPUT_BYTES {
695 return true;
696 }
697
698 let sensitive_patterns = [
700 "token=",
701 "password=",
702 "secret=",
703 "key=",
704 "auth=",
705 "bearer ",
706 "api_key",
707 ];
708
709 let output_lower = output.to_ascii_lowercase();
710 sensitive_patterns
711 .iter()
712 .any(|pattern| output_lower.contains(pattern))
713}
714
715fn redact_sensitive_patterns(command: String) -> String {
717 let mut result = command;
718
719 let sensitive_patterns = [
721 "--token=",
722 "--password=",
723 "--secret=",
724 "--api-key=",
725 "--auth=",
726 ];
727
728 for pattern in &sensitive_patterns {
729 if let Some(start) = result.find(pattern) {
730 let value_start = start + pattern.len();
731 if let Some(end) = result[value_start..].find(' ').map(|i| value_start + i) {
732 result.replace_range(value_start..end, "[REDACTED]");
733 } else {
734 result.replace_range(value_start.., "[REDACTED]");
736 }
737 }
738 }
739
740 if let Some(p_idx) = result.find(" -p ") {
742 let value_start = p_idx + 4;
743 if let Some(end) = result[value_start..].find(' ').map(|i| value_start + i) {
744 result.replace_range(value_start..end, "[REDACTED]");
745 } else {
746 result.replace_range(value_start.., "[REDACTED]");
747 }
748 }
749
750 result
751}
752
753fn validate_bundle(bundle: &AgentTaskProofBundle) -> Result<(), AgentProofError> {
755 if bundle.objective.is_empty() {
756 return Err(AgentProofError::InvalidBundle(
757 "Objective cannot be empty".to_string(),
758 ));
759 }
760
761 if bundle.agent_id.is_empty() {
762 return Err(AgentProofError::InvalidBundle(
763 "Agent ID cannot be empty".to_string(),
764 ));
765 }
766
767 for path in &bundle.touched_paths {
769 if path.as_os_str().len() > MAX_PATH_LENGTH {
770 return Err(AgentProofError::InvalidBundle(format!(
771 "Path too long: {}",
772 path.display()
773 )));
774 }
775 }
776
777 for path in &bundle.artifact_paths {
778 if path.as_os_str().len() > MAX_PATH_LENGTH {
779 return Err(AgentProofError::InvalidBundle(format!(
780 "Artifact path too long: {}",
781 path.display()
782 )));
783 }
784 }
785
786 Ok(())
787}
788
789fn shell_escape(s: &str) -> String {
791 if s.chars()
792 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
793 {
794 s.to_string()
795 } else {
796 format!("'{}'", s.replace('\'', "'\"'\"'"))
797 }
798}
799
800fn truncate_output(output: &str, max_chars: usize) -> String {
802 if output.len() <= max_chars {
803 output.replace('\n', "\\n")
804 } else {
805 format!(
806 "{}... [truncated]",
807 output[..max_chars].replace('\n', "\\n")
808 )
809 }
810}
811
812#[derive(Debug, Error)]
814pub enum AgentProofError {
815 #[error("Missing required field: {0}")]
816 MissingField(&'static str),
817 #[error("Invalid bundle: {0}")]
818 InvalidBundle(String),
819 #[error("IO error: {0}")]
820 Io(#[from] std::io::Error),
821 #[error("Serialization error: {0}")]
822 Serialization(#[from] serde_json::Error),
823 #[error("Crashpack error: {0}")]
824 Crashpack(#[from] CrashpackError),
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 #[test]
832 fn builder_requires_objective_and_agent_id() {
833 let result = AgentTaskProofBundleBuilder::new().build();
834 assert!(matches!(
835 result,
836 Err(AgentProofError::MissingField("objective"))
837 ));
838
839 let result = AgentTaskProofBundleBuilder::new()
840 .with_objective("test task")
841 .build();
842 assert!(matches!(
843 result,
844 Err(AgentProofError::MissingField("agent_id"))
845 ));
846
847 let bundle = AgentTaskProofBundleBuilder::new()
848 .with_objective("test task")
849 .with_agent_id("TestAgent")
850 .build()
851 .expect("should build with required fields");
852
853 assert_eq!(bundle.objective, "test task");
854 assert_eq!(bundle.agent_id, "TestAgent");
855 }
856
857 #[test]
858 fn commands_are_redacted_for_sensitive_content() {
859 let bundle = AgentTaskProofBundleBuilder::new()
860 .with_objective("test task")
861 .with_agent_id("TestAgent")
862 .with_command("curl --token=secret123 https://api.example.com", 0)
863 .build()
864 .expect("should build");
865
866 assert_eq!(bundle.commands.len(), 1);
867 assert!(bundle.commands[0].command.contains("[REDACTED]"));
868 assert!(!bundle.commands[0].command.contains("secret123"));
869 }
870
871 #[test]
872 fn replay_instructions_default_to_approval_required() {
873 let bundle = AgentTaskProofBundleBuilder::new()
874 .with_objective("test task")
875 .with_agent_id("TestAgent")
876 .with_command("cargo test", 0)
877 .build()
878 .expect("should build");
879
880 assert_eq!(
881 bundle.replay_instructions.safety_level,
882 ReplaySafetyLevel::ApprovalRequired
883 );
884 assert_eq!(
885 bundle.replay_instructions.approval_required_commands,
886 vec!["cargo test"]
887 );
888 }
889
890 #[test]
891 fn touched_paths_are_deduplicated() {
892 let bundle = AgentTaskProofBundleBuilder::new()
893 .with_objective("test task")
894 .with_agent_id("TestAgent")
895 .with_touched_path("src/main.rs")
896 .with_touched_path("src/main.rs")
897 .with_touched_path("src/lib.rs")
898 .build()
899 .expect("should build");
900
901 assert_eq!(bundle.touched_paths.len(), 2);
902 assert!(bundle.touched_paths.contains(&PathBuf::from("src/main.rs")));
903 assert!(bundle.touched_paths.contains(&PathBuf::from("src/lib.rs")));
904 }
905
906 #[test]
907 fn bundle_validation_rejects_empty_fields() {
908 let result = AgentTaskProofBundle {
909 schema_version: AGENT_PROOF_BUNDLE_SCHEMA_VERSION,
910 objective: String::new(),
911 agent_id: "TestAgent".to_string(),
912 bead_ids: Vec::new(),
913 file_reservations: Vec::new(),
914 touched_paths: Vec::new(),
915 commands: Vec::new(),
916 rch_details: None,
917 commit_ids: CommitRecord {
918 before_commit: "abc123".to_string(),
919 after_commit: None,
920 dirty_tree_before: false,
921 dirty_tree_after: false,
922 changed_files: Vec::new(),
923 },
924 validation_frontier: ValidationFrontierRecord {
925 main_commit: "def456".to_string(),
926 compile_failures: Vec::new(),
927 test_failures: Vec::new(),
928 production_lib_green: true,
929 },
930 mail_thread_ids: Vec::new(),
931 artifact_paths: Vec::new(),
932 first_blocker: None,
933 replay_instructions: ReplayInstructions {
934 safety_level: ReplaySafetyLevel::Safe,
935 safe_commands: Vec::new(),
936 remote_required_commands: Vec::new(),
937 approval_required_commands: Vec::new(),
938 environment_variables: BTreeMap::new(),
939 required_file_state: Vec::new(),
940 manual_setup_instructions: Vec::new(),
941 },
942 evidence_ledger: AtpEvidenceLedger::new(),
943 metadata: BTreeMap::new(),
944 };
945
946 assert!(matches!(
947 validate_bundle(&result),
948 Err(AgentProofError::InvalidBundle(_))
949 ));
950 }
951
952 #[test]
953 fn proof_bundle_can_emit_artifacts() {
954 use tempfile::TempDir;
955
956 let bundle = AgentTaskProofBundleBuilder::new()
957 .with_objective("test task")
958 .with_agent_id("TestAgent")
959 .with_command("echo hello", 0)
960 .build()
961 .expect("should build");
962
963 let temp_dir = TempDir::new().expect("should create temp dir");
964 bundle
965 .emit_proof_artifacts(temp_dir.path())
966 .expect("should emit artifacts");
967
968 assert!(temp_dir.path().join("agent_proof_bundle.json").exists());
970 assert!(temp_dir.path().join("evidence_ledger.json").exists());
971 assert!(temp_dir.path().join("replay.sh").exists());
972 assert!(temp_dir.path().join("commands.txt").exists());
973
974 #[cfg(unix)]
976 {
977 use std::os::unix::fs::PermissionsExt;
978 let perms = std::fs::metadata(temp_dir.path().join("replay.sh"))
979 .expect("should read permissions");
980 assert!(perms.permissions().mode() & 0o111 != 0);
981 }
982 }
983}