Skip to main content

computer_use/
overlay.rs

1//! Agent-cursor overlay protocol.
2//!
3//! `computer-use overlay serve` listens on `<cache>/overlay.sock`. The main
4//! CLI sends one JSON line `{x,y,session}` after a successful positional
5//! action. A missing helper adds the warning `overlay helper not running`
6//! and does not fail the action. Tests pass a temp socket path and do not
7//! open a window.
8
9#![allow(unsafe_code)]
10
11#[cfg(unix)]
12use std::io::{self, Write};
13use std::path::{Path, PathBuf};
14#[cfg(unix)]
15use std::time::Duration;
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::ComputerError;
20
21std::thread_local! {
22    static SOCKET_PATH: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
23}
24
25/// Restores the previous overlay socket override.
26pub struct SocketPathGuard {
27    previous: Option<PathBuf>,
28}
29
30impl Drop for SocketPathGuard {
31    fn drop(&mut self) {
32        let previous = self.previous.take();
33        SOCKET_PATH.with(|slot| *slot.borrow_mut() = previous);
34    }
35}
36
37/// Send overlay points to `path` until the guard drops.
38pub fn push_socket_path(path: impl Into<PathBuf>) -> SocketPathGuard {
39    let previous = SOCKET_PATH.with(|slot| slot.borrow_mut().replace(path.into()));
40    SocketPathGuard { previous }
41}
42
43/// One cursor position for the helper.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct OverlayPoint {
46    /// Screen x in logical points.
47    pub x: i32,
48    /// Screen y in logical points.
49    pub y: i32,
50    /// Session id. The helper picks a ring color from this string.
51    pub session: String,
52}
53
54/// Newline-terminated JSON for one point.
55pub fn encode_point(x: i32, y: i32, session: &str) -> String {
56    let point = OverlayPoint {
57        x,
58        y,
59        session: session.to_string(),
60    };
61    let mut line = serde_json::to_string(&point).unwrap_or_else(|_| "{}".to_string());
62    line.push('\n');
63    line
64}
65
66/// Parse one socket line. Binary input and non-objects are rejected.
67pub fn parse_overlay_line(bytes: &[u8]) -> Result<OverlayPoint, &'static str> {
68    let text = std::str::from_utf8(bytes).map_err(|_| "overlay message is not utf-8 json")?;
69    let text = text.trim();
70    if text.is_empty() {
71        return Err("overlay message is empty");
72    }
73    if !text.starts_with('{') {
74        return Err("overlay message is not a json object");
75    }
76    let point: OverlayPoint =
77        serde_json::from_str(text).map_err(|_| "overlay message is missing x, y, or session")?;
78    Ok(point)
79}
80
81/// Hue in `0..360` for a session id. Stable for the same string.
82pub fn session_hue(session: &str) -> u32 {
83    let mut hash: u32 = 2_166_136_261;
84    for byte in session.bytes() {
85        hash ^= u32::from(byte);
86        hash = hash.wrapping_mul(16_777_619);
87    }
88    hash % 360
89}
90
91/// Tell the helper about a point. `Some(warning)` when it is not listening.
92///
93/// Tests that did not call [`push_socket_path`] do nothing, so they never
94/// touch the real cache socket.
95pub fn note_point(x: i32, y: i32, session: &str) -> Option<&'static str> {
96    let path = SOCKET_PATH
97        .with(|slot| slot.borrow().clone())
98        .or_else(production_socket)?;
99    if deliver(&path, x, y, session).is_err() {
100        Some("overlay helper not running")
101    } else {
102        None
103    }
104}
105
106#[cfg(all(not(test), target_os = "macos"))]
107fn production_socket() -> Option<PathBuf> {
108    Some(crate::cache::cache_dir().join("overlay.sock"))
109}
110
111#[cfg(not(all(not(test), target_os = "macos")))]
112fn production_socket() -> Option<PathBuf> {
113    None
114}
115
116/// Run the overlay helper. Off macOS this is `unsupported`.
117pub fn serve() -> Result<(), ComputerError> {
118    crate::backend::serve_overlay()
119}
120
121fn deliver(path: &Path, x: i32, y: i32, session: &str) -> Result<(), ()> {
122    #[cfg(unix)]
123    {
124        unix_send(path, &encode_point(x, y, session))
125    }
126    #[cfg(not(unix))]
127    {
128        let _ = (path, x, y, session);
129        Err(())
130    }
131}
132
133#[cfg(unix)]
134fn unix_send(path: &Path, line: &str) -> Result<(), ()> {
135    let mut stream = connect_timeout(path, Duration::from_millis(200))?;
136    stream
137        .set_write_timeout(Some(Duration::from_millis(200)))
138        .map_err(|_| ())?;
139    stream.write_all(line.as_bytes()).map_err(|_| ())?;
140    Ok(())
141}
142
143#[cfg(unix)]
144fn connect_timeout(path: &Path, timeout: Duration) -> Result<std::os::unix::net::UnixStream, ()> {
145    use std::os::fd::FromRawFd;
146    use std::os::unix::ffi::OsStrExt;
147    let bytes = path.as_os_str().as_bytes();
148    // SAFETY: the socket is created here and either returned via `from_raw_fd` or closed.
149    // The address points at a stack `sockaddr_un` that outlives `connect` and `poll`.
150    // The length includes the `sun_len` field on macOS and the trailing NUL, matching `std`.
151    unsafe {
152        let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
153        if fd < 0 {
154            return Err(());
155        }
156        let flags = libc::fcntl(fd, libc::F_GETFL, 0);
157        if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
158            libc::close(fd);
159            return Err(());
160        }
161        let mut addr: libc::sockaddr_un = std::mem::zeroed();
162        addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
163        if bytes.contains(&0) || bytes.len() >= addr.sun_path.len() {
164            libc::close(fd);
165            return Err(());
166        }
167        std::ptr::copy_nonoverlapping(
168            bytes.as_ptr(),
169            addr.sun_path.as_mut_ptr().cast::<u8>(),
170            bytes.len(),
171        );
172        let mut len = std::mem::offset_of!(libc::sockaddr_un, sun_path) + bytes.len();
173        if bytes.first().is_some_and(|byte| *byte != 0) {
174            len += 1;
175        }
176        let rc = libc::connect(fd, (&raw const addr).cast(), len as libc::socklen_t);
177        if rc < 0 {
178            let err = io::Error::last_os_error();
179            let in_progress = err.raw_os_error() == Some(libc::EINPROGRESS)
180                || err.raw_os_error() == Some(libc::EAGAIN)
181                || err.kind() == io::ErrorKind::WouldBlock;
182            if !in_progress {
183                libc::close(fd);
184                return Err(());
185            }
186            let mut pollfd = libc::pollfd {
187                fd,
188                events: libc::POLLOUT,
189                revents: 0,
190            };
191            let ms = timeout.as_millis().min(i32::MAX as u128) as libc::c_int;
192            if libc::poll(&mut pollfd, 1, ms) <= 0 {
193                libc::close(fd);
194                return Err(());
195            }
196            let mut so_error: libc::c_int = 0;
197            let mut so_len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
198            let got = libc::getsockopt(
199                fd,
200                libc::SOL_SOCKET,
201                libc::SO_ERROR,
202                (&raw mut so_error).cast(),
203                &mut so_len,
204            );
205            if got < 0 || so_error != 0 {
206                libc::close(fd);
207                return Err(());
208            }
209        }
210        if flags >= 0 {
211            let _ = libc::fcntl(fd, libc::F_SETFL, flags);
212        }
213        Ok(std::os::unix::net::UnixStream::from_raw_fd(fd))
214    }
215}
216
217/// Remove a stale socket, bind, and set mode `0600`.
218#[cfg(unix)]
219#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
220pub(crate) fn bind_socket(path: &Path) -> io::Result<std::os::unix::net::UnixListener> {
221    use std::os::unix::fs::PermissionsExt;
222    use std::os::unix::net::UnixListener;
223    if let Some(parent) = path.parent() {
224        fs_create(parent)?;
225        fs_set_mode(parent, 0o700);
226    }
227    if path.exists() {
228        std::fs::remove_file(path)?;
229    }
230    let listener = UnixListener::bind(path)?;
231    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
232    Ok(listener)
233}
234
235#[cfg(unix)]
236#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
237fn fs_create(path: &Path) -> io::Result<()> {
238    std::fs::create_dir_all(path)
239}
240
241#[cfg(unix)]
242#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))]
243fn fs_set_mode(path: &Path, mode: u32) {
244    use std::os::unix::fs::PermissionsExt;
245    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn parser_accepts_a_json_line_and_rejects_a_binary_blob() {
254        let line = encode_point(10, 20, "session-a");
255        let point = parse_overlay_line(line.as_bytes()).unwrap();
256        assert_eq!(
257            point,
258            OverlayPoint {
259                x: 10,
260                y: 20,
261                session: "session-a".into(),
262            }
263        );
264        assert!(parse_overlay_line(&[0xff, 0xfe, 0x00]).is_err());
265        assert!(parse_overlay_line(b"not-json\n").is_err());
266        assert!(session_hue("notes") < 360);
267        assert_eq!(session_hue("notes"), session_hue("notes"));
268    }
269
270    #[test]
271    fn missing_helper_warns_and_a_live_socket_receives_the_line() {
272        // `sockaddr_un` is about 100 bytes. A long temp directory does not fit.
273        #[cfg(unix)]
274        let dir = std::path::PathBuf::from(format!("/tmp/cuo{}", std::process::id()));
275        #[cfg(not(unix))]
276        let dir = std::env::temp_dir().join(format!("cuo{}", std::process::id()));
277        let _ = std::fs::remove_dir_all(&dir);
278        std::fs::create_dir_all(&dir).unwrap();
279        let missing = dir.join("missing.sock");
280        let _guard = push_socket_path(&missing);
281        assert_eq!(note_point(3, 4, "s"), Some("overlay helper not running"));
282        drop(_guard);
283
284        #[cfg(unix)]
285        {
286            use std::io::BufRead;
287            let path = dir.join("overlay.sock");
288            let listener = bind_socket(&path).unwrap();
289            let _guard = push_socket_path(&path);
290            let server = std::thread::spawn(move || {
291                let (stream, _) = listener.accept().unwrap();
292                let mut line = String::new();
293                std::io::BufReader::new(stream)
294                    .read_line(&mut line)
295                    .unwrap();
296                line
297            });
298            assert_eq!(note_point(8, 9, "agent"), None);
299            let line = server.join().unwrap();
300            let point = parse_overlay_line(line.as_bytes()).unwrap();
301            assert_eq!(point.x, 8);
302            assert_eq!(point.y, 9);
303            assert_eq!(point.session, "agent");
304        }
305        let _ = std::fs::remove_dir_all(&dir);
306    }
307}