solari/client.rs
1//! `Client` (REST create/connect/get/kill) and the `Sandbox` handle exposing
2//! `.commands()`, `.files()`, `.code()`, and `.git()`. Mirrors
3//! `sandbox-client.ts` (REST shapes) + `SessionHandle` (namespaces).
4
5use std::collections::HashMap;
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9
10use crate::channel::ControlChannel;
11use crate::code::Code;
12use crate::commands::{run_command, Commands, RunOptions};
13use crate::error::SolariError;
14use crate::files::Files;
15use crate::git::{Git, GitRunner};
16use crate::http::{encode_uri_component, new_idempotency_key, HttpOptions, HttpTransport};
17use crate::types::{
18 CommandResult, CreateSandboxRequest, CreateSandboxResponse, Lifecycle, ResumeResponse, StartResponse,
19 SandboxView, VolumeAttachment,
20};
21
22/// Options for constructing a [`Client`].
23#[derive(Debug, Clone)]
24pub struct ClientOptions {
25 pub api_key: String,
26 pub base_url: String,
27 /// Per-call control-WS RPC timeout override (ms). Defaults to 300000.
28 pub call_timeout_ms: Option<u64>,
29}
30
31impl ClientOptions {
32 pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
33 ClientOptions {
34 api_key: api_key.into(),
35 base_url: base_url.into(),
36 call_timeout_ms: None,
37 }
38 }
39}
40
41/// Options accepted by `POST /sandboxes` (headless sandbox flavour).
42#[derive(Default, Clone)]
43pub struct CreateOptions {
44 pub template: Option<String>,
45 pub cpu: Option<u32>,
46 pub mem_mb: Option<u32>,
47 pub disk_gb: Option<u32>,
48 pub envs: Option<HashMap<String, String>>,
49 pub metadata: Option<HashMap<String, String>>,
50 pub timeout_ms: Option<u64>,
51 /// Legacy, gateway-side FALLBACK idle window in seconds, only used when
52 /// `timeout_ms` is unset — prefer `timeout_ms`.
53 pub ttl_seconds: Option<u64>,
54 pub from_snapshot: Option<String>,
55 /// VM isolation tier: `"shared"` (sandbox-only), `"dedicated"` (default)
56 /// or `"hardened"` (own VM, no outbound network). See `types::CreateSandboxRequest::isolation`.
57 pub isolation: Option<String>,
58 pub lifecycle: Option<Lifecycle>,
59 /// Initial display resolution, e.g. `"1280x720"`. Desktops only — a headless
60 /// sandbox has no display.
61 pub resolution: Option<String>,
62 /// Record the session server-side; the create response carries a presigned
63 /// playback URL. Desktops only.
64 pub record: Option<bool>,
65 /// Persistent volumes to mount before the session starts.
66 pub volumes: Option<Vec<VolumeAttachment>>,
67}
68
69impl CreateOptions {
70 /// Build the `POST /sandboxes` body for a given VM flavour.
71 ///
72 /// THE SINGLE PLACE an option becomes a wire field, and therefore the place
73 /// the cross-language wire contract is pinned (see `tests/contract_wire.rs`).
74 /// A field added above but not mapped here is silently invisible on the wire.
75 pub(crate) fn into_request(self, kind: &str) -> CreateSandboxRequest {
76 CreateSandboxRequest {
77 template: self.template,
78 kind: Some(kind.to_string()),
79 cpu: self.cpu,
80 mem_mb: self.mem_mb,
81 disk_gb: self.disk_gb,
82 envs: self.envs,
83 metadata: self.metadata,
84 timeout_ms: self.timeout_ms,
85 ttl_seconds: self.ttl_seconds,
86 from_snapshot: self.from_snapshot,
87 isolation: self.isolation,
88 lifecycle: self.lifecycle,
89 resolution: self.resolution,
90 record: self.record,
91 volumes: self.volumes,
92 }
93 }
94}
95
96impl From<CreateOptions> for CreateSandboxRequest {
97 fn from(o: CreateOptions) -> Self {
98 o.into_request("sandbox")
99 }
100}
101
102/// Talks the SDK ⇆ Gateway `/sandboxes` REST API and hands back [`Sandbox`]es.
103pub struct Client {
104 http: Arc<HttpTransport>,
105 call_timeout_ms: Option<u64>,
106}
107
108impl Client {
109 pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
110 let http = HttpTransport::new(HttpOptions::new(opts.api_key, opts.base_url))?;
111 Ok(Client {
112 http: Arc::new(http),
113 call_timeout_ms: opts.call_timeout_ms,
114 })
115 }
116
117 /// Create a new headless sandbox (`POST /sandboxes`).
118 pub async fn create(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
119 self.create_with_kind(opts, "sandbox").await
120 }
121
122 /// Create a GUI desktop VM (`POST /sandboxes` with `kind:"desktop"`).
123 ///
124 /// Mirrors TS `createDesktop()` / Python `create_desktop()`. The returned
125 /// handle carries [`Sandbox::stream_url`] for the live view, and exposes the
126 /// same core namespaces as a sandbox (`commands`/`files`/`code`/`git`).
127 /// Driving the GUI (mouse/keyboard/screenshot) is not part of this surface.
128 ///
129 /// Requires the `desktop` entitlement on the org's plan; the gateway
130 /// answers 402 `FeatureRequiresPlan` otherwise.
131 pub async fn create_desktop(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
132 self.create_with_kind(opts, "desktop").await
133 }
134
135 async fn create_with_kind(
136 &self,
137 opts: CreateOptions,
138 kind: &str,
139 ) -> Result<Sandbox, SolariError> {
140 let req = opts.into_request(kind);
141 let body = serde_json::to_value(&req).unwrap();
142 let resp: CreateSandboxResponse = self
143 .http
144 .request("POST", "/sandboxes", Some(body), Some(new_idempotency_key()))
145 .await?;
146 Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms)
147 }
148
149 /// Re-attach to a running sandbox by id (`GET /sandboxes/:id`).
150 pub async fn connect(&self, sandbox_id: &str) -> Result<Sandbox, SolariError> {
151 // Guard B: the caller supplied this id, so a blank one is their input to
152 // fix -> Validation (distinct from the response-side Other above).
153 if sandbox_id.trim().is_empty() {
154 return Err(SolariError::Validation {
155 message: "Solari: connect() requires a non-blank sandbox id.".to_string(),
156 });
157 }
158 let view = self.get(sandbox_id).await?;
159 let control_url = view.control_url.clone().unwrap_or_else(|| {
160 format!(
161 "{}/control/{}",
162 self.http.ws_origin(),
163 encode_uri_component(sandbox_id)
164 )
165 });
166 // `GET /sandboxes/:id` carries no streamUrl (the gateway's view
167 // serializer omits it), so derive it exactly as control_url is derived
168 // above — both are built from the same origin. Desktops only: a
169 // headless sandbox has no display to stream.
170 let stream_url = if view.kind == "desktop" {
171 Some(format!(
172 "{}/stream/{}",
173 self.http.ws_origin(),
174 encode_uri_component(sandbox_id)
175 ))
176 } else {
177 None
178 };
179 let resp = CreateSandboxResponse {
180 sandbox_id: view.sandbox_id,
181 kind: view.kind,
182 control_url,
183 expires_at: view.expires_at,
184 stream_url,
185 };
186 Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms)
187 }
188
189 /// Fetch a sandbox's current view (`GET /sandboxes/:id`).
190 pub async fn get(&self, sandbox_id: &str) -> Result<SandboxView, SolariError> {
191 self.http
192 .request(
193 "GET",
194 &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
195 None,
196 None,
197 )
198 .await
199 }
200
201 /// Destroy a sandbox (`DELETE /sandboxes/:id`). Idempotent.
202 pub async fn kill(&self, sandbox_id: &str) -> Result<(), SolariError> {
203 let _: serde_json::Value = self
204 .http
205 .request(
206 "DELETE",
207 &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
208 None,
209 None,
210 )
211 .await?;
212 Ok(())
213 }
214
215 /// Pause a session (`POST /sandboxes/:id/pause`): snapshot its RAM+disk and
216 /// free its host slot. The session keeps its id; bring it back with
217 /// [`Client::resume`].
218 pub async fn pause(&self, sandbox_id: &str) -> Result<(), SolariError> {
219 let _: serde_json::Value = self
220 .http
221 .request(
222 "POST",
223 &format!("/sandboxes/{}/pause", encode_uri_component(sandbox_id)),
224 None,
225 None,
226 )
227 .await?;
228 Ok(())
229 }
230
231 /// Resume a paused session (`POST /sandboxes/:id/resume`) and return the
232 /// control URL to re-attach to.
233 ///
234 /// The session comes back on a FRESH slot, so its pre-pause control URL is
235 /// stale. The gateway normally returns the new one; when it does not, derive
236 /// it from the gateway origin exactly as [`Client::connect`] does.
237 pub async fn resume(&self, sandbox_id: &str) -> Result<String, SolariError> {
238 let r: ResumeResponse = self
239 .http
240 .request(
241 "POST",
242 &format!("/sandboxes/{}/resume", encode_uri_component(sandbox_id)),
243 None,
244 None,
245 )
246 .await?;
247 Ok(r.control_url.unwrap_or_else(|| {
248 format!(
249 "{}/control/{}",
250 self.http.ws_origin(),
251 encode_uri_component(sandbox_id)
252 )
253 }))
254 }
255
256 /// Cold-boot a STOPPED sandbox (`POST /sandboxes/:id/start`) from the disk the
257 /// host kept, and return the fresh control URL to re-attach to.
258 ///
259 /// A sandbox is stopped when its guest powered itself off. This is a cold
260 /// boot, not a resume: running processes and memory are gone, files on disk
261 /// are kept. It takes a concurrency slot (429 at the cap) and opens a new
262 /// billing segment. Idempotent: an already-running sandbox answers 200 and its
263 /// current control URL.
264 ///
265 /// Any other state is refused with `SolariError::Gateway { status: 409, code }`
266 /// where `code` is `NotStopped` (use [`Client::resume`] for a paused one) or
267 /// `StartInProgress`. A stopped disk that cannot be started answers 409 with
268 /// its code (`StoppedDiskMissing`, `StoppedDiskUnsafe`, `GoldenMismatch`,
269 /// `DiskPlatformMismatch`, `DiskShaMismatch`, `DiskExceedsSpec`,
270 /// `DiskOwnerMismatch`). 409 `HostCapabilityMissing` means the host pool is not
271 /// armed for start (not retryable). 503 `NoCapacity` / `StartTimeout` is
272 /// `SolariError::NoCapacity` and 502 `StartHostError` is a gateway error, both
273 /// retryable; 500 `StartFailed` is not, and leaves the sandbox stopped with its
274 /// disk, so a later start is safe.
275 pub async fn start(&self, sandbox_id: &str) -> Result<String, SolariError> {
276 let r: StartResponse = self
277 .http
278 .request(
279 "POST",
280 &format!("/sandboxes/{}/start", encode_uri_component(sandbox_id)),
281 None,
282 None,
283 )
284 .await?;
285 Ok(r.control_url.unwrap_or_else(|| {
286 format!(
287 "{}/control/{}",
288 self.http.ws_origin(),
289 encode_uri_component(sandbox_id)
290 )
291 }))
292 }
293}
294
295/// A live sandbox handle. Exposes the core namespaces.
296pub struct Sandbox {
297 id: String,
298 kind: String,
299 control_url: String,
300 stream_url: Option<String>,
301 expires_at: String,
302 http: Arc<HttpTransport>,
303 channel: Arc<ControlChannel>,
304}
305
306impl Sandbox {
307 pub(crate) fn from_response(
308 http: Arc<HttpTransport>,
309 resp: CreateSandboxResponse,
310 call_timeout_ms: Option<u64>,
311 ) -> Result<Self, SolariError> {
312 // A blank id in the response builds a nearly-right "/sandboxes//exec"
313 // (double slash), not an obviously wrong path, so it slips through while
314 // every call the handle makes is malformed. A blank id in a RESPONSE is
315 // a bad reply from our side, not caller input -> Other. (Test blank.)
316 if resp.sandbox_id.trim().is_empty() {
317 return Err(SolariError::Other(
318 "Solari: the session response carried a blank id - a blank id builds a malformed /sandboxes// request"
319 .to_string(),
320 ));
321 }
322 let channel = ControlChannel::new(
323 resp.control_url.clone(),
324 http.auth_headers(),
325 call_timeout_ms,
326 );
327 Ok(Sandbox {
328 id: resp.sandbox_id,
329 kind: resp.kind,
330 control_url: resp.control_url,
331 stream_url: resp.stream_url,
332 expires_at: resp.expires_at,
333 http,
334 channel: Arc::new(channel),
335 })
336 }
337
338 /// The session id.
339 pub fn id(&self) -> &str {
340 &self.id
341 }
342 /// The VM flavour — `"sandbox"` or `"desktop"`.
343 pub fn kind(&self) -> &str {
344 &self.kind
345 }
346 /// The control-WS URL.
347 pub fn control_url(&self) -> &str {
348 &self.control_url
349 }
350 /// The live-view stream URL. `Some` for desktops only — a headless sandbox
351 /// has no display, so the gateway omits it.
352 pub fn stream_url(&self) -> Option<&str> {
353 self.stream_url.as_deref()
354 }
355 /// ISO-8601 expiry timestamp.
356 pub fn expires_at(&self) -> &str {
357 &self.expires_at
358 }
359 /// Whether the control channel is open.
360 pub fn connected(&self) -> bool {
361 self.channel.connected()
362 }
363
364 /// Open the control WebSocket. Idempotent.
365 pub async fn connect(&self) -> Result<(), SolariError> {
366 self.channel.connect().await
367 }
368
369 /// Close the control channel locally (does NOT release the remote session).
370 pub fn close(&self) {
371 self.channel.close();
372 }
373
374 /// Pause this session: snapshot its RAM+disk, free its host slot, and close
375 /// the control channel. The session keeps its id; bring it back with
376 /// [`Sandbox::resume`].
377 ///
378 /// Mirrors TS `handle.pause()` / Python `handle.pause()`: remote call first,
379 /// then the local channel close.
380 pub async fn pause(&self) -> Result<(), SolariError> {
381 let _: serde_json::Value = self
382 .http
383 .request(
384 "POST",
385 &format!("/sandboxes/{}/pause", encode_uri_component(&self.id)),
386 None,
387 None,
388 )
389 .await?;
390 self.close();
391 Ok(())
392 }
393
394 /// Resume this paused session and re-point the control channel at the fresh
395 /// slot it came back on.
396 ///
397 /// Mirrors TS `handle.resume()` / Python `handle.resume()`: resume, adopt the
398 /// new control URL, reconnect.
399 ///
400 /// Takes `&mut self` where the rest of the handle takes `&self`: resuming
401 /// genuinely replaces this handle's `control_url`, and [`Sandbox::control_url`]
402 /// hands out a borrow of it. Interior mutability would let that borrow go
403 /// stale behind the caller's back — the compiler should be the one saying
404 /// this call mutates the handle.
405 pub async fn resume(&mut self) -> Result<(), SolariError> {
406 let r: ResumeResponse = self
407 .http
408 .request(
409 "POST",
410 &format!("/sandboxes/{}/resume", encode_uri_component(&self.id)),
411 None,
412 None,
413 )
414 .await?;
415 let control_url = r.control_url.unwrap_or_else(|| {
416 format!(
417 "{}/control/{}",
418 self.http.ws_origin(),
419 encode_uri_component(&self.id)
420 )
421 });
422 self.control_url = control_url.clone();
423 self.channel.set_control_url(control_url);
424 self.channel.reconnect().await
425 }
426
427 /// Cold-boot this STOPPED session from its kept disk and re-point the control
428 /// channel at the fresh control URL. Processes and memory are gone, files on
429 /// disk are kept. See [`Client::start`] for the refusal codes.
430 ///
431 /// Takes `&mut self` for the same reason as [`Sandbox::resume`]: it replaces
432 /// this handle's `control_url`.
433 pub async fn start(&mut self) -> Result<(), SolariError> {
434 let r: StartResponse = self
435 .http
436 .request(
437 "POST",
438 &format!("/sandboxes/{}/start", encode_uri_component(&self.id)),
439 None,
440 None,
441 )
442 .await?;
443 let control_url = r.control_url.unwrap_or_else(|| {
444 format!(
445 "{}/control/{}",
446 self.http.ws_origin(),
447 encode_uri_component(&self.id)
448 )
449 });
450 self.control_url = control_url.clone();
451 self.channel.set_control_url(control_url);
452 self.channel.reconnect().await
453 }
454
455 /// Destroy the remote session and close the channel. Idempotent.
456 pub async fn kill(&self) -> Result<(), SolariError> {
457 let _: serde_json::Value = self
458 .http
459 .request(
460 "DELETE",
461 &format!("/sandboxes/{}", encode_uri_component(&self.id)),
462 None,
463 None,
464 )
465 .await?;
466 self.close();
467 Ok(())
468 }
469
470 // --- namespaces ----------------------------------------------------------
471
472 pub fn commands(&self) -> Commands<'_> {
473 Commands { sb: self }
474 }
475 pub fn files(&self) -> Files<'_> {
476 Files { sb: self }
477 }
478 pub fn code(&self) -> Code<'_> {
479 Code { sb: self }
480 }
481 pub fn git(&self) -> Git<'_> {
482 Git::new(self)
483 }
484
485 // --- internal accessors (used by the namespace modules) ------------------
486
487 pub(crate) fn channel(&self) -> &ControlChannel {
488 &self.channel
489 }
490 pub(crate) fn channel_arc(&self) -> Arc<ControlChannel> {
491 self.channel.clone()
492 }
493 pub(crate) fn http(&self) -> &HttpTransport {
494 &self.http
495 }
496}
497
498/// `Sandbox` runs `git` through `commands.run("git", …)` (no shell).
499impl GitRunner for Sandbox {
500 fn run_git<'a>(
501 &'a self,
502 args: Vec<String>,
503 cwd: Option<String>,
504 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
505 Box::pin(async move {
506 let opts = RunOptions {
507 args: Some(args),
508 cwd,
509 ..Default::default()
510 };
511 run_command(self, "git", &opts).await
512 })
513 }
514}