1use std::sync::Arc;
15use std::time::Duration;
16
17use crate::error::{GrpcCode, SailError};
18use crate::exec::ExecOptions;
19use crate::sailbox::object::Sailbox;
20
21#[derive(Debug, Clone, Default)]
23pub struct ShellOptions {
24 pub shell: Option<String>,
27 pub term: Option<String>,
29 pub cwd: Option<String>,
31 pub timeout: Option<Duration>,
33}
34
35pub fn stdio_is_tty() -> bool {
37 use std::io::IsTerminal;
38 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
39}
40
41fn tty_required() -> SailError {
42 SailError::Execution {
43 code: GrpcCode::FailedPrecondition,
44 detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
45 .to_string(),
46 }
47}
48
49impl Sailbox {
50 pub async fn shell(
58 &self,
59 command: Option<&str>,
60 options: ShellOptions,
61 ) -> Result<i32, SailError> {
62 if !stdio_is_tty() {
63 return Err(tty_required());
64 }
65 let command = match command {
66 Some(command) => command.to_string(),
67 None => login_shell_command(options.shell.as_deref()),
68 };
69 let (cols, rows) = terminal_size();
70 let proc = self
71 .client()
72 .exec_shell(
73 self.sailbox_id(),
74 &command,
75 ExecOptions {
76 timeout: options.timeout,
77 pty: true,
78 term: options
79 .term
80 .or_else(|| std::env::var("TERM").ok())
81 .unwrap_or_default(),
82 cols,
83 rows,
84 cwd: options.cwd,
85 ..Default::default()
86 },
87 )
88 .await?;
89 let proc = Arc::new(proc);
90 tokio::task::spawn_blocking(move || run_interactive(proc))
91 .await
92 .map_err(|err| SailError::Internal {
93 message: format!("shell bridge task failed: {err}"),
94 })?
95 }
96}
97
98fn login_shell_command(shell: Option<&str>) -> String {
103 match shell {
104 Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
105 None => "exec ${SHELL:-/bin/bash} -l".to_string(),
106 }
107}
108
109#[cfg(unix)]
111pub fn terminal_size() -> (u32, u32) {
112 let mut size = libc::winsize {
113 ws_row: 0,
114 ws_col: 0,
115 ws_xpixel: 0,
116 ws_ypixel: 0,
117 };
118 let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
119 if ok && size.ws_col > 0 && size.ws_row > 0 {
120 (u32::from(size.ws_col), u32::from(size.ws_row))
121 } else {
122 (80, 24)
123 }
124}
125
126#[cfg(not(unix))]
128pub fn terminal_size() -> (u32, u32) {
129 (80, 24)
130}
131
132#[cfg(not(unix))]
134pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
135 Err(SailError::Execution {
136 code: GrpcCode::Unimplemented,
137 detail: "interactive PTY sessions are not supported on this platform".to_string(),
138 })
139}
140
141#[cfg(unix)]
142pub use unix::run_interactive;
143
144#[cfg(unix)]
145mod unix {
146 use std::io::Write;
147 use std::sync::atomic::{AtomicBool, Ordering};
148 use std::sync::Arc;
149 use std::thread;
150 use std::time::Duration;
151
152 use super::terminal_size;
153 use crate::error::SailError;
154 use crate::exec::{ExecProcess, OutputStream, ReadStep};
155
156 fn block_on<F: std::future::Future>(future: F) -> F::Output {
162 match tokio::runtime::Handle::try_current() {
163 Ok(handle) => handle.block_on(future),
164 Err(_) => crate::runtime::block_on(future),
165 }
166 }
167
168 static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
170
171 extern "C" fn on_sigwinch(_signum: libc::c_int) {
172 RESIZE_PENDING.store(true, Ordering::Relaxed);
173 }
174
175 pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
179 let saved = enter_raw_mode()?;
180 let prev_winch = install_sigwinch();
181 let prev_flags = set_stdin_nonblocking();
182
183 let (cols, rows) = terminal_size();
185 block_on(proc.resize(cols, rows));
186
187 let stop = Arc::new(AtomicBool::new(false));
188 let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop));
189
190 drive_input(&proc, &stop);
191
192 let _ = output.join();
194 restore_stdin_flags(prev_flags);
195 restore_sigwinch(prev_winch);
196 restore_terminal(&saved);
197
198 let exit = block_on(proc.wait())?;
199 Ok(exit.exit_code)
200 }
201
202 fn spawn_output_pump(proc: Arc<ExecProcess>, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
204 thread::spawn(move || {
205 let mut reader = proc.reader(OutputStream::Stdout);
206 let mut stdout = std::io::stdout();
207 loop {
208 match reader.next(Duration::from_millis(100)) {
209 ReadStep::Chunk(text) => {
210 let _ = stdout.write_all(text.as_bytes());
211 let _ = stdout.flush();
212 }
213 ReadStep::Eof => break,
214 ReadStep::Pending => {}
215 }
216 }
217 stop.store(true, Ordering::Relaxed);
218 })
219 }
220
221 fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
224 let mut buf = [0u8; 4096];
225 let mut stdin_open = true;
226 while !stop.load(Ordering::Relaxed) {
227 if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
228 let (cols, rows) = terminal_size();
229 block_on(proc.resize(cols, rows));
230 }
231 if !stdin_open {
232 thread::sleep(Duration::from_millis(20));
233 continue;
234 }
235 let n = unsafe {
236 libc::read(
237 libc::STDIN_FILENO,
238 buf.as_mut_ptr().cast::<libc::c_void>(),
239 buf.len(),
240 )
241 };
242 match n.cmp(&0) {
243 std::cmp::Ordering::Greater => {
244 if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
245 break; }
247 }
248 std::cmp::Ordering::Equal => {
249 let _ = block_on(proc.close_stdin());
252 stdin_open = false;
253 }
254 std::cmp::Ordering::Less => {
255 let err = std::io::Error::last_os_error();
261 if matches!(
262 err.kind(),
263 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
264 ) {
265 thread::sleep(Duration::from_millis(10));
266 } else {
267 break;
268 }
269 }
270 }
271 }
272 }
273
274 fn enter_raw_mode() -> Result<libc::termios, SailError> {
278 unsafe {
279 let mut saved: libc::termios = std::mem::zeroed();
280 if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
281 return Err(SailError::Internal {
282 message: format!(
283 "could not enter raw terminal mode: {}",
284 std::io::Error::last_os_error()
285 ),
286 });
287 }
288 let mut raw = saved;
289 libc::cfmakeraw(&raw mut raw);
290 if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
291 return Err(SailError::Internal {
292 message: format!(
293 "could not enter raw terminal mode: {}",
294 std::io::Error::last_os_error()
295 ),
296 });
297 }
298 Ok(saved)
299 }
300 }
301
302 fn restore_terminal(saved: &libc::termios) {
303 unsafe {
304 let _ = libc::tcsetattr(
305 libc::STDIN_FILENO,
306 libc::TCSADRAIN,
307 std::ptr::from_ref(saved),
308 );
309 }
310 }
311
312 type SigHandler = libc::sighandler_t;
313
314 fn install_sigwinch() -> SigHandler {
315 let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
319 unsafe { libc::signal(libc::SIGWINCH, handler) }
320 }
321
322 fn restore_sigwinch(prev: SigHandler) {
323 unsafe {
324 libc::signal(libc::SIGWINCH, prev);
325 }
326 }
327
328 fn set_stdin_nonblocking() -> libc::c_int {
331 unsafe {
332 let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
333 if flags >= 0 {
334 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
335 }
336 flags
337 }
338 }
339
340 fn restore_stdin_flags(flags: libc::c_int) {
341 if flags >= 0 {
342 unsafe {
343 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
344 }
345 }
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn login_shell_quotes_an_explicit_path() {
355 assert_eq!(
357 login_shell_command(Some("/opt/my tools/zsh")),
358 "exec '/opt/my tools/zsh' -l"
359 );
360 assert_eq!(login_shell_command(None), "exec ${SHELL:-/bin/bash} -l");
362 }
363
364 #[tokio::test]
365 async fn shell_requires_a_tty() {
366 let client = crate::Client::builder("sk_test")
369 .api_url("http://127.0.0.1:1")
370 .sailbox_api_url("http://127.0.0.1:1")
371 .build()
372 .expect("build");
373 let err = client
374 .sailbox("sb_test")
375 .shell(None, ShellOptions::default())
376 .await
377 .expect_err("no tty in tests");
378 assert!(err.to_string().contains("interactive terminal"), "{err}");
379 }
380}