1use crate::command_exec::GateSandbox;
75use crate::types::{Assertion, AssertionCheck, PtyScript};
76use std::collections::HashMap;
77use std::path::Path;
78use std::sync::atomic::{AtomicBool, Ordering};
79use std::sync::{mpsc, Arc};
80use std::time::Duration;
81
82pub const DEFAULT_SESSION_TIMEOUT_SECS: u64 = 60;
84pub const DEFAULT_EXPECT_TIMEOUT_MS: u64 = 10_000;
86pub const MAX_TRANSCRIPT_BYTES: usize = 256 * 1024;
91const FAIL_TAIL_BYTES: usize = 2048;
94#[cfg_attr(not(unix), allow(dead_code))]
97const POLL_INTERVAL: Duration = Duration::from_millis(10);
98#[cfg(unix)]
100const MAX_DRAIN_BYTES: usize = 64 * 1024;
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum PtyVerdict {
105 Pass,
107 Fail,
110 Skipped,
114}
115
116#[derive(Debug, Clone)]
121pub struct PtyStepOutcome {
122 pub step: usize,
124 pub ok: bool,
125 pub detail: String,
128}
129
130#[derive(Debug)]
133pub struct PtyRunOutcome {
134 pub verdict: PtyVerdict,
135 pub steps: Vec<PtyStepOutcome>,
136 pub transcript: Vec<u8>,
138 pub truncated: bool,
140 pub note: Option<String>,
143}
144
145#[derive(Debug)]
150pub(crate) struct PtyAssertionArtifact {
151 pub assertion_id: String,
152 pub pass: bool,
153 pub transcript_rel: String,
155 pub detail: String,
158}
159
160#[derive(Debug)]
167pub(crate) struct PtySkippedAssertion {
168 pub assertion_id: String,
169 pub note: String,
171}
172
173#[derive(Debug)]
180pub(crate) struct PtyAssertionRun {
181 pub rendered: Option<String>,
182 pub artifacts: Vec<PtyAssertionArtifact>,
183 pub skipped: Vec<PtySkippedAssertion>,
184}
185
186struct CancelPtyOnDrop {
189 cancelled: Arc<AtomicBool>,
190 completed: mpsc::Receiver<()>,
191}
192
193impl Drop for CancelPtyOnDrop {
194 fn drop(&mut self) {
195 self.cancelled.store(true, Ordering::Release);
196 let _ = self.completed.recv();
200 }
201}
202
203pub(crate) async fn run_pty_assertions(
218 contract: &[Assertion],
219 root: &Path,
220 env: &HashMap<String, String>,
221 sandbox: &GateSandbox,
222 runs_dir: &Path,
223) -> PtyAssertionRun {
224 let pty_assertions: Vec<&Assertion> = contract
225 .iter()
226 .filter(|a| a.check == AssertionCheck::PtyScript)
227 .collect();
228 if pty_assertions.is_empty() {
229 return PtyAssertionRun {
230 rendered: None,
231 artifacts: Vec::new(),
232 skipped: Vec::new(),
233 };
234 }
235 let mut rendered = String::new();
236 let mut artifacts = Vec::new();
237 let mut skipped = Vec::new();
238 for assertion in pty_assertions {
239 let Some(script) = assertion.pty_script.clone() else {
240 rendered.push_str(&format!(
245 "- [{}] (check=pty-script but no pty script — cannot run)\n",
246 assertion.id
247 ));
248 continue;
249 };
250 let (wrapped, env) =
253 match crate::command_exec::prepare_gate_command(&script.command, env, sandbox) {
254 Ok(prepared) => prepared,
255 Err(error) => {
256 rendered.push_str(&format!(
257 "- [{}] pty-script `{}` → FAIL\n\
258 gate sandbox wrap failed closed (the pty session did not run): {error}\n",
259 assertion.id, script.command
260 ));
261 continue;
262 }
263 };
264 let root = root.to_path_buf();
265 let command = script.command.clone();
266 let cancelled = Arc::new(AtomicBool::new(false));
267 let (completed, completion) = mpsc::channel();
268 let cancel_on_drop = CancelPtyOnDrop {
269 cancelled: Arc::clone(&cancelled),
270 completed: completion,
271 };
272 let outcome = tokio::task::spawn_blocking(move || {
273 let _completed = completed;
274 imp::run_session(&script, &wrapped, &root, &env, cancelled)
275 })
276 .await
277 .unwrap_or_else(|join_error| PtyRunOutcome {
278 verdict: PtyVerdict::Fail,
281 steps: Vec::new(),
282 transcript: Vec::new(),
283 truncated: false,
284 note: Some(format!("pty driver task failed: {join_error}")),
285 });
286 drop(cancel_on_drop);
287 let (line, artifact, skip) = fold_outcome(assertion, &command, &outcome, runs_dir);
288 rendered.push_str(&line);
289 if let Some(artifact) = artifact {
290 artifacts.push(artifact);
291 }
292 if let Some(skip) = skip {
293 skipped.push(skip);
294 }
295 }
296 PtyAssertionRun {
297 rendered: Some(rendered),
298 artifacts,
299 skipped,
300 }
301}
302
303fn fold_outcome(
309 assertion: &Assertion,
310 command: &str,
311 outcome: &PtyRunOutcome,
312 runs_dir: &Path,
313) -> (
314 String,
315 Option<PtyAssertionArtifact>,
316 Option<PtySkippedAssertion>,
317) {
318 if outcome.verdict == PtyVerdict::Skipped {
319 let note = outcome.note.as_deref().unwrap_or("unsupported host");
326 return (
327 format!(
328 "- [{}] pty-script → FAIL (declared pty-script did not execute: SKIP — {note})\n",
329 assertion.id
330 ),
331 None,
332 Some(PtySkippedAssertion {
333 assertion_id: assertion.id.clone(),
334 note: note.to_string(),
335 }),
336 );
337 }
338 let transcript_rel = write_transcript(runs_dir, &assertion.id, outcome);
343 let pass = outcome.verdict == PtyVerdict::Pass;
344 let detail = step_summary(outcome);
345 let verdict = if pass { "PASS" } else { "FAIL" };
346 let reference = transcript_rel
347 .as_deref()
348 .map(crate::gate_results::file_artefact_ref)
349 .unwrap_or_else(|| "(transcript write failed)".to_string());
350 let mut line = format!(
351 "- [{}] pty-script `{}` → {verdict} ({detail}; transcript {reference})\n",
352 assertion.id, command
353 );
354 if !pass {
355 let tail = tail_text(&outcome.transcript, FAIL_TAIL_BYTES);
356 if !tail.is_empty() {
357 line.push_str(&format!("{}\n", crate::scrub::scrub(&tail)));
358 }
359 }
360 let artifact = transcript_rel.map(|rel| PtyAssertionArtifact {
361 assertion_id: assertion.id.clone(),
362 pass,
363 transcript_rel: rel,
364 detail,
365 });
366 (line, artifact, None)
367}
368
369fn step_summary(outcome: &PtyRunOutcome) -> String {
373 let mut parts: Vec<String> = outcome
374 .steps
375 .iter()
376 .map(|s| format!("step {} {}", s.step + 1, if s.ok { "ok" } else { "FAILED" }))
377 .collect();
378 if let Some(failed) = outcome.steps.iter().find(|s| !s.ok) {
379 parts.push(format!("({})", failed.detail));
380 }
381 if let Some(note) = &outcome.note {
382 parts.push(format!("({note})"));
383 }
384 if parts.is_empty() {
385 "no steps executed".to_string()
386 } else {
387 parts.join(" ")
388 }
389}
390
391fn write_transcript(
397 runs_dir: &Path,
398 assertion_id: &str,
399 outcome: &PtyRunOutcome,
400) -> Option<String> {
401 let dir = runs_dir.join("pty-transcripts");
402 std::fs::create_dir_all(&dir).ok()?;
403 let safe_id: String = assertion_id
407 .chars()
408 .map(|c| {
409 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
410 c
411 } else {
412 '-'
413 }
414 })
415 .collect();
416 let name = format!(
417 "{}-{}.log",
418 safe_id,
419 &uuid::Uuid::new_v4().simple().to_string()[..8]
420 );
421 let mut bytes = outcome.transcript.clone();
422 if outcome.truncated {
423 bytes.extend_from_slice(
424 format!("\n[kranz: transcript truncated at {MAX_TRANSCRIPT_BYTES} bytes]\n").as_bytes(),
425 );
426 }
427 std::fs::write(dir.join(&name), &bytes).ok()?;
428 Some(format!("runs/pty-transcripts/{name}"))
429}
430
431fn tail_text(transcript: &[u8], max: usize) -> String {
434 let start = transcript.len().saturating_sub(max);
435 String::from_utf8_lossy(&transcript[start..]).into_owned()
436}
437
438#[cfg(unix)]
447mod imp {
448 use super::*;
449 use crate::command_exec::WrappedCommand;
450 use crate::types::PtyStep;
451 use std::io::{Read, Write};
452 use std::os::unix::io::FromRawFd;
453 use std::os::unix::process::CommandExt;
454 use std::time::Instant;
455
456 pub fn run_session(
457 script: &PtyScript,
458 wrapped: &WrappedCommand,
459 cwd: &Path,
460 env: &HashMap<String, String>,
461 cancelled: Arc<AtomicBool>,
462 ) -> PtyRunOutcome {
463 if cancelled.load(Ordering::Acquire) {
464 return spawn_failure("pty validation cancelled before spawn".to_string());
465 }
466 let master = unsafe {
469 libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC | libc::O_NONBLOCK)
470 };
471 if master == -1 {
472 return spawn_failure(format!(
473 "posix_openpt failed: {}",
474 std::io::Error::last_os_error()
475 ));
476 }
477 if unsafe { libc::grantpt(master) } == -1 || unsafe { libc::unlockpt(master) } == -1 {
478 let err = std::io::Error::last_os_error();
479 unsafe { libc::close(master) };
480 return spawn_failure(format!("preparing pty slave failed: {err}"));
481 }
482 let mut name = [0 as libc::c_char; 128];
483 #[cfg(target_os = "macos")]
486 const TIOCPTYGNAME: libc::c_ulong = 0x4080_7453;
487 #[cfg(target_os = "macos")]
488 let name_result = unsafe { libc::ioctl(master, TIOCPTYGNAME, name.as_mut_ptr()) };
489 #[cfg(not(target_os = "macos"))]
490 let name_result = unsafe { libc::ptsname_r(master, name.as_mut_ptr(), name.len()) };
491 if name_result != 0 || !name.contains(&0) {
492 let err = if name_result > 0 {
493 std::io::Error::from_raw_os_error(name_result)
494 } else {
495 std::io::Error::last_os_error()
496 };
497 unsafe { libc::close(master) };
498 return spawn_failure(format!("resolving pty slave failed: {err}"));
499 }
500 let slave = unsafe {
501 libc::open(
502 name.as_ptr(),
503 libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC,
504 )
505 };
506 if slave == -1 {
507 let err = std::io::Error::last_os_error();
508 unsafe { libc::close(master) };
509 return spawn_failure(format!("opening pty slave failed: {err}"));
510 }
511 let winsize = libc::winsize {
513 ws_row: 24,
514 ws_col: 80,
515 ws_xpixel: 0,
516 ws_ypixel: 0,
517 };
518 #[allow(clippy::unnecessary_cast)]
519 let size_result =
520 unsafe { libc::ioctl(slave, libc::TIOCSWINSZ as libc::c_ulong, &winsize) };
521 if size_result == -1 {
522 let err = std::io::Error::last_os_error();
523 unsafe {
524 libc::close(master);
525 libc::close(slave);
526 }
527 return spawn_failure(format!("setting pty size failed: {err}"));
528 }
529
530 let (dup1, dup2) = unsafe {
534 (
535 libc::fcntl(slave, libc::F_DUPFD_CLOEXEC, 0),
536 libc::fcntl(slave, libc::F_DUPFD_CLOEXEC, 0),
537 )
538 };
539 if dup1 == -1 || dup2 == -1 {
540 let err = std::io::Error::last_os_error();
541 unsafe {
542 libc::close(master);
543 libc::close(slave);
544 if dup1 != -1 {
545 libc::close(dup1);
546 }
547 if dup2 != -1 {
548 libc::close(dup2);
549 }
550 }
551 return spawn_failure(format!("dup of pty slave failed: {err}"));
552 }
553
554 let mut cmd = std::process::Command::new(&wrapped.program);
555 cmd.args(&wrapped.args)
556 .current_dir(cwd)
557 .env_clear()
558 .envs(env)
559 .stdin(unsafe { std::process::Stdio::from_raw_fd(slave) })
562 .stdout(unsafe { std::process::Stdio::from_raw_fd(dup1) })
563 .stderr(unsafe { std::process::Stdio::from_raw_fd(dup2) });
564 unsafe {
573 cmd.pre_exec(move || {
574 if libc::setsid() == -1 {
575 return Err(std::io::Error::last_os_error());
576 }
577 #[allow(clippy::unnecessary_cast)]
583 let request = libc::TIOCSCTTY as libc::c_ulong;
584 if libc::ioctl(slave, request, 0) == -1 {
585 return Err(std::io::Error::last_os_error());
586 }
587 Ok(())
588 });
589 }
590
591 let mut child = match cmd.spawn() {
592 Ok(child) => child,
593 Err(error) => {
594 unsafe { libc::close(master) };
595 return spawn_failure(format!("target failed to spawn: {error}"));
596 }
597 };
598 let pid = child.id() as i32;
599 drop(cmd);
602
603 let mut master = unsafe { std::fs::File::from_raw_fd(master) };
606
607 let mut session = Session {
608 transcript: Vec::new(),
609 truncated: false,
610 child_eof: false,
611 cancelled,
612 };
613 let session_deadline = Instant::now()
614 + Duration::from_secs(script.timeout_secs.unwrap_or(DEFAULT_SESSION_TIMEOUT_SECS));
615 let mut steps = Vec::new();
616 let mut failed = false;
617 for (index, step) in script.steps.iter().enumerate() {
618 let outcome = match step {
619 PtyStep::Send { text } => {
620 drive_send(&mut master, &mut session, text, session_deadline, index)
621 }
622 PtyStep::Expect {
623 pattern,
624 regex,
625 timeout_ms,
626 } => drive_expect(
627 &mut master,
628 &mut session,
629 &mut child,
630 pattern,
631 *regex,
632 Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_EXPECT_TIMEOUT_MS)),
633 session_deadline,
634 index,
635 ),
636 };
637 let ok = outcome.ok;
638 steps.push(outcome);
639 if !ok {
640 failed = true;
641 break;
642 }
643 }
644
645 let exited = child.try_wait().ok().flatten();
651 let note = match exited {
652 Some(status) => Some(format!("target exited ({status})")),
653 None => {
654 unsafe {
658 libc::kill(-pid, libc::SIGKILL);
659 }
660 let _ = child.kill();
661 let reap_deadline = Instant::now() + Duration::from_secs(10);
671 let reaped = loop {
672 drain(&mut master, &mut session);
673 if child.try_wait().ok().flatten().is_some() {
674 break true;
675 }
676 if Instant::now() >= reap_deadline {
679 break false;
680 }
681 std::thread::sleep(POLL_INTERVAL);
682 };
683 if reaped || child.try_wait().ok().flatten().is_some() {
684 let _ = child.wait();
685 } else {
686 tracing::warn!(
687 "pty target did not reap within 10s of SIGKILL despite a drained \
688 pty; dropping the handle (the killed target may remain \
689 unreaped until the engine exits)"
690 );
691 }
692 if let Some((program, args)) = &wrapped.timeout_teardown {
693 let _ = crate::command_exec::run_with_timeout(
697 program,
698 args,
699 Duration::from_secs(30),
700 );
701 }
702 Some("target terminated by harness (script complete)".to_string())
703 }
704 };
705
706 PtyRunOutcome {
707 verdict: if failed {
708 PtyVerdict::Fail
709 } else {
710 PtyVerdict::Pass
711 },
712 steps,
713 transcript: session.transcript,
714 truncated: session.truncated,
715 note,
716 }
717 }
718
719 fn spawn_failure(reason: String) -> PtyRunOutcome {
720 PtyRunOutcome {
721 verdict: PtyVerdict::Fail,
722 steps: Vec::new(),
723 transcript: Vec::new(),
724 truncated: false,
725 note: Some(reason),
726 }
727 }
728
729 struct Session {
731 transcript: Vec<u8>,
732 truncated: bool,
733 child_eof: bool,
736 cancelled: Arc<AtomicBool>,
737 }
738
739 fn drain(master: &mut std::fs::File, session: &mut Session) -> usize {
743 let mut fresh = 0usize;
744 let mut buf = [0u8; 8192];
745 while fresh < MAX_DRAIN_BYTES {
746 match master.read(&mut buf) {
747 Ok(0) => {
748 session.child_eof = true;
749 break;
750 }
751 Ok(n) => {
752 let remaining = MAX_TRANSCRIPT_BYTES.saturating_sub(session.transcript.len());
753 if n > remaining {
754 session.transcript.extend_from_slice(&buf[..remaining]);
755 session.truncated = true;
756 } else {
757 session.transcript.extend_from_slice(&buf[..n]);
758 }
759 fresh += n;
760 }
761 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
762 Err(e) if e.raw_os_error() == Some(libc::EIO) => {
763 session.child_eof = true;
766 break;
767 }
768 Err(_) => break,
769 }
770 }
771 fresh
772 }
773
774 #[test]
775 fn pty_drain_yields_before_exhausting_continuously_ready_output() {
776 use std::io::{Seek, SeekFrom};
777 let mut input = tempfile::tempfile().unwrap();
778 let payload = vec![b'x'; MAX_TRANSCRIPT_BYTES * 2];
779 input.write_all(&payload).unwrap();
780 input.seek(SeekFrom::Start(0)).unwrap();
781 let mut session = Session {
782 transcript: Vec::new(),
783 truncated: false,
784 child_eof: false,
785 cancelled: Arc::new(AtomicBool::new(false)),
786 };
787 let first = drain(&mut input, &mut session);
788 assert!(
789 first > 0 && first < payload.len(),
790 "ready output must yield before EOF so deadlines can be checked"
791 );
792 assert!(!session.child_eof);
793 let mut total = first;
794 while !session.child_eof {
795 total += drain(&mut input, &mut session);
796 }
797 assert_eq!(total, payload.len(), "yielding must not lose input");
798 assert_eq!(session.transcript, payload[..MAX_TRANSCRIPT_BYTES]);
799 assert!(session.truncated);
800 }
801
802 fn drive_send(
803 master: &mut std::fs::File,
804 session: &mut Session,
805 text: &str,
806 session_deadline: Instant,
807 index: usize,
808 ) -> PtyStepOutcome {
809 let mut written = 0usize;
810 let bytes = text.as_bytes();
811 while written < bytes.len() {
812 if session.cancelled.load(Ordering::Acquire) {
813 return PtyStepOutcome {
814 step: index,
815 ok: false,
816 detail: "pty validation cancelled".to_string(),
817 };
818 }
819 if session.child_eof {
820 return PtyStepOutcome {
821 step: index,
822 ok: false,
823 detail: format!("send step {} failed: target closed the pty", index + 1),
824 };
825 }
826 if Instant::now() >= session_deadline {
827 return PtyStepOutcome {
828 step: index,
829 ok: false,
830 detail: format!("send step {} failed: session timeout", index + 1),
831 };
832 }
833 match master.write(&bytes[written..]) {
834 Ok(n) => written += n,
835 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
836 drain(master, session);
840 std::thread::sleep(POLL_INTERVAL);
841 }
842 Err(e) => {
843 return PtyStepOutcome {
844 step: index,
845 ok: false,
846 detail: format!("send step {} failed: {e}", index + 1),
847 };
848 }
849 }
850 }
851 PtyStepOutcome {
852 step: index,
853 ok: true,
854 detail: format!("send step {} wrote {} bytes", index + 1, bytes.len()),
855 }
856 }
857
858 #[allow(clippy::too_many_arguments)]
859 fn drive_expect(
860 master: &mut std::fs::File,
861 session: &mut Session,
862 child: &mut std::process::Child,
863 pattern: &str,
864 regex: bool,
865 step_timeout: Duration,
866 session_deadline: Instant,
867 index: usize,
868 ) -> PtyStepOutcome {
869 let deadline = (Instant::now() + step_timeout).min(session_deadline);
870 let compiled = if regex {
874 match regex::Regex::new(pattern) {
875 Ok(re) => Some(re),
876 Err(error) => {
877 return PtyStepOutcome {
878 step: index,
879 ok: false,
880 detail: format!(
881 "expect step {} has an invalid regex `{pattern}`: {error}",
882 index + 1
883 ),
884 };
885 }
886 }
887 } else {
888 None
889 };
890 let matched = |transcript: &[u8]| {
891 let text = String::from_utf8_lossy(transcript);
892 match &compiled {
893 Some(re) => re.is_match(&text),
894 None => text.contains(pattern),
895 }
896 };
897 let started = Instant::now();
898 loop {
899 if session.cancelled.load(Ordering::Acquire) {
900 return PtyStepOutcome {
901 step: index,
902 ok: false,
903 detail: "pty validation cancelled".to_string(),
904 };
905 }
906 let fresh = drain(master, session);
907 if matched(&session.transcript) {
908 return PtyStepOutcome {
909 step: index,
910 ok: true,
911 detail: format!(
912 "expect `{pattern}` matched ({}ms)",
913 started.elapsed().as_millis()
914 ),
915 };
916 }
917 if session.child_eof {
918 let status = child.try_wait().ok().flatten();
919 return PtyStepOutcome {
920 step: index,
921 ok: false,
922 detail: format!(
923 "expect `{pattern}` unmatched: target exited ({})",
924 status
925 .map(|s| s.to_string())
926 .unwrap_or_else(|| "status unknown".to_string())
927 ),
928 };
929 }
930 if Instant::now() >= deadline {
931 return PtyStepOutcome {
932 step: index,
933 ok: false,
934 detail: format!(
935 "expect `{pattern}` timed out after {}ms",
936 step_timeout.as_millis()
937 ),
938 };
939 }
940 if fresh == 0 {
944 std::thread::sleep(POLL_INTERVAL);
945 }
946 }
947 }
948}
949
950#[cfg(not(unix))]
955mod imp {
956 use super::*;
957 use crate::command_exec::WrappedCommand;
958
959 pub fn run_session(
960 _script: &PtyScript,
961 _wrapped: &WrappedCommand,
962 _cwd: &Path,
963 _env: &HashMap<String, String>,
964 _cancelled: Arc<AtomicBool>,
965 ) -> PtyRunOutcome {
966 PtyRunOutcome {
967 verdict: PtyVerdict::Skipped,
968 steps: Vec::new(),
969 transcript: Vec::new(),
970 truncated: false,
971 note: Some(
972 "pty validation is implemented for unix hosts only (libc openpty); \
973 this platform cannot drive terminal-interactive targets"
974 .to_string(),
975 ),
976 }
977 }
978}
979
980#[cfg(test)]
981mod tests {
982 use super::*;
983 use crate::types::PtyScript;
984 use crate::types::PtyStep;
985
986 #[cfg(unix)]
991 const REPL_OK: &str = "printf '> '; while IFS= read -r line; do case \"$line\" in quit) \
992 printf 'bye\\n'; exit 0;; *) printf 'echo:%s\\n> ' \"$line\";; esac; done";
993 #[cfg(unix)]
995 const REPL_DEFECT: &str = "printf '> '; while IFS= read -r line; do case \"$line\" in quit) \
996 printf 'bye\\n'; exit 0;; *) printf 'echo:WRONG:%s\\n> ' \"$line\";; esac; done";
997
998 #[cfg(unix)]
999 #[tokio::test]
1000 async fn pty_validation_cancellation_waits_for_target_cleanup() {
1001 use std::time::Instant;
1002
1003 for send in [false, true] {
1004 let dir = tempfile::tempdir().unwrap();
1005 let command = "stty raw -echo || exit 1; trap '' HUP; sleep 30 & child=$!; \
1006 printf '%s %s' \"$$\" \"$child\" > pids; printf 'ready\\n'; wait";
1007 let step = if send {
1008 PtyStep::Send {
1011 text: "x".repeat(1024 * 1024),
1012 }
1013 } else {
1014 PtyStep::Expect {
1015 pattern: "never printed".into(),
1016 regex: false,
1017 timeout_ms: Some(30_000),
1018 }
1019 };
1020 let contract = vec![pty_assertion(
1021 "a-cancel",
1022 command,
1023 vec![
1024 PtyStep::Expect {
1025 pattern: "ready".into(),
1026 regex: false,
1027 timeout_ms: Some(5_000),
1028 },
1029 step,
1030 ],
1031 )];
1032 let env = HashMap::new();
1033 let runs = dir.path().join("runs");
1034 let mut run = Box::pin(run_pty_assertions(
1035 &contract,
1036 dir.path(),
1037 &env,
1038 &GateSandbox::Disabled,
1039 &runs,
1040 ));
1041 let pids = tokio::select! {
1042 result = &mut run => panic!("PTY finished before cancellation: {result:?}"),
1043 pids = async {
1044 for _ in 0..1000 {
1045 if let Ok(text) = std::fs::read_to_string(dir.path().join("pids")) {
1046 let pids: Vec<i32> = text
1047 .split_whitespace()
1048 .filter_map(|pid| pid.parse().ok())
1049 .collect();
1050 if pids.len() == 2 {
1051 tokio::time::sleep(Duration::from_millis(50)).await;
1052 return pids;
1053 }
1054 }
1055 tokio::time::sleep(Duration::from_millis(10)).await;
1056 }
1057 panic!("PTY target did not start");
1058 } => pids,
1059 };
1060 let start = Instant::now();
1061 drop(run);
1062 assert!(
1063 start.elapsed() < Duration::from_secs(5),
1064 "cancellation waited for the script deadline"
1065 );
1066 assert!(
1067 unsafe { libc::kill(pids[0], 0) } != 0,
1068 "the PTY leader was not reaped before drop returned"
1069 );
1070 while unsafe { libc::kill(pids[1], 0) } == 0 {
1073 #[cfg(target_os = "linux")]
1074 if std::fs::read_to_string(format!("/proc/{}/stat", pids[1])).is_ok_and(|stat| {
1075 stat.rsplit_once(") ")
1076 .is_some_and(|(_, fields)| fields.starts_with("Z "))
1077 }) {
1078 break;
1079 }
1080 assert!(
1081 start.elapsed() < Duration::from_secs(5),
1082 "PTY descendant survived cancellation"
1083 );
1084 tokio::time::sleep(Duration::from_millis(10)).await;
1085 }
1086 assert!(
1087 !runs.join("pty-transcripts").exists(),
1088 "a cancelled assertion recorded a completed verdict"
1089 );
1090 }
1091 }
1092
1093 #[cfg(unix)]
1094 fn pty_assertion(id: &str, command: &str, steps: Vec<PtyStep>) -> Assertion {
1095 Assertion {
1096 id: id.to_string(),
1097 statement: "the REPL echoes input back".to_string(),
1098 check: AssertionCheck::PtyScript,
1099 command: None,
1100 negative_control: None,
1101 pty_script: Some(PtyScript {
1102 command: command.to_string(),
1103 steps,
1104 timeout_secs: Some(20),
1105 }),
1106 }
1107 }
1108
1109 #[cfg(unix)]
1110 fn repl_steps() -> Vec<PtyStep> {
1111 vec![
1112 PtyStep::Expect {
1113 pattern: "> ".to_string(),
1114 regex: false,
1115 timeout_ms: Some(10_000),
1116 },
1117 PtyStep::Send {
1118 text: "hello\n".to_string(),
1119 },
1120 PtyStep::Expect {
1121 pattern: "echo:hello".to_string(),
1122 regex: false,
1123 timeout_ms: Some(10_000),
1124 },
1125 PtyStep::Send {
1126 text: "quit\n".to_string(),
1127 },
1128 PtyStep::Expect {
1129 pattern: "bye".to_string(),
1130 regex: false,
1131 timeout_ms: Some(10_000),
1132 },
1133 ]
1134 }
1135
1136 #[cfg(unix)]
1140 #[tokio::test]
1141 async fn pty_validation_correct_target_passes_and_names_assertion() {
1142 let dir = tempfile::tempdir().unwrap();
1143 let contract = vec![pty_assertion("a-pty", REPL_OK, repl_steps())];
1144 let run = run_pty_assertions(
1145 &contract,
1146 dir.path(),
1147 &HashMap::new(),
1148 &GateSandbox::Disabled,
1149 &dir.path().join("runs"),
1150 )
1151 .await;
1152 assert_eq!(run.artifacts.len(), 1, "one transcript artifact: {run:?}");
1153 assert!(run.artifacts[0].pass, "correct REPL passes: {run:?}");
1154 assert_eq!(run.artifacts[0].assertion_id, "a-pty");
1155 let rendered = run.rendered.expect("pty assertions render evidence");
1156 assert!(rendered.contains("[a-pty]"), "assertion named: {rendered}");
1157 assert!(rendered.contains("→ PASS"), "verdict rendered: {rendered}");
1158 let transcript = std::fs::read(dir.path().join(&run.artifacts[0].transcript_rel)).unwrap();
1159 let text = String::from_utf8_lossy(&transcript);
1160 assert!(text.contains("echo:hello"), "transcript captured: {text}");
1161 assert!(text.contains("bye"), "full session captured: {text}");
1162 }
1163
1164 #[cfg(unix)]
1168 #[tokio::test]
1169 async fn pty_validation_seeded_defect_fails_and_names_assertion() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let mut steps = repl_steps();
1172 if let PtyStep::Expect { timeout_ms, .. } = &mut steps[2] {
1174 *timeout_ms = Some(1_000);
1175 }
1176 let contract = vec![pty_assertion("a-pty", REPL_DEFECT, steps)];
1177 let run = run_pty_assertions(
1178 &contract,
1179 dir.path(),
1180 &HashMap::new(),
1181 &GateSandbox::Disabled,
1182 &dir.path().join("runs"),
1183 )
1184 .await;
1185 assert_eq!(run.artifacts.len(), 1, "failing session still artifacts");
1186 assert!(!run.artifacts[0].pass, "defect must fail");
1187 let rendered = run.rendered.unwrap();
1188 assert!(rendered.contains("[a-pty]"), "assertion named: {rendered}");
1189 assert!(rendered.contains("→ FAIL"), "verdict rendered: {rendered}");
1190 assert!(
1191 rendered.contains("echo:hello"),
1192 "unmatched pattern named: {rendered}"
1193 );
1194 assert!(
1195 run.artifacts[0].detail.contains("FAILED"),
1196 "failed step named in the event detail: {}",
1197 run.artifacts[0].detail
1198 );
1199 }
1200
1201 #[cfg(unix)]
1206 #[tokio::test]
1207 async fn pty_validation_transcript_is_event_resolvable_artifact() {
1208 let dir = tempfile::tempdir().unwrap();
1209 let mission_dir = dir.path();
1210 let runs_dir = mission_dir.join("runs");
1211 let contract = vec![pty_assertion("a-pty", REPL_OK, repl_steps())];
1212 let run = run_pty_assertions(
1213 &contract,
1214 mission_dir,
1215 &HashMap::new(),
1216 &GateSandbox::Disabled,
1217 &runs_dir,
1218 )
1219 .await;
1220 let artifact = &run.artifacts[0];
1221 assert!(
1222 artifact.transcript_rel.starts_with("runs/pty-transcripts/"),
1223 "mission-relative runs/ path: {}",
1224 artifact.transcript_rel
1225 );
1226 let reference = crate::gate_results::file_artefact_ref(&artifact.transcript_rel);
1227 assert!(
1228 reference.starts_with("file:runs/"),
1229 "file: scheme: {reference}"
1230 );
1231 match crate::gate_results::resolve_artefact(mission_dir, &reference) {
1232 crate::gate_results::ArtefactResolution::Resolved { .. } => {}
1233 other => panic!("transcript must resolve against the mission dir: {other:?}"),
1234 }
1235 }
1236
1237 #[tokio::test]
1241 async fn pty_validation_contract_without_harness_skips() {
1242 let dir = tempfile::tempdir().unwrap();
1243 let contract = vec![
1244 Assertion {
1245 id: "a-1".to_string(),
1246 statement: "s".to_string(),
1247 check: AssertionCheck::Command,
1248 command: Some("true".to_string()),
1249 negative_control: None,
1250 pty_script: None,
1251 },
1252 Assertion {
1253 id: "a-2".to_string(),
1254 statement: "s".to_string(),
1255 check: AssertionCheck::AgentJudgement,
1256 command: None,
1257 negative_control: None,
1258 pty_script: None,
1259 },
1260 ];
1261 let run = run_pty_assertions(
1262 &contract,
1263 dir.path(),
1264 &HashMap::new(),
1265 &GateSandbox::Disabled,
1266 dir.path(),
1267 )
1268 .await;
1269 assert!(run.rendered.is_none(), "no pty assertions → no evidence");
1270 assert!(run.artifacts.is_empty(), "no pty assertions → no artifacts");
1271 assert!(run.skipped.is_empty(), "no pty assertions → no skips");
1272
1273 let malformed = vec![Assertion {
1276 id: "a-3".to_string(),
1277 statement: "s".to_string(),
1278 check: AssertionCheck::PtyScript,
1279 command: None,
1280 negative_control: None,
1281 pty_script: None,
1282 }];
1283 let run = run_pty_assertions(
1284 &malformed,
1285 dir.path(),
1286 &HashMap::new(),
1287 &GateSandbox::Disabled,
1288 dir.path(),
1289 )
1290 .await;
1291 let rendered = run.rendered.unwrap();
1292 assert!(
1293 rendered.contains("[a-3] (check=pty-script but no pty script — cannot run)"),
1294 "{rendered}"
1295 );
1296 assert!(run.artifacts.is_empty());
1297 assert!(run.skipped.is_empty());
1301 }
1302
1303 #[test]
1313 fn pty_validation_declared_pty_skip_fails_and_names_reason() {
1314 let dir = tempfile::tempdir().unwrap();
1315 let assertion = Assertion {
1316 id: "a-pty".to_string(),
1317 statement: "the REPL echoes input back".to_string(),
1318 check: AssertionCheck::PtyScript,
1319 command: None,
1320 negative_control: None,
1321 pty_script: Some(PtyScript {
1322 command: "./repl".to_string(),
1323 steps: Vec::new(),
1324 timeout_secs: None,
1325 }),
1326 };
1327 let outcome = PtyRunOutcome {
1328 verdict: PtyVerdict::Skipped,
1329 steps: Vec::new(),
1330 transcript: Vec::new(),
1331 truncated: false,
1332 note: Some(
1333 "pty validation is implemented for unix hosts only (libc openpty)".to_string(),
1334 ),
1335 };
1336 let (line, artifact, skip) = fold_outcome(&assertion, "./repl", &outcome, dir.path());
1337 assert!(line.contains("[a-pty]"), "assertion named: {line}");
1338 assert!(
1339 line.contains("→ FAIL"),
1340 "a declared skip is FAIL evidence, not a soft skip: {line}"
1341 );
1342 assert!(
1343 line.contains("did not execute"),
1344 "the skip is named as a non-execution: {line}"
1345 );
1346 assert!(
1347 line.contains("unix hosts only"),
1348 "the skip reason is named: {line}"
1349 );
1350 assert!(
1351 !line.contains("→ SKIP"),
1352 "no soft skip line for a declared assertion: {line}"
1353 );
1354 assert!(
1355 artifact.is_none(),
1356 "no session ran — no transcript artifact may exist"
1357 );
1358 let skip = skip.expect("the skip is recorded for the round decision");
1359 assert_eq!(skip.assertion_id, "a-pty");
1360 assert!(skip.note.contains("unix hosts only"), "{}", skip.note);
1361 }
1362
1363 #[cfg(unix)]
1364 #[tokio::test]
1365 async fn pty_validation_child_inherits_only_standard_terminal_streams() {
1366 let dir = tempfile::tempdir().unwrap();
1367 let contract = vec![pty_assertion(
1368 "a-pty",
1369 "for fd in /dev/fd/*; do n=${fd##*/}; \
1370 if [ -t \"$n\" ]; then printf 'tty-fd:%s\\n' \"$n\"; fi; done; \
1371 printf 'probe-complete\\n'",
1372 vec![PtyStep::Expect {
1373 pattern: "probe-complete".to_string(),
1374 regex: false,
1375 timeout_ms: None,
1376 }],
1377 )];
1378 let run = run_pty_assertions(
1379 &contract,
1380 dir.path(),
1381 &HashMap::new(),
1382 &GateSandbox::Disabled,
1383 &dir.path().join("runs"),
1384 )
1385 .await;
1386 assert!(run.artifacts[0].pass, "{}", run.artifacts[0].detail);
1387 let transcript =
1388 std::fs::read_to_string(dir.path().join(&run.artifacts[0].transcript_rel)).unwrap();
1389 let terminals: Vec<_> = transcript
1390 .lines()
1391 .filter_map(|line| line.trim().strip_prefix("tty-fd:"))
1392 .collect();
1393 assert_eq!(terminals, ["0", "1", "2"], "{transcript}");
1394 }
1395
1396 #[cfg(unix)]
1400 #[tokio::test]
1401 async fn pty_validation_transcript_is_bounded() {
1402 let dir = tempfile::tempdir().unwrap();
1403 std::fs::write(
1404 dir.path().join("oversized.txt"),
1405 vec![b'x'; MAX_TRANSCRIPT_BYTES + 4096],
1406 )
1407 .unwrap();
1408 let contract = vec![pty_assertion(
1409 "a-pty",
1410 "/bin/cat oversized.txt",
1413 vec![PtyStep::Expect {
1414 pattern: "this-pattern-never-appears".to_string(),
1415 regex: false,
1416 timeout_ms: None,
1417 }],
1418 )];
1419 let run = run_pty_assertions(
1420 &contract,
1421 dir.path(),
1422 &HashMap::new(),
1423 &GateSandbox::Disabled,
1424 &dir.path().join("runs"),
1425 )
1426 .await;
1427 assert!(!run.artifacts[0].pass, "never-matching expect fails");
1428 assert!(
1429 run.artifacts[0].detail.contains("unmatched: target exited"),
1430 "fixture must finish its output: {}",
1431 run.artifacts[0].detail
1432 );
1433 let transcript = std::fs::read(dir.path().join(&run.artifacts[0].transcript_rel)).unwrap();
1434 assert!(
1436 transcript.len() <= MAX_TRANSCRIPT_BYTES + 128,
1437 "bounded on disk: {} bytes",
1438 transcript.len()
1439 );
1440 assert_eq!(
1441 &transcript[..MAX_TRANSCRIPT_BYTES],
1442 vec![b'x'; MAX_TRANSCRIPT_BYTES]
1443 );
1444 let text = String::from_utf8_lossy(&transcript);
1445 assert!(text.contains("transcript truncated"), "truncation recorded");
1446 }
1447
1448 #[test]
1452 fn pty_validation_contract_serde_is_additive() {
1453 let old: Assertion = serde_json::from_str(
1454 r#"{"id":"a-1","statement":"s","check":"command","command":"true"}"#,
1455 )
1456 .unwrap();
1457 assert!(old.pty_script.is_none(), "absent key decodes to None");
1458 let old: Assertion =
1459 serde_json::from_str(r#"{"id":"a-1","statement":"s","check":"agent-judgement"}"#)
1460 .unwrap();
1461 assert!(old.pty_script.is_none());
1462
1463 let new: Assertion = serde_json::from_str(
1464 r#"{"id":"a-2","statement":"s","check":"pty-script",
1465 "ptyScript":{"command":"./repl","steps":[
1466 {"op":"expect","pattern":"> "},
1467 {"op":"send","text":"help\n"},
1468 {"op":"expect","pattern":"usage","regex":true,"timeoutMs":500}
1469 ]}}"#,
1470 )
1471 .unwrap();
1472 assert_eq!(new.check, AssertionCheck::PtyScript);
1473 let json = serde_json::to_value(&new).unwrap();
1475 assert_eq!(json["check"], "pty-script");
1476 assert!(json["ptyScript"].get("timeoutSecs").is_none());
1477 assert!(json["ptyScript"]["steps"][0].get("timeoutMs").is_none());
1478 assert_eq!(json["ptyScript"]["steps"][1]["op"], "send");
1479 let script = new.pty_script.unwrap();
1480 assert_eq!(script.command, "./repl");
1481 assert_eq!(script.timeout_secs, None, "session timeout defaults");
1482 assert_eq!(script.steps.len(), 3);
1483 match &script.steps[0] {
1484 PtyStep::Expect {
1485 pattern,
1486 regex,
1487 timeout_ms,
1488 } => {
1489 assert_eq!(pattern, "> ");
1490 assert!(!regex, "regex defaults to literal substring");
1491 assert_eq!(*timeout_ms, None, "step timeout defaults");
1492 }
1493 other => panic!("wrong step: {other:?}"),
1494 }
1495 }
1496}