use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::thread::{self, JoinHandle};
use anyhow::{Context, Result};
use portable_pty::{
Child, CommandBuilder, ExitStatus, MasterPty, NativePtySystem, PtySize, PtySystem,
};
#[cfg(unix)]
pub struct ResizeHandle {
fd: std::os::unix::io::RawFd,
}
#[cfg(unix)]
impl ResizeHandle {
pub fn resize(&self, size: PtySize) -> Result<()> {
let ws = libc::winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
let ret = unsafe { libc::ioctl(self.fd, libc::TIOCSWINSZ, &ws) };
if ret != 0 {
anyhow::bail!(
"TIOCSWINSZ ioctl failed: {}",
std::io::Error::last_os_error()
);
}
Ok(())
}
}
#[cfg(unix)]
impl Drop for ResizeHandle {
fn drop(&mut self) {
unsafe {
libc::close(self.fd);
}
}
}
#[cfg(not(unix))]
pub struct ResizeHandle;
#[cfg(not(unix))]
impl ResizeHandle {
pub fn resize(&self, _size: PtySize) -> Result<()> {
eprintln!("[supervisor::pty] resize not supported on this platform");
Ok(())
}
}
pub struct PtySpawnConfig {
pub program: String,
pub args: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub size: PtySize,
}
impl PtySpawnConfig {
#[allow(dead_code)] pub fn new(program: impl Into<String>, cwd: PathBuf) -> Self {
Self {
program: program.into(),
args: Vec::new(),
cwd,
env: HashMap::new(),
size: PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
}
}
}
pub struct PtySession {
master: Box<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
#[allow(dead_code)] io_threads: Vec<JoinHandle<()>>,
}
impl PtySession {
pub fn spawn(cfg: PtySpawnConfig) -> Result<Self> {
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(cfg.size)
.with_context(|| "openpty: failed to allocate pty pair")?;
let mut cmd = CommandBuilder::new(&cfg.program);
for arg in &cfg.args {
cmd.arg(arg);
}
cmd.cwd(&cfg.cwd);
cmd.env_clear();
for (k, v) in &cfg.env {
cmd.env(k, v);
}
let child = pair
.slave
.spawn_command(cmd)
.with_context(|| format!("spawn_command failed for program: {}", cfg.program))?;
drop(pair.slave);
Ok(Self {
master: pair.master,
child,
io_threads: Vec::new(),
})
}
#[allow(dead_code)] pub fn forward_stdio(&mut self) -> Result<()> {
if !self.io_threads.is_empty() {
anyhow::bail!("forward_stdio called twice on the same PtySession");
}
let mut reader = self
.master
.try_clone_reader()
.context("try_clone_reader: failed to clone pty reader for master→stdout thread")?;
let mut writer = self
.master
.take_writer()
.context("take_writer: failed to take pty writer for stdin→master thread")?;
let out_thread = thread::Builder::new()
.name("pty->stdout".into())
.spawn(move || {
let mut buf = [0u8; 8192];
let stdout = std::io::stdout();
loop {
match reader.read(&mut buf) {
Ok(0) => break, Ok(n) => {
let mut lock = stdout.lock();
if let Err(e) = lock.write_all(&buf[..n]) {
eprintln!("[supervisor::pty] stdout write error: {e}");
break;
}
if let Err(e) = lock.flush() {
eprintln!("[supervisor::pty] stdout flush error: {e}");
break;
}
}
Err(e) => {
eprintln!("[supervisor::pty] master read error: {e}");
break;
}
}
}
})
.context("spawn pty->stdout thread")?;
let in_thread = thread::Builder::new()
.name("stdin->pty".into())
.spawn(move || {
let mut buf = [0u8; 4096];
let stdin = std::io::stdin();
loop {
let mut lock = stdin.lock();
match lock.read(&mut buf) {
Ok(0) => break, Ok(n) => {
if let Err(e) = writer.write_all(&buf[..n]) {
eprintln!("[supervisor::pty] pty write error: {e}");
break;
}
if let Err(e) = writer.flush() {
eprintln!("[supervisor::pty] pty flush error: {e}");
break;
}
}
Err(e) => {
eprintln!("[supervisor::pty] stdin read error: {e}");
break;
}
}
}
})
.context("spawn stdin->pty thread")?;
self.io_threads.push(out_thread);
self.io_threads.push(in_thread);
Ok(())
}
#[allow(dead_code)] pub fn resize(&self, size: PtySize) -> Result<()> {
self.master
.resize(size)
.with_context(|| format!("pty resize to {}x{} failed", size.rows, size.cols))
}
pub fn wait(&mut self) -> Result<ExitStatus> {
self.child.wait().context("child.wait failed")
}
#[allow(dead_code)] pub fn kill(&mut self) -> Result<()> {
self.child.kill().context("child.kill failed")
}
#[cfg(unix)]
pub fn resize_handle(&self) -> Result<ResizeHandle> {
let fd = self.master.as_raw_fd()
.ok_or_else(|| anyhow::anyhow!("master pty does not expose a raw fd for resize"))?;
let duped = unsafe { libc::dup(fd) };
if duped < 0 {
anyhow::bail!("dup(master_fd) failed: {}", std::io::Error::last_os_error());
}
Ok(ResizeHandle { fd: duped })
}
#[cfg(not(unix))]
pub fn resize_handle(&self) -> Result<ResizeHandle> {
Ok(ResizeHandle)
}
pub fn take_writer(&self) -> Result<Box<dyn Write + Send>> {
self.master
.take_writer()
.context("take_writer: failed to take pty writer")
}
pub fn clone_reader(&self) -> Result<Box<dyn Read + Send>> {
self.master
.try_clone_reader()
.context("try_clone_reader: failed to clone pty reader")
}
pub fn process_id(&self) -> Option<u32> {
self.child.process_id()
}
}
pub(crate) struct PtyFilter {
carryover: Vec<u8>,
}
impl PtyFilter {
pub(crate) fn new() -> Self {
Self {
carryover: Vec::new(),
}
}
pub(crate) fn filter(&mut self, input: &[u8], output: &mut Vec<u8>) {
let combined;
let data: &[u8] = if self.carryover.is_empty() {
input
} else {
combined = [self.carryover.as_slice(), input].concat();
self.carryover.clear();
&combined
};
let len = data.len();
let mut i = 0;
while i < len {
if data[i] == 0x1b {
if i + 1 >= len {
self.carryover.extend_from_slice(&data[i..]);
return;
}
if data[i + 1] == b'[' {
let start = i;
i += 2; if i >= len {
self.carryover.extend_from_slice(&data[start..]);
return;
}
let has_question = data[i] == b'?';
let has_gt = data[i] == b'>';
let has_lt = data[i] == b'<';
let no_prefix = !has_question && !has_gt && !has_lt;
if has_question || has_gt || has_lt {
i += 1; }
while i < len && (data[i].is_ascii_digit() || data[i] == b';') {
i += 1;
}
if i >= len {
self.carryover.extend_from_slice(&data[start..]);
return;
}
if data[i].is_ascii_alphabetic() {
let final_byte = data[i];
i += 1; let should_filter = match final_byte {
b'c' if no_prefix => true,
b'n' | b'c' | b'h' | b'l' if has_question => true,
b'c' | b'q' | b'u' | b'm' if has_gt => true,
b'u' if has_lt => true,
_ => false,
};
if should_filter {
continue; }
output.extend_from_slice(&data[start..i]);
continue;
}
output.extend_from_slice(&data[start..i]);
continue;
}
if data[i + 1] == b'P' {
let start = i;
i += 2; loop {
if i >= len {
self.carryover.extend_from_slice(&data[start..]);
return;
}
if data[i] == 0x1b {
if i + 1 >= len {
self.carryover.extend_from_slice(&data[start..]);
return;
}
if data[i + 1] == b'\\' {
i += 2; break;
}
}
i += 1;
}
continue; }
}
output.push(data[i]);
i += 1;
}
}
}
#[cfg(test)]
fn filter_terminal_queries(input: &[u8], output: &mut Vec<u8>) {
PtyFilter::new().filter(input, output);
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
fn write_fake_claude(dir: &TempDir, name: &str, body: &str) -> PathBuf {
let path = dir.path().join(format!("{name}.sh"));
fs::write(&path, body).unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
path
}
fn minimal_env(extra: &[(&str, &str)]) -> HashMap<String, String> {
let mut env = HashMap::new();
env.insert("PATH".to_string(), "/usr/bin:/bin".to_string());
for (k, v) in extra {
env.insert(k.to_string(), v.to_string());
}
env
}
#[test]
fn spawns_and_waits_for_clean_exit() {
let dir = TempDir::new().unwrap();
let script = write_fake_claude(
&dir,
"clean_exit",
"#!/bin/sh\necho hello from fake-claude\nexit 0\n",
);
let cfg = PtySpawnConfig {
program: script.to_string_lossy().into_owned(),
args: vec![],
cwd: dir.path().to_path_buf(),
env: minimal_env(&[]),
size: PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
};
let mut session = PtySession::spawn(cfg).expect("spawn clean_exit");
let status = session.wait().expect("wait clean_exit");
assert!(status.success(), "expected clean exit, got {status:?}");
}
#[test]
fn propagates_nonzero_exit_code() {
let dir = TempDir::new().unwrap();
let script = write_fake_claude(&dir, "crash", "#!/bin/sh\nexit 42\n");
let cfg = PtySpawnConfig {
program: script.to_string_lossy().into_owned(),
args: vec![],
cwd: dir.path().to_path_buf(),
env: minimal_env(&[]),
size: PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
};
let mut session = PtySession::spawn(cfg).expect("spawn crash");
let status = session.wait().expect("wait crash");
assert!(!status.success(), "expected non-success, got {status:?}");
}
#[test]
fn env_is_not_inherited_from_parent() {
unsafe {
std::env::set_var("AGENT_DOC_PTY_PARENT_LEAK", "leaked");
}
let dir = TempDir::new().unwrap();
let script = write_fake_claude(
&dir,
"env_check",
"#!/bin/sh\nif [ -n \"${AGENT_DOC_PTY_PARENT_LEAK:-}\" ]; then exit 99; fi\nexit 0\n",
);
let cfg = PtySpawnConfig {
program: script.to_string_lossy().into_owned(),
args: vec![],
cwd: dir.path().to_path_buf(),
env: minimal_env(&[]),
size: PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
};
let mut session = PtySession::spawn(cfg).expect("spawn env_check");
let status = session.wait().expect("wait env_check");
assert!(
status.success(),
"child saw parent env leak (expected clean env), got {status:?}"
);
}
#[test]
fn cwd_is_set_on_child() {
let dir = TempDir::new().unwrap();
let subdir = dir.path().join("nested");
fs::create_dir(&subdir).unwrap();
let marker = subdir.join("marker.txt");
let script = write_fake_claude(
&dir,
"cwd_check",
"#!/bin/sh\ntouch marker.txt\nexit 0\n",
);
let cfg = PtySpawnConfig {
program: script.to_string_lossy().into_owned(),
args: vec![],
cwd: subdir.clone(),
env: minimal_env(&[]),
size: PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
};
let mut session = PtySession::spawn(cfg).expect("spawn cwd_check");
session.wait().expect("wait cwd_check");
assert!(marker.exists(), "marker not created in requested cwd");
}
#[test]
fn resize_after_spawn_succeeds() {
let dir = TempDir::new().unwrap();
let script = write_fake_claude(&dir, "sleeper", "#!/bin/sh\nsleep 0.3\nexit 0\n");
let cfg = PtySpawnConfig {
program: script.to_string_lossy().into_owned(),
args: vec![],
cwd: dir.path().to_path_buf(),
env: minimal_env(&[]),
size: PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
};
let mut session = PtySession::spawn(cfg).expect("spawn sleeper");
session
.resize(PtySize {
rows: 50,
cols: 120,
pixel_width: 0,
pixel_height: 0,
})
.expect("resize mid-run");
session.wait().expect("wait sleeper");
}
#[test]
fn missing_program_errors_cleanly() {
let dir = TempDir::new().unwrap();
let cfg = PtySpawnConfig {
program: "/nonexistent/path/to/definitely-not-claude".to_string(),
args: vec![],
cwd: dir.path().to_path_buf(),
env: minimal_env(&[]),
size: PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
};
let err = match PtySession::spawn(cfg) {
Ok(_) => panic!("spawn should fail for missing program"),
Err(e) => e,
};
let msg = format!("{err:#}");
assert!(
msg.contains("spawn_command failed") || msg.contains("definitely-not-claude"),
"error should identify the failed program, got: {msg}"
);
}
#[test]
fn filter_strips_da1_query() {
let input = b"\x1b[c";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "DA1 query should be stripped");
}
#[test]
fn filter_strips_da1_query_with_param() {
let input = b"\x1b[0c";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "DA1 query with param should be stripped");
}
#[test]
fn filter_strips_xtversion_query() {
let input = b"\x1b[>q";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "XTVERSION query should be stripped");
}
#[test]
fn filter_strips_dsr_response() {
let input = b"\x1b[?997;1n";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "DSR response should be stripped");
}
#[test]
fn filter_strips_da1_response() {
let input = b"\x1b[?1;2;4c";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "DA1 response should be stripped");
}
#[test]
fn filter_strips_da2_response() {
let input = b"\x1b[>0;115;0c";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "DA2 response should be stripped");
}
#[test]
fn filter_strips_dcs_string() {
let input = b"\x1bP>|tmux 3.6a\x1b\\";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "DCS string should be stripped");
}
#[test]
fn filter_strips_interleaved_sequences() {
let mut input = Vec::new();
input.extend_from_slice(b"\x1b[?997;1n");
input.extend_from_slice(b"\x1bP>|tmux 3.6a\x1b\\");
input.extend_from_slice(b"\x1b[?1;2;4c");
input.extend_from_slice(" Claude Code v2.1.109".as_bytes());
let mut out = Vec::new();
filter_terminal_queries(&input, &mut out);
assert_eq!(
String::from_utf8_lossy(&out),
" Claude Code v2.1.109",
"only the banner text should remain"
);
}
#[test]
fn filter_strips_dec_private_mode_set() {
let input = b"\x1b[?2026h";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "DEC private mode set should be stripped");
}
#[test]
fn filter_strips_dec_private_mode_reset() {
let input = b"\x1b[?2026l";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "DEC private mode reset should be stripped");
}
#[test]
fn filter_strips_kitty_keyboard_push() {
let input = b"\x1b[>1u";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "Kitty keyboard push should be stripped");
}
#[test]
fn filter_strips_kitty_keyboard_pop() {
let input = b"\x1b[<u";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "Kitty keyboard pop should be stripped");
}
#[test]
fn filter_strips_kitty_progressive_enhancement() {
let input = b"\x1b[>4;2m";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert!(out.is_empty(), "Kitty progressive enhancement should be stripped");
}
#[test]
fn filter_preserves_normal_csi() {
let input = b"\x1b[32mhello\x1b[0m";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert_eq!(out, input.to_vec(), "SGR sequences should be preserved");
}
#[test]
fn filter_preserves_cursor_movement() {
let input = b"\x1b[4A";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert_eq!(out, input.to_vec(), "cursor movement should be preserved");
}
#[test]
fn filter_preserves_plain_text() {
let input = b"hello world\n";
let mut out = Vec::new();
filter_terminal_queries(input, &mut out);
assert_eq!(out, input.to_vec(), "plain text should pass through unchanged");
}
#[test]
fn filter_stateful_esc_split_across_reads() {
let mut f = PtyFilter::new();
let mut out = Vec::new();
f.filter(b"hello\x1b", &mut out);
assert_eq!(
String::from_utf8_lossy(&out),
"hello",
"text before ESC emitted, ESC buffered"
);
out.clear();
f.filter(b"P>|tmux 3.6a\x1b\\\x1b[?1;2;4c world", &mut out);
assert_eq!(
String::from_utf8_lossy(&out),
" world",
"DCS and DA1 stripped, trailing text preserved"
);
}
#[test]
fn filter_stateful_dcs_split_at_st() {
let mut f = PtyFilter::new();
let mut out = Vec::new();
f.filter(b"\x1bP>|tmux 3.6a\x1b", &mut out);
assert!(out.is_empty(), "incomplete DCS buffered, nothing emitted");
out.clear();
f.filter(b"\\done", &mut out);
assert_eq!(
String::from_utf8_lossy(&out),
"done",
"DCS consumed after ST completed across boundary"
);
}
#[test]
fn filter_stateful_csi_split_at_params() {
let mut f = PtyFilter::new();
let mut out = Vec::new();
f.filter(b"\x1b[?997", &mut out);
assert!(out.is_empty(), "incomplete CSI buffered");
out.clear();
f.filter(b";1nok", &mut out);
assert_eq!(
String::from_utf8_lossy(&out),
"ok",
"DSR stripped after completion across boundary"
);
}
#[test]
fn filter_stateful_csi_split_at_bracket() {
let mut f = PtyFilter::new();
let mut out = Vec::new();
f.filter(b"a\x1b", &mut out);
assert_eq!(String::from_utf8_lossy(&out), "a");
out.clear();
f.filter(b"[?2026h", &mut out);
assert!(out.is_empty(), "DEC mode set stripped across boundary");
}
#[test]
fn filter_stateful_real_world_banner() {
let mut f = PtyFilter::new();
let full = b"\x1b[?997;1n\x1bP>|tmux 3.6a\x1b\\\x1b[?1;2;4c Claude Code v2.1.109";
let mut out = Vec::new();
f.filter(&full[..5], &mut out); f.filter(&full[5..12], &mut out); f.filter(&full[12..25], &mut out); f.filter(&full[25..], &mut out);
assert_eq!(
String::from_utf8_lossy(&out),
" Claude Code v2.1.109",
"all escape sequences stripped despite arbitrary split points"
);
}
#[test]
fn filter_stateful_normal_esc_preserved_across_boundary() {
let mut f = PtyFilter::new();
let mut out = Vec::new();
f.filter(b"hi\x1b", &mut out);
assert_eq!(String::from_utf8_lossy(&out), "hi");
out.clear();
f.filter(b"[32mgreen\x1b[0m", &mut out);
assert_eq!(out, b"\x1b[32mgreen\x1b[0m", "SGR preserved across boundary");
}
}