1use std::{
2 collections::{BTreeMap, HashMap},
3 env, fs,
4 io::{self, BufRead, BufReader, Read, Write},
5 os::unix::io::AsRawFd,
6 os::unix::net::{UnixListener, UnixStream},
7 path::{Path, PathBuf},
8 sync::{
9 Arc, Mutex,
10 atomic::{AtomicU64, Ordering},
11 mpsc,
12 },
13 thread,
14};
15
16use anyhow::{Context, Result, anyhow, bail};
17use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
18use serde::{Deserialize, Serialize};
19
20use crate::{CommandSpec, PtySession, SignalStreamParser};
21
22const MAX_TRANSCRIPT_BYTES: usize = 262_144;
23const OUTPUT_CHUNK_BYTES: usize = 8192;
24const ATTACH_ENV_KEYS: &[&str] = &[
25 "HOME",
26 "PATH",
27 "SHELL",
28 "TERM",
29 "TERMINFO",
30 "TERMINFO_DIRS",
31 "COLORTERM",
32 "TERM_PROGRAM",
33 "TASKERS_AGENT_SESSION_ID",
34 "TASKERS_CTL_PATH",
35 "TASKERS_DISABLE_SHELL_INTEGRATION",
36 "TASKERS_EMBEDDED",
37 "TASKERS_PANE_ID",
38 "TASKERS_REAL_SHELL",
39 "TASKERS_SHELL_INTEGRATION_DIR",
40 "TASKERS_SOCKET",
41 "TASKERS_SURFACE_ID",
42 "TASKERS_TERMINAL_SESSION_ID",
43 "TASKERS_TERMINAL_SOCKET",
44 "TASKERS_USER_BASHRC",
45 "TASKERS_USER_ZDOTDIR",
46 "TASKERS_WORKSPACE_ID",
47 "TASKERS_TTY_NAME",
48 "ZDOTDIR",
49];
50
51#[derive(Debug, Clone)]
52pub struct TerminalSessionClient {
53 socket_path: PathBuf,
54}
55
56impl TerminalSessionClient {
57 pub fn new(socket_path: PathBuf) -> Self {
58 Self { socket_path }
59 }
60
61 pub fn socket_path(&self) -> &Path {
62 &self.socket_path
63 }
64
65 pub fn ping(&self) -> Result<()> {
66 let mut stream = self.connect()?;
67 write_request(&mut stream, &SessionRequest::Ping)?;
68 match read_event(&mut BufReader::new(stream))? {
69 SessionEvent::Pong => Ok(()),
70 other => bail!("unexpected ping response: {other:?}"),
71 }
72 }
73
74 pub fn has_session(&self, session_id: &str) -> Result<bool> {
75 let mut stream = self.connect()?;
76 write_request(
77 &mut stream,
78 &SessionRequest::HasSession {
79 session_id: session_id.into(),
80 },
81 )?;
82 match read_event(&mut BufReader::new(stream))? {
83 SessionEvent::Exists { exists } => Ok(exists),
84 SessionEvent::Error { message } => Err(anyhow!(message)),
85 other => bail!("unexpected session lookup response: {other:?}"),
86 }
87 }
88
89 pub fn terminate_session(&self, session_id: &str) -> Result<()> {
90 let mut stream = self.connect()?;
91 write_request(
92 &mut stream,
93 &SessionRequest::Terminate {
94 session_id: session_id.into(),
95 },
96 )?;
97 match read_event(&mut BufReader::new(stream))? {
98 SessionEvent::Ack => Ok(()),
99 SessionEvent::Error { message } => Err(anyhow!(message)),
100 other => bail!("unexpected terminate response: {other:?}"),
101 }
102 }
103
104 pub fn list_sessions(&self) -> Result<Vec<String>> {
105 let mut stream = self.connect()?;
106 write_request(&mut stream, &SessionRequest::ListSessions)?;
107 match read_event(&mut BufReader::new(stream))? {
108 SessionEvent::SessionList { session_ids } => Ok(session_ids),
109 SessionEvent::Error { message } => Err(anyhow!(message)),
110 other => bail!("unexpected session list response: {other:?}"),
111 }
112 }
113
114 pub fn attach_or_create(&self, session_id: &str, shell_args: &[String]) -> Result<()> {
115 let _terminal_mode = TerminalModeGuard::new()?;
116 let (mut cols, mut rows) = terminal_size().unwrap_or((120, 40));
117 let mut stream = self.connect()?;
118 let attach = SessionRequest::Attach {
119 session_id: session_id.into(),
120 cols,
121 rows,
122 cwd: env::current_dir()
123 .ok()
124 .map(|path| path.display().to_string()),
125 shell_args: shell_args.to_vec(),
126 env: collect_attach_env(),
127 };
128 write_request(&mut stream, &attach)?;
129 stream
130 .set_nonblocking(true)
131 .context("failed to set terminal session socket nonblocking")?;
132
133 let stdin = io::stdin();
134 let stdin_fd = stdin.as_raw_fd();
135 let socket_fd = stream.as_raw_fd();
136 let mut stdin = stdin.lock();
137 let mut stdout = io::stdout().lock();
138 let mut input_buffer = [0u8; 4096];
139 let mut socket_buffer = Vec::new();
140
141 loop {
142 if let Some((next_cols, next_rows)) = terminal_size() {
143 if next_cols != cols || next_rows != rows {
144 cols = next_cols;
145 rows = next_rows;
146 write_request(&mut stream, &SessionRequest::Resize { cols, rows })?;
147 }
148 }
149
150 let mut pollfds = [
151 libc::pollfd {
152 fd: stdin_fd,
153 events: libc::POLLIN | libc::POLLHUP | libc::POLLERR,
154 revents: 0,
155 },
156 libc::pollfd {
157 fd: socket_fd,
158 events: libc::POLLIN | libc::POLLERR | libc::POLLHUP,
159 revents: 0,
160 },
161 ];
162 let poll_result = unsafe { libc::poll(pollfds.as_mut_ptr(), pollfds.len() as _, 100) };
163 if poll_result < 0 {
164 let error = io::Error::last_os_error();
165 if error.kind() == io::ErrorKind::Interrupted {
166 continue;
167 }
168 return Err(error).context("terminal session poll failed");
169 }
170
171 if (pollfds[1].revents & (libc::POLLERR | libc::POLLHUP)) != 0 {
172 break;
173 }
174 if (pollfds[1].revents & libc::POLLIN) != 0 {
175 if pump_session_events(&mut stream, &mut socket_buffer, &mut stdout)? {
176 break;
177 }
178 }
179 if (pollfds[0].revents & (libc::POLLERR | libc::POLLHUP)) != 0 {
180 let _ = write_request(&mut stream, &SessionRequest::Detach);
181 break;
182 }
183 if (pollfds[0].revents & libc::POLLIN) != 0 {
184 let bytes_read = stdin
185 .read(&mut input_buffer)
186 .context("failed to read terminal stdin")?;
187 if bytes_read == 0 {
188 let _ = write_request(&mut stream, &SessionRequest::Detach);
189 break;
190 }
191 write_request(
192 &mut stream,
193 &SessionRequest::Input {
194 data_b64: BASE64.encode(&input_buffer[..bytes_read]),
195 },
196 )?;
197 }
198 }
199
200 Ok(())
201 }
202
203 fn connect(&self) -> Result<UnixStream> {
204 UnixStream::connect(&self.socket_path).with_context(|| {
205 format!(
206 "failed to connect to terminal session socket {}",
207 self.socket_path.display()
208 )
209 })
210 }
211}
212
213#[derive(Clone)]
214pub struct TerminalSessionDaemon {
215 sessions: Arc<Mutex<HashMap<String, SessionState>>>,
216 next_client_id: Arc<AtomicU64>,
217}
218
219struct SessionState {
220 pty: Arc<Mutex<PtySession>>,
221 transcript: Vec<u8>,
222 needs_redraw: bool,
223 clients: HashMap<u64, mpsc::Sender<SessionEvent>>,
224}
225
226impl TerminalSessionDaemon {
227 pub fn new() -> Self {
228 Self {
229 sessions: Arc::new(Mutex::new(HashMap::new())),
230 next_client_id: Arc::new(AtomicU64::new(1)),
231 }
232 }
233
234 pub fn serve(&self, socket_path: &Path) -> Result<()> {
235 if let Some(parent) = socket_path.parent() {
236 fs::create_dir_all(parent).with_context(|| {
237 format!(
238 "failed to create terminal session socket directory {}",
239 parent.display()
240 )
241 })?;
242 }
243 if socket_path.exists() {
244 fs::remove_file(socket_path).with_context(|| {
245 format!("failed to remove stale socket {}", socket_path.display())
246 })?;
247 }
248
249 let listener = UnixListener::bind(socket_path)
250 .with_context(|| format!("failed to bind {}", socket_path.display()))?;
251 set_private_socket_permissions(socket_path)?;
252 for stream in listener.incoming() {
253 let stream = match stream {
254 Ok(stream) => stream,
255 Err(error) => {
256 eprintln!("terminal session accept failed: {error}");
257 continue;
258 }
259 };
260 let daemon = self.clone();
261 thread::spawn(move || {
262 if let Err(error) = daemon.handle_connection(stream) {
263 eprintln!("terminal session connection failed: {error:?}");
264 }
265 });
266 }
267 Ok(())
268 }
269
270 fn handle_connection(&self, mut stream: UnixStream) -> Result<()> {
271 ensure_peer_is_owner(&stream)?;
272 let mut reader = BufReader::new(
273 stream
274 .try_clone()
275 .context("failed to clone terminal session stream")?,
276 );
277 let request = read_request(&mut reader)?;
278 match request {
279 SessionRequest::Ping => {
280 write_event(&mut stream, &SessionEvent::Pong)?;
281 }
282 SessionRequest::ListSessions => {
283 let session_ids = self
284 .sessions
285 .lock()
286 .expect("session daemon mutex poisoned")
287 .keys()
288 .cloned()
289 .collect::<Vec<_>>();
290 write_event(&mut stream, &SessionEvent::SessionList { session_ids })?;
291 }
292 SessionRequest::HasSession { session_id } => {
293 let exists = self
294 .sessions
295 .lock()
296 .expect("session daemon mutex poisoned")
297 .contains_key(&session_id);
298 write_event(&mut stream, &SessionEvent::Exists { exists })?;
299 }
300 SessionRequest::Terminate { session_id } => {
301 let state = self
302 .sessions
303 .lock()
304 .expect("session daemon mutex poisoned")
305 .remove(&session_id);
306 if let Some(state) = state {
307 state
308 .pty
309 .lock()
310 .expect("pty session mutex poisoned")
311 .kill()
312 .ok();
313 }
314 write_event(&mut stream, &SessionEvent::Ack)?;
315 }
316 SessionRequest::Attach {
317 session_id,
318 cols,
319 rows,
320 cwd,
321 shell_args,
322 env,
323 } => {
324 self.attach_client(stream, session_id, cols, rows, cwd, shell_args, env)?;
325 }
326 other => bail!("unexpected initial request: {other:?}"),
327 }
328 Ok(())
329 }
330
331 fn attach_client(
332 &self,
333 stream: UnixStream,
334 session_id: String,
335 cols: u16,
336 rows: u16,
337 cwd: Option<String>,
338 shell_args: Vec<String>,
339 env: BTreeMap<String, String>,
340 ) -> Result<()> {
341 let created = self.ensure_session(&session_id, cols, rows, cwd, shell_args, env)?;
342
343 let client_id = self.next_client_id.fetch_add(1, Ordering::Relaxed);
344 let (tx, rx) = mpsc::channel::<SessionEvent>();
345 let (transcript, redraw_after_attach, pty) = {
346 let mut sessions = self.sessions.lock().expect("session daemon mutex poisoned");
347 let session = sessions
348 .get_mut(&session_id)
349 .ok_or_else(|| anyhow!("session {session_id} was not created"))?;
350 let redraw_after_attach =
351 !created && session.needs_redraw && session.transcript.is_empty();
352 session.clients.insert(client_id, tx.clone());
353 if redraw_after_attach {
354 session.needs_redraw = false;
355 }
356 (
357 session.transcript.clone(),
358 redraw_after_attach,
359 Arc::clone(&session.pty),
360 )
361 };
362
363 tx.send(SessionEvent::Attached).ok();
364 queue_output(&tx, &transcript);
365 if redraw_after_attach {
366 let _ = pty
367 .lock()
368 .expect("pty session mutex poisoned")
369 .write_all(b"\x0c");
370 }
371
372 let writer_stream = stream
373 .try_clone()
374 .context("failed to clone client stream")?;
375 let writer = thread::spawn(move || -> Result<()> {
376 let mut writer = writer_stream;
377 while let Ok(event) = rx.recv() {
378 write_event(&mut writer, &event)?;
379 }
380 Ok(())
381 });
382
383 let mut reader = BufReader::new(stream);
384 loop {
385 match read_request(&mut reader) {
386 Ok(SessionRequest::Input { data_b64 }) => {
387 let bytes = BASE64
388 .decode(data_b64)
389 .context("failed to decode session input")?;
390 let pty = {
391 let sessions = self.sessions.lock().expect("session daemon mutex poisoned");
392 sessions
393 .get(&session_id)
394 .map(|session| Arc::clone(&session.pty))
395 .ok_or_else(|| anyhow!("session {session_id} vanished"))?
396 };
397 pty.lock()
398 .expect("pty session mutex poisoned")
399 .write_all(&bytes)
400 .context("failed to write session input")?;
401 }
402 Ok(SessionRequest::Resize { cols, rows }) => {
403 let pty = {
404 let sessions = self.sessions.lock().expect("session daemon mutex poisoned");
405 sessions
406 .get(&session_id)
407 .map(|session| Arc::clone(&session.pty))
408 .ok_or_else(|| anyhow!("session {session_id} vanished"))?
409 };
410 pty.lock()
411 .expect("pty session mutex poisoned")
412 .resize(cols, rows)
413 .context("failed to resize session")?;
414 }
415 Ok(SessionRequest::Detach) => break,
416 Ok(other) => bail!("unexpected attach request: {other:?}"),
417 Err(error) => {
418 if is_unexpected_eof(&error) {
419 break;
420 }
421 return Err(error);
422 }
423 }
424 }
425
426 {
427 let mut sessions = self.sessions.lock().expect("session daemon mutex poisoned");
428 if let Some(session) = sessions.get_mut(&session_id) {
429 session.clients.remove(&client_id);
430 if session.clients.is_empty() {
431 session.transcript.clear();
432 session.needs_redraw = true;
433 }
434 }
435 }
436 drop(tx);
437 writer
438 .join()
439 .map_err(|_| anyhow!("client writer panicked"))??;
440 Ok(())
441 }
442
443 fn ensure_session(
444 &self,
445 session_id: &str,
446 cols: u16,
447 rows: u16,
448 cwd: Option<String>,
449 shell_args: Vec<String>,
450 env: BTreeMap<String, String>,
451 ) -> Result<bool> {
452 let mut sessions = self.sessions.lock().expect("session daemon mutex poisoned");
453 if sessions.contains_key(session_id) {
454 return Ok(false);
455 }
456
457 let spec = build_command_spec(cols, rows, cwd, shell_args, env)?;
458 let spawned = PtySession::spawn(&spec)?;
459 let pty = Arc::new(Mutex::new(spawned.session));
460 let reader = spawned.reader;
461 sessions.insert(
462 session_id.into(),
463 SessionState {
464 pty: Arc::clone(&pty),
465 transcript: Vec::new(),
466 needs_redraw: false,
467 clients: HashMap::new(),
468 },
469 );
470 drop(sessions);
471 self.spawn_reader(session_id.to_string(), reader);
472 Ok(true)
473 }
474
475 fn spawn_reader(&self, session_id: String, mut reader: crate::PtyReader) {
476 let sessions = Arc::clone(&self.sessions);
477 thread::spawn(move || {
478 let mut buffer = [0u8; 4096];
479 let mut signal_parser = SignalStreamParser::default();
480 loop {
481 let bytes_read = match reader.read_into(&mut buffer) {
482 Ok(0) => break,
483 Ok(bytes_read) => bytes_read,
484 Err(_) => break,
485 };
486 let chunk = &buffer[..bytes_read];
487 let mut clients = Vec::new();
488 {
489 let mut all_sessions = sessions.lock().expect("session daemon mutex poisoned");
490 let Some(session) = all_sessions.get_mut(&session_id) else {
491 break;
492 };
493 session.transcript.extend_from_slice(chunk);
494 trim_transcript(&mut session.transcript);
495 clients.extend(session.clients.values().cloned());
496 }
497
498 for _event in signal_parser.push_events(&String::from_utf8_lossy(chunk)) {}
499 for client in clients {
500 queue_output(&client, chunk);
501 }
502 }
503
504 let clients = {
505 let mut all_sessions = sessions.lock().expect("session daemon mutex poisoned");
506 all_sessions
507 .remove(&session_id)
508 .map(|session| session.clients.into_values().collect::<Vec<_>>())
509 .unwrap_or_default()
510 };
511 for client in clients {
512 client.send(SessionEvent::Closed).ok();
513 }
514 });
515 }
516}
517
518fn build_command_spec(
519 cols: u16,
520 rows: u16,
521 cwd: Option<String>,
522 mut shell_args: Vec<String>,
523 mut env_map: BTreeMap<String, String>,
524) -> Result<CommandSpec> {
525 let integration_dir = env_map
526 .get("TASKERS_SHELL_INTEGRATION_DIR")
527 .cloned()
528 .ok_or_else(|| anyhow!("missing TASKERS_SHELL_INTEGRATION_DIR"))?;
529 env_map.insert("TASKERS_SESSION_CHILD".into(), "1".into());
530 let wrapper_path = PathBuf::from(integration_dir).join("taskers-shell-wrapper.sh");
531 let mut args = vec![wrapper_path.display().to_string()];
532 args.append(&mut shell_args);
533 env_map
534 .entry("TERM".into())
535 .or_insert_with(|| "xterm-256color".into());
536
537 Ok(CommandSpec {
538 program: "sh".into(),
539 args,
540 cwd: cwd.map(PathBuf::from),
541 env: env_map,
542 cols,
543 rows,
544 })
545}
546
547fn collect_attach_env() -> BTreeMap<String, String> {
548 let mut env_map = BTreeMap::new();
549 for key in ATTACH_ENV_KEYS {
550 if let Ok(value) = env::var(key) {
551 env_map.insert((*key).into(), value);
552 }
553 }
554 env_map
555}
556
557fn trim_transcript(transcript: &mut Vec<u8>) {
558 if transcript.len() <= MAX_TRANSCRIPT_BYTES {
559 return;
560 }
561 let drop_len = transcript.len() - MAX_TRANSCRIPT_BYTES;
562 transcript.drain(..drop_len);
563}
564
565fn queue_output(sender: &mpsc::Sender<SessionEvent>, bytes: &[u8]) {
566 for chunk in bytes.chunks(OUTPUT_CHUNK_BYTES) {
567 sender
568 .send(SessionEvent::Output {
569 data_b64: BASE64.encode(chunk),
570 })
571 .ok();
572 }
573}
574
575fn write_request(stream: &mut UnixStream, request: &SessionRequest) -> Result<()> {
576 let data = serde_json::to_vec(request).context("failed to serialize session request")?;
577 stream
578 .write_all(&data)
579 .context("failed to write session request")?;
580 stream
581 .write_all(b"\n")
582 .context("failed to terminate session request")?;
583 stream.flush().ok();
584 Ok(())
585}
586
587fn write_event(stream: &mut UnixStream, event: &SessionEvent) -> Result<()> {
588 let data = serde_json::to_vec(event).context("failed to serialize session event")?;
589 stream
590 .write_all(&data)
591 .context("failed to write session event")?;
592 stream
593 .write_all(b"\n")
594 .context("failed to terminate session event")?;
595 stream.flush().ok();
596 Ok(())
597}
598
599fn read_request(reader: &mut BufReader<UnixStream>) -> Result<SessionRequest> {
600 let mut line = String::new();
601 let bytes = reader
602 .read_line(&mut line)
603 .context("failed to read session request")?;
604 if bytes == 0 {
605 bail!("unexpected EOF while reading session request");
606 }
607 serde_json::from_str(line.trim_end()).context("failed to parse session request")
608}
609
610fn read_event(reader: &mut BufReader<UnixStream>) -> Result<SessionEvent> {
611 let mut line = String::new();
612 let bytes = reader
613 .read_line(&mut line)
614 .context("failed to read session event")?;
615 if bytes == 0 {
616 bail!("unexpected EOF while reading session event");
617 }
618 serde_json::from_str(line.trim_end()).context("failed to parse session event")
619}
620
621fn is_unexpected_eof(error: &anyhow::Error) -> bool {
622 error
623 .to_string()
624 .contains("unexpected EOF while reading session request")
625}
626
627fn set_private_socket_permissions(socket_path: &Path) -> Result<()> {
628 use std::os::unix::fs::PermissionsExt;
629
630 let permissions = fs::Permissions::from_mode(0o600);
631 fs::set_permissions(socket_path, permissions).with_context(|| {
632 format!(
633 "failed to set private permissions on terminal socket {}",
634 socket_path.display()
635 )
636 })
637}
638
639fn ensure_peer_is_owner(stream: &UnixStream) -> Result<()> {
640 #[cfg(not(target_os = "linux"))]
641 {
642 let _ = stream;
643 return Ok(());
644 }
645
646 #[cfg(target_os = "linux")]
647 {
648 let expected_uid = unsafe { libc::geteuid() };
649 let mut credentials = libc::ucred {
650 pid: 0,
651 uid: 0,
652 gid: 0,
653 };
654 let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
655 let result = unsafe {
656 libc::getsockopt(
657 stream.as_raw_fd(),
658 libc::SOL_SOCKET,
659 libc::SO_PEERCRED,
660 (&mut credentials as *mut libc::ucred).cast(),
661 &mut len,
662 )
663 };
664 if result != 0 {
665 return Err(io::Error::last_os_error()).context("failed to read peer credentials");
666 }
667 if credentials.uid != expected_uid {
668 bail!(
669 "rejecting terminal session client from uid {} (expected {})",
670 credentials.uid,
671 expected_uid
672 );
673 }
674 Ok(())
675 }
676}
677
678fn pump_session_events(
679 stream: &mut UnixStream,
680 pending: &mut Vec<u8>,
681 stdout: &mut impl Write,
682) -> Result<bool> {
683 let mut buffer = [0u8; 4096];
684 loop {
685 match stream.read(&mut buffer) {
686 Ok(0) => return Ok(true),
687 Ok(bytes_read) => pending.extend_from_slice(&buffer[..bytes_read]),
688 Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
689 Err(error) => return Err(error).context("failed to read terminal session socket"),
690 }
691 }
692
693 while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') {
694 let line = pending.drain(..=newline).collect::<Vec<_>>();
695 let event = serde_json::from_slice::<SessionEvent>(&line[..line.len() - 1])
696 .context("failed to parse session event")?;
697 match event {
698 SessionEvent::Attached => {}
699 SessionEvent::Output { data_b64 } => {
700 let bytes = BASE64
701 .decode(data_b64)
702 .context("failed to decode sidecar output")?;
703 stdout
704 .write_all(&bytes)
705 .context("failed to write sidecar output")?;
706 stdout.flush().ok();
707 }
708 SessionEvent::Closed => return Ok(true),
709 SessionEvent::Error { message } => return Err(anyhow!(message)),
710 other => bail!("unexpected attach event: {other:?}"),
711 }
712 }
713
714 Ok(false)
715}
716
717struct TerminalModeGuard {
718 fd: i32,
719 original: libc::termios,
720}
721
722impl TerminalModeGuard {
723 fn new() -> Result<Option<Self>> {
724 let stdin = io::stdin();
725 let fd = stdin.as_raw_fd();
726 let is_tty = unsafe { libc::isatty(fd) } == 1;
727 if !is_tty {
728 return Ok(None);
729 }
730
731 let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
732 let get_result = unsafe { libc::tcgetattr(fd, &mut termios) };
733 if get_result != 0 {
734 bail!("failed to read terminal mode");
735 }
736 let original = termios;
737 unsafe {
738 libc::cfmakeraw(&mut termios);
739 }
740 let set_result = unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) };
741 if set_result != 0 {
742 bail!("failed to set terminal raw mode");
743 }
744
745 Ok(Some(Self { fd, original }))
746 }
747}
748
749impl Drop for TerminalModeGuard {
750 fn drop(&mut self) {
751 unsafe {
752 libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
753 }
754 }
755}
756
757fn terminal_size() -> Option<(u16, u16)> {
758 let stdout = io::stdout();
759 let fd = stdout.as_raw_fd();
760 let mut winsize = libc::winsize {
761 ws_row: 0,
762 ws_col: 0,
763 ws_xpixel: 0,
764 ws_ypixel: 0,
765 };
766 let result = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut winsize) };
767 if result != 0 || winsize.ws_col == 0 || winsize.ws_row == 0 {
768 return None;
769 }
770 Some((winsize.ws_col, winsize.ws_row))
771}
772
773#[derive(Debug, Serialize, Deserialize)]
774#[serde(tag = "type", rename_all = "snake_case")]
775enum SessionRequest {
776 Ping,
777 ListSessions,
778 HasSession {
779 session_id: String,
780 },
781 Terminate {
782 session_id: String,
783 },
784 Attach {
785 session_id: String,
786 cols: u16,
787 rows: u16,
788 cwd: Option<String>,
789 shell_args: Vec<String>,
790 env: BTreeMap<String, String>,
791 },
792 Input {
793 data_b64: String,
794 },
795 Resize {
796 cols: u16,
797 rows: u16,
798 },
799 Detach,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize)]
803#[serde(tag = "type", rename_all = "snake_case")]
804enum SessionEvent {
805 Pong,
806 SessionList { session_ids: Vec<String> },
807 Exists { exists: bool },
808 Ack,
809 Attached,
810 Output { data_b64: String },
811 Closed,
812 Error { message: String },
813}
814
815#[cfg(test)]
816mod tests {
817 use super::{collect_attach_env, terminal_size};
818
819 #[test]
820 fn collect_attach_env_preserves_terminal_session_identity() {
821 unsafe {
822 std::env::set_var("TASKERS_TERMINAL_SESSION_ID", "session-123");
823 }
824 let env = collect_attach_env();
825 assert_eq!(
826 env.get("TASKERS_TERMINAL_SESSION_ID").map(String::as_str),
827 Some("session-123")
828 );
829 unsafe {
830 std::env::remove_var("TASKERS_TERMINAL_SESSION_ID");
831 }
832 }
833
834 #[test]
835 fn terminal_size_helper_returns_none_or_positive_dimensions() {
836 match terminal_size() {
837 Some((cols, rows)) => {
838 assert!(cols > 0);
839 assert!(rows > 0);
840 }
841 None => {}
842 }
843 }
844}