Skip to main content

alien_core/bindings/
sandbox.rs

1//! Sandbox binding definitions.
2//!
3//! Carries what a provider needs to reach the durable parent and create sandboxes inside it.
4//! Sandbox identity is never in here — sandboxes are created at runtime and the provider is the
5//! record, so a binding describes the parent only.
6
7use super::BindingValue;
8use crate::{EgressPolicy, SandboxEgress};
9use serde::{Deserialize, Serialize};
10
11/// Represents a sandbox binding for creating and reaching sandboxes.
12///
13/// Service tags are prefixed with `sandbox-` because serde selects the variant on the `service`
14/// field alone. An unprefixed `local` would deserialize as another resource's local binding by
15/// silently dropping the fields that differ.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "service")]
18pub enum SandboxBinding {
19    /// AWS Lambda MicroVM sandboxes
20    #[serde(rename = "sandbox-aws")]
21    Aws(AwsSandboxBinding),
22    /// Azure Container Apps Sandboxes
23    #[serde(rename = "sandbox-azure")]
24    Azure(AzureSandboxBinding),
25    /// GCP Agent Platform sandboxes, created under a durable Agent Engine
26    #[serde(rename = "sandbox-gcp-agent-platform")]
27    GcpAgentPlatform(GcpAgentPlatformSandboxBinding),
28    /// Sandbox pods under a sandboxed runtime class
29    #[serde(rename = "sandbox-kubernetes")]
30    Kubernetes(KubernetesSandboxBinding),
31    /// Local Docker sandboxes managed by the local sandbox manager
32    #[serde(rename = "sandbox-local")]
33    Local(LocalSandboxBinding),
34}
35
36/// Gateway handoff, or a policy-only binding from deployments without a gateway.
37/// Policy-only bindings remain readable but cannot enforce per-sandbox changes.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39#[serde(untagged)]
40pub enum SandboxEgressBinding {
41    /// Complete gateway configuration from the deployment controller.
42    Gateway(SandboxEgressGateway),
43    /// Legacy declaration; no runtime enforcement capability is implied.
44    Policy(SandboxEgress),
45    /// Default policy for a new Internet declaration, retained in controller state.
46    Defaults {
47        #[serde(rename = "defaultPolicy")]
48        default_policy: EgressPolicy,
49    },
50}
51
52impl SandboxEgressBinding {
53    /// Declaration used for validation before any backend call.
54    pub fn declaration(&self) -> &SandboxEgress {
55        match self {
56            Self::Gateway(_) => &SandboxEgress::Internet,
57            Self::Policy(value) => value,
58            Self::Defaults { .. } => &SandboxEgress::Internet,
59        }
60    }
61    /// Default runtime policy for an Internet declaration.
62    pub fn default_policy(&self) -> Option<&EgressPolicy> {
63        match self {
64            Self::Defaults { default_policy } => Some(default_policy),
65            _ => None,
66        }
67    }
68    /// Gateway configuration, absent on older deployments.
69    pub fn gateway(&self) -> Option<&SandboxEgressGateway> {
70        match self {
71            Self::Gateway(value) => Some(value),
72            Self::Policy(_) | Self::Defaults { .. } => None,
73        }
74    }
75}
76
77impl<'de> Deserialize<'de> for SandboxEgressBinding {
78    fn deserialize<D: serde::Deserializer<'de>>(
79        deserializer: D,
80    ) -> std::result::Result<Self, D::Error> {
81        #[derive(Deserialize)]
82        #[serde(untagged)]
83        enum Input {
84            Gateway(SandboxEgressGateway),
85            Policy(SandboxEgress),
86            Defaults {
87                #[serde(rename = "defaultPolicy")]
88                default_policy: EgressPolicy,
89            },
90        }
91        Ok(match Input::deserialize(deserializer)? {
92            Input::Gateway(value) => Self::Gateway(value),
93            Input::Policy(value) => Self::Policy(value),
94            Input::Defaults { default_policy } => Self::Defaults { default_policy },
95        })
96    }
97}
98impl From<&crate::Sandbox> for SandboxEgressBinding {
99    fn from(value: &crate::Sandbox) -> Self {
100        match &value.default_policy {
101            Some(default_policy) => Self::Defaults {
102                default_policy: default_policy.clone(),
103            },
104            None => Self::Policy(value.egress.clone()),
105        }
106    }
107}
108
109impl From<SandboxEgress> for SandboxEgressBinding {
110    fn from(value: SandboxEgress) -> Self {
111        Self::Policy(value)
112    }
113}
114
115/// Public binding metadata. Private key material never belongs in a binding.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118pub struct SandboxEgressGateway {
119    /// Linked gateway Container binding; its internal URL is resolved at runtime.
120    pub container: String,
121    /// Deployment prefix and resource id.
122    pub audience: String,
123    /// Vault locator for the deployment's signing key.
124    pub signing_key: SandboxEgressSigningKey,
125    /// Key id published in the gateway's rotation set.
126    pub kid: String,
127    /// Trust root for an admin listener using a deployment-issued TLS certificate.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub admin_ca_pem: Option<String>,
130    /// Replicas that must each acknowledge a policy write. Absent when the gateway
131    /// replicates writes itself behind one admin address.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub replicas: Option<SandboxEgressReplicas>,
134}
135
136/// Ready gateway replicas a policy write must reach.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase", deny_unknown_fields)]
139pub struct SandboxEgressReplicas {
140    /// Name that resolves, through the workload's DNS search path, to every ready replica.
141    pub dns: String,
142    /// Admin port on each replica.
143    pub port: u16,
144    /// Replicas the gateway runs. A write refuses when fewer are resolvable.
145    pub count: u32,
146}
147
148/// Management-only vault reference for an Ed25519 PKCS#8 key.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase", deny_unknown_fields)]
151pub struct SandboxEgressSigningKey {
152    /// Name of the vault binding, normally `secrets`.
153    pub vault: String,
154    /// Secret name, normally `sandbox-egress-signing-key`.
155    pub key: String,
156}
157
158/// AWS sandbox binding configuration.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct AwsSandboxBinding {
162    /// Default runtime policy when create omits one.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub default_policy: Option<EgressPolicy>,
165    /// Declared internet access. Absent only in bindings created by older releases.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub egress: Option<SandboxEgressBinding>,
168    /// MicroVM image ARN that scopes the sandboxes this binding creates
169    pub image_arn: BindingValue<String>,
170    /// Image version. Sandboxes are enumerated by image and version together, so a rolled
171    /// version remains a cleanup scope until its own MicroVMs are gone.
172    pub image_version: BindingValue<String>,
173    /// Region the MicroVMs run in
174    pub region: BindingValue<String>,
175    /// Execution role attached to each MicroVM, distinct from the workload's own role
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub execution_role_arn: Option<BindingValue<String>>,
178    /// Egress connectors every sandbox is started with.
179    ///
180    /// Carried rather than implied: a MicroVM started with no connector reaches the public
181    /// internet, so an empty list here is `allow`, not `deny`. The declared mode is realised by
182    /// which connector setup built, and the sandbox has to be started with it.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub egress_connector_arns: Vec<BindingValue<String>>,
185    /// Ports a preview capability may be minted for.
186    ///
187    /// Carried because the token is what grants ingress: `CreateMicrovmAuthToken` mints access to
188    /// whatever port it is asked for, so "a port not listed here can never be exposed" is only
189    /// true if the declared list reaches the code that mints.
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub preview_ports: Vec<u16>,
192    /// Idle seconds after which a sandbox pauses, if the declaration asked for one.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub idle_pause_seconds: Option<u32>,
195    /// Wall-clock ceiling on a sandbox, if the declaration asked for one.
196    ///
197    /// Enforced by Lambda rather than by us: `RunMicrovm` takes it as
198    /// `maximumDurationInSeconds` and terminates the MicroVM when it elapses.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub max_lifetime_seconds: Option<u32>,
201    /// Whether the declaration asked for open egress.
202    ///
203    /// Carried because an empty connector list cannot otherwise be read: a MicroVM started with
204    /// no connector reaches the internet, so a `deny` binding stripped of its connectors would be
205    /// indistinguishable from `allow`. Absent means `deny`, which is the answer that fails closed.
206    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
207    pub allow_egress: bool,
208}
209
210/// Azure sandbox binding configuration.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct AzureSandboxBinding {
214    /// New packages serve commands and files through a bounded root agent. Absent in released
215    /// native images, which retain their original transport until explicitly rebuilt.
216    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
217    pub agent: bool,
218    /// Default runtime policy when create omits one.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub default_policy: Option<EgressPolicy>,
221    /// Sandbox group that scopes every sandbox, image, snapshot and secret
222    pub sandbox_group: BindingValue<String>,
223    /// ADC data-plane endpoint, which is separate from the ARM control plane
224    pub data_plane_endpoint: BindingValue<String>,
225    /// Region the sandbox group lives in; selects the per-region ADC endpoint
226    pub region: BindingValue<String>,
227    /// Resource group the sandbox group sits in. The data-plane path is scoped by it, and the
228    /// Azure client config does not carry one.
229    pub resource_group: BindingValue<String>,
230    /// Outbound policy every sandbox is created with, as declared.
231    ///
232    /// Carried whole rather than as a flag: the data plane's default action is `Allow`, so a
233    /// sandbox created without a policy is an open one, and a hostname list has no boolean to
234    /// travel in.
235    pub egress: SandboxEgressBinding,
236    /// Idle seconds after which a sandbox pauses, if the declaration asked for one.
237    ///
238    /// Carried because the data plane takes it at create and nowhere else: a policy that does not
239    /// travel with the create body is a declaration the sandbox never hears about.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub idle_pause_seconds: Option<u32>,
242    /// Catalog name or registry image every sandbox is created from, as `code` declares it. A
243    /// registry image is resolved by label to the disk image the controller built, so the value
244    /// stays one a setup package can render.
245    pub disk_image: BindingValue<String>,
246    /// Sandbox ceilings in the data plane's own units, from the declaration. Optional because a
247    /// binding from an earlier release carries none — a required field would fail to deserialize
248    /// on an already-running deployment. Absent takes the data plane's own default.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub cpu: Option<BindingValue<String>>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub memory: Option<BindingValue<String>>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub disk: Option<BindingValue<String>>,
255}
256
257/// GCP Agent Platform sandbox binding configuration.
258///
259/// Sandboxes have a durable parent to address: an Agent Engine provisioned at deploy and reached
260/// through a regional endpoint.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct GcpAgentPlatformSandboxBinding {
264    /// Default runtime policy when create omits one.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub default_policy: Option<EgressPolicy>,
267    /// Declared internet access. Absent only in bindings created by older releases.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub egress: Option<SandboxEgressBinding>,
270    /// Agent Engine that parents every sandbox. Sandboxes are created and enumerated under it,
271    /// so a binding without it can neither reach nor reap them.
272    pub engine: BindingValue<String>,
273    /// Template every sandbox is created from. It carries the image digest, the ceilings and the
274    /// egress rules, so a sandbox created without it runs an unpinned image with none applied.
275    pub template: BindingValue<String>,
276    /// Region selecting the regional aiplatform endpoint. The engine is regional with no global
277    /// alias, so the endpoint cannot be derived without it.
278    pub region: BindingValue<String>,
279    /// Seconds a sandbox may live, from the declaration. Carried only when one was declared; an
280    /// absent value takes the service default, which is why it is not defaulted here.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub max_lifetime_seconds: Option<u32>,
283    /// Whether the declaration asked for open egress. The template enforces the policy; this lets a
284    /// reader that never sees the template tell `allow` from `deny`. Absent means `deny`.
285    #[serde(default)]
286    pub allow_egress: bool,
287}
288
289/// Kubernetes sandbox binding configuration.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct KubernetesSandboxBinding {
293    /// Default runtime policy when create omits one.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub default_policy: Option<EgressPolicy>,
296    /// Declared internet access. Absent only in bindings created by older releases.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub egress: Option<SandboxEgressBinding>,
299    /// Namespace sandbox pods are created in
300    pub namespace: BindingValue<String>,
301    /// Runtime class every sandbox pod must carry, such as `gvisor` or `kata`
302    pub runtime_class: BindingValue<String>,
303    /// Label selector identifying this sandbox's pods, used for enumeration and reaping
304    pub selector: BindingValue<String>,
305    /// Sandbox broker served by the operator. Claiming a pod is a `PATCH` on pods, which must
306    /// not reach the application.
307    pub broker_url: BindingValue<String>,
308    /// Secret holding the capability signing key, by name. The binding names it; only the
309    /// broker can read it.
310    pub key_name: BindingValue<String>,
311    /// Where Kubernetes mounted the pod's own ServiceAccount token. A path, not a secret: the
312    /// platform put the file there and the broker verifies it with a `TokenReview`.
313    pub token_path: BindingValue<String>,
314}
315
316/// Local sandbox binding configuration.
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct LocalSandboxBinding {
320    /// Default runtime policy when create omits one.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub default_policy: Option<EgressPolicy>,
323    /// Declared internet access. Absent only in bindings created by older releases.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub egress: Option<SandboxEgressBinding>,
326    /// Loopback endpoint of the local sandbox manager
327    pub manager_url: BindingValue<String>,
328    /// Key scoping this binding's sandboxes within the manager
329    pub sandbox_key: BindingValue<String>,
330    /// File holding the route's bearer token. A locator, not the token: a binding is
331    /// serialized into the workload's environment, and a secret there is a secret in state.
332    pub token_path: BindingValue<String>,
333}
334
335impl SandboxBinding {
336    /// Egress handoff, including declarations stored by earlier releases.
337    pub fn egress(&self) -> Option<&SandboxEgressBinding> {
338        match self {
339            Self::Aws(value) => value.egress.as_ref(),
340            Self::Azure(value) => Some(&value.egress),
341            Self::GcpAgentPlatform(value) => value.egress.as_ref(),
342            Self::Kubernetes(value) => value.egress.as_ref(),
343            Self::Local(value) => value.egress.as_ref(),
344        }
345    }
346    /// Default runtime policy, including the released allowDomains alias.
347    pub fn default_policy(&self) -> Option<&EgressPolicy> {
348        let explicit = match self {
349            Self::Aws(value) => value.default_policy.as_ref(),
350            Self::Azure(value) => value.default_policy.as_ref(),
351            Self::GcpAgentPlatform(value) => value.default_policy.as_ref(),
352            Self::Kubernetes(value) => value.default_policy.as_ref(),
353            Self::Local(value) => value.default_policy.as_ref(),
354        };
355        explicit.or_else(|| self.egress().and_then(SandboxEgressBinding::default_policy))
356    }
357
358    /// Creates an AWS sandbox binding.
359    pub fn aws(
360        image_arn: impl Into<BindingValue<String>>,
361        image_version: impl Into<BindingValue<String>>,
362        region: impl Into<BindingValue<String>>,
363    ) -> Self {
364        Self::Aws(AwsSandboxBinding {
365            default_policy: None,
366            egress: None,
367            image_arn: image_arn.into(),
368            image_version: image_version.into(),
369            region: region.into(),
370            execution_role_arn: None,
371            egress_connector_arns: Vec::new(),
372            preview_ports: Vec::new(),
373            idle_pause_seconds: None,
374            max_lifetime_seconds: None,
375            allow_egress: false,
376        })
377    }
378
379    /// Creates an Azure sandbox binding.
380    pub fn azure(
381        sandbox_group: impl Into<BindingValue<String>>,
382        data_plane_endpoint: impl Into<BindingValue<String>>,
383        region: impl Into<BindingValue<String>>,
384        resource_group: impl Into<BindingValue<String>>,
385        disk_image: impl Into<BindingValue<String>>,
386        egress: SandboxEgress,
387        idle_pause_seconds: Option<u32>,
388    ) -> Self {
389        Self::Azure(AzureSandboxBinding {
390            agent: false,
391            default_policy: None,
392            sandbox_group: sandbox_group.into(),
393            data_plane_endpoint: data_plane_endpoint.into(),
394            region: region.into(),
395            resource_group: resource_group.into(),
396            egress: egress.into(),
397            idle_pause_seconds,
398            disk_image: disk_image.into(),
399            // Ceilings are set on the struct where a caller has them; a positional argument each
400            // would make this constructor ten wide for the case that rarely carries them.
401            cpu: None,
402            memory: None,
403            disk: None,
404        })
405    }
406
407    /// Creates a GCP Agent Platform binding from the legacy template switch.
408    /// Gateway-backed declarations must supply their explicit egress configuration.
409    pub fn gcp_agent_platform(
410        engine: impl Into<BindingValue<String>>,
411        template: impl Into<BindingValue<String>>,
412        region: impl Into<BindingValue<String>>,
413        max_lifetime_seconds: Option<u32>,
414        allow_egress: bool,
415    ) -> Self {
416        Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding {
417            default_policy: None,
418            egress: Some(
419                (if allow_egress {
420                    SandboxEgress::Allow
421                } else {
422                    SandboxEgress::None
423                })
424                .into(),
425            ),
426            engine: engine.into(),
427            template: template.into(),
428            region: region.into(),
429            max_lifetime_seconds,
430            allow_egress,
431        })
432    }
433
434    /// Creates a Kubernetes sandbox binding.
435    pub fn kubernetes(
436        namespace: impl Into<BindingValue<String>>,
437        runtime_class: impl Into<BindingValue<String>>,
438        selector: impl Into<BindingValue<String>>,
439        broker_url: impl Into<BindingValue<String>>,
440        key_name: impl Into<BindingValue<String>>,
441        token_path: impl Into<BindingValue<String>>,
442        egress: Option<SandboxEgress>,
443    ) -> Self {
444        Self::Kubernetes(KubernetesSandboxBinding {
445            default_policy: None,
446            egress: egress.map(Into::into),
447            namespace: namespace.into(),
448            runtime_class: runtime_class.into(),
449            selector: selector.into(),
450            broker_url: broker_url.into(),
451            key_name: key_name.into(),
452            token_path: token_path.into(),
453        })
454    }
455
456    /// Creates a local sandbox binding.
457    pub fn local(
458        manager_url: impl Into<BindingValue<String>>,
459        sandbox_key: impl Into<BindingValue<String>>,
460        token_path: impl Into<BindingValue<String>>,
461        egress: Option<SandboxEgress>,
462    ) -> Self {
463        Self::Local(LocalSandboxBinding {
464            default_policy: None,
465            egress: egress.map(Into::into),
466            manager_url: manager_url.into(),
467            sandbox_key: sandbox_key.into(),
468            token_path: token_path.into(),
469        })
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::bindings::{ContainerBinding, KvBinding};
477
478    #[test]
479    fn every_variant_roundtrips() {
480        let bindings = vec![
481            SandboxBinding::aws(
482                "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
483                "3",
484                "us-east-2",
485            ),
486            SandboxBinding::azure(
487                "sbg1",
488                "https://management.swedencentral.azuredevcompute.io",
489                "swedencentral",
490                "rg",
491                "ubuntu",
492                SandboxEgress::None,
493                None,
494            ),
495            SandboxBinding::gcp_agent_platform(
496                "projects/p/locations/us-central1/reasoningEngines/1",
497                "projects/p/locations/us-central1/sandboxTemplates/agent",
498                "us-central1",
499                Some(3600),
500                true,
501            ),
502            SandboxBinding::kubernetes(
503                "alien-sandboxes",
504                "gvisor",
505                "alien.dev/sandbox=agent",
506                "http://alien-operator.alien.svc:8080",
507                "alien-sandbox-agent-capability",
508                "/var/run/secrets/kubernetes.io/serviceaccount/token",
509                None,
510            ),
511            SandboxBinding::local(
512                "http://127.0.0.1:8931",
513                "agent",
514                "/state/sandbox-manager.token",
515                None,
516            ),
517        ];
518
519        for binding in bindings {
520            let json = serde_json::to_string(&binding).expect("serializes");
521            let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
522            assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
523        }
524    }
525
526    /// The Agent Platform binding carries an egress-bearing template, so there is no safe default
527    /// for a missing required field: a binding stripped of one must fail to load rather than
528    /// deserialize into a sandbox with no image, no limits and open egress. `maxLifetimeSeconds` is
529    /// the one field that may be absent, and its absence must still parse.
530    #[test]
531    fn agent_platform_required_fields_have_no_default() {
532        let binding = SandboxBinding::gcp_agent_platform(
533            "projects/p/locations/us-central1/reasoningEngines/1",
534            "projects/p/locations/us-central1/sandboxTemplates/agent",
535            "us-central1",
536            Some(3600),
537            true,
538        );
539        let full = serde_json::to_value(&binding).expect("serializes");
540
541        for required in ["engine", "template", "region"] {
542            let mut stripped = full.clone();
543            stripped
544                .as_object_mut()
545                .expect("binding serializes as an object")
546                .remove(required)
547                .expect("the field is present before it is stripped");
548            serde_json::from_value::<SandboxBinding>(stripped)
549                .expect_err(&format!("a binding missing '{required}' must not load"));
550        }
551
552        let mut without_ttl = full;
553        without_ttl
554            .as_object_mut()
555            .expect("binding serializes as an object")
556            .remove("maxLifetimeSeconds")
557            .expect("the fixture set a ttl");
558        without_ttl
559            .as_object_mut()
560            .expect("binding serializes as an object")
561            .remove("allowEgress")
562            .expect("the fixture allowed egress");
563        without_ttl.as_object_mut().unwrap().remove("egress");
564        let restored: SandboxBinding = serde_json::from_value(without_ttl)
565            .expect("legacy binding without optional fields loads");
566        let SandboxBinding::GcpAgentPlatform(restored) = restored else {
567            panic!("the service tag must select Agent Platform");
568        };
569        assert_eq!(restored.max_lifetime_seconds, None);
570        assert!(!restored.allow_egress, "legacy bindings default to deny");
571        assert_eq!(
572            restored.egress, None,
573            "legacy bindings carry no explicit declaration"
574        );
575    }
576
577    #[test]
578    fn service_tags_are_prefixed_and_distinct() {
579        let tags: Vec<String> = vec![
580            SandboxBinding::aws("a", "1", "r"),
581            SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::None, None),
582            SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None, false),
583            SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t", None),
584            SandboxBinding::local("u", "k", "t", None),
585        ]
586        .iter()
587        .map(|binding| {
588            serde_json::to_value(binding).expect("serializes")["service"]
589                .as_str()
590                .expect("has a service tag")
591                .to_string()
592        })
593        .collect();
594
595        for tag in &tags {
596            assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
597        }
598
599        let mut unique = tags.clone();
600        unique.sort();
601        unique.dedup();
602        assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
603    }
604
605    /// The failure the bindings AGENTS.md warns about: with `tag = "service"`, serde picks the
606    /// variant on that field alone, so a shared tag lets one resource's binding deserialize as
607    /// another's by dropping the fields that differ.
608    #[test]
609    fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
610        let json = serde_json::to_string(&SandboxBinding::local(
611            "http://127.0.0.1:8931",
612            "agent",
613            "/state/sandbox-manager.token",
614            None,
615        ))
616        .expect("serializes");
617
618        serde_json::from_str::<KvBinding>(&json)
619            .expect_err("a sandbox binding must not parse as a KV binding");
620        serde_json::from_str::<ContainerBinding>(&json)
621            .expect_err("a sandbox binding must not parse as a container binding");
622    }
623
624    #[test]
625    fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
626        let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
627            .expect("serializes");
628
629        serde_json::from_str::<SandboxBinding>(&json)
630            .expect_err("a container binding must not parse as a sandbox binding");
631    }
632}