Skip to main content

sail/
shell.rs

1//! Interactive terminal session against a `pty` command in a Sailbox.
2//!
3//! [`Sailbox::shell`](crate::Sailbox::shell) is the high-level entry; the CLI
4//! drives [`run_interactive`] directly for its `--tty` flows. It puts the local
5//! terminal in raw mode, forwards keystrokes (so Ctrl-C/Ctrl-D reach the remote
6//! process as signals), renders the merged output, propagates window resizes,
7//! and restores the terminal on exit. Unix-only: on other platforms the calls
8//! return an unsupported error and the build still succeeds.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use crate::error::{RpcStatus, SailError};
14use crate::exec::ExecOptions;
15use crate::sailbox::object::Sailbox;
16
17/// Options for [`Sailbox::shell`].
18#[derive(Debug, Clone, Default)]
19pub struct ShellOptions {
20    /// Login shell to run when no command is given (default: the guest's
21    /// `$SHELL`, else `/bin/bash`). Ignored when a command is given.
22    pub shell: Option<String>,
23    /// `$TERM` for the remote pty (default: the local `$TERM`).
24    pub term: Option<String>,
25    /// Working directory for the session.
26    pub cwd: Option<String>,
27    /// Wall-clock limit for the session; `None` means no limit.
28    pub timeout: Option<Duration>,
29    /// Turn off all local forwarding for the session (on by default): the
30    /// browser opens and localhost servers, plus paste, drag-and-drop, and
31    /// clipboard bridging. Every byte then passes through verbatim.
32    pub no_forward: bool,
33    /// Turn off forwarding the session's browser opens only, keeping everything
34    /// else forwarded. Ignored when `no_forward` is set.
35    pub no_forward_browser: bool,
36}
37
38/// True when stdin and stdout are both TTYs, required for an interactive PTY.
39#[doc(hidden)]
40pub fn stdio_is_tty() -> bool {
41    use std::io::IsTerminal;
42    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
43}
44
45fn tty_required() -> SailError {
46    SailError::Execution {
47        code: RpcStatus::FailedPrecondition,
48        detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
49            .to_string(),
50    }
51}
52
53impl Sailbox {
54    /// Open an interactive pty session on the Sailbox, driving the local
55    /// terminal. With no `command`, runs a login shell; pass a command to run
56    /// that under a pty instead (e.g. a REPL or an editor). Raw-mode
57    /// keystrokes (including Ctrl-C, Ctrl-Z, and Ctrl-D) reach the remote
58    /// process, its output renders locally, and terminal resizes propagate.
59    /// Blocks until the remote process exits and returns its exit code.
60    /// Requires an interactive local terminal (stdin and stdout TTYs). While
61    /// the session is open, browser opens, localhost servers, paste, and
62    /// drag-and-drop are forwarded to the local machine, and Ctrl+V forwards
63    /// your clipboard (a two-way clipboard on devbox images, upload-and-paste
64    /// elsewhere); see [`ShellOptions::no_forward`].
65    ///
66    /// Runs on the local machine, which must be Unix (it needs Unix TTY and
67    /// signal APIs).
68    ///
69    /// This is the one process-global API in the crate: for the session's
70    /// duration it owns stdin/stdout, switches the terminal to raw mode, and
71    /// installs a signal handler, restoring them when the session ends. The
72    /// bridge runs on a blocking thread, so cancelling this future does not
73    /// end the session; stop it by exiting the remote process.
74    pub async fn shell(
75        &self,
76        command: Option<&str>,
77        options: ShellOptions,
78    ) -> Result<i32, SailError> {
79        if !stdio_is_tty() {
80            return Err(tty_required());
81        }
82        let command = match command {
83            Some(command) => command.to_string(),
84            None => login_shell_command(options.shell.as_deref()),
85        };
86        let (cols, rows) = terminal_size();
87        // An interactive shell forwards the session's localhost servers, browser
88        // opens, and clipboard/paste to the user's machine unless opted out.
89        let (forward_ports, forward_browser, forward_clipboard) =
90            crate::exec::forward_flags(options.no_forward, options.no_forward_browser);
91        let proc = self
92            .client()
93            .exec_shell(
94                self.sailbox_id(),
95                &command,
96                ExecOptions {
97                    timeout: options.timeout,
98                    pty: true,
99                    term: options
100                        .term
101                        .or_else(|| std::env::var("TERM").ok())
102                        .unwrap_or_default(),
103                    cols,
104                    rows,
105                    cwd: options.cwd,
106                    forward_ports,
107                    forward_browser,
108                    forward_clipboard,
109                    ..Default::default()
110                },
111            )
112            .await?;
113        let proc = Arc::new(proc);
114        tokio::task::spawn_blocking(move || run_interactive(proc))
115            .await
116            .map_err(|err| SailError::Internal {
117                message: format!("shell bridge task failed: {err}"),
118            })?
119    }
120}
121
122/// The command for an interactive login session: `exec` the login shell so
123/// `$0` and login semantics match ssh. An explicit shell is quoted so a path
124/// with spaces runs as a literal program; the default stays unquoted so the
125/// guest shell expands `$SHELL`.
126fn login_shell_command(shell: Option<&str>) -> String {
127    match shell {
128        Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
129        None => "exec ${SHELL:-/bin/bash} -l".to_string(),
130    }
131}
132
133/// The local terminal size as (cols, rows), defaulting to 80x24.
134#[cfg(unix)]
135#[doc(hidden)]
136pub fn terminal_size() -> (u32, u32) {
137    let mut size = libc::winsize {
138        ws_row: 0,
139        ws_col: 0,
140        ws_xpixel: 0,
141        ws_ypixel: 0,
142    };
143    let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
144    if ok && size.ws_col > 0 && size.ws_row > 0 {
145        (u32::from(size.ws_col), u32::from(size.ws_row))
146    } else {
147        (80, 24)
148    }
149}
150
151/// The local terminal size as (cols, rows), defaulting to 80x24.
152#[cfg(not(unix))]
153#[doc(hidden)]
154pub fn terminal_size() -> (u32, u32) {
155    (80, 24)
156}
157
158/// Interactive PTY sessions need Unix TTY and signal APIs.
159#[cfg(not(unix))]
160#[doc(hidden)]
161pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
162    Err(SailError::Execution {
163        code: RpcStatus::Unimplemented,
164        detail: "interactive PTY sessions are not supported on this platform".to_string(),
165    })
166}
167
168#[cfg(unix)]
169#[doc(hidden)]
170pub use unix::run_interactive;
171
172#[cfg(unix)]
173#[doc(hidden)]
174pub use unix::{drive_output_pump, RenderControl};
175
176#[cfg(unix)]
177mod unix {
178    use std::collections::{HashMap, HashSet};
179    use std::io::Write;
180    use std::path::PathBuf;
181    use std::sync::atomic::{AtomicBool, Ordering};
182    use std::sync::Arc;
183    use std::thread;
184    use std::time::{Duration, Instant};
185
186    use super::terminal_size;
187    use crate::error::{RpcStatus, SailError};
188    use crate::exec::{ExecProcess, ForwardEvent, OutputStream, ReadStep};
189    use crate::shell_input::{
190        dropped_local_files, find, partial_suffix_len, sanitize_drop_name, InputEvent,
191        InputScanner, PASTE_END, PASTE_START,
192    };
193
194    /// Drive a future to completion from this bridge's dedicated thread. On
195    /// the shared runtime's blocking pool (the [`Sailbox::shell`] path) an
196    /// ambient handle exists and `Handle::block_on` is the correct, safe
197    /// call; on a plain thread (the CLI's direct `run_interactive` use) fall
198    /// back to the crate's shared-runtime `block_on`.
199    fn block_on<F: std::future::Future>(future: F) -> F::Output {
200        match tokio::runtime::Handle::try_current() {
201            Ok(handle) => handle.block_on(future),
202            Err(_) => crate::runtime::block_on(future),
203        }
204    }
205
206    /// Set by the SIGWINCH handler; drained by the input loop to issue a resize.
207    static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
208
209    extern "C" fn on_sigwinch(_signum: libc::c_int) {
210        RESIZE_PENDING.store(true, Ordering::Relaxed);
211    }
212
213    /// Drive the local terminal against a PTY exec until the remote process
214    /// exits, returning its exit code. Raw mode and the SIGWINCH handler are
215    /// always restored, even on error. When the session forwards its clipboard
216    /// (`proc.forward_clipboard()`), dragged files and Ctrl+V pastes forward
217    /// into the guest and in-guest copies mirror back to the local clipboard;
218    /// without it every byte passes through verbatim.
219    pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
220        let saved = enter_raw_mode()?;
221        let prev_winch = install_sigwinch();
222        let prev_in_flags = set_stdin_nonblocking();
223        // Non-blocking stdout so the output pump is never parked in a write to a
224        // slow terminal: it must stay free to notice the ring dropped and repaint.
225        let prev_out_flags = set_stdout_nonblocking();
226
227        // Seed the remote PTY with the current size.
228        let (cols, rows) = terminal_size();
229        block_on(proc.resize(cols, rows));
230
231        let stop = Arc::new(AtomicBool::new(false));
232        let render = Arc::new(RenderControl::default());
233        let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop), Arc::clone(&render));
234        // Browser-open and localhost-port forwarding (guest-gated by the launch
235        // flags); idles harmlessly when the session opted out.
236        let forward = spawn_forward_consumer(Arc::clone(&proc));
237        // The clipboard bridge, both directions, rides the same opt-in as the
238        // clipboard launch flag: mirror in-guest copies onto the local
239        // clipboard, and scan stdin for pastes/drags to send the other way.
240        let forward_clipboard = proc.forward_clipboard();
241        let clipboard = forward_clipboard.then(|| spawn_clipboard_consumer(Arc::clone(&proc)));
242
243        if forward_clipboard {
244            drive_input_forwarding(
245                PasteBridge::new(Arc::clone(&proc), Arc::clone(&render)),
246                &stop,
247            );
248        } else {
249            drive_input(&proc, &stop);
250        }
251
252        // Tear down in reverse order so the terminal is always usable afterwards.
253        let _ = output.join();
254        let _ = forward.join();
255        if let Some(consumer) = clipboard {
256            let _ = consumer.join();
257        }
258        restore_stdout_flags(prev_out_flags);
259        restore_stdin_flags(prev_in_flags);
260        restore_sigwinch(prev_winch);
261        restore_terminal(&saved);
262
263        // A witnessed Exit is the command's real result. When the stream ended
264        // without one, the command did not exit; the box was parked (put to
265        // sleep) or otherwise became unreachable mid-session. Report that instead
266        // of calling wait(), which would block forever on an Exit an interactive
267        // shell never emits; the box's session stays intact for a fresh reconnect.
268        match proc.try_wait() {
269            Some(result) => result,
270            None => Err(SailError::Execution {
271                code: RpcStatus::Unavailable,
272                detail: format!(
273                    "the box became unavailable and the shell session ended; \
274                     reconnect with `sail box shell {}`",
275                    proc.sailbox_id(),
276                ),
277            }),
278        }
279    }
280
281    /// Shared switches between the input side and the output pump: `paused`
282    /// stops the pump writing to the terminal while an upload progress line
283    /// owns it, and `bracketed_paste` tracks whether the guest application has
284    /// paste bracketing (DEC mode 2004) on, so injected pastes are framed the
285    /// way the terminal would frame a real one. Public only because the pump
286    /// is driven directly by integration tests.
287    #[doc(hidden)]
288    #[derive(Default)]
289    pub struct RenderControl {
290        paused: AtomicBool,
291        bracketed_paste: AtomicBool,
292    }
293
294    /// Least time between screen-repaint requests while the local terminal is
295    /// too slow to keep up: without a bound a persistently-behind reader would
296    /// ask on every drop and flood the guest with resync RPCs. Capping repaint
297    /// requests to one per 100 ms is plenty to keep the screen current.
298    const RESYNC_MIN_INTERVAL: Duration = Duration::from_millis(100);
299
300    /// Most backlog the pump buffers toward the terminal before it stops draining
301    /// the ring. Holding the cap small means a slow terminal quickly lets the
302    /// ring back up and drop-oldest, which the reader reports as a drop — the
303    /// signal that triggers a repaint. Larger would just make the terminal crawl
304    /// further through stale frames before recovering.
305    const OUTPUT_PENDING_CAP: usize = 256 * 1024;
306
307    /// Spawn the thread that renders merged PTY output to the terminal, then
308    /// signals stop when the stream ends. The terminal fd is already non-blocking
309    /// (set by [`run_interactive`]).
310    fn spawn_output_pump(
311        proc: Arc<ExecProcess>,
312        stop: Arc<AtomicBool>,
313        render: Arc<RenderControl>,
314    ) -> thread::JoinHandle<()> {
315        thread::spawn(move || {
316            let mut reader = proc.reader(OutputStream::Stdout);
317            let mut sink = RawFdWriter(libc::STDOUT_FILENO);
318            drive_output_pump(&mut reader, &mut sink, &proc, &render);
319            stop.store(true, Ordering::Relaxed);
320        })
321    }
322
323    /// Least time between retries of a port that could not be forwarded because
324    /// its local port was busy. The port watcher only re-reports the guest's
325    /// listeners when the set changes, so this retry covers a local port freeing
326    /// up while the guest server keeps running.
327    const FORWARD_RETRY_INTERVAL: Duration = Duration::from_secs(3);
328
329    /// Spawn the thread that acts on the session's local-forwarding events. It
330    /// drains until the stream ends (the accessor then returns `None`). Active
331    /// port forwards are held for the life of the session and dropped on exit.
332    fn spawn_forward_consumer(proc: Arc<ExecProcess>) -> thread::JoinHandle<()> {
333        thread::spawn(move || {
334            let mut forwards: HashMap<u16, crate::forward::PortForward> = HashMap::new();
335            // Ports whose local port was busy, so the forward could not bind. Kept
336            // so the bind is retried on the interval below in case the local port
337            // frees up.
338            let mut conflicts: HashSet<u16> = HashSet::new();
339            loop {
340                // Wait for the next event, but only until the retry interval when
341                // there are conflicts to re-attempt; otherwise wait indefinitely.
342                let next: Result<Option<ForwardEvent>, tokio::time::error::Elapsed> =
343                    if conflicts.is_empty() {
344                        Ok(block_on(proc.next_forward_event()))
345                    } else {
346                        block_on(async {
347                            tokio::time::timeout(FORWARD_RETRY_INTERVAL, proc.next_forward_event())
348                                .await
349                        })
350                    };
351                match next {
352                    Ok(Some(ForwardEvent::OpenUrl(url))) => {
353                        let open = if !is_openable_scheme(&url) {
354                            // open_local_url only opens http(s); skip building a
355                            // forward for a URL it would refuse anyway.
356                            false
357                        } else if let Some(port) = crate::forward::forwardable_local_port(&url) {
358                            // A URL for a server in the box: forward its port, then
359                            // open the bound local address (rewritten below). If it
360                            // can't be forwarded, don't open it against the user's
361                            // own machine. A login's localhost callback is a server
362                            // too, so the port watcher forwards it the same way.
363                            if ensure_forward(&proc, &mut forwards, &mut conflicts, port) {
364                                true
365                            } else {
366                                notify_local_port_busy(&url, port);
367                                false
368                            }
369                        } else if crate::forward::is_unforwardable_loopback_url(&url) {
370                            // A loopback URL the tunnel can't reach: opening it would
371                            // hit the user's own machine, not the sandbox.
372                            notify_loopback_unreachable(&url);
373                            false
374                        } else if let Some(callback) = crate::forward::redirect_callback(&url) {
375                            // An external login URL whose redirect returns to a
376                            // loopback callback. Don't start a login whose redirect,
377                            // carrying the auth code, would hit the user's machine
378                            // rather than the sandbox.
379                            match callback {
380                                // Open only once the callback port is actually
381                                // forwarded. The snapshot precedes this URL, so a
382                                // listening forwardable callback is already in
383                                // `forwards`; a port not there is one whose local
384                                // port is busy or whose server is not listening on a
385                                // reachable address, and an immediate redirect would
386                                // hit the user's own machine.
387                                crate::forward::RedirectCallback::Forwardable(port)
388                                    if forwards.contains_key(&port) =>
389                                {
390                                    true
391                                }
392                                crate::forward::RedirectCallback::Forwardable(port) => {
393                                    notify_callback_unforwarded(&url, port);
394                                    false
395                                }
396                                // A loopback the tunnel cannot dial at all.
397                                crate::forward::RedirectCallback::Unreachable => {
398                                    notify_callback_unreachable(&url);
399                                    false
400                                }
401                            }
402                        } else {
403                            // An external URL with no localhost callback: open it.
404                            true
405                        };
406                        if open {
407                            let url = crate::forward::rewrite_loopback_url(&url, |remote| {
408                                forwards
409                                    .get(&remote)
410                                    .map(crate::forward::PortForward::local_port)
411                            });
412                            open_local_url(&url);
413                        }
414                    }
415                    Ok(Some(ForwardEvent::PortSnapshot(ports))) => {
416                        // Reconcile against the authoritative set: drop forwards and
417                        // conflicts for servers that are gone, then forward the rest.
418                        let listening: HashSet<u16> = ports.iter().copied().collect();
419                        forwards.retain(|port, _| listening.contains(port));
420                        conflicts.retain(|port| listening.contains(port));
421                        for port in ports {
422                            ensure_forward(&proc, &mut forwards, &mut conflicts, port);
423                        }
424                    }
425                    // The stream ended.
426                    Ok(None) => break,
427                    // No event within the interval: retry any port whose local port
428                    // was busy, in case it has since freed up.
429                    Err(_) => {
430                        for port in conflicts.iter().copied().collect::<Vec<_>>() {
431                            ensure_forward(&proc, &mut forwards, &mut conflicts, port);
432                        }
433                    }
434                }
435            }
436        })
437    }
438
439    /// Forward `port` (guest to the same local port) if it is not already
440    /// forwarded. Returns whether the port is now forwarded. The local port
441    /// always matches the guest port and is never remapped: a login callback
442    /// redirect targets that exact port, so binding elsewhere would send the
443    /// browser to whatever already holds the local port rather than the sandbox.
444    /// A busy local port is recorded in `conflicts` and retried on the interval.
445    fn ensure_forward(
446        proc: &Arc<ExecProcess>,
447        forwards: &mut HashMap<u16, crate::forward::PortForward>,
448        conflicts: &mut HashSet<u16>,
449        port: u16,
450    ) -> bool {
451        if forwards.contains_key(&port) {
452            return true;
453        }
454        if let Ok(forward) = block_on(proc.forward_port(port, port)) {
455            forwards.insert(port, forward);
456            conflicts.remove(&port);
457            true
458        } else {
459            conflicts.insert(port);
460            false
461        }
462    }
463
464    /// Notify that a URL the Sailbox asked to open targets a loopback address the
465    /// sandbox cannot reach, so it was not opened against the user's own machine.
466    fn notify_loopback_unreachable(url: &str) {
467        let notice = format!(
468            "\r\n[sail] not opening {url}: it targets a loopback address the sandbox cannot reach\r\n"
469        );
470        let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
471    }
472
473    /// Notify that a Sailbox server was not opened because its port is already in use
474    /// on the local machine, so the forward could not bind it.
475    fn notify_local_port_busy(url: &str, port: u16) {
476        let notice = format!("\r\n[sail] not opening {url}: local port {port} is in use\r\n");
477        let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
478    }
479
480    /// Whether `open_local_url` would open this URL (it opens only http(s)).
481    fn is_openable_scheme(url: &str) -> bool {
482        url.starts_with("http://") || url.starts_with("https://")
483    }
484
485    /// Notify that a login was not opened because its localhost callback port is
486    /// not forwarded (its local port is busy, or its server is not listening on a
487    /// reachable address), so the provider's redirect could not reach the sandbox.
488    fn notify_callback_unforwarded(url: &str, port: u16) {
489        let notice = format!(
490            "\r\n[sail] not opening {url}: its login callback port {port} is not forwarded\r\n"
491        );
492        let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
493    }
494
495    /// Notify that a login was not opened because its callback is a loopback
496    /// address the sandbox cannot reach, so the redirect would hit the user's
497    /// own machine.
498    fn notify_callback_unreachable(url: &str) {
499        let notice = format!(
500            "\r\n[sail] not opening {url}: its login callback is a loopback address the sandbox cannot reach\r\n"
501        );
502        let _ = RawFdWriter(libc::STDERR_FILENO).write_all(notice.as_bytes());
503    }
504
505    /// Open a URL in the user's local browser, best-effort. Only http(s) URLs
506    /// are opened, so a sandbox process cannot drive arbitrary local handlers.
507    /// The child inherits no terminal, so an opener's own output can't corrupt
508    /// the session. Silent on success: the browser tab appearing is the signal.
509    fn open_local_url(url: &str) {
510        if !is_openable_scheme(url) {
511            return;
512        }
513        let _ = local_browser_command(url)
514            .stdin(std::process::Stdio::null())
515            .stdout(std::process::Stdio::null())
516            .stderr(std::process::Stdio::null())
517            .spawn();
518    }
519
520    /// The platform command that opens a URL in the default browser. This
521    /// module is Unix-only, so the choice is macOS `open` or Linux `xdg-open`.
522    fn local_browser_command(url: &str) -> std::process::Command {
523        let program = if cfg!(target_os = "macos") {
524            "open"
525        } else {
526            "xdg-open"
527        };
528        let mut command = std::process::Command::new(program);
529        command.arg(url);
530        command
531    }
532
533    /// Render one live output stream onto a terminal `sink` until the stream
534    /// ends, favoring a current screen over a faithful replay.
535    ///
536    /// The terminal writer must never block the loop: a slow terminal has to keep
537    /// the pump free to notice the ring dropped and ask the guest to repaint the
538    /// current screen ([`ExecProcess::resync`]). So `sink` is written
539    /// non-blockingly, backlog is held to [`OUTPUT_PENDING_CAP`] so the ring
540    /// backs up and drops-oldest when the terminal falls behind, and a reported
541    /// drop discards the torn backlog and requests a repaint rather than crawling
542    /// the slow terminal through stale frames it will never catch. The command is
543    /// detached on the server, so none of this ever blocks it.
544    ///
545    /// Generic over the sink so the drop-to-repaint behavior is testable against a
546    /// deliberately slow writer without a real terminal.
547    #[doc(hidden)]
548    pub fn drive_output_pump<W: Write>(
549        reader: &mut crate::exec::StreamReader,
550        sink: &mut W,
551        proc: &Arc<ExecProcess>,
552        render: &RenderControl,
553    ) {
554        let mut pending: Vec<u8> = Vec::new();
555        let mut last_resync: Option<Instant> = None;
556        // Hold an observed drop until a repaint is actually requested. resync_due
557        // only fires once per RESYNC_MIN_INTERVAL, so a drop seen during that
558        // cooldown would otherwise be forgotten, leaving the screen showing a
559        // torn, partial frame.
560        let mut resync_pending = false;
561        let mut modes = BracketedPasteTracker::default();
562        loop {
563            // An upload progress line owns the terminal: stop rendering (and
564            // stop draining the ring, which then backs up and drops-oldest just
565            // like a slow terminal — the existing repaint path heals it).
566            if render.paused.load(Ordering::Relaxed) {
567                thread::sleep(Duration::from_millis(5));
568                continue;
569            }
570            // Push as much backlog as the terminal accepts right now, without
571            // blocking on it.
572            let mut flushed = false;
573            if !pending.is_empty() {
574                let written = write_nonblocking(sink, &pending);
575                if written > 0 {
576                    pending.drain(..written);
577                    flushed = true;
578                }
579            }
580            // Refill from the ring, but only up to the cap: leaving the rest in
581            // the ring lets it back up and drop-oldest when the terminal is slow.
582            let mut progressed = false;
583            if pending.len() < OUTPUT_PENDING_CAP {
584                // Don't wait for new data while there is still backlog to push.
585                let wait = if pending.is_empty() {
586                    Duration::from_millis(50)
587                } else {
588                    Duration::ZERO
589                };
590                match reader.next(wait) {
591                    ReadStep::Chunk(bytes) => {
592                        // A Snapshot reset the ring: `bytes` is the repaint, and
593                        // it supersedes the stale backlog buffered toward the
594                        // terminal. Drop that backlog before queuing the repaint
595                        // so the finished screen renders at once instead of stuck
596                        // behind bytes the slow terminal will never finish
597                        // draining (the bounded end-of-stream flush would give up
598                        // before reaching it).
599                        if reader.took_reset() {
600                            pending.clear();
601                        }
602                        modes.scan(&bytes, render);
603                        pending.extend_from_slice(&bytes);
604                        progressed = true;
605                    }
606                    ReadStep::Eof => {
607                        flush_blocking(sink, &pending);
608                        return;
609                    }
610                    ReadStep::Pending => {}
611                }
612                while pending.len() < OUTPUT_PENDING_CAP {
613                    match reader.try_next() {
614                        // Honor a reset here too: the repaint can land in this
615                        // batch drain when the Snapshot arrives after next()
616                        // above already returned a stale chunk this iteration.
617                        Some(more) => {
618                            if reader.took_reset() {
619                                pending.clear();
620                            }
621                            modes.scan(&more, render);
622                            pending.extend_from_slice(&more);
623                        }
624                        None => break,
625                    }
626                }
627            }
628            // The ring evicted output we had not shown: the backlog is now a torn
629            // tail, so drop it and repaint the current screen instead.
630            if reader.took_drop() {
631                pending.clear();
632                resync_pending = true;
633            }
634            if resync_pending && resync_due(&mut last_resync) {
635                resync_pending = false;
636                let handle = Arc::clone(proc);
637                crate::runtime::runtime().spawn(async move { handle.resync().await });
638            }
639            // Yield when no new ring data was read and bytes are still queued,
640            // either because the backlog is at the cap (so the ring can back up
641            // and drop-oldest for a slow terminal) or because the terminal is
642            // back-pressured and accepted nothing (so the loop does not spin).
643            // A terminal actively draining a partial backlog is making progress,
644            // so it keeps looping.
645            if !progressed
646                && !pending.is_empty()
647                && (pending.len() >= OUTPUT_PENDING_CAP || !flushed)
648            {
649                thread::sleep(Duration::from_millis(5));
650            }
651        }
652    }
653
654    /// Write what the terminal will take right now, returning the bytes accepted.
655    /// A full terminal (`WouldBlock`), or any transient error, accepts zero and
656    /// the caller keeps the rest rather than propagating a terminal write error.
657    fn write_nonblocking<W: Write>(sink: &mut W, buf: &[u8]) -> usize {
658        sink.write(buf).unwrap_or(0)
659    }
660
661    /// End of stream: land the final bytes even against a non-blocking terminal,
662    /// but bounded so a wedged terminal cannot hang the exit.
663    fn flush_blocking<W: Write>(sink: &mut W, buf: &[u8]) {
664        let mut off = 0;
665        for _ in 0..2000 {
666            if off >= buf.len() {
667                break;
668            }
669            match sink.write(&buf[off..]) {
670                Ok(0) => thread::sleep(Duration::from_millis(1)),
671                Ok(n) => off += n,
672                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
673                    thread::sleep(Duration::from_millis(1));
674                }
675                Err(_) => break,
676            }
677        }
678        let _ = sink.flush();
679    }
680
681    /// A `Write` over a raw fd. On a non-blocking fd a full pipe surfaces as a
682    /// `WouldBlock` error rather than parking the thread.
683    struct RawFdWriter(libc::c_int);
684
685    impl Write for RawFdWriter {
686        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
687            let n = unsafe { libc::write(self.0, buf.as_ptr().cast(), buf.len()) };
688            if n < 0 {
689                Err(std::io::Error::last_os_error())
690            } else {
691                Ok(n as usize)
692            }
693        }
694
695        fn flush(&mut self) -> std::io::Result<()> {
696            Ok(())
697        }
698    }
699
700    /// Whether enough time has passed since the last repaint request to send
701    /// another, stamping the clock when it returns true.
702    fn resync_due(last: &mut Option<Instant>) -> bool {
703        let now = Instant::now();
704        if last.is_none_or(|t| now.duration_since(t) >= RESYNC_MIN_INTERVAL) {
705            *last = Some(now);
706            true
707        } else {
708            false
709        }
710    }
711
712    /// Forward raw stdin bytes to the guest, draining pending resizes, until the
713    /// output stream ends or local stdin closes.
714    fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
715        let mut buf = [0u8; 4096];
716        let mut stdin_open = true;
717        while !stop.load(Ordering::Relaxed) {
718            if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
719                let (cols, rows) = terminal_size();
720                block_on(proc.resize(cols, rows));
721            }
722            if !stdin_open {
723                thread::sleep(Duration::from_millis(20));
724                continue;
725            }
726            let n = unsafe {
727                libc::read(
728                    libc::STDIN_FILENO,
729                    buf.as_mut_ptr().cast::<libc::c_void>(),
730                    buf.len(),
731                )
732            };
733            match n.cmp(&0) {
734                std::cmp::Ordering::Greater => {
735                    if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
736                        break; // remote closed stdin or exec ended
737                    }
738                }
739                std::cmp::Ordering::Equal => {
740                    // Local stdin reached EOF: send EOF and stop reading it, but
741                    // keep draining output until the remote process exits.
742                    let _ = block_on(proc.close_stdin());
743                    stdin_open = false;
744                }
745                std::cmp::Ordering::Less => {
746                    // A nonblocking read with no data yet (WouldBlock), or one a
747                    // handled signal such as SIGWINCH interrupted (Interrupted),
748                    // is transient: back off briefly and retry rather than ending
749                    // the input loop, which would wedge stdin until the command
750                    // exits.
751                    let err = std::io::Error::last_os_error();
752                    if matches!(
753                        err.kind(),
754                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
755                    ) {
756                        thread::sleep(Duration::from_millis(10));
757                    } else {
758                        break;
759                    }
760                }
761            }
762        }
763    }
764
765    // --- local paste and drag-and-drop forwarding ---
766
767    /// Parent of the per-session directory where forwarded drops and pastes
768    /// land. Each session gets its own subdirectory (see PasteBridge::new) so
769    /// two shells' drops of the same name never collide, and a cancel/failure
770    /// rollback only ever deletes files this session uploaded.
771    const GUEST_DROPS_ROOT: &str = "/tmp/sail-drops";
772    /// Wait this long before drawing the upload progress line: most drops are
773    /// small images that land invisibly fast, and flashing a progress line for
774    /// them would just flicker the screen.
775    const UPLOAD_UI_DELAY: Duration = Duration::from_millis(400);
776    /// Largest content pushed onto the guest clipboard in one RPC (the message
777    /// must fit the transport's 4 MiB frame). Larger images upload as files
778    /// and paste as a guest path instead.
779    const CLIPBOARD_PUSH_MAX: usize = 3 * 1024 * 1024;
780    /// Longest a clipboard push may hold the input thread. The push must
781    /// complete before the Ctrl+V chord is forwarded (or the guest
782    /// application would paste the clipboard's previous content), and the
783    /// input thread is what sequences both, so a slow guest stalls keystroke
784    /// forwarding for the push's duration. This deadline bounds that stall
785    /// well under the RPC's own; on expiry the caller falls back exactly as
786    /// for any other failed push. It must exceed the guest agent's own 3s
787    /// serve-verification budget (saild's guestClipboardCmdTimeout) with
788    /// round-trip slack, or a push the guest completed near its budget would
789    /// be misclassified as failed.
790    const CLIPBOARD_PUSH_DEADLINE: Duration = Duration::from_secs(5);
791    /// Upload stream granularity: small enough for responsive progress, large
792    /// enough that per-message overhead is noise.
793    const UPLOAD_CHUNK_BYTES: usize = 256 * 1024;
794    /// How long an ambiguous escape-sequence prefix waits for its remaining
795    /// bytes before being forwarded as a real keypress. Terminals send
796    /// sequences in one burst, so only a human typing a lone ESC waits this out.
797    const CARRY_FLUSH_AFTER: Duration = Duration::from_millis(25);
798
799    /// Tracks DEC private mode 2004 (bracketed paste) in the guest's output so
800    /// injected pastes are framed exactly as the terminal would frame a real
801    /// one. Snapshot repaints re-assert tracked modes, so the flag survives
802    /// reattach and heals after any dropped chunk.
803    #[derive(Default)]
804    struct BracketedPasteTracker {
805        tail: Vec<u8>,
806    }
807
808    impl BracketedPasteTracker {
809        fn scan(&mut self, bytes: &[u8], render: &RenderControl) {
810            const INTRO: &[u8] = b"\x1b[?";
811            let mut buf = std::mem::take(&mut self.tail);
812            buf.extend_from_slice(bytes);
813            let mut i = 0;
814            while i < buf.len() {
815                let Some(at) = find(&buf[i..], INTRO) else {
816                    break;
817                };
818                let start = i + at;
819                let mut j = start + INTRO.len();
820                while j < buf.len() && (buf[j].is_ascii_digit() || buf[j] == b';') {
821                    j += 1;
822                }
823                let Some(&fin) = buf.get(j) else {
824                    // Split across chunks: carry the partial sequence, bounded —
825                    // a parameter run longer than any real mode list is not one.
826                    if buf.len() - start <= 24 {
827                        self.tail = buf[start..].to_vec();
828                    }
829                    return;
830                };
831                if fin == b'h' || fin == b'l' {
832                    let in_params = buf[start + INTRO.len()..j]
833                        .split(|&b| b == b';')
834                        .any(|param| param == b"2004");
835                    if in_params {
836                        render.bracketed_paste.store(fin == b'h', Ordering::Relaxed);
837                    }
838                }
839                i = j;
840            }
841            let keep = partial_suffix_len(&buf, INTRO);
842            if keep > 0 {
843                self.tail = buf[buf.len() - keep..].to_vec();
844            }
845        }
846    }
847
848    /// Applies guest-clipboard updates to the local clipboard, so a copy made
849    /// inside the guest is pasteable locally. Exits when the stream ends. The
850    /// clipboard handle stays alive for the whole session: on X11 the
851    /// selection lives only as long as the handle that set it.
852    fn spawn_clipboard_consumer(proc: Arc<ExecProcess>) -> thread::JoinHandle<()> {
853        thread::spawn(move || {
854            let mut clipboard: Option<arboard::Clipboard> = None;
855            while let Some((mime, data)) = block_on(proc.next_clipboard_update()) {
856                if mime != "text/plain" {
857                    continue;
858                }
859                let Ok(text) = String::from_utf8(data) else {
860                    continue;
861                };
862                if clipboard.is_none() {
863                    clipboard = arboard::Clipboard::new().ok();
864                }
865                if let Some(clipboard) = clipboard.as_mut() {
866                    let _ = clipboard.set_text(text);
867                }
868            }
869        })
870    }
871
872    /// The forwarding twin of [`drive_input`]: stdin is scanned for bracketed
873    /// pastes and the Ctrl+V chord (see [`crate::shell_input`]), which the
874    /// [`PasteBridge`] turns into uploads, clipboard pushes, or verbatim
875    /// forwards; every other byte passes through untouched.
876    fn drive_input_forwarding(mut bridge: PasteBridge, stop: &AtomicBool) {
877        let mut scanner = InputScanner::new();
878        let mut buf = [0u8; 4096];
879        let mut stdin_open = true;
880        let mut carry_deadline: Option<Instant> = None;
881        while !stop.load(Ordering::Relaxed) {
882            if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
883                let (cols, rows) = terminal_size();
884                block_on(bridge.proc.resize(cols, rows));
885            }
886            if !stdin_open {
887                thread::sleep(Duration::from_millis(20));
888                continue;
889            }
890            // Keystrokes read while an upload owned stdin replay first, in
891            // order. Refresh the idle deadline like the read path: a replayed
892            // bare ESC lands in the scanner carry and must still flush.
893            if !bridge.stashed_input.is_empty() {
894                let stashed = std::mem::take(&mut bridge.stashed_input);
895                if bridge.handle_events(scanner.scan(&stashed)).is_err() {
896                    break;
897                }
898                carry_deadline = scanner
899                    .has_idle_carry()
900                    .then(|| Instant::now() + CARRY_FLUSH_AFTER);
901            }
902            let n = unsafe {
903                libc::read(
904                    libc::STDIN_FILENO,
905                    buf.as_mut_ptr().cast::<libc::c_void>(),
906                    buf.len(),
907                )
908            };
909            match n.cmp(&0) {
910                std::cmp::Ordering::Greater => {
911                    if bridge
912                        .handle_events(scanner.scan(&buf[..n as usize]))
913                        .is_err()
914                    {
915                        break;
916                    }
917                    carry_deadline = scanner
918                        .has_idle_carry()
919                        .then(|| Instant::now() + CARRY_FLUSH_AFTER);
920                }
921                std::cmp::Ordering::Equal => {
922                    // Local stdin reached EOF: flush whatever the scanner
923                    // still held verbatim, then send EOF and keep draining
924                    // output until the remote process exits.
925                    let held = scanner.flush_all();
926                    if !held.is_empty() && bridge.forward(&held).is_err() {
927                        break;
928                    }
929                    let _ = block_on(bridge.proc.close_stdin());
930                    stdin_open = false;
931                }
932                std::cmp::Ordering::Less => {
933                    let err = std::io::Error::last_os_error();
934                    if !matches!(
935                        err.kind(),
936                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
937                    ) {
938                        break;
939                    }
940                    // Idle: a carried escape prefix past its deadline was a
941                    // real keypress (e.g. a lone ESC), so forward it now. A
942                    // mid-paste hold is exempt (has_idle_carry): the paste's
943                    // remaining bytes are still coming.
944                    if carry_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
945                        carry_deadline = None;
946                        if scanner.has_idle_carry() {
947                            let carry = scanner.take_carry();
948                            if !carry.is_empty() && bridge.forward(&carry).is_err() {
949                                break;
950                            }
951                        }
952                    }
953                    thread::sleep(Duration::from_millis(10));
954                }
955            }
956        }
957    }
958
959    /// What the local clipboard holds, in the order pasting cares about:
960    /// copied files, then an image (encoded to PNG), then text.
961    enum LocalClipboard {
962        Files(Vec<PathBuf>),
963        Image(Vec<u8>),
964        Text(String),
965        Empty,
966    }
967
968    fn read_local_clipboard() -> LocalClipboard {
969        let Ok(mut clipboard) = arboard::Clipboard::new() else {
970            return LocalClipboard::Empty;
971        };
972        if let Ok(files) = clipboard.get().file_list() {
973            if !files.is_empty() && files.iter().all(|path| path.is_file()) {
974                return LocalClipboard::Files(files);
975            }
976        }
977        if let Ok(image) = clipboard.get_image() {
978            if let Some(png) = encode_png(&image) {
979                return LocalClipboard::Image(png);
980            }
981        }
982        match clipboard.get_text() {
983            Ok(text) if !text.is_empty() => LocalClipboard::Text(text),
984            _ => LocalClipboard::Empty,
985        }
986    }
987
988    /// Encode arboard's raw RGBA image as PNG, the type both the guest
989    /// clipboard and the coding agents (claude, codex) inside it expect. The
990    /// encoder itself rejects a byte buffer that does not match the
991    /// dimensions.
992    fn encode_png(image: &arboard::ImageData) -> Option<Vec<u8>> {
993        let width = u32::try_from(image.width).ok()?;
994        let height = u32::try_from(image.height).ok()?;
995        let mut out = Vec::new();
996        let mut encoder = png::Encoder::new(&mut out, width, height);
997        encoder.set_color(png::ColorType::Rgba);
998        encoder.set_depth(png::BitDepth::Eight);
999        let mut writer = encoder.write_header().ok()?;
1000        writer.write_image_data(&image.bytes).ok()?;
1001        writer.finish().ok()?;
1002        Some(out)
1003    }
1004
1005    /// How an upload ended: the guest paths to paste, a user cancel (paste
1006    /// nothing), or a failure (the caller falls back to forwarding the
1007    /// original bytes where that makes sense).
1008    enum UploadOutcome {
1009        Done(Vec<String>),
1010        Cancelled,
1011        Failed,
1012    }
1013
1014    /// One file (or in-memory blob) headed for the guest drops directory.
1015    struct UploadSource {
1016        guest_path: String,
1017        size: u64,
1018        data: UploadData,
1019    }
1020
1021    enum UploadData {
1022        File(PathBuf),
1023        Memory(Vec<u8>),
1024    }
1025
1026    /// Turns scanned paste events into guest activity: dragged files upload
1027    /// and paste as guest paths, Ctrl+V forwards the local clipboard (guest
1028    /// clipboard when supported, file upload otherwise), everything else
1029    /// forwards verbatim.
1030    struct PasteBridge {
1031        proc: Arc<ExecProcess>,
1032        render: Arc<RenderControl>,
1033        /// This session's private drop directory under GUEST_DROPS_ROOT, keyed
1034        /// by the exec id so concurrent shells never share a path.
1035        drops_dir: String,
1036        /// Guest file names already used this session, so a re-dropped name
1037        /// gets a numbered variant instead of clobbering a different file.
1038        used_names: std::collections::HashSet<String>,
1039        /// Whether this guest accepts clipboard writes. `None` until the first
1040        /// attempt; latches `Some(false)` on Unimplemented (the guest has no
1041        /// clipboard) so later pastes skip straight to the file fallback.
1042        guest_clipboard: Option<bool>,
1043        /// Keystrokes read while an upload owned stdin (watching for cancel),
1044        /// replayed in order afterwards.
1045        stashed_input: Vec<u8>,
1046    }
1047
1048    impl PasteBridge {
1049        fn new(proc: Arc<ExecProcess>, render: Arc<RenderControl>) -> PasteBridge {
1050            // Name the per-session directory by the hash of the exec id, so it
1051            // is always path-safe, never `.`/`..`, and collision-free whatever
1052            // shape the server's id takes: two concurrent sessions never share
1053            // a path.
1054            use sha2::Digest as _;
1055            use std::fmt::Write as _;
1056            let digest = sha2::Sha256::digest(proc.exec_request_id().as_bytes());
1057            let mut session = String::with_capacity(16);
1058            for byte in &digest[..8] {
1059                let _ = write!(session, "{byte:02x}");
1060            }
1061            let drops_dir = format!("{GUEST_DROPS_ROOT}/{session}");
1062            PasteBridge {
1063                proc,
1064                render,
1065                drops_dir,
1066                used_names: std::collections::HashSet::new(),
1067                guest_clipboard: None,
1068                stashed_input: Vec::new(),
1069            }
1070        }
1071
1072        fn handle_events(&mut self, events: Vec<InputEvent>) -> Result<(), ()> {
1073            for event in events {
1074                match event {
1075                    InputEvent::Bytes(bytes) => self.forward(&bytes)?,
1076                    InputEvent::Paste(body) => self.handle_paste(&body)?,
1077                    InputEvent::PasteChord(chord) => self.handle_chord(&chord)?,
1078                }
1079            }
1080            Ok(())
1081        }
1082
1083        /// Forward bytes to the guest pty. Err means the exec ended and the
1084        /// input loop should stop, matching [`drive_input`].
1085        fn forward(&self, bytes: &[u8]) -> Result<(), ()> {
1086            block_on(self.proc.write_stdin(bytes)).map_err(|_| ())
1087        }
1088
1089        /// A completed bracketed paste: a drag-and-drop of local files uploads
1090        /// them and pastes the guest paths; any other paste (or a failed
1091        /// upload) forwards byte-identically.
1092        fn handle_paste(&mut self, body: &[u8]) -> Result<(), ()> {
1093            if let Some(files) = dropped_local_files(body) {
1094                match self.upload_files(&files) {
1095                    UploadOutcome::Done(paths) => {
1096                        // Keep the trailing separator the drag arrived with,
1097                        // so typing right after the drop stays a separate
1098                        // argument exactly as it would locally.
1099                        let text = format!("{} ", paths.join(" "));
1100                        return self.inject_uploaded(&text, &paths);
1101                    }
1102                    UploadOutcome::Cancelled => return Ok(()),
1103                    UploadOutcome::Failed => {} // fall through to the original paste
1104                }
1105            }
1106            let mut raw = Vec::with_capacity(body.len() + PASTE_START.len() + PASTE_END.len());
1107            raw.extend_from_slice(PASTE_START);
1108            raw.extend_from_slice(body);
1109            raw.extend_from_slice(PASTE_END);
1110            self.forward(&raw)
1111        }
1112
1113        /// A Ctrl+V press: make the local clipboard available in the guest,
1114        /// then (except for uploads that paste a path themselves) deliver the
1115        /// keypress so the guest application reacts to it as usual.
1116        fn handle_chord(&mut self, chord: &[u8]) -> Result<(), ()> {
1117            match read_local_clipboard() {
1118                LocalClipboard::Files(files) => {
1119                    let outcome = self.upload_files(&files);
1120                    self.inject_outcome(outcome)
1121                }
1122                LocalClipboard::Image(png) => {
1123                    if png.len() <= CLIPBOARD_PUSH_MAX && self.push_clipboard("image/png", &png) {
1124                        return self.forward(chord);
1125                    }
1126                    // No guest clipboard took the image (a non-devbox guest, or
1127                    // one over the push cap): upload it and inject the path, the
1128                    // only way a paste-reading agent gets an image here. The
1129                    // chord is deliberately not forwarded — doing so would paste
1130                    // whatever the guest clipboard last held. Unlike text, which
1131                    // is on the clipboard almost always (so the text path never
1132                    // injects, to keep vim visual-block / quoted-insert intact),
1133                    // an image on the clipboard is almost always an intended
1134                    // paste, so injecting wins over preserving a Ctrl+V binding.
1135                    let name = self.reserve_name(std::path::Path::new("clipboard.png"));
1136                    let outcome = self.upload_bytes(name, png);
1137                    self.inject_outcome(outcome)
1138                }
1139                LocalClipboard::Text(text) => {
1140                    if text.len() <= CLIPBOARD_PUSH_MAX {
1141                        // The chord is forwarded whether or not the push
1142                        // lands. Ctrl+V is not only "paste": vim binds it to
1143                        // visual-block and readline to quoted-insert, and text
1144                        // sits on the clipboard almost always, so replacing a
1145                        // failed push with injected text or a file would fire
1146                        // inside those apps constantly. On a guest with no
1147                        // clipboard the app's paste read finds nothing, which
1148                        // is exactly how these sessions behaved before
1149                        // clipboard forwarding existed; terminal-level paste
1150                        // (Cmd+V) remains the text path there.
1151                        self.push_clipboard("text/plain", text.as_bytes());
1152                        return self.forward(chord);
1153                    }
1154                    // Too big for a clipboard push. Forwarding the keypress
1155                    // anyway would paste whatever the guest clipboard last
1156                    // held, so deliver the text as a file like an oversized
1157                    // image.
1158                    let name = self.reserve_name(std::path::Path::new("clipboard.txt"));
1159                    let outcome = self.upload_bytes(name, text.into_bytes());
1160                    self.inject_outcome(outcome)
1161                }
1162                LocalClipboard::Empty => self.forward(chord),
1163            }
1164        }
1165
1166        /// Try to place content on the guest clipboard, reporting success,
1167        /// waiting at most [`CLIPBOARD_PUSH_DEADLINE`]. Unimplemented latches
1168        /// the fallback: this guest has no clipboard (its image ships none,
1169        /// or it predates the feature), and that never changes mid-session.
1170        /// Other failures, the deadline included, just skip the push this
1171        /// time.
1172        fn push_clipboard(&mut self, mime: &str, data: &[u8]) -> bool {
1173            if self.guest_clipboard == Some(false) {
1174                return false;
1175            }
1176            let pushed = block_on(async {
1177                tokio::time::timeout(CLIPBOARD_PUSH_DEADLINE, self.proc.set_clipboard(mime, data))
1178                    .await
1179            });
1180            match pushed {
1181                Ok(Ok(())) => {
1182                    self.guest_clipboard = Some(true);
1183                    true
1184                }
1185                Ok(Err(SailError::Execution {
1186                    code: RpcStatus::Unimplemented,
1187                    ..
1188                })) => {
1189                    self.guest_clipboard = Some(false);
1190                    false
1191                }
1192                Ok(Err(_)) | Err(_) => false,
1193            }
1194        }
1195
1196        /// Paste an upload's guest paths, or forward nothing when the upload was
1197        /// cancelled or failed (the original chord/paste was already handled).
1198        fn inject_outcome(&mut self, outcome: UploadOutcome) -> Result<(), ()> {
1199            match outcome {
1200                UploadOutcome::Done(paths) => self.inject_uploaded(&paths.join(" "), &paths),
1201                UploadOutcome::Cancelled | UploadOutcome::Failed => Ok(()),
1202            }
1203        }
1204
1205        /// Paste the uploaded files' guest paths, deleting the uploads if the
1206        /// session ends before the paste can land, so nothing stays
1207        /// unreferenced in the guest.
1208        fn inject_uploaded(&mut self, text: &str, paths: &[String]) -> Result<(), ()> {
1209            if self.inject(text).is_err() {
1210                let _ = block_on(self.proc.remove_guest_files(paths));
1211                return Err(());
1212            }
1213            Ok(())
1214        }
1215
1216        /// Paste text into the guest exactly as the terminal would: bracketed
1217        /// while the application has mode 2004 on, plain keystrokes otherwise.
1218        fn inject(&self, text: &str) -> Result<(), ()> {
1219            let bracketed = self.render.bracketed_paste.load(Ordering::Relaxed);
1220            let mut bytes = Vec::with_capacity(text.len() + 16);
1221            if bracketed {
1222                bytes.extend_from_slice(PASTE_START);
1223            }
1224            bytes.extend_from_slice(text.as_bytes());
1225            if bracketed {
1226                bytes.extend_from_slice(PASTE_END);
1227            }
1228            self.forward(&bytes)
1229        }
1230
1231        /// Reserve a guest-side name for an upload, numbering repeats
1232        /// (photo.png, photo-2.png, ...) within the session.
1233        fn reserve_name(&mut self, path: &std::path::Path) -> String {
1234            let base = sanitize_drop_name(path);
1235            let mut name = base.clone();
1236            let mut n = 1;
1237            while !self.used_names.insert(name.clone()) {
1238                n += 1;
1239                name = match base.rsplit_once('.') {
1240                    Some((stem, ext)) if !stem.is_empty() => format!("{stem}-{n}.{ext}"),
1241                    _ => format!("{base}-{n}"),
1242                };
1243            }
1244            name
1245        }
1246
1247        fn upload_files(&mut self, files: &[PathBuf]) -> UploadOutcome {
1248            let mut sources = Vec::with_capacity(files.len());
1249            for path in files {
1250                let Ok(meta) = std::fs::metadata(path) else {
1251                    return UploadOutcome::Failed;
1252                };
1253                let name = self.reserve_name(path);
1254                sources.push(UploadSource {
1255                    guest_path: format!("{}/{name}", self.drops_dir),
1256                    size: meta.len(),
1257                    data: UploadData::File(path.clone()),
1258                });
1259            }
1260            self.run_upload(sources)
1261        }
1262
1263        /// Upload one in-memory blob under a name already reserved via
1264        /// [`reserve_name`](Self::reserve_name).
1265        fn upload_bytes(&mut self, name: String, bytes: Vec<u8>) -> UploadOutcome {
1266            self.run_upload(vec![UploadSource {
1267                guest_path: format!("{}/{name}", self.drops_dir),
1268                size: bytes.len() as u64,
1269                data: UploadData::Memory(bytes),
1270            }])
1271        }
1272
1273        /// Stream the sources to the guest while this thread keeps the
1274        /// terminal responsive: a progress line appears for slow uploads, Esc
1275        /// or Ctrl+C cancels (aborting the write streams, which the guest
1276        /// discards uncommitted), and other keystrokes are stashed for replay.
1277        /// Guest output rendering is paused throughout; if anything was drawn
1278        /// over the screen, a resync repaints it from the authoritative guest
1279        /// screen state.
1280        fn run_upload(&mut self, sources: Vec<UploadSource>) -> UploadOutcome {
1281            let total: u64 = sources.iter().map(|s| s.size).sum();
1282            let guest_paths: Vec<String> = sources.iter().map(|s| s.guest_path.clone()).collect();
1283            let label = if sources.len() == 1 {
1284                guest_paths[0]
1285                    .rsplit('/')
1286                    .next()
1287                    .unwrap_or_default()
1288                    .to_string()
1289            } else {
1290                format!("{} files", sources.len())
1291            };
1292            self.render.paused.store(true, Ordering::Relaxed);
1293            let progress = Arc::new(std::sync::atomic::AtomicU64::new(0));
1294            let committed: Arc<std::sync::Mutex<Vec<String>>> =
1295                Arc::new(std::sync::Mutex::new(Vec::new()));
1296            // Spawn where the exec stream lives (see block_on): an embedding
1297            // runtime's channels must not be redialed on the crate's own
1298            // reactor.
1299            let handle = tokio::runtime::Handle::try_current()
1300                .unwrap_or_else(|_| crate::runtime::runtime().handle().clone());
1301            let mut task = handle.spawn(upload_task(
1302                Arc::clone(&self.proc),
1303                sources,
1304                Arc::clone(&progress),
1305                Arc::clone(&committed),
1306            ));
1307            let started = Instant::now();
1308            let mut ui = ProgressLine::new(label, total);
1309            let mut esc_at: Option<Instant> = None;
1310            let outcome = loop {
1311                if task.is_finished() {
1312                    // A bare ESC still inside its disambiguation window was a
1313                    // real keypress after all; replay it with the other
1314                    // stashed input instead of dropping it.
1315                    if esc_at.take().is_some() {
1316                        self.stashed_input.push(0x1b);
1317                    }
1318                    break match block_on(&mut task) {
1319                        Ok(Ok(())) => UploadOutcome::Done(guest_paths.clone()),
1320                        Ok(Err(err)) => {
1321                            self.roll_back_upload(&committed, &guest_paths);
1322                            ui.flash(&format!("[sail] upload failed: {err}"));
1323                            UploadOutcome::Failed
1324                        }
1325                        Err(_) => {
1326                            self.roll_back_upload(&committed, &guest_paths);
1327                            UploadOutcome::Failed
1328                        }
1329                    };
1330                }
1331                if self.poll_cancel(&mut esc_at) {
1332                    // Aborting drops the in-flight writer mid-stream; the
1333                    // guest treats the torn stream as uncommitted and discards
1334                    // it.
1335                    task.abort();
1336                    let _ = block_on(&mut task);
1337                    self.roll_back_upload(&committed, &guest_paths);
1338                    ui.flash("[sail] upload canceled");
1339                    break UploadOutcome::Cancelled;
1340                }
1341                ui.tick(started, progress.load(Ordering::Relaxed));
1342                thread::sleep(Duration::from_millis(30));
1343            };
1344            ui.clear();
1345            self.render.paused.store(false, Ordering::Relaxed);
1346            if ui.wrote {
1347                // The progress line scribbled over the guest's screen; repaint
1348                // it from the guest's authoritative screen state.
1349                block_on(self.proc.resync());
1350            }
1351            outcome
1352        }
1353
1354        /// Undo an upload that will paste nothing (cancelled or failed):
1355        /// delete the files that already committed — nothing points at them,
1356        /// and cancel means the user wants none of the dragged content in the
1357        /// guest — and release the reserved names so a retried drag lands on
1358        /// the same paths. Deletion is best effort: if the guest is unreachable
1359        /// the unreferenced files linger in its /tmp until the Sailbox goes away.
1360        fn roll_back_upload(
1361            &mut self,
1362            committed: &Arc<std::sync::Mutex<Vec<String>>>,
1363            guest_paths: &[String],
1364        ) {
1365            let committed = std::mem::take(&mut *committed.lock().unwrap());
1366            if !committed.is_empty() {
1367                let _ = block_on(self.proc.remove_guest_files(&committed));
1368            }
1369            for path in guest_paths {
1370                if let Some(name) = path.rsplit('/').next() {
1371                    self.used_names.remove(name);
1372                }
1373            }
1374        }
1375
1376        /// Drain stdin during an upload. Ctrl+C cancels at once; a bare ESC
1377        /// cancels after a short pause — long enough for the rest of an escape
1378        /// sequence (an arrow key) to arrive and be stashed instead. Everything
1379        /// else is stashed and replayed after the upload.
1380        fn poll_cancel(&mut self, esc_at: &mut Option<Instant>) -> bool {
1381            let mut buf = [0u8; 256];
1382            loop {
1383                let n = unsafe {
1384                    libc::read(
1385                        libc::STDIN_FILENO,
1386                        buf.as_mut_ptr().cast::<libc::c_void>(),
1387                        buf.len(),
1388                    )
1389                };
1390                if n <= 0 {
1391                    break;
1392                }
1393                let bytes = &buf[..n as usize];
1394                for (idx, &byte) in bytes.iter().enumerate() {
1395                    if byte == 0x03 {
1396                        // Keys typed in the same burst as the cancel replay
1397                        // after the upload settles.
1398                        self.stashed_input.extend_from_slice(&bytes[idx + 1..]);
1399                        return true;
1400                    }
1401                    if esc_at.take().is_some() {
1402                        // The pending ESC was the start of a sequence after all.
1403                        self.stashed_input.push(0x1b);
1404                    }
1405                    if byte == 0x1b && idx == bytes.len() - 1 {
1406                        *esc_at = Some(Instant::now());
1407                    } else {
1408                        self.stashed_input.push(byte);
1409                    }
1410                }
1411            }
1412            // 60ms: longer than one 30ms poll tick of run_upload, so a split
1413            // escape sequence has a whole further poll to finish arriving.
1414            esc_at.is_some_and(|at| at.elapsed() >= Duration::from_millis(60))
1415        }
1416    }
1417
1418    /// The background half of an upload: stream every source into the guest,
1419    /// publishing progress for the interactive thread's UI and each committed
1420    /// guest path for cancel's rollback.
1421    async fn upload_task(
1422        proc: Arc<ExecProcess>,
1423        sources: Vec<UploadSource>,
1424        progress: Arc<std::sync::atomic::AtomicU64>,
1425        committed: Arc<std::sync::Mutex<Vec<String>>>,
1426    ) -> Result<(), SailError> {
1427        use tokio::io::AsyncReadExt;
1428        for source in sources {
1429            let mut writer = proc.guest_file_writer(&source.guest_path);
1430            match source.data {
1431                UploadData::Memory(bytes) => {
1432                    for chunk in bytes.chunks(UPLOAD_CHUNK_BYTES) {
1433                        writer.write_chunk(chunk.to_vec()).await?;
1434                        progress.fetch_add(chunk.len() as u64, Ordering::Relaxed);
1435                    }
1436                }
1437                UploadData::File(path) => {
1438                    let mut file =
1439                        tokio::fs::File::open(&path)
1440                            .await
1441                            .map_err(|err| SailError::Internal {
1442                                message: format!("read {}: {err}", path.display()),
1443                            })?;
1444                    let mut chunk = vec![0u8; UPLOAD_CHUNK_BYTES];
1445                    loop {
1446                        let n = file
1447                            .read(&mut chunk)
1448                            .await
1449                            .map_err(|err| SailError::Internal {
1450                                message: format!("read {}: {err}", path.display()),
1451                            })?;
1452                        if n == 0 {
1453                            break;
1454                        }
1455                        writer.write_chunk(chunk[..n].to_vec()).await?;
1456                        progress.fetch_add(n as u64, Ordering::Relaxed);
1457                    }
1458                }
1459            }
1460            // Record the path before finish()'s await: finish closes the
1461            // client stream, so a cancel that aborts this task during the
1462            // await can still let the guest commit the file. Recording first
1463            // guarantees rollback has the path — an rm of a file that never
1464            // committed is a harmless no-op.
1465            committed.lock().unwrap().push(source.guest_path);
1466            writer.finish().await?;
1467        }
1468        Ok(())
1469    }
1470
1471    /// The one-line upload status drawn at the cursor. It only ever repaints
1472    /// itself in place; whatever it overwrote is restored by the post-upload
1473    /// resync.
1474    struct ProgressLine {
1475        label: String,
1476        total: u64,
1477        wrote: bool,
1478        last_draw: Option<Instant>,
1479    }
1480
1481    impl ProgressLine {
1482        fn new(label: String, total: u64) -> ProgressLine {
1483            ProgressLine {
1484                label,
1485                total,
1486                wrote: false,
1487                last_draw: None,
1488            }
1489        }
1490
1491        fn tick(&mut self, started: Instant, sent: u64) {
1492            if !self.wrote && started.elapsed() < UPLOAD_UI_DELAY {
1493                return;
1494            }
1495            if self
1496                .last_draw
1497                .is_some_and(|last| last.elapsed() < Duration::from_millis(100))
1498            {
1499                return;
1500            }
1501            self.last_draw = Some(Instant::now());
1502            self.wrote = true;
1503            let percent = (sent.min(self.total) * 100)
1504                .checked_div(self.total)
1505                .unwrap_or(100);
1506            write_terminal_line(&format!(
1507                "[sail] uploading {}  {percent}%  {} / {}  (esc cancels)",
1508                self.label,
1509                format_bytes(sent),
1510                format_bytes(self.total),
1511            ));
1512        }
1513
1514        /// Show a final status long enough to read before the screen repaints.
1515        fn flash(&mut self, message: &str) {
1516            self.wrote = true;
1517            write_terminal_line(message);
1518            thread::sleep(Duration::from_millis(1200));
1519        }
1520
1521        fn clear(&mut self) {
1522            if self.wrote {
1523                write_terminal(ERASE_LINE);
1524            }
1525        }
1526    }
1527
1528    /// Carriage return + erase-line: return to column 0 and clear the row, so
1529    /// the next write overwrites the cursor line in place.
1530    const ERASE_LINE: &[u8] = b"\r\x1b[2K";
1531
1532    /// Overwrite the cursor line with `text`.
1533    fn write_terminal_line(text: &str) {
1534        let mut bytes = Vec::with_capacity(text.len() + ERASE_LINE.len());
1535        bytes.extend_from_slice(ERASE_LINE);
1536        bytes.extend_from_slice(text.as_bytes());
1537        write_terminal(&bytes);
1538    }
1539
1540    fn write_terminal(bytes: &[u8]) {
1541        let mut sink = RawFdWriter(libc::STDOUT_FILENO);
1542        flush_blocking(&mut sink, bytes);
1543    }
1544
1545    /// Sizes for the progress line, in the 1000-based MB/KB a file manager
1546    /// labels the dragged file with.
1547    fn format_bytes(n: u64) -> String {
1548        const MB: u64 = 1_000_000;
1549        if n >= 10 * MB {
1550            format!("{} MB", n / MB)
1551        } else if n >= MB {
1552            format!("{:.1} MB", n as f64 / MB as f64)
1553        } else {
1554            format!("{} KB", n.div_ceil(1000))
1555        }
1556    }
1557
1558    // --- platform terminal plumbing ---
1559
1560    /// Put the local terminal into raw mode, returning the saved settings.
1561    fn enter_raw_mode() -> Result<libc::termios, SailError> {
1562        unsafe {
1563            let mut saved: libc::termios = std::mem::zeroed();
1564            if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
1565                return Err(SailError::Internal {
1566                    message: format!(
1567                        "could not enter raw terminal mode: {}",
1568                        std::io::Error::last_os_error()
1569                    ),
1570                });
1571            }
1572            let mut raw = saved;
1573            libc::cfmakeraw(&raw mut raw);
1574            if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
1575                return Err(SailError::Internal {
1576                    message: format!(
1577                        "could not enter raw terminal mode: {}",
1578                        std::io::Error::last_os_error()
1579                    ),
1580                });
1581            }
1582            Ok(saved)
1583        }
1584    }
1585
1586    fn restore_terminal(saved: &libc::termios) {
1587        unsafe {
1588            let _ = libc::tcsetattr(
1589                libc::STDIN_FILENO,
1590                libc::TCSADRAIN,
1591                std::ptr::from_ref(saved),
1592            );
1593        }
1594    }
1595
1596    type SigHandler = libc::sighandler_t;
1597
1598    fn install_sigwinch() -> SigHandler {
1599        // `signal` takes the handler as a numeric `sighandler_t`; cast through a
1600        // concrete fn pointer first so this is a pointer-to-int cast, not a
1601        // fn-item-to-int cast.
1602        let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
1603        unsafe { libc::signal(libc::SIGWINCH, handler) }
1604    }
1605
1606    fn restore_sigwinch(prev: SigHandler) {
1607        unsafe {
1608            libc::signal(libc::SIGWINCH, prev);
1609        }
1610    }
1611
1612    /// Put stdin into non-blocking mode so the input loop can interleave reads
1613    /// with resize handling and the stop flag. Returns the previous fcntl flags.
1614    fn set_stdin_nonblocking() -> libc::c_int {
1615        unsafe {
1616            let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
1617            if flags >= 0 {
1618                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
1619            }
1620            flags
1621        }
1622    }
1623
1624    fn restore_stdin_flags(flags: libc::c_int) {
1625        if flags >= 0 {
1626            unsafe {
1627                libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
1628            }
1629        }
1630    }
1631
1632    /// Put stdout into non-blocking mode so the output pump is never parked in a
1633    /// write to a slow terminal. Returns the previous fcntl flags.
1634    fn set_stdout_nonblocking() -> libc::c_int {
1635        unsafe {
1636            let flags = libc::fcntl(libc::STDOUT_FILENO, libc::F_GETFL);
1637            if flags >= 0 {
1638                libc::fcntl(libc::STDOUT_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
1639            }
1640            flags
1641        }
1642    }
1643
1644    fn restore_stdout_flags(flags: libc::c_int) {
1645        if flags >= 0 {
1646            unsafe {
1647                libc::fcntl(libc::STDOUT_FILENO, libc::F_SETFL, flags);
1648            }
1649        }
1650    }
1651
1652    #[cfg(test)]
1653    mod tests {
1654        use super::*;
1655
1656        #[test]
1657        fn resync_due_throttles_back_to_back_requests() {
1658            let mut last = None;
1659            // The first request is always due and stamps the clock.
1660            assert!(resync_due(&mut last));
1661            // A second request within RESYNC_MIN_INTERVAL is suppressed, so a
1662            // persistently-behind reader cannot flood the guest with resync RPCs.
1663            assert!(!resync_due(&mut last));
1664        }
1665
1666        #[test]
1667        fn format_bytes_matches_its_thousand_based_labels() {
1668            assert_eq!(format_bytes(0), "0 KB");
1669            assert_eq!(format_bytes(1), "1 KB");
1670            assert_eq!(format_bytes(999_999), "1000 KB");
1671            assert_eq!(format_bytes(1_000_000), "1.0 MB");
1672            assert_eq!(format_bytes(3_200_000), "3.2 MB");
1673            assert_eq!(format_bytes(25_000_000), "25 MB");
1674        }
1675    }
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680    use super::*;
1681
1682    #[test]
1683    fn login_shell_quotes_an_explicit_path() {
1684        // A path with spaces runs as one literal program.
1685        assert_eq!(
1686            login_shell_command(Some("/opt/my tools/zsh")),
1687            "exec '/opt/my tools/zsh' -l"
1688        );
1689        // The default stays unquoted so the guest expands $SHELL.
1690        assert_eq!(
1691            login_shell_command(/* shell */ None),
1692            "exec ${SHELL:-/bin/bash} -l"
1693        );
1694    }
1695
1696    #[tokio::test]
1697    async fn shell_requires_a_tty() {
1698        // Test processes have no TTY on stdin/stdout, so the precondition
1699        // fires before any network or terminal manipulation.
1700        let client = crate::Client::builder("sk_test")
1701            .api_url("http://127.0.0.1:1")
1702            .sailbox_api_url("http://127.0.0.1:1")
1703            .build()
1704            .expect("build");
1705        let err = client
1706            .sailbox("sb_test")
1707            .shell(/* command */ None, ShellOptions::default())
1708            .await
1709            .expect_err("no tty in tests");
1710        assert!(err.to_string().contains("interactive terminal"), "{err}");
1711    }
1712}