Skip to main content

running_process/client/
pty_session.rs

1//! Client-side helpers for daemon-owned detachable PTY sessions
2//! (issue #130 milestone 2).
3//!
4//! Sessions are spawned and listed via the regular [`DaemonClient`] RPC
5//! channel. Attach is special: after the daemon responds with
6//! `AttachPtySessionResponse` the same socket switches into a streaming
7//! mode that carries [`PtyStreamFrame`] (daemon → client) and
8//! [`PtyInputFrame`] (client → daemon) messages. [`PtyAttachment`] owns the
9//! socket for the lifetime of that stream and exposes blocking
10//! send/receive helpers suitable for tests and small clients. Async
11//! clients can build on top of the attachment framing exposed by
12//! [`PtyAttachment`].
13
14use crate::client::paths;
15use crate::client::{ClientError, DaemonClient};
16use crate::platform::ipc::Stream;
17use crate::proto::daemon::{
18    pty_input_frame::Frame as InputOneof, AttachPtySessionRequest, AttachPtySessionResponse,
19    DaemonRequest, DaemonResponse, DetachPtySessionRequest, KeyValue, ListPtySessionsRequest,
20    ListPtySessionsResponse, PtyInputFrame, PtyResize, PtySessionInfo, PtyStreamFrame, RequestType,
21    SpawnPtySessionRequest, SpawnPtySessionResponse, StatusCode, TerminatePtySessionRequest,
22};
23use crate::terminal_graphics::{
24    current_terminal_capabilities, terminal_graphics_capabilities_to_proto, TerminalCapabilities,
25    TerminalGraphicsCapabilities,
26};
27use prost::Message;
28use std::io::{BufReader, BufWriter, Read, Write};
29use std::path::PathBuf;
30use std::time::Duration;
31
32// ---------------------------------------------------------------------------
33// Spawn / list / terminate convenience builders
34// ---------------------------------------------------------------------------
35
36/// Request shape for spawning a daemon-owned PTY session.
37#[derive(Debug, Clone)]
38pub struct PtySpawnRequest {
39    /// Command and arguments to execute inside the PTY.
40    pub argv: Vec<String>,
41    /// Working directory for the spawned process.
42    pub cwd: Option<PathBuf>,
43    /// Explicit environment variables applied after the selected base.
44    pub env: Vec<(String, String)>,
45    /// Deprecated wire-compatibility bit. Prefer [`Self::environment_policy`].
46    pub clear_inherited_env: bool,
47    /// Base environment used before applying [`Self::env`].
48    pub environment_policy: crate::EnvironmentPolicy,
49    /// Initial terminal row count.
50    pub rows: u16,
51    /// Initial terminal column count.
52    pub cols: u16,
53    /// Optional caller-defined owner string used for listing and filtering sessions.
54    pub originator: Option<String>,
55}
56
57impl PtySpawnRequest {
58    /// Create a request with default size that snapshots the caller
59    /// environment and replaces the daemon environment.
60    pub fn new<S: Into<String>>(argv: impl IntoIterator<Item = S>) -> Self {
61        Self {
62            argv: argv.into_iter().map(Into::into).collect(),
63            cwd: None,
64            env: std::env::vars().collect(),
65            clear_inherited_env: true,
66            environment_policy: crate::EnvironmentPolicy::Clear,
67            rows: 24,
68            cols: 80,
69            originator: None,
70        }
71    }
72
73    /// Set the working directory for the spawned process.
74    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
75        self.cwd = Some(cwd.into());
76        self
77    }
78
79    /// Set the initial PTY size.
80    pub fn with_size(mut self, rows: u16, cols: u16) -> Self {
81        self.rows = rows;
82        self.cols = cols;
83        self
84    }
85
86    /// Set the caller-defined owner string for this session.
87    pub fn with_originator(mut self, originator: impl Into<String>) -> Self {
88        self.originator = Some(originator.into());
89        self
90    }
91
92    /// Replace the request's explicit environment variables.
93    pub fn with_envs<I, K, V>(mut self, env: I) -> Self
94    where
95        I: IntoIterator<Item = (K, V)>,
96        K: Into<String>,
97        V: Into<String>,
98    {
99        self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
100        self
101    }
102
103    /// Select the base environment for this contained remote child. `Auto`
104    /// resolves to the contained-process default, `Inherit`.
105    pub fn with_environment_policy(mut self, policy: crate::EnvironmentPolicy) -> Self {
106        self.environment_policy = match policy {
107            crate::EnvironmentPolicy::Auto => crate::EnvironmentPolicy::Inherit,
108            explicit => explicit,
109        };
110        self.clear_inherited_env = self
111            .environment_policy
112            .legacy_clear_fallback()
113            .expect("resolved environment policy");
114        self
115    }
116}
117
118/// Reply summary for a successful spawn.
119#[derive(Debug, Clone)]
120pub struct SpawnedPtySession {
121    /// Daemon-assigned PTY session identifier.
122    pub session_id: String,
123    /// Process ID of the spawned session leader.
124    pub pid: u32,
125    /// Creation time reported by the daemon, in seconds since the Unix epoch.
126    pub created_at: f64,
127}
128
129impl DaemonClient {
130    /// Ask the daemon to spawn a new PTY session that it owns.
131    pub fn spawn_pty_session(
132        &mut self,
133        request: &PtySpawnRequest,
134    ) -> Result<SpawnedPtySession, ClientError> {
135        let policy = match request.environment_policy {
136            crate::EnvironmentPolicy::Auto => crate::EnvironmentPolicy::Inherit,
137            explicit => explicit,
138        };
139        let proto = SpawnPtySessionRequest {
140            argv: request.argv.clone(),
141            cwd: request
142                .cwd
143                .as_ref()
144                .map(|p| p.to_string_lossy().into_owned())
145                .unwrap_or_default(),
146            env: request
147                .env
148                .iter()
149                .map(|(k, v)| KeyValue {
150                    key: k.clone(),
151                    value: v.clone(),
152                })
153                .collect(),
154            clear_inherited_env: policy
155                .legacy_clear_fallback()
156                .map_err(|message| ClientError::Io(std::io::Error::other(message)))?,
157            rows: request.rows as u32,
158            cols: request.cols as u32,
159            originator: request.originator.clone().unwrap_or_default(),
160            environment_policy: policy
161                .wire_value()
162                .map_err(|message| ClientError::Io(std::io::Error::other(message)))?,
163        };
164
165        let daemon_request = DaemonRequest {
166            id: self.next_request_id(),
167            r#type: RequestType::SpawnPtySession.into(),
168            protocol_version: 1,
169            client_name: "running-process-client".into(),
170            spawn_pty_session: Some(proto),
171            ..Default::default()
172        };
173
174        let response = self.send_request(daemon_request)?;
175        ensure_ok(&response)?;
176        let payload: SpawnPtySessionResponse =
177            response
178                .spawn_pty_session
179                .ok_or_else(|| ClientError::Server {
180                    code: StatusCode::Internal,
181                    message: "spawn_pty_session response missing payload".into(),
182                })?;
183        Ok(SpawnedPtySession {
184            session_id: payload.session_id,
185            pid: payload.pid,
186            created_at: payload.created_at,
187        })
188    }
189
190    /// List PTY sessions known to the daemon. Empty `originator_filter`
191    /// returns all sessions in scope.
192    pub fn list_pty_sessions(
193        &mut self,
194        originator_filter: &str,
195    ) -> Result<Vec<PtySessionInfo>, ClientError> {
196        let req = DaemonRequest {
197            id: self.next_request_id(),
198            r#type: RequestType::ListPtySessions.into(),
199            protocol_version: 1,
200            client_name: "running-process-client".into(),
201            list_pty_sessions: Some(ListPtySessionsRequest {
202                originator: originator_filter.into(),
203            }),
204            ..Default::default()
205        };
206        let response = self.send_request(req)?;
207        ensure_ok(&response)?;
208        let payload: ListPtySessionsResponse =
209            response
210                .list_pty_sessions
211                .ok_or_else(|| ClientError::Server {
212                    code: StatusCode::Internal,
213                    message: "list_pty_sessions response missing payload".into(),
214                })?;
215        Ok(payload.sessions)
216    }
217
218    /// Ask the daemon to detach any current attachment from a session,
219    /// leaving the session alive. Idempotent.
220    pub fn detach_pty_session(&mut self, session_id: &str) -> Result<(), ClientError> {
221        let req = DaemonRequest {
222            id: self.next_request_id(),
223            r#type: RequestType::DetachPtySession.into(),
224            protocol_version: 1,
225            client_name: "running-process-client".into(),
226            detach_pty_session: Some(DetachPtySessionRequest {
227                session_id: session_id.into(),
228            }),
229            ..Default::default()
230        };
231        let response = self.send_request(req)?;
232        ensure_ok(&response)?;
233        Ok(())
234    }
235
236    /// Schedule termination of a PTY session. Returns as soon as the
237    /// daemon accepts the schedule; the actual termination happens on a
238    /// daemon background task (soft signal, grace, then hard kill).
239    pub fn terminate_pty_session(
240        &mut self,
241        session_id: &str,
242        grace_ms: u32,
243    ) -> Result<(), ClientError> {
244        let req = DaemonRequest {
245            id: self.next_request_id(),
246            r#type: RequestType::TerminatePtySession.into(),
247            protocol_version: 1,
248            client_name: "running-process-client".into(),
249            terminate_pty_session: Some(TerminatePtySessionRequest {
250                session_id: session_id.into(),
251                grace_ms,
252            }),
253            ..Default::default()
254        };
255        let response = self.send_request(req)?;
256        ensure_ok(&response)?;
257        Ok(())
258    }
259}
260
261fn ensure_ok(response: &DaemonResponse) -> Result<(), ClientError> {
262    if response.code == StatusCode::Ok as i32 {
263        return Ok(());
264    }
265    let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
266    Err(ClientError::Server {
267        code,
268        message: response.message.clone(),
269    })
270}
271
272// ---------------------------------------------------------------------------
273// PtyAttachment
274// ---------------------------------------------------------------------------
275
276/// Active attachment to a daemon-owned PTY session.
277///
278/// Owns the socket; the connection is in streaming mode and cannot be used
279/// for unrelated RPCs.
280pub struct PtyAttachment {
281    reader: BufReader<Stream>,
282    writer: BufWriter<Stream>,
283    /// Bytes received in the initial AttachPtySessionResponse (output the
284    /// client missed before attach succeeded).
285    pub initial_backlog: Vec<u8>,
286    /// Cumulative bytes dropped from the daemon's ring buffer before this
287    /// attach. Zero if the buffer never overflowed.
288    pub bytes_missed: u64,
289}
290
291/// Errors specific to attach.
292#[derive(Debug)]
293pub enum AttachError {
294    /// Failed to open a socket connection to the daemon.
295    Connect(std::io::Error),
296    /// I/O failed while exchanging attach or stream frames.
297    Io(std::io::Error),
298    /// A daemon response or stream frame could not be decoded.
299    Decode(prost::DecodeError),
300    /// The daemon rejected the attach request.
301    Server {
302        /// Status code returned by the daemon.
303        code: StatusCode,
304        /// Human-readable error message returned by the daemon.
305        message: String,
306    },
307    /// The daemon never sent an AttachPtySessionResponse payload.
308    MissingPayload,
309}
310
311impl std::fmt::Display for AttachError {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        match self {
314            AttachError::Connect(e) => write!(f, "attach connect failed: {e}"),
315            AttachError::Io(e) => write!(f, "attach io error: {e}"),
316            AttachError::Decode(e) => write!(f, "attach decode error: {e}"),
317            AttachError::Server { code, message } => {
318                write!(f, "attach server error {code:?}: {message}")
319            }
320            AttachError::MissingPayload => write!(f, "attach response missing payload"),
321        }
322    }
323}
324
325impl std::error::Error for AttachError {}
326
327impl PtyAttachment {
328    /// Open a fresh socket to the daemon and attach to `session_id`.
329    pub fn attach(
330        scope_hash: Option<&str>,
331        session_id: &str,
332        rows: u16,
333        cols: u16,
334        steal: bool,
335    ) -> Result<Self, AttachError> {
336        let socket_path = paths::socket_path(scope_hash);
337        Self::attach_to(&socket_path, session_id, rows, cols, steal)
338    }
339
340    /// Open a fresh socket at `socket_path` and attach to `session_id`.
341    pub fn attach_to(
342        socket_path: &str,
343        session_id: &str,
344        rows: u16,
345        cols: u16,
346        steal: bool,
347    ) -> Result<Self, AttachError> {
348        let mut terminal_capabilities = current_terminal_capabilities();
349        if !terminal_capabilities.is_tty {
350            terminal_capabilities.is_tty = true;
351            terminal_capabilities.graphics = TerminalGraphicsCapabilities::unknown();
352        }
353        Self::attach_to_with_terminal_capabilities(
354            socket_path,
355            session_id,
356            rows,
357            cols,
358            steal,
359            terminal_capabilities,
360        )
361    }
362
363    /// Attach with explicit terminal metadata. This is useful for tests,
364    /// non-interactive attach clients, and callers that already performed
365    /// capability probing before opening the daemon socket.
366    pub fn attach_to_with_terminal_capabilities(
367        socket_path: &str,
368        session_id: &str,
369        rows: u16,
370        cols: u16,
371        steal: bool,
372        terminal_capabilities: TerminalCapabilities,
373    ) -> Result<Self, AttachError> {
374        // Bounded connect (issue #590, cluster B): a bound-but-never-
375        // accepting daemon socket must not wedge the attaching client.
376        paths::make_socket_endpoint(socket_path).map_err(AttachError::Connect)?;
377        let stream = crate::client::deadline_io::connect_with_timeout(socket_path)
378            .map_err(AttachError::Connect)?;
379        let stream_clone = stream.try_clone().map_err(AttachError::Connect)?;
380        let mut reader = BufReader::new(stream);
381        let mut writer = BufWriter::new(stream_clone);
382
383        // Send the AttachPtySession request.
384        let attach_request = DaemonRequest {
385            id: 1,
386            r#type: RequestType::AttachPtySession.into(),
387            protocol_version: 1,
388            client_name: "running-process-client".into(),
389            attach_pty_session: Some(AttachPtySessionRequest {
390                session_id: session_id.into(),
391                rows: rows as u32,
392                cols: cols as u32,
393                steal,
394                term: terminal_capabilities.term.unwrap_or_default(),
395                is_tty: terminal_capabilities.is_tty,
396                graphics_capabilities: Some(terminal_graphics_capabilities_to_proto(
397                    &terminal_capabilities.graphics,
398                )),
399            }),
400            ..Default::default()
401        };
402        write_length_prefixed(&mut writer, &attach_request.encode_to_vec())
403            .map_err(AttachError::Io)?;
404
405        // Read the initial response.
406        let response_bytes = read_length_prefixed(&mut reader).map_err(AttachError::Io)?;
407        let response = DaemonResponse::decode(&response_bytes[..]).map_err(AttachError::Decode)?;
408        if response.code != StatusCode::Ok as i32 {
409            let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
410            return Err(AttachError::Server {
411                code,
412                message: response.message,
413            });
414        }
415        let payload: AttachPtySessionResponse = response
416            .attach_pty_session
417            .ok_or(AttachError::MissingPayload)?;
418
419        Ok(Self {
420            reader,
421            writer,
422            initial_backlog: payload.backlog,
423            bytes_missed: payload.bytes_missed,
424        })
425    }
426
427    /// Block until the next stream frame arrives.
428    pub fn recv_frame(&mut self) -> Result<PtyStreamFrame, AttachError> {
429        let bytes = read_length_prefixed(&mut self.reader).map_err(AttachError::Io)?;
430        PtyStreamFrame::decode(&bytes[..]).map_err(AttachError::Decode)
431    }
432
433    /// Block until the next stream frame arrives, or until `timeout`
434    /// elapses (returns `Ok(None)`). The underlying socket is put into
435    /// nonblocking mode for the duration of the wait; callers should not
436    /// interleave this with `recv_frame`.
437    pub fn recv_frame_with_timeout(
438        &mut self,
439        timeout: Duration,
440    ) -> Result<Option<PtyStreamFrame>, AttachError> {
441        let deadline = std::time::Instant::now() + timeout;
442        self.reader
443            .get_ref()
444            .set_nonblocking(true)
445            .map_err(AttachError::Io)?;
446        let read_result =
447            crate::client::deadline_io::read_frame_with_deadline(&mut self.reader, deadline);
448        let restore_result = self.reader.get_ref().set_nonblocking(false);
449        if let Err(error) = restore_result {
450            return Err(AttachError::Io(error));
451        }
452
453        match read_result {
454            Ok(bytes) => PtyStreamFrame::decode(&bytes[..])
455                .map(Some)
456                .map_err(AttachError::Decode),
457            Err(error) if error.kind() == std::io::ErrorKind::TimedOut => Ok(None),
458            Err(error) => Err(AttachError::Io(error)),
459        }
460    }
461
462    /// Send raw input bytes to the PTY.
463    pub fn send_input(&mut self, bytes: &[u8]) -> Result<(), AttachError> {
464        let frame = PtyInputFrame {
465            frame: Some(InputOneof::Input(bytes.to_vec())),
466        };
467        write_length_prefixed(&mut self.writer, &frame.encode_to_vec()).map_err(AttachError::Io)
468    }
469
470    /// Send a resize event.
471    pub fn resize(&mut self, rows: u16, cols: u16) -> Result<(), AttachError> {
472        let frame = PtyInputFrame {
473            frame: Some(InputOneof::Resize(PtyResize {
474                rows: rows as u32,
475                cols: cols as u32,
476            })),
477        };
478        write_length_prefixed(&mut self.writer, &frame.encode_to_vec()).map_err(AttachError::Io)
479    }
480
481    /// Send an interrupt (Ctrl+C / SIGINT) to the child process group.
482    pub fn send_interrupt(&mut self) -> Result<(), AttachError> {
483        let frame = PtyInputFrame {
484            frame: Some(InputOneof::Interrupt(true)),
485        };
486        write_length_prefixed(&mut self.writer, &frame.encode_to_vec()).map_err(AttachError::Io)
487    }
488
489    /// Cleanly detach this attachment; the session keeps running.
490    pub fn detach(mut self) -> Result<(), AttachError> {
491        let frame = PtyInputFrame {
492            frame: Some(InputOneof::Detach(true)),
493        };
494        write_length_prefixed(&mut self.writer, &frame.encode_to_vec()).map_err(AttachError::Io)
495    }
496}
497
498// ---------------------------------------------------------------------------
499// Length-prefixed framing (matches the daemon's LengthDelimitedCodec)
500// ---------------------------------------------------------------------------
501
502fn write_length_prefixed<W: Write>(w: &mut W, payload: &[u8]) -> Result<(), std::io::Error> {
503    let len = payload.len() as u32;
504    w.write_all(&len.to_be_bytes())?;
505    w.write_all(payload)?;
506    w.flush()
507}
508
509fn read_length_prefixed<R: Read>(r: &mut R) -> Result<Vec<u8>, std::io::Error> {
510    let mut len_buf = [0u8; 4];
511    r.read_exact(&mut len_buf)?;
512    let len = u32::from_be_bytes(len_buf) as usize;
513    // Cap before allocating so a corrupt/desynced frame can't drive a
514    // multi-GiB allocation + unbounded read (issue #590, cluster B).
515    crate::client::deadline_io::check_frame_len(len)?;
516    let mut buf = vec![0u8; len];
517    r.read_exact(&mut buf)?;
518    Ok(buf)
519}
520
521// ---------------------------------------------------------------------------
522// Tests
523// ---------------------------------------------------------------------------
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn pty_spawn_request_builder_defaults() {
531        let req = PtySpawnRequest::new(["echo", "hi"])
532            .with_size(40, 100)
533            .with_originator("test:1");
534        assert_eq!(req.argv, vec!["echo".to_string(), "hi".to_string()]);
535        assert_eq!(req.rows, 40);
536        assert_eq!(req.cols, 100);
537        assert_eq!(req.originator.as_deref(), Some("test:1"));
538        assert_eq!(req.environment_policy, crate::EnvironmentPolicy::Clear);
539        assert!(req.clear_inherited_env);
540        assert!(!req.env.is_empty());
541    }
542
543    #[test]
544    fn pty_spawn_request_dual_writes_explicit_policy() {
545        let inherit = PtySpawnRequest::new(["echo"])
546            .with_environment_policy(crate::EnvironmentPolicy::Inherit);
547        assert_eq!(
548            inherit.environment_policy,
549            crate::EnvironmentPolicy::Inherit
550        );
551        assert!(!inherit.clear_inherited_env);
552
553        let baseline = PtySpawnRequest::new(["echo"])
554            .with_environment_policy(crate::EnvironmentPolicy::UserBaseline);
555        assert_eq!(
556            baseline.environment_policy,
557            crate::EnvironmentPolicy::UserBaseline
558        );
559        assert!(baseline.clear_inherited_env);
560    }
561}
562
563#[cfg(test)]
564#[path = "../tests/client_pty_session_coverage.rs"]
565mod coverage_tests;