use crate::command_exec::GateSandbox;
use crate::types::{Assertion, AssertionCheck, PtyScript};
use std::collections::HashMap;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use std::time::Duration;
pub const DEFAULT_SESSION_TIMEOUT_SECS: u64 = 60;
pub const DEFAULT_EXPECT_TIMEOUT_MS: u64 = 10_000;
pub const MAX_TRANSCRIPT_BYTES: usize = 256 * 1024;
const FAIL_TAIL_BYTES: usize = 2048;
#[cfg_attr(not(unix), allow(dead_code))]
const POLL_INTERVAL: Duration = Duration::from_millis(10);
#[cfg(unix)]
const MAX_DRAIN_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PtyVerdict {
Pass,
Fail,
Skipped,
}
#[derive(Debug, Clone)]
pub struct PtyStepOutcome {
pub step: usize,
pub ok: bool,
pub detail: String,
}
#[derive(Debug)]
pub struct PtyRunOutcome {
pub verdict: PtyVerdict,
pub steps: Vec<PtyStepOutcome>,
pub transcript: Vec<u8>,
pub truncated: bool,
pub note: Option<String>,
}
#[derive(Debug)]
pub(crate) struct PtyAssertionArtifact {
pub assertion_id: String,
pub pass: bool,
pub transcript_rel: String,
pub detail: String,
}
#[derive(Debug)]
pub(crate) struct PtySkippedAssertion {
pub assertion_id: String,
pub note: String,
}
#[derive(Debug)]
pub(crate) struct PtyAssertionRun {
pub rendered: Option<String>,
pub artifacts: Vec<PtyAssertionArtifact>,
pub skipped: Vec<PtySkippedAssertion>,
}
struct CancelPtyOnDrop {
cancelled: Arc<AtomicBool>,
completed: mpsc::Receiver<()>,
}
impl Drop for CancelPtyOnDrop {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Release);
let _ = self.completed.recv();
}
}
pub(crate) async fn run_pty_assertions(
contract: &[Assertion],
root: &Path,
env: &HashMap<String, String>,
sandbox: &GateSandbox,
runs_dir: &Path,
) -> PtyAssertionRun {
let pty_assertions: Vec<&Assertion> = contract
.iter()
.filter(|a| a.check == AssertionCheck::PtyScript)
.collect();
if pty_assertions.is_empty() {
return PtyAssertionRun {
rendered: None,
artifacts: Vec::new(),
skipped: Vec::new(),
};
}
let mut rendered = String::new();
let mut artifacts = Vec::new();
let mut skipped = Vec::new();
for assertion in pty_assertions {
let Some(script) = assertion.pty_script.clone() else {
rendered.push_str(&format!(
"- [{}] (check=pty-script but no pty script — cannot run)\n",
assertion.id
));
continue;
};
let (wrapped, env) =
match crate::command_exec::prepare_gate_command(&script.command, env, sandbox) {
Ok(prepared) => prepared,
Err(error) => {
rendered.push_str(&format!(
"- [{}] pty-script `{}` → FAIL\n\
gate sandbox wrap failed closed (the pty session did not run): {error}\n",
assertion.id, script.command
));
continue;
}
};
let root = root.to_path_buf();
let command = script.command.clone();
let cancelled = Arc::new(AtomicBool::new(false));
let (completed, completion) = mpsc::channel();
let cancel_on_drop = CancelPtyOnDrop {
cancelled: Arc::clone(&cancelled),
completed: completion,
};
let outcome = tokio::task::spawn_blocking(move || {
let _completed = completed;
imp::run_session(&script, &wrapped, &root, &env, cancelled)
})
.await
.unwrap_or_else(|join_error| PtyRunOutcome {
verdict: PtyVerdict::Fail,
steps: Vec::new(),
transcript: Vec::new(),
truncated: false,
note: Some(format!("pty driver task failed: {join_error}")),
});
drop(cancel_on_drop);
let (line, artifact, skip) = fold_outcome(assertion, &command, &outcome, runs_dir);
rendered.push_str(&line);
if let Some(artifact) = artifact {
artifacts.push(artifact);
}
if let Some(skip) = skip {
skipped.push(skip);
}
}
PtyAssertionRun {
rendered: Some(rendered),
artifacts,
skipped,
}
}
fn fold_outcome(
assertion: &Assertion,
command: &str,
outcome: &PtyRunOutcome,
runs_dir: &Path,
) -> (
String,
Option<PtyAssertionArtifact>,
Option<PtySkippedAssertion>,
) {
if outcome.verdict == PtyVerdict::Skipped {
let note = outcome.note.as_deref().unwrap_or("unsupported host");
return (
format!(
"- [{}] pty-script → FAIL (declared pty-script did not execute: SKIP — {note})\n",
assertion.id
),
None,
Some(PtySkippedAssertion {
assertion_id: assertion.id.clone(),
note: note.to_string(),
}),
);
}
let transcript_rel = write_transcript(runs_dir, &assertion.id, outcome);
let pass = outcome.verdict == PtyVerdict::Pass;
let detail = step_summary(outcome);
let verdict = if pass { "PASS" } else { "FAIL" };
let reference = transcript_rel
.as_deref()
.map(crate::gate_results::file_artefact_ref)
.unwrap_or_else(|| "(transcript write failed)".to_string());
let mut line = format!(
"- [{}] pty-script `{}` → {verdict} ({detail}; transcript {reference})\n",
assertion.id, command
);
if !pass {
let tail = tail_text(&outcome.transcript, FAIL_TAIL_BYTES);
if !tail.is_empty() {
line.push_str(&format!("{}\n", crate::scrub::scrub(&tail)));
}
}
let artifact = transcript_rel.map(|rel| PtyAssertionArtifact {
assertion_id: assertion.id.clone(),
pass,
transcript_rel: rel,
detail,
});
(line, artifact, None)
}
fn step_summary(outcome: &PtyRunOutcome) -> String {
let mut parts: Vec<String> = outcome
.steps
.iter()
.map(|s| format!("step {} {}", s.step + 1, if s.ok { "ok" } else { "FAILED" }))
.collect();
if let Some(failed) = outcome.steps.iter().find(|s| !s.ok) {
parts.push(format!("({})", failed.detail));
}
if let Some(note) = &outcome.note {
parts.push(format!("({note})"));
}
if parts.is_empty() {
"no steps executed".to_string()
} else {
parts.join(" ")
}
}
fn write_transcript(
runs_dir: &Path,
assertion_id: &str,
outcome: &PtyRunOutcome,
) -> Option<String> {
let dir = runs_dir.join("pty-transcripts");
std::fs::create_dir_all(&dir).ok()?;
let safe_id: String = assertion_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'-'
}
})
.collect();
let name = format!(
"{}-{}.log",
safe_id,
&uuid::Uuid::new_v4().simple().to_string()[..8]
);
let mut bytes = outcome.transcript.clone();
if outcome.truncated {
bytes.extend_from_slice(
format!("\n[kranz: transcript truncated at {MAX_TRANSCRIPT_BYTES} bytes]\n").as_bytes(),
);
}
std::fs::write(dir.join(&name), &bytes).ok()?;
Some(format!("runs/pty-transcripts/{name}"))
}
fn tail_text(transcript: &[u8], max: usize) -> String {
let start = transcript.len().saturating_sub(max);
String::from_utf8_lossy(&transcript[start..]).into_owned()
}
#[cfg(unix)]
mod imp {
use super::*;
use crate::command_exec::WrappedCommand;
use crate::types::PtyStep;
use std::io::{Read, Write};
use std::os::unix::io::FromRawFd;
use std::os::unix::process::CommandExt;
use std::time::Instant;
pub fn run_session(
script: &PtyScript,
wrapped: &WrappedCommand,
cwd: &Path,
env: &HashMap<String, String>,
cancelled: Arc<AtomicBool>,
) -> PtyRunOutcome {
if cancelled.load(Ordering::Acquire) {
return spawn_failure("pty validation cancelled before spawn".to_string());
}
let master = unsafe {
libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC | libc::O_NONBLOCK)
};
if master == -1 {
return spawn_failure(format!(
"posix_openpt failed: {}",
std::io::Error::last_os_error()
));
}
if unsafe { libc::grantpt(master) } == -1 || unsafe { libc::unlockpt(master) } == -1 {
let err = std::io::Error::last_os_error();
unsafe { libc::close(master) };
return spawn_failure(format!("preparing pty slave failed: {err}"));
}
let mut name = [0 as libc::c_char; 128];
#[cfg(target_os = "macos")]
const TIOCPTYGNAME: libc::c_ulong = 0x4080_7453;
#[cfg(target_os = "macos")]
let name_result = unsafe { libc::ioctl(master, TIOCPTYGNAME, name.as_mut_ptr()) };
#[cfg(not(target_os = "macos"))]
let name_result = unsafe { libc::ptsname_r(master, name.as_mut_ptr(), name.len()) };
if name_result != 0 || !name.contains(&0) {
let err = if name_result > 0 {
std::io::Error::from_raw_os_error(name_result)
} else {
std::io::Error::last_os_error()
};
unsafe { libc::close(master) };
return spawn_failure(format!("resolving pty slave failed: {err}"));
}
let slave = unsafe {
libc::open(
name.as_ptr(),
libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC,
)
};
if slave == -1 {
let err = std::io::Error::last_os_error();
unsafe { libc::close(master) };
return spawn_failure(format!("opening pty slave failed: {err}"));
}
let winsize = libc::winsize {
ws_row: 24,
ws_col: 80,
ws_xpixel: 0,
ws_ypixel: 0,
};
#[allow(clippy::unnecessary_cast)]
let size_result =
unsafe { libc::ioctl(slave, libc::TIOCSWINSZ as libc::c_ulong, &winsize) };
if size_result == -1 {
let err = std::io::Error::last_os_error();
unsafe {
libc::close(master);
libc::close(slave);
}
return spawn_failure(format!("setting pty size failed: {err}"));
}
let (dup1, dup2) = unsafe {
(
libc::fcntl(slave, libc::F_DUPFD_CLOEXEC, 0),
libc::fcntl(slave, libc::F_DUPFD_CLOEXEC, 0),
)
};
if dup1 == -1 || dup2 == -1 {
let err = std::io::Error::last_os_error();
unsafe {
libc::close(master);
libc::close(slave);
if dup1 != -1 {
libc::close(dup1);
}
if dup2 != -1 {
libc::close(dup2);
}
}
return spawn_failure(format!("dup of pty slave failed: {err}"));
}
let mut cmd = std::process::Command::new(&wrapped.program);
cmd.args(&wrapped.args)
.current_dir(cwd)
.env_clear()
.envs(env)
.stdin(unsafe { std::process::Stdio::from_raw_fd(slave) })
.stdout(unsafe { std::process::Stdio::from_raw_fd(dup1) })
.stderr(unsafe { std::process::Stdio::from_raw_fd(dup2) });
unsafe {
cmd.pre_exec(move || {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
#[allow(clippy::unnecessary_cast)]
let request = libc::TIOCSCTTY as libc::c_ulong;
if libc::ioctl(slave, request, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(error) => {
unsafe { libc::close(master) };
return spawn_failure(format!("target failed to spawn: {error}"));
}
};
let pid = child.id() as i32;
drop(cmd);
let mut master = unsafe { std::fs::File::from_raw_fd(master) };
let mut session = Session {
transcript: Vec::new(),
truncated: false,
child_eof: false,
cancelled,
};
let session_deadline = Instant::now()
+ Duration::from_secs(script.timeout_secs.unwrap_or(DEFAULT_SESSION_TIMEOUT_SECS));
let mut steps = Vec::new();
let mut failed = false;
for (index, step) in script.steps.iter().enumerate() {
let outcome = match step {
PtyStep::Send { text } => {
drive_send(&mut master, &mut session, text, session_deadline, index)
}
PtyStep::Expect {
pattern,
regex,
timeout_ms,
} => drive_expect(
&mut master,
&mut session,
&mut child,
pattern,
*regex,
Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_EXPECT_TIMEOUT_MS)),
session_deadline,
index,
),
};
let ok = outcome.ok;
steps.push(outcome);
if !ok {
failed = true;
break;
}
}
let exited = child.try_wait().ok().flatten();
let note = match exited {
Some(status) => Some(format!("target exited ({status})")),
None => {
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = child.kill();
let reap_deadline = Instant::now() + Duration::from_secs(10);
let reaped = loop {
drain(&mut master, &mut session);
if child.try_wait().ok().flatten().is_some() {
break true;
}
if Instant::now() >= reap_deadline {
break false;
}
std::thread::sleep(POLL_INTERVAL);
};
if reaped || child.try_wait().ok().flatten().is_some() {
let _ = child.wait();
} else {
tracing::warn!(
"pty target did not reap within 10s of SIGKILL despite a drained \
pty; dropping the handle (the killed target may remain \
unreaped until the engine exits)"
);
}
if let Some((program, args)) = &wrapped.timeout_teardown {
let _ = crate::command_exec::run_with_timeout(
program,
args,
Duration::from_secs(30),
);
}
Some("target terminated by harness (script complete)".to_string())
}
};
PtyRunOutcome {
verdict: if failed {
PtyVerdict::Fail
} else {
PtyVerdict::Pass
},
steps,
transcript: session.transcript,
truncated: session.truncated,
note,
}
}
fn spawn_failure(reason: String) -> PtyRunOutcome {
PtyRunOutcome {
verdict: PtyVerdict::Fail,
steps: Vec::new(),
transcript: Vec::new(),
truncated: false,
note: Some(reason),
}
}
struct Session {
transcript: Vec<u8>,
truncated: bool,
child_eof: bool,
cancelled: Arc<AtomicBool>,
}
fn drain(master: &mut std::fs::File, session: &mut Session) -> usize {
let mut fresh = 0usize;
let mut buf = [0u8; 8192];
while fresh < MAX_DRAIN_BYTES {
match master.read(&mut buf) {
Ok(0) => {
session.child_eof = true;
break;
}
Ok(n) => {
let remaining = MAX_TRANSCRIPT_BYTES.saturating_sub(session.transcript.len());
if n > remaining {
session.transcript.extend_from_slice(&buf[..remaining]);
session.truncated = true;
} else {
session.transcript.extend_from_slice(&buf[..n]);
}
fresh += n;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(e) if e.raw_os_error() == Some(libc::EIO) => {
session.child_eof = true;
break;
}
Err(_) => break,
}
}
fresh
}
#[test]
fn pty_drain_yields_before_exhausting_continuously_ready_output() {
use std::io::{Seek, SeekFrom};
let mut input = tempfile::tempfile().unwrap();
let payload = vec![b'x'; MAX_TRANSCRIPT_BYTES * 2];
input.write_all(&payload).unwrap();
input.seek(SeekFrom::Start(0)).unwrap();
let mut session = Session {
transcript: Vec::new(),
truncated: false,
child_eof: false,
cancelled: Arc::new(AtomicBool::new(false)),
};
let first = drain(&mut input, &mut session);
assert!(
first > 0 && first < payload.len(),
"ready output must yield before EOF so deadlines can be checked"
);
assert!(!session.child_eof);
let mut total = first;
while !session.child_eof {
total += drain(&mut input, &mut session);
}
assert_eq!(total, payload.len(), "yielding must not lose input");
assert_eq!(session.transcript, payload[..MAX_TRANSCRIPT_BYTES]);
assert!(session.truncated);
}
fn drive_send(
master: &mut std::fs::File,
session: &mut Session,
text: &str,
session_deadline: Instant,
index: usize,
) -> PtyStepOutcome {
let mut written = 0usize;
let bytes = text.as_bytes();
while written < bytes.len() {
if session.cancelled.load(Ordering::Acquire) {
return PtyStepOutcome {
step: index,
ok: false,
detail: "pty validation cancelled".to_string(),
};
}
if session.child_eof {
return PtyStepOutcome {
step: index,
ok: false,
detail: format!("send step {} failed: target closed the pty", index + 1),
};
}
if Instant::now() >= session_deadline {
return PtyStepOutcome {
step: index,
ok: false,
detail: format!("send step {} failed: session timeout", index + 1),
};
}
match master.write(&bytes[written..]) {
Ok(n) => written += n,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
drain(master, session);
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => {
return PtyStepOutcome {
step: index,
ok: false,
detail: format!("send step {} failed: {e}", index + 1),
};
}
}
}
PtyStepOutcome {
step: index,
ok: true,
detail: format!("send step {} wrote {} bytes", index + 1, bytes.len()),
}
}
#[allow(clippy::too_many_arguments)]
fn drive_expect(
master: &mut std::fs::File,
session: &mut Session,
child: &mut std::process::Child,
pattern: &str,
regex: bool,
step_timeout: Duration,
session_deadline: Instant,
index: usize,
) -> PtyStepOutcome {
let deadline = (Instant::now() + step_timeout).min(session_deadline);
let compiled = if regex {
match regex::Regex::new(pattern) {
Ok(re) => Some(re),
Err(error) => {
return PtyStepOutcome {
step: index,
ok: false,
detail: format!(
"expect step {} has an invalid regex `{pattern}`: {error}",
index + 1
),
};
}
}
} else {
None
};
let matched = |transcript: &[u8]| {
let text = String::from_utf8_lossy(transcript);
match &compiled {
Some(re) => re.is_match(&text),
None => text.contains(pattern),
}
};
let started = Instant::now();
loop {
if session.cancelled.load(Ordering::Acquire) {
return PtyStepOutcome {
step: index,
ok: false,
detail: "pty validation cancelled".to_string(),
};
}
let fresh = drain(master, session);
if matched(&session.transcript) {
return PtyStepOutcome {
step: index,
ok: true,
detail: format!(
"expect `{pattern}` matched ({}ms)",
started.elapsed().as_millis()
),
};
}
if session.child_eof {
let status = child.try_wait().ok().flatten();
return PtyStepOutcome {
step: index,
ok: false,
detail: format!(
"expect `{pattern}` unmatched: target exited ({})",
status
.map(|s| s.to_string())
.unwrap_or_else(|| "status unknown".to_string())
),
};
}
if Instant::now() >= deadline {
return PtyStepOutcome {
step: index,
ok: false,
detail: format!(
"expect `{pattern}` timed out after {}ms",
step_timeout.as_millis()
),
};
}
if fresh == 0 {
std::thread::sleep(POLL_INTERVAL);
}
}
}
}
#[cfg(not(unix))]
mod imp {
use super::*;
use crate::command_exec::WrappedCommand;
pub fn run_session(
_script: &PtyScript,
_wrapped: &WrappedCommand,
_cwd: &Path,
_env: &HashMap<String, String>,
_cancelled: Arc<AtomicBool>,
) -> PtyRunOutcome {
PtyRunOutcome {
verdict: PtyVerdict::Skipped,
steps: Vec::new(),
transcript: Vec::new(),
truncated: false,
note: Some(
"pty validation is implemented for unix hosts only (libc openpty); \
this platform cannot drive terminal-interactive targets"
.to_string(),
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::PtyScript;
use crate::types::PtyStep;
#[cfg(unix)]
const REPL_OK: &str = "printf '> '; while IFS= read -r line; do case \"$line\" in quit) \
printf 'bye\\n'; exit 0;; *) printf 'echo:%s\\n> ' \"$line\";; esac; done";
#[cfg(unix)]
const REPL_DEFECT: &str = "printf '> '; while IFS= read -r line; do case \"$line\" in quit) \
printf 'bye\\n'; exit 0;; *) printf 'echo:WRONG:%s\\n> ' \"$line\";; esac; done";
#[cfg(unix)]
#[tokio::test]
async fn pty_validation_cancellation_waits_for_target_cleanup() {
use std::time::Instant;
for send in [false, true] {
let dir = tempfile::tempdir().unwrap();
let command = "stty raw -echo || exit 1; trap '' HUP; sleep 30 & child=$!; \
printf '%s %s' \"$$\" \"$child\" > pids; printf 'ready\\n'; wait";
let step = if send {
PtyStep::Send {
text: "x".repeat(1024 * 1024),
}
} else {
PtyStep::Expect {
pattern: "never printed".into(),
regex: false,
timeout_ms: Some(30_000),
}
};
let contract = vec![pty_assertion(
"a-cancel",
command,
vec![
PtyStep::Expect {
pattern: "ready".into(),
regex: false,
timeout_ms: Some(5_000),
},
step,
],
)];
let env = HashMap::new();
let runs = dir.path().join("runs");
let mut run = Box::pin(run_pty_assertions(
&contract,
dir.path(),
&env,
&GateSandbox::Disabled,
&runs,
));
let pids = tokio::select! {
result = &mut run => panic!("PTY finished before cancellation: {result:?}"),
pids = async {
for _ in 0..1000 {
if let Ok(text) = std::fs::read_to_string(dir.path().join("pids")) {
let pids: Vec<i32> = text
.split_whitespace()
.filter_map(|pid| pid.parse().ok())
.collect();
if pids.len() == 2 {
tokio::time::sleep(Duration::from_millis(50)).await;
return pids;
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("PTY target did not start");
} => pids,
};
let start = Instant::now();
drop(run);
assert!(
start.elapsed() < Duration::from_secs(5),
"cancellation waited for the script deadline"
);
assert!(
unsafe { libc::kill(pids[0], 0) } != 0,
"the PTY leader was not reaped before drop returned"
);
while unsafe { libc::kill(pids[1], 0) } == 0 {
#[cfg(target_os = "linux")]
if std::fs::read_to_string(format!("/proc/{}/stat", pids[1])).is_ok_and(|stat| {
stat.rsplit_once(") ")
.is_some_and(|(_, fields)| fields.starts_with("Z "))
}) {
break;
}
assert!(
start.elapsed() < Duration::from_secs(5),
"PTY descendant survived cancellation"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(
!runs.join("pty-transcripts").exists(),
"a cancelled assertion recorded a completed verdict"
);
}
}
#[cfg(unix)]
fn pty_assertion(id: &str, command: &str, steps: Vec<PtyStep>) -> Assertion {
Assertion {
id: id.to_string(),
statement: "the REPL echoes input back".to_string(),
check: AssertionCheck::PtyScript,
command: None,
negative_control: None,
pty_script: Some(PtyScript {
command: command.to_string(),
steps,
timeout_secs: Some(20),
}),
}
}
#[cfg(unix)]
fn repl_steps() -> Vec<PtyStep> {
vec![
PtyStep::Expect {
pattern: "> ".to_string(),
regex: false,
timeout_ms: Some(10_000),
},
PtyStep::Send {
text: "hello\n".to_string(),
},
PtyStep::Expect {
pattern: "echo:hello".to_string(),
regex: false,
timeout_ms: Some(10_000),
},
PtyStep::Send {
text: "quit\n".to_string(),
},
PtyStep::Expect {
pattern: "bye".to_string(),
regex: false,
timeout_ms: Some(10_000),
},
]
}
#[cfg(unix)]
#[tokio::test]
async fn pty_validation_correct_target_passes_and_names_assertion() {
let dir = tempfile::tempdir().unwrap();
let contract = vec![pty_assertion("a-pty", REPL_OK, repl_steps())];
let run = run_pty_assertions(
&contract,
dir.path(),
&HashMap::new(),
&GateSandbox::Disabled,
&dir.path().join("runs"),
)
.await;
assert_eq!(run.artifacts.len(), 1, "one transcript artifact: {run:?}");
assert!(run.artifacts[0].pass, "correct REPL passes: {run:?}");
assert_eq!(run.artifacts[0].assertion_id, "a-pty");
let rendered = run.rendered.expect("pty assertions render evidence");
assert!(rendered.contains("[a-pty]"), "assertion named: {rendered}");
assert!(rendered.contains("→ PASS"), "verdict rendered: {rendered}");
let transcript = std::fs::read(dir.path().join(&run.artifacts[0].transcript_rel)).unwrap();
let text = String::from_utf8_lossy(&transcript);
assert!(text.contains("echo:hello"), "transcript captured: {text}");
assert!(text.contains("bye"), "full session captured: {text}");
}
#[cfg(unix)]
#[tokio::test]
async fn pty_validation_seeded_defect_fails_and_names_assertion() {
let dir = tempfile::tempdir().unwrap();
let mut steps = repl_steps();
if let PtyStep::Expect { timeout_ms, .. } = &mut steps[2] {
*timeout_ms = Some(1_000);
}
let contract = vec![pty_assertion("a-pty", REPL_DEFECT, steps)];
let run = run_pty_assertions(
&contract,
dir.path(),
&HashMap::new(),
&GateSandbox::Disabled,
&dir.path().join("runs"),
)
.await;
assert_eq!(run.artifacts.len(), 1, "failing session still artifacts");
assert!(!run.artifacts[0].pass, "defect must fail");
let rendered = run.rendered.unwrap();
assert!(rendered.contains("[a-pty]"), "assertion named: {rendered}");
assert!(rendered.contains("→ FAIL"), "verdict rendered: {rendered}");
assert!(
rendered.contains("echo:hello"),
"unmatched pattern named: {rendered}"
);
assert!(
run.artifacts[0].detail.contains("FAILED"),
"failed step named in the event detail: {}",
run.artifacts[0].detail
);
}
#[cfg(unix)]
#[tokio::test]
async fn pty_validation_transcript_is_event_resolvable_artifact() {
let dir = tempfile::tempdir().unwrap();
let mission_dir = dir.path();
let runs_dir = mission_dir.join("runs");
let contract = vec![pty_assertion("a-pty", REPL_OK, repl_steps())];
let run = run_pty_assertions(
&contract,
mission_dir,
&HashMap::new(),
&GateSandbox::Disabled,
&runs_dir,
)
.await;
let artifact = &run.artifacts[0];
assert!(
artifact.transcript_rel.starts_with("runs/pty-transcripts/"),
"mission-relative runs/ path: {}",
artifact.transcript_rel
);
let reference = crate::gate_results::file_artefact_ref(&artifact.transcript_rel);
assert!(
reference.starts_with("file:runs/"),
"file: scheme: {reference}"
);
match crate::gate_results::resolve_artefact(mission_dir, &reference) {
crate::gate_results::ArtefactResolution::Resolved { .. } => {}
other => panic!("transcript must resolve against the mission dir: {other:?}"),
}
}
#[tokio::test]
async fn pty_validation_contract_without_harness_skips() {
let dir = tempfile::tempdir().unwrap();
let contract = vec![
Assertion {
id: "a-1".to_string(),
statement: "s".to_string(),
check: AssertionCheck::Command,
command: Some("true".to_string()),
negative_control: None,
pty_script: None,
},
Assertion {
id: "a-2".to_string(),
statement: "s".to_string(),
check: AssertionCheck::AgentJudgement,
command: None,
negative_control: None,
pty_script: None,
},
];
let run = run_pty_assertions(
&contract,
dir.path(),
&HashMap::new(),
&GateSandbox::Disabled,
dir.path(),
)
.await;
assert!(run.rendered.is_none(), "no pty assertions → no evidence");
assert!(run.artifacts.is_empty(), "no pty assertions → no artifacts");
assert!(run.skipped.is_empty(), "no pty assertions → no skips");
let malformed = vec![Assertion {
id: "a-3".to_string(),
statement: "s".to_string(),
check: AssertionCheck::PtyScript,
command: None,
negative_control: None,
pty_script: None,
}];
let run = run_pty_assertions(
&malformed,
dir.path(),
&HashMap::new(),
&GateSandbox::Disabled,
dir.path(),
)
.await;
let rendered = run.rendered.unwrap();
assert!(
rendered.contains("[a-3] (check=pty-script but no pty script — cannot run)"),
"{rendered}"
);
assert!(run.artifacts.is_empty());
assert!(run.skipped.is_empty());
}
#[test]
fn pty_validation_declared_pty_skip_fails_and_names_reason() {
let dir = tempfile::tempdir().unwrap();
let assertion = Assertion {
id: "a-pty".to_string(),
statement: "the REPL echoes input back".to_string(),
check: AssertionCheck::PtyScript,
command: None,
negative_control: None,
pty_script: Some(PtyScript {
command: "./repl".to_string(),
steps: Vec::new(),
timeout_secs: None,
}),
};
let outcome = PtyRunOutcome {
verdict: PtyVerdict::Skipped,
steps: Vec::new(),
transcript: Vec::new(),
truncated: false,
note: Some(
"pty validation is implemented for unix hosts only (libc openpty)".to_string(),
),
};
let (line, artifact, skip) = fold_outcome(&assertion, "./repl", &outcome, dir.path());
assert!(line.contains("[a-pty]"), "assertion named: {line}");
assert!(
line.contains("→ FAIL"),
"a declared skip is FAIL evidence, not a soft skip: {line}"
);
assert!(
line.contains("did not execute"),
"the skip is named as a non-execution: {line}"
);
assert!(
line.contains("unix hosts only"),
"the skip reason is named: {line}"
);
assert!(
!line.contains("→ SKIP"),
"no soft skip line for a declared assertion: {line}"
);
assert!(
artifact.is_none(),
"no session ran — no transcript artifact may exist"
);
let skip = skip.expect("the skip is recorded for the round decision");
assert_eq!(skip.assertion_id, "a-pty");
assert!(skip.note.contains("unix hosts only"), "{}", skip.note);
}
#[cfg(unix)]
#[tokio::test]
async fn pty_validation_child_inherits_only_standard_terminal_streams() {
let dir = tempfile::tempdir().unwrap();
let contract = vec![pty_assertion(
"a-pty",
"for fd in /dev/fd/*; do n=${fd##*/}; \
if [ -t \"$n\" ]; then printf 'tty-fd:%s\\n' \"$n\"; fi; done; \
printf 'probe-complete\\n'",
vec![PtyStep::Expect {
pattern: "probe-complete".to_string(),
regex: false,
timeout_ms: None,
}],
)];
let run = run_pty_assertions(
&contract,
dir.path(),
&HashMap::new(),
&GateSandbox::Disabled,
&dir.path().join("runs"),
)
.await;
assert!(run.artifacts[0].pass, "{}", run.artifacts[0].detail);
let transcript =
std::fs::read_to_string(dir.path().join(&run.artifacts[0].transcript_rel)).unwrap();
let terminals: Vec<_> = transcript
.lines()
.filter_map(|line| line.trim().strip_prefix("tty-fd:"))
.collect();
assert_eq!(terminals, ["0", "1", "2"], "{transcript}");
}
#[cfg(unix)]
#[tokio::test]
async fn pty_validation_transcript_is_bounded() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("oversized.txt"),
vec![b'x'; MAX_TRANSCRIPT_BYTES + 4096],
)
.unwrap();
let contract = vec![pty_assertion(
"a-pty",
"/bin/cat oversized.txt",
vec![PtyStep::Expect {
pattern: "this-pattern-never-appears".to_string(),
regex: false,
timeout_ms: None,
}],
)];
let run = run_pty_assertions(
&contract,
dir.path(),
&HashMap::new(),
&GateSandbox::Disabled,
&dir.path().join("runs"),
)
.await;
assert!(!run.artifacts[0].pass, "never-matching expect fails");
assert!(
run.artifacts[0].detail.contains("unmatched: target exited"),
"fixture must finish its output: {}",
run.artifacts[0].detail
);
let transcript = std::fs::read(dir.path().join(&run.artifacts[0].transcript_rel)).unwrap();
assert!(
transcript.len() <= MAX_TRANSCRIPT_BYTES + 128,
"bounded on disk: {} bytes",
transcript.len()
);
assert_eq!(
&transcript[..MAX_TRANSCRIPT_BYTES],
vec![b'x'; MAX_TRANSCRIPT_BYTES]
);
let text = String::from_utf8_lossy(&transcript);
assert!(text.contains("transcript truncated"), "truncation recorded");
}
#[test]
fn pty_validation_contract_serde_is_additive() {
let old: Assertion = serde_json::from_str(
r#"{"id":"a-1","statement":"s","check":"command","command":"true"}"#,
)
.unwrap();
assert!(old.pty_script.is_none(), "absent key decodes to None");
let old: Assertion =
serde_json::from_str(r#"{"id":"a-1","statement":"s","check":"agent-judgement"}"#)
.unwrap();
assert!(old.pty_script.is_none());
let new: Assertion = serde_json::from_str(
r#"{"id":"a-2","statement":"s","check":"pty-script",
"ptyScript":{"command":"./repl","steps":[
{"op":"expect","pattern":"> "},
{"op":"send","text":"help\n"},
{"op":"expect","pattern":"usage","regex":true,"timeoutMs":500}
]}}"#,
)
.unwrap();
assert_eq!(new.check, AssertionCheck::PtyScript);
let json = serde_json::to_value(&new).unwrap();
assert_eq!(json["check"], "pty-script");
assert!(json["ptyScript"].get("timeoutSecs").is_none());
assert!(json["ptyScript"]["steps"][0].get("timeoutMs").is_none());
assert_eq!(json["ptyScript"]["steps"][1]["op"], "send");
let script = new.pty_script.unwrap();
assert_eq!(script.command, "./repl");
assert_eq!(script.timeout_secs, None, "session timeout defaults");
assert_eq!(script.steps.len(), 3);
match &script.steps[0] {
PtyStep::Expect {
pattern,
regex,
timeout_ms,
} => {
assert_eq!(pattern, "> ");
assert!(!regex, "regex defaults to literal substring");
assert_eq!(*timeout_ms, None, "step timeout defaults");
}
other => panic!("wrong step: {other:?}"),
}
}
}