Skip to content

Commit 130afff

Browse files
committed
Filter Windows PTY cursor sequences
Review finding: The Windows Python backend now emits raw ConPTY cursor-control sequences in normal replies, breaking an existing Python backend test and polluting user-visible output. Review comment: - [P1] Strip ConPTY cursor-control bytes from Python output C:\Users\kalin\Documents\GitHub\mcp-repl\src\worker_process.rs:7337-7339 When the Windows PTY path is used, this raw reader forwards VT control sequences emitted by ConPTY (e.g. \u001b[?25l\u001b[15;1H\u001b[?25h) directly into MCP replies; a simple Python reply now includes these bytes and python_backend_runs_inside_mcp_repl_worker fails on Windows. Please filter/suppress the ConPTY screen-control traffic before appending stdout, otherwise Windows Python sessions leak terminal escape codes to clients. Response: Added a Windows PTY output filter that strips ConPTY screen-control CSI sequences before append_raw_text, including split sequences, while preserving SGR sequences. Added regression assertions in python_backend_runs_inside_mcp_repl_worker and unit coverage for split ConPTY cursor sequences.
1 parent 7d3e277 commit 130afff

2 files changed

Lines changed: 110 additions & 1 deletion

File tree

src/worker_process.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7353,6 +7353,86 @@ where
73537353
}))
73547354
}
73557355

7356+
#[cfg(target_family = "windows")]
7357+
#[derive(Default)]
7358+
struct WindowsPtyOutputFilter {
7359+
state: WindowsPtyOutputFilterState,
7360+
pending: Vec<u8>,
7361+
}
7362+
7363+
#[cfg(target_family = "windows")]
7364+
#[derive(Default)]
7365+
enum WindowsPtyOutputFilterState {
7366+
#[default]
7367+
Ground,
7368+
Escape,
7369+
Csi,
7370+
}
7371+
7372+
#[cfg(target_family = "windows")]
7373+
impl WindowsPtyOutputFilter {
7374+
fn filter(&mut self, bytes: &[u8]) -> Vec<u8> {
7375+
let mut output = Vec::with_capacity(bytes.len());
7376+
for &byte in bytes {
7377+
match self.state {
7378+
WindowsPtyOutputFilterState::Ground => {
7379+
if byte == 0x1b {
7380+
self.pending.clear();
7381+
self.pending.push(byte);
7382+
self.state = WindowsPtyOutputFilterState::Escape;
7383+
} else {
7384+
output.push(byte);
7385+
}
7386+
}
7387+
WindowsPtyOutputFilterState::Escape => {
7388+
self.pending.push(byte);
7389+
if byte == b'[' {
7390+
self.state = WindowsPtyOutputFilterState::Csi;
7391+
} else {
7392+
output.extend_from_slice(&self.pending);
7393+
self.pending.clear();
7394+
self.state = WindowsPtyOutputFilterState::Ground;
7395+
}
7396+
}
7397+
WindowsPtyOutputFilterState::Csi => {
7398+
self.pending.push(byte);
7399+
if is_csi_final_byte(byte) {
7400+
if !is_conpty_screen_control_csi(&self.pending) {
7401+
output.extend_from_slice(&self.pending);
7402+
}
7403+
self.pending.clear();
7404+
self.state = WindowsPtyOutputFilterState::Ground;
7405+
} else if self.pending.len() > 128 {
7406+
output.extend_from_slice(&self.pending);
7407+
self.pending.clear();
7408+
self.state = WindowsPtyOutputFilterState::Ground;
7409+
}
7410+
}
7411+
}
7412+
}
7413+
output
7414+
}
7415+
}
7416+
7417+
#[cfg(target_family = "windows")]
7418+
fn is_csi_final_byte(byte: u8) -> bool {
7419+
(0x40..=0x7e).contains(&byte)
7420+
}
7421+
7422+
#[cfg(target_family = "windows")]
7423+
fn is_conpty_screen_control_csi(sequence: &[u8]) -> bool {
7424+
if !sequence.starts_with(b"\x1b[") {
7425+
return false;
7426+
}
7427+
match sequence.last().copied() {
7428+
Some(b'@' | b'A'..=b'K' | b'P' | b'S' | b'T' | b'X' | b'f' | b'r' | b's' | b'u') => true,
7429+
Some(b'h' | b'l') => sequence
7430+
.get(2..sequence.len().saturating_sub(1))
7431+
.is_some_and(|params| params.starts_with(b"?")),
7432+
_ => false,
7433+
}
7434+
}
7435+
73567436
#[cfg(target_family = "windows")]
73577437
fn spawn_blocking_output_reader<R>(
73587438
stream: Option<R>,
@@ -7369,10 +7449,16 @@ where
73697449
let stop_requested = Arc::new(AtomicBool::new(false));
73707450
let handle = thread::spawn(move || {
73717451
let mut buffer = [0u8; 8192];
7452+
let mut filter = WindowsPtyOutputFilter::default();
73727453
loop {
73737454
match stream.read(&mut buffer) {
73747455
Ok(0) => break,
7375-
Ok(n) => live_output.append_raw_text(&buffer[..n], output_stream),
7456+
Ok(n) => {
7457+
let filtered = filter.filter(&buffer[..n]);
7458+
if !filtered.is_empty() {
7459+
live_output.append_raw_text(&filtered, output_stream);
7460+
}
7461+
}
73767462
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
73777463
Err(_) => break,
73787464
}
@@ -8154,6 +8240,25 @@ mod tests {
81548240
(result, kills)
81558241
}
81568242

8243+
#[cfg(target_family = "windows")]
8244+
#[test]
8245+
fn windows_pty_output_filter_strips_split_conpty_cursor_sequences() {
8246+
let mut filter = WindowsPtyOutputFilter::default();
8247+
let mut output = filter.filter(b"\r\nmcp-repl\n\x1b[?25");
8248+
output.extend(filter.filter(b"l\x1b[15;1H\x1b[?25h>>> "));
8249+
8250+
assert_eq!(String::from_utf8(output).unwrap(), "\r\nmcp-repl\n>>> ");
8251+
}
8252+
8253+
#[cfg(target_family = "windows")]
8254+
#[test]
8255+
fn windows_pty_output_filter_preserves_sgr_sequences() {
8256+
let mut filter = WindowsPtyOutputFilter::default();
8257+
let output = filter.filter(b"\x1b[31mred\x1b[0m\n");
8258+
8259+
assert_eq!(String::from_utf8(output).unwrap(), "\x1b[31mred\x1b[0m\n");
8260+
}
8261+
81578262
#[test]
81588263
fn trims_echo_prefix_across_text_chunks() {
81598264
let mut contents = vec![

tests/python_backend.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,10 @@ else:
779779
)
780780
.await?;
781781
let text = result_text(&result);
782+
assert!(
783+
!text.contains('\x1b'),
784+
"did not expect terminal control sequences in a simple Python reply, got: {text:?}"
785+
);
782786
assert!(
783787
text.lines().any(|line| line.trim() == "mcp-repl"),
784788
"expected Python worker process image to be mcp-repl, got: {text:?}"

0 commit comments

Comments
 (0)