diff --git a/Cargo.lock b/Cargo.lock index bdc61d7..03af5b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/agent-tui-integration/Cargo.toml b/crates/agent-tui-integration/Cargo.toml index ace53c7..3d0404c 100644 --- a/crates/agent-tui-integration/Cargo.toml +++ b/crates/agent-tui-integration/Cargo.toml @@ -40,6 +40,11 @@ chrono = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } +[target.'cfg(windows)'.dependencies] +# GetConsoleMode/SetConsoleMode for the dsr_probe test binary's VT-input setup +# (src/bin/dsr_probe.rs). Same windows-sys version the daemon uses. +windows-sys = { version = "0.59", features = ["Win32_System_Console", "Win32_Foundation"] } + [dev-dependencies] # Optional Docker-API features in the dev tier so `cargo test -p # agent-tui-integration --features docker` works without --no-default-features. diff --git a/crates/agent-tui-integration/src/bin/conpty_probe.rs b/crates/agent-tui-integration/src/bin/conpty_probe.rs new file mode 100644 index 0000000..264c929 --- /dev/null +++ b/crates/agent-tui-integration/src/bin/conpty_probe.rs @@ -0,0 +1,144 @@ +//! Windows-only test probes for the ConPTY paths shipped in PR #124. +//! +//! Two modes, used by `tests/windows_conpty.rs`: +//! +//! `conpty_probe dsr` +//! The minimal interactive child that blocks on the terminal's answer to the +//! startup DSR (`ESC[6n` → `ESC[;R`). Enables VT input on its own +//! console (so the reply arrives as raw bytes, the way a real TUI reads it), +//! emits the query, reads the reply, and prints it as `DSR-REPLY:` +//! with `DSR-READ:` diagnostics. Without the daemon's `take_pty_writes` +//! write-back the read never produces the reply. +//! +//! `conpty_probe read-stdin` +//! Reads up to 16 stdin bytes (stopping after `0x03`) and prints them as +//! `STDIN-HEX:`. Proves the daemon's ConPTY-input write path +//! (`signal SIGINT` writes ETX) end to end, independent of conhost's +//! mode-dependent ETX → `CTRL_C_EVENT` translation. +//! +//! Built as a bin of the integration crate so `cargo test` places it next to +//! `agent-tui.exe` under `target/debug/` on the Windows CI leg. On non-Windows +//! it compiles to an inert stub so the workspace builds everywhere. + +#![deny(unsafe_code)] + +#[cfg(windows)] +mod imp { + use std::fmt::Write as _; + use std::io::{Read, Write}; + + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Console::{ + GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, SetConsoleMode, + }; + + /// `ENABLE_VIRTUAL_TERMINAL_INPUT` (0x0200): deliver VT sequences — including + /// terminal replies and ETX — as raw input bytes instead of cooked key + /// events or processed-input control events. + const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200; + + /// Enable VT input on our own console. Returns false (with a printed + /// diagnostic) if any step fails — on a ConPTY client these handles are + /// console handles, so failure here is itself interesting test data. + fn enable_vt_input() -> bool { + // SAFETY: standard Win32 console calls on our own process's stdin + // handle; the returned handle is borrowed, never closed here. + #[allow(unsafe_code)] + unsafe { + let h: HANDLE = GetStdHandle(STD_INPUT_HANDLE); + if h.is_null() { + println!("MODE-SET:fail:null-handle"); + return false; + } + let mut mode: u32 = 0; + if GetConsoleMode(h, &raw mut mode) == 0 { + println!("MODE-SET:fail:get-mode:{}", std::io::Error::last_os_error()); + return false; + } + if SetConsoleMode(h, ENABLE_VIRTUAL_TERMINAL_INPUT) == 0 { + println!("MODE-SET:fail:set-mode:{}", std::io::Error::last_os_error()); + return false; + } + println!("MODE-SET:ok:was-{mode:#x}"); + true + } + } + + /// Read up to `buf.len()` bytes, stopping early at `stop` or EOF/error. + /// Returns (bytes-read, status string) for diagnostics. + fn read_until(buf: &mut [u8], stop: u8) -> (usize, String) { + let mut n = 0usize; + let stdin = std::io::stdin(); + let mut lock = stdin.lock(); + while n < buf.len() { + match lock.read(&mut buf[n..=n]) { + Ok(0) => return (n, "eof".to_string()), + Ok(m) => { + n += m; + if buf[n - 1] == stop { + return (n, "stop-byte".to_string()); + } + } + Err(e) => return (n, format!("err:{e}")), + } + } + (n, "full".to_string()) + } + + fn dsr() -> i32 { + if !enable_vt_input() { + return 2; + } + print!("\x1b[6n"); + let _ = std::io::stdout().flush(); + let mut buf = [0u8; 64]; + let (n, status) = read_until(&mut buf, b'R'); + println!("DSR-READ:n={n}:status={status}"); + // Print the reply as HEX, not raw: the reply bytes are themselves a VT + // sequence (ESC[;R) and the pane's own terminal parser + // swallows them — which is exactly what hid a *successful* reply on + // the first CI iteration (n=6, stop-byte, empty text). + let mut hex = String::with_capacity(n * 2); + for b in &buf[..n] { + let _ = write!(hex, "{b:02x}"); + } + println!("DSR-REPLY-HEX:{hex}"); + 0 + } + + fn read_stdin() -> i32 { + if !enable_vt_input() { + return 2; + } + let mut buf = [0u8; 16]; + let (n, status) = read_until(&mut buf, 0x03); + let mut hex = String::with_capacity(n * 2); + for b in &buf[..n] { + let _ = write!(hex, "{b:02x}"); + } + println!("STDIN-READ:n={n}:status={status}"); + println!("STDIN-HEX:{hex}"); + 0 + } + + pub(crate) fn run() -> i32 { + match std::env::args().nth(1).as_deref() { + Some("dsr") => dsr(), + Some("read-stdin") => read_stdin(), + other => { + eprintln!("usage: conpty_probe , got {other:?}"); + 2 + } + } + } +} + +fn main() { + #[cfg(windows)] + std::process::exit(imp::run()); + #[cfg(not(windows))] + { + eprintln!("conpty_probe is a Windows-only test probe"); + std::process::exit(2); + } +} diff --git a/crates/agent-tui-integration/tests/windows_conpty.rs b/crates/agent-tui-integration/tests/windows_conpty.rs new file mode 100644 index 0000000..00f8d4d --- /dev/null +++ b/crates/agent-tui-integration/tests/windows_conpty.rs @@ -0,0 +1,472 @@ +//! Windows ConPTY end-to-end coverage for the parity work shipped in PR #124. +//! +//! These tests are `#[cfg(windows)]` with NO Cargo-feature gate, so the stock +//! `cargo test --workspace` CI leg on `windows-latest` runs them (the +//! docker/bwrap suites are feature-gated and stay Linux-only). Each test gets +//! an isolated session + socket dir, so the file is safe under cargo's +//! default parallel test threads, and every rig ties its daemon's lifetime to +//! the test process (`AGENT_TUI_MONITOR_PARENT_PID`) plus a best-effort +//! `daemon shutdown` on drop. +//! +//! Covered (each maps to a fix in #124): +//! 1. `run | consumer` in a `cmd.exe` pipeline sees prompt EOF (the `win_spawn` +//! handle-inheritance allow-list). +//! 2. An interactive pane's startup DSR (`ESC[6n`) is answered, unblocking +//! the child (engine `take_pty_writes` write-back via `conpty_probe`). +//! 3. `tail --follow` delivers a fast-exiting child's trailing bytes before +//! the stream ends (`exit_drained` / `DRAIN_GRACE`). +//! 4. `die` reaps the whole descendant tree (`taskkill /F /T`). +//! 5. `signal SIGINT` delivers ETX to the child's ConPTY input. +//! 6. `signal SIGBREAK` is rejected with an actionable error (no false +//! success). + +#![cfg(windows)] + +use std::future::Future; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use agent_tui_integration::{agent_tui_binary, workspace_root}; +use anyhow::{Context, Result, bail}; + +/// Isolated per-test daemon context: unique session, private socket dir and +/// state home under %TEMP%. Drop shuts the daemon down best-effort and +/// removes the directory. +struct Rig { + session: String, + root: PathBuf, + socket_dir: PathBuf, + state_home: PathBuf, +} + +impl Rig { + fn new(name: &str) -> Result { + let id = uuid::Uuid::new_v4().simple().to_string(); + let root = std::env::temp_dir().join(format!("agent-tui-win-e2e-{name}-{}", &id[..8])); + let socket_dir = root.join("sock"); + let state_home = root.join("state"); + std::fs::create_dir_all(&socket_dir) + .with_context(|| format!("create {}", socket_dir.display()))?; + std::fs::create_dir_all(&state_home) + .with_context(|| format!("create {}", state_home.display()))?; + Ok(Self { + session: format!("e2e-{}", &id[..8]), + root, + socket_dir, + state_home, + }) + } + + /// Per-rig env every child process needs (CLI invocations and the + /// cmd.exe pipeline repro alike). + fn apply_env(&self, cmd: &mut tokio::process::Command) { + cmd.env("AGENT_TUI_SOCKET_DIR", &self.socket_dir) + .env("AGENT_TUI_ALLOWED_BINARIES", "*") + // Tie any lazy-spawned daemon's lifetime to this test process so + // a panicking or SIGKILLed test runner doesn't orphan a daemon. + .env( + "AGENT_TUI_MONITOR_PARENT_PID", + std::process::id().to_string(), + ) + .env("XDG_STATE_HOME", &self.state_home) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } + + /// Base command with the per-rig env every invocation needs. `json` + /// selects the global `--json` flag; text surfaces (`tail`, streaming + /// verbs) are asserted on raw stdout instead. + fn cli(&self, json: bool) -> tokio::process::Command { + let bin = agent_tui_binary().expect("agent-tui binary built by cargo test"); + let mut cmd = tokio::process::Command::new(bin); + cmd.arg("--session") + .arg(&self.session) + .arg("--socket-dir") + .arg(&self.socket_dir); + if json { + cmd.arg("--json"); + } + self.apply_env(&mut cmd); + cmd + } + + async fn output(&self, json: bool, args: &[&str]) -> Result { + let mut cmd = self.cli(json); + Ok(cmd.args(args).output().await?) + } + + /// `cmd.exe /d /c