Skip to main content

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