1mod 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 #[tokio::test]
63 async fn commands_run_over_mock_ws() {
64 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 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 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 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
94 });
95
96 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 #[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 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 #[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 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 #[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 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 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 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 assert!(json.get("cpu").is_none());
319 assert!(json.get("fromSnapshot").is_none());
320 assert_eq!(sbx.kind(), "sandbox");
322 assert_eq!(sbx.stream_url(), None);
323 }
324
325 #[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 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 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 #[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 #[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}