Skip to main content

solari/
lib.rs

1//! # solari-sdk
2//!
3//! Rust binding for the Solari sandbox SDK (core surface). Talks the same wire
4//! protocol as the reference `@solarisdk/core` TypeScript package:
5//!
6//! - **REST** to the gateway for session lifecycle (create/connect/get/kill) and
7//!   the one-shot `/exec` fast path.
8//! - **Control WebSocket** (newline-delimited JSON-RPC) for everything a live
9//!   session does: `commands`, `files`, `code.run`, and the client-side `git`
10//!   namespace.
11//!
12//! ```no_run
13//! use solari::{Client, ClientOptions, CreateOptions, RunOptions};
14//!
15//! # async fn ex() -> Result<(), solari::SolariError> {
16//! let client = Client::new(ClientOptions::with_api_key("slr_live_…"))?;
17//! let sbx = client.create(CreateOptions { template: Some("base".into()), ..Default::default() }).await?;
18//! let out = sbx.commands().run("echo", RunOptions::new().args(["hello"])).await?;
19//! println!("exit={} stdout={}", out.exit_code, out.stdout);
20//! let status = sbx.git().status(Some("/repo")).await?;
21//! println!("branch={} clean={}", status.branch, status.clean);
22//! # Ok(()) }
23//! ```
24
25mod channel;
26#[cfg(test)]
27mod concurrency_tests;
28mod client;
29mod code;
30mod commands;
31mod error;
32mod files;
33mod git;
34mod http;
35mod types;
36
37pub use channel::{AsyncFrame, ControlChannel};
38pub use client::{Client, ClientOptions, CreateOptions, Sandbox, DEFAULT_BASE_URL};
39pub use code::{Code, RunCodeOptions};
40pub use commands::{CommandHandle, CommandIo, Commands, OutputCallback, RunOptions};
41pub use error::SolariError;
42pub use files::Files;
43pub use git::{
44    Git, GitCloneOptions, GitCommitOptions, GitLogOptions, GitRemoteOptions, GitRunner,
45};
46pub use http::{new_idempotency_key, HttpOptions, HttpTransport};
47pub use types::{
48    Chart, ChartAxis, ChartType, CodeResultItem, CommandResult, CreateSandboxRequest,
49    CreateSandboxResponse, FsEntry, FsStat, GatewayErrorBody, GitBranch, GitCommit, GitStatus,
50    Lifecycle, ResumeResponse, RunCodeResult, SandboxView, StartResponse, VolumeAttachment,
51};
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use base64::Engine;
57    use futures_util::{SinkExt, StreamExt};
58    use std::sync::{Arc, Mutex};
59    use tokio::io::{AsyncReadExt, AsyncWriteExt};
60    use tokio::net::TcpListener;
61
62    /// commands().run happy path over an in-process mock control-WS that scripts
63    /// start-reply → cmd.data → cmd.exit.
64    #[tokio::test]
65    async fn commands_run_over_mock_ws() {
66        // Mock control-WS server.
67        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
68        let ws_port = listener.local_addr().unwrap().port();
69        tokio::spawn(async move {
70            let (stream, _) = listener.accept().await.unwrap();
71            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
72            // Read the cmd.start request frame.
73            let msg = ws.next().await.unwrap().unwrap();
74            let text = msg.into_text().unwrap();
75            let req: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
76            assert_eq!(req.get("method").unwrap(), "cmd.start");
77            let id = req.get("id").unwrap().as_str().unwrap().to_string();
78            let hello = base64::engine::general_purpose::STANDARD.encode("hello\n");
79            let world = base64::engine::general_purpose::STANDARD.encode("oops");
80            // start reply, then data (stdout+stderr), then exit.
81            let frames = vec![
82                format!("{{\"id\":\"{id}\",\"ok\":true,\"result\":{{\"cmdId\":\"c1\"}}}}"),
83                format!("{{\"type\":\"cmd.data\",\"cmdId\":\"c1\",\"stream\":\"stdout\",\"base64\":\"{hello}\"}}"),
84                format!("{{\"type\":\"cmd.data\",\"cmdId\":\"c1\",\"stream\":\"stderr\",\"base64\":\"{world}\"}}"),
85                "{\"type\":\"cmd.exit\",\"cmdId\":\"c1\",\"exitCode\":3}".to_string(),
86            ];
87            for f in frames {
88                ws.send(tokio_tungstenite::tungstenite::Message::Text(
89                    format!("{f}\n").into(),
90                ))
91                .await
92                .unwrap();
93            }
94            // keep the socket open briefly so frames flush.
95            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
96        });
97
98        // Build a sandbox pointed at the mock WS. base_url is unused because we
99        // pre-connect the channel (fast path is skipped when connected).
100        let resp = CreateSandboxResponse {
101            sandbox_id: "sb".into(),
102            kind: "sandbox".into(),
103            control_url: format!("ws://127.0.0.1:{ws_port}/control/sb"),
104            expires_at: "".into(),
105            stream_url: None,
106            recording_url: None,
107        };
108        let sbx = Sandbox::from_response(
109            Arc::new(HttpTransport::new(HttpOptions::new("k", "http://127.0.0.1:1")).unwrap()),
110            resp,
111            Some(5000),
112        )
113        .unwrap();
114        sbx.connect().await.unwrap();
115
116        let out = sbx.commands().run("echo", RunOptions::new().args(["hello"])).await.unwrap();
117        assert_eq!(out.exit_code, 3);
118        assert_eq!(out.stdout, "hello\n");
119        assert_eq!(out.stderr, "oops");
120    }
121
122    /// `timeout_ms` is a command deadline on the WebSocket path: the command is killed (SIGKILL) and
123    /// the result is exit -1 with the output so far.
124    #[tokio::test]
125    async fn commands_run_timeout_kills_the_command() {
126        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
127        let ws_port = listener.local_addr().unwrap().port();
128        let killed = Arc::new(Mutex::new(None::<serde_json::Value>));
129        let killed_srv = killed.clone();
130        tokio::spawn(async move {
131            let (stream, _) = listener.accept().await.unwrap();
132            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
133            let msg = ws.next().await.unwrap().unwrap();
134            let req: serde_json::Value = serde_json::from_str(msg.into_text().unwrap().as_str()).unwrap();
135            let id = req.get("id").unwrap().as_str().unwrap().to_string();
136            let out = base64::engine::general_purpose::STANDARD.encode("partial\n");
137            for f in [
138                format!("{{\"id\":\"{id}\",\"ok\":true,\"result\":{{\"cmdId\":\"c1\"}}}}"),
139                format!("{{\"type\":\"cmd.data\",\"cmdId\":\"c1\",\"stream\":\"stdout\",\"base64\":\"{out}\"}}"),
140            ] {
141                ws.send(tokio_tungstenite::tungstenite::Message::Text(format!("{f}\n").into())).await.unwrap();
142            }
143            // The command never exits; the next frame must be cmd.kill.
144            if let Some(Ok(msg)) = ws.next().await {
145                let req: serde_json::Value = serde_json::from_str(msg.into_text().unwrap().as_str()).unwrap();
146                let id = req.get("id").unwrap().as_str().unwrap().to_string();
147                *killed_srv.lock().unwrap() = Some(req);
148                let _ = ws
149                    .send(tokio_tungstenite::tungstenite::Message::Text(
150                        format!("{{\"id\":\"{id}\",\"ok\":true,\"result\":{{\"ok\":true}}}}\n").into(),
151                    ))
152                    .await;
153            }
154            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
155        });
156
157        let resp = CreateSandboxResponse {
158            sandbox_id: "sb".into(),
159            kind: "sandbox".into(),
160            control_url: format!("ws://127.0.0.1:{ws_port}/control/sb"),
161            expires_at: "".into(),
162            stream_url: None,
163            recording_url: None,
164        };
165        let sbx = Sandbox::from_response(
166            Arc::new(HttpTransport::new(HttpOptions::new("k", "http://127.0.0.1:1")).unwrap()),
167            resp,
168            Some(5000),
169        )
170        .unwrap();
171        sbx.connect().await.unwrap();
172
173        let started = std::time::Instant::now();
174        let mut opts = RunOptions::new().args(["3600"]);
175        opts.timeout_ms = Some(80);
176        let out = sbx.commands().run("sleep", opts).await.unwrap();
177        assert!(started.elapsed() < std::time::Duration::from_secs(3), "run did not honour timeout_ms");
178        assert_eq!(out.exit_code, -1);
179        assert_eq!(out.stdout, "partial\n");
180        let kill = killed.lock().unwrap().clone().expect("cmd.kill was sent");
181        assert_eq!(kill["method"], "cmd.kill");
182        assert_eq!(kill["params"]["signal"], 9);
183        assert_eq!(kill["params"]["cmdId"], "c1");
184    }
185
186    /// files().list / files().stat must parse the guest wire shape exactly:
187    /// the directory flag is "dir" (not "isDir") and the stat timestamp is
188    /// "modTimeMs". Regression guard for the wire-field bug.
189    #[tokio::test]
190    async fn files_list_and_stat_parse_wire_shape() {
191        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
192        let ws_port = listener.local_addr().unwrap().port();
193        tokio::spawn(async move {
194            let (stream, _) = listener.accept().await.unwrap();
195            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
196            // Answer each RPC by echoing its id and switching on its method.
197            for _ in 0..2 {
198                let msg = ws.next().await.unwrap().unwrap();
199                let text = msg.into_text().unwrap();
200                let req: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
201                let id = req.get("id").unwrap().as_str().unwrap().to_string();
202                let method = req.get("method").unwrap().as_str().unwrap();
203                let result = match method {
204                    "fs.list" => "{\"entries\":[{\"name\":\"a.txt\",\"dir\":false,\"size\":12},{\"name\":\"sub\",\"dir\":true,\"size\":4096}]}".to_string(),
205                    "fs.stat" => "{\"name\":\"a.txt\",\"dir\":false,\"size\":12,\"mode\":420,\"modTimeMs\":1720000000000}".to_string(),
206                    _ => "{}".to_string(),
207                };
208                ws.send(tokio_tungstenite::tungstenite::Message::Text(
209                    format!("{{\"id\":\"{id}\",\"ok\":true,\"result\":{result}}}\n").into(),
210                ))
211                .await
212                .unwrap();
213            }
214            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
215        });
216
217        let resp = CreateSandboxResponse {
218            sandbox_id: "sb".into(),
219            kind: "sandbox".into(),
220            control_url: format!("ws://127.0.0.1:{ws_port}/control/sb"),
221            expires_at: "".into(),
222            stream_url: None,
223            recording_url: None,
224        };
225        let sbx = Sandbox::from_response(
226            Arc::new(HttpTransport::new(HttpOptions::new("k", "http://127.0.0.1:1")).unwrap()),
227            resp,
228            Some(5000),
229        )
230        .unwrap();
231        sbx.connect().await.unwrap();
232
233        let entries = sbx.files().list("/work").await.unwrap();
234        assert_eq!(entries.len(), 2);
235        assert_eq!(entries[0].name, "a.txt");
236        assert!(!entries[0].dir);
237        assert_eq!(entries[0].size, 12);
238        assert_eq!(entries[1].name, "sub");
239        assert!(entries[1].dir);
240        assert_eq!(entries[1].size, 4096);
241
242        let st = sbx.files().stat("/work/a.txt").await.unwrap();
243        assert_eq!(st.name, "a.txt");
244        assert!(!st.dir);
245        assert_eq!(st.size, 12);
246        assert_eq!(st.mode, 420);
247        assert_eq!(st.mod_time_ms, 1720000000000);
248    }
249
250    /// REST create request shape: method/path, headers (Authorization,
251    /// Idempotency-Key, Accept, Content-Type), and body (template + kind).
252    #[tokio::test]
253    async fn create_rest_request_shape() {
254        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
255        let port = listener.local_addr().unwrap().port();
256        let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
257        let cap = captured.clone();
258        tokio::spawn(async move {
259            let (mut sock, _) = listener.accept().await.unwrap();
260            // Read until end of headers, then read the Content-Length body.
261            let mut buf = Vec::new();
262            let mut tmp = [0u8; 1024];
263            loop {
264                let n = sock.read(&mut tmp).await.unwrap();
265                if n == 0 {
266                    break;
267                }
268                buf.extend_from_slice(&tmp[..n]);
269                if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
270                    let head = String::from_utf8_lossy(&buf[..pos]).to_string();
271                    let clen = content_length(&head).unwrap_or(0);
272                    let body_start = pos + 4;
273                    while buf.len() < body_start + clen {
274                        let n = sock.read(&mut tmp).await.unwrap();
275                        if n == 0 {
276                            break;
277                        }
278                        buf.extend_from_slice(&tmp[..n]);
279                    }
280                    *cap.lock().unwrap() = Some(String::from_utf8_lossy(&buf).to_string());
281                    break;
282                }
283            }
284            let body = r#"{"sandboxId":"sbx_1","kind":"sandbox","controlUrl":"ws://x/control/sbx_1","expiresAt":"2026-01-01T00:00:00Z"}"#;
285            let resp = format!(
286                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
287                body.len(),
288                body
289            );
290            sock.write_all(resp.as_bytes()).await.unwrap();
291            sock.flush().await.unwrap();
292        });
293
294        let client =
295            Client::new(ClientOptions::new("secret-key", format!("http://127.0.0.1:{port}"))).unwrap();
296        let sbx = client
297            .create(CreateOptions {
298                template: Some("base".into()),
299                ..Default::default()
300            })
301            .await
302            .unwrap();
303        assert_eq!(sbx.id(), "sbx_1");
304        assert_eq!(sbx.control_url(), "ws://x/control/sbx_1");
305
306        let raw = captured.lock().unwrap().clone().unwrap();
307        let (head, body) = raw.split_once("\r\n\r\n").unwrap();
308        // Request line + headers.
309        assert!(head.starts_with("POST /sandboxes "), "head: {head}");
310        let lower = head.to_lowercase();
311        assert!(lower.contains("authorization: bearer secret-key"), "head: {head}");
312        assert!(lower.contains("accept: application/json"));
313        assert!(lower.contains("content-type: application/json"));
314        assert!(lower.contains("idempotency-key: "), "missing idempotency key: {head}");
315        // Body.
316        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
317        assert_eq!(json.get("template").unwrap(), "base");
318        assert_eq!(json.get("kind").unwrap(), "sandbox");
319        // Unset fields are omitted.
320        assert!(json.get("cpu").is_none());
321        assert!(json.get("fromSnapshot").is_none());
322        // A headless sandbox has no display: the gateway sends no streamUrl.
323        assert_eq!(sbx.kind(), "sandbox");
324        assert_eq!(sbx.stream_url(), None);
325    }
326
327    /// `create_desktop` sends `kind:"desktop"` and surfaces the returned
328    /// streamUrl on the handle.
329    #[tokio::test]
330    async fn create_desktop_sends_kind_and_surfaces_stream_url() {
331        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
332        let port = listener.local_addr().unwrap().port();
333        let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
334        let cap = captured.clone();
335        tokio::spawn(async move {
336            let (mut sock, _) = listener.accept().await.unwrap();
337            let mut buf = Vec::new();
338            let mut tmp = [0u8; 1024];
339            loop {
340                let n = sock.read(&mut tmp).await.unwrap();
341                if n == 0 {
342                    break;
343                }
344                buf.extend_from_slice(&tmp[..n]);
345                if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
346                    let head = String::from_utf8_lossy(&buf[..pos]).to_string();
347                    let clen = content_length(&head).unwrap_or(0);
348                    let body_start = pos + 4;
349                    while buf.len() < body_start + clen {
350                        let n = sock.read(&mut tmp).await.unwrap();
351                        if n == 0 {
352                            break;
353                        }
354                        buf.extend_from_slice(&tmp[..n]);
355                    }
356                    *cap.lock().unwrap() = Some(String::from_utf8_lossy(&buf).to_string());
357                    break;
358                }
359            }
360            // Mirrors the gateway: streamUrl is present for kind:"desktop".
361            let body = r#"{"sandboxId":"sbx_d","kind":"desktop","controlUrl":"ws://x/control/sbx_d","expiresAt":"2026-01-01T00:00:00Z","streamUrl":"ws://x/stream/sbx_d"}"#;
362            let resp = format!(
363                "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
364                body.len(),
365                body
366            );
367            sock.write_all(resp.as_bytes()).await.unwrap();
368            sock.flush().await.unwrap();
369        });
370
371        let client =
372            Client::new(ClientOptions::new("secret-key", format!("http://127.0.0.1:{port}"))).unwrap();
373        let sbx = client
374            .create_desktop(CreateOptions {
375                template: Some("default".into()),
376                cpu: Some(2),
377                ..Default::default()
378            })
379            .await
380            .unwrap();
381
382        assert_eq!(sbx.id(), "sbx_d");
383        assert_eq!(sbx.kind(), "desktop");
384        assert_eq!(sbx.stream_url(), Some("ws://x/stream/sbx_d"));
385        assert_eq!(sbx.control_url(), "ws://x/control/sbx_d");
386
387        let raw = captured.lock().unwrap().clone().unwrap();
388        let (head, body) = raw.split_once("\r\n\r\n").unwrap();
389        assert!(head.starts_with("POST /sandboxes "), "head: {head}");
390        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
391        // The whole point: the gateway keys desktop-ness off this field.
392        assert_eq!(json.get("kind").unwrap(), "desktop");
393        assert_eq!(json.get("template").unwrap(), "default");
394        assert_eq!(json.get("cpu").unwrap(), 2);
395    }
396
397    fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
398        haystack.windows(needle.len()).position(|w| w == needle)
399    }
400
401    fn content_length(head: &str) -> Option<usize> {
402        for line in head.lines() {
403            if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
404                return v.trim().parse().ok();
405            }
406        }
407        None
408    }
409
410    // The recording link from a create that asked for one reaches the handle (it used to be discarded), and the
411    // one-shot result keeps `timedOut`.
412    #[tokio::test]
413    async fn recording_url_and_timed_out_are_kept() {
414        let resp: CreateSandboxResponse = serde_json::from_str(
415            r#"{"sandboxId":"sbx_r","kind":"desktop","controlUrl":"ws://x/control/sbx_r","expiresAt":"","recordingUrl":"https://rec.example/r/1"}"#,
416        )
417        .unwrap();
418        let http = Arc::new(HttpTransport::new(HttpOptions::new("k", "http://127.0.0.1:1")).unwrap());
419        let sbx = Sandbox::from_response(http, resp, Some(5000)).unwrap();
420        assert_eq!(sbx.recording_url(), Some("https://rec.example/r/1"));
421
422        let r: crate::types::CommandResult =
423            serde_json::from_str(r#"{"exitCode":-1,"stdout":"","stderr":"","timedOut":true}"#).unwrap();
424        assert!(r.timed_out && r.exit_code == -1);
425        let ok: crate::types::CommandResult = serde_json::from_str(r#"{"exitCode":0,"stdout":"x","stderr":""}"#).unwrap();
426        assert!(!ok.timed_out);
427    }
428
429    // A blank id builds a nearly-right "/sandboxes//exec" (double slash), so it
430    // is rejected where it enters: Other for a blank id in a response (our
431    // side), Validation for a blank id the caller passed to connect().
432    #[tokio::test]
433    async fn blank_id_guard() {
434        let resp = CreateSandboxResponse {
435            sandbox_id: "".into(),
436            kind: "sandbox".into(),
437            control_url: "ws://127.0.0.1:1/control/".into(),
438            expires_at: "".into(),
439            stream_url: None,
440            recording_url: None,
441        };
442        let http = Arc::new(HttpTransport::new(HttpOptions::new("k", "http://127.0.0.1:1")).unwrap());
443        match Sandbox::from_response(http, resp, Some(5000)) {
444            Err(SolariError::Other(_)) => {}
445            Err(e) => panic!("from_response(blank) = Err({e:?}), want Err(Other)"),
446            Ok(_) => panic!("from_response(blank) = Ok, want Err(Other)"),
447        }
448
449        let client = Client::new(ClientOptions::new("k", "http://127.0.0.1:1")).unwrap();
450        for id in ["", "   ", "\t"] {
451            match client.connect(id).await {
452                Err(SolariError::Validation { .. }) => {}
453                Err(e) => panic!("connect({id:?}) = Err({e:?}), want Validation"),
454                Ok(_) => panic!("connect({id:?}) = Ok, want Validation"),
455            }
456        }
457    }
458}
459
460#[cfg(test)]
461mod default_base_url_tests {
462    use super::*;
463
464    #[test]
465    fn with_api_key_uses_the_hosted_api() {
466        assert_eq!(DEFAULT_BASE_URL, "https://api.getsolari.com");
467        assert_eq!(ClientOptions::with_api_key("k").base_url, DEFAULT_BASE_URL);
468    }
469
470    #[test]
471    fn empty_base_url_builds_a_client() {
472        assert!(Client::new(ClientOptions::new("k", "")).is_ok());
473    }
474}