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