Skip to main content

netget/server/pty/
mod.rs

1//! Pseudo-terminal (PTY) server implementation.
2//!
3//! Platform: Unix/Linux/macOS only. The server allocates a PTY with `openpty`, holds the master,
4//! and role-plays a program with a terminal: it reads what a terminal program types on the slave
5//! (`pty_input_received`), and the model decides the bytes that appear on the terminal
6//! (`write_pty_output`). A real client is any terminal program that opens the slave device.
7#![cfg(unix)]
8
9pub mod actions;
10
11use anyhow::{Context, Result};
12use std::ffi::OsStr;
13use std::fs::File;
14use std::io::{Read, Write};
15use std::os::unix::ffi::OsStrExt;
16use std::os::unix::io::{FromRawFd, OwnedFd, RawFd};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use tokio::io::unix::AsyncFd;
20use tokio::sync::mpsc;
21use tracing::{error, info};
22
23use crate::llm::action_helper::call_llm;
24use crate::llm::ollama_client::OllamaClient;
25use crate::llm::ActionResult;
26use crate::logging::emit::Log;
27use crate::protocol::Event;
28use crate::server::pty::actions::{PtyProtocol, PTY_INPUT_RECEIVED_EVENT, PTY_OPENED_EVENT};
29use crate::state::app_state::AppState;
30use crate::utils::WireFailure;
31
32/// Removes the slave symlink this server created when the read task ends — including on abort by
33/// `stop_server`, because aborting drops the task future and with it this guard.
34struct LinkCleanup(Option<PathBuf>);
35
36impl Drop for LinkCleanup {
37    fn drop(&mut self) {
38        if let Some(p) = &self.0 {
39            let _ = std::fs::remove_file(p);
40        }
41    }
42}
43
44/// Resolve the filesystem path of the slave PTY device from its fd, portably (Linux and macOS).
45fn slave_device_path(slave_fd: RawFd) -> Result<PathBuf> {
46    let mut buf = vec![0u8; 256];
47    // ttyname_r returns 0 on success, or an errno value.
48    let rc = unsafe { libc::ttyname_r(slave_fd, buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
49    if rc != 0 {
50        return Err(std::io::Error::from_raw_os_error(rc))
51            .context("ttyname_r failed to resolve slave PTY device path");
52    }
53    let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char) };
54    Ok(PathBuf::from(OsStr::from_bytes(cstr.to_bytes())))
55}
56
57/// Point `link` at `target` as a symlink, refusing to clobber a non-symlink.
58///
59/// `link_path` comes from the model or an MCP caller, so a pre-existing symlink is replaced but a
60/// regular file/dir at that path is refused rather than deleted — the same guard shape as
61/// `socket_file`'s stale-socket handling.
62fn ensure_symlink(link: &Path, target: &Path) -> Result<()> {
63    match std::fs::symlink_metadata(link) {
64        Ok(meta) => {
65            if meta.file_type().is_symlink() {
66                std::fs::remove_file(link)
67                    .with_context(|| format!("Failed to replace existing symlink {:?}", link))?;
68            } else {
69                anyhow::bail!(
70                    "Refusing to use link_path {:?}: it exists and is not a symlink. Delete it \
71                     yourself or pass a different link_path.",
72                    link
73                );
74            }
75        }
76        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
77        Err(e) => return Err(e).with_context(|| format!("Failed to stat {:?}", link)),
78    }
79    std::os::unix::fs::symlink(target, link)
80        .with_context(|| format!("Failed to symlink {:?} -> {:?}", link, target))
81}
82
83/// Put the slave PTY into raw mode: no echo and no canonical line buffering, so the model owns the
84/// exact byte stream in both directions (nothing echoes back to the master, and bytes flow without
85/// waiting for a newline). Uses libc directly (nix is a dev-only dependency here).
86fn set_slave_raw(slave_fd: RawFd) -> Result<()> {
87    unsafe {
88        let mut termios: libc::termios = std::mem::zeroed();
89        if libc::tcgetattr(slave_fd, &mut termios) != 0 {
90            return Err(std::io::Error::last_os_error()).context("tcgetattr on slave PTY failed");
91        }
92        libc::cfmakeraw(&mut termios);
93        if libc::tcsetattr(slave_fd, libc::TCSANOW, &termios) != 0 {
94            return Err(std::io::Error::last_os_error()).context("tcsetattr on slave PTY failed");
95        }
96    }
97    Ok(())
98}
99
100/// Set O_NONBLOCK on `fd` so it can be driven by tokio's `AsyncFd`.
101fn set_nonblocking(fd: RawFd) -> Result<()> {
102    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
103    if flags < 0 {
104        return Err(std::io::Error::last_os_error()).context("fcntl F_GETFL on PTY master failed");
105    }
106    let rc = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
107    if rc < 0 {
108        return Err(std::io::Error::last_os_error()).context("fcntl F_SETFL on PTY master failed");
109    }
110    Ok(())
111}
112
113/// Pseudo-terminal server.
114pub struct PtyServer;
115
116impl PtyServer {
117    /// Allocate the PTY, wire up the master, and spawn the read/dispatch loop.
118    ///
119    /// Awaits readiness (PTY allocated, slave configured, symlink created, master registered) and
120    /// returns `Err` on any failure, so `server_startup` records `ServerStatus::Error`.
121    pub async fn spawn_with_llm_actions(
122        link_path: Option<PathBuf>,
123        send_first: bool,
124        llm_client: OllamaClient,
125        app_state: Arc<AppState>,
126        status_tx: mpsc::UnboundedSender<String>,
127        server_id: crate::state::ServerId,
128    ) -> Result<()> {
129        // Allocate the PTY with libc::openpty (nix is a dev-only dependency in this crate).
130        let (master_raw, slave_raw) = unsafe {
131            let mut master: libc::c_int = -1;
132            let mut slave: libc::c_int = -1;
133            if libc::openpty(
134                &mut master,
135                &mut slave,
136                std::ptr::null_mut(),
137                std::ptr::null_mut(),
138                std::ptr::null_mut(),
139            ) != 0
140            {
141                return Err(std::io::Error::last_os_error()).context("openpty failed");
142            }
143            (master, slave)
144        };
145        // Own both descriptors before any fallible setup. In particular, refusing an
146        // existing regular file at link_path must not leak the already-open master.
147        let master_file = unsafe { File::from_raw_fd(master_raw) };
148        // Own the slave fd so it is closed on drop; holding it open keeps the master from
149        // returning EIO when a terminal client detaches.
150        let slave_fd = unsafe { OwnedFd::from_raw_fd(slave_raw) };
151
152        set_slave_raw(slave_raw)?;
153
154        let slave_path = slave_device_path(slave_raw)?;
155        info!("PTY allocated, slave device {:?}", slave_path);
156
157        if let Some(link) = &link_path {
158            ensure_symlink(link, &slave_path)?;
159            info!("PTY slave symlinked at {:?}", link);
160        }
161        let cleanup = LinkCleanup(link_path.clone());
162
163        // Master fd: make nonblocking and hand to a std File / AsyncFd.
164        set_nonblocking(master_raw)?;
165        let async_master = AsyncFd::new(master_file)
166            .context("Failed to register PTY master with the async runtime")?;
167
168        Log::new(Some(&status_tx)).info(format!(
169            "PTY server ready, slave device {}",
170            slave_path.to_string_lossy()
171        ));
172
173        let protocol = Arc::new(PtyProtocol::new());
174        let task_registrar = app_state.clone();
175
176        let handle = tokio::spawn(async move {
177            // Keep the slave fd open for the whole session: it prevents the master from returning
178            // EIO when a terminal client detaches, so clients can come and go without ending the
179            // server. Moved in here so it drops on abort. The cleanup guard unlinks the symlink.
180            let _slave_fd = slave_fd;
181            let _cleanup = cleanup;
182
183            // send_first: give the model a chance to print a banner/prompt on connect.
184            if send_first {
185                let event = Event::new(
186                    &PTY_OPENED_EVENT,
187                    serde_json::json!({ "slave_path": slave_path.to_string_lossy() }),
188                );
189                Self::dispatch(
190                    &event,
191                    &async_master,
192                    &llm_client,
193                    &app_state,
194                    server_id,
195                    protocol.as_ref(),
196                    &status_tx,
197                )
198                .await;
199            }
200
201            let mut buffer = vec![0u8; 8192];
202            loop {
203                let mut guard = match async_master.readable().await {
204                    Ok(g) => g,
205                    Err(e) => {
206                        error!("PTY master readable() error: {}", e);
207                        break;
208                    }
209                };
210
211                let read_result = guard.try_io(|inner| inner.get_ref().read(&mut buffer));
212
213                let n = match read_result {
214                    Ok(Ok(0)) => {
215                        // With the slave fd held open this should not occur. `clear_ready()` is
216                        // what makes `continue` safe: `try_io` only drops the cached readiness
217                        // when the closure reports WouldBlock, so continuing on any *other*
218                        // outcome leaves readiness set, `readable()` returns instantly and the
219                        // loop pins a core. Clearing it means the next pass really waits on the
220                        // fd.
221                        guard.clear_ready();
222                        continue;
223                    }
224                    Ok(Ok(n)) => n,
225                    Ok(Err(e)) => {
226                        // EIO here means no slave is currently attached; treat other errors as fatal.
227                        if e.raw_os_error() == Some(libc::EIO) {
228                            guard.clear_ready();
229                            continue;
230                        }
231                        error!("PTY master read error: {}", e);
232                        break;
233                    }
234                    Err(_would_block) => continue,
235                };
236
237                let data = &buffer[..n];
238                let (data_str, encoding) = if data
239                    .iter()
240                    .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
241                {
242                    (String::from_utf8_lossy(data).to_string(), "utf8")
243                } else {
244                    (hex::encode(data), "hex")
245                };
246
247                Log::new(Some(&status_tx))
248                    .debug(format!("PTY received {} bytes of input ({})", n, encoding));
249
250                let event = Event::new(
251                    &PTY_INPUT_RECEIVED_EVENT,
252                    serde_json::json!({ "data": data_str, "encoding": encoding }),
253                );
254                Self::dispatch(
255                    &event,
256                    &async_master,
257                    &llm_client,
258                    &app_state,
259                    server_id,
260                    protocol.as_ref(),
261                    &status_tx,
262                )
263                .await;
264            }
265        });
266
267        task_registrar.register_server_task(server_id, handle).await;
268        Ok(())
269    }
270
271    /// Run one LLM round-trip for `event` and write any produced output onto the PTY master.
272    #[allow(clippy::too_many_arguments)]
273    async fn dispatch(
274        event: &Event,
275        async_master: &AsyncFd<File>,
276        llm_client: &OllamaClient,
277        app_state: &Arc<AppState>,
278        server_id: crate::state::ServerId,
279        protocol: &PtyProtocol,
280        status_tx: &mpsc::UnboundedSender<String>,
281    ) {
282        match call_llm(llm_client, app_state, server_id, None, event, protocol).await {
283            Ok(result) => {
284                for msg in result.messages {
285                    let _ = status_tx.send(msg);
286                }
287                let mut wrote = 0usize;
288                for pr in result.protocol_results {
289                    if let ActionResult::Output(bytes) = pr {
290                        wrote += 1;
291                        Self::write_master(async_master, &bytes, status_tx).await;
292                    }
293                    // CloseConnection / WaitForMore are meaningless for a PTY and are ignored.
294                }
295                // "The model deliberately printed nothing" is a legitimate answer on a terminal —
296                // a program need not emit a byte for every keystroke — so nothing is written here.
297                // It is logged with its own tag so it stays distinguishable from an LLM failure.
298                if wrote == 0 {
299                    info!(
300                        "PTY {} decision=model_no_output",
301                        event.event_type.id.as_str()
302                    );
303                }
304            }
305            Err(e) => {
306                // The backend failed. The terminal gets a *category* only; the error itself goes
307                // to the log and the status stream, never onto the wire (see utils/wire_failure).
308                let failure = WireFailure::classify(&e);
309                let decision = if failure.is_overloaded() {
310                    "fail_closed_overloaded"
311                } else {
312                    "fail_closed_llm_error"
313                };
314                error!(
315                    "PTY {} decision={} error={}",
316                    event.event_type.id.as_str(),
317                    decision,
318                    e
319                );
320                Log::new(Some(status_tx))
321                    .error(format!("PTY LLM error (decision={decision}): {e}"));
322                // CRLF on both sides: cfmakeraw clears ONLCR, so a bare LF would staircase on a
323                // real terminal, and the leading CRLF keeps the notice off whatever half-typed
324                // line the user is looking at.
325                let notice = format!("\r\n[{}]\r\n", failure.prefixed_text());
326                Self::write_master(async_master, notice.as_bytes(), status_tx).await;
327            }
328        }
329    }
330
331    /// Write `bytes` to the PTY master (they appear to the terminal program reading the slave).
332    async fn write_master(
333        async_master: &AsyncFd<File>,
334        bytes: &[u8],
335        status_tx: &mpsc::UnboundedSender<String>,
336    ) {
337        let mut written = 0;
338        while written < bytes.len() {
339            let mut guard = match async_master.writable().await {
340                Ok(g) => g,
341                Err(e) => {
342                    Log::new(Some(status_tx)).error(format!("PTY write error: {e}"));
343                    return;
344                }
345            };
346            match guard.try_io(|inner| inner.get_ref().write(&bytes[written..])) {
347                Ok(Ok(0)) => break,
348                Ok(Ok(n)) => written += n,
349                Ok(Err(e)) => {
350                    Log::new(Some(status_tx)).error(format!("PTY write error: {e}"));
351                    return;
352                }
353                Err(_would_block) => continue,
354            }
355        }
356        // FileOnly: the write_pty_output action's own log_template already reports
357        // "-> PTY {data_len}B" to the TUI at INFO.
358        Log::new(Some(status_tx)).debug(format!("PTY wrote {} bytes to terminal", written));
359    }
360}