Skip to main content

kranz_engine/
pty_harness.rs

1//! Pty-driven functional validation (ticket `.kranz/tickets/pty-functional-validation.md`):
2//! terminal-interactive deliverables — REPLs, TUIs, interactive CLIs — are
3//! driven by the engine through a scripted pty session, and the functional
4//! validator judges the per-step verdicts as authoritative evidence, exactly
5//! like it judges engine-run contract-command output (validator repair 3/5).
6//!
7//! This is VALIDATOR tooling, not an execution feature: the script judges
8//! what the delivered software DOES on a terminal and never feeds work back
9//! into the mission (the positioning ADR's retained list — same carve-out
10//! the M5 browser/computer-use lane already occupies). Nothing here spawns
11//! an agent.
12//!
13//! ## Mechanism decision (minimal-dependency posture)
14//!
15//! No pty crate is in the tree (`portable-pty` was the candidate — it would
16//! add `anyhow`/`filedescriptor`/`shared_library`/`winapi`-adjacent deps to
17//! buy Windows ConPTY this ticket does not need). The harness therefore
18//! lives on `std::process` plus the platform's own pty facility through
19//! POSIX PTY calls — `libc` is ALREADY the engine's unix dependency, so the
20//! whole mechanism is one contained module with no new dependency. The cost
21//! is platform coverage: the session core is `#[cfg(unix)]`, and non-unix
22//! hosts degrade LOUDLY — every pty-script assertion renders a SKIP line
23//! naming the platform gap (the same posture uncontainable
24//! platforms/backends take for validator containment), never a silent pass.
25//!
26//! ## Sandbox composition
27//!
28//! A pty session runs under the SAME policy as the contract commands in the
29//! same evidence pass: the orchestrator hands the harness the argv that
30//! `GateSandbox::wrap_shell` produces for the script's command (plus the
31//! offline-adjusted gate env via [`crate::command_exec::prepare_gate_command`]), so `enforce: off`
32//! reproduces the pre-wrap `sh -c` byte-for-byte and an enforced posture
33//! puts the target inside the same seatbelt/bwrap/container wrap its
34//! sibling commands get. The pty ALLOCATION lives in the engine process;
35//! only the target tree is wrapped, and the harness never widens what the
36//! wrap allows. The wrapped child leads a new session (`setsid` +
37//! `TIOCSCTTY`), so the end-of-script SIGKILL reaches the whole group, and
38//! the container arm's named teardown runs on a killed client exactly as in
39//! the bounded runner.
40//!
41//! ## Script format
42//!
43//! Declared inline on the contract assertion ([`crate::types::PtyScript`]):
44//! `send` steps write bytes verbatim, `expect` steps assert the accumulated
45//! session output contains a literal substring (or matches a regex) within
46//! a per-step timeout. The transcript is the pty's raw output stream —
47//! terminal echo means sent input appears naturally for canonical-mode
48//! targets — bounded at [`MAX_TRANSCRIPT_BYTES`], written under the
49//! mission's gitignored `runs/pty-transcripts/`, and referenced from a
50//! `validation.pty.transcript` event with the `file:`-scheme ArtefactRef
51//! idiom ([`crate::gate_results`]). The transcript FILE is raw target
52//! output (runs/ is gitignored scratch, same posture as session transcript
53//! .jsonl files); the per-step verdict text handed to the validator and the
54//! event detail carries no target output beyond the scrubbed failure tail.
55//!
56//! ## Skip discipline
57//!
58//! A contract with no pty-script assertions takes today's path byte-for-byte
59//! (no harness, no artifacts, no events) — "targets with no declared run
60//! harness skip cleanly" is the absence case, mirroring browser QA. A
61//! declared target that fails to spawn is NOT a skip: the deliverable
62//! declared runnable does not run, which FAILS the assertion honestly.
63//!
64//! A DECLARED pty-script whose session SKIPs (a host that cannot drive a
65//! pty) is not a soft skip either (ticket `pty-script-skip-vacuous-green`):
66//! the declared functional validation never ran, so the evidence line FAILS
67//! naming the skip reason, the skip is recorded for the round's loud
68//! decision, and no transcript artifact/event exists for it — the absence
69//! of a `validation.pty.transcript` verdict is what the final gate's
70//! vacuous-green backstop keys on (it re-runs only command assertions, so
71//! without that check a declared pty-script that skipped every round would
72//! green the mission without its declared validation ever executing).
73
74use 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
82/// Default wall-clock cap for one whole scripted session.
83pub const DEFAULT_SESSION_TIMEOUT_SECS: u64 = 60;
84/// Default per-`expect` timeout.
85pub const DEFAULT_EXPECT_TIMEOUT_MS: u64 = 10_000;
86/// Transcript bound: output past this is discarded (the `truncated` flag
87/// records it), so a runaway target cannot fill the mission dir or the
88/// evidence record. 256 KiB holds hours of REPL interaction and minutes of
89/// full-screen redraw.
90pub const MAX_TRANSCRIPT_BYTES: usize = 256 * 1024;
91/// The scrubbed transcript tail attached to a FAILED assertion's evidence —
92/// enough to judge the mismatch, bounded so the rendered block stays small.
93const FAIL_TAIL_BYTES: usize = 2048;
94/// Poll cadence of the drive loop: fine enough to catch prompt output
95/// promptly, coarse enough to never busy-spin.
96#[cfg_attr(not(unix), allow(dead_code))]
97const POLL_INTERVAL: Duration = Duration::from_millis(10);
98/// Return to the deadline checks even when a target keeps the pty readable.
99#[cfg(unix)]
100const MAX_DRAIN_BYTES: usize = 64 * 1024;
101
102/// The session-level verdict of one pty-script assertion.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum PtyVerdict {
105    /// Every `expect` step matched within its timeout.
106    Pass,
107    /// An `expect` step missed (timeout, target exit, invalid pattern), a
108    /// `send` could not be delivered, or the target failed to spawn.
109    Fail,
110    /// The harness could not run at all on this host (non-unix platform).
111    /// For a DECLARED assertion this is FAIL evidence naming the skip
112    /// reason (ticket `pty-script-skip-vacuous-green`) — never a soft pass.
113    Skipped,
114}
115
116/// The per-step verdict record. One entry per EXECUTED step (the run stops
117/// at the first failed `expect`, so a failing run's last entry is the
118/// failure; `send` steps are recorded too so the validator sees how far the
119/// script drove).
120#[derive(Debug, Clone)]
121pub struct PtyStepOutcome {
122    /// Zero-based index into the script's `steps`.
123    pub step: usize,
124    pub ok: bool,
125    /// What happened, naming the step — e.g. `expect `> ` matched (812ms)`
126    /// or `expect `echo:hello` timed out after 500ms`.
127    pub detail: String,
128}
129
130/// What one scripted pty session produced: the verdict, per-step verdicts,
131/// and the bounded transcript.
132#[derive(Debug)]
133pub struct PtyRunOutcome {
134    pub verdict: PtyVerdict,
135    pub steps: Vec<PtyStepOutcome>,
136    /// Raw pty output bytes, capped at [`MAX_TRANSCRIPT_BYTES`].
137    pub transcript: Vec<u8>,
138    /// True when output past the cap was discarded.
139    pub truncated: bool,
140    /// Session-level context: spawn failure reason, target exit status,
141    /// harness termination note.
142    pub note: Option<String>,
143}
144
145/// One transcript artifact ready for event emission: the assertion, the
146/// verdict, and the mission-relative path the transcript was written to
147/// (the `file:` scheme is glued on at emit via
148/// [`crate::gate_results::file_artefact_ref`]).
149#[derive(Debug)]
150pub(crate) struct PtyAssertionArtifact {
151    pub assertion_id: String,
152    pub pass: bool,
153    /// Mission-relative transcript path (`runs/pty-transcripts/<…>.log`).
154    pub transcript_rel: String,
155    /// Per-step summary (contract-authored patterns and timings only — no
156    /// raw target output), carried as the event's `detail`.
157    pub detail: String,
158}
159
160/// A DECLARED pty-script assertion whose session never ran (the harness
161/// reported [`PtyVerdict::Skipped`] — this host cannot drive a pty). The
162/// orchestrator surfaces each as a loud per-round decision; the final gate
163/// independently refuses to green a declared pty assertion with no
164/// `validation.pty.transcript` verdict in the log (ticket
165/// `pty-script-skip-vacuous-green`).
166#[derive(Debug)]
167pub(crate) struct PtySkippedAssertion {
168    pub assertion_id: String,
169    /// Why the harness could not run (the session core's skip note).
170    pub note: String,
171}
172
173/// The result of running every pty-script assertion of a validation
174/// contract: rendered evidence lines for the functional validator's task
175/// (same block the command-assertion results feed) plus the transcript
176/// artifacts for event emission. `rendered` is `None` when the contract
177/// declares no pty scripts — today's behavior byte-for-byte. `skipped`
178/// names every declared assertion whose session never ran.
179#[derive(Debug)]
180pub(crate) struct PtyAssertionRun {
181    pub rendered: Option<String>,
182    pub artifacts: Vec<PtyAssertionArtifact>,
183    pub skipped: Vec<PtySkippedAssertion>,
184}
185
186/// Dropping the mission future must finish the blocking driver's cleanup
187/// before its caller can release the mission lock or remove the worktree.
188struct 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        // The driver owns the only sender and drops it after cleanup, even
197        // on panic or when a queued blocking task never starts. It never
198        // needs this async executor to make progress.
199        let _ = self.completed.recv();
200    }
201}
202
203/// Run every pty-script assertion in `contract` as part of the validation
204/// round's engine-run evidence pass. Called exactly where the bounded
205/// contract commands run, with the same `root`, cleared contract `env`, and
206/// resolved `sandbox` — a pty session is posture-identical to a contract
207/// command, only interactive. Assertions are driven sequentially (the round
208/// is sequential today; parallel ptys would interleave transcript writes
209/// and muddy the evidence order).
210///
211/// Never fails the ROUND: every failure mode lands as a rendered FAIL line
212/// against the named assertion (evidence for the validator), the same
213/// fail-closed-as-evidence posture the bounded command path takes. A SKIP
214/// verdict (this host cannot drive a pty) is FAIL evidence too — a declared
215/// pty-script that did not execute must never read as a soft pass (ticket
216/// `pty-script-skip-vacuous-green`).
217pub(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            // Mirrors the `(check=command but no command — cannot run)` arm:
241            // a malformed contract entry is rendered, never silently dropped.
242            // No session runs and no transcript event exists for it, so the
243            // final gate's unexecuted-assertion backstop flags it too.
244            rendered.push_str(&format!(
245                "- [{}] (check=pty-script but no pty script — cannot run)\n",
246                assertion.id
247            ));
248            continue;
249        };
250        // The same final env + wrap the bounded runner computes per command;
251        // a wrap failure fails CLOSED as evidence (the session did not run).
252        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            // A panicking driver must not take the round down — surface it
279            // as an honest FAIL against the assertion instead.
280            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
303/// Fold one finished session outcome into its evidence-block line, the
304/// optional transcript artifact, and — for a SKIP — the skip record.
305/// Factored out of the drive loop so the skip arm, which only the non-unix
306/// session core produces in production, is testable on every host with a
307/// synthetic outcome (ticket `pty-script-skip-vacuous-green`).
308fn 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        // A DECLARED pty-script that did not execute is not a skip: the
320        // declared functional validation never ran, so the evidence FAILS
321        // and names the skip reason. No transcript artifact is emitted (no
322        // session ran, so no transcript exists) — the absence of a
323        // validation.pty.transcript verdict for the assertion is exactly
324        // what the final gate's vacuous-green backstop keys on.
325        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    // The transcript lands as a validation artifact regardless of verdict —
339    // a FAILING session's transcript is the most valuable evidence of all.
340    // A write failure drops the reference (never emit a file: ref whose
341    // bytes are absent) but keeps the verdict.
342    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
369/// The one-line per-step summary carried in the evidence line and the
370/// event detail: step kinds, patterns (contract-authored — no target
371/// output), and outcomes, joined compactly.
372fn 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
391/// Write the bounded transcript under `runs/pty-transcripts/` and return
392/// the mission-relative path (no scheme). `None` on any io failure — the
393/// caller then renders the verdict WITHOUT a file reference rather than
394/// emitting one whose bytes are missing (resolve_artefact's unresolved
395/// case is for pruned missions, not for bytes we never wrote).
396fn 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    // Assertion ids are plan-authored (`a-1`, `fix-3`); keep the filename
404    // charset boring anyway, and suffix a uuid so re-run rounds never
405    // overwrite an earlier round's evidence.
406    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
431/// The last `max` bytes of the transcript as lossy text, for the scrubbed
432/// failure tail in the rendered evidence.
433fn 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// ---------------------------------------------------------------------------
439// Platform cores
440// ---------------------------------------------------------------------------
441
442/// The unix session core: allocate a pty pair, spawn the (already wrapped)
443/// target with the slave as its controlling terminal, and drive the script
444/// against the master. Blocking by design — the caller parks it on
445/// `spawn_blocking`; the drive loop is sleep-polled at [`POLL_INTERVAL`].
446#[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        // Allocate with close-on-exec atomically: openpty followed by fcntl
467        // races other threads spawning children between those two calls.
468        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        // macOS exposes this ptsname ioctl in sys/ttycom.h; its libc crate
484        // has no ptsname_r binding. Both paths use a caller-owned buffer.
485        #[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        // Match the fixed 80x24 terminal the harness has always provided.
512        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        // The child gets the slave on stdin/stdout/stderr. dup it twice and
531        // hand the original over as the third — each Stdio owns exactly one
532        // fd.
533        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            // SAFETY: from_raw_fd takes ownership of the dup'd fds exactly
560            // once each; the originals are not used afterwards.
561            .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        // New session + the pty slave as controlling terminal: full-screen
565        // targets (vim/gdb-style) require a ctty, not merely isatty(stdin),
566        // and the session-leader pid doubling as the process-group id is
567        // what makes the end-of-script group SIGKILL reach the whole tree
568        // (the configure_bounded_child discipline, interactive variant).
569        // SAFETY: runs only in the forked child pre-exec; setsid/ioctl are
570        // async-signal-safe, and `slave` still names the open pty there
571        // (std's fd cleanup runs after pre_exec, right before exec).
572        unsafe {
573            cmd.pre_exec(move || {
574                if libc::setsid() == -1 {
575                    return Err(std::io::Error::last_os_error());
576                }
577                // The ioctl request parameter is c_ulong on both macOS and
578                // linux-gnu, but the TIOCSCTTY constant's type varies (u32
579                // on macOS, c_ulong on linux-gnu) — the cast is load-bearing
580                // on macOS and an identity on linux, so allow the identity
581                // case rather than cfg-split a one-liner.
582                #[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        // Command owns the parent's slave descriptors even after spawn.
600        // Close them now so the master can observe EOF when the child exits.
601        drop(cmd);
602
603        // The parent drives the master only; nonblocking so the poll loop
604        // owns the timing (per-step and whole-session deadlines).
605        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        // Termination: a target still running at script end gets the group
646        // SIGKILL (session leader's pgid IS its pid); an already-exited
647        // target is only reaped. The container arm's named teardown runs
648        // exactly when the bounded runner would run it — the client was
649        // killed before an exit code arrived.
650        let exited = child.try_wait().ok().flatten();
651        let note = match exited {
652            Some(status) => Some(format!("target exited ({status})")),
653            None => {
654                // SAFETY: kill(-pid) targets the child's process group —
655                // valid while the child is ours; ESRCH (already gone) is
656                // harmless.
657                unsafe {
658                    libc::kill(-pid, libc::SIGKILL);
659                }
660                let _ = child.kill();
661                // Reap WITHOUT wedging: a SIGKILLed pty target can block in
662                // kernel exit while its slave-side output queue stays
663                // undrained (observed on macOS: the target lingers in
664                // 'trying to exit' state and wait() never returns), so pump
665                // the master while polling the reap. A target STILL
666                // unreaped after the bound — never observed, defense only —
667                // is dropped rather than allowed to hang the validation
668                // round in an unbounded wait(). It may remain a zombie
669                // until the engine exits.
670                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                    // Terminal EOF can precede a waitable process exit;
677                    // it does not mean the target has been reaped.
678                    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                    // Best-effort, bounded — the same 30s teardown bound
694                    // the bounded runner applies to a killed container
695                    // client; a teardown failure is ignored.
696                    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    /// The mutable driver state threaded through every step.
730    struct Session {
731        transcript: Vec<u8>,
732        truncated: bool,
733        /// The master returned EOF/EIO — the target closed the pty, so
734        /// later expects can never match and sends can never land.
735        child_eof: bool,
736        cancelled: Arc<AtomicBool>,
737    }
738
739    /// Drain one bounded batch into the transcript, then yield to the caller's
740    /// deadline checks. Returns bytes read, including discarded output (0
741    /// also covers EOF, which flips `child_eof`).
742    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                    // Linux/macOS pty master reads EIO once the slave side
764                    // is fully closed — the target's EOF.
765                    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                    // The pty's input buffer is full (a target not reading);
837                    // drain pending output and retry within the session
838                    // deadline rather than failing outright.
839                    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        // Compile once per step; an invalid pattern is a contract-authoring
871        // error and fails the assertion NAMED, exactly like a contract
872        // command that cannot run.
873        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            // Sleep only when the poll produced nothing: a spewing target
941            // (a TUI redrawing, a build log) is drained at full speed,
942            // while an idle pty never busy-spins.
943            if fresh == 0 {
944                std::thread::sleep(POLL_INTERVAL);
945            }
946        }
947    }
948}
949
950/// The non-unix core: no pty facility in the dependency set (see the module
951/// docs' mechanism decision). Every assertion degrades to a LOUD skip —
952/// rendered into the evidence block — never a silent pass or an
953/// unsandboxed fallback.
954#[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    /// The fixture interactive target shipped in-test: a tiny sh REPL with
987    /// a `> ` prompt that echoes input back as `echo:<line>` and says `bye`
988    /// on `quit`. Driven through the harness exactly like a contract's
989    /// pty-script command (GateSandbox::Disabled → `/bin/sh -c …`).
990    #[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    /// The seeded-defect variant: the same REPL, but the echo is wrong.
994    #[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                // Fill the terminal input queue; cancellation must also
1009                // interrupt a blocked send to a target that never reads.
1010                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            // Orphaned descendants can briefly remain zombies under init;
1071            // their process group must have received SIGKILL too.
1072            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    /// A correct interactive target driven through scripted input PASSES,
1137    /// with every expect step's verdict recorded and the session transcript
1138    /// capturing the exchange (prompt, echoed input, response).
1139    #[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    /// The seeded-defect variant FAILS, with the assertion id, the failed
1165    /// step, and the unmatched pattern all named — and the failing
1166    /// session's transcript still lands as the artifact.
1167    #[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        // Bound the failing expect so the test stays fast.
1173        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    /// The transcript lands as a validation artifact referenced the way
1202    /// events carry file evidence: a mission-relative `file:`-schemed
1203    /// reference that resolve_artefact classifies Resolved against the
1204    /// mission dir (the gate_results ArtefactRef idiom).
1205    #[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    /// Skip discipline: a contract with no pty-script assertion takes
1238    /// today's path byte-for-byte — no rendered evidence, no artifacts (the
1239    /// "no declared run harness" case, mirroring browser QA).
1240    #[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        // A declared pty-script check without a script is a malformed
1274        // contract entry — rendered as cannot-run, never silently dropped.
1275        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        // Not a harness SKIP (no session core was consulted) — the final
1298        // gate's unexecuted-assertion backstop flags it via the missing
1299        // transcript verdict instead.
1300        assert!(run.skipped.is_empty());
1301    }
1302
1303    /// Regression for ticket `pty-script-skip-vacuous-green`: a DECLARED
1304    /// pty-script whose session SKIPs (a non-unix host, or any wrap that
1305    /// cannot host a pty) never produced the declared functional validation,
1306    /// so the evidence line FAILS naming the skip reason and the skip is
1307    /// recorded for the round's loud decision — a soft SKIP line is
1308    /// reserved for contracts that never declared a pty script. No
1309    /// transcript artifact exists (no session ran): the missing
1310    /// `validation.pty.transcript` verdict is the final gate's signal.
1311    /// Synthetic outcome, so the skip arm is exercised on every host.
1312    #[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    /// The transcript is bounded: a target spewing output past
1397    /// MAX_TRANSCRIPT_BYTES gets the cap enforced and the truncation
1398    /// recorded, so runaway output cannot fill the mission dir.
1399    #[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            // A finite oversized payload reaches EOF after crossing the cap;
1411            // truncation must not depend on throughput before a short timer.
1412            "/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        // File bytes = capped transcript + the truncation marker line.
1435        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    /// The contract addition is serde-additive: a pre-field assertion
1449    /// (no ptyScript key) still parses, the new check decodes from its
1450    /// kebab-case wire name, and every field default fills in.
1451    #[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        // The wire shape stays camelCase/tagged and omits defaulted Nones.
1474        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}