Skip to main content

microsandbox_types/
cloud.rs

1//! Wire types for the cloud backend's HTTP calls.
2//!
3//! HTTP route versions choose this concrete request shape. The request shape is
4//! user-facing intent, so disk sizing sits beside CPU and memory; conversion
5//! into the domain spec moves that value onto the OCI rootfs where the runtime
6//! realizes it.
7
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use zeroize::Zeroizing;
15
16use crate::domain::{
17    DiskImageFormat, EnvVar, HandoffInit, HostPattern, HostPermissions, MountOptions,
18    NetworkPolicy, NetworkSpec, OciRootfsSource, Patch, PullPolicy, Rlimit, RlimitResource,
19    RootDisk, RootfsSource, SandboxLogLevel, SandboxPolicy, SandboxResources,
20    SandboxRuntimeOptions, SandboxSpec, SecretEntry, SecretInjection, SecretsConfig,
21    SecurityProfile, StatVirtualization, ViolationAction, VolumeMount, default_private,
22    default_strict,
23};
24use crate::modify::SecretSource;
25use crate::{TypesError, TypesResult};
26
27//--------------------------------------------------------------------------------------------------
28// Types: Request
29//--------------------------------------------------------------------------------------------------
30
31/// Wire shape of a cloud sandbox create request body.
32///
33/// Flattens [`CloudSandboxSpec`] onto the request body, so on the wire this is
34/// byte-identical to `CloudSandboxSpec`. The generated bindings surface the
35/// flattened shape as `CloudSandboxSpec` directly (see
36/// [`CloudCreateSandboxResponse::spec`], typed `CloudSandboxSpec`).
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
39pub struct CloudCreateSandboxRequest {
40    /// The cloud sandbox specification, flattened onto the request body.
41    #[serde(flatten)]
42    pub spec: CloudSandboxSpec,
43}
44
45/// Cloud sandbox specification carried on create routes.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
48#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
49#[serde(default)]
50pub struct CloudSandboxSpec {
51    /// Unique sandbox name.
52    pub name: String,
53
54    /// Root filesystem source.
55    #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
56    pub image: CloudRootfsSource,
57
58    /// CPU, memory, and user-facing disk resources.
59    pub resources: CloudSandboxResources,
60
61    /// Guest runtime options.
62    pub runtime: CloudSandboxRuntimeOptions,
63
64    /// Environment variables visible to commands in the sandbox.
65    pub env: Vec<EnvVar>,
66
67    /// User-defined labels attached to the sandbox.
68    pub labels: BTreeMap<String, String>,
69
70    /// Sandbox-wide resource limits inherited by guest processes.
71    pub rlimits: Vec<CloudRlimit>,
72
73    /// Volume mounts.
74    pub mounts: Vec<CloudVolumeMount>,
75
76    /// Rootfs patches applied before VM start.
77    pub patches: Vec<CloudPatch>,
78
79    /// Network specification.
80    pub network: CloudNetworkSpec,
81
82    /// Hand off PID 1 to a guest init binary after agentd setup.
83    pub init: Option<HandoffInit>,
84
85    /// Pull policy for OCI images.
86    pub pull_policy: CloudPullPolicy,
87
88    /// In-guest security profile.
89    pub security_profile: SecurityProfile,
90
91    /// Sandbox lifecycle policy.
92    pub lifecycle: SandboxPolicy,
93}
94
95/// Cloud resource request.
96#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
97#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
98#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
99#[serde(default)]
100pub struct CloudSandboxResources {
101    /// Number of virtual CPUs.
102    pub vcpus: u8,
103
104    /// Guest memory in MiB.
105    pub memory_mib: u32,
106
107    /// Writable disk size in MiB. Applies only to OCI root filesystems.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub disk_size_mib: Option<u32>,
110}
111
112//--------------------------------------------------------------------------------------------------
113// Types: Spec sub-twins
114//
115// Snake_case wire twins for domain enums that serialize PascalCase, so the whole
116// cloud contract stays snake_case without changing the domain (runtime/SDK) wire.
117//--------------------------------------------------------------------------------------------------
118
119/// Cloud pull policy. Twin of domain [`PullPolicy`] with a snake_case wire.
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
122#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
123#[serde(rename_all = "snake_case")]
124pub enum CloudPullPolicy {
125    /// Use cached layers if complete, pull otherwise.
126    #[default]
127    IfMissing,
128    /// Always fetch the manifest, reusing cached layers whose digests match.
129    Always,
130    /// Never contact the registry; error if the image is not fully cached.
131    Never,
132}
133
134impl From<PullPolicy> for CloudPullPolicy {
135    fn from(policy: PullPolicy) -> Self {
136        match policy {
137            PullPolicy::IfMissing => Self::IfMissing,
138            PullPolicy::Always => Self::Always,
139            PullPolicy::Never => Self::Never,
140        }
141    }
142}
143
144impl From<CloudPullPolicy> for PullPolicy {
145    fn from(policy: CloudPullPolicy) -> Self {
146        match policy {
147            CloudPullPolicy::IfMissing => Self::IfMissing,
148            CloudPullPolicy::Always => Self::Always,
149            CloudPullPolicy::Never => Self::Never,
150        }
151    }
152}
153
154/// Disk image format for cloud disk-image sources. Twin of [`DiskImageFormat`]
155/// with a snake_case wire.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
158#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
159#[serde(rename_all = "snake_case")]
160pub enum CloudDiskImageFormat {
161    /// QEMU Copy-on-Write v2.
162    Qcow2,
163    /// Raw disk image.
164    Raw,
165    /// VMware Disk (FLAT/ZERO only, no delta links).
166    Vmdk,
167}
168
169impl From<DiskImageFormat> for CloudDiskImageFormat {
170    fn from(format: DiskImageFormat) -> Self {
171        match format {
172            DiskImageFormat::Qcow2 => Self::Qcow2,
173            DiskImageFormat::Raw => Self::Raw,
174            DiskImageFormat::Vmdk => Self::Vmdk,
175        }
176    }
177}
178
179impl From<CloudDiskImageFormat> for DiskImageFormat {
180    fn from(format: CloudDiskImageFormat) -> Self {
181        match format {
182            CloudDiskImageFormat::Qcow2 => Self::Qcow2,
183            CloudDiskImageFormat::Raw => Self::Raw,
184            CloudDiskImageFormat::Vmdk => Self::Vmdk,
185        }
186    }
187}
188
189/// POSIX resource-limit identifiers. Twin of [`RlimitResource`] with a
190/// snake_case wire.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
193#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
194#[serde(rename_all = "snake_case")]
195pub enum CloudRlimitResource {
196    /// Max CPU time in seconds (`RLIMIT_CPU`).
197    Cpu,
198    /// Max file size in bytes (`RLIMIT_FSIZE`).
199    Fsize,
200    /// Max data segment size (`RLIMIT_DATA`).
201    Data,
202    /// Max stack size (`RLIMIT_STACK`).
203    Stack,
204    /// Max core file size (`RLIMIT_CORE`).
205    Core,
206    /// Max resident set size (`RLIMIT_RSS`).
207    Rss,
208    /// Max number of processes (`RLIMIT_NPROC`).
209    Nproc,
210    /// Max open file descriptors (`RLIMIT_NOFILE`).
211    Nofile,
212    /// Max locked memory (`RLIMIT_MEMLOCK`).
213    Memlock,
214    /// Max address space size (`RLIMIT_AS`).
215    As,
216    /// Max file locks (`RLIMIT_LOCKS`).
217    Locks,
218    /// Max pending signals (`RLIMIT_SIGPENDING`).
219    Sigpending,
220    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
221    Msgqueue,
222    /// Max nice priority (`RLIMIT_NICE`).
223    Nice,
224    /// Max real-time priority (`RLIMIT_RTPRIO`).
225    Rtprio,
226    /// Max real-time timeout (`RLIMIT_RTTIME`).
227    Rttime,
228}
229
230impl From<RlimitResource> for CloudRlimitResource {
231    fn from(resource: RlimitResource) -> Self {
232        match resource {
233            RlimitResource::Cpu => Self::Cpu,
234            RlimitResource::Fsize => Self::Fsize,
235            RlimitResource::Data => Self::Data,
236            RlimitResource::Stack => Self::Stack,
237            RlimitResource::Core => Self::Core,
238            RlimitResource::Rss => Self::Rss,
239            RlimitResource::Nproc => Self::Nproc,
240            RlimitResource::Nofile => Self::Nofile,
241            RlimitResource::Memlock => Self::Memlock,
242            RlimitResource::As => Self::As,
243            RlimitResource::Locks => Self::Locks,
244            RlimitResource::Sigpending => Self::Sigpending,
245            RlimitResource::Msgqueue => Self::Msgqueue,
246            RlimitResource::Nice => Self::Nice,
247            RlimitResource::Rtprio => Self::Rtprio,
248            RlimitResource::Rttime => Self::Rttime,
249        }
250    }
251}
252
253impl From<CloudRlimitResource> for RlimitResource {
254    fn from(resource: CloudRlimitResource) -> Self {
255        match resource {
256            CloudRlimitResource::Cpu => Self::Cpu,
257            CloudRlimitResource::Fsize => Self::Fsize,
258            CloudRlimitResource::Data => Self::Data,
259            CloudRlimitResource::Stack => Self::Stack,
260            CloudRlimitResource::Core => Self::Core,
261            CloudRlimitResource::Rss => Self::Rss,
262            CloudRlimitResource::Nproc => Self::Nproc,
263            CloudRlimitResource::Nofile => Self::Nofile,
264            CloudRlimitResource::Memlock => Self::Memlock,
265            CloudRlimitResource::As => Self::As,
266            CloudRlimitResource::Locks => Self::Locks,
267            CloudRlimitResource::Sigpending => Self::Sigpending,
268            CloudRlimitResource::Msgqueue => Self::Msgqueue,
269            CloudRlimitResource::Nice => Self::Nice,
270            CloudRlimitResource::Rtprio => Self::Rtprio,
271            CloudRlimitResource::Rttime => Self::Rttime,
272        }
273    }
274}
275
276/// A POSIX resource limit. Twin of [`Rlimit`] using [`CloudRlimitResource`].
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
279#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
280pub struct CloudRlimit {
281    /// Resource type.
282    pub resource: CloudRlimitResource,
283    /// Soft limit (can be raised up to the hard limit by the process).
284    pub soft: u64,
285    /// Hard limit (ceiling, requires privileges to raise).
286    pub hard: u64,
287}
288
289impl From<Rlimit> for CloudRlimit {
290    fn from(rlimit: Rlimit) -> Self {
291        Self {
292            resource: rlimit.resource.into(),
293            soft: rlimit.soft,
294            hard: rlimit.hard,
295        }
296    }
297}
298
299impl From<CloudRlimit> for Rlimit {
300    fn from(rlimit: CloudRlimit) -> Self {
301        Self {
302            resource: rlimit.resource.into(),
303            soft: rlimit.soft,
304            hard: rlimit.hard,
305        }
306    }
307}
308
309/// Rootfs patch applied before VM start. Twin of [`Patch`], internally tagged
310/// with a snake_case `type` instead of the domain's external PascalCase tag.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
313#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
314#[serde(tag = "type", rename_all = "snake_case")]
315pub enum CloudPatch {
316    /// Write text content to a file.
317    Text {
318        /// Absolute guest path, such as `/etc/app.conf`.
319        path: String,
320        /// Text content to write.
321        content: String,
322        /// File permissions, such as `0o644`. `None` uses the default.
323        mode: Option<u32>,
324        /// Allow replacing a file that already exists in the rootfs.
325        replace: bool,
326    },
327    /// Write raw bytes to a file.
328    File {
329        /// Absolute guest path.
330        path: String,
331        /// Raw byte content to write.
332        content: Vec<u8>,
333        /// File permissions, such as `0o644`. `None` uses the default.
334        mode: Option<u32>,
335        /// Allow replacing a file that already exists in the rootfs.
336        replace: bool,
337    },
338    /// Copy a file from the host into the rootfs.
339    CopyFile {
340        /// Host path to copy from.
341        #[cfg_attr(feature = "ts", ts(type = "string"))]
342        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
343        src: PathBuf,
344        /// Absolute guest destination path.
345        dst: String,
346        /// File permissions. `None` preserves source permissions.
347        mode: Option<u32>,
348        /// Allow replacing a file that already exists in the rootfs.
349        replace: bool,
350    },
351    /// Copy a directory from the host into the rootfs.
352    CopyDir {
353        /// Host directory to copy from.
354        #[cfg_attr(feature = "ts", ts(type = "string"))]
355        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
356        src: PathBuf,
357        /// Absolute guest destination path.
358        dst: String,
359        /// Allow replacing files that already exist in the rootfs.
360        replace: bool,
361    },
362    /// Create a symlink.
363    Symlink {
364        /// Symlink target path.
365        target: String,
366        /// Absolute guest path where the symlink is created.
367        link: String,
368        /// Allow replacing a path that already exists in the rootfs.
369        replace: bool,
370    },
371    /// Create a directory.
372    Mkdir {
373        /// Absolute guest path.
374        path: String,
375        /// Directory permissions, such as `0o755`. `None` uses the default.
376        mode: Option<u32>,
377    },
378    /// Remove a file or directory.
379    Remove {
380        /// Absolute guest path to remove.
381        path: String,
382    },
383    /// Append content to an existing file.
384    Append {
385        /// Absolute guest path of the file to append to.
386        path: String,
387        /// Content to append.
388        content: String,
389    },
390}
391
392impl From<Patch> for CloudPatch {
393    fn from(patch: Patch) -> Self {
394        match patch {
395            Patch::Text {
396                path,
397                content,
398                mode,
399                replace,
400            } => Self::Text {
401                path,
402                content,
403                mode,
404                replace,
405            },
406            Patch::File {
407                path,
408                content,
409                mode,
410                replace,
411            } => Self::File {
412                path,
413                content,
414                mode,
415                replace,
416            },
417            Patch::CopyFile {
418                src,
419                dst,
420                mode,
421                replace,
422            } => Self::CopyFile {
423                src,
424                dst,
425                mode,
426                replace,
427            },
428            Patch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
429            Patch::Symlink {
430                target,
431                link,
432                replace,
433            } => Self::Symlink {
434                target,
435                link,
436                replace,
437            },
438            Patch::Mkdir { path, mode } => Self::Mkdir { path, mode },
439            Patch::Remove { path } => Self::Remove { path },
440            Patch::Append { path, content } => Self::Append { path, content },
441        }
442    }
443}
444
445impl From<CloudPatch> for Patch {
446    fn from(patch: CloudPatch) -> Self {
447        match patch {
448            CloudPatch::Text {
449                path,
450                content,
451                mode,
452                replace,
453            } => Self::Text {
454                path,
455                content,
456                mode,
457                replace,
458            },
459            CloudPatch::File {
460                path,
461                content,
462                mode,
463                replace,
464            } => Self::File {
465                path,
466                content,
467                mode,
468                replace,
469            },
470            CloudPatch::CopyFile {
471                src,
472                dst,
473                mode,
474                replace,
475            } => Self::CopyFile {
476                src,
477                dst,
478                mode,
479                replace,
480            },
481            CloudPatch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
482            CloudPatch::Symlink {
483                target,
484                link,
485                replace,
486            } => Self::Symlink {
487                target,
488                link,
489                replace,
490            },
491            CloudPatch::Mkdir { path, mode } => Self::Mkdir { path, mode },
492            CloudPatch::Remove { path } => Self::Remove { path },
493            CloudPatch::Append { path, content } => Self::Append { path, content },
494        }
495    }
496}
497
498/// Cloud root filesystem source.
499///
500/// Mirrors the domain [`RootfsSource`] JSON shape, but keeps writable-disk
501/// sizing out of the image payload. Cloud callers express that intent through
502/// [`CloudSandboxResources::disk_size_mib`]; conversion to the domain spec
503/// attaches it to OCI rootfs.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
506#[serde(tag = "type", rename_all = "snake_case")]
507pub enum CloudRootfsSource {
508    /// Use a host directory directly as the root filesystem.
509    Bind {
510        /// Host path to bind mount.
511        #[cfg_attr(feature = "ts", ts(type = "string"))]
512        path: PathBuf,
513    },
514
515    /// Use an OCI image reference with an EROFS lower and ext4 overlay upper.
516    Oci {
517        /// OCI image reference (e.g. `python`).
518        reference: String,
519    },
520
521    /// Use a disk image file as the root filesystem via virtio-blk.
522    DiskImage {
523        /// Path to the disk image file on the host.
524        #[cfg_attr(feature = "ts", ts(type = "string"))]
525        path: PathBuf,
526        /// Disk image format.
527        format: CloudDiskImageFormat,
528        /// Inner filesystem type (optional; auto-detected if absent).
529        fstype: Option<String>,
530    },
531}
532
533/// Cloud volume mount. Internal-tagged mirror of the domain [`VolumeMount`];
534/// the transient `create` field is not carried on the wire.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
537#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
538#[serde(tag = "type", rename_all = "snake_case")]
539pub enum CloudVolumeMount {
540    /// Bind mount a host directory into the guest.
541    Bind {
542        /// Host directory to bind into the guest.
543        #[cfg_attr(feature = "ts", ts(type = "string"))]
544        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
545        host: PathBuf,
546        /// Guest path to mount at.
547        guest: String,
548        /// Mount options (read-only, no-exec, …).
549        #[serde(default)]
550        options: MountOptions,
551        /// How guest `stat()` results are virtualized.
552        #[serde(default = "default_strict")]
553        stat_virtualization: StatVirtualization,
554        /// Host permission policy applied to the mount.
555        #[serde(default = "default_private")]
556        host_permissions: HostPermissions,
557        /// Optional guest-write quota in MiB.
558        #[serde(default)]
559        quota_mib: Option<u32>,
560    },
561
562    /// Mount a named volume into the guest.
563    Named {
564        /// Named volume to mount.
565        name: String,
566        /// Guest path to mount at.
567        guest: String,
568        /// Mount options (read-only, no-exec, …).
569        #[serde(default)]
570        options: MountOptions,
571        /// How guest `stat()` results are virtualized.
572        #[serde(default = "default_strict")]
573        stat_virtualization: StatVirtualization,
574        /// Host permission policy applied to the mount.
575        #[serde(default = "default_private")]
576        host_permissions: HostPermissions,
577    },
578
579    /// Temporary filesystem backed by guest memory.
580    Tmpfs {
581        /// Guest path to mount at.
582        guest: String,
583        /// Optional size cap in MiB.
584        #[serde(default)]
585        size_mib: Option<u32>,
586        /// Mount options (read-only, no-exec, …).
587        #[serde(default)]
588        options: MountOptions,
589    },
590
591    /// Mount a disk image file as a virtio-blk device at a guest path.
592    DiskImage {
593        /// Host path to the disk image file.
594        #[cfg_attr(feature = "ts", ts(type = "string"))]
595        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
596        host: PathBuf,
597        /// Guest path to mount at.
598        guest: String,
599        /// Disk image format.
600        format: CloudDiskImageFormat,
601        /// Inner filesystem type (auto-detected if absent).
602        #[serde(default)]
603        fstype: Option<String>,
604        /// Mount options (read-only, no-exec, …).
605        #[serde(default)]
606        options: MountOptions,
607    },
608}
609
610impl From<CloudVolumeMount> for VolumeMount {
611    fn from(m: CloudVolumeMount) -> Self {
612        match m {
613            CloudVolumeMount::Bind {
614                host,
615                guest,
616                options,
617                stat_virtualization,
618                host_permissions,
619                quota_mib,
620            } => VolumeMount::Bind {
621                host,
622                guest,
623                options,
624                stat_virtualization,
625                host_permissions,
626                // The cloud wire type does not carry the opt-out yet; default to
627                // the protective no-follow behavior.
628                follow_root_symlinks: false,
629                quota_mib,
630            },
631            CloudVolumeMount::Named {
632                name,
633                guest,
634                options,
635                stat_virtualization,
636                host_permissions,
637            } => VolumeMount::Named {
638                name,
639                guest,
640                create: None,
641                options,
642                stat_virtualization,
643                host_permissions,
644                follow_root_symlinks: false,
645            },
646            CloudVolumeMount::Tmpfs {
647                guest,
648                size_mib,
649                options,
650            } => VolumeMount::Tmpfs {
651                guest,
652                size_mib,
653                options,
654            },
655            CloudVolumeMount::DiskImage {
656                host,
657                guest,
658                format,
659                fstype,
660                options,
661            } => VolumeMount::DiskImage {
662                host,
663                guest,
664                format: format.into(),
665                fstype,
666                options,
667            },
668        }
669    }
670}
671
672impl From<VolumeMount> for CloudVolumeMount {
673    fn from(m: VolumeMount) -> Self {
674        match m {
675            VolumeMount::Bind {
676                host,
677                guest,
678                options,
679                stat_virtualization,
680                host_permissions,
681                follow_root_symlinks: _,
682                quota_mib,
683            } => CloudVolumeMount::Bind {
684                host,
685                guest,
686                options,
687                stat_virtualization,
688                host_permissions,
689                quota_mib,
690            },
691            VolumeMount::Named {
692                name,
693                guest,
694                create: _,
695                options,
696                stat_virtualization,
697                host_permissions,
698                follow_root_symlinks: _,
699            } => CloudVolumeMount::Named {
700                name,
701                guest,
702                options,
703                stat_virtualization,
704                host_permissions,
705            },
706            VolumeMount::Tmpfs {
707                guest,
708                size_mib,
709                options,
710            } => CloudVolumeMount::Tmpfs {
711                guest,
712                size_mib,
713                options,
714            },
715            VolumeMount::DiskImage {
716                host,
717                guest,
718                format,
719                fstype,
720                options,
721            } => CloudVolumeMount::DiskImage {
722                host,
723                guest,
724                format: format.into(),
725                fstype,
726                options,
727            },
728        }
729    }
730}
731
732/// Cloud network specification: a subset of the domain [`NetworkSpec`].
733/// Interface overrides, host port mapping, DNS, TLS interception, and host-CA
734/// trust are not part of this type. `deny_unknown_fields` — posting an omitted
735/// field is an error, not a silent drop.
736#[derive(Debug, Clone, Serialize, Deserialize)]
737#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
738#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
739#[serde(default, deny_unknown_fields)]
740pub struct CloudNetworkSpec {
741    /// Whether networking is enabled for this sandbox.
742    pub enabled: bool,
743
744    /// Egress/ingress policy.
745    #[serde(skip_serializing_if = "Option::is_none")]
746    pub policy: Option<NetworkPolicy>,
747
748    /// Secret-injection config.
749    #[serde(skip_serializing_if = "Option::is_none")]
750    pub secrets: Option<CloudSecretsConfig>,
751
752    /// Max concurrent guest connections.
753    #[serde(skip_serializing_if = "Option::is_none")]
754    pub max_connections: Option<usize>,
755}
756
757impl Default for CloudNetworkSpec {
758    fn default() -> Self {
759        Self {
760            enabled: true,
761            policy: None,
762            secrets: None,
763            max_connections: None,
764        }
765    }
766}
767
768/// Cloud guest runtime options: a subset of [`SandboxRuntimeOptions`]. The
769/// hostname and the metrics-sampling knobs are not part of this type.
770/// `deny_unknown_fields`.
771#[derive(Debug, Clone, Default, Serialize, Deserialize)]
772#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
773#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
774#[serde(default, deny_unknown_fields)]
775pub struct CloudSandboxRuntimeOptions {
776    /// Working directory for guest commands.
777    pub workdir: Option<String>,
778
779    /// Default shell.
780    pub shell: Option<String>,
781
782    /// Named in-guest scripts.
783    pub scripts: BTreeMap<String, String>,
784
785    /// Entrypoint override.
786    pub entrypoint: Option<Vec<String>>,
787
788    /// Command override.
789    pub cmd: Option<Vec<String>>,
790
791    /// Guest user.
792    pub user: Option<String>,
793
794    /// Runtime log level.
795    pub log_level: Option<SandboxLogLevel>,
796}
797
798//--------------------------------------------------------------------------------------------------
799// Types: Response
800//--------------------------------------------------------------------------------------------------
801
802/// Wire shape of the cloud sandbox response returned by sandbox endpoints.
803#[derive(Debug, Clone, Serialize, Deserialize)]
804#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
805#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
806pub struct CloudCreateSandboxResponse {
807    /// Server-side UUID.
808    pub id: String,
809    /// Owning org's UUID.
810    pub org_id: String,
811    /// User-facing, per-org sandbox name.
812    pub name: String,
813    /// Canonical, resolved SSH username token.
814    pub slug: String,
815    /// Current lifecycle status.
816    pub status: CloudSandboxStatus,
817    /// Why the sandbox is not running yet, when known. Only present while
818    /// `status` is `starting`.
819    #[serde(default)]
820    pub status_reason: Option<CloudSandboxStatusReason>,
821    /// The sandbox spec the cloud control plane stored for this sandbox.
822    pub spec: CloudSandboxSpec,
823    /// Whether the sandbox should be removed when its allocation terminates.
824    pub ephemeral: bool,
825    /// Creation timestamp.
826    #[cfg_attr(feature = "ts", ts(type = "string"))]
827    pub created_at: DateTime<Utc>,
828    /// Last start timestamp, when known.
829    #[serde(default)]
830    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
831    pub started_at: Option<DateTime<Utc>>,
832    /// Last stop timestamp, when known.
833    #[serde(default)]
834    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
835    pub stopped_at: Option<DateTime<Utc>>,
836    /// Human-readable message for the most recent failure, when any.
837    #[serde(default)]
838    pub last_failure_message: Option<String>,
839}
840
841/// Sandbox lifecycle status returned by the cloud control plane.
842#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
843#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
844#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
845#[serde(rename_all = "snake_case")]
846pub enum CloudSandboxStatus {
847    /// Created in the database but not yet started.
848    Created,
849    /// Start request has been submitted.
850    Starting,
851    /// Sandbox is running.
852    Running,
853    /// Stop request has been submitted.
854    Stopping,
855    /// Sandbox is stopped.
856    Stopped,
857    /// Sandbox failed.
858    Failed,
859}
860
861/// Reason a sandbox start is still in progress. Only meaningful while
862/// `status` is `starting`.
863#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
864#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
865#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
866#[serde(rename_all = "snake_case")]
867pub enum CloudSandboxStatusReason {
868    /// The start has been accepted and is being scheduled.
869    Scheduling,
870    /// No capacity is currently available; the start proceeds when
871    /// capacity frees up.
872    InsufficientCapacity,
873}
874
875/// Wire shape of paginated list responses.
876#[derive(Debug, Clone, Serialize, Deserialize)]
877#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
878#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
879pub struct CloudPaginated<T> {
880    /// Page of response items.
881    pub data: Vec<T>,
882    /// Cursor for the next page, when one exists.
883    #[serde(default)]
884    pub next_cursor: Option<String>,
885}
886
887/// Wire shape of the message response returned by mutation endpoints.
888#[derive(Debug, Clone, Serialize, Deserialize)]
889#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
890#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
891pub struct CloudMessageResponse {
892    /// Human-readable response message.
893    pub message: String,
894}
895
896/// Wire shape of the typed error body returned by cloud APIs on 4xx/5xx responses.
897#[derive(Debug, Clone, Serialize, Deserialize)]
898#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
899#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
900pub struct CloudErrorBody {
901    /// Flat machine-readable error code, when returned in this shape.
902    #[serde(default)]
903    pub code: Option<String>,
904    /// Flat human-readable error message, when returned in this shape.
905    #[serde(default)]
906    pub message: Option<String>,
907    /// Nested error object returned by the API error responder.
908    #[serde(default)]
909    pub error: Option<CloudErrorDetails>,
910}
911
912/// Nested cloud API error details.
913#[derive(Debug, Clone, Serialize, Deserialize)]
914#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
915#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
916pub struct CloudErrorDetails {
917    /// Machine-readable error code.
918    #[serde(default)]
919    pub code: Option<String>,
920    /// Human-readable error message.
921    #[serde(default)]
922    pub message: Option<String>,
923}
924
925//--------------------------------------------------------------------------------------------------
926// Trait Implementations
927//--------------------------------------------------------------------------------------------------
928
929impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
930    type Error = TypesError;
931
932    fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
933        req.spec.try_into()
934    }
935}
936
937impl TryFrom<CloudSandboxSpec> for SandboxSpec {
938    type Error = TypesError;
939
940    fn try_from(spec: CloudSandboxSpec) -> TypesResult<Self> {
941        let disk_size_mib = spec.resources.disk_size_mib;
942        let image = match spec.image {
943            // The cloud wire expresses only the managed kind (a size); tmpfs and
944            // disk-image root disks are local-only until the wire grows a kind field.
945            CloudRootfsSource::Oci { reference } => RootfsSource::Oci(OciRootfsSource {
946                reference,
947                root_disk: disk_size_mib.map(RootDisk::managed),
948            }),
949            CloudRootfsSource::Bind { .. } | CloudRootfsSource::DiskImage { .. }
950                if disk_size_mib.is_some() =>
951            {
952                return Err(TypesError::invalid_config(
953                    "resources.disk_size_mib is only valid for OCI rootfs",
954                ));
955            }
956            CloudRootfsSource::Bind { path } => RootfsSource::Bind {
957                path,
958                follow_root_symlinks: false,
959            },
960            CloudRootfsSource::DiskImage {
961                path,
962                format,
963                fstype,
964            } => RootfsSource::DiskImage {
965                path,
966                format: format.into(),
967                fstype,
968            },
969        };
970
971        let resources = SandboxResources {
972            cpus: spec.resources.vcpus,
973            memory_mib: spec.resources.memory_mib,
974            // The cloud wire type has no boot-capacity fields yet; treat the
975            // effective resources as the maximum (mirrors SandboxResources
976            // deserialization for legacy configs).
977            max_cpus: spec.resources.vcpus,
978            max_memory_mib: spec.resources.memory_mib,
979        };
980
981        // Fields not present on `CloudNetworkSpec` are defaulted here, listed
982        // explicitly (not `..default()`) so a new `NetworkSpec` field forces a
983        // decision here.
984        let network = NetworkSpec {
985            enabled: spec.network.enabled,
986            interface: None,
987            ports: Vec::new(),
988            policy: spec.network.policy,
989            dns: None,
990            tls: None,
991            secrets: spec.network.secrets.map(Into::into),
992            max_connections: spec.network.max_connections,
993            trust_host_cas: false,
994        };
995        let runtime = SandboxRuntimeOptions {
996            workdir: spec.runtime.workdir,
997            shell: spec.runtime.shell,
998            scripts: spec.runtime.scripts,
999            entrypoint: spec.runtime.entrypoint,
1000            cmd: spec.runtime.cmd,
1001            hostname: None,
1002            user: spec.runtime.user,
1003            log_level: spec.runtime.log_level,
1004            metrics_sample_interval_ms: None,
1005            disable_metrics_sample: false,
1006        };
1007
1008        Ok(Self {
1009            name: spec.name,
1010            image,
1011            resources,
1012            runtime,
1013            env: spec.env,
1014            labels: spec.labels,
1015            rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1016            mounts: spec.mounts.into_iter().map(Into::into).collect(),
1017            patches: spec.patches.into_iter().map(Into::into).collect(),
1018            network,
1019            init: spec.init,
1020            pull_policy: spec.pull_policy.into(),
1021            security_profile: spec.security_profile,
1022            lifecycle: spec.lifecycle,
1023        })
1024    }
1025}
1026
1027impl From<SandboxSpec> for CloudCreateSandboxRequest {
1028    fn from(spec: SandboxSpec) -> Self {
1029        Self { spec: spec.into() }
1030    }
1031}
1032
1033impl From<SandboxSpec> for CloudSandboxSpec {
1034    fn from(spec: SandboxSpec) -> Self {
1035        let (image, disk_size_mib) = match spec.image {
1036            // Only the managed size is representable on the cloud wire today; tmpfs and
1037            // disk-image root disks are local-only and map to no disk_size_mib.
1038            RootfsSource::Oci(oci) => (
1039                CloudRootfsSource::Oci {
1040                    reference: oci.reference,
1041                },
1042                match &oci.root_disk {
1043                    Some(RootDisk::Managed { size_mib }) => *size_mib,
1044                    _ => None,
1045                },
1046            ),
1047            RootfsSource::Bind { path, .. } => (CloudRootfsSource::Bind { path }, None),
1048            RootfsSource::DiskImage {
1049                path,
1050                format,
1051                fstype,
1052            } => (
1053                CloudRootfsSource::DiskImage {
1054                    path,
1055                    format: format.into(),
1056                    fstype,
1057                },
1058                None,
1059            ),
1060        };
1061
1062        Self {
1063            name: spec.name,
1064            image,
1065            resources: CloudSandboxResources {
1066                vcpus: spec.resources.cpus,
1067                memory_mib: spec.resources.memory_mib,
1068                disk_size_mib,
1069            },
1070            runtime: CloudSandboxRuntimeOptions {
1071                workdir: spec.runtime.workdir,
1072                shell: spec.runtime.shell,
1073                scripts: spec.runtime.scripts,
1074                entrypoint: spec.runtime.entrypoint,
1075                cmd: spec.runtime.cmd,
1076                user: spec.runtime.user,
1077                log_level: spec.runtime.log_level,
1078            },
1079            env: spec.env,
1080            labels: spec.labels,
1081            rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1082            mounts: spec.mounts.into_iter().map(Into::into).collect(),
1083            patches: spec.patches.into_iter().map(Into::into).collect(),
1084            network: CloudNetworkSpec {
1085                enabled: spec.network.enabled,
1086                policy: spec.network.policy,
1087                secrets: spec.network.secrets.map(Into::into),
1088                max_connections: spec.network.max_connections,
1089            },
1090            init: spec.init,
1091            pull_policy: spec.pull_policy.into(),
1092            security_profile: spec.security_profile,
1093            lifecycle: spec.lifecycle,
1094        }
1095    }
1096}
1097
1098impl Default for CloudSandboxResources {
1099    fn default() -> Self {
1100        let resources = SandboxResources::default();
1101        Self {
1102            vcpus: resources.cpus,
1103            memory_mib: resources.memory_mib,
1104            disk_size_mib: None,
1105        }
1106    }
1107}
1108
1109impl CloudRootfsSource {
1110    /// Create an OCI rootfs source from an image reference.
1111    pub fn oci(reference: impl Into<String>) -> Self {
1112        Self::Oci {
1113            reference: reference.into(),
1114        }
1115    }
1116
1117    /// Return the OCI image reference if this is an OCI rootfs.
1118    pub fn oci_reference(&self) -> Option<&str> {
1119        match self {
1120            Self::Oci { reference } => Some(reference),
1121            _ => None,
1122        }
1123    }
1124}
1125
1126impl Default for CloudRootfsSource {
1127    fn default() -> Self {
1128        Self::oci(String::new())
1129    }
1130}
1131
1132//--------------------------------------------------------------------------------------------------
1133// Types: Secrets
1134//--------------------------------------------------------------------------------------------------
1135
1136/// Secret-injection config for the cloud API. Twin of domain [`SecretsConfig`].
1137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1138#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1139#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1140pub struct CloudSecretsConfig {
1141    /// Secrets to inject.
1142    #[serde(default)]
1143    pub entries: Vec<CloudSecretEntry>,
1144    /// Default action when a placeholder leaks to a disallowed host.
1145    #[serde(default)]
1146    pub on_violation: CloudViolationAction,
1147}
1148
1149/// A single cloud secret entry. Twin of domain [`SecretEntry`].
1150#[derive(Debug, Clone, Serialize, Deserialize)]
1151#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1152#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1153pub struct CloudSecretEntry {
1154    /// Environment variable name exposed to the sandbox.
1155    pub env_var: String,
1156    /// The secret value (empty when `source` carries a reference instead).
1157    #[serde(default)]
1158    pub value: String,
1159    /// Host-side source resolved into `value` at spawn time.
1160    #[serde(default, skip_serializing_if = "Option::is_none")]
1161    pub source: Option<CloudSecretSource>,
1162    /// Placeholder the sandbox sees instead of the real value.
1163    pub placeholder: String,
1164    /// Hosts allowed to receive this secret.
1165    #[serde(default)]
1166    pub allowed_hosts: Vec<CloudHostPattern>,
1167    /// Where the secret may be injected.
1168    #[serde(default)]
1169    pub injection: SecretInjection,
1170    /// Per-secret violation action overriding the config default.
1171    #[serde(default, skip_serializing_if = "Option::is_none")]
1172    pub on_violation: Option<CloudViolationAction>,
1173    /// Require verified TLS identity before substituting (default: true).
1174    #[serde(default = "cloud_default_true")]
1175    pub require_tls_identity: bool,
1176}
1177
1178/// Host-side source for a cloud secret. Twin of [`SecretSource`].
1179#[derive(Debug, Clone, Serialize, Deserialize)]
1180#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1181#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1182#[serde(tag = "type", rename_all = "snake_case")]
1183pub enum CloudSecretSource {
1184    /// Read from a host environment variable at apply time.
1185    Env {
1186        /// Host environment variable name.
1187        var: String,
1188    },
1189    /// Read from a host-side secret store reference.
1190    Store {
1191        /// Store-specific secret reference.
1192        reference: String,
1193    },
1194}
1195
1196/// Host allowlist pattern for cloud secrets. Twin of [`HostPattern`], with the
1197/// domain's scalar variants normalized to `{ value }` for a uniform union.
1198#[derive(Debug, Clone, Serialize, Deserialize)]
1199#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1200#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1201#[serde(tag = "type", rename_all = "snake_case")]
1202pub enum CloudHostPattern {
1203    /// Exact hostname match.
1204    Exact {
1205        /// Hostname to match exactly.
1206        value: String,
1207    },
1208    /// Wildcard match (e.g. `*.openai.com`).
1209    Wildcard {
1210        /// Wildcard pattern.
1211        value: String,
1212    },
1213    /// Any host (dangerous — the secret can be exfiltrated).
1214    Any,
1215}
1216
1217/// Action on a cloud secret violation. Twin of [`ViolationAction`], with
1218/// `Passthrough`'s host list normalized to a `hosts` field.
1219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1220#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1221#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1222#[serde(tag = "type", rename_all = "snake_case")]
1223pub enum CloudViolationAction {
1224    /// Block the request silently.
1225    Block,
1226    /// Block and log (default).
1227    #[default]
1228    BlockAndLog,
1229    /// Block and terminate the sandbox.
1230    BlockAndTerminate,
1231    /// Forward the request with the placeholder unchanged for matching hosts.
1232    Passthrough {
1233        /// Hosts for which the placeholder passes through unchanged.
1234        hosts: Vec<CloudHostPattern>,
1235    },
1236}
1237
1238fn cloud_default_true() -> bool {
1239    true
1240}
1241
1242//--------------------------------------------------------------------------------------------------
1243// Conversions: Secrets
1244//--------------------------------------------------------------------------------------------------
1245
1246impl From<HostPattern> for CloudHostPattern {
1247    fn from(pattern: HostPattern) -> Self {
1248        match pattern {
1249            HostPattern::Exact(value) => Self::Exact { value },
1250            HostPattern::Wildcard(value) => Self::Wildcard { value },
1251            HostPattern::Any => Self::Any,
1252        }
1253    }
1254}
1255
1256impl From<CloudHostPattern> for HostPattern {
1257    fn from(pattern: CloudHostPattern) -> Self {
1258        match pattern {
1259            CloudHostPattern::Exact { value } => Self::Exact(value),
1260            CloudHostPattern::Wildcard { value } => Self::Wildcard(value),
1261            CloudHostPattern::Any => Self::Any,
1262        }
1263    }
1264}
1265
1266impl From<ViolationAction> for CloudViolationAction {
1267    fn from(action: ViolationAction) -> Self {
1268        match action {
1269            ViolationAction::Block => Self::Block,
1270            ViolationAction::BlockAndLog => Self::BlockAndLog,
1271            ViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1272            ViolationAction::Passthrough(hosts) => Self::Passthrough {
1273                hosts: hosts.into_iter().map(Into::into).collect(),
1274            },
1275        }
1276    }
1277}
1278
1279impl From<CloudViolationAction> for ViolationAction {
1280    fn from(action: CloudViolationAction) -> Self {
1281        match action {
1282            CloudViolationAction::Block => Self::Block,
1283            CloudViolationAction::BlockAndLog => Self::BlockAndLog,
1284            CloudViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1285            CloudViolationAction::Passthrough { hosts } => {
1286                Self::Passthrough(hosts.into_iter().map(Into::into).collect())
1287            }
1288        }
1289    }
1290}
1291
1292impl From<SecretSource> for CloudSecretSource {
1293    fn from(source: SecretSource) -> Self {
1294        match source {
1295            SecretSource::Env { var } => Self::Env { var },
1296            SecretSource::Store { reference } => Self::Store { reference },
1297        }
1298    }
1299}
1300
1301impl From<CloudSecretSource> for SecretSource {
1302    fn from(source: CloudSecretSource) -> Self {
1303        match source {
1304            CloudSecretSource::Env { var } => Self::Env { var },
1305            CloudSecretSource::Store { reference } => Self::Store { reference },
1306        }
1307    }
1308}
1309
1310impl From<SecretEntry> for CloudSecretEntry {
1311    fn from(entry: SecretEntry) -> Self {
1312        Self {
1313            env_var: entry.env_var,
1314            value: entry.value.to_string(),
1315            source: entry.source.map(Into::into),
1316            placeholder: entry.placeholder,
1317            allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1318            injection: entry.injection,
1319            on_violation: entry.on_violation.map(Into::into),
1320            require_tls_identity: entry.require_tls_identity,
1321        }
1322    }
1323}
1324
1325impl From<CloudSecretEntry> for SecretEntry {
1326    fn from(entry: CloudSecretEntry) -> Self {
1327        Self {
1328            env_var: entry.env_var,
1329            value: Zeroizing::new(entry.value),
1330            source: entry.source.map(Into::into),
1331            placeholder: entry.placeholder,
1332            allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1333            injection: entry.injection,
1334            on_violation: entry.on_violation.map(Into::into),
1335            require_tls_identity: entry.require_tls_identity,
1336        }
1337    }
1338}
1339
1340impl From<SecretsConfig> for CloudSecretsConfig {
1341    fn from(config: SecretsConfig) -> Self {
1342        Self {
1343            entries: config.secrets.into_iter().map(Into::into).collect(),
1344            on_violation: config.on_violation.into(),
1345        }
1346    }
1347}
1348
1349impl From<CloudSecretsConfig> for SecretsConfig {
1350    fn from(config: CloudSecretsConfig) -> Self {
1351        Self {
1352            secrets: config.entries.into_iter().map(Into::into).collect(),
1353            on_violation: config.on_violation.into(),
1354        }
1355    }
1356}
1357
1358//--------------------------------------------------------------------------------------------------
1359// Tests
1360//--------------------------------------------------------------------------------------------------
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365    use crate::domain::{
1366        DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_MEMORY_MIB, OciRootfsSource, RootDisk, RootfsSource,
1367    };
1368
1369    fn spec(name: &str) -> CloudSandboxSpec {
1370        CloudSandboxSpec {
1371            name: name.into(),
1372            image: CloudRootfsSource::Oci {
1373                reference: "python:3.12".into(),
1374            },
1375            ..Default::default()
1376        }
1377    }
1378
1379    #[test]
1380    fn create_request_flattens_spec() {
1381        let req = CloudCreateSandboxRequest {
1382            spec: spec("agent-1"),
1383        };
1384        let json = serde_json::to_value(&req).unwrap();
1385        // Spec fields are flattened onto the top level (SDK parity).
1386        assert_eq!(json["name"], "agent-1");
1387        assert!(json.get("image").is_some());
1388
1389        let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1390        assert_eq!(back.spec.name, "agent-1");
1391    }
1392
1393    #[test]
1394    fn cloud_rootfs_source_uses_internal_tagging() {
1395        let json = serde_json::to_value(CloudRootfsSource::Oci {
1396            reference: "python:3.12".into(),
1397        })
1398        .unwrap();
1399        assert_eq!(
1400            json,
1401            serde_json::json!({"type": "oci", "reference": "python:3.12"})
1402        );
1403
1404        let bind = serde_json::to_value(CloudRootfsSource::Bind {
1405            path: "/host".into(),
1406        })
1407        .unwrap();
1408        assert_eq!(bind, serde_json::json!({"type": "bind", "path": "/host"}));
1409
1410        let back: CloudRootfsSource = serde_json::from_value(json).unwrap();
1411        assert!(matches!(back, CloudRootfsSource::Oci { reference } if reference == "python:3.12"));
1412    }
1413
1414    #[test]
1415    fn cloud_secret_twins_use_internal_tagging() {
1416        // Scalar domain variants normalize to a uniform `{ "type", value }` union.
1417        assert_eq!(
1418            serde_json::to_value(CloudHostPattern::Exact {
1419                value: "api.example.com".into(),
1420            })
1421            .unwrap(),
1422            serde_json::json!({"type": "exact", "value": "api.example.com"})
1423        );
1424        assert_eq!(
1425            serde_json::to_value(CloudSecretSource::Env {
1426                var: "OPENAI".into()
1427            })
1428            .unwrap(),
1429            serde_json::json!({"type": "env", "var": "OPENAI"})
1430        );
1431        assert_eq!(
1432            serde_json::to_value(CloudViolationAction::Passthrough {
1433                hosts: vec![CloudHostPattern::Any],
1434            })
1435            .unwrap(),
1436            serde_json::json!({"type": "passthrough", "hosts": [{"type": "any"}]})
1437        );
1438    }
1439
1440    #[test]
1441    fn cloud_secrets_config_round_trips_through_domain() {
1442        let cloud = CloudSecretsConfig {
1443            entries: vec![CloudSecretEntry {
1444                env_var: "OPENAI_API_KEY".into(),
1445                value: "sk-x".into(),
1446                source: Some(CloudSecretSource::Env {
1447                    var: "OPENAI".into(),
1448                }),
1449                placeholder: "$MSB_OPENAI".into(),
1450                allowed_hosts: vec![CloudHostPattern::Exact {
1451                    value: "api.openai.com".into(),
1452                }],
1453                injection: SecretInjection::default(),
1454                on_violation: Some(CloudViolationAction::BlockAndTerminate),
1455                require_tls_identity: true,
1456            }],
1457            on_violation: CloudViolationAction::BlockAndLog,
1458        };
1459
1460        let back: CloudSecretsConfig = SecretsConfig::from(cloud.clone()).into();
1461        assert_eq!(back.entries.len(), 1);
1462        assert_eq!(back.entries[0].value, "sk-x");
1463        assert_eq!(back.entries[0].allowed_hosts.len(), 1);
1464        assert!(matches!(
1465            back.entries[0].on_violation,
1466            Some(CloudViolationAction::BlockAndTerminate)
1467        ));
1468    }
1469
1470    #[test]
1471    fn create_request_converts_disk_size_to_oci_rootfs() {
1472        let mut req = CloudCreateSandboxRequest {
1473            spec: spec("agent-1"),
1474        };
1475        req.spec.resources.disk_size_mib = Some(8192);
1476
1477        let domain = SandboxSpec::try_from(req).unwrap();
1478
1479        assert_eq!(domain.resources.cpus, DEFAULT_SANDBOX_CPUS);
1480        assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1481        match domain.image {
1482            RootfsSource::Oci(oci) => {
1483                assert_eq!(oci.reference, "python:3.12");
1484                assert_eq!(oci.root_disk, Some(RootDisk::managed(8192)));
1485            }
1486            other => panic!("expected OCI rootfs, got {other:?}"),
1487        }
1488    }
1489
1490    #[test]
1491    fn create_request_rejects_disk_size_for_non_oci_rootfs() {
1492        let mut req = CloudCreateSandboxRequest {
1493            spec: spec("agent-1"),
1494        };
1495        req.spec.image = CloudRootfsSource::Bind {
1496            path: "/tmp/rootfs".into(),
1497        };
1498        req.spec.resources.disk_size_mib = Some(8192);
1499
1500        let err = SandboxSpec::try_from(req).unwrap_err();
1501
1502        assert!(err.to_string().contains("disk_size_mib"));
1503    }
1504
1505    #[test]
1506    fn domain_spec_converts_oci_size_to_cloud_resources() {
1507        let domain = SandboxSpec {
1508            name: "agent-1".into(),
1509            image: RootfsSource::Oci(OciRootfsSource {
1510                reference: "python:3.12".into(),
1511                root_disk: Some(RootDisk::managed(8192)),
1512            }),
1513            ..Default::default()
1514        };
1515
1516        let req = CloudCreateSandboxRequest::from(domain);
1517
1518        assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
1519        match req.spec.image {
1520            CloudRootfsSource::Oci { reference } => {
1521                assert_eq!(reference, "python:3.12");
1522            }
1523            other => panic!("expected OCI rootfs, got {other:?}"),
1524        }
1525    }
1526
1527    #[test]
1528    fn create_request_minimal_defaults() {
1529        // Only the spec's name + image are set; everything else defaults.
1530        let req = CloudCreateSandboxRequest {
1531            spec: spec("agent-1"),
1532        };
1533        let json = serde_json::to_value(&req).unwrap();
1534        let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1535        assert_eq!(back.spec.name, "agent-1");
1536    }
1537
1538    #[test]
1539    fn sandbox_response_round_trips() {
1540        let sb = CloudCreateSandboxResponse {
1541            id: "00000000-0000-0000-0000-000000000002".into(),
1542            org_id: "00000000-0000-0000-0000-000000000001".into(),
1543            name: "agent-1".into(),
1544            slug: "brave-otter".into(),
1545            status: CloudSandboxStatus::Created,
1546            status_reason: None,
1547            spec: spec("agent-1"),
1548            ephemeral: true,
1549            created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
1550            started_at: None,
1551            stopped_at: None,
1552            last_failure_message: None,
1553        };
1554        let json = serde_json::to_value(&sb).unwrap();
1555        assert_eq!(json["slug"], "brave-otter");
1556        assert_eq!(json["name"], "agent-1");
1557
1558        let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1559        assert_eq!(back.slug, "brave-otter");
1560        assert_eq!(back.status, CloudSandboxStatus::Created);
1561        assert_eq!(back.spec.name, "agent-1");
1562        assert!(back.started_at.is_none());
1563    }
1564}