1#![cfg(unix)]
8
9pub mod actions;
10
11use anyhow::{Context, Result};
12use std::ffi::OsStr;
13use std::fs::File;
14use std::io::{Read, Write};
15use std::os::unix::ffi::OsStrExt;
16use std::os::unix::io::{FromRawFd, OwnedFd, RawFd};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use tokio::io::unix::AsyncFd;
20use tokio::sync::mpsc;
21use tracing::{error, info};
22
23use crate::llm::action_helper::call_llm;
24use crate::llm::ollama_client::OllamaClient;
25use crate::llm::ActionResult;
26use crate::logging::emit::Log;
27use crate::protocol::Event;
28use crate::server::pty::actions::{PtyProtocol, PTY_INPUT_RECEIVED_EVENT, PTY_OPENED_EVENT};
29use crate::state::app_state::AppState;
30use crate::utils::WireFailure;
31
32struct LinkCleanup(Option<PathBuf>);
35
36impl Drop for LinkCleanup {
37 fn drop(&mut self) {
38 if let Some(p) = &self.0 {
39 let _ = std::fs::remove_file(p);
40 }
41 }
42}
43
44fn slave_device_path(slave_fd: RawFd) -> Result<PathBuf> {
46 let mut buf = vec![0u8; 256];
47 let rc = unsafe { libc::ttyname_r(slave_fd, buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
49 if rc != 0 {
50 return Err(std::io::Error::from_raw_os_error(rc))
51 .context("ttyname_r failed to resolve slave PTY device path");
52 }
53 let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char) };
54 Ok(PathBuf::from(OsStr::from_bytes(cstr.to_bytes())))
55}
56
57fn ensure_symlink(link: &Path, target: &Path) -> Result<()> {
63 match std::fs::symlink_metadata(link) {
64 Ok(meta) => {
65 if meta.file_type().is_symlink() {
66 std::fs::remove_file(link)
67 .with_context(|| format!("Failed to replace existing symlink {:?}", link))?;
68 } else {
69 anyhow::bail!(
70 "Refusing to use link_path {:?}: it exists and is not a symlink. Delete it \
71 yourself or pass a different link_path.",
72 link
73 );
74 }
75 }
76 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
77 Err(e) => return Err(e).with_context(|| format!("Failed to stat {:?}", link)),
78 }
79 std::os::unix::fs::symlink(target, link)
80 .with_context(|| format!("Failed to symlink {:?} -> {:?}", link, target))
81}
82
83fn set_slave_raw(slave_fd: RawFd) -> Result<()> {
87 unsafe {
88 let mut termios: libc::termios = std::mem::zeroed();
89 if libc::tcgetattr(slave_fd, &mut termios) != 0 {
90 return Err(std::io::Error::last_os_error()).context("tcgetattr on slave PTY failed");
91 }
92 libc::cfmakeraw(&mut termios);
93 if libc::tcsetattr(slave_fd, libc::TCSANOW, &termios) != 0 {
94 return Err(std::io::Error::last_os_error()).context("tcsetattr on slave PTY failed");
95 }
96 }
97 Ok(())
98}
99
100fn set_nonblocking(fd: RawFd) -> Result<()> {
102 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
103 if flags < 0 {
104 return Err(std::io::Error::last_os_error()).context("fcntl F_GETFL on PTY master failed");
105 }
106 let rc = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
107 if rc < 0 {
108 return Err(std::io::Error::last_os_error()).context("fcntl F_SETFL on PTY master failed");
109 }
110 Ok(())
111}
112
113pub struct PtyServer;
115
116impl PtyServer {
117 pub async fn spawn_with_llm_actions(
122 link_path: Option<PathBuf>,
123 send_first: bool,
124 llm_client: OllamaClient,
125 app_state: Arc<AppState>,
126 status_tx: mpsc::UnboundedSender<String>,
127 server_id: crate::state::ServerId,
128 ) -> Result<()> {
129 let (master_raw, slave_raw) = unsafe {
131 let mut master: libc::c_int = -1;
132 let mut slave: libc::c_int = -1;
133 if libc::openpty(
134 &mut master,
135 &mut slave,
136 std::ptr::null_mut(),
137 std::ptr::null_mut(),
138 std::ptr::null_mut(),
139 ) != 0
140 {
141 return Err(std::io::Error::last_os_error()).context("openpty failed");
142 }
143 (master, slave)
144 };
145 let master_file = unsafe { File::from_raw_fd(master_raw) };
148 let slave_fd = unsafe { OwnedFd::from_raw_fd(slave_raw) };
151
152 set_slave_raw(slave_raw)?;
153
154 let slave_path = slave_device_path(slave_raw)?;
155 info!("PTY allocated, slave device {:?}", slave_path);
156
157 if let Some(link) = &link_path {
158 ensure_symlink(link, &slave_path)?;
159 info!("PTY slave symlinked at {:?}", link);
160 }
161 let cleanup = LinkCleanup(link_path.clone());
162
163 set_nonblocking(master_raw)?;
165 let async_master = AsyncFd::new(master_file)
166 .context("Failed to register PTY master with the async runtime")?;
167
168 Log::new(Some(&status_tx)).info(format!(
169 "PTY server ready, slave device {}",
170 slave_path.to_string_lossy()
171 ));
172
173 let protocol = Arc::new(PtyProtocol::new());
174 let task_registrar = app_state.clone();
175
176 let handle = tokio::spawn(async move {
177 let _slave_fd = slave_fd;
181 let _cleanup = cleanup;
182
183 if send_first {
185 let event = Event::new(
186 &PTY_OPENED_EVENT,
187 serde_json::json!({ "slave_path": slave_path.to_string_lossy() }),
188 );
189 Self::dispatch(
190 &event,
191 &async_master,
192 &llm_client,
193 &app_state,
194 server_id,
195 protocol.as_ref(),
196 &status_tx,
197 )
198 .await;
199 }
200
201 let mut buffer = vec![0u8; 8192];
202 loop {
203 let mut guard = match async_master.readable().await {
204 Ok(g) => g,
205 Err(e) => {
206 error!("PTY master readable() error: {}", e);
207 break;
208 }
209 };
210
211 let read_result = guard.try_io(|inner| inner.get_ref().read(&mut buffer));
212
213 let n = match read_result {
214 Ok(Ok(0)) => {
215 guard.clear_ready();
222 continue;
223 }
224 Ok(Ok(n)) => n,
225 Ok(Err(e)) => {
226 if e.raw_os_error() == Some(libc::EIO) {
228 guard.clear_ready();
229 continue;
230 }
231 error!("PTY master read error: {}", e);
232 break;
233 }
234 Err(_would_block) => continue,
235 };
236
237 let data = &buffer[..n];
238 let (data_str, encoding) = if data
239 .iter()
240 .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
241 {
242 (String::from_utf8_lossy(data).to_string(), "utf8")
243 } else {
244 (hex::encode(data), "hex")
245 };
246
247 Log::new(Some(&status_tx))
248 .debug(format!("PTY received {} bytes of input ({})", n, encoding));
249
250 let event = Event::new(
251 &PTY_INPUT_RECEIVED_EVENT,
252 serde_json::json!({ "data": data_str, "encoding": encoding }),
253 );
254 Self::dispatch(
255 &event,
256 &async_master,
257 &llm_client,
258 &app_state,
259 server_id,
260 protocol.as_ref(),
261 &status_tx,
262 )
263 .await;
264 }
265 });
266
267 task_registrar.register_server_task(server_id, handle).await;
268 Ok(())
269 }
270
271 #[allow(clippy::too_many_arguments)]
273 async fn dispatch(
274 event: &Event,
275 async_master: &AsyncFd<File>,
276 llm_client: &OllamaClient,
277 app_state: &Arc<AppState>,
278 server_id: crate::state::ServerId,
279 protocol: &PtyProtocol,
280 status_tx: &mpsc::UnboundedSender<String>,
281 ) {
282 match call_llm(llm_client, app_state, server_id, None, event, protocol).await {
283 Ok(result) => {
284 for msg in result.messages {
285 let _ = status_tx.send(msg);
286 }
287 let mut wrote = 0usize;
288 for pr in result.protocol_results {
289 if let ActionResult::Output(bytes) = pr {
290 wrote += 1;
291 Self::write_master(async_master, &bytes, status_tx).await;
292 }
293 }
295 if wrote == 0 {
299 info!(
300 "PTY {} decision=model_no_output",
301 event.event_type.id.as_str()
302 );
303 }
304 }
305 Err(e) => {
306 let failure = WireFailure::classify(&e);
309 let decision = if failure.is_overloaded() {
310 "fail_closed_overloaded"
311 } else {
312 "fail_closed_llm_error"
313 };
314 error!(
315 "PTY {} decision={} error={}",
316 event.event_type.id.as_str(),
317 decision,
318 e
319 );
320 Log::new(Some(status_tx))
321 .error(format!("PTY LLM error (decision={decision}): {e}"));
322 let notice = format!("\r\n[{}]\r\n", failure.prefixed_text());
326 Self::write_master(async_master, notice.as_bytes(), status_tx).await;
327 }
328 }
329 }
330
331 async fn write_master(
333 async_master: &AsyncFd<File>,
334 bytes: &[u8],
335 status_tx: &mpsc::UnboundedSender<String>,
336 ) {
337 let mut written = 0;
338 while written < bytes.len() {
339 let mut guard = match async_master.writable().await {
340 Ok(g) => g,
341 Err(e) => {
342 Log::new(Some(status_tx)).error(format!("PTY write error: {e}"));
343 return;
344 }
345 };
346 match guard.try_io(|inner| inner.get_ref().write(&bytes[written..])) {
347 Ok(Ok(0)) => break,
348 Ok(Ok(n)) => written += n,
349 Ok(Err(e)) => {
350 Log::new(Some(status_tx)).error(format!("PTY write error: {e}"));
351 return;
352 }
353 Err(_would_block) => continue,
354 }
355 }
356 Log::new(Some(status_tx)).debug(format!("PTY wrote {} bytes to terminal", written));
359 }
360}