Skip to main content

computer_protocol/
lib.rs

1use std::num::NonZeroU32;
2
3use des::{
4    Des,
5    cipher::{BlockCipherEncrypt, KeyInit},
6};
7use serde::{Deserialize, Serialize};
8
9pub mod act;
10mod desktop;
11mod files;
12mod shell;
13mod transfer;
14
15pub use act::{
16    ActReply, ActRequest, Action, ActionError, Button, Direction, Kind, Point, RawAction,
17};
18pub use desktop::{LaunchAppRequest, OpenPathRequest};
19pub use files::{
20    FileEntry, FileKind, ImageType, ListFilesReply, ListFilesRequest, MAX_IMAGE_BYTES,
21    MAX_WRITE_BYTES, ReadFileReply, ReadFileRequest, WriteFileReply, WriteFileRequest,
22};
23pub use shell::{
24    DEFAULT_SHELL_TIMEOUT_MAX_SECS, DEFAULT_SHELL_TIMEOUT_SECS, LONGEST_SHELL_TIMEOUT_SECS,
25    SetCwdReply, SetCwdRequest, ShellOutcome, ShellReply, ShellRequest, ShellTimeouts,
26    ShellTimeoutsError,
27};
28pub use transfer::{
29    DownloadRequest, MAX_LISTED_SKIPS, SKIP_REPORT_ENTRY, SkipList, Skipped, TransferReply,
30    UploadCheck, UploadQuery,
31};
32
33/// Version of the wire format between the host and `computerd`.
34pub const PROTOCOL_VERSION: u32 = 11;
35
36pub const RELEASE_VERSION: Option<&str> = match option_env!("COMPUTER_USE_MCP_VERSION") {
37    Some(version) if !version.is_empty() => Some(version),
38    _ => None,
39};
40
41pub const VERSION: &str = match RELEASE_VERSION {
42    Some(version) => version,
43    None => concat!(env!("CARGO_PKG_VERSION"), "-dev"),
44};
45
46/// Port `computerd` listens on inside the container.
47pub const API_PORT: u16 = 7070;
48
49/// Environment variable that carries the API token into the container.
50pub const TOKEN_ENV: &str = "COMPUTERD_TOKEN";
51
52/// Environment variable that tells `computerd` which host port its viewer port is published on.
53pub const HOST_PORT_BASE_ENV: &str = "COMPUTERD_HOST_PORT_BASE";
54
55/// Host port the viewer page is published on unless the user picks another base.
56pub const DEFAULT_PORT_BASE: u16 = 20900;
57
58/// Port of the viewer page and its WebSocket bridge inside the container.
59pub const VIEWER_PORT: u16 = 20900;
60
61/// Number of screens the computer has.
62pub const SCREEN_COUNT: u8 = 16;
63
64/// Port inside the container where raw VNC for `screen` (1 to [`SCREEN_COUNT`]) is served.
65#[must_use]
66pub fn vnc_port(screen: u8) -> u16 {
67    VIEWER_PORT + u16::from(screen)
68}
69
70/// Link that opens the viewer page when the host publishes it on `host_port`.
71///
72/// The password after `#key=` stays in the browser and is never sent to the server in a request line.
73#[must_use]
74pub fn viewer_link(host_port: u16, key: &str) -> String {
75    format!("http://127.0.0.1:{host_port}/#key={key}")
76}
77
78/// Link that opens the viewer page focused on `screen`.
79#[must_use]
80pub fn viewer_screen_link(host_port: u16, key: &str, screen: u8) -> String {
81    format!("http://127.0.0.1:{host_port}/#key={key}&screen={screen}")
82}
83
84/// Fixed key VNC uses to obfuscate the password in a password file.
85const VNC_FILE_KEY: [u8; 8] = [23, 82, 107, 6, 35, 78, 88, 7];
86
87/// Contents of a VNC password file for `key`, as `Xvnc` and `vncviewer -passwd` read it.
88///
89/// The key is cut or padded to 8 bytes and DES-encrypted with a fixed key. VNC bit-reverses every byte of a DES key.
90#[must_use]
91pub fn vnc_password_file(key: &str) -> [u8; 8] {
92    let mut block = [0u8; 8];
93    for (slot, byte) in block.iter_mut().zip(key.bytes()) {
94        *slot = byte;
95    }
96    let cipher = Des::new(&VNC_FILE_KEY.map(u8::reverse_bits).into());
97    let mut out = block.into();
98    cipher.encrypt_block(&mut out);
99    out.into()
100}
101
102/// Longest accepted session title, in characters.
103pub const MAX_TITLE_CHARS: usize = 80;
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct Health {
107    pub protocol_version: u32,
108    pub version: String,
109}
110
111/// What the host needs to know about the viewer.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ViewerInfo {
114    /// Password for the viewer page and for raw VNC.
115    pub key: String,
116    /// Viewer pages open in a browser now.
117    pub pages: usize,
118}
119
120/// Why a session title was refused.
121#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
122pub enum TitleError {
123    #[error("title must not be empty")]
124    Empty,
125    #[error("title is {0} characters long, the limit is {MAX_TITLE_CHARS}")]
126    TooLong(usize),
127}
128
129/// Short description of the task a session works on, 1 to 80 characters.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(try_from = "String", into = "String")]
132pub struct SessionTitle(String);
133
134impl SessionTitle {
135    /// Trims the text and checks its length.
136    ///
137    /// # Errors
138    ///
139    /// Fails when the trimmed text is empty or longer than [`MAX_TITLE_CHARS`].
140    pub fn parse(text: &str) -> Result<Self, TitleError> {
141        let text = text.trim();
142        let len = text.chars().count();
143        if len == 0 {
144            Err(TitleError::Empty)
145        } else if len > MAX_TITLE_CHARS {
146            Err(TitleError::TooLong(len))
147        } else {
148            Ok(Self(text.to_owned()))
149        }
150    }
151
152    #[must_use]
153    pub fn as_str(&self) -> &str {
154        &self.0
155    }
156}
157
158impl TryFrom<String> for SessionTitle {
159    type Error = TitleError;
160
161    fn try_from(text: String) -> Result<Self, Self::Error> {
162        Self::parse(&text)
163    }
164}
165
166impl From<SessionTitle> for String {
167    fn from(title: SessionTitle) -> Self {
168        title.0
169    }
170}
171
172/// Smallest accepted screen side, in pixels.
173pub const MIN_SCREEN_SIDE: u16 = 320;
174
175/// Largest accepted screen side, in pixels.
176pub const MAX_SCREEN_SIDE: u16 = 7680;
177
178/// Screen size in pixels. Sides range from [`MIN_SCREEN_SIDE`] to [`MAX_SCREEN_SIDE`].
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(try_from = "String", into = "String")]
181pub struct ScreenSize {
182    width: u16,
183    height: u16,
184}
185
186/// Why a screen size was refused.
187#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
188#[error(
189    "screen size must look like 1280x800, with each side from {MIN_SCREEN_SIDE} to {MAX_SCREEN_SIDE}"
190)]
191pub struct ScreenSizeError;
192
193impl ScreenSize {
194    pub const DEFAULT: Self = Self {
195        width: 1280,
196        height: 800,
197    };
198
199    /// Parses text such as `1280x800`.
200    ///
201    /// # Errors
202    ///
203    /// Fails when the text is not `<width>x<height>` or a side is out of range.
204    pub fn parse(text: &str) -> Result<Self, ScreenSizeError> {
205        let (width, height) = text.trim().split_once(['x', 'X']).ok_or(ScreenSizeError)?;
206        let side = |text: &str| {
207            text.parse::<u16>()
208                .ok()
209                .filter(|side| (MIN_SCREEN_SIDE..=MAX_SCREEN_SIDE).contains(side))
210                .ok_or(ScreenSizeError)
211        };
212        Ok(Self {
213            width: side(width)?,
214            height: side(height)?,
215        })
216    }
217
218    #[must_use]
219    pub fn width(self) -> u16 {
220        self.width
221    }
222
223    #[must_use]
224    pub fn height(self) -> u16 {
225        self.height
226    }
227}
228
229impl Default for ScreenSize {
230    fn default() -> Self {
231        Self::DEFAULT
232    }
233}
234
235impl std::fmt::Display for ScreenSize {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        write!(f, "{}x{}", self.width, self.height)
238    }
239}
240
241impl TryFrom<String> for ScreenSize {
242    type Error = ScreenSizeError;
243
244    fn try_from(text: String) -> Result<Self, Self::Error> {
245        Self::parse(&text)
246    }
247}
248
249impl From<ScreenSize> for String {
250    fn from(size: ScreenSize) -> Self {
251        size.to_string()
252    }
253}
254
255/// Body of `POST /sessions`.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct CreateSession {
258    pub title: SessionTitle,
259    /// Size of the session's screen, applied when the screen opens.
260    pub screen_size: ScreenSize,
261    /// Timeouts for the session's shell commands.
262    pub shell_timeouts: ShellTimeouts,
263    /// The MCP server process that keeps the session alive with heartbeats.
264    pub owner: OwnerId,
265    /// Seconds without agent calls after which the session ends.
266    pub idle_secs: NonZeroU32,
267}
268
269/// Seconds between the heartbeats an MCP server sends for its sessions.
270pub const HEARTBEAT_INTERVAL_SECS: u64 = 10;
271
272/// Seconds without a heartbeat after which the sessions of an owner end.
273pub const OWNER_TIMEOUT_SECS: u64 = 30;
274
275/// Seconds without agent calls after which a session ends, unless the host sets another time.
276pub const DEFAULT_IDLE_SECS: u32 = 3600;
277
278/// Length of an owner id, in hex digits.
279pub const OWNER_ID_LEN: usize = 32;
280
281/// Identifies one MCP server process: 32 lowercase hex digits, random per process.
282#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
283#[serde(try_from = "String", into = "String")]
284pub struct OwnerId(String);
285
286/// The text is not an owner id.
287#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
288#[error("not an owner id")]
289pub struct OwnerIdError;
290
291impl OwnerId {
292    /// Checks that the text is exactly 32 lowercase hex digits.
293    ///
294    /// # Errors
295    ///
296    /// Fails when the text has another length or other characters.
297    pub fn parse(text: &str) -> Result<Self, OwnerIdError> {
298        let valid = text.len() == OWNER_ID_LEN
299            && text
300                .bytes()
301                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
302        if valid {
303            Ok(Self(text.to_owned()))
304        } else {
305            Err(OwnerIdError)
306        }
307    }
308
309    #[must_use]
310    pub fn as_str(&self) -> &str {
311        &self.0
312    }
313}
314
315impl TryFrom<String> for OwnerId {
316    type Error = OwnerIdError;
317
318    fn try_from(text: String) -> Result<Self, Self::Error> {
319        Self::parse(&text)
320    }
321}
322
323impl From<OwnerId> for String {
324    fn from(id: OwnerId) -> Self {
325        id.0
326    }
327}
328
329impl std::fmt::Display for OwnerId {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        f.write_str(&self.0)
332    }
333}
334
335/// Length of a session id, in hex digits.
336pub const SESSION_ID_LEN: usize = 32;
337
338/// The id `computerd` issues for a session: 32 lowercase hex digits.
339#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
340#[serde(try_from = "String", into = "String")]
341pub struct SessionId(String);
342
343/// The text is not a session id.
344#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
345#[error("not a session id")]
346pub struct SessionIdError;
347
348impl SessionId {
349    /// Checks that the text has the format `computerd` issues.
350    ///
351    /// # Errors
352    ///
353    /// Fails when the text is not exactly 32 lowercase hex digits.
354    pub fn parse(text: &str) -> Result<Self, SessionIdError> {
355        let valid = text.len() == SESSION_ID_LEN
356            && text
357                .bytes()
358                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
359        if valid {
360            Ok(Self(text.to_owned()))
361        } else {
362            Err(SessionIdError)
363        }
364    }
365
366    #[must_use]
367    pub fn as_str(&self) -> &str {
368        &self.0
369    }
370}
371
372impl TryFrom<String> for SessionId {
373    type Error = SessionIdError;
374
375    fn try_from(text: String) -> Result<Self, Self::Error> {
376        Self::parse(&text)
377    }
378}
379
380impl From<SessionId> for String {
381    fn from(id: SessionId) -> Self {
382        id.0
383    }
384}
385
386impl std::fmt::Display for SessionId {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.write_str(&self.0)
389    }
390}
391
392/// Reply to `POST /sessions`.
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394pub struct SessionCreated {
395    pub session: SessionId,
396}
397
398/// Pointer position on the screen, in pixels.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400pub struct Cursor {
401    pub x: i16,
402    pub y: i16,
403}
404
405/// Reply to `POST /sessions/{id}/observe`.
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct Observation {
408    /// Changes whenever the screen content changed since the previous frame.
409    pub frame_id: u64,
410    /// Capture time as an RFC 3339 UTC timestamp.
411    pub captured_at: String,
412    pub width: u16,
413    pub height: u16,
414    pub cursor: Cursor,
415    /// Title of the focused window, empty when there is none.
416    pub active_window: String,
417    /// Base64 PNG of the screen. `None` when the frame is the one the session saw last.
418    pub png_base64: Option<String>,
419    /// Screen number this call opened for the session, set on a reply to `observe` and never inside an [`ActReply`].
420    #[serde(default)]
421    pub opened_screen: Option<u8>,
422}
423
424/// Body of every error reply from `computerd`.
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426pub struct ApiError {
427    pub message: String,
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn the_password_file_is_the_des_obfuscation_xvnc_reads() {
436        assert_eq!(
437            vnc_password_file("abcd2345"),
438            [255, 232, 190, 74, 23, 18, 52, 125],
439            "bytes from the same encoding that Xvnc accepted in the Docker test"
440        );
441    }
442
443    #[test]
444    fn title_limits_count_characters_after_trimming() {
445        let longest = "é".repeat(MAX_TITLE_CHARS);
446        assert_eq!(
447            SessionTitle::parse(&format!("  {longest}  "))
448                .unwrap()
449                .as_str(),
450            longest
451        );
452        assert_eq!(
453            SessionTitle::parse(&format!("{longest}x")),
454            Err(TitleError::TooLong(MAX_TITLE_CHARS + 1))
455        );
456        assert_eq!(SessionTitle::parse(" \t"), Err(TitleError::Empty));
457    }
458
459    #[test]
460    fn session_ids_must_be_exactly_32_lowercase_hex_digits() {
461        let good = "0123456789abcdef0123456789abcdef";
462        assert_eq!(SessionId::parse(good).unwrap().as_str(), good);
463        for bad in [
464            "",
465            "../health",
466            "0123456789abcdef0123456789abcde",
467            "0123456789abcdef0123456789abcdef0",
468            "0123456789ABCDEF0123456789abcdef",
469            "0123456789abcdef0123456789abcde/",
470        ] {
471            assert_eq!(SessionId::parse(bad), Err(SessionIdError), "{bad}");
472        }
473    }
474
475    #[test]
476    fn screen_size_parses_width_by_height_within_limits() {
477        let size = ScreenSize::parse(" 1920x1080 ").unwrap();
478        assert_eq!((size.width(), size.height()), (1920, 1080));
479        assert_eq!(size.to_string(), "1920x1080");
480        for bad in [
481            "",
482            "1280",
483            "1280x",
484            "x800",
485            "1280x800x2",
486            "319x800",
487            "1280x7681",
488            "-1x800",
489            "axb",
490        ] {
491            assert_eq!(ScreenSize::parse(bad), Err(ScreenSizeError), "{bad}");
492        }
493    }
494
495    #[test]
496    fn request_body_with_a_bad_title_is_refused() {
497        let body = serde_json::json!({ "title": "", "screen_size": "1280x800", "shell_timeouts": { "default_secs": 120, "max_secs": 600 } });
498        assert!(serde_json::from_value::<CreateSession>(body).is_err());
499    }
500}