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