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