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