Skip to main content

sail/
client.rs

1//! The Sail client: the canonical async surface that owns configuration and
2//! transport, shared by every binding (Python, TypeScript, CLI).
3//!
4//! [`Client`] is a cheap-to-clone handle (`Arc` inside, like `reqwest::Client`):
5//! clone it freely to share the connection pools and config. Construct it with
6//! [`Client::from_env`] or [`Client::builder`].
7//!
8//! Every method is `async`. Synchronous callers (the PyO3 bridge with the GIL
9//! released, the CLI) drive these futures with
10//! [`crate::block_on`]; an async host awaits them directly.
11//!
12//! ```no_run
13//! # async fn run() -> Result<(), sail::error::SailError> {
14//! use sail::Client;
15//!
16//! // From the environment (SAIL_API_KEY):
17//! let client = Client::from_env()?;
18//! let page = client.list_sailboxes(&Default::default()).await?;
19//! println!("{} sailboxes", page.items.len());
20//!
21//! // Or build one explicitly:
22//! let client = Client::builder("sk_...").build()?;
23//! let app = client.find_app("my-app", /* mint_if_missing */ true).await?;
24//! # let _ = (client, app);
25//! # Ok(())
26//! # }
27//! ```
28
29use std::sync::Arc;
30use std::time::Duration;
31use time::OffsetDateTime;
32
33use crate::app::{self, App};
34use crate::config::Config;
35use crate::error::{RpcStatus, SailError};
36use crate::exec::{ExecOptions, ExecParams, ExecProcess, ExecResult, OutputStream};
37use crate::http::HttpCore;
38use crate::imagebuilder::ImageBuilder;
39use crate::sailbox::api::{SailboxApi, UpgradeResult};
40use crate::sailbox::fs::{DirEntry, EntryType};
41use crate::sailbox::object::Sailbox;
42use crate::sailbox::types::{
43    CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
44    SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
45    SailboxSpendResponse, VolumeInfo, WhoAmI,
46};
47use crate::worker::{FileReader, FileWriter, Listener, WorkerProxy, WriteOptions};
48
49/// A configured Sail client. Cheap to clone; shares transport across clones.
50#[derive(Clone)]
51pub struct Client {
52    inner: Arc<Inner>,
53}
54
55impl std::fmt::Debug for Client {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("Client")
58            .field("config", &self.inner.config)
59            .finish_non_exhaustive()
60    }
61}
62
63struct Inner {
64    config: Config,
65    /// Sailbox-API host: lifecycle, list/get, listeners, volume.
66    sailbox_http: HttpCore,
67    /// Central public-API host: app find, inference, voyages.
68    api_http: HttpCore,
69    /// Per-sailbox worker proxy: exec, files, listener reads. Its own `Arc` so
70    /// the streaming file/exec methods (which take `&Arc<Self>`) can share it.
71    worker: Arc<WorkerProxy>,
72    imagebuilder: ImageBuilder,
73    /// Successful image-readiness builds, shared by every clone of this
74    /// client (see [`crate::imagecache`]).
75    image_ready: crate::imagecache::ImageReadyCache,
76}
77
78/// Builds a [`Client`] from explicit values, falling back to the default
79/// endpoints. Prefer [`Client::from_env`] for the common env-driven case.
80///
81/// `Debug` redacts the API key, so a logged builder never leaks the
82/// credential.
83#[derive(Default, Clone)]
84pub struct ClientBuilder {
85    mode: Option<String>,
86    api_key: Option<String>,
87    api_url: Option<String>,
88    sailbox_api_url: Option<String>,
89    imagebuilder_url: Option<String>,
90    ingress_url: Option<String>,
91    client_label: Option<String>,
92}
93
94impl std::fmt::Debug for ClientBuilder {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.debug_struct("ClientBuilder")
97            .field(
98                "api_key",
99                &crate::config::redact_key(self.api_key.as_deref().unwrap_or("")),
100            )
101            .field("mode", &self.mode)
102            .field("api_url", &self.api_url)
103            .field("sailbox_api_url", &self.sailbox_api_url)
104            .field("imagebuilder_url", &self.imagebuilder_url)
105            .field("ingress_url", &self.ingress_url)
106            .field("client_label", &self.client_label)
107            .finish()
108    }
109}
110
111impl ClientBuilder {
112    /// A builder with the given API key; unset endpoints use the Sail
113    /// defaults.
114    pub fn new(api_key: impl Into<String>) -> ClientBuilder {
115        ClientBuilder {
116            api_key: Some(api_key.into()),
117            ..ClientBuilder::default()
118        }
119    }
120
121    /// Select the named environment (`prod`/`dev`/`staging`/`local`), which
122    /// picks the endpoint defaults. Unset means prod.
123    #[doc(hidden)]
124    pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
125        self.mode = Some(mode.into());
126        self
127    }
128
129    /// Override the Sail API URL.
130    pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
131        self.api_url = Some(api_url.into());
132        self
133    }
134
135    /// Override the sailbox-API URL.
136    pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
137        self.sailbox_api_url = Some(url.into());
138        self
139    }
140
141    /// Override the image-build endpoint (`host:port`).
142    pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
143        self.imagebuilder_url = Some(url.into());
144        self
145    }
146
147    /// Override the listener ingress base URL (what `SAILBOX_INGRESS_URL`
148    /// sets from the environment), for custom or self-hosted Sailbox stacks.
149    pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
150        self.ingress_url = Some(url.into());
151        self
152    }
153
154    /// Identify the first-party binding using the shared transport.
155    #[doc(hidden)]
156    pub fn client_label(mut self, label: impl Into<String>) -> ClientBuilder {
157        self.client_label = Some(label.into());
158        self
159    }
160
161    /// Build the client, resolving any unset endpoint from the defaults.
162    pub fn build(self) -> Result<Client, SailError> {
163        let api_key = self.api_key.unwrap_or_default();
164        let config = Config::resolve(
165            self.mode.as_deref(),
166            api_key,
167            self.api_url,
168            self.sailbox_api_url,
169            self.imagebuilder_url,
170            self.ingress_url,
171        )?;
172        Client::from_config_with_label(
173            config,
174            self.client_label
175                .as_deref()
176                .unwrap_or(crate::http::DEFAULT_CLIENT_LABEL),
177        )
178    }
179}
180
181/// Bound on the transparent image rebuild inside a create retry when the
182/// request carries no image-build timeout; matches the default build budget
183/// the SDK wrappers document.
184const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
185
186/// The scheduler's create rejection for an image it cannot resolve as ready.
187/// CreateSailbox in backend/internal/sailbox/scheduler stamps the
188/// "resolve image:" prefix on that arm; keep them in sync.
189fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
190    matches!(
191        result,
192        Err(SailError::Creation {
193            status: 409,
194            message,
195            ..
196        }) if message.starts_with("resolve image:")
197    )
198}
199
200impl Client {
201    /// Start a [`ClientBuilder`].
202    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
203        ClientBuilder::new(api_key)
204    }
205
206    /// Build a client from the environment (`SAIL_API_KEY`, …).
207    pub fn from_env() -> Result<Client, SailError> {
208        Client::from_config(Config::from_env()?)
209    }
210
211    /// Build a client from the environment and identify a first-party binding.
212    #[doc(hidden)]
213    pub fn from_env_with_label(label: &str) -> Result<Client, SailError> {
214        Client::from_config_with_label(Config::from_env()?, label)
215    }
216
217    /// Build a client from an already-resolved [`Config`].
218    pub fn from_config(config: Config) -> Result<Client, SailError> {
219        Client::from_config_with_label(config, crate::http::DEFAULT_CLIENT_LABEL)
220    }
221
222    fn from_config_with_label(config: Config, client_label: &str) -> Result<Client, SailError> {
223        let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?
224            .with_client_label(client_label);
225        let api_http =
226            HttpCore::new(&config.api_url, &config.api_key)?.with_client_label(client_label);
227        let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
228        let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
229        Ok(Client {
230            inner: Arc::new(Inner {
231                config,
232                sailbox_http,
233                api_http,
234                worker,
235                imagebuilder,
236                image_ready: crate::imagecache::ImageReadyCache::new(),
237            }),
238        })
239    }
240
241    pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
242        &self.inner.image_ready
243    }
244
245    /// Test hook: shrink the window after which a cached successful image
246    /// build is re-verified with the server. Compiled only for tests (this
247    /// crate's own and, under `test-fakes`, the integration crate), so it
248    /// never widens the published API.
249    #[cfg(any(test, feature = "test-fakes"))]
250    pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
251        self.inner.image_ready.set_refresh_window(window);
252    }
253
254    /// The resolved configuration.
255    pub fn config(&self) -> &Config {
256        &self.inner.config
257    }
258
259    /// The worker proxy for exec, file copy, and listener reads.
260    #[doc(hidden)]
261    pub fn worker(&self) -> Arc<WorkerProxy> {
262        Arc::clone(&self.inner.worker)
263    }
264
265    /// The imagebuilder dispatcher client.
266    #[doc(hidden)]
267    pub fn imagebuilder(&self) -> &ImageBuilder {
268        &self.inner.imagebuilder
269    }
270
271    /// The sailbox-API HTTP host (for binding-built requests).
272    #[doc(hidden)]
273    pub fn sailbox_http(&self) -> &HttpCore {
274        &self.inner.sailbox_http
275    }
276
277    /// The central public-API HTTP host (for binding-built requests).
278    #[doc(hidden)]
279    pub fn api_http(&self) -> &HttpCore {
280        &self.inner.api_http
281    }
282
283    fn sailbox_api(&self) -> SailboxApi<'_> {
284        SailboxApi::new(&self.inner.sailbox_http)
285    }
286
287    /// Send a create; when the scheduler rejects it because the image is not
288    /// ready even though readiness was cached, rebuild once and retry. A
289    /// backend deploy can change the canonical image identity behind the same
290    /// spec, so a cached "ready" can be stale until the refresh window. The
291    /// scheduler resolves the image before it creates any row, so nothing
292    /// exists server-side and the retried create is safe. Unrelated create
293    /// conflicts (name, idempotency) pass through untouched.
294    async fn create_with_image_revalidation(
295        &self,
296        req: &CreateSailboxRequest,
297        timeout: Option<Duration>,
298    ) -> Result<SailboxHandle, SailError> {
299        let create_started = std::time::Instant::now();
300        let result = self.sailbox_api().create(req, timeout).await;
301        let custom_image = req.image != crate::image::ImageSpec::default()
302            && !crate::imagebuild::is_builtin_base_spec(&req.image);
303        if !custom_image || !image_not_ready_conflict(&result) {
304            return result;
305        }
306        if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
307            // Drop every entry whose build started before this create began:
308            // those may carry the identity the server just rejected. A build
309            // started after conflict discovery is another stale caller's
310            // recovery, joined below rather than clobbered.
311            self.image_ready_cache()
312                .invalidate_spec_started_before(&spec_hash, create_started);
313        }
314        // The hard envelope means joining another caller's in-flight rebuild
315        // cannot outlive this caller's budget; the recovery marking keeps the
316        // rebuild joinable through later stale creates' invalidations.
317        let rebuild_timeout = req
318            .image_build_timeout
319            .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
320        let rebuild =
321            self.build_spec_ready_cached(&req.image, rebuild_timeout, /* recovery */ true);
322        tokio::time::timeout(rebuild_timeout, rebuild)
323            .await
324            .unwrap_or_else(|_| {
325                Err(SailError::Transport {
326                    kind: crate::error::TransportKind::Timeout,
327                    message: "timed out building the image".to_string(),
328                    source: None,
329                })
330            })?;
331        self.sailbox_api().create(req, timeout).await
332    }
333
334    // --- sailbox lifecycle ---
335
336    /// Create a Sailbox. `timeout` bounds each attempt of the synchronous
337    /// create (which can take minutes server-side); the call retries
338    /// with one idempotency key so the backend can dedupe rather than
339    /// duplicate, and gives up after roughly `max_attempts * timeout`.
340    /// An interrupted or re-invoked create is a new request and may leave a
341    /// prior box behind under the same name. 10 minutes is a good default;
342    /// `None` leaves each attempt unbounded. If the budget is exhausted the
343    /// box may still be coming up server-side; find or terminate it by
344    /// `name`.
345    pub async fn create_sailbox(
346        &self,
347        req: &CreateSailboxRequest,
348        timeout: Option<Duration>,
349    ) -> Result<Sailbox, SailError> {
350        let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
351        if !req.ssh {
352            return self
353                .create_with_image_revalidation(req, timeout)
354                .await
355                .map(bind);
356        }
357        // Validate the full request now, port-22 entries included: they are
358        // stripped below (their allowlist applies at the enable_ssh expose),
359        // so create's own validation never sees them, and an invalid entry
360        // must fail here rather than after the VM exists.
361        crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
362        // SSH setup is org-scoped: preflight the org CA (created on first use)
363        // so a CA outage fails before the VM exists.
364        self.org_ssh_ca_public_key().await?;
365        // Port 22 belongs to enable_ssh, which exposes it only after verifying
366        // the CA-only sshd owns it (never the create request), so a failed
367        // setup can't leave port 22 exposed. An explicit port-22 entry
368        // contributes just its allowlist, applied at that expose.
369        let mut req = req.clone();
370        let ssh_allowlist = req
371            .ingress_ports
372            .iter()
373            .find(|port| port.guest_port == 22)
374            .map(|port| port.allowlist.clone())
375            .unwrap_or_default();
376        req.ingress_ports.retain(|port| port.guest_port != 22);
377        let handle = self.create_with_image_revalidation(&req, timeout).await?;
378        let handle_id = handle.sailbox_id.clone();
379        // The VM is already up, so skip the readiness probe (wait: false).
380        if let Err(err) = self
381            .enable_ssh(
382                &handle_id,
383                &ssh_allowlist,
384                /* wait */ false,
385                Duration::ZERO,
386            )
387            .await
388        {
389            // The sailbox exists; surface its id so the caller can fetch it to
390            // retry enable_ssh or terminate it.
391            return Err(SailError::Creation {
392                message: format!(
393                    "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
394                     id to retry enable_ssh or terminate it."
395                ),
396                status: 0,
397                body: serde_json::Value::Null,
398            });
399        }
400        Ok(bind(handle))
401    }
402
403    /// Fetch a single Sailbox.
404    #[doc(hidden)]
405    pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
406        self.sailbox_api().get(sailbox_id).await
407    }
408
409    /// Fetch the identity (org, and user when user-scoped) behind the API key.
410    #[doc(hidden)]
411    pub async fn whoami(&self) -> Result<WhoAmI, SailError> {
412        self.sailbox_api().whoami().await
413    }
414
415    /// List Sailboxes in the current org.
416    pub async fn list_sailboxes(
417        &self,
418        query: &ListSailboxesQuery,
419    ) -> Result<SailboxPage, SailError> {
420        self.sailbox_api().list(query).await
421    }
422
423    /// Estimate Sailbox spend for the current organization over a time window.
424    pub async fn sailbox_spend(
425        &self,
426        query: &SailboxSpendQuery,
427    ) -> Result<SailboxSpendResponse, SailError> {
428        self.sailbox_api().spend(query).await
429    }
430
431    /// Fetch a Sailbox's resource-usage time series.
432    pub async fn sailbox_metrics(
433        &self,
434        sailbox_id: &str,
435        query: &SailboxMetricsQuery,
436    ) -> Result<SailboxMetricsResponse, SailError> {
437        self.sailbox_api().metrics(sailbox_id, query).await
438    }
439
440    /// Terminate a Sailbox (idempotent).
441    #[doc(hidden)]
442    pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
443        self.sailbox_api().terminate(sailbox_id).await
444    }
445
446    /// Pause a Sailbox.
447    #[doc(hidden)]
448    pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
449        self.sailbox_api().pause(sailbox_id).await
450    }
451
452    /// Sleep a Sailbox, optionally scheduling a wall-clock wake first.
453    #[doc(hidden)]
454    pub async fn sleep_sailbox(
455        &self,
456        sailbox_id: &str,
457        wake_at: Option<OffsetDateTime>,
458    ) -> Result<Option<OffsetDateTime>, SailError> {
459        self.sailbox_api().sleep(sailbox_id, wake_at).await
460    }
461
462    /// Resume a paused/sleeping Sailbox.
463    #[doc(hidden)]
464    pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
465        self.sailbox_api().resume(sailbox_id).await
466    }
467
468    /// Checkpoint a running Sailbox.
469    #[doc(hidden)]
470    pub async fn checkpoint_sailbox(
471        &self,
472        sailbox_id: &str,
473        name: Option<&str>,
474        ttl_seconds: Option<i64>,
475    ) -> Result<SailboxCheckpoint, SailError> {
476        self.sailbox_api()
477            .checkpoint(sailbox_id, name, ttl_seconds)
478            .await
479    }
480
481    /// Fork a Sailbox into a new child in one call. Id-form of
482    /// [`Sailbox::fork`](crate::Sailbox::fork), which documents the contract.
483    #[doc(hidden)]
484    pub async fn fork_sailbox(
485        &self,
486        sailbox_id: &str,
487        name: Option<&str>,
488        timeout: Option<Duration>,
489    ) -> Result<Sailbox, SailError> {
490        self.sailbox_api()
491            .fork(sailbox_id, name, timeout.map(duration_to_whole_seconds))
492            .await
493            .map(|handle| Sailbox::bind(self.clone(), handle))
494    }
495
496    /// Create a new Sailbox from a checkpoint.
497    pub async fn create_from_checkpoint(
498        &self,
499        checkpoint_id: &str,
500        name: Option<&str>,
501        timeout: Option<Duration>,
502    ) -> Result<Sailbox, SailError> {
503        self.sailbox_api()
504            .from_checkpoint(checkpoint_id, name, timeout.map(duration_to_whole_seconds))
505            .await
506            .map(|handle| Sailbox::bind(self.clone(), handle))
507    }
508
509    /// Upgrade the Sailbox runtime (applies now if running, else at next wake).
510    #[doc(hidden)]
511    pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
512        self.sailbox_api().upgrade(sailbox_id).await
513    }
514
515    /// Expose a guest port at runtime; returns the add-listener response.
516    #[doc(hidden)]
517    pub async fn expose_listener(
518        &self,
519        sailbox_id: &str,
520        guest_port: u32,
521        protocol: crate::sailbox::types::IngressProtocol,
522        allowlist: &[String],
523    ) -> Result<Listener, SailError> {
524        let mut response = self
525            .sailbox_api()
526            .expose(sailbox_id, guest_port, protocol, allowlist)
527            .await?;
528        self.fill_listener_url(sailbox_id, &mut response);
529        Ok(response)
530    }
531
532    /// Remove a runtime ingress port.
533    #[doc(hidden)]
534    pub async fn unexpose_listener(
535        &self,
536        sailbox_id: &str,
537        guest_port: u32,
538    ) -> Result<(), SailError> {
539        self.sailbox_api().unexpose(sailbox_id, guest_port).await
540    }
541
542    /// List a Sailbox's ingress listeners without resuming (waking) the box.
543    #[doc(hidden)]
544    pub async fn list_listeners(
545        &self,
546        sailbox_id: &str,
547    ) -> Result<Vec<crate::worker::Listener>, SailError> {
548        let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
549        for listener in &mut listeners {
550            self.fill_listener_url(sailbox_id, listener);
551        }
552        Ok(listeners)
553    }
554
555    /// Fetch one ingress listener by guest port without resuming (waking) the
556    /// box; a missing port is a [`SailError::NotFound`].
557    #[doc(hidden)]
558    pub async fn get_listener(
559        &self,
560        sailbox_id: &str,
561        guest_port: u32,
562    ) -> Result<crate::worker::Listener, SailError> {
563        let mut listener = self
564            .sailbox_api()
565            .get_listener(sailbox_id, guest_port)
566            .await?;
567        self.fill_listener_url(sailbox_id, &mut listener);
568        Ok(listener)
569    }
570
571    /// Fill an empty `public_url` on a non-TCP listener with the URL
572    /// synthesized from this client's ingress config (the server leaves
573    /// listener URLs empty in local/path mode).
574    fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
575        if listener.public_url.is_empty()
576            && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
577        {
578            listener.public_url = crate::sailbox::listeners::synthesized_public_url(
579                self.config(),
580                sailbox_id,
581                listener.guest_port,
582            );
583        }
584    }
585
586    /// Ingress-identity headers for this Sailbox.
587    #[doc(hidden)]
588    pub async fn ingress_auth_headers(
589        &self,
590        sailbox_id: &str,
591    ) -> Result<Vec<(String, String)>, SailError> {
592        self.sailbox_api().ingress_auth_headers(sailbox_id).await
593    }
594
595    /// The caller org's SSH CA public key (created on first use).
596    pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
597        self.sailbox_api().org_ssh_ca_public_key().await
598    }
599
600    /// Sign `public_key` into a short-lived org-CA certificate (principal
601    /// `root`). `timeout` (seconds) bounds a single no-retry attempt; `None`
602    /// retries.
603    pub async fn issue_user_cert(
604        &self,
605        public_key: &str,
606        timeout: Option<Duration>,
607    ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
608        self.sailbox_api()
609            .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
610            .await
611    }
612
613    // --- NFS volumes ---
614
615    /// Look up (optionally minting) an NFS volume by name.
616    pub async fn get_volume(
617        &self,
618        name: &str,
619        mint_if_missing: bool,
620    ) -> Result<VolumeInfo, SailError> {
621        self.sailbox_api().get_volume(name, mint_if_missing).await
622    }
623
624    /// List NFS volumes in the current org.
625    pub async fn list_volumes(
626        &self,
627        max_objects: Option<i64>,
628    ) -> Result<Vec<VolumeInfo>, SailError> {
629        self.sailbox_api().list_volumes(max_objects).await
630    }
631
632    /// Delete a volume by id.
633    pub async fn delete_volume(
634        &self,
635        volume_id: &str,
636        allow_missing: bool,
637    ) -> Result<Option<VolumeInfo>, SailError> {
638        self.sailbox_api()
639            .delete_volume(volume_id, allow_missing)
640            .await
641    }
642
643    // --- apps (central API) ---
644
645    /// Find an app by name, optionally minting it.
646    pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
647        app::find_app(&self.inner.api_http, name, mint_if_missing).await
648    }
649
650    /// Every app the current org owns, newest first.
651    pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
652        app::list_apps(&self.inner.api_http).await
653    }
654
655    // --- exec and files (per-sailbox worker proxy) ---
656
657    /// Resolve a Sailbox's current worker-proxy endpoint.
658    ///
659    /// `resume` wakes a paused/sleeping Sailbox and returns its *current*
660    /// endpoint, which is the host worker's address and changes when the Sailbox
661    /// migrates (e.g. after preemption). The GET Sailbox API omits this routing
662    /// field, so resuming is the only way to learn it, and resolving it fresh per
663    /// call avoids ever dialing a stale worker.
664    #[doc(hidden)]
665    pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
666        let handle = self.resume_sailbox(sailbox_id).await?;
667        if handle.exec_endpoint.is_empty() {
668            return Err(SailError::Internal {
669                message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
670            });
671        }
672        Ok(handle.exec_endpoint)
673    }
674
675    /// Id-form of [`Sailbox::exec`](crate::Sailbox::exec), which documents the
676    /// contract. Spawns the output pump on the calling task's tokio runtime.
677    #[doc(hidden)]
678    pub async fn exec(
679        &self,
680        sailbox_id: &str,
681        argv: Vec<String>,
682        options: ExecOptions,
683    ) -> Result<ExecProcess, SailError> {
684        if argv.is_empty() {
685            return Err(SailError::InvalidArgument {
686                message: "command must be non-empty".to_string(),
687            });
688        }
689        if options.cwd.is_some() || options.background {
690            return Err(SailError::InvalidArgument {
691                message: "cwd and background require a shell command; use exec_shell or run_shell"
692                    .to_string(),
693            });
694        }
695        // Validate and encode the env before resolving the endpoint: it is
696        // purely local, so a malformed key must not first wake a paused sailbox.
697        let env = crate::exec::encode_env(&options.env)?;
698        let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
699        let params = ExecParams {
700            sailbox_id: sailbox_id.to_string(),
701            exec_endpoint,
702            argv,
703            // The wire is whole seconds where 0 means "no limit", so a set
704            // sub-second timeout rounds up to 1s rather than collapsing to 0.
705            timeout_seconds: options
706                .timeout
707                .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
708            idempotency_key: options.idempotency_key,
709            // A pty always feeds keystrokes to the command, so it implies an
710            // open stdin regardless of the flag.
711            open_stdin: options.open_stdin || options.pty,
712            pty: options.pty,
713            term: options.term,
714            cols: options.cols,
715            rows: options.rows,
716            env,
717            retry_timeout: options.retry_timeout.as_secs_f64(),
718            forward_ports: options.forward_ports,
719            forward_browser: options.forward_browser,
720            extra_metadata: Vec::new(),
721            // The clipboard bridge is a pty-session behavior; the guest would
722            // ignore it elsewhere, so don't ask.
723            forward_clipboard: options.forward_clipboard && options.pty,
724        };
725        ExecProcess::start(self.worker(), params).await
726    }
727
728    /// Run a shell command in a Sailbox via `/bin/sh -lc`, honoring the
729    /// `cwd`/`background` conveniences in [`ExecOptions`]. See [`Client::exec`]
730    /// for the argv form and the runtime notes.
731    #[doc(hidden)]
732    pub async fn exec_shell(
733        &self,
734        sailbox_id: &str,
735        command: &str,
736        mut options: ExecOptions,
737    ) -> Result<ExecProcess, SailError> {
738        let argv = crate::exec::shell_argv(command, &options)?;
739        // The conveniences are baked into the argv now; clear them so the argv
740        // path's guard does not re-reject them.
741        options.cwd = None;
742        options.background = false;
743        self.exec(sailbox_id, argv, options).await
744    }
745
746    /// Open a streaming read of a guest file. Resumes (wakes) the Sailbox to
747    /// reach it; the returned [`FileReader`] yields chunks until end of file.
748    ///
749    /// # Runtime
750    ///
751    /// Spawns the read pump on the calling task's tokio runtime (see
752    /// [`crate::worker::WorkerProxy::read_file`]).
753    #[doc(hidden)]
754    pub async fn read_stream(
755        &self,
756        sailbox_id: &str,
757        remote_path: &str,
758    ) -> Result<FileReader, SailError> {
759        let endpoint = self.exec_endpoint(sailbox_id).await?;
760        Ok(self
761            .inner
762            .worker
763            .read_file(&endpoint, sailbox_id, remote_path))
764    }
765
766    /// Read a guest file into memory in one call (convenience over
767    /// [`Client::read_stream`], which streams a large file without
768    /// buffering it whole).
769    #[doc(hidden)]
770    pub async fn read_file(
771        &self,
772        sailbox_id: &str,
773        remote_path: &str,
774    ) -> Result<Vec<u8>, SailError> {
775        let reader = self.read_stream(sailbox_id, remote_path).await?;
776        let mut contents = Vec::new();
777        while let Some(chunk) = reader.next().await {
778            contents.extend_from_slice(&chunk?);
779        }
780        Ok(contents)
781    }
782
783    /// Open a streaming write to a guest file. Resumes (wakes) the Sailbox to
784    /// reach it; feed the returned [`FileWriter`] with `write_chunk` and end with
785    /// `finish`, so a large source is never buffered whole.
786    ///
787    /// # Runtime
788    ///
789    /// Spawns the write RPC on the calling task's tokio runtime (see
790    /// [`crate::worker::WorkerProxy::write_file`]).
791    #[doc(hidden)]
792    pub async fn write_stream(
793        &self,
794        sailbox_id: &str,
795        remote_path: &str,
796        options: WriteOptions,
797    ) -> Result<FileWriter, SailError> {
798        let endpoint = self.exec_endpoint(sailbox_id).await?;
799        Ok(self.inner.worker.write_file(
800            &endpoint,
801            sailbox_id,
802            remote_path,
803            options.create_parents,
804            options.mode,
805        ))
806    }
807
808    /// Write `data` to a guest file in one call (convenience over
809    /// [`Client::write_stream`], which streams a large source without
810    /// buffering it whole).
811    #[doc(hidden)]
812    pub async fn write_file(
813        &self,
814        sailbox_id: &str,
815        remote_path: &str,
816        data: &[u8],
817        options: WriteOptions,
818    ) -> Result<(), SailError> {
819        let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
820        writer.write(data).await?;
821        writer.finish().await
822    }
823
824    // --- filesystem helpers ---
825    //
826    // These build a coreutils command, run it to completion, and inspect the
827    // result. They live in the core so the command construction and the `find`
828    // output parse are defined once, and every language binding consumes the
829    // structured results rather than re-parsing `find`'s output.
830
831    /// Run a command to completion and return its buffered result.
832    async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
833        self.exec(sailbox_id, argv, ExecOptions::default())
834            .await?
835            .wait()
836            .await
837    }
838
839    /// Create a directory and any missing parents (like `mkdir -p`); a no-op if
840    /// it already exists.
841    #[doc(hidden)]
842    pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
843        crate::sailbox::fs::require_path(path)?;
844        let result = self
845            .run_argv(
846                sailbox_id,
847                vec![
848                    "mkdir".to_string(),
849                    "-p".to_string(),
850                    "--".to_string(),
851                    path.to_string(),
852                ],
853            )
854            .await?;
855        fs_command_ok(&result, &format!("create directory {path}"))
856    }
857
858    /// Remove a file or directory tree (like `rm -rf`); a no-op if it is already
859    /// absent.
860    #[doc(hidden)]
861    pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
862        crate::sailbox::fs::require_path(path)?;
863        let result = self
864            .run_argv(
865                sailbox_id,
866                vec![
867                    "rm".to_string(),
868                    "-rf".to_string(),
869                    "--".to_string(),
870                    path.to_string(),
871                ],
872            )
873            .await?;
874        fs_command_ok(&result, &format!("remove {path}"))
875    }
876
877    /// Whether `path` exists in the guest. Follows symlinks (like `test -e`), so
878    /// a dangling symlink reports `false`.
879    #[doc(hidden)]
880    pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
881        crate::sailbox::fs::require_path(path)?;
882        let result = self
883            .run_argv(
884                sailbox_id,
885                vec!["test".to_string(), "-e".to_string(), path.to_string()],
886            )
887            .await?;
888        // `test -e` answers with its exit code: 0 exists, 1 does not. Any other
889        // code (for example a signal-killed process) is a failed check, not an
890        // answer, so surface it rather than reading it as absent.
891        match result.exit_code {
892            0 => Ok(true),
893            1 => Ok(false),
894            _ => Err(fs_command_error(
895                &result,
896                &format!("check whether {path} exists"),
897            )),
898        }
899    }
900
901    /// List a directory's immediate entries (files and subdirectories, no
902    /// recursion). Requires GNU `find`, which the default Debian image ships. A
903    /// missing path errors, as does a path that exists but is not a directory.
904    #[doc(hidden)]
905    pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
906        crate::sailbox::fs::require_path(path)?;
907        let process = self
908            .exec(
909                sailbox_id,
910                crate::sailbox::fs::list_dir_argv(path),
911                ExecOptions::default(),
912            )
913            .await?;
914        let result = process.wait().await?;
915        fs_command_ok(&result, &format!("list directory {path}"))?;
916        // Buffered stdout is a capped, drop-oldest tail, so parsing a truncated
917        // listing would silently drop entries.
918        if result.stdout_truncated {
919            return Err(SailError::Execution {
920                code: RpcStatus::FailedPrecondition,
921                detail: format!(
922                    "directory listing for {path} was truncated because it has \
923                     too many entries; list a smaller subtree"
924                ),
925            });
926        }
927        // The records are NUL-terminated, and only the raw buffered bytes keep
928        // NUL: the string-typed `ExecResult` replaces it, as does the persisted
929        // tail that `wait` falls back to when the live stream loses its ending.
930        // So parse the local raw bytes, and require that the stream delivered
931        // them all; when it did not, the local buffer may be missing entries.
932        if !result.stdout_complete {
933            return Err(SailError::Execution {
934                code: RpcStatus::FailedPrecondition,
935                detail: format!(
936                    "directory listing for {path} was interrupted before it \
937                     finished streaming; retry the call"
938                ),
939            });
940        }
941        let mut entries =
942            crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
943                .map_err(|detail| SailError::Execution {
944                code: RpcStatus::FailedPrecondition,
945                detail: format!("directory listing for {path} could not be used: {detail}"),
946            })?;
947        // `find` emits the start point itself as the first record, carrying the
948        // path's own type.
949        if entries.is_empty() {
950            return Err(SailError::Execution {
951                code: RpcStatus::FailedPrecondition,
952                detail: format!(
953                    "directory listing for {path} produced no records; \
954                     listing requires GNU find in the guest"
955                ),
956            });
957        }
958        let start = entries.remove(0);
959        if start.entry_type != EntryType::Directory {
960            return Err(SailError::Execution {
961                code: RpcStatus::FailedPrecondition,
962                detail: format!(
963                    "{path} is not a directory (it is a {})",
964                    start.entry_type.as_str()
965                ),
966            });
967        }
968        Ok(entries)
969    }
970}
971
972/// Whole seconds for the wire, rounding a positive duration up (like the
973/// exec timeout) so the server never enforces a shorter bound than the
974/// caller asked for; an explicit zero stays zero for the API to reject.
975fn duration_to_whole_seconds(timeout: Duration) -> i64 {
976    if timeout.is_zero() {
977        0
978    } else {
979        timeout.as_secs_f64().ceil() as i64
980    }
981}
982
983/// Fail on a non-zero exit from a filesystem helper command.
984fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
985    if result.exit_code != 0 {
986        return Err(fs_command_error(result, action));
987    }
988    Ok(())
989}
990
991/// The error for a failed filesystem helper command, folding the guest's stderr
992/// into the message.
993fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
994    let stderr = result.stderr.trim();
995    let suffix = if stderr.is_empty() {
996        String::new()
997    } else {
998        format!(": {stderr}")
999    };
1000    SailError::Execution {
1001        code: RpcStatus::FailedPrecondition,
1002        detail: format!(
1003            "failed to {action} (exit code {}){suffix}",
1004            result.exit_code
1005        ),
1006    }
1007}
1008
1009#[cfg(test)]
1010mod timeout_tests {
1011    use super::*;
1012
1013    #[test]
1014    fn durations_round_up_to_whole_seconds() {
1015        assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1016        assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1017        assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1018        assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1019    }
1020}