1use std::collections::HashMap;
2use std::ffi::OsString;
3use std::fs::{self, OpenOptions};
4use std::io::{self, Read, Write};
5#[cfg(unix)]
6use std::os::unix::fs::PermissionsExt;
7use std::path::Path;
8#[cfg(unix)]
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Mutex};
12use std::thread;
13
14use portable_pty::PtySize;
15
16use crate::sandbox_spawn::SpawnPlan;
17
18use super::persistence::{atomic_write, ExitMarker, TaskPaths};
19use super::pty_runtime::{CompletionCoordinator, PtyRuntime};
20
21#[allow(clippy::too_many_arguments)]
22pub(crate) fn spawn_pty_for_command(
23 spawn_plan: &SpawnPlan,
24 task_id: &str,
25 session_id: &str,
26 user_command: &str,
27 paths: &TaskPaths,
28 workdir: &Path,
29 env: &HashMap<String, String>,
30 rows: u16,
31 cols: u16,
32 wake_tx: crossbeam_channel::Sender<()>,
33) -> Result<PtyRuntime, String> {
34 #[cfg(unix)]
35 {
36 let shell = spawn_plan
37 .host_shell_path()
38 .map(Path::to_path_buf)
39 .unwrap_or_else(resolve_posix_shell);
40 let args = vec![OsString::from("-c"), OsString::from(user_command)];
41 try_spawn_pty(
42 spawn_plan,
43 task_id,
44 session_id,
45 shell.as_os_str(),
46 &args,
47 paths,
48 workdir,
49 env,
50 rows,
51 cols,
52 wake_tx,
53 )
54 }
55 #[cfg(windows)]
56 {
57 use crate::windows_shell::shell_candidates;
58
59 let candidates = shell_candidates();
60 let mut last_err = String::from("no Windows shell candidates available");
61
62 for shell in candidates {
63 let wrapper_body = shell.wrapper_script_bytes(user_command, &paths.exit);
64 let wrapper_path = windows_wrapper_path(paths, &shell);
65 if let Err(error) = fs::write(&wrapper_path, wrapper_body) {
66 last_err = format!("write wrapper {wrapper_path:?}: {error}");
67 continue;
68 }
69
70 let args: Vec<OsString> = shell
71 .pty_wrapper_args(&wrapper_path)
72 .into_iter()
73 .map(OsString::from)
74 .collect();
75
76 match try_spawn_pty(
77 spawn_plan,
78 task_id,
79 session_id,
80 std::ffi::OsStr::new(shell.binary().as_ref()),
81 &args,
82 paths,
83 workdir,
84 env,
85 rows,
86 cols,
87 wake_tx.clone(),
88 ) {
89 Ok(runtime) => return Ok(runtime),
90 Err(error) => {
91 let msg = format!("{shell:?}: {error}");
92 if msg.contains("NotFound") || msg.contains("not recognized") {
93 last_err = msg;
94 continue;
95 }
96 return Err(msg);
97 }
98 }
99 }
100
101 Err(last_err)
102 }
103}
104
105#[cfg(unix)]
106pub(crate) fn resolve_posix_shell() -> PathBuf {
107 resolve_posix_shell_with(
108 || std::env::var_os("SHELL").map(PathBuf::from),
109 is_executable_file,
110 )
111}
112
113#[cfg(unix)]
114fn resolve_posix_shell_with<S, X>(shell_env: S, is_executable: X) -> PathBuf
115where
116 S: FnOnce() -> Option<PathBuf>,
117 X: Fn(&Path) -> bool,
118{
119 if let Some(shell) =
120 shell_env().filter(|path| !path.as_os_str().is_empty() && is_executable(path.as_path()))
121 {
122 return shell;
123 }
124
125 for fallback in ["/bin/bash", "/bin/sh", "/bin/zsh"] {
126 let path = PathBuf::from(fallback);
127 if is_executable(&path) {
128 return path;
129 }
130 }
131
132 PathBuf::from("/bin/sh")
133}
134
135#[cfg(unix)]
136fn is_executable_file(path: &Path) -> bool {
137 fs::metadata(path)
138 .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
139 .unwrap_or(false)
140}
141
142#[cfg(windows)]
143fn windows_wrapper_path(
144 paths: &TaskPaths,
145 shell: &crate::windows_shell::WindowsShell,
146) -> std::path::PathBuf {
147 let extension = match shell {
148 crate::windows_shell::WindowsShell::Pwsh
149 | crate::windows_shell::WindowsShell::Powershell => "ps1",
150 crate::windows_shell::WindowsShell::Cmd => "bat",
151 crate::windows_shell::WindowsShell::Posix(_) => "sh",
152 };
153 let stem = paths
154 .json
155 .file_stem()
156 .and_then(|stem| stem.to_str())
157 .unwrap_or("wrapper");
158 paths.dir.join(format!("{stem}.{extension}"))
159}
160
161#[allow(clippy::too_many_arguments)]
162fn try_spawn_pty(
163 spawn_plan: &SpawnPlan,
164 task_id: &str,
165 session_id: &str,
166 program: &std::ffi::OsStr,
167 args: &[OsString],
168 paths: &TaskPaths,
169 workdir: &Path,
170 env: &HashMap<String, String>,
171 rows: u16,
172 cols: u16,
173 wake_tx: crossbeam_channel::Sender<()>,
174) -> Result<PtyRuntime, String> {
175 let command = crate::sandbox_spawn::pty_command_for_plan(
176 spawn_plan,
177 program,
178 args,
179 &paths.json,
180 workdir,
181 env,
182 )?;
183 let pty_system = portable_pty::native_pty_system();
184 let pair = pty_system
185 .openpty(PtySize {
186 rows,
187 cols,
188 pixel_width: 0,
189 pixel_height: 0,
190 })
191 .map_err(|error| format!("open PTY failed: {error}"))?;
192 let child = pair
193 .slave
194 .spawn_command(command)
195 .map_err(|error| format!("spawn PTY command failed: {error}"))?;
196 let child_pid = child.process_id();
197 let killer = child.clone_killer();
198 let reader = pair
199 .master
200 .try_clone_reader()
201 .map_err(|error| format!("clone PTY reader failed: {error}"))?;
202 let writer = pair
203 .master
204 .take_writer()
205 .map_err(|error| format!("take PTY writer failed: {error}"))?;
206
207 let reader_done = Arc::new(AtomicBool::new(false));
208 let exit_observed = Arc::new(AtomicBool::new(false));
209 let was_killed = Arc::new(AtomicBool::new(false));
210 let coordinator = Arc::new(CompletionCoordinator::new(
211 task_id.to_string(),
212 session_id.to_string(),
213 wake_tx,
214 ));
215
216 let writer = Arc::new(Mutex::new(writer));
217 spawn_reader(
218 reader,
219 paths.pty.clone(),
220 Arc::clone(&reader_done),
221 Arc::clone(&coordinator),
222 Some(Arc::clone(&writer)),
223 );
224 spawn_waiter(
225 child,
226 paths.exit.clone(),
227 Arc::clone(&was_killed),
228 Arc::clone(&exit_observed),
229 Arc::clone(&coordinator),
230 );
231
232 Ok(PtyRuntime {
233 master: Some(pair.master),
234 writer,
235 killer,
236 child_pid,
237 reader_done,
238 exit_observed,
239 was_killed,
240 coordinator,
241 })
242}
243
244const DSR_CARRY_OVER: usize = 3;
248
249pub(crate) fn spawn_reader(
250 mut reader: Box<dyn Read + Send>,
251 spill_path: std::path::PathBuf,
252 reader_done: Arc<AtomicBool>,
253 coordinator: Arc<CompletionCoordinator>,
254 writer: Option<Arc<Mutex<Box<dyn Write + Send>>>>,
255) {
256 thread::spawn(move || {
257 let result = (|| -> io::Result<()> {
258 if let Some(parent) = spill_path.parent() {
259 fs::create_dir_all(parent)?;
260 }
261 let mut file = OpenOptions::new()
262 .create(true)
263 .append(true)
264 .open(&spill_path)?;
265 let mut buf = [0_u8; 8192];
266 let mut dsr = DsrScanner::default();
267 loop {
268 match reader.read(&mut buf) {
269 Ok(0) => break,
270 Ok(n) => {
271 file.write_all(&buf[..n])?;
272 file.flush()?;
273 if dsr.scan(&buf[..n]) {
274 if let Some(writer) = writer.as_ref() {
282 if let Ok(mut writer) = writer.lock() {
283 let _ = writer.write_all(b"\x1b[1;1R");
284 let _ = writer.flush();
285 }
286 }
287 }
288 }
289 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
290 Err(error) => return Err(error),
291 }
292 }
293 Ok(())
294 })();
295 if let Err(error) = result {
296 crate::slog_warn!(
297 "PTY reader for {}:{} stopped with error: {error}",
298 coordinator.session_id,
299 coordinator.task_id
300 );
301 }
302 reader_done.store(true, Ordering::SeqCst);
303 coordinator.signal_one_done();
304 });
305}
306
307#[derive(Default)]
315struct DsrScanner {
316 carry: Vec<u8>,
317}
318
319impl DsrScanner {
320 fn scan(&mut self, chunk: &[u8]) -> bool {
321 let mut combined = Vec::with_capacity(self.carry.len() + chunk.len());
322 combined.extend_from_slice(&self.carry);
323 combined.extend_from_slice(chunk);
324 let detected = combined.windows(4).any(|w| w == b"\x1b[6n");
325 let start = combined.len().saturating_sub(DSR_CARRY_OVER);
328 self.carry.clear();
329 self.carry.extend_from_slice(&combined[start..]);
330 detected
331 }
332}
333
334pub(crate) fn spawn_waiter(
335 mut child: Box<dyn portable_pty::Child + Send + Sync>,
336 exit_path: std::path::PathBuf,
337 was_killed: Arc<AtomicBool>,
338 exit_observed: Arc<AtomicBool>,
339 coordinator: Arc<CompletionCoordinator>,
340) {
341 thread::spawn(move || {
342 let marker = loop {
343 match child.wait() {
344 Ok(status) => {
345 if was_killed.load(Ordering::SeqCst) {
346 break ExitMarker::Killed;
347 }
348 let code = i32::try_from(status.exit_code()).unwrap_or(i32::MAX);
349 break ExitMarker::Code(code);
350 }
351 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
352 Err(error) => {
353 crate::slog_warn!(
354 "PTY waiter for {}:{} failed: {error}",
355 coordinator.session_id,
356 coordinator.task_id
357 );
358 break ExitMarker::Killed;
359 }
360 }
361 };
362
363 if let Err(error) = write_exit_marker(&exit_path, &marker, &coordinator.task_id) {
364 crate::slog_warn!(
365 "PTY waiter for {}:{} failed to write exit marker: {error}",
366 coordinator.session_id,
367 coordinator.task_id
368 );
369 }
370 exit_observed.store(true, Ordering::SeqCst);
371 coordinator.signal_one_done();
372 });
373}
374
375fn write_exit_marker(path: &Path, marker: &ExitMarker, task_id: &str) -> io::Result<()> {
376 let content = match marker {
377 ExitMarker::Code(code) => code.to_string(),
378 ExitMarker::Killed => "killed".to_string(),
379 };
380 atomic_write(path, content.as_bytes(), task_id)
381}
382
383#[cfg(all(test, unix))]
387mod tests {
388 use std::io;
389 use std::sync::atomic::{AtomicBool, Ordering};
390 use std::sync::Arc;
391 use std::time::{Duration, Instant};
392
393 use portable_pty::{Child, ChildKiller, ExitStatus};
394
395 use super::*;
396
397 #[derive(Debug)]
398 struct FakeKiller;
399
400 impl ChildKiller for FakeKiller {
401 fn kill(&mut self) -> io::Result<()> {
402 Ok(())
403 }
404
405 fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
406 Box::new(FakeKiller)
407 }
408 }
409
410 #[derive(Debug)]
411 struct InterruptedOnceChild {
412 waits: usize,
413 }
414
415 impl ChildKiller for InterruptedOnceChild {
416 fn kill(&mut self) -> io::Result<()> {
417 Ok(())
418 }
419
420 fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
421 Box::new(FakeKiller)
422 }
423 }
424
425 impl Child for InterruptedOnceChild {
426 fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
427 Ok(None)
428 }
429
430 fn wait(&mut self) -> io::Result<ExitStatus> {
431 self.waits += 1;
432 if self.waits == 1 {
433 Err(io::Error::from(io::ErrorKind::Interrupted))
434 } else {
435 Ok(ExitStatus::with_exit_code(0))
436 }
437 }
438
439 fn process_id(&self) -> Option<u32> {
440 None
441 }
442
443 #[cfg(windows)]
444 fn as_raw_handle(&self) -> Option<std::os::windows::io::RawHandle> {
445 None
446 }
447 }
448
449 #[cfg(unix)]
450 #[test]
451 fn pty_shell_prefers_executable_shell_env() {
452 let shell = PathBuf::from("/custom/zsh");
453 let resolved =
454 resolve_posix_shell_with(|| Some(shell.clone()), |path| path == shell.as_path());
455
456 assert_eq!(resolved, shell);
457 }
458
459 #[cfg(unix)]
460 #[test]
461 fn pty_shell_ignores_unusable_shell_env_and_uses_fallback_order() {
462 let resolved = resolve_posix_shell_with(
463 || Some(PathBuf::from("/missing/fish")),
464 |path| path == Path::new("/bin/sh") || path == Path::new("/bin/zsh"),
465 );
466
467 assert_eq!(resolved, PathBuf::from("/bin/sh"));
468 }
469
470 #[cfg(unix)]
471 #[test]
472 fn pty_shell_uses_bin_bash_before_later_fallbacks() {
473 let resolved = resolve_posix_shell_with(
474 || None,
475 |path| path == Path::new("/bin/bash") || path == Path::new("/bin/sh"),
476 );
477
478 assert_eq!(resolved, PathBuf::from("/bin/bash"));
479 }
480
481 #[cfg(unix)]
482 #[test]
483 fn pty_waiter_retries_wait_on_interrupted() {
484 let temp = tempfile::tempdir().unwrap();
485 let exit_path = temp.path().join("task.exit");
486 let (wake_tx, wake_rx) = crossbeam_channel::bounded(1);
487 let coordinator = Arc::new(CompletionCoordinator::new(
488 "task".to_string(),
489 "session".to_string(),
490 wake_tx,
491 ));
492 let was_killed = Arc::new(AtomicBool::new(false));
493 let exit_observed = Arc::new(AtomicBool::new(false));
494
495 spawn_waiter(
496 Box::new(InterruptedOnceChild { waits: 0 }),
497 exit_path.clone(),
498 was_killed,
499 Arc::clone(&exit_observed),
500 Arc::clone(&coordinator),
501 );
502 coordinator.signal_one_done();
503
504 let started = Instant::now();
505 while !exit_observed.load(Ordering::SeqCst) {
506 assert!(started.elapsed() < Duration::from_secs(2));
507 std::thread::sleep(Duration::from_millis(10));
508 }
509 wake_rx.recv_timeout(Duration::from_secs(1)).unwrap();
510 assert_eq!(fs::read_to_string(exit_path).unwrap(), "0");
511 }
512
513 fn scan_chunks(chunks: &[&[u8]]) -> usize {
517 let mut scanner = DsrScanner::default();
518 chunks.iter().filter(|chunk| scanner.scan(chunk)).count()
519 }
520
521 #[test]
522 fn dsr_detected_within_single_read() {
523 assert_eq!(scan_chunks(&[b"\x1b[6n"]), 1);
524 }
525
526 #[test]
527 fn dsr_detected_two_read_splits() {
528 assert_eq!(scan_chunks(&[b"\x1b[6", b"n"]), 1); assert_eq!(scan_chunks(&[b"\x1b[", b"6n"]), 1); assert_eq!(scan_chunks(&[b"\x1b", b"[6n"]), 1); }
532
533 #[test]
534 fn dsr_detected_across_more_than_two_reads() {
535 assert_eq!(scan_chunks(&[b"\x1b", b"[", b"6", b"n"]), 1); assert_eq!(scan_chunks(&[b"\x1b", b"[", b"6n"]), 1); assert_eq!(scan_chunks(&[b"\x1b[", b"6", b"n"]), 1); assert_eq!(scan_chunks(&[b"junk\x1b", b"[6", b"n more"]), 1);
542 }
543
544 #[test]
545 fn dsr_detected_once_with_surrounding_output() {
546 assert_eq!(scan_chunks(&[b"hello\x1b[6nworld"]), 1);
548 }
549
550 #[test]
551 fn dsr_not_detected_no_match() {
552 assert_eq!(scan_chunks(&[b"abc", b"def"]), 0);
553 assert_eq!(scan_chunks(&[b"hello"]), 0);
554 assert_eq!(scan_chunks(&[b"\x1b[6", b"x"]), 0);
556 }
557}