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