1use 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#[derive(Debug, Clone)]
38pub struct PtySpawnRequest {
39 pub argv: Vec<String>,
41 pub cwd: Option<PathBuf>,
43 pub env: Vec<(String, String)>,
45 pub clear_inherited_env: bool,
47 pub environment_policy: crate::EnvironmentPolicy,
49 pub rows: u16,
51 pub cols: u16,
53 pub originator: Option<String>,
55}
56
57impl PtySpawnRequest {
58 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 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
75 self.cwd = Some(cwd.into());
76 self
77 }
78
79 pub fn with_size(mut self, rows: u16, cols: u16) -> Self {
81 self.rows = rows;
82 self.cols = cols;
83 self
84 }
85
86 pub fn with_originator(mut self, originator: impl Into<String>) -> Self {
88 self.originator = Some(originator.into());
89 self
90 }
91
92 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 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#[derive(Debug, Clone)]
120pub struct SpawnedPtySession {
121 pub session_id: String,
123 pub pid: u32,
125 pub created_at: f64,
127}
128
129impl DaemonClient {
130 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 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 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 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
272pub struct PtyAttachment {
281 reader: BufReader<Stream>,
282 writer: BufWriter<Stream>,
283 pub initial_backlog: Vec<u8>,
286 pub bytes_missed: u64,
289}
290
291#[derive(Debug)]
293pub enum AttachError {
294 Connect(std::io::Error),
296 Io(std::io::Error),
298 Decode(prost::DecodeError),
300 Server {
302 code: StatusCode,
304 message: String,
306 },
307 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 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 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 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 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 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 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 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 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 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 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 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 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
498fn 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 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#[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;