Skip to main content

zyris_terminal/
lib.rs

1//! The reference implementation of the Zyris `terminal` capability: a real pseudo-terminal.
2//!
3//! [`zyris_caps::Terminal`] is the contract — `open`, `read`, `screen`, `write`, `resize`, `close`
4//! and `exec`. This crate is one implementation of it. [`PtyTerminal`] spawns a shell behind a PTY
5//! with `portable-pty`, keeps that shell's output in a per-session ring buffer, and feeds the same
6//! bytes to a `vt100` screen model — so `read` returns the stream while `screen` renders the grid,
7//! and neither consumes the other.
8//!
9//! ```
10//! use zyris::{Node, NodeKind};
11//! use zyris_caps::TerminalServer;
12//! use zyris_terminal::PtyTerminal;
13//!
14//! let _node = Node::builder()
15//!     .name("workstation")
16//!     .kind(NodeKind::Service)
17//!     .capability(TerminalServer(PtyTerminal::rooted("/srv/work")))
18//!     .build()
19//!     .unwrap();
20//! ```
21//!
22//! Sessions are capped, and one nobody has touched for ten minutes is swept away: an agent stuck
23//! in a loop cannot leave shells running, and neither can a node that lost the wire.
24//!
25//! **The root is a default, not a jail.** It says where a relative path starts and where `exec`
26//! runs; an absolute path still leaves it, because `zyris_caps::resolve_under` resolves rather
27//! than fences. A deployment that needs a fence enforces one above this crate.
28
29mod buffer;
30mod sanitize;
31mod session;
32
33use std::collections::HashMap;
34use std::path::PathBuf;
35use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
36use std::sync::{Arc, Mutex, Weak};
37use std::time::{Duration, Instant};
38
39use bytes::Bytes;
40use zyris::{Blob, ErrorCode, Streaming, WireError};
41
42use zyris_caps::{
43    resolve_under, ExecOutput, PtyChunk, PtyId, PtyOpened, PtyRead, PtyScreen, Settle, Terminal,
44};
45
46use buffer::trim_incomplete_tail;
47use sanitize::{strip_controls, trim_incomplete_escape};
48use session::{gone, Sessions, POLL_INTERVAL};
49
50/// The maximum bytes an `open_stream` subscriber carries in one chunk.
51const STREAM_CHUNK_MAX: usize = 64 * 1024;
52
53/// The maximum bytes a single `read` call returns. Matches `file_io`'s `READ_UNARY_MAX` — the
54/// value measured to clear Attacca's `ZYRIS_MAX_RESULT_BYTES` budget (default 1,000,000).
55const PTY_READ_MAX: usize = 128 * 1024;
56
57/// How many sessions may be open at once. Without a cap, an agent stuck in a loop would spawn
58/// shells without limit.
59const MAX_SESSIONS: usize = 8;
60
61/// How long to wait before closing a session nobody has touched.
62///
63/// This is the point where spec §5.1 decided to cover connection-tracking plumbing with this
64/// instead — if only the wire got cut, nobody calls `read` anymore, so it eventually gets
65/// collected here. The tradeoff is that the shell process stays alive for up to this long after
66/// the disconnect.
67const IDLE_TIMEOUT: Duration = Duration::from_secs(600);
68
69pub struct PtyTerminal {
70    default_shell: String,
71    root: PathBuf,
72    next_id: AtomicU64,
73    sessions: Sessions,
74    idle_timeout: Duration,
75    sweeper_started: AtomicBool,
76}
77
78impl PtyTerminal {
79    pub fn rooted(root: impl Into<PathBuf>) -> Self {
80        PtyTerminal { root: root.into(), ..PtyTerminal::default() }
81    }
82
83    /// Injection point for the idle timeout. A test cannot wait around for 10 minutes.
84    pub fn with_idle_timeout(mut self, d: Duration) -> Self {
85        self.idle_timeout = d;
86        self
87    }
88
89    /// Starts the idle sweeper on the first `open`.
90    ///
91    /// It cannot be started in the constructor — `PtyTerminal::default()` can be called outside a
92    /// tokio runtime, and `tokio::spawn` would panic there. `open` is async, so being inside a
93    /// runtime is guaranteed.
94    ///
95    /// Holds the session map as a `Weak`. Holding it strongly would mean that even after
96    /// `PtyTerminal` is dropped by a `local.declare` re-declaration, the sweeper would keep the map
97    /// alive and the shells would survive with it.
98    fn ensure_sweeper(&self) {
99        if self.sweeper_started.swap(true, Ordering::Relaxed) {
100            return;
101        }
102        let weak: Weak<Mutex<HashMap<String, session::PtySession>>> = Arc::downgrade(&self.sessions);
103        let idle = self.idle_timeout;
104        let tick = (idle / 4).max(Duration::from_millis(10));
105        tokio::spawn(async move {
106            loop {
107                tokio::time::sleep(tick).await;
108                let Some(sessions) = weak.upgrade() else { return };
109                // When a session drops out of the map, `PtySession` is dropped, which closes the
110                // master fd and delivers SIGHUP to the shell on the slave side.
111                sessions.lock().unwrap().retain(|_, s| s.last_touch.elapsed() < idle);
112            }
113        });
114    }
115
116    fn new_session(&self, shell: Option<String>, cols: u16, rows: u16) -> zyris::Result<String> {
117        self.ensure_sweeper();
118        if self.sessions.lock().unwrap().len() >= MAX_SESSIONS {
119            return Err(WireError::new(
120                ErrorCode::Other("too_many_ptys".into()),
121                format!("at most {MAX_SESSIONS} PTYs at a time; close one first"),
122            ));
123        }
124        let id = format!("pty-{}", self.next_id.fetch_add(1, Ordering::Relaxed));
125        session::spawn(
126            &self.sessions,
127            id.clone(),
128            shell.unwrap_or_else(|| self.default_shell.clone()),
129            &self.root,
130            cols,
131            rows,
132        )?;
133        Ok(id)
134    }
135
136    /// Writes `input`, then waits until the output goes quiet.
137    ///
138    /// Returns on whichever of three conditions comes first: quiet is satisfied and the gate is
139    /// open / the deadline is reached / the shell has exited and is quiet.
140    ///
141    /// **The gate is only enforced when `input` is present.** If the input triggers no output at
142    /// all, quiet would be satisfied immediately, and returning an empty response right away would
143    /// make the agent misread that as "the command is finished." Waiting out the deadline to
144    /// confirm there really is nothing is more honest. Conversely, applying the same hold to a
145    /// pure observation with no `input` would mean every poll eats the full `timeout_ms`.
146    ///
147    /// **Limits of the gate**: in cooked mode the terminal echoes typed bytes immediately, so
148    /// "there is something to read" becomes true right away and the gate opens. If the command is
149    /// slow to start producing output, a response containing only the echo can go out first — but
150    /// since `more` is false and `exited` is `None` at that point, the agent can read that as "not
151    /// done yet" and calling again picks up where it left off. Where the gate actually earns its
152    /// keep is raw mode with no echo (feeding keys to vim or htop).
153    async fn settle(
154        &self,
155        pty: &PtyId,
156        input: Option<String>,
157        settle: Option<Settle>,
158    ) -> zyris::Result<()> {
159        let Settle { quiet_ms, timeout_ms } = settle.unwrap_or_default();
160        let gated = input.is_some();
161
162        let baseline = {
163            let mut sessions = self.sessions.lock().unwrap();
164            let s = sessions.get_mut(&pty.0).ok_or_else(gone)?;
165            s.touch();
166            if let Some(text) = input {
167                s.write_input(text.as_bytes())?;
168            }
169            s.buf.total_written()
170        };
171
172        if quiet_ms == 0 {
173            return Ok(());
174        }
175
176        let quiet = Duration::from_millis(quiet_ms);
177        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
178        loop {
179            tokio::time::sleep(POLL_INTERVAL).await;
180            {
181                let sessions = self.sessions.lock().unwrap();
182                let s = sessions.get(&pty.0).ok_or_else(gone)?;
183                let is_quiet = s.last_byte_at.elapsed() >= quiet;
184                // If the shell has exited and is quiet, nothing more is coming — return regardless of the gate.
185                if is_quiet && s.exited.is_some() {
186                    return Ok(());
187                }
188                // The gate looks only at **bytes that arrived after this call started**. Letting
189                // already-pending unread bytes open it would mean a leftover fragment of the
190                // previous call's prompt keeps the gate permanently open, blocking nothing at all.
191                let gate_open = !gated || s.buf.total_written() > baseline;
192                if is_quiet && gate_open {
193                    return Ok(());
194                }
195            }
196            if Instant::now() >= deadline {
197                return Ok(());
198            }
199        }
200    }
201}
202
203/// An `open_stream` subscriber that walks the ring buffer with its own cursor.
204///
205/// It never touches the session's `read_cursor`, so calling `read` while a stream is still
206/// attached does not steal bytes from either side.
207fn subscribe(
208    sessions: Sessions,
209    id: String,
210) -> impl futures_util::Stream<Item = zyris::Result<PtyChunk>> {
211    futures_util::stream::unfold((sessions, id, 0u64), |(sessions, id, mut cursor)| async move {
212        loop {
213            {
214                let guard = sessions.lock().unwrap();
215                let s = guard.get(&id)?;
216                let (bytes, _dropped) = s.buf.read_at(&mut cursor, STREAM_CHUNK_MAX);
217                if !bytes.is_empty() {
218                    let chunk = PtyChunk { data: Blob::from_bytes(Bytes::from(bytes)) };
219                    drop(guard);
220                    return Some((Ok(chunk), (sessions, id, cursor)));
221                }
222                if s.exited.is_some() {
223                    return None;
224                }
225            }
226            tokio::time::sleep(POLL_INTERVAL).await;
227        }
228    })
229}
230
231impl Default for PtyTerminal {
232    fn default() -> Self {
233        let default_shell = if cfg!(windows) {
234            "powershell.exe".to_string()
235        } else {
236            std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
237        };
238        PtyTerminal {
239            default_shell,
240            root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
241            next_id: AtomicU64::new(1),
242            sessions: Arc::new(Mutex::new(HashMap::new())),
243            idle_timeout: IDLE_TIMEOUT,
244            sweeper_started: AtomicBool::new(false),
245        }
246    }
247}
248
249/// The maximum bytes `exec` allows for one output stream. Bytes past this are dropped, and
250/// `ExecOutput::stdout_truncated` / `stderr_truncated` report that it happened.
251///
252/// The point is to stay under Attacca's result budget (`ZYRIS_MAX_RESULT_BYTES`) — 1 MiB is the
253/// safe side, for the same reason as `PTY_READ_MAX`.
254const EXEC_OUTPUT_CAP: usize = 1024 * 1024;
255
256#[derive(Default)]
257struct OutputAcc {
258    buf: Vec<u8>,
259    truncated: bool,
260}
261
262/// Collects a pipe up to `cap`, and keeps reading even past that, discarding whatever overflows.
263///
264/// Stopping the read could make the child block once its pipe fills up. Bytes past the cap are
265/// left out of the result, but the child is still allowed to run to completion.
266async fn drain_capped<R>(mut reader: R, acc: Arc<Mutex<OutputAcc>>, cap: usize)
267where
268    R: tokio::io::AsyncRead + Unpin,
269{
270    use tokio::io::AsyncReadExt;
271
272    let mut chunk = [0u8; 8192];
273    loop {
274        let n = match reader.read(&mut chunk).await {
275            Ok(0) | Err(_) => break,
276            Ok(n) => n,
277        };
278        let mut acc = acc.lock().unwrap();
279        let room = cap.saturating_sub(acc.buf.len());
280        if room > 0 {
281            acc.buf.extend_from_slice(&chunk[..room.min(n)]);
282        }
283        if n > room {
284            acc.truncated = true;
285        }
286    }
287}
288
289/// Kills the process tree.
290///
291/// Unix: since `exec` made `process_group(0)` set the child up as the leader of a new group,
292/// passing that leader's pid as a negative number kills every grandchild the shell left behind in
293/// one shot. Retries briefly, until the group is gone, to cover the setpgid race window (before
294/// the child has created its group yet).
295#[cfg(unix)]
296fn kill_tree(pid: Option<u32>) {
297    use std::thread;
298    use std::time::Duration;
299
300    let Some(pid) = pid else { return };
301    let target = -(pid as i32);
302    for _ in 0..50 {
303        // SAFETY: a negative pid addresses a process group. This is the group we just created,
304        // and our own process is not in it.
305        if unsafe { libc::kill(target, libc::SIGKILL) } == 0 {
306            return; // SIGKILL was delivered to the whole group
307        }
308        // If the leader (the child itself) is already dead, the group will never come into being.
309        if unsafe { libc::kill(pid as i32, 0) } != 0 {
310            return;
311        }
312        thread::sleep(Duration::from_millis(10));
313    }
314}
315
316/// Windows: `taskkill /T` kills the child tree. Killing just cmd.exe would leave grandchildren
317/// orphaned, so a tree kill is needed.
318#[cfg(not(unix))]
319fn kill_tree(pid: Option<u32>) {
320    if let Some(pid) = pid {
321        let _ = std::process::Command::new("taskkill")
322            .args(["/T", "/F", "/PID", &pid.to_string()])
323            .output();
324    }
325}
326
327pub(crate) fn pty_err(msg: impl std::fmt::Display) -> WireError {
328    WireError::new(ErrorCode::Internal, msg.to_string())
329}
330
331#[zyris::async_trait]
332impl Terminal for PtyTerminal {
333    async fn open(&self, shell: Option<String>, cols: u16, rows: u16) -> zyris::Result<PtyOpened> {
334        Ok(PtyOpened { pty: PtyId(self.new_session(shell, cols, rows)?) })
335    }
336
337    async fn open_stream(
338        &self,
339        shell: Option<String>,
340        cols: u16,
341        rows: u16,
342    ) -> zyris::Result<Streaming<PtyOpened, PtyChunk>> {
343        let id = self.new_session(shell, cols, rows)?;
344        let items = subscribe(self.sessions.clone(), id.clone());
345        Ok(Streaming::new(PtyOpened { pty: PtyId(id) }, items))
346    }
347
348    async fn read(
349        &self,
350        pty: PtyId,
351        input: Option<String>,
352        settle: Option<Settle>,
353    ) -> zyris::Result<PtyRead> {
354        self.settle(&pty, input, settle).await?;
355
356        let mut sessions = self.sessions.lock().unwrap();
357        let s = sessions.get_mut(&pty.0).ok_or_else(gone)?;
358        let mut cursor = s.read_cursor;
359        let (bytes, dropped) = s.buf.read_at(&mut cursor, PTY_READ_MAX);
360
361        // Carry a truncated tail over to the next call — whether it's a multibyte character or an
362        // escape sequence. Except if the shell has exited and this is the last of the bytes, there
363        // is no next call to carry it to, so it's just burned.
364        let at_end = s.exited.is_some() && cursor == s.buf.total_written();
365        let keep = if at_end {
366            bytes.len()
367        } else {
368            trim_incomplete_tail(&bytes).min(trim_incomplete_escape(&bytes))
369        };
370        s.read_cursor = cursor - (bytes.len() - keep) as u64;
371
372        Ok(PtyRead {
373            content: strip_controls(&String::from_utf8_lossy(&bytes[..keep])),
374            more: s.read_cursor < s.buf.total_written(),
375            dropped,
376            exited: s.exited,
377        })
378    }
379
380    async fn screen(
381        &self,
382        pty: PtyId,
383        input: Option<String>,
384        settle: Option<Settle>,
385    ) -> zyris::Result<PtyScreen> {
386        self.settle(&pty, input, settle).await?;
387
388        let sessions = self.sessions.lock().unwrap();
389        let s = sessions.get(&pty.0).ok_or_else(gone)?;
390        // **`read_cursor` is left untouched.** The screen is a render of accumulated state, not a consumption of it.
391        let screen = s.parser.screen();
392        let (_, cols) = screen.size();
393        let (cursor_row, cursor_col) = screen.cursor_position();
394        Ok(PtyScreen {
395            lines: screen.rows(0, cols).collect(),
396            cursor_row,
397            cursor_col,
398            exited: s.exited,
399        })
400    }
401
402    async fn write(&self, pty: PtyId, data: Blob) -> zyris::Result<()> {
403        let bytes = match data {
404            Blob::Inline(b) => b,
405            Blob::Attachment(_) => {
406                return Err(WireError::invalid_params("attachment blobs unsupported for pty write"))
407            }
408        };
409        let mut sessions = self.sessions.lock().unwrap();
410        let s = sessions.get_mut(&pty.0).ok_or_else(gone)?;
411        s.touch();
412        s.write_input(&bytes)
413    }
414
415    async fn resize(&self, pty: PtyId, cols: u16, rows: u16) -> zyris::Result<()> {
416        let mut sessions = self.sessions.lock().unwrap();
417        let s = sessions.get_mut(&pty.0).ok_or_else(gone)?;
418        s.touch();
419        s.resize(cols, rows)
420    }
421
422    async fn close(&self, pty: PtyId) -> zyris::Result<()> {
423        self.sessions.lock().unwrap().remove(&pty.0);
424        Ok(())
425    }
426
427    #[allow(clippy::too_many_arguments)]
428    async fn exec(
429        &self,
430        command: Option<String>,
431        argv: Option<Vec<String>>,
432        cwd: Option<String>,
433        timeout_ms: Option<u64>,
434        stdin: Option<String>,
435        env: Option<HashMap<String, String>>,
436        shell: Option<String>,
437    ) -> zyris::Result<ExecOutput> {
438        use std::process::Stdio;
439        use tokio::io::AsyncWriteExt;
440        use tokio::process::Command;
441
442        let argv = match argv {
443            Some(v) if v.is_empty() => {
444                return Err(WireError::invalid_params("argv must not be empty"))
445            }
446            other => other,
447        };
448        let has_command = matches!(command.as_deref(), Some(c) if !c.trim().is_empty());
449        match (has_command, argv.is_some()) {
450            (false, false) => return Err(WireError::invalid_params("give `command` or `argv`, not neither")),
451            (true, true) => return Err(WireError::invalid_params("give `command` or `argv`, not both")),
452            _ => {}
453        }
454        // Windows command mode is always cmd /C, so shell goes unused (PowerShell is chosen via argv).
455        #[cfg(not(unix))]
456        let _ = &shell;
457
458        let mut cmd = match argv {
459            // argv mode: no shell is involved. Arguments go straight to the program, so no
460            // escaping is needed — this is what makes PowerShell's gnarly quoting rules moot.
461            Some(argv) => {
462                let mut c = Command::new(&argv[0]);
463                c.args(&argv[1..]);
464                c
465            }
466            None => {
467                #[cfg(unix)]
468                {
469                    let mut c = Command::new(shell.unwrap_or_else(|| "/bin/sh".to_string()));
470                    c.arg("-c").arg(command.unwrap_or_default());
471                    c
472                }
473                #[cfg(not(unix))]
474                {
475                    // cmd /C has the worst quoting rules, so PowerShell is right to be called via argv instead.
476                    let mut c = Command::new("cmd");
477                    c.arg("/C").arg(command.unwrap_or_default());
478                    c
479                }
480            }
481        };
482
483        cmd.current_dir(match cwd {
484            Some(cwd) => resolve_under(&self.root, &cwd),
485            None => self.root.clone(),
486        });
487        if let Some(env) = env {
488            cmd.envs(env);
489        }
490        cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() });
491        cmd.stdout(Stdio::piped());
492        cmd.stderr(Stdio::piped());
493        // On Unix, make the child the leader of a new process group — so on timeout the whole
494        // tree can be killed (including any grandchildren the shell left behind).
495        #[cfg(unix)]
496        cmd.process_group(0);
497
498        let mut child = cmd.spawn().map_err(pty_err)?;
499        let pid = child.id();
500
501        if let Some(input) = stdin {
502            if let Some(mut sin) = child.stdin.take() {
503                tokio::spawn(async move {
504                    let _ = sin.write_all(input.as_bytes()).await;
505                    let _ = sin.shutdown().await;
506                });
507            }
508        }
509
510        // A separate task collects each output stream up to cap. Even past the cap it **keeps
511        // reading and draining it** — stopping would let the child's pipe fill up and block forever.
512        let stdout = child.stdout.take().expect("stdout was launched as piped");
513        let stderr = child.stderr.take().expect("stderr was launched as piped");
514        let out_acc = Arc::new(Mutex::new(OutputAcc::default()));
515        let err_acc = Arc::new(Mutex::new(OutputAcc::default()));
516        let out_task = tokio::spawn(drain_capped(stdout, out_acc.clone(), EXEC_OUTPUT_CAP));
517        let err_task = tokio::spawn(drain_capped(stderr, err_acc.clone(), EXEC_OUTPUT_CAP));
518
519        let (exit_code, timed_out) = match timeout_ms {
520            Some(ms) => match tokio::time::timeout(Duration::from_millis(ms), child.wait()).await {
521                Ok(status) => (status.map_err(pty_err)?.code().unwrap_or(-1), false),
522                // Timeout: kill and reap the whole tree. The previous implementation just dropped
523                // the child, which left zombies and orphans behind.
524                Err(_) => {
525                    kill_tree(pid);
526                    let _ = child.wait().await;
527                    (-1, true)
528                }
529            },
530            None => (child.wait().await.map_err(pty_err)?.code().unwrap_or(-1), false),
531        };
532
533        // The pipes close, and the reader tasks with them, only once the child dies or exits.
534        let _ = out_task.await;
535        let _ = err_task.await;
536
537        let (stdout, stdout_truncated) = {
538            let acc = out_acc.lock().unwrap();
539            (String::from_utf8_lossy(&acc.buf).into_owned(), acc.truncated)
540        };
541        let (stderr, stderr_truncated) = {
542            let acc = err_acc.lock().unwrap();
543            (String::from_utf8_lossy(&acc.buf).into_owned(), acc.truncated)
544        };
545
546        Ok(ExecOutput { exit_code, stdout, stderr, timed_out, stdout_truncated, stderr_truncated })
547    }
548}