1mod 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 #[tokio::test]
65 async fn commands_run_over_mock_ws() {
66 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 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 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 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
96 });
97
98 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 #[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 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 #[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 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 #[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 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 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 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 assert!(json.get("cpu").is_none());
321 assert!(json.get("fromSnapshot").is_none());
322 assert_eq!(sbx.kind(), "sandbox");
324 assert_eq!(sbx.stream_url(), None);
325 }
326
327 #[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 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 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 #[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 #[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}